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.
This commit was merged in pull request #6.
This commit is contained in:
2026-10-22 04:41:12 +00:00
39 changed files with 6614 additions and 173 deletions
+9
View File
@@ -0,0 +1,9 @@
{
"phase": 6,
"stage": "ship_pending",
"milestone": "v0.11",
"milestone_name": "placement-tracking",
"phase_role": "final",
"attempts": 0,
"updated_at": "2026-10-22T04:50:00Z"
}
+62
View File
@@ -331,3 +331,65 @@ None. Every phase maps to a REQ-10-001..005 row; the one candidate (employer dra
6. G-317/G-318/G-311 → PLAN Task 4-1-02 amendment note (single combined note)
7. G-319 → PLAN Task 3-1-01 amendment note
8. G-320b → PLAN Task 4-2-01 amendment note + UX criterion 3 clause
---
## v0.11 Grill (Phase 0 GRILL, 2026-10-01)
### Run: 2026-10-01 03:00 (mode: mechanical, focus: all — v0.11 placement tracking)
### Verdict: Proceed (confidence: 0.82)
**Verdict summary.** The v0.11 plan (6 phases, 15 waves, ~30 tasks) is feasible, correctly sequenced, and — rare for a plan this audit-flavored — unusually honest about its own limits (R-67's three methodology gaps are pinned into the export). Every load-bearing tree citation re-verified held: the billing transition-matrix shape, the `decide_claim` same-transaction precedent, the `submitted`-only application status (zero migration), the G-313 conftest reset pattern, the G-319 counts-only pin, all four main.py wiring sites, the engine-client error-class rails, and the overview mock block D-109 replaces. The two genuinely novel mechanisms (the hash chain, the export) each carry dedicated adversarial tests (tamper/fork/determinism), and the one coherence gap research found (R-80 re-hire) is pinned to a route. But red-teaming found **8 binding defects**: the export's verification story overclaims by omission (the chain detects in-place mutation of an intact sequence — it cannot detect a wholesale DB rewrite, and no instruction tells the auditor how to use prior exports as the anchor); **the learner status panel cannot find the learner's application on revisit** (the read route is keyed by `application_id`, no route exists to discover it for a posting, and the plan never says how the panel obtains it — the happy path only works in the same browser session); the apply route's idempotent arm returns a 200 "applied" confirmation for a withdrawn (terminal) application — a lie D-104's withdraw creates; `role_title` accepts the empty string (the 422 arm pins absence, not emptiness); revoked placements are counted but their evidence bundles are unspecified (an unverifiable `revoked_count`); the re-hired PlacementRow's `decided_at` is unspecified; the operator route's `to` has no 422-vs-409 discrimination; and nothing pins the live UI wording against "CIRR-verified" theater. All eight amend existing tasks — nothing re-architects, no scope cuts required.
### Per-Axis Findings
| # | Axis | Verdict | Reason |
|---|------|---------|--------|
| 1 | Feasibility | PASS | Outcome is unmeasurable without this milestone (no hire state exists in-tree); every prerequisite shipped; 6-phase shape matches the two shipped precedents |
| 2 | Scope | PASS | Explicit out-of-scope list; R-67's three pilot gaps pre-disclosed in the methodology (the hidden-requirement class handled better than any prior milestone); the one boundary change (G-319 supersession) is decisioned with a pin split |
| 3 | Architecture | CONCERN → fixed | Chain NOT over-engineered (it IS the deliverable; the cheap version) but UNDER-scoped (G-321: the walk cannot detect a wholesale DB rewrite — export-to-export prefix continuity is the anchor); the mine-read gap strands the learner status panel on revisit (G-322); D-100 cross-store transaction verified sound (one Session, one commit — the two-commit alternative voids the tamper-evidence) |
| 4 | People | PASS | Roster matches the work; the security-auditor's four review points cover exactly the novel-risk surfaces |
| 5 | Timeline | PASS | Consistent with v0.9/v0.10 identical-shape precedents; the flush-then-hash-then-commit append is the one new mechanism, mitigated by R-70's in-tree template + tamper/fork arms |
| 6 | Budget | PASS | Zero new deps, zero knobs, zero workflow changes; chain verification is O(n) sha256 over tiny rows |
| 7 | Risks | CONCERN → fixed | Pre-mortem done (R-80 found + pinned by research itself; all 10 open risks map 1:1 to tasks); NEW cross-decision defect found: apply → withdraw → re-apply returns a 200 "applied" lie (G-323 — D-043 × D-099 × D-104 interaction nobody mapped) |
| 8 | Governance | CONCERN → fixed | Decisions traceable + discipline enforced (the R-80 escalation flow is the pattern working); MISSING: live-surface wording discipline — "CIRR-verified" theater drift unpinned (G-328); the operator route's 422-vs-409 split unspecified (G-327) |
| 9 | Adoption | CONCERN → fixed | Export is honestly-minimal (dataset + disclosure, not theater) PROVIDED G-321/G-328 hold; counting gameable only via operator-gated claim approval — honestly disclosed (a-321); empty-string role_title accepted (G-324) |
| M | Meta | CONCERN → fixed | Revoked placements' evidence bundles unspecified — an auditor handed revoked_count with no evidence has an unverifiable number, and correction history is what an auditor most wants to inspect (G-325); the re-hired row's decided_at unpinned (G-326); simplest-80% version = this plan (no cut found) |
### Binding Decisions (G-321..G-328 — applied to PLAN before execute)
**G-321 — the export methodology states the chain's proof scope, its limit, and the auditor's real anchor.** The walk proves in-place mutation/fork detection of an intact sequence; it does NOT detect a wholesale DB rewrite (delete all events, re-append a fresh plausible chain from GENESIS — it walks green; GENESIS is a constant with no external anchor). PlacementRow itself is not hash-chained — a raw-SQL status flip leaves a chain-consistent ledger with a lying row. Decision: the methodology's `verification` block + the summary's verification instructions state (a) what the chain proves, (b) what it does not (wholesale replacement — the auditor's defense is export-to-export continuity: a prior export's `ledger.events` must appear as an exact prefix of a later export's, and `head_hash` at the prior export must equal the hash at that prefix position), and (c) an event↔row cross-consistency check (every active placement has `placement.created`; every revoked has `placement.revoked`; revoked_count matches the `placement.revoked` events). Amends Tasks 4-1-01, 4-1-02; asserted present by 4-1-03. Confidence 0.80.
**G-322 — the mine point-read: the learner status panel must never depend on client-persisted application_ids.** The read route is keyed by application_id, but no route discovers a learner's application for a posting (the idempotent apply return yields the id only in-session; there is no by-(posting, learner) read anywhere in-tree) — REQ-11-005's outcome visibility breaks on revisit, the common case for a job board. Decision: store `get_application_by_posting_learner(posting_id, learner_id)` (the insert_application unique key — the get_posting point-read shape) + route `GET /v1/marketplace/applications/mine?posting_id=&learner_id=` on the unchanged D-043 gate stack, returning `{application_id, status}` or 404. The job-detail panel composes: mine-read → found? render the status panel via D-104's read : not found? render the apply CTA. Amends D-104 (additive route), Tasks 5-1-01 + 5-2-01; tests in test_learner_status.py (own/foreign/unverified/unknown arms + 404-when-none). Confidence 0.85.
**G-323 — apply on an existing terminal application → 409, never a 200 "applied" confirmation.** insert_application's idempotent arm (marketplace/store.py:341-344) returns the EXISTING application with 200 — after D-104's withdraw makes it terminal, re-apply tells the learner they applied to a job they can never re-enter the pipeline for. Decision: the apply route's idempotent arm gains a terminal-state guard: returned application in `withdrawn`/`rejected`/`hired` → 409 `{"reason": "application_terminal", "status": …}` (the posting_not_published 409 shape, marketplace.py:366-373), never a 200; no new application row (the unique key stands); recovery is visibility (G-322's mine-read + status panel), not resurrection. Amends Task 2-2-01's test list (apply → withdraw → re-apply → 409). Confidence 0.75.
**G-324 — `role_title = Field(min_length=1, max_length=200)`; start_date keeps no semantic range.** Pydantic "required" rejects absence, not emptiness — the house Field shape (marketplace/base.py:68-71) is not specified for the hire body and `role_title: ""` would enter the hashed ledger. Decision: min_length=1 (pinned beside the missing-fields 422 arm), max_length=200 (bounds absurd payloads out of the hashed rows). start_date stays a bare pydantic date — retroactive + future legal per D-103; garbage dates surface as anomalous by_cohort_month buckets in the operator overview and are corrected via the revoke/re-hire lane; the methodology's attestation note covers date integrity. Amends Tasks 1-1-01, 2-1-01. Confidence 0.80.
**G-325 — evidence_bundles cover EVERY placement (active + revoked), each with the full event history.** An auditor handed `revoked_count: 3` with no evidence for the three revocations has an unverifiable number — and the correction history (hired → revoked → rehired) is precisely what an auditor most wants to inspect. Decision: bundles cover active AND revoked placements with the full event history incl. placement.revoked/placement.rehired; `cohort` stays active-only; revoked_count equals the placement.revoked events (cross-checkable, rides G-321(c)). Amends Tasks 4-1-01, 4-1-03 (completeness test: every placement active-or-revoked has a bundle). Confidence 0.80.
**G-326 — the re-hired PlacementRow's decided_at = the re-hire event's store-stamped created_at.** D-102 stamps decided_at from "the hired transition's created_at" — a re-hire has no hired transition; an executor will guess. Decision: the same `datetime.now(UTC)` stamp rule extends to the re-hire arm; stated in the module docstring + asserted in the revoke→re-hire test arm. Amends Task 2-2-02. Confidence 0.85.
**G-327 — the operator transition route's `to` is a pydantic Literal of the seven D-099 statuses.** Unknown value → 422 (body validation), matrix-illegal known value → 409 InvalidTransition — the route never sees a status outside the vocabulary (no 500s, no silent acceptance). Test arm in test_admin_lane.py (garbage `to` → 422, never 500/409). Amends Task 2-2-02. Confidence 0.85.
**G-328 — live surfaces never present attestation as verification.** Nothing pins the admin placements block / overview copy against the theater drift — "CIRR-verified placements" or "verified: 1,000" on a live surface presents attestation as third-party verification (A-304), the exact self-certification trap the milestone could fall into. Decision: live surfaces label counts as recorded placements ("distinct learners placed", "placements recorded"); "CIRR" NEVER appears as a verification claim on any live surface — "CIRR-style" describes the export format in release notes/methodology only; the summary's title renders the attestation language ("employer-attested, operator-overseen"). Amends Task 4-2-01 (copy) + UX criterion 5's test arm (source pin: no "verified" placement wording on any new surface). Confidence 0.75.
### Advisories (recorded, no action)
- a-321: No technical self-hire guard exists (learner handles and employer accounts are disjoint namespaces); the anti-gaming control is operator claim-approval diligence + per-employer overview detection + the revoke lane — already disclosed as R-67 gap (c); do not add a theater cross-check.
- a-322: Withdraw shares the apply-rate window (5/60s per learner, D-104) — a learner withdrawing 5 applications quickly locks themselves out of applying for the window's remainder. Honest, bounded, no new knob.
- a-323: The matrix arm (read status → validate) is check-then-act, but the terminal guard makes a concurrent loser's write a 409-conflict on re-read; the D-111 concurrent-transition test covers the shape; no pessimistic locking at pilot volumes.
- a-324: The employer pipeline list necessarily carries learner_id per row (own postings only) — the G-319 pin split's application-scoped arm working as decisioned; the P2 security review confirms the list route's cross-entity arms return 403 with no data, same as the transitions.
### Escalations
None. All eight findings resolve with evidence-backed defaults (each names its amends-task and its test); nothing re-architects, no axis falls below 0.60.
### Plan Edits Recorded (applied by the orchestrator)
1. G-321/G-325/G-328 → PLAN Tasks 4-1-01, 4-1-02, 4-1-03, 4-2-01 amendment notes + UX criterion 5 arm
2. G-322 → PLAN Tasks 5-1-01, 5-2-01 amendment notes (additive route per D-104)
3. G-323 → PLAN Task 2-2-01 amendment note (apply-route terminal guard + test arm)
4. G-324 → PLAN Tasks 1-1-01, 2-1-01 amendment notes
5. G-326/G-327 → PLAN Task 2-2-02 amendment notes
+35 -37
View File
@@ -2,13 +2,13 @@
## Persona Roster
> **v0.10 update (RESEARCH, lead-developer assessment):** milestone = B2B Employer Network (Feature — ai_service/employers/ module: 8th D-027 store + scrypt auth + session/claim flows, /v1/employers routes, employer web-surface conversion + admin network overview). **Activated:** lead-developer (coordination), backend-engineer (owns ai_service/employers/* + api/employers*.py + api/employers posting/billing routes + the D-091 helper + config.py knobs + main.py wiring — REQ-10-001..005 core), data-engineer (owns the 4-table employer store schema: account/session/claim/audit rows, unique + check-first constraints, tz normalization — REQ-10-001/002), frontend-engineer (owns the employer surface conversion + engine-client employer section + SessionRequiredError + login/register pages + packages/types/employer.ts + admin overview sections + pricing CTA conversion — REQ-10-003/005), security-auditor (phase-specific: scrypt params + compare_digest, session hashing-at-rest, throttle windows, CORS Authorization, 422 password-echo scrub, claim-evidence honesty, sessionStorage exposure, PII — REQ-10-001/002), identity-engineer stays DEACTIVATED (see its note — employer auth is NOT the v0.5 learner identity module). **Deactivated:** cli-engineer, devops-engineer, sandbox-engineer, ai-engineer, voice-engineer, design-system-engineer (all unchanged surfaces this milestone).
> **v0.11 update (RESEARCH, lead-developer assessment):** milestone = Placement Tracking and Audit (Feature — ai_service/placements/ module: 9th D-027 store with the hash-chained placement_event ledger, the application state machine via one transactional transition_application, /v1/employers/applications per-target routes, learner withdraw/status routes in marketplace.py, the placements_admin operator lane + export, four web surfaces). **Activated:** lead-developer (coordination + the R-80 re-hire coherence review + the G-319 pin-split review), backend-engineer (owns ai_service/placements/* + api/placements_admin.py + the employer application routes + learner routes in marketplace.py + deps.py get_placement_store + main.py wiring — REQ-11-001..005 core), data-engineer (owns the 2-table placement store schema: PlacementRow partial-unique + PlacementEventRow global hash chain flush-then-hash-then-commit, canonical serialization, the D-102 revoke semantics — REQ-11-001/002), frontend-engineer (owns the four D-109 surfaces: /employer/applications page, overview placements section, job-detail status panel, admin placements block + engine-client placements section + packages/types/placement.ts — REQ-11-003/005), security-auditor (phase-specific: the hash-chain tamper evidence, the G-319 supersession PII review, gate-order pins, export integrity — REQ-11-001/002/004). **Deactivated:** cli-engineer, devops-engineer, sandbox-engineer, ai-engineer, identity-engineer (learner gates compose unchanged — D-104), voice-engineer, design-system-engineer (all unchanged surfaces this milestone).
### lead-developer
```yaml
active: true
phase_specific: false
reason: Coordinates the v0.10 B2B Employer Network milestone (auth foundation → claim → surface conversion → posting management → checkout waves); owns wave order + the cross-boundary wire review (employer.ts ↔ employers/base.py dual-schema pins, the 402/401/403 discrimination contract) + the D-091 extraction's no-drift review (both principal gates compose the same helper)
reason: Coordinates the v0.11 Placement Tracking milestone (store → state machine → reporting → export → visibility waves); owns wave order + the R-80 re-hire coherence decision's implementation review (the placement.rehired route/event shape) + the G-319 pin-split review (counts-only stays on own-postings lists; learner_id on application-scoped routes only) + the placement.ts ↔ placements/base.py dual-schema pin review + the D-100 cross-store transaction discipline review (one transition_application, no second status writer)
domain: coordination
frameworks:
- turborepo
@@ -27,7 +27,7 @@ territory:
```yaml
active: false
phase_specific: false
reason: v0.10 DEACTIVATED — no infra changes; the employer module rides the existing release lane (image + assets build automatically on tag per D-064/D-065). Roster + v0.8 territory retained.
reason: v0.11 DEACTIVATED — no infra changes; the placements module rides the existing release lane (image + assets build automatically on tag per D-064/D-065). Roster + v0.8 territory retained.
domain: devops
frameworks:
- docker
@@ -53,7 +53,7 @@ territory:
```yaml
active: false
phase_specific: false
reason: v0.10 DEACTIVATED — no CLI changes; the employer surface is server+web only. Roster + v0.8 territory retained.
reason: v0.11 DEACTIVATED — no CLI changes; the placement surface is server+web only. Roster + v0.8 territory retained.
domain: cli
frameworks:
- node
@@ -82,7 +82,7 @@ territory:
```yaml
active: false
phase_specific: false
reason: v0.10 DEACTIVATED — no sandbox fabric changes; the D-068 degrade surface ships unchanged. Roster + v0.8 territory retained.
reason: v0.11 DEACTIVATED — no sandbox fabric changes; the D-068 degrade surface ships unchanged. Roster + v0.8 territory retained.
domain: infra
frameworks:
- python
@@ -106,7 +106,7 @@ territory:
```yaml
active: true
phase_specific: true
reason: v0.10 re-activated (phase-specific) — P1 auth review: scrypt params/verify discipline (N=2^14/r=8/p=1 module constants pinned by test, hmac.compare_digest, self-describing rows so params evolve without invalidating stored hashes — D-086), session tokens hashed-at-rest (sha256(token) only, DB-leak yields nothing — D-087), throttle windows (per-email failed-login + global cap; email-keyed never IP — D-088), 401 vs 403 semantics (credential vs authorization — D-090); P2 claim review: evidence is advisory, never displayed as verification (A-304 — example.com corpus makes match=false corpus-wide), one-account-one-entity UNIQUE enforcement; P3+ the 422 password-echo fix (main.py pii_fields gains "password"+"email" — pydantic 422s echo err.input today, R-56/open-risk-3), sessionStorage exposure copy, employer mock-complete own-session check (G-301 hole class); P6 final review (no secrets/logs/releases leak credentials)
reason: v0.11 re-activated (phase-specific) — P1 ledger review: the hash-chain construction (canonical JSON rules, GENESIS pinned, UNIQUE(prev_hash) fork backstop, flush-then-hash-then-commit single append path, no delete path — R-70), partial-unique active-placement guard (G-316 shape); P2 gate review: G-311 gate order on application transitions (401 → 403-claim → 403-cross-entity → 409), the G-319 supersession PII review (learner_id = opaque handle, application-scoped only, anti-enumeration preserved — R-79); P4 export integrity (determinism rules, generated_at exclusion, methodology honesty gaps stated — R-67/R-68/R-73); P6 final review (no PII/credentials in logs or releases)
domain: security
frameworks:
- pytest
@@ -116,19 +116,19 @@ constraints:
- secrets-via-env-only (D-014)
- secrets-never-logged
- fail-closed-on-unconfigured
- tokens-hashed-at-rest (D-087)
- never-display-forgeable-as-verified (D-089/A-304)
- never-display-forgeable-as-verified (A-304 — the export methodology states its own limits)
- tamper-evidence-tested (an audit ledger nobody has tried to break is theater, D-111)
territory:
- "apps/ai-service/ai_service/employers/**"
- "apps/ai-service/ai_service/api/employers*.py"
- "apps/ai-service/tests/employers/**"
- "apps/ai-service/ai_service/placements/**"
- "apps/ai-service/ai_service/api/placements_admin.py"
- "apps/ai-service/tests/placements/**"
```
### backend-engineer
```yaml
active: true
phase_specific: false
reason: v0.10 ACTIVATED — owns the employers module core: ai_service/employers/base.py (pydantic contracts), passwords.py (stdlib scrypt KDF, D-086), store.py wiring via deps.py get_employer_store, claim evidence normalization (stdlib suffix match, D-089); api/employers.py (auth routes register/login/logout/me + claim + own-postings CRUD/publish/close + own-checkout + own-entitlement; require_employer_session dep per D-090) + api/employers_admin.py (accounts/claims/audit operator routes, require_admin imported from marketplace_admin — D-094); the D-091 shared consume_posting_entitlement helper + its adoption in marketplace_admin.create_posting (wire contract byte-identical); config.py throttle knobs + .env.example; main.py wiring (employer_store lifespan + close, router mounts, CORS allow_headers gains "Authorization" — R-60)
reason: v0.11 ACTIVATED — owns the placements module core: ai_service/placements/base.py (pydantic contracts + the D-099 transition matrix + event-name vocabulary), store.py wiring via deps.py get_placement_store; the ONE transactional transition_application method (status write + ledger append + placement-on-hired, one commit — D-100) + the R-70 flush-then-hash-then-commit append shape; api routes: employer per-target transitions under /v1/employers/applications (G-311 gate order — D-105), learner withdraw/status in marketplace.py (D-043 stack unchanged — D-104), placements_admin.py operator lane (generic transition + revoke + R-80 re-hire route + overview/export reads — D-106/D-108); main.py wiring (placement_store lifespan + close, placements_admin mount); no new config knobs (no new throttles)
domain: backend
frameworks:
- fastapi
@@ -141,16 +141,14 @@ constraints:
- secrets-via-env-only (D-014)
- idempotent-scripts
- fail-closed-admin
- never-call-cloud-in-tests (zero network by construction, D-096)
- gate-before-insert (the 402 consumes nothing, R-58)
- real-kdf-in-tests (no fake-hash path, D-096)
- never-call-cloud-in-tests (zero network by construction, D-111)
- one-status-writer (placements store is the ONLY post-creation writer of application.status — R-78)
- no-cross-family-foreign-keys (G-302 ID references at read)
territory:
- "apps/ai-service/ai_service/employers/**"
- "apps/ai-service/ai_service/config.py"
- "apps/ai-service/ai_service/placements/**"
- "apps/ai-service/ai_service/main.py"
- "apps/ai-service/ai_service/api/**"
- "apps/ai-service/tests/employers/**"
- "apps/ai-service/.env.example"
- "apps/ai-service/tests/placements/**"
- "apps/ai-service/pyproject.toml"
```
@@ -158,7 +156,7 @@ territory:
```yaml
active: true
phase_specific: false
reason: v0.10 ACTIVATED — owns the employer surface conversion (D-092): engine-client employer section (register/login/logout/me, claim, own-postings, own-checkout, own-entitlement + SessionRequiredError 401 discrimination + nc-employer-session sessionStorage wrapper — the adminToken pattern), /employer/login + /employer/register pages (greenfield), postings page own-lifecycle conversion, overview page real active-postings + applicant counts (talent metrics stay mock-labeled), employer profile via the existing public GET, packages/types/employer.ts (dual-schema pinned, D-093); admin overview sections on the moderation page (accounts list, claim queue with evidence signal + approve/reject, audit — the entitlement panel's locked-state pattern, D-094); pricing CTA conversion from contact-path to employer-login path (supersedes D-079); talent pages stay mock (D-092)
reason: v0.11 ACTIVATED — owns the four D-109 surfaces: new /employer/applications page (own pipeline with status badges + per-target transition CTAs + hire form {role_title, start_date}), employer overview placements section (replaces the mock-labeled placement metrics block — D-092 never-fake-convert), job-detail-client application-status panel (learner history + placement confirmation via D-104 read, EngineError.status discrimination, no new error classes), admin moderation placements block (overview counts + per-employer lists + export links, adminToken transport); engine-client placements section (employer/learner/admin functions, AbortController everywhere, SessionRequiredError reuse — D-110); packages/types/placement.ts dual-schema pinned (D-110); talent badge stays absent (A-304)
domain: frontend
frameworks:
- react
@@ -174,14 +172,14 @@ constraints:
territory:
- "apps/web/**"
- "packages/ui/**"
- "packages/types/employer.ts"
- "packages/types/placement.ts"
```
### ai-engineer
```yaml
active: false
phase_specific: false
reason: v0.10 DEACTIVATED — no LLM-facing work; employer flows need no tagging/enrichment/embedding changes (publish re-tag + enrich-on-first-publish compose unchanged behind the employer routes).
reason: v0.11 DEACTIVATED — no LLM-facing work; placement flows need no tagging/enrichment/embedding changes (hire transitions record decisions; no AI surface is touched).
domain: ai
frameworks:
- pydantic
@@ -202,7 +200,7 @@ territory:
```yaml
active: true
phase_specific: false
reason: v0.10 ACTIVATED — owns the 8th D-027 store schema: employers/store.py with the 4-table family (employer_accounts: email UNIQUE + scrypt hash + employer_id UNIQUE-on-approval; employer_sessions: token_hash UNIQUE, expires_at = login + 7-day constant, lazy TTL; employer_claims: pending→approved|rejected + evidence JSON + decided_by/decided_at; employer_audit_events: the BillingEventRow audit shape), the employer_ prefix (bare `employer` is marketplace's table), check-first one-pending-per-entity/one-approved-owner constraints (the insert_application precedent), _validate/_normalize + _as_utc tz discipline, SQLModel.metadata.create_all idempotent compat (D-095), consume-free read paths for accounts/claims/audit lists
reason: v0.11 ACTIVATED — owns the 2-table 9th store schema (D-112): placements/store.py with PlacementRow (id, learner/employer/posting/application refs, role_title, start_date DATE, status active|revoked, created_at, decided_at; partial UNIQUE application_id WHERE status='active' — the G-316 shape, D-102) + PlacementEventRow (append-only global ledger: nullable indexed application_id + placement_id, event_type, actor_type employer|learner|operator|system, actor_ref, payload, prev_hash UNIQUE + hash, created_at — D-101); the canonical serialization rules (sort_keys + separators, _as_utc(dt).isoformat() pinned, no floats — R-79/R-69); flush-then-hash-then-commit append (R-70); the R-80 placement.rehired event vocabulary; cross-store transaction (one Session writes ApplicationRow + both placement tables — D-100); tz discipline + create_all idempotent compat (D-095 shape); _validate/_normalize + surfaced IntegrityError
domain: data
frameworks:
- sqlmodel
@@ -212,12 +210,12 @@ constraints:
- schema-first
- type-safe
- dual-schema-sync
- idempotency-by-constraint (UniqueConstraint + check-first)
- tokens-never-stored-raw (sha256 only, D-087)
- audit-append-only (provenance rows never mutated)
- idempotency-by-constraint (partial UNIQUE + check-first)
- audit-append-only (the ledger has NO delete path — R-70)
- canonical-serialization-pinned (one normalization, test-pinned)
territory:
- "apps/ai-service/ai_service/employers/store.py"
- "apps/ai-service/tests/employers/test_store.py"
- "apps/ai-service/ai_service/placements/store.py"
- "apps/ai-service/tests/placements/test_store.py"
- "packages/types/**"
```
@@ -225,7 +223,7 @@ territory:
```yaml
active: false
phase_specific: false
reason: v0.10 stays DEACTIVATED — the employer auth in this milestone is NOT the v0.5 learner identity module: identity/ is the KYC/age-gate store (IdentityRecord, derived age bands, require_verified_age gates for LEARNERS per D-043); v0.10's employer principals are a THIRD principal class (D-097 — no learner gates on employer routes, the D-043 composition on learner routes untouched). The identity module, its store, and its gates ship unchanged; backend-engineer owns the new employer auth surface. Reactivation is a founder call if/when a real KYC vendor lands or employer SSO/OIDC (explicitly out of scope at pilot) is scheduled.
reason: v0.11 stays DEACTIVATED — the milestone composes the EXISTING learner gates unchanged (D-104: the withdraw/status routes ride the D-043 stack — G-5 allowlist → verified 18+ → apply-rate window); no identity-module changes. Reactivation is a founder call if/when a real KYC vendor lands.
domain: identity
frameworks:
- fastapi
@@ -244,7 +242,7 @@ territory:
```yaml
active: false
phase_specific: false
reason: v0.5 territory — voice shipped complete; no v0.10 voice work.
reason: v0.5 territory — voice shipped complete; no v0.11 voice work.
domain: ai-media
frameworks:
- httpx
@@ -265,7 +263,7 @@ territory:
```yaml
active: false
phase_specific: false
reason: No design-token or primitive work in v0.10 (login/register pages reuse existing Input/Button/Card primitives); roster retained.
reason: No design-token or primitive work in v0.11 (the applications page reuses existing Badge/Button/Card primitives); roster retained.
domain: frontend
frameworks:
- tailwindcss
@@ -281,7 +279,7 @@ territory:
| Persona | Phases | Removed After |
|---------|--------|---------------|
| security-auditor | 1 (auth surface: scrypt + session hashing + throttles + CORS + 422-scrub fix), 2 (claim evidence honesty + UNIQUE enforcement), 5 (employer mock-complete own-session + checkout review), 6 (final review: no credentials in logs/releases) | milestone complete |
| security-auditor | 1 (store/ledger review: hash chain, canonical serialization, partial unique, no-delete path), 2 (gate order + G-319 supersession PII review on application routes), 4 (export integrity: determinism rules, methodology honesty), 6 (final review: no PII in logs/releases) | milestone complete |
All other personas span the milestone. Deactivated personas receive no tasks.
@@ -289,9 +287,9 @@ All other personas span the milestone. Deactivated personas receive no tasks.
| Conflict | Resolution |
|----------|------------|
| backend-engineer vs frontend-engineer (engine-client.ts) | backend-engineer owns the API contract shapes the client consumes; frontend-engineer owns engine-client.ts itself (the employer section, SessionRequiredError, the session wrapper) — the shared `EntitlementResponse`/`PurchaseEntitlement` interfaces in engine-client.ts (lines 730-752) are a frozen v0.9 contract neither side may re-shape; the new employer wire types land in packages/types/employer.ts (data-engineer dual-schema-syncs, lead-developer reviews the pins) |
| backend-engineer vs security-auditor (api/employers.py) | backend-engineer implements the dep/routes/throttles; security-auditor reviews and may patch security DEFECTS directly in api/employers*.py + employers/passwords.py (its territory) — e.g. the 422 password-echo scrub fix in main.py's pii_fields; implementation vs review stays with backend |
| backend-engineer vs data-engineer (employers/store.py) | data-engineer owns the schema + store methods; backend-engineer owns routes/deps/wiring that compose it; passwords.py (KDF core) is backend territory, the password_hash COLUMN contract is data territory |
| backend-engineer vs frontend-engineer (main.py CORS block) | backend-engineer owns the allow_headers edit at main.py:341; security-auditor owns the tests/api/test_cors.py preflight PIN (the E87 regression lesson is a security concern — the pin lives in the security review's changeset) |
| frontend-engineer vs security-auditor (sessionStorage) | frontend-engineer implements the nc-employer-session wrapper; security-auditor reviews the exposure copy + logout-revoke discipline and pins the no-retry-loop locked state (the G-308 pattern); neither patches the other's files — findings flow through lead-developer |
| backend-engineer vs frontend-engineer (engine-client.ts) | backend-engineer owns the API contract shapes the client consumes; frontend-engineer owns engine-client.ts itself (the placements section) — the existing identity/marketplace/billing/employer sections are frozen contracts neither side may re-shape; the new wire types land in packages/types/placement.ts (data-engineer dual-schema-syncs, lead-developer reviews the pins) |
| backend-engineer vs security-auditor (api routes) | backend-engineer implements the routes/deps/gates; security-auditor reviews and may patch security DEFECTS directly in api/placements_admin.py + placements/store.py (its territory) — implementation vs review stays with backend |
| backend-engineer vs data-engineer (placements/store.py) | data-engineer owns the schema + store methods (rows, chain, canonical serialization); backend-engineer owns routes/deps/wiring that compose it; the transition_application METHOD contract is data territory (the transaction + hash append), the routes calling it are backend territory |
| backend-engineer vs data-engineer (application.status column) | the placements store is the ONLY post-creation status writer (R-78) — marketplace/store.py's insert_application writes it once at creation; no route or future store adds a second writer outside the one-transaction discipline |
| frontend-engineer vs security-auditor (PII surfaces) | frontend-engineer implements the application surfaces (learner_id visible on own applicants); security-auditor reviews the G-319 pin split (counts-only stays on own-postings lists) and pins the cross-entity 403 negative arms; neither patches the other's files — findings flow through lead-developer |
| lead-developer vs any | lead-developer coordinates only, does not directly modify code files |
+114 -114
View File
@@ -1,198 +1,198 @@
# Nextcraft — PLAN.md (v0.10 B2B Employer Network)
# Nextcraft — PLAN.md (v0.11 CIRR-Style Placement Tracking and Audit)
## Overview
**Milestone:** v0.10 — B2B Employer Network (Feature, D-085): real employer auth/accounts + employer self-serve — employers become principals: registered accounts with server-side sessions (scrypt-hashed credentials, hashed-at-rest session tokens), claim/verify of their existing v0.6 `EmployerRow` entity (admin-approved at pilot, evidence advisory), the employer dashboard converting from the v0.1 mockup to real data (own-postings lifecycle, real counts, honest mock-labels only where no backend exists), self-serve posting management composing the v0.9 entitlement gate unchanged (D-091 shared helper, 402 wire contract byte-stable), and self-serve checkout over the v0.9 billing catalog (buyer = session employer, superseding D-076; admin operator routes retained as fallback + oversight, D-094 network overview). Mock-first doctrine holds: no external auth provider, tests never call a cloud, zero new dependencies (R-66).
**Tag line:** phase tags advance the v0.9.x line (P0 → v0.9.0 … P6 → v0.9.6 = milestone release), continuing from the v0.8.6 milestone release
**Branch:** milestone/v0.10-b2b-employer-network
**Milestone:** v0.11 — CIRR-Style Placement Tracking and Audit (Feature, D-098): the machinery that makes the guiding outcome measurable and auditable — the placement record model (the 9th D-027 store: `PlacementRow` with the partial-unique one-active-placement-per-application guard + the append-only `PlacementEventRow` ledger, one global sha256 hash chain with a `UNIQUE(prev_hash)` fork backstop), the employer-side application state machine extending the v0.6 `submitted`-only status (the forward-only D-099 matrix, per-target employer routes under `/v1/employers/applications`, learner withdraw, the operator lane incl. revoke + the R-80 re-hire), placement reporting (distinct-learner counts cumulative toward ≥1,000, per employer, per start-date cohort month), and the audit-grade export (self-contained JSON + human summary generated from the same document, methodology stating its own three pilot gaps, re-export determinism). Mock-first doctrine holds: no third-party auditor integration (the export IS the deliverable), tests never call a cloud, zero new dependencies (D-112).
**Tag line:** phase tags advance the v0.10.x line (P0 → v0.10.0 … P6 → v0.10.6 = milestone release), continuing from the v0.10 milestone release (v0.9.6)
**Branch:** milestone/v0.11-placement-tracking (phase work on phase/NN-* branches; Phase 0 on phase/00-pre-execution)
**Phase list (requirement mapping):**
| Phase | Requirement | Focus |
|-------|-------------|-------|
| P1 | REQ-10-001 | Employer auth foundation: employers/ module (8th D-027 store + stdlib scrypt KDF + hashed session rows + both throttle windows), register/login/logout/me routes, main.py wiring (store + CORS Authorization + 422 scrub) — zero web changes |
| P2 | REQ-10-002 | Employer entity claim: claim route + advisory evidence (stdlib domain match), one account : one entity (UNIQUE + check-first), admin claim review/approve/reject operator routes + audit |
| P3 | REQ-10-003 | Employer account surface: own-postings read route, packages/types/employer.ts + dual-schema pins, engine-client employer section (SessionRequiredError + nc-employer-session), login/register pages, session-gated shell, postings/overview conversion (talent stays mock) |
| P4 | REQ-10-004 | Self-serve posting management: D-091 shared consume_posting_entitlement helper + admin adoption (no drift), employer create/edit/publish/close routes, PostingManager write actions |
| P5 | REQ-10-005 | Self-serve checkout + admin overview: employer checkout + entitlement view + own-session mock-complete through the REAL webhook path, pricing CTAs → employer-login path, admin network overview sections |
| P6 | — | Final review + audit + milestone ship (v0.9.6 = milestone release) |
| P1 | REQ-11-001 | Placement store + evidence ledger: placements/ module (PlacementRow + PlacementEventRow global hash chain, partial UNIQUE active guard), the D-099 matrix + event vocabulary in base.py, the ONE transactional transition_application (flush-then-hash-then-commit), main.py wiring — zero web changes |
| P2 | REQ-11-002 | Application state machine: employer per-target routes (G-311 gate order: 401 → 403-claim → 403-cross-entity → 409), learner withdraw (D-043 stack), placements_admin operator lane (generic transition + revoke + the R-80 re-hire), G-319 pin split |
| P3 | REQ-11-003 | Placement reporting: admin overview (distinct learners, by_employer, by_cohort_month), per-employer placement view, placement.ts wire types + engine-client placements section, /employer/applications page + overview placements section replacing the mock block |
| P4 | REQ-11-004 | Audit export: the self-contained JSON + summary-from-same-JSON, methodology with the three pilot gaps, determinism rules, admin web placements block + export links, the full-chain E2E proof |
| P5 | REQ-11-005 | Learner visibility: GET application-status read, job-detail status panel + placement confirmation, badge honesty |
| P6 | — | Final review + audit + milestone ship (v0.10.6 = milestone release) |
**Wave order (binding): principals before claims before surface before management before monetization.** P1 builds the module everything else composes (store + KDF + session gate — P2's claim route authenticates through it, P3's surface gates on it, P4/P5 parameterize it). P2 precedes P3 because the dashboard's claimed-entity profile and claim-pending states need decided claims to exist. P3 precedes P4 because the postings page conversion consumes the own-postings read route and the P4 mutations wire into the converted page. P4 precedes P5 because checkout's buyer = the session's claimed, managing entity and the upgrade CTA targets the surface P4 completed. Each phase is a vertical slice: store/schema → routes → web wiring → tests green in the same wave → ship.
**Wave order (binding): ledger before machine before reporting before export before visibility.** P1 builds the store + chain every later phase composes (P2's transitions append to it in one transaction; P3's counts read the rows it writes; P4's export embeds the chain it maintains; P5's panel renders the events it records). P2 precedes P3 because reporting counts placements that only the hired transition creates — a report over an un-exercised machine proves nothing. P3 precedes P4 because the export's summary block is the D-107 computation (one truth source — the summary is generated from the same JSON) and the admin web block consumes both the overview and the export. P4 precedes P5 by requirements priority (REQ-11-004 critical ships before REQ-11-005 medium; no dependency runs the other way — the learner panel composes only P1/P2 machinery). Each phase is a vertical slice: store/schema → routes → web wiring → tests green in the same wave → ship.
**Binding contracts:** clarify decisions D-086..D-097 (PROJECT.md) and research conclusions R-55..R-66 (RESEARCH.md) are design contracts; this plan operationalizes them, never re-litigates. Module layout is decided (ARCHITECTURE v0.10): `ai_service/employers/{__init__,base,store,passwords,claim_evidence}.py` — no service.py; `api/employers.py` + `api/employers_admin.py`; `tests/employers/`.
**Binding contracts:** clarify decisions D-099..D-112 (PROJECT.md) and research conclusions R-67..R-80 (RESEARCH.md) are design contracts; this plan operationalizes them, never re-litigates. Module layout is decided: `ai_service/placements/{__init__,base,store}.py` — NO service.py (D-112/R-74 — routes compose the store via `app.state` DI); routes: the applications section of api/employers.py, learner routes in api/marketplace.py, `api/placements_admin.py`; `tests/placements/`; `packages/types/placement.ts`.
**Environment facts (probe-verified, R-55..R-66):** scrypt is stdlib in the shipped venv (3.11.2), ~47 ms/hash at N=2^14/r=8/p=1, 16 MiB working set, default maxmem admits it — never pin maxmem below ~17 MiB (R-55); require_admin extract→verify→return shape at api/marketplace_admin.py:46-85, the 422 scrubber redacts only `date_of_birth` today (main.py:398-424, pii_fields line 401 — open-risk-3) (R-56); the 8th-store recipe fully specified by billing/store.py, bare `employer` table taken by marketplace/store.py:144 (R-57); create_posting gate order + the 402 dict's hardcoded posts_used=0/posts_limit=0 in BOTH arms (marketplace_admin.py:157-251) (R-58); billing_admin.create_checkout source mechanics incl. unique-pending placeholder + G-310 URLs (billing_admin.py:77-146) + the admin mock-complete path (152-227) (R-59); the four main.py wiring sites (lifespan ~182-190, CORS allow_headers line 341, inline router mounts 366-389, Settings-int knob shape) (R-60); web census — engine-client.ts section order (employer appends beside billing at ~717), adminToken() wrapper at 759-767, the pins.test.ts billing.ts block 205-231 + "Contact Sales" pin 250, NO login page in any route group (R-61); moderation page + entitlement-panel locked-state pattern (R-62); both throttle precedents (identity _SubmitRateLimiter 97-116, sandboxes _check_global_create_rate 123-137, reset-fixture pattern tests/marketplace/conftest.py:123-131) (R-63); tests/billing/ layout + state-injection make_client fixture (test_entitlement_enforcement.py:28-59) (R-64); seeded employer websites are example.com placeholders → evidence match=false corpus-wide, stdlib urlsplit suffix-match normalization (R-65); zero new deps, zero workspace changes, two .env.example knobs (R-66).
**Environment facts (probe-verified, R-67..R-80):** the hash covers the row's own autoincrement id → the append must be flush-then-hash-then-commit inside one Session, ALL appends funneling through the one transition_application (R-70; decide_claim is the all-or-nothing template employers/store.py:600-652; the ledger must have NO delete path — INTEGER PRIMARY KEY reuses max+1 if the top row is deleted; verification is O(n) sha256 over tiny rows); the matrix precedent is billing's `_VALID_TRANSITIONS` with empty-tuple terminals (billing/store.py:84-90; the 409-matrix test trio tests/billing/test_store.py:400-418; zero migration — existing rows are all `submitted`, marketplace/store.py:211); the shared-DB multi-engine topology is shipped (8 stores, identical pragmas; global SQLModel metadata means create_all on any engine creates every registered table, employers/store.py:400; cross-module import is family-sanctioned employers/base.py:39; NO cross-family foreign keys — G-302 ID references at read); export determinism needs pinned orderings + total generated_at exclusion + float absence (R-73; the list_postings ordering discipline marketplace/store.py:940-948); main.py wiring sites verified (lifespan getattr-guard 182-190/195-200, close() in the finally 335-336, routers beside the employers block 400-411, CORS already carries Authorization 357, G-11 admin boot warning 251-256, deps.py:98); gates verified (require_employer_session employers.py:173-191, _require_claimed :467-484, _own_posting_or_404 :487-503, per-target route idiom :635/:687, provenance `"account:{id}"` :792; learner stack marketplace.py:46-52 → :358 → :359 with window :66-76, `_APPLY_TIMES` :58, cap :63; operator lane employers_admin.py:47 + OPERATOR :54, guard marketplace_admin.py:62-85); web rails verified (engine-client sections — SessionRequiredError :126-131, ClaimRequiredError :136, parseError :146, placements section appends after employer billing :1114; pins.test.ts employer dual-schema block :267-374 + G-319 block :376+; the four surfaces — employer-overview-client.tsx :162/:190-230 real vs mock block :335-372 labelled :351, job-detail-client.tsx apply :95-115 + EngineError 409/404 :103-105 + confirmation :253, moderation page :3/:35 → employer-network-panel.tsx :56 + adminToken/locked-state :28-40); test templates verified (make_client state-injection tests/marketplace/conftest.py:80-120; conftest resets :123-131 + tests/employers/conftest.py:31-33; partial-index sqlite_master pin test_claims_store.py:75-88; gate pins test_postings_write.py:225-234/:315 + test_marketplace_api.py:188; counts-only pin test_postings_read.py:216); two WAL facts (insert_application :873-901 never touches status post-creation; the tamper test's second engine sees the mutation only after commit); three clocks, three serialization rules (start_date date-only ISO YYYY-MM-DD with cohort month `strftime("%Y-%m")`; created_at naive-stored re-labeled via `_as_utc` marketplace/store.py:263-267 — `isoformat()` emits +00:00 for aware, nothing for naive, so the chain pins ONE normalization; generated_at export-only; learner_id is an opaque G-5 handle the 422 scrubber's pii_fields main.py:439 never sees).
**Verification commands (phase gates — the repo's own scripts, R-66):**
**Verification commands (phase gates — the repo's own scripts):**
- `pnpm lint` (turbo lint — eslint across all TS workspaces) and `pnpm ai:lint` (ruff on ai-service)
- `pnpm typecheck` (turbo typecheck — tsc workspaces) and `pnpm ai:typecheck` (mypy — zero new overrides, the D-086 passlib rejection holds)
- `pnpm ai:test:fast` (FAST=1 scripts/test.sh — `-m "not real_server and not userns"` per D-063; tests/employers/ joins the fast lane)
- `pnpm typecheck` (turbo typecheck — tsc workspaces) and `pnpm ai:typecheck` (mypy — zero new overrides; stdlib-only, D-112 keeps the D-086 regime intact)
- `pnpm ai:test:fast` (FAST=1 — `-m "not real_server and not userns"` per D-063; tests/placements/ joins the fast lane)
- `pnpm web:test` (node:test incl. pins.test.ts) + `pnpm cli:test` (unchanged surface, regression guard)
- `pnpm --filter web build` (static export, D-039 unchanged) on web-touching phases (P3, P4, P5)
**NFR note:** this is a Feature milestone — no purity gate; `feat:` commits are expected and scoped to ai_service/employers/, api/employers*.py + the D-091 helper, config.py/main.py wiring, tests/employers/, packages/types/employer.ts, the employer web surface, and the admin overview/pricing CTA surfaces.
**NFR note:** this is a Feature milestone — no purity gate; `feat:` commits are expected and scoped to ai_service/placements/, api/placements_admin.py + the employers.py/marketplace.py route sections + deps.py/main.py wiring, tests/placements/, packages/types/placement.ts, the employer/job-detail/admin web surfaces.
**Secrets policy (binding):** no new secrets this milestone — AI_ADMIN_TOKEN stays env-only (D-014); employer passwords are user data: stored only as self-describing scrypt hashes (D-086), never logged, never returned, never echoed in 422s (pii_fields fix, R-56); session tokens stored ONLY as sha256(token) (D-087 — a DB-file leak yields nothing usable); tests use throwaway credentials against tmp_path DBs; no credential value appears in any committed file, log line, or release asset.
**Secrets policy (binding):** no new secrets this milestone — AI_ADMIN_TOKEN stays env-only (D-014); the placements admin lane inherits require_admin's fail-closed behavior + the G-11 boot warning free (R-74); the export carries no credentials and no identity PII (learner_id is an opaque allowlisted handle, R-79 — name/email/DOB are never stored, identity/base.py:25-33); tests use tmp_path DBs with no credential values; no secret appears in any committed file, log line, or release asset.
---
## Phase 1: Employer Auth Foundation (REQ-10-001)
## Phase 1: Placement Store + Evidence Ledger (REQ-11-001)
**Goal:** The employers module exists and is proven: 4-table store + stdlib scrypt KDF + hashed server-side sessions + register/login/logout/me routes + both throttle windows + main.py wiring — all tested with the REAL KDF, zero web changes, zero new dependencies.
**Goal:** The placements module exists and is proven: the 2-table 9th D-027 store (immutable-at-creation PlacementRow + the append-only global hash-chain PlacementEventRow), the D-099 matrix + event vocabulary in base.py, the ONE transactional transition_application with flush-then-hash-then-commit, main.py wiring — tamper-tested, fork-tested, zero web changes, zero new dependencies.
### Wave 1-1: Module core — store + KDF + contracts (backend-engineer + data-engineer, sequential)
### Wave 1-1: Module core — contracts + matrix + schema (backend-engineer + data-engineer, sequential)
- **Task 1-1-01** (backend-engineer): `ai_service/employers/__init__.py` + `base.py` — pydantic contracts (D-093's list, dual-schema-pinned later in P3): `EmployerAccount` (id, email, employer_id nullable, created_at, `email_verified: Literal[False]` — UNVERIFIED per D-097, displayed as such), `EmployerClaim` (id, account_id, employer_id, status pending|approved|rejected, evidence dict, created_at, decided_by/decided_at), `RegisterEmployerRequest`/`LoginEmployerRequest` (email + password, honest validation), `TokenResponse` (token, expires_at), `EmployerMe` (account + claimed employer_id + claim state). No service.py (R-57) — routes compose the store via deps DI. (G-315: email fields are plain `str` with stdlib-only validation — non-empty, exactly one "@", non-empty domain, stripped + lowercased on normalize; pydantic `EmailStr` is BANNED — email-validator is not in the venv and adding it would be the first new dependency, breaking R-66/D-086.)
- **Task 1-1-02** (data-engineer): `employers/store.py` — 8th D-027 store (R-57, D-095): 4 tables, all `employer_`-prefixed (bare `employer` is marketplace's EmployerRow, marketplace/store.py:144): `employer_accounts` (id PK `acct-`-style, email UNIQUE indexed, password_hash self-describing string, employer_id UNIQUE nullable-until-claim-approval, created_at), `employer_sessions` (id PK, token_hash UNIQUE — stores ONLY sha256(token), account_id indexed, created_at, expires_at = login + `SESSION_TTL_DAYS = 7` module constant), `employer_claims` (schema per D-089, built in P2 — table lands now per D-095's one-time create_all), `employer_audit_events` (auto id PK, account_id/employer_id nullable indexed, kind/name, detail JSON, operator, created_at — the BillingEventRow audit shape, billing/store.py:337-359). Mechanics mirror billing/store.py exactly: `_sqlite_connect` pragmas (WAL / synchronous=NORMAL / busy_timeout=5000 / foreign_keys=ON), `sa_type=String/JSON` portable columns, `_validate()`/`_normalize()` hooks, `_as_utc` tz re-labeling, Protocol `EmployerStore` + `SQLiteEmployerStore` pair, surfaced IntegrityError; `SESSION_TTL_DAYS` pinned by test (the PAST_DUE_GRACE_DAYS shape, D-087/D-083). Store methods: insert_account (check-first → (row, created)), get_account_by_email, mint_session (token_hash=sha256(token), expires_at=now+TTL, `now` injectable), get_session_by_token_hash (**lazy TTL — expired row reads as absent, G-306, no timer**), revoke_session (DELETE), append_audit_event, close. Tests: `tests/employers/test_store.py` — tmp_path DB, table-existence via sqlite_master (4 employer_ tables on the shared db_path), pragma pins, UNIQUE violations surfaced (duplicate email, duplicate token_hash), **token never stored raw (row holds sha256 only)**, lazy TTL with injected now (login+6d present, +8d absent — no sleeps), revoke → lookup miss.
- **Task 1-1-03** (backend-engineer): `employers/passwords.py` — stdlib scrypt KDF (D-086, R-55): module constants `SCRYPT_N=2**14, SCRYPT_R=8, SCRYPT_P=1, SCRYPT_DKLEN=32` (**pinned by test**); per-password salt `secrets.token_bytes(16)`; stored self-describing `scrypt$16384$8$1$<salt_b64>$<hash_b64>` (params evolve without invalidating rows); verify parses params from the stored string, derives, compares via `hmac.compare_digest`; **no maxmem argument below ~17 MiB** (R-55 probe: default 32 MiB admits the 16 MiB working set; if pinned for explicitness use 33554432); module docstring records the ~50 ms/hash budget + throttle-bounded burst risk (open-risk-1). Tests: `tests/employers/test_passwords.py` — REAL KDF, no fake-hash path (D-096): hash→verify accept, wrong password reject, two hashes of the same password differ (random salt), params-pinned constants test, self-describing round-trip (old-format row with explicit params still verifies), **tests never assert hash bytes — accept/reject behavior only**.
- **Task 1-1-01** (backend-engineer): `ai_service/placements/__init__.py` + `base.py` — pydantic contracts + constants: `TRANSITION_MATRIX` per D-099 (snake_case `submitted → in_review → interview → offer → hired|rejected|withdrawn`, forward-only with skip-forward — any non-terminal may move to any LATER stage or any terminal, terminals have empty-tuple outgoing edges, the billing/store.py:84-90 shape); `APPLICATION_STATUSES`; actor vocabulary `employer|learner|operator|system` (the `_BILLING_EVENT_KINDS` shape billing/store.py:92, D-101); event-name vocabulary per D-101 + **R-80's placement lane**: application-stage events (NO backfill — `application.submitted` is never fabricated for v0.6 rows, D-101/A-304; one event per transition: in_review/interview/offer/rejected/withdrawn/hired) + placement-level events (`placement.created` on hired, `placement.revoked` on the D-102 correction, `placement.rehired` on the R-80 operator re-hire); `PlacementRead`/`PlacementEventRead`/`EmployerApplicationSummary` response contracts (dual-schema-pinned P3 per D-110); `HireTransitionRequest {role_title, start_date}` (D-103: start_date is a pydantic `date` — never a datetime, future + past allowed, timezone-free by construction; **G-324: role_title = Field(min_length=1, max_length=200) — empty-string → 422, the house Field shape marketplace/base.py:68-71**); `GENESIS_HASH = "0" * 64` module constant (D-101, test-pinned). No service.py — routes compose the store via app.state DI (D-112).
- **Task 1-1-02** (data-engineer): `placements/store.py` — the 9th D-027 store (D-112/R-72): tables `placement` (id PK, learner_id, employer_id, posting_id, application_id indexed, role_title, start_date stored ISO `YYYY-MM-DD` — a calendar date, no datetime coercion in its path, R-79; status `active|revoked`, created_at, decided_at) + `placement_event` (auto-increment id PK, application_id nullable+indexed, placement_id nullable, event_type, actor_type, actor_ref, payload JSON, prev_hash **UNIQUE**, hash, created_at). Bare `placement` is FREE (D-095's prefix rule was collision-driven — marketplace owns posting/employer/application, no placement table exists). D-102 semantics: role_title/start_date/decided_at/learner_id/employer_id/posting_id/application_id are set once at the hired transition and NEVER rewritten (a silent rewrite is exactly the tamper the chain detects); `status` is the ONLY mutable column with exactly one transition `active → revoked`; decided_at = the hired transition's store-stamped created_at (`datetime.now(UTC)`); the one-active-per-application guard is a **partial UNIQUE index `uq_active_placement_per_application` ON application_id WHERE status='active'** (the G-316 `uq_pending_claim_per_entity` shape, employers/store.py:161-167 — revoked rows stay in history; a re-hire after a mistaken revoke creates a fresh row). Mechanics mirror employers/store.py exactly (D-112): `_sqlite_connect` pragmas WAL / synchronous=NORMAL / busy_timeout=5000 / foreign_keys=ON, `sa_type` portable columns, `_validate()`/`_normalize()` hooks, `_as_utc` re-labeling, Protocol `PlacementStore` + `SQLitePlacementStore` pair, surfaced IntegrityError, `_constraint_error` helper; global-metadata `create_all` idempotent (checkfirst adds both tables to every existing DB at next boot — the shipped D-095 behavior, employers/store.py:398-400); NO cross-family foreign keys (employer/learner/posting/application are ID references resolved at read/export, G-302); **NO delete method and no DELETE SQL anywhere in the module** (R-70: deleting the top row makes INTEGER PRIMARY KEY reuse max+1 — chain-integrity hole; source-pinned). Canonical serialization per D-101/R-69/R-79: `_canonical_event_json` = `json.dumps({...}, sort_keys=True, separators=(",", ":"))` with created_at normalized via **always `_as_utc(dt).isoformat()` — ONE pinned normalization** (the R-79 pitfall: isoformat emits `+00:00` for aware, nothing for naive — drift between the hashing and reading paths breaks the chain on first re-read) and **no floats in payload ever** (str/int/bool/None only, R-69); `hash = hashlib.sha256` hexdigest over the canonical JSON + prev_hash. Tests `tests/placements/test_store.py`: GENESIS_HASH pinned; table existence via sqlite_master (2 tables) + the partial-index SQL pinned (the test_claims_store.py:75-88 template); pragma pins; duplicate prev_hash → IntegrityError surfaced; no-delete-path source pin; tz round-trip (naive stored, aware read, canonical form stable).
- **Task 1-1-03** (data-engineer): the chain append + `transition_application` — D-100/R-70: `transition_application(application_id, to_status, actor_type, actor_ref, role_title=None, start_date=None)` — ONE Session on the placements engine covering both tables (R-72: the marketplace `application` row is written through it — the sanctioned D-100 deliberate exception; both tables + the status write land in ONE commit, the decide_claim all-or-nothing precedent employers/store.py:634-652 — the two-commit alternative's crash window voids the tamper-evidence the milestone sells): get_application (marketplace/store.py gains ONLY this point read, the get_posting shape :578-584 — insert_application and every other marketplace seam unchanged) → matrix validation (module InvalidTransition → 409 at routes) → update `application.status` → read chain head (`ORDER BY id DESC LIMIT 1`) → construct the event row with prev_hash=head → **`session.flush()` to obtain the autoincrement id → compute hash → set the column → on hired: create the PlacementRow (decided_at = this transition's created_at; role_title/start_date REQUIRED, D-103) + append `placement.created` with placement_id set → `session.commit()`** — flush-then-hash-then-commit, and ALL ledger appends funnel through this one method (a second append path races prev_hash; the UNIQUE backstop catches it as a lost event). Column ownership stated in the module docstring (R-78): marketplace insert_application (:873-901) writes status once at creation; the placements store is the ONLY post-creation writer of application.status — no route or future store adds a second writer outside the one-transaction discipline. Store methods: `verify_chain` (recompute the walk from GENESIS_HASH → head; O(n) sha256 over tiny rows — milliseconds at pilot scale, R-70), `get_placement`, `get_application_events` (id-ordered), `list_active_placements`, `revoke_placement` (the one status-only mutation + `placement.revoked` event, one transaction, operator-only by route), `rehire_placement` (R-80: fresh PlacementRow + `placement.rehired` event WITHOUT touching the application's terminal status — revoked rows don't match the partial WHERE, the exact G-316 property employers/store.py:159-167). Tests: chain-walk verification green after N appends; hired → PlacementRow + both events chained (application_id event + placement_id event); **tamper test** — direct row mutation via a second engine on the same file (mutation commits before the verification walk reads, R-78 WAL snapshot isolation) → verify_chain fails; **fork test** — a second row claiming the same prev_hash → IntegrityError surfaced; **concurrent-transition arm** (the test_concurrent_consumes_one_post_left shape, tests/billing/test_store.py:335): two sessions transition the same application → one winner commits, the loser surfaces IntegrityError/InvalidTransition — never two events on one prev_hash; revoke → status revoked + event; rehire after revoke → fresh active row + three-event ledger sequence (hired, placement.revoked, placement.rehired).
### Wave 1-2: Auth routes + throttles + main.py wiring (backend-engineer)
### Wave 1-2: main.py wiring + deps + conftest (backend-engineer)
- **Task 1-2-01** (backend-engineer): `api/employers.py` — `require_employer_session` dependency (D-090, R-56: the require_admin extract→verify→return shape, single-header `Authorization: Bearer <token>` via partition + scheme check; sha256(token) hash-lookup; **401** missing/expired/unknown — credential failure; **403** reserved for cross-entity authorization; returns session + account + claimed employer_id; token namespaces cannot collide with admin tokens — one class fails the other's gate) + `deps.py` `get_employer_store` (app.state DI). Routes: `POST /v1/employers/auth/register` (email+password → scrypt hash → insert_account; duplicate email → 409 honest; account UNVERIFIED per D-097 — no learner gates ever composed on employer routes), `POST /v1/employers/auth/login` (verify → mint token → `{token, expires_at}`; bad credentials → 401 honest, no user-enumeration difference in response body beyond the 401), `POST /v1/employers/auth/logout` (revoke: DELETE row + audit event; unknown/revoked token → 401), `GET /v1/employers/auth/me` (session + account + claimed employer_id + unverified email displayed as such). Tests: `tests/employers/test_auth.py` — **register→login→me→logout flow green**; duplicate register 409; wrong password 401; logout revokes (subsequent me → 401); expired token → 401 (injected now); no-token → 401; concurrent sessions per account allowed (two logins, both me-green, one logout leaves the other alive). REAL scrypt throughout (≈2 hashes ≈100 ms/test, R-64 budget).
- **Task 1-2-02** (backend-engineer): throttles (D-088, R-63 — both copy the proven in-tree mechanics, keyed by SUBMITTED EMAIL, never client IP): (a) per-EMAIL failed-login window — module dict[email]→timestamps, `time.monotonic()` slice-trim, **default 5 failures / rolling 15 min (900 s, window seconds parameterized — identity hardcodes 60.0) → 429 with the honest "wait" detail**; failures-only append; **successful login CLEARS the email's window**; (b) global per-min cap on login+register attempts — module `deque[float]`, while-popleft older-than-60s, `len >= settings.employer_auth_attempts_per_min` → 429, **append only after ALL checks pass (a rejected attempt never consumes budget)**. Settings knobs as plain ints (the `identity_submits_per_min` shape, R-60d): `employer_login_failures_per_window: int = 5`, `employer_auth_attempts_per_min: int = 30`; `.env.example` entries beside the AI_ADMIN_TOKEN block (commented, default-off, R-66). Tests: `tests/employers/test_throttle.py` — 5 failures → 429 with wait detail; success clears the email window; global cap → 429; rejected attempts don't consume the global budget; **both limiter module states reset via fixtures (the _reset_apply_rate_window pattern, R-63)**. (G-313: an autouse fixture in a NEW tests/employers/conftest.py clears BOTH limiter module states before/after EVERY employers test — the whole suite runs inside one 60s window and self-429s otherwise; the sanctioned deviation from billing's no-conftest layout, R-64. G-314: the global deque appends every well-formed login+register attempt — FAILED PASSWORDS COUNT; only 422-invalid bodies and the cap's own 429s are exempt, or D-088's credential-stuffing bound is void; the "rejected attempts consume nothing" test is reworded to "422-invalid consumes nothing; failed-password attempts DO count; N distinct emails inside the window → the (cap+1)-th 429s — the stuffing test". G-320a: the login route order is body-validation → global cap → per-email window → scrypt verify → failure-append — a 429'd attempt performs ZERO KDF derivations, pinned.)
- **Task 1-2-03** (backend-engineer): main.py wiring (R-60, all four sites): (a) employer_store lifespan block directly after billing_store (~line 191, same getattr-guard) + `close()` in the finally block; (b) employers router mount beside the billing block (inline-import pattern); (c) **CORS `allow_headers` gains `"Authorization"` (main.py:341) + the `tests/api/test_cors.py` preflight pin lands in THIS task** (the E87 lesson applied before it ships — open-risk-2: without it every cross-origin employer fetch dies preflight in the two-port dev + D-038 network-mode topologies); (d) **pii_fields gains `"password"` + `"email"`** (main.py:401, R-56/open-risk-3 — a pydantic 422 on register would echo the raw password into err["input"] today) + the pin: register with an invalid body → 422 whose detail never contains the submitted password (added to tests/employers/test_auth.py). Tests: test_cors.py Authorization-preflight green; 422-echo pin green.
- **Task 1-2-01** (backend-engineer): wiring per D-112/R-74: `deps.py` `get_placement_store` beside get_employer_store (deps.py:98); main.py lifespan getattr-guard `placement_store` directly after the employers block (main.py:195-200 template) + `close()` in the finally block (main.py:336); CORS already carries Authorization (main.py:357 — no change); the G-11 admin boot warning (main.py:251-256) covers the placements admin free. Zero new config knobs, zero .env.example changes (no new throttle — D-104 rides the existing window; the router file + mounts land with its first routes in P2, the main.py:400-411 pattern). `tests/placements/conftest.py` NOW (D-111/G-313/open-risk-4): the make_client state-injection fixture (the tests/marketplace/conftest.py:80-120 shape — tmp_path DB, create_app, `app.state.placement_store` injected, hermetic zero-network by construction — no MockTransport needed, no external calls exist) + the **autouse `_APPLY_TIMES` reset (tests/marketplace/conftest.py:123-131 pattern) before/after EVERY placements test — the withdraw route rides that limiter in P2; without the reset the suite self-429s (the G-313 lesson applied before it ships)**.
### Wave 1-3: P1 security review + phase gate (security-auditor + lead-developer)
- **Task 1-3-01** (security-auditor): auth-core review — scrypt params/compare_digest discipline (constants pinned, self-describing rows), session tokens hashed-at-rest (sha256 only — DB leak yields nothing, D-087), throttle windows (email-keyed never IP, both knobs honored), 401 vs 403 semantics (D-090), CORS Authorization pin present, 422 scrub pinned, revoke-on-logout. Findings recorded; P0s fixed in-phase.
- **Task 1-3-02** (lead-developer): P1 phase gate — `pnpm ai:lint` + `pnpm ai:typecheck` (zero new mypy overrides) + `pnpm ai:test:fast` + `pnpm web:test` + `pnpm cli:test` green (existing suites unaffected — the module is additive, marketplace/billing/identity untouched); D-097 discipline check (no require_verified_age/allowlist composed on any employer route); test counts recorded.
- **Task 1-3-01** (security-auditor): ledger review (P1 scope, D-111's "an audit ledger nobody has tried to break is theater"): canonical serialization pinned to ONE normalization (the hashing/reading drift pitfall caught by tamper tests), GENESIS pinned, UNIQUE(prev_hash) fork backstop real (IntegrityError surfaced never swallowed), flush-then-hash-then-commit with a single append funnel (no second writer), no-delete-path pinned, partial-unique active-placement guard emitted by create_all (G-316 shape), actor vocabulary closed, no floats in payload, no PII in payload fields. Findings recorded; P0s fixed in-phase.
- **Task 1-3-02** (lead-developer): P1 phase gate — `pnpm ai:lint` + `pnpm ai:typecheck` (zero new mypy overrides — stdlib only) + `pnpm ai:test:fast` + `pnpm web:test` + `pnpm cli:test` green (additive module — marketplace/billing/employers suites untouched); the D-100 discipline check (one transition_application, no second status writer, no second append path); test counts recorded.
## Phase 2: Employer Entity Claim (REQ-10-002)
## Phase 2: Application State Machine (REQ-11-002)
**Goal:** An authenticated account claims an existing EmployerRow entity; claims are pending with recorded advisory evidence; admin approve/reject activates ownership (one account : one entity); the operator queue and audit trail are real.
**Goal:** The hiring pipeline is live: employers drive per-target transitions on their own claimed-entity applications (G-311 gate order, audit-logged, ledger-chained in the same transaction), learners withdraw their own, the operator lane transitions/revoke/re-hire with provenance — every matrix arm tested, the G-319 pin split enforced.
### Wave 2-1: Claim core — evidence + store + route (backend-engineer + data-engineer)
### Wave 2-1: Employer application routes + G-319 pin split (backend-engineer)
- **Task 2-1-01** (backend-engineer): `employers/claim_evidence.py` — stdlib registrable-domain normalization (R-65): `urllib.parse.urlsplit(website).hostname` lowercased ("https://example.com/openai" → "example.com"), email domain after the last "@" lowercased, `match` = simple SUFFIX match (equal, or one endswith "." + other) — no PSL library, no tldextract (zero-dep discipline); module docstring records the multi-label-suffix over-match as acceptable for an advisory signal; evidence shape per D-089: `{account_email_domain, entity_website_domain, match}`. Tests: `tests/employers/test_claim_evidence.py` — normalization table (scheme/path stripped, case-folded), match true on same registrable domain, false on the example.com corpus (R-65).
- **Task 2-1-02** (data-engineer): store claim methods (the insert_application check-first precedent, marketplace/store.py:341-347): `insert_claim` (**one PENDING claim per entity → created=False/409; one APPROVED owner per entity; rejection frees the entity for a new claim**), `decide_claim` (approve → set `employer_accounts.employer_id` — the UNIQUE one-account-one-entity constraint — + decided_by/decided_at + audit append; reject → decided_by/decided_at + audit append), `list_claims` (status filter, consume-free read path for the admin queue), `get_claim`. Tests: `tests/employers/test_claims_store.py` — second pending claim on an entity rejected; claim after rejection succeeds; approve sets employer_id + writes an audit row; second approve by another account → IntegrityError surfaced (one approved owner); decided_at/decided_at provenance pinned. (G-316: the pending-arm uniqueness is TOCTOU on check-first alone — `employer_claims.__table_args__` gains a partial UNIQUE index `uq_pending_claim_per_entity` on employer_id `sqlite_where status='pending'` (SQLite partial index, emitted by create_all — the D-095 one-time path); insert_claim keeps the check-first 409 fast-path and SURFACES the IntegrityError as the same honest 409 under race; the approved-owner arm is already structurally sound via employer_accounts.employer_id UNIQUE. Pinned: the partial index exists in sqlite_master; a raced second pending insert → 409, never a duplicate pending row.)
- **Task 2-1-03** (backend-engineer): `POST /v1/employers/claims {employer_id}` (session-gated via require_employer_session): entity must exist in the marketplace store (404 unknown — claims target the v0.6 ingested EmployerRow data), account must not already hold an entity (409 honest — one account : one entity), evidence computed AT CLAIM TIME + stored as provenance JSON on the claim row, response = pending claim with evidence. **Auto-approval is deferred: the operator decision activates (D-089 — without email verification the match is forgeable; A-304: never present unverified as verified).** Tests: `tests/employers/test_claims.py` — claim → pending with the exact evidence dict; unknown entity 404; already-owner 409; second pending claim on the same entity 409; unauthenticated 401.
- **Task 2-1-01** (backend-engineer): api/employers.py gains `GET /v1/employers/applications?status=` (own claimed-entity pipeline — session → claimed → applications resolved through own postings via a new in-pattern marketplace grouped read, the count_applications_by_posting discipline store.py:915-932, never per-row N+1; the `EmployerApplicationSummary` contract with **learner_id visible per the G-319 supersession, D-105: application-scoped reads expose learner_id ONLY for applications on the session's own claimed-entity postings**) + the per-target action routes `POST /v1/employers/applications/{application_id}/in-review | /interview | /offer | /reject | /hire` (the per-target idiom employers.py:635/:687 — the matrix stays OUT of request bodies; hire body `{role_title, start_date}` per D-103, both REQUIRED; **G-324: role_title min_length=1/max_length=200 — the empty-string 422 arm pinned beside the missing-fields arm**). Gate order G-311 (D-105/R-75): **401** (require_employer_session employers.py:173-191) → **403** claim (_require_claimed :467-484) → **403** cross-entity (fetch application → posting → `posting.employer_id != claimed` — the _own_posting_or_404 shape :487-503 applied through the application's posting) → **409** transition (never a 409 before a 403). Every route calls the ONE transition_application with `actor_type="employer"`, `actor_ref=f"account:{account.id}"` (the :792 provenance shape) + appends an `employer_audit_events` row (the D-089 one-queryable lane). Tests `tests/placements/test_employer_transitions.py`: gate-order pins (no token → 401, unclaimed → 403, foreign application → 403 — never a 409 first; the test_postings_write.py:225-234/315 pin shape); matrix 409 arms (every illegal move: backward `offer → in_review`, terminal-source `hired → anything`/`rejected → anything`/`withdrawn → anything`); skip-forward arms legal (`submitted → hired` fast-track, `submitted → interview`, `in_review → reject` — R-71); hire → 201 + PlacementRow + chained events + audit row; hire without role_title/start_date → honest 422 with the D-103 required-fields detail; **G-324 arm: role_title="" → 422**.
- **Task 2-1-02** (backend-engineer): the **G-319 pin split** (R-77/D-105, binding): the own-postings LIST stays counts-only — tests/employers/test_postings_read.py:216 green UNCHANGED (the v0.10 boundary holds there); learner_id appears ONLY on application-scoped responses for own-posting applications; cross-entity → 403 with NO application data (anti-enumeration preserved). Pins in test_employer_transitions.py: the list response carries no learner identifiers; the application-scoped GET carries learner_id on own postings; a foreign application's transition/list never returns any application payload.
### Wave 2-2: Admin claim review routes + security review (backend-engineer + security-auditor)
### Wave 2-2: Learner withdraw + operator lane incl. the R-80 re-hire (backend-engineer)
- **Task 2-2-01** (backend-engineer): `api/employers_admin.py` — `require_admin` imported from marketplace_admin (the billing_admin.py:27 precedent, D-094): `GET /v1/employers/admin/accounts` (email, claimed entity, created_at, unverified marker), `GET /v1/employers/admin/claims` (queue: status, evidence, domain-match signal, decided state), `POST /v1/employers/admin/claims/{id}/approve`, `POST .../claims/{id}/reject` (set decided_by/decided_at + append employer_audit_events per D-089), `GET /v1/employers/admin/audit` (queryable trail). Fail-closed 403 on no/missing/invalid token (the R-56 matrix). Tests: `tests/employers/test_claims_admin.py` — **approve → account.employer_id set + audit row + claim approved**; reject frees the entity (a new claim from another account succeeds); decided twice → 409 honest; 403 fail-closed on all five routes; audit query returns claim decisions with operator provenance.
- **Task 2-2-02** (security-auditor): claim-evidence review — evidence is advisory, never displayed as verification (A-304; the example.com corpus makes match=false corpus-wide, R-65/open-risk-6); one-account-one-entity UNIQUE enforced at the store (approve path surfaces IntegrityError, never silently overwrites); claim decisions audit-complete (decided_by/decided_at + events); the 409 paths don't leak account existence beyond the authenticated principal. Findings recorded; P0s fixed in-phase.
- **Task 2-2-01** (backend-engineer): api/marketplace.py gains `POST /v1/marketplace/applications/{application_id}/withdraw` body `{learner_id}` (D-104): the **D-043 composition unchanged** — `_enforce_allowlist` (marketplace.py:46-52) → `require_verified_age(18)` (:358) → `_check_apply_rate` (:359; window :66-76, `_APPLY_TIMES` :58, cap 5/rolling-60s :63 — the EXISTING window, no new throttle knob); 403 when the application is not the caller's, 409 when terminal (D-099: `withdrawn` is learner- or operator-triggerable, NEVER an employer session — employers reject instead); calls the same transition_application with `actor_type="learner"`; composes the placements store via app.state DI (deps.py). **G-323 (binding): the apply route's idempotent arm gains a terminal-state guard** — a returned application in `withdrawn`/`rejected`/`hired` → 409 `{"reason": "application_terminal", "status": …}` (the posting_not_published 409 shape, marketplace.py:366-373), never a 200 "applied" confirmation; no new application row (the unique key stands); recovery is visibility (G-322's mine-read + status panel), not resurrection. Tests `tests/placements/test_learner_withdraw.py`: unallowlisted → 403, unverified → 403, foreign application → 403, own+submitted → 200 `withdrawn` + ledger event actor learner, terminal → 409; **apply → withdraw → re-apply → 409 application_terminal (never a 200)**; the conftest reset proves no self-429 (5 withdraws inside the window → the 6th 429s honestly, reset between tests).
- **Task 2-2-02** (backend-engineer): `api/placements_admin.py` (D-106): prefix `/v1/placements/admin`, `require_admin` IMPORTED from marketplace_admin (the billing_admin.py:27 import precedent, employers_admin.py:47 — token namespaces cannot collide, D-090); router mounts in main.py beside the employers block (main.py:400-411). Routes: `POST /v1/placements/admin/applications/{application_id}/transition` `{to, role_title?, start_date?}` — the generic operator remediation route validated against the SAME D-099 matrix (role_title/start_date required iff `to=hired`); **G-327 (binding): `to` is a pydantic Literal of the seven D-099 statuses — unknown value → 422 (body validation), matrix-illegal known value → 409 InvalidTransition; the route never sees a status outside the vocabulary (no 500s, no silent acceptance; test arm: garbage `to` → 422)**; `actor_type="operator"`, `OPERATOR = "operator"` route-level provenance (employers_admin.py:54 — no per-operator accounts, D-094); the operator can drive ANY matrix-legal transition including `withdrawn` on a learner's behalf (remediation with provenance, the D-083 lane). `POST /v1/placements/admin/placements/{placement_id}/revoke` (D-102: the one `active → revoked` correction). **THE R-80 RE-HIRE ROUTE (pinned explicitly): `POST /v1/placements/admin/placements/by-application/{application_id}/re-hire` `{role_title, start_date}`** — after the first hire the application is terminal (`hired → hired` is a 409 on every matrix-validated surface — the R-80 coherence gap); the re-hire creates the FRESH PlacementRow + appends `placement.rehired` (actor operator) WITHOUT touching the application's terminal status — the ledger reads hired → revoke → rehire as three events, never fabricating a second application transition (the rejected matrix-amendment and revoke+reapplication shapes stay rejected per R-80); structurally safe because revoked rows don't match the partial WHERE status='active' (the exact G-316 property). **G-326 (binding): the re-hired PlacementRow's `decided_at` = the re-hire event's store-stamped created_at (D-102's `datetime.now(UTC)` stamp rule extended) — stated in the module docstring + asserted in the revoke→re-hire test arm.** Reporting/export reads land P3/P4. Tests `tests/placements/test_admin_lane.py`: fail-closed 403 on every route (no/missing/invalid token — the R-56 matrix); generic transition every legal arm + the 409 arms + **garbage `to` → 422 (G-327)**; revoke → placement revoked (→ excluded from counts, asserted P3); **revoke → re-hire → fresh active placement + the three-event ledger sequence pinned + decided_at = the re-hire event's stamp (G-326) + second active placement on the same application without revoke → the IntegrityError surfaced as an honest 409**.
## Phase 3: Employer Account Surface (REQ-10-003)
### Wave 2-3: P2 security review + phase gate (security-auditor + lead-developer)
**Goal:** The employer surface is real: own-postings read route feeding a converted dashboard (login/register pages, session-gated shell, own-postings lifecycle view, real counts), wire types dual-schema-pinned, 401 → honest logged-out state — talent pages stay mock, honestly labeled.
- **Task 2-3-01** (security-auditor): gate + PII review (P2 scope): G-311 gate order on every transition route (401 → 403-claim → 403-cross-entity → 409, pinned); the G-319 supersession (R-79): learner_id is an opaque G-5 handle, application-scoped only, anti-enumeration preserved (cross-entity 403 carries no data); employer sessions can never withdraw (D-099); operator provenance on every admin action incl. re-hire; the audit trail answers every decision (employer_audit_events + ledger actor refs). Findings recorded; P0s fixed in-phase.
- **Task 2-3-02** (lead-developer): P2 phase gate — `pnpm ai:lint` + `pnpm ai:typecheck` + `pnpm ai:test:fast` + `pnpm web:test` + `pnpm cli:test` green (existing marketplace/employers suites green unmodified); the R-80 coherence review recorded (placements, not applications, are the correction lane — the route shape vs D-099/D-102); test counts recorded.
### Wave 3-1: Own-postings read route (backend-engineer)
## Phase 3: Placement Reporting (REQ-11-003)
- **Task 3-1-01** (backend-engineer): `GET /v1/employers/postings` (session-gated; **unclaimed account → 403 + claim-CTA detail** — the D-090 403 semantics; claimed → own postings only, resolved from the marketplace store by the session's employer_id, status facets draft|published|closed, **per-posting applicant_count from the applications table** — the D-092 overview data source). Reuses marketplace store via deps DI; no new tables. Tests: `tests/employers/test_postings_read.py` — own-entity rows only (a second claimed employer's postings never appear); unclaimed 403 with claim-CTA; status facets pinned (counts per status); applicant_count matches seeded applications; no-token 401. (G-319: the response is a NAMED pydantic contract in employers/base.py — `OwnPostingSummary` (PostingSummary reused + applicant_count) + the facets/list response — so the MH-3a dual-schema pin is schema↔schema, not a prose payload; the counts ride a new in-pattern marketplace store read `count_applications_by_posting(posting_ids) -> dict[str, int]`, never a per-posting N+1; employer-facing responses expose applicant COUNTS ONLY — learner identifiers NEVER appear in any /v1/employers/* response, PII boundary, security-auditor P3 scope.)
**Goal:** The guiding outcome is measurable: distinct-learner counts computed from rows at query time (per employer, per start-date cohort month, cumulative toward 1,000), the per-employer placement view live, wire types dual-schema-pinned, the employer surfaces converted (the applications page + the overview placements section replacing the mock block).
### Wave 3-2: Wire types + engine-client employer section (frontend-engineer)
### Wave 3-1: Reporting reads (backend-engineer)
- **Task 3-2-01** (frontend-engineer): `packages/types/employer.ts` (the billing.ts precedent, R-61/R-66) — EmployerAccount, EmployerClaim (+evidence), token/me shapes, own-postings list response (PostingSummary REUSED from marketplace.ts:99-117, never duplicated; entitlement view reuses the v0.9 shapes per D-093) + `export * from './employer'` in index.ts. Dual-schema pins in `apps/web/tests/pins.test.ts` (G-108 discipline; the billing.ts pin block 205-231 is the template): **the employer.ts pin block asserts every field field-for-field against ai_service/employers/base.py's pydantic contracts — both schemas**.
- **Task 3-2-02** (frontend-engineer): `engine-client.ts` employer section (appended beside billing, R-61 — ONE client module, no fork): `registerEmployer`/`loginEmployer`/`logoutEmployer`/`getEmployerMe`/`claimEmployerEntity`/`listOwnEmployerPostings` + the `nc-employer-session` sessionStorage wrapper (the adminToken() 759-767 pattern: window-guarded get/set/clear; tab-scoped bounds XSS exposure — open-risk-4, documented in surface copy) + **`SessionRequiredError` — the 401 discrimination arm joining the parseError chain (no 401 arm exists today, R-61); 401 → honest logged-out state, NEVER a retry loop** + logout clears storage AND calls the revoke route + AbortController on every fetch (A-406). Tests: web fetch-stub tests (the billing-entitlement.test.ts template) — 401 → SessionRequiredError; token attached as Authorization header on every employer fetch; logout clears storage + fires revoke.
- **Task 3-1-01** (backend-engineer): reporting per D-107 — counts computed from rows at query time, NEVER denormalized counters (G-302/G-306): `GET /v1/placements/admin/overview` → `{total_active_placements, distinct_learners_placed, guiding_goal: 1000, by_employer: [{employer_id, count}], by_cohort_month: [{month, count}]}` — **the guiding-outcome metric is DISTINCT LEARNERS with ≥1 active placement, all-time cumulative toward 1,000 (a learner placed twice counts ONCE — the outcome's noun is learners, PROJECT.md line 7); revoked excluded from every count (D-102); cohort month = `strftime("%Y-%m")` over start_date (D-103/R-79 — employment-start month, not hired month, not application month)**; by_employer ordered count desc then employer_id, by_cohort_month month ascending (R-73); joins through existing stores (employer identity via employer_accounts/EmployerRow, posting via postings, learner via application rows); batched grouped reads (the count_applications_by_posting discipline store.py:915-932 — never per-row N+1). Plus `GET /v1/employers/placements` — the per-employer placement view (session-gated; own claimed-entity hired applications with role_title/start_date, honest empty state; 403 + claim-CTA unclaimed; 401 no token) — D-109 surface 2's data source. Tests `tests/placements/test_reporting.py`: distinct-learner rule (one learner, two employers, two placements → distinct_learners_placed=1, total_active_placements=2); cohort bucketing (two starts in one month → one bucket; a future-dated start lands in its own month); revoke exclusion (revoked → gone from every count; a learner whose only placement was revoked → back to 0); per-employer view own-rows-only + cross-entity 403 + unclaimed 403.
### Wave 3-3: Pages — login/register + dashboard conversion (frontend-engineer)
### Wave 3-2: Wire types + engine-client placements section (frontend-engineer + data-engineer)
- **Task 3-3-01** (frontend-engineer): `/employer/login` + `/employer/register` pages (greenfield client pages, R-61 — no login page exists in any route group; existing Input/Button/Card primitives, no design-token work): honest states throughout — 401 invalid-credentials, 409 duplicate email, 429 throttle wait, 422 validation (never echoing the password — server-side pinned already); register success → logged-in dashboard; login stores the session token. Tests: fetch-stub render tests — loading/error/empty honest, 429 copy, register→login handoff. (G-312: fetch-stub + source-pin discipline — no render harness, per D-059.)
- **Task 3-3-02** (frontend-engineer): employer dashboard conversion: postings page drops the `jobs` mock import + PostingManager's derived mock statuses/applicants (components/employer/posting-manager.tsx:29-37) → own-postings read lifecycle view (draft → published → closed visible, applicant counts real; write actions land P4); overview page drops `candidates, employers` mock imports + inline METRICS/PIPELINE constants (page.tsx:10, 18-74) → real active-postings count + per-posting applicant counts from listOwnEmployerPostings + claimed-entity profile via the existing public GET /v1/marketplace/employers/{id} (enrichment retained); **talent-match/placement metrics STAY mock-labeled (D-092 — no backend computes them); talent search + candidate profile pages keep their mock imports unchanged (no candidate backend exists — honest out-of-scope)**. Session-gated shell: no/expired session → honest logged-out state with login CTA (SessionRequiredError path), never a retry loop; claim-pending state visible (claim submitted, awaiting review). Tests: web render tests — logged-out state, claim-pending state, postings lifecycle render, real counts; **pins: postings/overview pages grep-clean of the removed mock imports; talent pages' mock imports retained**; `pnpm --filter web build` (static export) green. (G-312: "render tests" = the D-059/E87 discipline — fetch-stub tests over engine-client employer functions + SOURCE pins for component honest states (logged-out/claim-pending/loading/error branches present in source, the pins.test.ts discipline); NO jsdom/happy-dom render harness exists in-tree and none may be added this milestone.)
- **Task 3-2-01** (frontend-engineer, data-engineer dual-schema-sync): `packages/types/placement.ts` (D-110 — the fourth types module, the employer.ts precedent): ApplicationStatus union (regex-pinned the D-093 status-union way), Placement, PlacementEvent, EmployerApplicationSummary, transition request/response shapes, HireTransitionRequest, PlacementOverview (+ PlacementExport added with the export shapes in P4) + `export * from './placement'` in index.ts. Dual-schema pins in `apps/web/tests/pins.test.ts` (the employer.ts block :267-374 template): **every field asserted both sides against `ai_service/placements/base.py`** — lead-developer reviews the pins (the G-108 discipline from day one).
- **Task 3-2-02** (frontend-engineer): engine-client.ts **placements section** (D-110 — appended after employer billing :1114; ONE client module, no fork): listEmployerApplications, the employer transition functions (inReview/interview/offer/reject/hire with `{role_title, start_date}`), listEmployerPlacements, getPlacementOverview (learner status/withdraw + export functions land P5/P4 with their routes) — every fetch AbortController-equipped (A-406), 401 → SessionRequiredError REUSE (engine-client.ts:126-131), 403 claim → ClaimRequiredError reuse (:136), **NO new error classes** (403 cross-entity renders from EngineError.status). Tests: web fetch-stub tests (the billing-entitlement.test.ts template) — 401 → SessionRequiredError, token attached on every fetch, transition dispatch with the exact body.
## Phase 4: Self-Serve Posting Management (REQ-10-004)
### Wave 3-3: Employer web surfaces (frontend-engineer)
**Goal:** Employers manage their own postings end-to-end: the v0.9 entitlement gate extracted ONCE into a shared api/ helper, both principal gates composing it with byte-identical wire contracts; employer create/edit/publish/close routes; the converted PostingManager gains write actions with honest 402/403 states.
- **Task 3-3-01** (frontend-engineer): D-109 surface 1 — the NEW `/employer/applications` page (beside postings/login/register/billing/talent): the hiring pipeline — own applications with status badges (the seven D-099 states), per-target transition CTAs, the hire form `{role_title, start_date}` (a date input, future dates allowed — D-103), learner_id shown (own applicants, G-319 supersession); honest pending/empty/403-claim states (the A-406 board-client discipline); session-gated shell (SessionRequiredError → honest logged-out state, never a retry loop). Tests: web fetch-stub + source pins (the G-312 discipline — no render harness): loading/error/empty/claim states present in source, status badge per status, hire form dispatches the transition, withdraw NEVER offered to employers (D-099).
- **Task 3-3-02** (frontend-engineer): D-109 surface 2 — the employer overview page's real placements section **REPLACES the mock-labeled placement metrics block** (employer-overview-client.tsx :335-372, the "(mock — no backend computes this)" label :351 — D-092: never leave a convertible surface on mock): hired applications with role_title/start-date via listEmployerPlacements, honest empty state; the talent-match/applicant-pipeline mock metrics STAY mock-labeled (no backend computes them). Tests: pins — the overview grep-clean of the placement mock block + the retained mock labels intact; fetch-stub placements render + empty state; `pnpm --filter web build` (static export) green.
### Wave 4-1: Shared entitlement helper + employer posting routes (backend-engineer + lead-developer)
### Wave 3-4: P3 phase gate (lead-developer)
- **Task 4-1-01** (backend-engineer): the D-091 extraction: `consume_posting_entitlement(store, employer_id, upgrade_cta)` in api/ beside both callers (no billing/→api/ boundary violation) — extracted from marketplace_admin.create_posting lines 181-220; **canonical 402 dict `{reason, posts_used: 0, posts_limit: 0, upgrade_cta}` — posts_used/posts_limit hardcoded 0 in BOTH arms preserved EXACTLY (R-58 — the wire contract; UpgradeRequiredError consumes reason + upgrade_cta unchanged, D-080/D-084)**; store-missing → 503 fail-closed (the getattr guard); **gate runs BEFORE insert — a 402 consumes nothing, creates nothing**; `consume_posting` itself (G-304 single conditional UPDATE) UNTOUCHED. marketplace_admin.create_posting adopts the helper with the admin-checkout CTA — behavior byte-identical. Tests: **tests/billing/test_entitlement_enforcement.py green UNCHANGED (the no-drift proof)** + a new payload-parity test: admin-gate 402 vs employer-gate 402 assert equal reason/posts_used/posts_limit with only upgrade_cta differing per principal.
- **Task 4-1-02** (backend-engineer): employer posting write routes (session gate → claim check → entitlement → insert, mirroring the "admin gate still first" composition, R-58): `POST /v1/employers/postings` (create draft — `consume_posting_entitlement(billing_store, session.employer_id, employer-CTA)`; 403 + claim-CTA unclaimed), `PATCH /v1/employers/postings/{id}` (**own drafts only — published/closed → 409 honest; another entity's posting → 403, the D-090 authorization arm; unknown → 404**), `POST .../postings/{id}/publish` (own draft → published + posted_at; the tagging/enrichment composition behind publish unchanged), `POST .../postings/{id}/close` (own posting → closed). Admin posting routes remain untouched (operator path unchanged, provenance-recorded). Tests: `tests/employers/test_postings_write.py` — create consumes from the employer's OWN package (posts_used+1 pinned); 402 both arms via direct store grants (the grant_posts precedent, test_entitlement_enforcement.py:70-85 — no package → `no_active_package`, exhausted → `posting_limit_reached`); **402 consumes nothing (gate-before-insert pinned: failed create leaves posts_used unchanged and no row)**; cross-entity 403; edit published → 409; publish/close lifecycle + applicant counts accrue; unlimited/enterprise manual grants bypass. (G-311: the session/claim gates compose BEFORE the entitlement check — unauthenticated → 401, unclaimed → 403, never a 402 (the R-58 admin-gate-first order; REQ-10-004's "AFTER" wording is corrected by this grill). G-317: employer create stamps its OWN provenance — `source="employer"` with `emp-` id/external_id schemes, never the admin route's `source="admin"` (authorship must stay answerable across seeded/admin/employer classes); the admin routes are untouched. G-318: employer edit composes the IDENTICAL update_posting_fields + tag_posting re-tag path as the admin edit (D-052 — tags are derived, overwritten on edit; tag failure honest, edit stands) with the own-drafts guard evaluated BEFORE any mutation — edit published/closed → 409 with no field/tag side effects. Pinned: employer-created posting carries the employer source stamp; employer edit re-tags on a mock provider; edit published → 409 AND nothing mutated.)
- **Task 4-1-03** (lead-developer): D-091 no-drift review — both principal gates compose the SAME helper (source-level: no duplicated gate logic in either route); the 402 payload-parity test green; v0.9 billing/marketplace_admin suites green unmodified; wire contract (UpgradeRequiredError) unchanged.
- **Task 3-4-01** (lead-developer): P3 phase gate — `pnpm lint` + `pnpm typecheck` + `pnpm ai:lint` + `pnpm ai:typecheck` + `pnpm ai:test:fast` + `pnpm web:test` + `pnpm cli:test` + `pnpm --filter web build` (web-touching phase) green; the D-110 pin review verdict recorded (placement.ts ↔ placements/base.py field-for-field); counts-discipline check (no denormalized counters anywhere); test counts recorded.
### Wave 4-2: PostingManager write actions (frontend-engineer)
## Phase 4: Audit Export (REQ-11-004)
- **Task 4-2-01** (frontend-engineer): PostingManager gains the write actions over the engine-client employer section: create draft, edit own draft, publish, close — honest states: **402 → UpgradeRequiredError renders the upgrade CTA with the correct reason (the CTA routes to the employer billing surface, P5)**; 403 claim-CTA renders the claim prompt; 409/404 honest; optimistic-free, loading/error/empty per component state; AbortController everywhere. Tests: web fetch-stub — 402 CTA render with reason, publish/close dispatch, own-draft-only error copy, claim-prompt on 403. (G-320b: the 402 branch renders reason + upgrade CTA ONLY — the payload's posts_used/posts_limit are byte-stable zeros (D-091/MH-4a) and are NEVER rendered as limits; real limits render exclusively from GET /v1/employers/billing/entitlement; pinned by source pin.)
**Goal:** The third-party-auditor deliverable exists: the self-contained JSON export + the human summary generated from the same document, methodology stating its own three pilot gaps, byte-deterministic re-exports, the admin web placements block + export links, the full-chain E2E proof.
## Phase 5: Self-Serve Checkout + Admin Overview (REQ-10-005)
### Wave 4-1: Export + summary routes + methodology + E2E (backend-engineer)
**Goal:** The B2B loop closes: employer-principal checkout over the v0.9 catalog through the REAL webhook path (buyer = session's claimed entity), own entitlement view, pricing CTAs → employer-login path, and the admin moderation page gains the network overview (accounts, claim queue, audit).
- **Task 4-1-01** (backend-engineer): `GET /v1/placements/admin/export` (D-108) — one self-contained JSON document via a single store-level builder function: `{schema_version, generated_at, summary: {distinct_learners_placed, total_active_placements, revoked_count, by_cohort_month}, cohort: [placement records with employer/posting/application refs + role_title + start_date], evidence_bundles: [per-placement {posting_record, application_record, transition_history[], decision_provenance} — **G-325 (binding): bundles cover EVERY placement (active AND revoked), each with the full event history incl. placement.revoked/placement.rehired — the correction history is precisely what an auditor most wants to inspect; `cohort` stays active-only; `revoked_count` equals the placement.revoked events (cross-checkable, rides G-321(c))** — each resolved from SOURCE ROWS at export time (G-302: the export EMBEDS resolved source content — snapshot-at-export, which is what self-contained means — while live rows stay the only write path; **NO backfill**: pre-v0.11 `submitted` applications carry only their `created_at` in the bundle — honest absence, never fabricated history, D-101/A-304)], methodology: {placement_definition, counting_rules, verification, scope}, ledger: {events: [{id, application_id, placement_id, event_type, actor_type, actor_ref, payload, created_at, prev_hash, hash}], head_hash}}`. Methodology per R-68 (written from in-tree facts) with **the three R-67 pilot gaps stated explicitly — (a) no minimum-employed-duration check (a placement = a recorded hire, not a 90-day sustained role), (b) no salary (D-098), (c) verification is employer-principal attestation + operator oversight, not third-party employment verification — an audit methodology that omits its own verification limits is A-304-class dishonesty**: `placement_definition` = a PlacementRow created by the hired transition, employer-attested role_title + start_date, active at export time, one active placement per application (the G-316 partial unique); `counting_rules` = distinct learners with ≥1 active placement, all-time cumulative toward 1,000, revoked excluded, cohort month = start_date calendar month (D-107); **`verification` per G-321 (binding): states (a) what the chain proves (in-place mutation/fork detection of an intact sequence), (b) what it does NOT (wholesale DB replacement undetectable by the walk alone — the auditor's defense is export-to-export continuity: a prior export's ledger.events must appear as an exact prefix of a later export's, with the prior head_hash equal to the hash at that prefix position), and (c) the event↔row cross-consistency check (every active placement has placement.created; every revoked has placement.revoked; revoked_count matches the placement.revoked events)**; `scope` = pilot-bounded, every placement in the DB unbounded by date, evidence class = employer-principal attestation; the cohort divergence documented deliberately (employment-start month, not graduation cohort — no graduation events exist in a marketplace, R-68); **CIRR kept as naming, not citation — the text stands on its own definitions (R-67)**. Determinism rules (R-73, binding): events ordered by id (chain order); `by_cohort_month` month ascending; cohort/evidence lists ordered placement `created_at` then id (the list_postings discipline, marketplace/store.py:940-948) — pydantic declaration-order serialization is deterministic but set-derived lists would not be, so every list is built with an explicit sort; **`generated_at` is export-only metadata (a function of the request, not the rows — R-79) and is excluded from determinism comparison**; **float absence — every export value is str/int/bool, no ratios or percentages (counts are integers by construction, D-107)**; `head_hash` = the last event's hash = `verify_chain()`'s computed head. pydantic + `JSONResponse` only — zero new deps (D-112). Tests land with Task 4-1-03.
- **Task 4-1-02** (backend-engineer): `GET /v1/placements/admin/export/summary` (D-108) — `text/markdown` rendered **FROM the same JSON document (one builder, two renderings — the summary can never drift from the export, D-108's anti-drift rule)**: totals table (distinct learners placed, total active placements, revoked count, the guiding-goal progress line toward 1,000), per-cohort month table, the methodology prose (rendered from the export's methodology block — **G-321: incl. the chain's proof scope, its wholesale-rewrite limit, and the export-to-export prefix-continuity anchor**), **the head hash + chain-walk verification instructions** (recompute `hashlib.sha256` over the canonical JSON + prev_hash from GENESIS_HASH event-by-event — the walk is offline: the export embeds every event, so the auditor needs nothing else, R-70); **G-328 (binding): the summary's title carries the attestation language ("employer-attested, operator-overseen") — never a "CIRR-verified" claim**. Tests: summary totals equal the export's summary block on the same DB state (the same-builder pin); content-type pinned; 403 fail-closed without the admin token.
- **Task 4-1-03** (backend-engineer): `tests/placements/test_export.py` — **re-export determinism: two exports on an unchanged DB → identical `ledger.events` chains + identical `head_hash`, with generated_at excluded (parsed field-by-field comparison or key-strip — R-73's TOTAL-exclusion rule)**; **chain-head consistency: `head_hash` = the last event's `hash` = `verify_chain()`'s output** (the walk re-computes every hash from GENESIS_HASH); **methodology completeness: the three R-67 gaps + all four methodology keys asserted present + the G-321 verification-scope statements (proof scope, wholesale-rewrite limit, export-to-export continuity, event↔row cross-check) asserted present**; **evidence-bundle completeness per G-325: every placement (active OR revoked) has a bundle with the resolved posting_record + application_record + transition_history (revoked bundles carry placement.revoked; re-hired carry placement.rehired)**; revoked placements absent from cohort but present in revoked_count (D-108); 403 fail-closed on both routes. Plus **the full-chain E2E proof `tests/placements/test_e2e_audit.py`** (the milestone's machine-checked reason-to-exist, D-111): employer register → claim → admin approve → create draft (direct package grant, the grant_posts precedent test_entitlement_enforcement.py:70-85) → publish → verified learner applies (seed_verified_identity, SUITE_LEARNERS) → employer drives in_review → interview → offer → hire `{role_title, start_date}` → PlacementRow created → ledger events chained (`verify_chain` green) → export → identical chain + head on re-export → overview counts the learner ONCE toward 1,000.
### Wave 5-1: Employer checkout + entitlement view + mock-complete (backend-engineer)
### Wave 4-2: Admin placements block + export links + security review + phase gate (frontend-engineer + security-auditor + lead-developer)
- **Task 5-1-01** (backend-engineer): `POST /v1/employers/billing/checkout {item_id}` (R-59 verbatim transpose of billing_admin.create_checkout — only principal derivation + metadata provenance change): session gate → **unclaimed → 403 + claim-CTA** → catalog lookup 404 → mode = subscription iff kind==plan → success/cancel URLs from `settings.public_base_url` (G-310) → `insert_checkout_session` FIRST with unique pending placeholder (`pending-{uuid4().hex[:16]}`) → CheckoutSessionSpec with metadata employer_id = claimed entity, operator_ref → the account's provenance (D-076's "operator" becomes account provenance) → provider.create_checkout_session via app.state.payment_provider → bind_provider_session_id → CheckoutResponse. Plus `GET /v1/employers/billing/entitlement` (own purchases/subscriptions: current package, posts used/limit, expiry — honest states; the v0.9 shapes reused). The webhook route + _grant_entitlement serve employer checkouts verbatim (G-303 per-session exactly-once holds). Tests: `tests/employers/test_checkout.py` — unclaimed 403 claim-CTA; 404 unknown item; 201 shape + persisted row; metadata provenance pinned (employer_id = claimed entity, account ref recorded); entitlement view honest states (none/active/expired).
- **Task 5-1-02** (backend-engineer): employer mock-complete arm (D-082 transposed, R-59): `POST /v1/employers/billing/mock/complete` mounted ONLY under provider=mock (factory-conditional), **employer-SESSION-gated, completing one's OWN session — cross-session completion → 403 (the G-301 hole class, open-risk-5); provider_session_id resolved from LOCAL rows by the session's own checkout rows (G-302 — payloads are untrusted ID references)**; mints the signed event through the REAL webhook path (mint_event → process_webhook_event, direct handler call — the D-082 proof shape); 404 under stripe (test-pinned). Tests: `tests/employers/test_checkout_e2e.py` — **E2E: register → login → claim → admin approve → checkout → own mock-complete → webhook → purchase exists with posts available**; cross-session completion → 403; stripe-mode mount → 404; duplicate complete falls through already-complete (no double grant, G-303).
- **Task 4-2-01** (frontend-engineer): D-109 surface 4 — the moderation page (app/(admin)/admin/moderation/page.tsx:3/:35, the EmployerNetworkPanel area employer-network-panel.tsx:56) gains a placements block: aggregate counts (distinct learners placed, total active placements, guiding-goal progress) + per-employer placement lists via `GET /v1/placements/admin/overview`, transported over the existing `adminToken()` G-308 sessionStorage wrapper (engine-client.ts:815; the locked-state :28-40 pattern — **403 renders the honest locked state with the token prompt, never a retry loop**); export links — both fetched via the admin transport (token header attached, AbortController-equipped): **the summary opens in-tab** (the markdown rendered in the panel) and **the JSON export downloads** (blob download from the fetched document); honest empty state at zero placements. **G-328 (binding): counts are labeled as recorded placements ("distinct learners placed", "placements recorded") — "CIRR" NEVER appears as a verification claim on any live surface; "CIRR-style" describes the export format in release notes/methodology only (source pin: no "verified" placement wording on any new surface).** Tests: web fetch-stub — locked state on 403, counts + per-employer lists render, export links dispatch with the admin header, empty state; source pins for the locked-state branches + the G-328 wording pin.
- **Task 4-2-02** (security-auditor): export-integrity review (P4 scope) — determinism rules honored (orderings pinned, generated_at total exclusion, no floats), **no PII beyond opaque handles** (learner_id is a G-5 roster handle, not name/email/DOB — identity PII is never stored, R-79; the export + 422 scrubber surface carry nothing else), methodology honesty (the three gaps stated; attestation never presented as third-party verification — A-304, the never-display-forgeable-as-verified constraint), head-hash truth (the export's head equals verify_chain's), evidence bundles resolved from source rows (G-302), both export gates fail-closed. Findings recorded; P0s fixed in-phase.
- **Task 4-2-03** (lead-developer): P4 phase gate — `pnpm lint` + `pnpm typecheck` + `pnpm ai:lint` + `pnpm ai:typecheck` + `pnpm ai:test:fast` + `pnpm web:test` + `pnpm cli:test` + `pnpm --filter web build` green (web-touching phase); the determinism + methodology-honesty verdicts recorded; test counts recorded.
### Wave 5-2: Employer billing surface + pricing CTAs + admin network overview (frontend-engineer)
## Phase 5: Learner Placement Visibility (REQ-11-005)
- **Task 5-2-01** (frontend-engineer): employer billing surface + pricing CTA conversion: the dashboard gains the entitlement view (current package, posts used/limit bar, expiry — honest empty/expired states) + buy flow (catalog from the public `/v1/billing/catalog`, own-checkout trigger, **in-app mock-complete confirmation honestly labeled as the pilot mock path**); **pricing-client.tsx CTAs convert from contact-path to employer-login path (superseding D-079) — the "Contact Sales" enterprise pin at pins.test.ts:250 STAYS (R-61)**; the operator-mediated copy is replaced by the self-serve truth with the admin-fallback noted honestly. Tests: web fetch-stub — entitlement render, checkout dispatch, mock-complete confirmation copy, CTA → /employer/login path, enterprise Contact Sales retained.
- **Task 5-2-02** (frontend-engineer): admin network overview (D-094, R-62): the moderation page (app/(admin)/admin/moderation/page.tsx) gains sibling `<section>` blocks below the v0.9 EmployerEntitlementPanel: employer accounts list (email, claimed entity, entitlement state columns), claim queue (**evidence + domain-match signal rendered ADVISORY — never as verification, A-304**; approve/reject actions), audit view — fed by the P2 operator routes via engine-client admin transport over the existing `adminToken()` G-308 wrapper; **403 renders an honest locked state with the token prompt, never a retry loop (the entitlement-panel load() pattern, R-62)**. Tests: web fetch-stub — locked state on 403, claim queue renders evidence + advisory signal, approve/reject dispatch, accounts list columns.
**Goal:** The learner sees their outcome on the surface where they applied: current status + full timestamped history + placement confirmation when hired (one read, one truth source), withdraw within reach; the badge stays honest everywhere.
### Wave 5-3: P5 security review (security-auditor)
### Wave 5-1: Learner application-status read route (backend-engineer)
- **Task 5-3-01** (security-auditor): checkout surface review — employer mock-complete own-session check (cross-session 403 pinned; mock-only mount pinned; stripe-mode 404 pinned — the G-301 class closed), unclaimed checkout 403 claim-CTA honest, entitlement view leaks nothing cross-entity, sessionStorage exposure copy present on the dashboard (open-risk-4), pricing copy honest (self-serve real, mock path labeled, enterprise pin retained). Findings recorded; P0s fixed in-phase.
- **Task 5-1-01** (backend-engineer): `GET /v1/marketplace/applications/{application_id}?learner_id=` (D-104, in api/marketplace.py — the existing apply surface): gated by the **D-043 composition** (G-5 allowlist marketplace.py:46-52 → verified 18+ :358 — the P2 withdraw additionally rides the existing apply-rate window; no new throttle knob, D-088 untouched); returns `{application, events[], placement|null}` — current status + the full id-ordered timestamped ledger history for THIS application (scoped query, never another learner's events) + the placement record when hired (REQ-11-005's outcome visibility + placement confirmation in one read, D-104); composes the placements store via `app.state` DI (deps.py); the response is a named pydantic contract in placements/base.py (pinned in placement.ts per D-110). **G-322 (binding): the mine point-read — store `get_application_by_posting_learner(posting_id, learner_id)` (the insert_application unique key — the get_posting point-read shape, marketplace/store.py:578-584) + route `GET /v1/marketplace/applications/mine?posting_id=&learner_id=` on the unchanged D-043 gate stack, returning `{application_id, status}` or 404 — the panel NEVER depends on client-persisted application_ids; without this route the status panel cannot find the application on revisit (the common case for a job board).** Tests `tests/placements/test_learner_status.py`: own application → 200 with status + events[] (id-ordered, this application only); hired → placement payload with role_title/start_date/employer refs; foreign learner_id → 403; unverified → 403; unallowlisted → 403; unknown application → 404; **mine-read arms (G-322): own → 200 {application_id, status}; none → 404; foreign learner_id → 403; unverified → 403**; the G-313 conftest reset holds (no self-429).
## Phase 6: Final Review + Ship (milestone release, v0.9.x line)
### Wave 5-2: Job-detail status panel (frontend-engineer)
**Goal:** Multi-persona review of all v0.10 changes; audit (reconstruction + discipline); milestone ship: merge to main, tag v0.9.6 (final v0.9.x patch = milestone release), full asset matrix + registry image via the existing release lane (no infra changes — the module rides D-064/D-065), milestone completion commits, branch cleanup.
- **Task 5-2-01** (frontend-engineer): D-109 surface 3 — job-detail-client.tsx (the existing apply surface: apply :95-115, EngineError 409/404 discrimination :103-105, confirmation render :253) gains the application-status panel: **G-322 composition: mine-read (`GET /applications/mine?posting_id=&learner_id=`) first → found? render the status panel via D-104's read : not found? render the apply CTA — never client-persisted application_ids**; current status badge (the seven D-099 states), timestamped history rows (events[]), placement confirmation when hired (role title, employer, start date — D-104's read), and the withdraw CTA (the learner's ONLY edit, D-104 — 409 terminal copy honest, and G-323's apply → 409 application_terminal copy renders the honest terminal state); **EngineError.status discrimination through the existing checks — NO new error classes** (403 foreign → the honest "not your application" state; 404 unknown; 401/403 gate arms render their existing states); AbortController everywhere (A-406). Badge honesty (A-304/D-109): **a placement badge renders ONLY in this learner's-own application surface where the data is real; the talent profile pages carry NO badge (v0.1 mock imports — a badge on a mock page is fabricated placement, D-092 binding)**. Tests: web fetch-stub + source pins (the G-312 discipline) — each status state renders, history rows, placement-confirmation payload render, withdraw dispatch + terminal 409 copy, foreign-403 copy, **mine-read composition (mine 404 → apply CTA; mine 200 → status panel)**; pins — talent-profile pages grep-clean of placement-badge additions; pins.test.ts placement blocks green.
### Wave 5-3: P5 phase gate (lead-developer)
- **Task 5-3-01** (lead-developer): P5 phase gate — `pnpm lint` + `pnpm typecheck` + `pnpm ai:lint` + `pnpm ai:typecheck` + `pnpm ai:test:fast` + `pnpm web:test` + `pnpm cli:test` + `pnpm --filter web build` green (web-touching phase); the A-304 badge-honesty census recorded (no badge on mock talent pages, no fabricated placement anywhere, mock labels retained where no backend computes them); test counts recorded.
## Phase 6: Final Review + Ship (milestone release, v0.10.x line)
**Goal:** Multi-persona review of all v0.11 changes; audit (reconstruction + discipline); milestone ship: merge to main, tag v0.10.6 (final v0.10.x patch = milestone release), full asset matrix + registry image via the existing release lane (no infra changes — the module rides D-064/D-065), milestone completion commits, branch cleanup.
### Wave 6-1: Multi-persona final review (lead-developer + security-auditor)
- **Task 6-1-01** (lead-developer → all active personas): full milestone-branch diff review — the D-091 no-drift verdict holds end-to-end (one helper, two gates, byte-identical 402), the cross-boundary wire review (employer.ts ↔ employers/base.py pins, the 401/402/403 discrimination contract in engine-client), D-097 discipline (learner gates untouched on learner routes; zero on employer routes), the D-092 honest-surface census (talent mock labels intact, no fake-converted surface, no convertible surface left on mock). P0s fixed in-phase, never loop back to EXECUTE.
- **Task 6-1-02** (security-auditor): final review — no credentials in logs/releases/assets; scrypt params + self-describing rows pinned; sessions hashed-at-rest; 422 scrub covers password+email; throttle windows active; audit trail complete (claim decisions, grants, revocations all provenance-recorded); no secret ever echoed by an exception path.
- **Task 6-1-01** (lead-developer → all active personas): full milestone-branch diff review — the **D-100 one-transaction discipline holds end-to-end** (one transition_application, no second status writer, no second append path — R-78), the **R-80 re-hire coherence verdict** (placements, not applications, are the correction lane; the ledger reads hired → revoke → rehire as three events, never a fabricated second application transition), the **G-319 pin-split verdict** (counts-only stays on the own-postings list — test_postings_read.py:216 unchanged; learner_id application-scoped only), the **D-110 wire-pin review** (placement.ts ↔ placements/base.py field-for-field + the status-union regex), the D-092 honest-surface census (the overview mock block replaced, talent mocks retained + labeled, no fake-converted surface, no convertible surface left on mock). P0s fixed in-phase; P1+ findings dispositioned with recorded follow-ups — never loop back to EXECUTE.
- **Task 6-1-02** (security-auditor): final review — no credentials or identity PII in logs/releases/assets (learner_id is an opaque handle everywhere, R-79); gate orders pinned on every principal class (employer/learner/operator); the ledger's no-delete path intact; tamper/fork/concurrency arms green; export determinism + methodology honesty re-verified; no secret echoed by any exception path.
### Wave 6-2: Audit + ship (lead-developer)
- **Task 6-2-01** (lead-developer → ship): audit (git log ↔ .ciagent/ reconstruction; file discipline — the R-66 addition census: ai_service/employers/, api/employers*.py, the api/ helper + adoption, config.py knobs, main.py wiring, tests/employers/, .env.example, packages/types/employer.ts, engine-client section, employer pages, admin sections, pricing CTA; branch/commit discipline — feat: scoped to the employer surface) + ship: merge milestone → main; tag v0.9.6; Gitea release with full summary + the complete asset matrix + registry image (the existing tag-triggered lane — zero workflow changes); delete milestone branches (tags preserve history); mark REQ-10-001..005 complete in REQUIREMENTS.md; ROADMAP milestone entry; checkpoint cleared.
- **Task 6-2-01** (lead-developer → ship): audit (git log ↔ .ciagent/ reconstruction; file discipline — the D-112 addition census: ai_service/placements/, api/placements_admin.py + the employers.py/marketplace.py route sections + deps.py get_placement_store, the marketplace store's get_application point read + grouped reads, main.py wiring, tests/placements/, packages/types/placement.ts + index.ts export, engine-client placements section, /employer/applications page, overview placements section, job-detail panel, admin placements block; branch/commit discipline — feat: scoped to the placement surface) + ship: merge milestone → main; tag v0.10.6; Gitea release with full summary + the complete asset matrix + registry image (the existing tag-triggered lane — zero workflow changes); delete milestone branches (tags preserve history); mark REQ-11-001..005 complete in REQUIREMENTS.md; ROADMAP milestone entry; checkpoint cleared.
---
## Must-Haves (per phase — concrete, falsifiable, test-linked)
- **P1 (REQ-10-001):** the employers module is importable + tested standalone; register/login/logout/me live with hashed sessions + both throttles; main.py wired; zero web changes. **MH-1a** register→login→me→logout flow green in tests/employers/test_auth.py; logout revokes (subsequent me → 401); concurrent sessions survive single logout. **MH-1b** REAL scrypt in every auth test (no fake-hash path; ≈2 hashes/test within the R-55 budget); params are module constants pinned by test; hash bytes never asserted. **MH-1c** employer_sessions stores ONLY sha256(token) (store-pinned); lazy 7-day TTL pinned via injected now (+6d present, +8d absent — no sleeps). **MH-1d** both throttle windows green in tests/employers/test_throttle.py — 5 failures/15 min → 429 with wait detail, success clears the email window, global cap → 429, rejected attempts consume nothing; reset fixtures isolate module state. **MH-1e** CORS preflight with `Access-Control-Request-Headers: Authorization` allowed — pinned in tests/api/test_cors.py (landed with the routes, not after). **MH-1f** a 422 on register never echoes the submitted password or email (pii_fields pin); mypy + ruff + full fast pytest green, zero new mypy overrides; no learner gate composed on any employer route (D-097).
- **P2 (REQ-10-002):** claim → pending → admin approve → account.employer_id set, all pinned. **MH-2a** claim→approve→ownership set + audit row green in tests/employers/test_claims_admin.py. **MH-2b** evidence dict `{account_email_domain, entity_website_domain, match}` computed at claim time, stored as provenance, pinned in tests/employers/test_claims.py; displayed nowhere as verification (match=false corpus-wide per R-65). **MH-2c** one PENDING claim per entity + one APPROVED owner per entity + rejection frees the entity — all three pinned in tests/employers/test_claims_store.py. **MH-2d** all five operator routes (accounts/claims/approve/reject/audit) fail-closed 403 without a valid admin token.
- **P3 (REQ-10-003):** the dashboard reads real data through the authenticated surface. **MH-3a** pins.test.ts employer.ts block asserts every field both schemas (ai_service/employers/base.py ↔ packages/types/employer.ts, D-093; PostingSummary reused from marketplace.ts, never duplicated). **MH-3b** 401 → SessionRequiredError → honest logged-out state with login CTA, never a retry loop (web test-pinned). **MH-3c** postings + overview pages grep-clean of removed mock imports (`jobs`, `candidates`, `employers`, inline METRICS/PIPELINE constants); own-postings lifecycle + real active count + per-posting applicant counts render from GET /v1/employers/postings; unclaimed → claim-CTA state visible; claim-pending state visible. **MH-3d** talent search + candidate profile pages keep their mock imports + mock labels (D-092 honest out-of-scope). **MH-3e** /employer/login + /employer/register render with honest 401/409/429/422 states; `pnpm --filter web build` (static export) green.
- **P4 (REQ-10-004):** one shared gate, two principals, no drift. **MH-4a** the 402 payload is byte-identical between admin and employer gates on `{reason, posts_used: 0, posts_limit: 0}` — only upgrade_cta carries the principal's CTA — pinned by the payload-parity test; **tests/billing/test_entitlement_enforcement.py green UNCHANGED**. **MH-4b** gate-before-insert: a 402 consumes nothing and creates nothing (posts_used unchanged, no row) — pinned. **MH-4c** employer create consumes from the employer's OWN package (posts_used+1 pinned); both 402 arms (no_active_package, posting_limit_reached) proven via direct store grants; unlimited bypass holds. **MH-4d** cross-entity access → 403; own-drafts-only edit (published → 409); publish/close lifecycle green in tests/employers/test_postings_write.py; admin posting routes unchanged (existing marketplace_admin tests green unmodified).
- **P5 (REQ-10-005):** the B2B loop closes machine-checked. **MH-5a** employer checkout E2E through the REAL webhook path green in tests/employers/test_checkout_e2e.py: register → claim → approve → checkout → own mock-complete → purchase exists with posts available (G-303 exactly-once holds — duplicate complete never double-grants). **MH-5b** cross-session mock-complete → 403; the route 404s under provider=stripe (mock-only mount) — both pinned (the G-301 class closed). **MH-5c** unclaimed checkout → 403 + claim-CTA; entitlement view renders honest none/active/expired states. **MH-5d** pricing CTAs route to /employer/login (contact-path copy gone from purchasable tiers); the enterprise "Contact Sales" pin retained (pins.test.ts:250). **MH-5e** admin network overview renders the accounts list, claim queue with advisory evidence + approve/reject actions, and the audit view; 403 → honest locked state with token prompt (web tests).
- **P6 (ship):** **MH-6a** multi-persona review clean (P0s fixed in-phase); the D-091 no-drift + wire-pin verdicts recorded. **MH-6b** security final review clean (no credentials in logs/releases; audit trail complete). **MH-6c** reconstruction + discipline checks green; v0.9.6 tagged, merged to main, release carries the full asset matrix + registry image via the existing lane; REQ-10-001..005 marked complete; branches deleted; checkpoint cleared.
## Milestone Must-Haves
- REQ-10-001..005 all complete per their REQUIREMENTS.md rows.
- The full B2B loop is the machine-checked E2E proof: register → login → claim → admin approve → create draft → buy package (own mock checkout → REAL webhook) → publish → posting live — every step pinned in tests/employers/.
- Zero new dependencies (R-66), zero workspace changes, zero mypy overrides; scrypt/secrets/hmac/sha256/urllib stdlib-only end-to-end.
- The v0.9 wire contracts unchanged: UpgradeRequiredError byte-stable, existing billing/marketplace_admin entitlement tests green unmodified.
- REQ-11-001..005 all complete per their REQUIREMENTS.md rows.
- The E2E machine-checked proof (the milestone's reason to exist, tests/placements/test_e2e_audit.py): employer registers → claims → operator approves → posts → learner (verified 18+) applies → employer drives in_review → interview → offer → hire `{role_title, start_date}` → PlacementRow created → the global chain walks green GENESIS_HASH → head → the export reproduces identical chains + head_hash on re-export → the overview counts the learner ONCE toward 1,000 — every step pinned in tests/placements/.
- Zero new dependencies (D-112 — the chain is stdlib hashlib/json; export is pydantic + JSONResponse), zero workspace changes, zero mypy overrides, zero .env.example changes (no new knobs — recorded explicitly).
- The v0.6/v0.9/v0.10 wire contracts unchanged: marketplace/billing/employers suites green unmodified (the only marketplace seam changes are the get_application point read + grouped reads); the own-postings counts-only pin green unchanged (G-319); UpgradeRequiredError and every existing engine-client contract byte-stable.
- Wire pins green: placement.ts dual-schema pinned field-for-field against placements/base.py (D-110), status-union regex pinned.
- Suite counts: pytest fast lane + web + cli all green, zero warnings (the v0.7 regime holds).
- Docs: .env.example throttle knobs; REQUIREMENTS/ROADMAP updated; deploy/README.md unaffected (no admin-box changes — record explicitly); the mock-first doctrine intact (no external auth provider, tests never call a cloud).
- Docs: REQUIREMENTS/ROADMAP updated; deploy/README.md unaffected (no admin-box changes — recorded explicitly); the mock-first doctrine intact (no external systems, no third-party auditor integration — the export IS the deliverable; tests never call a cloud).
## Risks
1. **scrypt CPU on concurrent auth** (R-55, ~50 ms/hash single-threaded; a login burst stacks) — bounded by both throttle windows; budget recorded in the passwords.py docstring; acceptable at pilot.
2. **CORS Authorization omission kills every cross-origin employer fetch** (E87 class, R-60/open-risk-2) — the main.py edit + the test_cors.py pin land in P1 Task 1-2-03, the same wave as the routes, never after.
3. **422 password echo** (R-56/open-risk-3) — pii_fields fix + pin land in P1 with the register route; auditor re-verifies at the P1 review.
4. **sessionStorage token exposure** (D-092 tradeoff, open-risk-4) — tab-scoped by design, exposure documented in surface copy; tokens stored hashed server-side (DB leak yields nothing); logout revokes the row.
5. **Employer mock-complete cross-session completion** (G-301 hole class, open-risk-5) — own-session check (403 cross-session) + mock-only mount + session gate, all pinned in P5.
6. **example.com seed websites make claim evidence match=false corpus-wide** (R-65/open-risk-6) — the queue copy presents the signal as advisory; the operator decision activates; auto-approval stays deferred (A-304).
7. **D-091 extraction drift** (the 402 dict's hardcoded zeros are easy to "improve") — the payload-parity test + unchanged v0.9 entitlement suite are the no-drift proof; lead-developer reviews at P4.
8. **Throttle module-state leaks across tests** (R-63) — both limiters get reset fixtures from day one (the _reset_apply_rate_window pattern).
1. **Re-hire-after-revoke coherence gap (R-80)** — `hired → hired` is a 409 on every matrix-validated surface, so the re-hire is pinned as the placement-level operator route `POST /v1/placements/admin/placements/by-application/{application_id}/re-hire` + the `placement.rehired` ledger event, never an application transition — Task 2-2-02; the three-event sequence + the partial-unique IntegrityError arm tested.
2. **Hash-after-flush write shape (R-70)** — flush-then-hash-then-commit inside one transaction, ALL appends through the one transition_application, the no-delete-path pin — Task 1-1-03 (+ the 1-1-02 source pin); tamper/fork/concurrency tests green.
3. **created_at canonical serialization (R-79/R-69)** — ONE pinned normalization (`_as_utc(dt).isoformat()`) in `_canonical_event_json`; the tamper tests catch hashing/reading drift before the chain breaks on its first re-read — Task 1-1-02.
4. **_APPLY_TIMES limiter reset (R-77)** — tests/placements/conftest.py replicates the autouse reset from day one (the G-313 lesson applied before it ships, not after flaky archaeology) — Task 1-2-01.
5. **Methodology honesty block (R-67/R-68)** — the three pilot gaps (no duration check, no salary, attestation-not-third-party-verification) stated explicitly in the export methodology — Task 4-1-01; asserted present by Task 4-1-03.
6. **Export determinism rules (R-73)** — pinned list orderings (events by id; cohort month ascending; placements created_at then id), total generated_at exclusion, float absence — Task 4-1-01; the re-export test is the proof.
7. **Status-column ownership (R-78)** — the placements store is the ONLY post-creation writer of application.status; stated column-level in the module docstring + enforced by the D-100 discipline check at the P1 gate (Task 1-3-02).
8. **G-319 supersession pin split (R-77)** — counts-only stays on the own-postings list (test_postings_read.py:216 unchanged); learner_id appears on application-scoped routes only, cross-entity 403 carries no data — Task 2-1-02 pins both arms.
9. **Concurrent-transition arm (R-78)** — one-winner-one-IntegrityError on truly concurrent transition_application calls (the billing concurrent-consumption shape, tests/billing/test_store.py:335) — Task 1-1-03.
10. **CIRR specifics from knowledge, not live sources (R-67)** — the methodology text stands on its own definitions; CIRR is naming, not citation; the summary renders the prose an auditor reads without external references — Tasks 4-1-01/4-1-02.
## User-Facing Surface
1. **/employer/login + /employer/register** — greenfield auth pages with honest 401/409/429/422 states; register → logged-in dashboard; sessions tab-scoped via `nc-employer-session`.
2. **Employer dashboard — postings page** — the employer's OWN postings lifecycle (draft → published → closed) with real applicant counts, create/edit/publish/close actions, honest 402 upgrade CTA + 403 claim prompt.
3. **Employer dashboard — overview** — real active-postings count + per-posting applicant counts + claimed-entity profile; talent-match/placement metrics honestly mock-labeled; claim-pending state visible.
4. **Talent search + candidate profile** — remain the v0.1 mock, honestly labeled (no candidate backend exists — D-092).
5. **Pricing page** — CTAs convert to the employer-login path (self-serve checkout is real); enterprise tier keeps Contact Sales.
6. **Admin moderation page — network overview** — employer accounts list, claim queue with advisory evidence + approve/reject, audit view; 403 → honest locked state.
7. **API surfaces** — /v1/employers/* (session-gated: auth, claims, postings, billing) + /v1/employers/admin/* (operator-gated) + the existing /v1/billing/* reused verbatim.
8. **Release notes** — the v0.9.x line ships the employer network through the existing automated lane (no admin-box changes required).
1. **/employer/applications (NEW)** — the hiring pipeline: own applications with status badges, per-target transition CTAs, the hire form `{role_title, start_date}` (a date input, future dates allowed); honest pending/empty/claim states.
2. **Employer dashboard — overview placements section** — real hired placements with role/start-date (the mock block REPLACED); talent-match/applicant-pipeline metrics stay mock-labeled.
3. **Job detail — application-status panel** — the learner's current status + timestamped history + placement confirmation when hired + withdraw CTA; honest 403/404/409 states.
4. **Admin moderation page — placements block** — aggregate counts toward the 1,000 guiding goal + per-employer lists + export links (summary in-tab, JSON download); 403 → honest locked state with token prompt.
5. **Talent search + candidate profile** — remain the v0.1 mock, honestly labeled, with NO placement badge (A-304 — no badge on mock pages).
6. **API surfaces** — `/v1/employers/applications/*` (session-gated transitions + own pipeline list), `/v1/marketplace/applications/{id}` + `/withdraw` (learner-gated, D-043 stack), `/v1/placements/admin/*` (operator-gated: transition, revoke, re-hire, overview, export, export/summary).
7. **Release notes** — the v0.10.x line ships placement tracking through the existing automated lane (no admin-box changes required).
## Happy Path (end-to-end, written BEFORE execute)
1. **Employer registers:** /employer/register → account created (email unverified, labeled as such) → logged in → dashboard shell with the claim prompt.
2. **Employer claims their entity:** picks their EmployerRow from the marketplace corpus → claim pending, evidence recorded → dashboard shows the honest claim-pending state.
3. **Operator approves:** admin moderation page → claim queue shows the evidence + domain-match signal (advisory) → approve → account bound to the entity (one owner) → audit row written.
4. **Employer tries to post without a package:** create draft → 402 with upgrade CTA (reason: no_active_package) → nothing consumed, nothing created.
5. **Employer buys a package (pilot mock):** dashboard billing surface → own checkout over the catalog → in-app mock-complete (honestly labeled) → REAL webhook path → purchase granted → posts available in the entitlement view.
6. **Employer publishes:** create draft (posts_used+1) → publish → posting live on the public job board; applicant counts accrue on the overview; the 11th post on a 10-pack → 402 upgrade CTA again.
7. **Operator oversight throughout:** the network overview shows the account, claimed entity, and entitlement state; the audit trail answers every claim/grant/revocation decision.
1. **Employer onboards (v0.10 rails unchanged):** register → claim their entity → operator approves → the account owns the entity.
2. **Employer posts:** create draft (package granted) → publish → the posting is live on the job board.
3. **Learner applies:** a verified 18+ learner applies from the job-detail page → application `submitted` → the status panel shows the timestamped entry.
4. **Employer drives the pipeline:** /employer/applications → in_review → interview → offer → the learner's panel reflects each step with its event history.
5. **Hire:** the employer submits `{role_title: "AI Orchestration Engineer", start_date: next month}` → hired (terminal) → the PlacementRow is created in the same transaction as the ledger events → the learner's panel shows the placement confirmation (role, employer, start date).
6. **Operator oversight:** the admin overview counts the learner ONCE toward 1,000; a mistaken hire → operator revoke → re-hire creates a fresh placement with a three-event ledger trail — the learner is never erased from the count by a correction.
7. **Auditor exports:** the admin placements block → the JSON export downloads (self-contained: cohort + evidence bundles + methodology + chain with head hash) + the summary opens in-tab → re-export reproduces the identical chain + head → the chain-walk instructions let a third party recompute every hash offline.
## UX Acceptance Criteria
1. **Honest states everywhere:** every new surface renders real loading/error/empty states — no spinners that lie, no silent failures, no fake data.
2. **401 → honest logged-out state:** SessionRequiredError renders the logged-out state with a login CTA — never a retry loop, never a silent redirect.
3. **402 upgrade CTA visible with correct limits:** the payload's reason drives the copy (no package vs limit reached); the CTA routes to the employer billing surface; limits shown are the store's truth. (G-320b: the 402 payload's posts_used/posts_limit are byte-stable zeros, never rendered — real limits render from the entitlement view only.)
4. **Claim-pending state visible to the employer:** the dashboard says "claim submitted, awaiting review" until the operator decides — never presented as verified before approval.
5. **Admin sees the claim queue with evidence:** the domain-match signal renders as advisory (match=false is the corpus norm); approve/reject are explicit operator actions with an audit trail.
6. **Talent pages remain mock-labeled:** the honest-surface rule cuts both ways — never fake-convert, never leave a convertible surface on mock; talent stays mock because no backend computes it.
2. **Gate order never surfaces a 409 before a 403:** unauthenticated → 401, unclaimed → 403 claim prompt, foreign application → 403 with no data, only then matrix 409s — pinned on every transition route (G-311).
3. **401 → honest logged-out state:** SessionRequiredError renders the login CTA on employer surfaces — never a retry loop, never a silent redirect; admin surfaces render the token-prompt locked state on 403.
4. **Counts honesty:** the overview counts DISTINCT learners toward 1,000 (a learner placed twice counts once); revoked placements excluded from every count; counts render from the store's rows at query time — never a denormalized or cached counter.
5. **Export self-verification:** the summary carries the head hash + offline chain-walk instructions; the methodology states its own three pilot gaps AND the chain's proof scope + wholesale-rewrite limit + export-to-export continuity anchor (G-321) — employer attestation is never presented as third-party verification (A-304); **G-328: no "verified" placement wording on any live surface (source-pinned)**.
6. **Badge honesty:** a placement badge renders only on the learner's own application surface where the data is real; no badge until a placement exists; none ever on the mock talent pages.
7. **Mock labels retained where no backend exists:** talent-match/applicant-pipeline metrics stay mock-labeled; talent pages stay the v0.1 mock — never fake-convert, never leave a convertible surface on mock (D-092).
+28 -1
View File
@@ -16,7 +16,34 @@ Nextcraft is an AI-native outcome school where graduates prove what they can bui
---
## Milestone v0.10 — B2B Employer Network (ACTIVE, Feature, started 2026-09-30)
## Milestone v0.11 — CIRR-Style Placement Tracking and Audit (ACTIVE, Feature, started 2026-10-01)
**Scope (D-098, auto-decided at SPECIFY under full autonomy D-007; founder scope confirmed 2026-10-01):** Activates **REQ-F-020 — CIRR-style placement tracking and audit** as REQ-11-001..005. The guiding outcome ("≥1,000 learners placed in AI-orchestration roles within 36 months, **verified by CIRR-style third-party audit**") is the school's single north star — v0.11 builds the machinery that makes it measurable and auditable. Every prerequisite is shipped: the learner identity spine (v0.5), the credential pipeline (v0.3), the marketplace applications flow (v0.6, `ApplicationRow`), the employer principals (v0.10, claimed entities + audit trail). v0.11 adds: the placement record model (learner + employer + role + start date, evidenced, immutable event log), the employer-side application state machine extending the v0.6 `submitted`-only status (hiring-stages transitions with audit), placement reporting surfaces (per-employer, per-learner, aggregate), and the **audit-grade export** — the evidence-backed dataset a third-party auditor can verify (cohort snapshots, per-placement evidence chains, tamper-evident event ledger, methodology documentation). Rationale: D-085's closing sentence names CIRR tracking as what the employer network "unlocks"; the guiding outcome is unmeasurable without it; no other deferred requirement (REQ-F-019 is medium and adds a surface, not the outcome). Confidence 0.80 ≥ threshold 0.60 (D-007).
**Out of scope:** human tutor marketplace (REQ-F-019, deferred), external third-party auditor integration (the export is the deliverable — no CIRR org exists to integrate with), learner-side employment verification (self-report stays out; employer-side state changes are the evidence), compensation/salary tracking (role + start date + employer suffice for placement counting; salary is out per CIRR-adjacent minimalism), public placement-stats marketing pages (internal + export only).
**Success (milestone-level):** every application in the marketplace has an auditable state history (submitted → in_review → interview → offer → hired/rejected/withdrawn, transitions employer-principal-driven and audit-logged); a placement record exists for every hired application with evidence (posting ref, application ref, timestamps, decision provenance); aggregate placement counts are computable from the ledger (per employer, per cohort month, cumulative toward ≥1,000); the audit export produces a self-contained, evidence-linked dataset (JSON + human-readable summary) whose placement claims are each traceable to immutable events; the mock-first doctrine holds (no external systems; tests never call a cloud). a placement record exists for every hired application with evidence (posting ref, application ref, timestamps, decision provenance); aggregate placement counts are computable from the ledger (per employer, per cohort month, cumulative toward ≥1,000); the audit export produces a self-contained, evidence-linked dataset (JSON + human-readable summary) whose placement claims are each traceable to immutable events; the mock-first doctrine holds (no external systems; tests never call a cloud).
## Clarify decisions (D-099+, CLARIFY stage full autonomy, 2026-10-01)
| ID | Decision | Rationale | Confidence |
|----|----------|-----------|------------|
| D-099 | Application status vocabulary = **snake_case set `submitted → in_review → interview → offer → hired / rejected / withdrawn`, forward-only with skip-forward, terminals have no outgoing edges**: the validated matrix (billing's `_VALID_TRANSITIONS` shape, billing/store.py:84-90) allows from any non-terminal status (a) any LATER stage (a fast-track `submitted → hired` is a legal hire — CIRR counts the hire, not the ceremony) and (b) any terminal (`rejected`/`withdrawn`); NO backward moves (no `offer → in_review`; the posting lifecycle draft→published→closed is strictly forward, store.py:289/609-632 — hiring inherits the discipline); `hired`/`rejected`/`withdrawn` are terminal — re-transition → 409 (`InvalidTransition`, the set_posting_status route contract); `withdrawn` is triggerable by the learner (own application, D-104) OR the operator (remediation, actor recorded — never by an employer session, who may `reject` instead); value naming is snake_case `in_review` (REQ-11-002's own text; billing's `past_due` precedent). Existing v0.6 rows are all `submitted` (store.py:211) — a valid matrix start, zero data migration. | The tree's two transition machines (posting store.py:609-632, subscription billing/store.py:84-90) both pin exact matrices with terminal empty-tuples; G-305's lesson (no phantom states, no un-matrixed moves) applies verbatim; skipping stages is honest pipeline reality (REQ-11-002 names the stages but no rule forbids acceleration, and an operator correcting a mis-entered pipeline must be able to jump forward). | 0.85 |
| D-100 | The state machine lives in **`ai_service/placements/` — the placements store owns ONE `transition_application(application_id, to_status, actor_type, actor_ref, role_title=None, start_date=None)` method that updates the marketplace `application` row + appends the ledger event + creates the `PlacementRow` on hired, ALL IN ONE TRANSACTION** (the decide_claim all-or-nothing precedent, employers/store.py:636-651: "the audit row rides the SAME transaction"); the transition matrix constant lives in `placements/base.py` (billing/base.py `CANONICAL_EVENT_NAMES` placement); the marketplace store KEEPS application insert/read ownership (insert_application unchanged; gains only a `get_application(application_id)` point read, the get_posting shape at store.py:578-584) — the placements store's cross-family write to the `application` table is the sanctioned deliberate exception: both stores share the one SQLite file, so one Session on the placements engine covers both tables atomically. The rejected alternative — route composes `marketplace.set_application_status` + `placements.append_event` as two commits — leaves a crash window where the status changed but no ledger row exists, which voids REQ-11-002's "every transition appends the placement ledger" (the exact tamper-evidence the milestone sells). | REQ-11-002's ledger-append requirement is only provable under one transaction; decide_claim is the in-tree proof that decision + audit event must land together; cross-module reuse is family-sanctioned (employers/base.py:22 subclasses marketplace.base.Posting — D-093); D-027 table ownership bends here because the feature boundary (application lifecycle) crosses the v0.6 table boundary, and the bend is one column-write executed by one named store method, never a route-side two-step. | 0.80 |
| D-101 | Hash-chain construction = **ONE GLOBAL chain over `PlacementEventRow` ordered by autoincrement id: each row stores `prev_hash` (the prior event's hash; `GENESIS_HASH = "0" * 64` module constant, test-pinned) + its own `hash` = `hashlib.sha256` (hex digest) over canonical JSON `{id, application_id, placement_id, event_type, actor_type, actor_ref, payload, created_at}` + `prev_hash`, serialized `json.dumps(..., sort_keys=True, separators=(",", ":"))`, created_at as ISO-8601 UTC** (deterministic; stdlib-only — the D-086 zero-dep doctrine, the D-077 HMAC-SHA256 crypto precedent); **`UNIQUE(prev_hash)`** makes a chain fork structurally impossible (two events claiming the same predecessor surface IntegrityError — the G-316 structural-backstop class); event rows carry BOTH a nullable indexed `application_id` (application-stage transitions log before any placement exists) and nullable `placement_id` (set from hired onward); **NO backfill**: pre-v0.11 applications have no `submitted` event — the export's evidence bundle carries the application record's own `created_at` (honest absence, never fabricated history, A-304; the G-302 doctrine cuts both ways). Actor vocabulary: `employer \| learner \| operator \| system` (the billing `_BILLING_EVENT_KINDS` shape, billing/store.py:92). | REQ-11-004 names "the event-ledger chain with head hash" — singular: one global chain with one head is the simplest verifiable shape (a single linear walk re-computes every hash; per-placement chains would need N heads and per-chain genesis bookkeeping for nothing at pilot scale); canonical sorted-key JSON is the standard deterministic-serialization answer and makes the re-export integrity test exact; SQLite's single-writer serialization plus the UNIQUE backstop closes the two-appenders-same-prev race the G-316 lesson demands. | 0.80 |
| D-102 | PlacementRow semantics = **immutable at creation; `status` is the ONLY mutable column with exactly one transition `active → revoked` (operator-only correction, the D-083 manual-remediation lane)**: `role_title`/`start_date`/`decided_at`/`learner_id`/`employer_id`/`posting_id`/`application_id` are set once at the hired transition and NEVER rewritten (a silent field rewrite is exactly the tamper the ledger exists to detect; the D-053 append-only enrichment doctrine); `decided_at` = the hired transition's created_at (store-stamped, `datetime.now(UTC)`, the insert_application precedent); the one-placement-per-application guard is a **partial UNIQUE index on `application_id` WHERE status='active'** (the G-316 `uq_pending_claim_per_entity` shape, employers/store.py:161-167) — a revoked placement stays in history, and a re-hire after a mistaken revoke creates a fresh placement for the same application, so an operator fat-finger never permanently erases a learner from the ≥1,000 count; `employer_id`/`learner_id`/`posting_id` are ID REFERENCES resolved from source rows at read/export (G-302 — never trusted denormalized copies). | REQ-11-001's plain "UniqueConstraint (application_id)" would make a mistaken hire uncorrectable forever (terminal application + unique placement = no re-hire path), which is operationally dishonest for an audit-grade dataset that must survive human error; the partial-index precedent exists in-tree for exactly this conditional-uniqueness shape; revoked placements are excluded from counts (D-107) so the correction is counting-honest too. | 0.75 |
| D-103 | `start_date` = **the employment start DATE (first day of work), a pydantic `date` — not the offer date, not a datetime**: the offer moment is already audit-truth as the hired transition's `decided_at` (D-102), so `start_date` answers "when did/will employment begin" (the CIRR question); timezone-free by construction (a calendar date has no tz — the cohort month D-107 keys on it); **future dates allowed** (hired today, starts next month — the normal case) and past dates allowed (retroactive recording of a hire that began last week); REQUIRED in the hire request body alongside `role_title` (employer-supplied at transition time, REQ-11-002's "hired creates the PlacementRow + role_title + start_date payload"); `role_title` is free text (defaults to nothing — never silently the posting title; the employer names the actual role). | A datetime would fabricate precision no employer supplies (nobody's start is "2026-11-03T14:22:51Z"); date-only matches every CIRR-adjacent reporting convention (monthly cohort tables); making both fields explicit-request-body requirements keeps the placement record employer-attested (the A-304 doctrine: the record carries what the principal actually asserted, never an inferred copy). | 0.85 |
| D-104 | Learner routes live in **`api/marketplace.py` (the existing apply surface) with the D-043 composition unchanged — G-5 allowlist → verified 18+ → `_check_apply_rate`**: `POST /v1/marketplace/applications/{application_id}/withdraw` (body `{learner_id}` — 403 when the application is not the caller's, 409 when terminal, 422-free) and `GET /v1/marketplace/applications/{application_id}?learner_id=` returning `{application, events[], placement|null}` (current status + full timestamped ledger history + the placement record when hired — REQ-11-005's outcome visibility + placement confirmation in one read); the withdraw rides the EXISTING apply-rate window (marketplace.py:66-76 — 5/rolling-60s per learner, no new throttle knob, D-088's auth-only throttle scope untouched); the routes compose the placements store via `app.state` DI (the territory rule, deps.py) for the history/placement sections and call the SAME `transition_application` store method (D-100) with `actor_type="learner"`. Learner-initiated edits go NO further: only `withdrawn` (REQ-11-005's "no learner-initiated edits… the withdrawn transition is the exception"). | D-097's binding left the learner gates untouched and pinned them to learner surfaces — the marketplace module IS the learner application surface (apply at marketplace.py:345-382 with this exact gate stack); the G-5 allowlist stays the pilot guard on every learner-gated route (A-306); riding the existing limiter avoids a new knob and its G-313 conftest-reset trap is already solved in tests/marketplace (conftest.py:123-131, mirrored into tests/placements per D-111). | 0.85 |
| D-105 | Employer transition routes = **per-target action routes under `/v1/employers/applications`** — `GET /v1/employers/applications?status=` (own pipeline), `POST .../{application_id}/in-review`, `.../interview`, `.../offer`, `.../reject`, `.../hire` (hire body `{role_title, start_date}`) — mirroring the posting lifecycle idiom (`/postings/{id}/publish`, `/postings/{id}/close`, employers.py:632-715); gate order (G-311): session 401 (`require_employer_session`, employers.py:173-191) → claim 403 (`_require_claimed`, employers.py:467-484) → cross-entity 403 (fetch application → posting → `posting.employer_id != claimed` → 403, the `_own_posting_or_404` shape at employers.py:487-503 applied through the application's posting) → transition 409 (never a 409 before a 403); every route rides the same `transition_application` method with `actor_type="employer", actor_ref="account:{id}"` (the D-091 checkout-metadata provenance shape) and appends an `employer_audit_events` row (the audit trail stays one queryable lane, D-089). **G-319 scoping (binding):** the v0.10 counts-only PII boundary was scoped to the own-postings LIST surface; v0.11 supersedes it for application-scoped reads/transitions — `learner_id` becomes visible ONLY for applications on the session's own claimed-entity postings (cross-entity → 403, no enumeration); the own-postings list's `applicant_count` stays counts-only. | A hiring pipeline without applicant identity is unworkable (REQ-11-002/003 inherently require it; A-408's "founder contacts employers out-of-band" was the no-employer-principal era's workaround, ended by v0.10); per-target routes keep the matrix OUT of request bodies (each route's store call names its target — one less validation surface, the publish/close precedent); G-319's intent was anti-enumeration/anti-cross-entity, and the 403-on-foreign-application arm preserves exactly that. | 0.80 |
| D-106 | Admin oversight = **`api/placements_admin.py`, prefix `/v1/placements/admin`, `require_admin` IMPORTED from marketplace_admin (the billing_admin.py:27 import precedent, employers_admin.py:47) — one generic remediation route `POST /v1/placements/admin/applications/{application_id}/transition` `{to, role_title?, start_date?}`** (validated against the same D-099 matrix; role_title/start_date required iff `to=hired`; `actor_type="operator"` with `OPERATOR = "operator"` route-level provenance, employers_admin.py:54 — no per-operator accounts exist, D-094), **plus `POST .../placements/{placement_id}/revoke`** (D-102's operator-only correction, one transition) and the reporting/export reads (D-107/D-108): `GET .../overview`, `GET .../export`, `GET .../export/summary`; no employer-session gate, no learner gate (a third principal class — D-097); token namespaces cannot collide (D-090); the operator can drive ANY matrix-legal transition including `withdrawn` on a learner's behalf (remediation with provenance — the D-083 manual-grant lane). | REQ-11-002 names "admin routes retain operator oversight (admin transition + audit, the D-094 lane)" — one generic route (vs the employer surface's per-target idiom) is the operator-tooling shape: fewer routes to audit, and the matrix validation lives once in the store (D-100); the export prefix matches REQ-11-004's own `/v1/placements/admin/export`; fail-closed on unset token is inherited free from require_admin (G-11 boot warning already covers it, main.py:251-256). | 0.85 |
| D-107 | Reporting semantics = **counts computed from rows at query time (never denormalized counters — G-302), with the guiding-outcome metric = DISTINCT LEARNERS with ≥1 `active` placement, all-time cumulative toward 1,000**: a learner placed twice counts ONCE ("≥1,000 learners placed" — the PROJECT.md guiding outcome is learners, not placements); `revoked` placements are excluded from every count (D-102's correction semantics); **cohort month = the calendar month of `start_date` (`YYYY-MM`)** — not application month, not hired month (CIRR cohorts on employment start); `GET /v1/placements/admin/overview` returns `{total_active_placements, distinct_learners_placed, guiding_goal: 1000, by_employer: [{employer_id, count}], by_cohort_month: [{month, count}]}`; per-employer view = the claimed entity's `hired` applications with role/start-date (REQ-11-003's employer-dashboard placements section); joins run through the existing stores (employer identity via `employer_accounts.employer_id`/EmployerRow, posting via postings, learner via application rows — REQ-11-003's own "counts join through existing stores"); batched reads use the `count_applications_by_posting` grouped-read discipline (store.py:915-932) — never per-row N+1. | The guiding outcome's noun is "learners" (PROJECT.md line 7) — counting placements would inflate the north star; date-only cohort keys make month buckets deterministic (D-103); counting from rows keeps revoke honest without counter-maintenance writes (the G-306 lazy-evaluation lesson: derived state computed at read, never a timer or a stored tally). | 0.85 |
| D-108 | Export format = **`GET /v1/placements/admin/export` returns one self-contained JSON document**: `{schema_version, generated_at, summary: {distinct_learners_placed, total_active_placements, revoked_count, by_cohort_month}, cohort: [placement records with employer/posting/application refs + role_title + start_date], evidence_bundles: [per-placement {posting_record, application_record, transition_history[], decision_provenance} — each resolved from SOURCE ROWS at export time (G-302)], methodology: {placement_definition, counting_rules, verification, scope}, ledger: {events: [{id, application_id, placement_id, event_type, actor_type, actor_ref, payload, created_at, prev_hash, hash}], head_hash}}`; **`GET /v1/placements/admin/export/summary`** returns the human-readable rendering (text/markdown: totals table, per-cohort table, methodology prose, head hash, chain-walk verification instructions); scope is honest pilot-bounded (every placement in the DB, unbounded by date — REQ-11-004's own scoping); **re-export determinism: the integrity test asserts identical `ledger.events` chains + identical `head_hash` across two exports, with `generated_at` excluded from comparison** (the chain is a function of stored rows; generated_at is the export's own timestamp, not ledger content); the summary is generated from the same JSON (one truth source — the summary can never drift from the export). | REQ-11-004 names all four dataset components ((a) cohort snapshot, (b) evidence bundles, (c) methodology, (d) chain+head) — this is exactly that shape with names an auditor can consume; "resolved from source rows, never denormalized copies" means snapshot-at-export (the export EMBEDS resolved source content — that's what self-contained means) while the live rows stay the only write path; methodology-as-structured-JSON keeps it versioned and machine-checkable while the summary route renders the prose an auditor reads. | 0.80 |
| D-109 | Web conversion scope = **four surfaces, no new client module**: (1) new `/employer/applications` page (the hiring pipeline: own applications with status badges + per-target transition CTAs — honest pending/empty/403-claim states, the A-406 board-client discipline); (2) the employer overview page gains a real placements section (hired applications with role/start-date, honest empty state) — its existing applicant-pipeline/talent-match mock metrics stay mock-labeled (D-092: never fake-convert, never leave a convertible surface on mock); (3) the job-detail client (the existing apply surface, job-detail-client.tsx) gains an application-status panel: current status + timestamped history + placement confirmation when hired, via D-104's learner read (401/403/409 discrimination through existing `EngineError.status` checks — job-detail-client.tsx:103-105 precedent, NO new error classes); (4) the admin moderation page's EmployerNetworkPanel area gains a placements block (aggregate counts + per-employer lists via D-107 overview, adminToken() transport per G-308) + an export link (summary opens in-tab; JSON export downloads). **The talent profile's placement badge stays absent** — talent profile pages remain v0.1 mock (D-092 binding), and a badge on a mock page is A-304 dishonesty; a REAL badge renders only on the learner's own application surface (D-104's placement section) where the data is real. | REQ-11-003 names the employer dashboard + moderation-page surfaces; REQ-11-005 names the learner application surface (the job detail page IS that surface today — apply + confirmation live there, job-detail-client.tsx:50/253); D-092's conversion doctrine (real where a real backend exists, mock where none does) maps cleanly: placements have a backend, talent profiles do not. | 0.80 |
| D-110 | Wire discipline = **`packages/types/placement.ts` (ApplicationStatus union, Placement, PlacementEvent, EmployerApplicationSummary, transition request/response shapes, PlacementOverview, PlacementExport) dual-schema pinned field-for-field against `ai_service/placements/base.py` pydantic contracts in `apps/web/tests/pins.test.ts`** (the D-093 template — the employer.ts pin block); engine-client.ts gains a **placements section** (no new client module — D-092's binding: one client module keeps parseError discrimination in one place): employer list/transition/hire functions, learner application-status/withdraw, admin overview/export — every fetch AbortController-equipped (A-406), 401 → SessionRequiredError reuse (engine-client.ts:126-131), no new error classes. | Every prior cross-boundary wire (PostingSummary D-084, employer.ts D-093) rides exactly this rail from day one; the placement surface is a new cross-boundary contract and the pins make drift structurally detectable (G-108 discipline); the 403 cross-entity arm needs no client-side class — components render the honest "not your application" state from EngineError.status. | 0.85 |
| D-111 | Testing doctrine = **`tests/placements/` mirroring D-096: real flows against create_app + TestClient, tmp_path DBs, zero network by construction, no MockTransport (no external calls exist), mypy green with no new overrides (stdlib only — D-086)**: state-machine 409 matrix arms (every illegal move incl. backward + terminal-source), gate order pins (employer transition: no-token → 401, unclaimed → 403, foreign application → 403 — never a 409 first; learner withdraw: unallowlisted → 403, unverified → 403, foreign application → 403, terminal → 409), hired E2E (transition → PlacementRow created → ledger events chained → employer dashboard placement visible), revoke + re-hire (partial-unique arm: second active placement on the same application → IntegrityError surfaced), **hash-chain tamper tests** (direct row mutation via a second engine → verification walk fails; fork attempt → UNIQUE(prev_hash) surfaces), **export determinism** (two exports → identical chains + head, generated_at excluded), learner visibility (history + placement confirmation), reporting counts (distinct-learner rule: one learner, two placements → counts once). `tests/placements/conftest.py` carries the G-313-shape autouse reset for the marketplace apply-rate limiter module state (the withdraw route rides it — tests/marketplace/conftest.py:123-131 pattern) — the suite cannot self-429. | The billing/employers suites are the proven template (behavior over the real app, real scrypt, honest assertions); the tamper and determinism tests are THIS milestone's reason to exist — an audit ledger nobody has tried to break is theater; the limiter reset is the G-13 lesson applied before it ships (v0.10 caught it only after flaky archaeology). | 0.85 |
| D-112 | Migration/compat = **9th D-027 store, tables `placement` + `placement_event` on the shared DB file, `SQLModel.metadata.create_all` idempotent — zero migration** (the D-095 shape verbatim): `SQLitePlacementStore` mirrors employers/store.py mechanics exactly (WAL/synchronous=NORMAL/busy_timeout/foreign_keys pragmas at connect, `sa_type` portable columns, `_validate`/`_normalize` hooks, Protocol + SQLite impl pair, surfaced IntegrityError, tz-aware↔naive normalization, `_constraint_error` helper); bare `placement` is FREE (D-095's prefix rule was collision-driven — marketplace owns `posting`/`employer`/`application`, no `placement` table exists); the marketplace `application` table gains NO new columns (status is already a plain str column, store.py:211 — the D-099 vocabulary extends the validated set, existing `submitted` rows stay valid); lifespan wiring = the getattr-guard template (main.py:195-200) + `close()` in the finally block (main.py:336); routers mount in main.py beside the employers block (main.py:400-411). No new dependencies (hash chain is stdlib `hashlib`/`json`; export is pydantic + `JSONResponse`). | Seven prior stores never broke an existing DB via this exact pattern (D-095's 0.90 confidence carries forward); create_all checkfirst adds the two tables to every existing deployment at next boot; the zero-dep claim stays intact (D-086/R-66) — sha256 + canonical JSON need nothing the venv lacks. | 0.90 |
## Milestone v0.10 — B2B Employer Network (COMPLETE, Feature, 2026-09-30, shipped as v0.9.6)
**Scope (D-085, auto-decided at SPECIFY under full autonomy D-007; founder scope confirmed 2026-09-30):** Real employer auth/accounts and employer self-serve — the milestone every prior decision sequences next: A-407 ("Real employer auth lands post-payments with the B2B employer network" — payments shipped v0.9), A-401 (employer self-serve posting management deferred pending employer auth), D-076/D-079 (operator-mediated checkout + contact-path CTAs were honest *because* no employer principals exist). Employers become principals: registered accounts with sessions, claiming their existing employer entity (the v0.6 `EmployerRow` ingested data), self-serve posting management (draft → published → closed on their own entities), self-serve checkout (the v0.9 billing catalog with employer-principal buyers instead of operator mediation), and the employer dashboard surface converting from the v0.1 mockup to real data. Rationale: the guiding outcome (≥1,000 placements) needs a self-serve employer side; the supply side (v0.6), monetization rails (v0.9), and install-anywhere distribution (v0.8) are all in place — the employer network is the missing B2B surface that unlocks CIRR tracking (REQ-F-020 needs employer-side placement data) and closes the last mock-only surface. Confidence 0.80 ≥ threshold 0.60 (D-007).
+47 -1
View File
@@ -1,5 +1,41 @@
# Nextcraft — REQUIREMENTS.md
## v0.11 Requirements (ACTIVE — Placement Tracking and Audit, Feature; branch `milestone/v0.11-placement-tracking`)
**Milestone type: Feature** — CIRR-style placement tracking and audit (REQ-F-020 activated per D-098). Tags on the v0.10.x line (P0 → v0.10.0 … P6 → v0.10.6 = milestone release); branch `milestone/v0.11-placement-tracking`. REQ-F-020 activated as REQ-11-001..005: the placement store + evidence model (REQ-11-001), the employer-side application state machine with audit-logged transitions (REQ-11-002), the placement reporting surfaces (REQ-11-003), the audit-grade export (REQ-11-004), learner-side placement visibility (REQ-11-005). Mock-first doctrine holds (no external systems, no third-party auditor integration — the export is the deliverable); tests never call a cloud.
### Placement Store + Evidence Model
| ID | Description | Priority | Phase | Status |
|----|-------------|----------|-------|--------|
| REQ-11-001 | Placement store + evidence model: `ai_service/placements/` module (D-031 extend pattern — 9th D-027 store family, shared DB file, `create_all` idempotent): `PlacementRow` (id, learner_id, employer_id, posting_id, application_id, role_title, start_date, status, created_at, decided_at) with partial UNIQUE on `application_id` WHERE status='active' — one ACTIVE placement per application, revoked rows stay in history (D-102, the G-316 conditional-uniqueness shape); `PlacementEventRow` (append-only ledger: application_id nullable+indexed, placement_id nullable, event_type, actor_type, actor_ref, payload, prev_hash, hash, created_at) — one global hash chain, sha256 over canonical JSON + prev_hash, `UNIQUE(prev_hash)` fork backstop (D-101), tamper-evident by append-only discipline + hash chain (the chain makes silent rewrites detectable, G-301-class integrity); evidence links resolved from existing rows only (posting ref from postings store, application ref from applications store, employer ref from the claimed entity — G-302 doctrine: never trust denormalized copies); SQLModel typed access, Protocol + SQLite impl pair (the employer-store pattern), tz-normalization, fail-closed on unknown inputs | critical | 1 | complete (v0.10.1) |
### Application State Machine
| ID | Description | Priority | Phase | Status |
|----|-------------|----------|-------|--------|
| REQ-11-002 | Employer-side application state machine: the v0.6 `ApplicationRow.status` extends from `submitted`-only to the hiring lifecycle (submitted → in_review → interview → offer → hired / rejected / withdrawn — forward-only with skip-forward, terminals have no outgoing edges, D-099; withdrawn learner- or operator-initiated, all others employer-principal-driven); transitions via per-target employer routes under `/v1/employers/applications` (`/{id}/in-review`, `/{id}/interview`, `/{id}/offer`, `/{id}/reject`, `/{id}/hire` with `{role_title, start_date}` — D-105) gated by the employer session + claimed-entity authorization (the posting's employer must be the session's claimed entity — 403 otherwise, G-311 gate order); every transition appends the placement ledger in the SAME transaction as the status write (application events + placement events on hired — D-100); rejected/withdrawn/hired are terminal with different semantics (hired creates the PlacementRow + role_title + start_date payload); admin routes retain operator oversight (admin transition + audit, the D-094 lane); learner read access stays the existing surface (their own application status history); G-319 scoping: learner identifiers become visible on employer surfaces only for applications on the session's claimed-entity postings (the own-postings list stays counts-only) | critical | 2 | complete (v0.10.2) |
### Placement Reporting
| ID | Description | Priority | Phase | Status |
|----|-------------|----------|-------|--------|
| REQ-11-003 | Placement reporting: aggregate placement counts computed from the ledger (per employer, per cohort month, cumulative toward the ≥1,000 guiding outcome); per-employer placement view (the employer dashboard gains a placements section — their hired applications with role/start date, honest empty states); per-learner view (the learner's application list gains outcome states); admin placement overview (the moderation page's network section gains aggregate counts + per-employer placement lists — the operator lane); counts join through existing stores (employer identity via claimed entities, learner via applications, posting via postings) | high | 3 | complete (v0.10.3) |
### Audit Export
| ID | Description | Priority | Phase | Status |
|----|-------------|----------|-------|--------|
| REQ-11-004 | CIRR-style audit export: admin-gated export routes (`GET /v1/placements/admin/export` + `GET /v1/placements/admin/export/summary`, text/markdown — D-108) producing the third-party-auditor dataset: (a) a cohort snapshot (all placements with employer refs, role titles, start dates, evidence chains); (b) per-placement evidence bundles (posting record, application record, state-transition history, decision provenance — each link resolved from source rows, never denormalized copies); (c) a methodology document (how a placement is defined, counted, and verified — the counting rules an auditor needs); (d) the event-ledger chain with head hash (tamper-evidence for the export itself); output as JSON + a human-readable summary; honest scoping (pilot scale: the export covers every placement in the DB, unbounded by date); export integrity test (re-export reproduces identical event chains + head hash, generated_at excluded — D-108) | critical | 4 | complete (v0.10.4) |
### Learner Placement Visibility
| ID | Description | Priority | Phase | Status |
|----|-------------|----------|-------|--------|
| REQ-11-005 | Learner-side placement visibility: the learner application surface gains outcome visibility via `GET /v1/marketplace/applications/{application_id}?learner_id=` (current status + full timestamped state history + placement record when hired — D-104) and `POST /v1/marketplace/applications/{application_id}/withdraw` (learner-gated per the D-043 composition: G-5 allowlist → verified 18+ → apply-rate window); placement confirmation (when hired, the learner sees the placement record — role title, employer, start date — sourced from the same ledger); no learner-initiated edits beyond withdrawn (status changes are employer/admin-driven only); the talent profile's placement badge stays absent until a placement exists (honest, the A-304 doctrine — no badge on the v0.1 mock pages) | medium | 5 | complete (v0.10.5) |
---
## v0.10 Requirements (Complete — B2B Employer Network, Feature; shipped as v0.9.6)
**Milestone type: Feature** — real employer auth/accounts + employer self-serve (the B2B employer network; activated per D-085). Shipped on the v0.9.x line (P0 → v0.9.0 … P6 → v0.9.6 = milestone release); branch `milestone/v0.10-b2b-employer-network`. All 5 requirements complete: the employer auth foundation with scrypt credentials + hashed sessions + throttles (REQ-10-001), the entity claim/verify flow with advisory evidence + admin approval (REQ-10-002), the dashboard real-data conversion with honest states (REQ-10-003), self-serve postings through the shared D-091 entitlement gate byte-identical with the admin path (REQ-10-004), self-serve checkout over the v0.9 catalog through the real webhook path + the admin network overview (REQ-10-005). Auth is session-based with hashed credentials (mock-first doctrine: no external auth provider, tests never call a cloud); admin operator routes are retained unchanged (D-043 composition untouched).
@@ -362,7 +398,7 @@
| REQ-F-017 | Identity verification and age-gating (16+/18+) — real KYC backend | high | v0.5 | activated → complete (REQ-5-003/004) |
| REQ-F-018 | Payment processing and subscription management | high | v0.9 | activated → REQ-9-001..005 |
| REQ-F-019 | Human tutor marketplace (third-party courses) | medium | v0.4+ | deferred |
| REQ-F-020 | CIRR-style placement tracking and audit | medium | v0.5+ | deferred |
| REQ-F-020 | CIRR-style placement tracking and audit | medium | v0.5+ | activated → REQ-11-001..005 |
---
@@ -382,6 +418,16 @@
## Traceability Matrix
### v0.11 (active)
| 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 |
### v0.6 (complete)
| Requirement | Phase | Status |
+302
View File
@@ -1,3 +1,305 @@
# Nextcraft — RESEARCH.md (v0.11 Phase 0 RESEARCH — Placement Tracking and Audit)
## v0.11 research conclusions (Phase 0 RESEARCH, 2026-10-01)
In-tree census of the placement-tracking surface, 2026-10-01, plus
audit-convention findings (CIRR methodology, hash-chained ledgers, ATS
stage models, evidence-bundle formats) grounded in audit practice and
marked [convention] vs [pilot adaptation] — no web access in this
environment; the methodology text is written to stand on its own
definitions with CIRR kept as naming, not citation. Binding clarify
decisions D-099..D-112 are NOT re-litigated; this research
operationalizes them. Probes: marketplace/store.py (ApplicationRow +
insert_application + transition machines), employers/store.py (the
8th store + G-316 partial index + decide_claim transaction), billing/
(store.py matrix + event rows + concurrent-test precedent), api/
employers.py + marketplace.py + employers_admin.py +
marketplace_admin.py (gates + provenance + throttles), main.py
(lifespan + routers + CORS + boot warning), engine-client.ts +
pins.test.ts + packages/types/ (web rails), tests/employers/ +
tests/marketplace/ + tests/billing/ (suite layouts + conftest
resets), employer/admin web surfaces (D-109's four surfaces).
**R-67. CIRR methodology transfers as disclosure discipline; three
pilot gaps must be stated in the export methodology (R-67/R-68).**
[convention] CIRR-style audits rest on four load-bearing parts: a
placement definition (in-field role, FTE, exclusions for
self-employment), cohort construction with stated denominators,
employer-verified evidence sampled by an independent auditor, and
disclosure discipline (methodology published beside the numbers,
self-reported vs employer-verified labeled separately). [pilot
adaptation] v0.11's employer-principal attestation (authenticated
`hire` by a claim-verified employer — employers.py:173-191/:467-484,
claim approval with provenance employers/store.py:583-655) is a
stronger evidence class than learner self-report but is NOT CIRR-grade
employer verification. The methodology JSON must state three gaps
honestly: (a) no minimum-employed-duration check (a placement = a
recorded hire, not a 90-day sustained role), (b) no salary (D-098),
(c) verification is employer-attestation + operator oversight, not
third-party employment verification. D-107's distinct-learner counting
matches CIRR's placed-graduate semantics for a cumulative north star;
CIRR reports cohort RATES over denominators, which v0.11 deliberately
does not claim at pilot. Confidence 0.75.
**R-68. The methodology section writes itself from in-tree facts.**
`placement_definition` = a PlacementRow created by the hired
transition, employer-attested role_title + start_date, active at
export time, one active placement per application (partial unique, the
G-316 shape employers/store.py:159-167); `counting_rules` = distinct
learners with ≥1 active placement, all-time cumulative, revoked
excluded, cohort month = start_date calendar month (D-107);
`verification` = each evidence bundle carries the source posting +
application records + full transition ledger whose global sha256 chain
re-walks from GENESIS_HASH to head_hash (D-101); `scope` =
pilot-bounded, every placement in the DB, evidence is
employer-principal attestation. The methodology text must name the
three R-67 gaps explicitly — an audit methodology that omits its own
verification limits is A-304-class dishonesty. Cohort divergence is
deliberate and documented: employment-start month, not graduation
cohort (no graduation events exist in a marketplace). Confidence 0.85.
**R-69. D-101's global hash chain sits in the well-trodden
blockchain-less tamper-evidence lane (CT, git, rekor).** A global
linear chain with one head is the right pilot shape — one walk
recomputes everything; Merkle trees buy proof-efficiency nobody needs
at thousands of events. The UNIQUE(prev_hash) fork backstop has direct
prior-art analog; G-316 proves SQLite unique/partial indexes emit
correctly through create_all and surface IntegrityError
(employers/store.py:398-400). Canonical-serialization rules: sort_keys
+ separators pinned; datetime formatting must be NORMALIZED (R-79);
avoid floats in payload entirely (str/int/bool/None only); sha256
hexdigest is the D-087 token pattern (employers/store.py:230) —
stdlib-only, zero new deps. No backfill: pre-v0.11 submitted rows
carry only their created_at (marketplace/store.py:873-901) — honest
absence per A-304. Confidence 0.85.
**R-70. The hash covers the row's own autoincrement id → the append
must be flush-then-hash-then-commit inside one transaction, and ALL
appends funnel through the one transition_application method (D-100).**
Within one Session: read head (ORDER BY id DESC LIMIT 1), construct
row with prev_hash=head, session.flush() to obtain the id, compute
hash = sha256(canonical_json + prev_hash), set the column, commit.
Nearest in-tree template: decide_claim (employers/store.py:600-652 —
get → guard → mutate → flush → append audit → commit). Corollaries:
(1) a second append path would race on prev_hash (the UNIQUE backstop
catches it as a lost event — IntegrityError); (2) the ledger must have
NO delete path (INTEGER PRIMARY KEY reuses max+1 if the top row is
deleted — D-111's tests pin zero DELETE methods + no DELETE SQL
anywhere). Verification cost at pilot: O(n) sha256 over tiny rows —
milliseconds for thousands of events; the export embeds every event so
the auditor's walk is offline. Confidence 0.80.
**R-71. ATS prior art confirms forward-only-with-skip-forward; the
exclusions are genuinely excludable (D-099).** Real ATSs allow backward
moves because they manage scheduling and offers — which D-98 excludes.
At pilot, `submitted → hired` fast-track matches how small-scale hiring
actually proceeds. Terminals-with-no-outgoing-edges is the tree's own
discipline (billing/store.py:84-90, marketplace/store.py:609-632,
InvalidTransition → 409 at :289). No interview scheduling (no calendar
surface exists — A-408), no offers machinery (no document generation),
no email (no mail transport — D-097 precedent). The matrix constant
belongs in placements/base.py (CANONICAL_EVENT_NAMES shape,
billing/base.py:71-78); the 409-matrix test arms mirror billing's
test_legal_transitions/test_illegal_transition_raises/test_cancelled_is_terminal
trio (tests/billing/test_store.py:400-418) plus skip-forward arms.
Zero data migration: existing rows are all `submitted`
(marketplace/store.py:211) — a valid matrix start. Confidence 0.85.
**R-72. D-100's cross-store single-transaction write is mechanically
sound on the shared file.** Every D-027 store builds its own engine on
the same db_path with identical pragmas (WAL/synchronous=NORMAL/
busy_timeout=5000/foreign_keys=ON — marketplace/store.py:277-286);
multiple engines on one file is the SHIPPED topology (8 stores).
SQLModel's metadata is global — create_all on any engine creates every
registered table (employers/store.py:400), so the placements store
writes ApplicationRow through one Session on its own engine without
bringing up the marketplace store. Importing across modules is
family-sanctioned (employers/base.py:39 imports marketplace.base).
decide_claim is the exact all-or-nothing precedent
(employers/store.py:634-652). Guardrails: NO cross-family foreign keys
(G-302 ID references at read); the store must tolerate being
constructed against a fresh file (global metadata + idempotent
create_all covers it). The two-commit alternative's crash window (status
changed, no ledger row) is precisely the tamper-evidence void the
milestone sells. Confidence 0.85.
**R-73. Export determinism needs three pinned rules (D-108).**
(1) Ordering — events by id (chain order); cohort/summary lists by
pinned sorts (cohort month ascending; placements by created_at then id
— the list_postings discipline, marketplace/store.py:940-948); pydantic
declaration-order serialization is deterministic but set-derived lists
would not be. (2) generated_at exclusion must be TOTAL — compare
parsed documents field-by-field or strip the key before comparison.
(3) Float absence — every export value str/int/bool; no ratios or
percentages (counts are integers by construction, D-107). The summary
generated from the same JSON is the anti-drift rule. Zero new deps
(JSONResponse + pydantic in-tree; D-112 holds). Confidence 0.80.
**R-74. Service seams verified for D-112.** Store lifespan wiring:
the getattr-guard template at main.py:182-190 (billing)/195-200
(employers) — placements slots in beside employers; close() in the
finally block main.py:335-336; router mounting beside the employers
block main.py:400-411; learner routes mount from the existing
marketplace router (no new mount — D-104), placements_admin mounts
beside employers_admin; CORS already carries Authorization
(main.py:357 — no change); the G-11 admin boot warning main.py:251-256
covers the placements admin free. deps.py gains get_placement_store
beside get_employer_store (deps.py:98). create_all checkfirst is the
probe-verified D-095 shipped behavior (employers/store.py:398-400).
Confidence 0.90.
**R-75. API gates verified for D-104/D-105/D-106.** require_employer_session
(employers.py:173-191, 401 class, returns (session, account));
_require_claimed (employers.py:467-484, 403 + claim-CTA);
_own_posting_or_404 (employers.py:487-503). The application-scoped
cross-entity arm is the same shape through the application's posting:
fetch application → posting → employer_id != claimed → 403. G-311
gate order is test-pinned (tests/employers/test_postings_write.py:225-
234). Per-target route idiom: employers.py:635 (publish)/:687 (close).
Provenance: `"account:{account.id}"` (employers.py:792). Learner stack:
_enforce_allowlist (marketplace.py:46-52) → require_verified_age(18)
(:358) → _check_apply_rate (:359; window :66-76, module state
_APPLY_TIMES :58, cap 5/60s :63) — withdraw composes the identical
stack, actor_type=learner. Operator lane: require_admin imports from
marketplace_admin (employers_admin.py:47; guard marketplace_admin.py:
62-85, fail-closed dual-header); OPERATOR="operator"
(employers_admin.py:54); the applications-review precedent
(marketplace_admin.py:420-443) already exposes learner_id to the
operator — unchanged. Token namespaces cannot collide (D-090 —
hash-lookup vs env-compare, verified in both guards). Confidence 0.90.
**R-76. Web rails verified for D-109/D-110.** engine-client.ts
sections in order: identity :351, marketplace :545 (applyPosting :647-
656), search :659, resume :730, billing :773, entitlements :782, admin
transport :843 (adminToken :815), employer auth :918 —
SessionRequiredError :126-131, ClaimRequiredError :136, parseError
:146 (401/403 discrimination); a placements section appends after
employer billing (:1114). pins.test.ts: marketplace :48-133, billing
:203-230, employer dual-schema :267-374 (field-for-field + status-union
regex pins — the D-110 template), G-319 own-postings block :376+.
packages/types/ carries billing/employer/marketplace — placement.ts is
the fourth. Four surfaces: employer overview (app/(employer)/employer/
page.tsx:25 → employer-overview-client.tsx — real postings/counts
:162/:190-230, the mock-labeled placement metrics block :335-372
labelled "(mock — no backend computes this)" :351 — the real
placements section REPLACES that mock block); /employer/applications
page is NEW beside postings/login/register/billing/talent; job-detail-
client.tsx is the learner apply surface (apply :95-115, EngineError
409/404 discrimination :103-105, confirmation render :253 — the
status panel composes the same discipline, no new classes); moderation
page (app/(admin)/admin/moderation/page.tsx:3/:35 →
employer-network-panel.tsx:56, adminToken/locked-state :28-40) gains
the placements block. Talent-profile badge stays absent — talent pages
are v0.1 mock imports; a badge on a mock page is fabricated placement
(A-304). Confidence 0.90.
**R-77. Test suites verified for D-111.** tests/employers/ (16 files)
is the D-096 layout; tests/marketplace/conftest.py:123-131 is the
G-313-shape autouse reset (_reset_apply_rate_window clears _APPLY_TIMES
before/after every test — the withdraw route rides this limiter, so
tests/placements/conftest.py MUST replicate it or the suite
self-429s); tests/employers/conftest.py:31-33 same pattern for the two
employer throttles. Hermeticity: tmp_path DBs, state-injected stores
via create_app (tests/marketplace/conftest.py:80-120 make_client),
zero network via root conftest. Matrix arms template:
tests/billing/test_store.py:400-418. Partial-index pin template:
tests/employers/test_claims_store.py:75-88 (SELECT from sqlite_master
asserting the index SQL — applies to the placement partial unique,
D-102). Gate-order pins: tests/employers/test_postings_write.py:315
(cross-entity 403), tests/marketplace/test_marketplace_api.py:188
(invalid transitions 409). G-319 pin SPLIT needed (D-105): counts-only
stays on the own-postings list (test_postings_read.py:216); learner_id
appears on application-scoped responses for own-posting applications;
cross-entity → 403 with no application data. Confidence 0.85.
**R-78. SQLite two-store cross-write interplay safe at pilot; two WAL
facts to pin.** (1) The marketplace store's own application writes are
idempotent inserts (insert_application :873-901) that never touch
status after creation — the placements store is the ONLY status writer
post-creation; state the COLUMN-level ownership in the plan (no second
status writer outside the one-transaction discipline). (2) The tamper
test's second engine on the same file sees WAL snapshot isolation —
the mutation must commit before the verification walk reads it
(Session context-managers guarantee). Multi-session contention on one
file is already exercised in-tree (test_concurrent_consumes_one_post_left,
tests/billing/test_store.py:335). busy_timeout=5000 is generous for
hiring-transition volumes (rare events, not hot-path). Confidence 0.80.
**R-79. Three clocks, three serialization rules.** (1) start_date is
a calendar date — timezone-free, ISO YYYY-MM-DD in SQLite, cohort
month = strftime("%Y-%m"); no datetime coercion in its path. (2)
created_at/decided_at are UTC datetimes stored NAIVE in SQLite,
re-labeled tz-aware on read (_as_utc, marketplace/store.py:263-267 —
the family pattern). The hash-serialization pitfall: datetime.isoformat()
emits +00:00 for aware, nothing for naive — the chain's canonical JSON
must pin ONE normalization (always _as_utc(dt).isoformat(), or a fixed
YYYY-MM-DDTHH:MM:SSZ format) and the tamper tests catch drift between
the hashing and reading paths. (3) generated_at is export-only
metadata, excluded from determinism (D-108 — a function of the request,
not the rows). Privacy (G-319 supersession): learner_id is an opaque
allowlisted handle (the G-5 roster), not name/email/DOB — identity PII
never stored (identity/base.py:25-33), the 422 scrubber's pii_fields
(main.py:439) never sees learner_id; exposure bounded to opaque handles
on the employer's own applicants, cross-entity 403 preserves
anti-enumeration. start_date is not PII-class. Confidence 0.85.
**R-80. Coherence gap found: re-hire after revoke is UNREACHABLE
through the D-099 matrix — PLAN must pin the operator re-hire as a
placement-level route.** After the first hire the application is
`hired` — terminal, no outgoing edges; D-106 validates the operator's
generic route against the same matrix, so `hired → hired` is a 409 on
every surface; the revoke itself (active → revoked, D-102) leaves the
application hired — correct — but no matrix-legal path creates the
fresh placement D-102 promises. Candidate shapes: (a) an explicit
operator re-hire route (POST /v1/placements/admin/placements/
by-application/{application_id}/re-hire {role_title, start_date})
creating the fresh PlacementRow + a `placement.rehired` ledger event
(actor operator) WITHOUT touching the application's terminal status —
audit-coherent: the ledger shows hired → revoke → rehire as three
events, never fabricating a second application transition; (b) matrix
amendment hired → hired operator-only — REJECTED (breaks the terminals
rule, and "the application was hired twice" is false); (c) revoke +
full reapplication — REJECTED (a new application may be impossible on
a closed posting, and it orphans the learner from the count — exactly
what D-102 exists to prevent). Shape (a) is coherent: placements, not
applications, are the correction lane (D-102's own framing); the
partial unique index makes it safe (revoked rows don't match WHERE
status='active' — the exact G-316 property that makes
claim-rejection-free-reclaim work, employers/store.py:159-167). The
ledger event vocabulary (D-101) admits this event type; D-111's revoke
+ re-hire test arm is unaffected. Confidence 0.80.
## Open risks (v0.11, tracked into PLAN)
1. **Re-hire-after-revoke coherence gap (R-80)** — pin the operator
re-hire as a placement-level route + `placement.rehired` ledger
event, never an application transition. Changes route list, event
vocabulary, and a test arm.
2. **Hash-after-flush write shape (R-70)** — flush-then-hash-then-commit
inside one transaction; ALL appends through the one
transition_application method; no-delete-path pin.
3. **created_at canonical serialization (R-79/R-69)** — pin ONE
normalization (_as_utc(dt).isoformat() or fixed Z-format) or the
chain breaks on its first re-read.
4. **_APPLY_TIMES limiter reset (R-77)** — tests/placements/conftest.py
must replicate the autouse reset or the suite self-429s (G-313).
5. **Methodology honesty block (R-67/R-68)** — the three pilot gaps
stated explicitly in the export methodology.
6. **Export determinism rules (R-73)** — pinned list orderings, total
generated_at exclusion, float absence.
7. **Status-column ownership (R-78)** — placements store is the only
post-creation writer of application.status; state it column-level.
8. **G-319 supersession pin split (R-77)** — counts-only stays on the
own-postings list; learner_id on application-scoped routes only.
9. **Concurrent-transition arm (R-78)** — one-winner-one-IntegrityError
on truly concurrent transition_application calls (the billing
concurrent-consumption test shape).
10. **CIRR specifics from knowledge, not live sources (R-67)** — the
methodology text stands on its own definitions; CIRR is naming,
not citation.
---
# Nextcraft — RESEARCH.md (v0.10 Phase 0 RESEARCH — B2B Employer Network)
## v0.10 research conclusions (Phase 0 RESEARCH, 2026-09-30)
+17 -1
View File
@@ -2,6 +2,8 @@
## 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.10 — B2B Employer Network (COMPLETE, shipped as v0.9.6, 2026-10-01).** Feature milestone: real employer auth/accounts + self-serve (D-085). Employers become principals — registered accounts with sessions, employer-entity claim/verify, the employer dashboard converting from the v0.1 mockup to real data, self-serve posting management with the v0.9 entitlement enforcement intact, and self-serve checkout over the v0.9 billing catalog (superseding D-076 operator mediation; admin operator routes retained as fallback + oversight). Tags on the v0.9.x line (P0 → v0.9.0 … P6 → v0.9.6 = milestone release); branch `milestone/v0.10-b2b-employer-network`.
**Milestone v0.9 — Payment Engine (COMPLETE, shipped as v0.8.6, 2026-09-30).** Payment processing and subscription management (REQ-F-018 activated per D-074): the billing module (provider protocol mock+stripe, 7th D-027 store, HMAC webhooks), catalog + pricing surface, operator-mediated checkout, entitlement enforcement, full subscription lifecycle. Founder scope decision D-074; clarify D-075..D-084; grill G-301+. Tags on the v0.8.x line (v0.8.0 phase 0 → v0.8.6 final = milestone release); branch `milestone/v0.9-payment-engine`.
@@ -36,7 +38,21 @@ Payment processing and subscription management (REQ-F-018 activated per D-074, a
## Phase List
### v0.10 (complete)
### v0.11 (active)
| # | Name | Status | Depends On | Requirements | Success Criteria |
|---|------|--------|------------|--------------|------------------|
| 0 | Pre-execution | active | — | — | Specify (D-098), clarify, research, plan, grill, MVP/UX check |
| 1 | Placement store + evidence ledger | complete (v0.10.1) | 0 | REQ-11-001 | `ai_service/placements/` module: 9th D-027 store (PlacementRow + PlacementEventRow, hash-chained append-only ledger), evidence resolved from source rows only (G-302 doctrine), `create_all` idempotent on the shared DB file, Protocol + SQLite pair |
| 2 | Application state machine | complete (v0.10.2) | 1 | REQ-11-002 | Employer routes extend the v0.6 application status from submitted-only to the full hiring lifecycle; claimed-entity authorization (403 cross-entity, G-311 gate order); every transition ledger-logged; hired creates the placement; admin oversight retained |
| 3 | Placement reporting | complete (v0.10.3) | 2 | REQ-11-003 | Aggregate counts from the ledger (per employer, per cohort month, cumulative toward ≥1,000); employer dashboard placements section; learner outcome states; admin network overview counts |
| 4 | Audit export | complete (v0.10.4) | 3 | REQ-11-004 | Admin-gated export: cohort snapshot + per-placement evidence bundles + methodology doc + chain head hash; JSON + human-readable summary; re-export reproducibility test |
| 5 | Learner placement visibility | complete (v0.10.5) | 4 | REQ-11-005 | Learner application outcomes + full state history; hired learners see their placement record; honest no-badge-until-placed (A-304) |
| 6 | Final review + ship | 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 |
---
## Phase List (v0.10 — complete)
| # | Name | Status | Depends On | Requirements | Success Criteria |
|---|------|--------|------------|--------------|------------------|
+3 -3
View File
@@ -46,9 +46,9 @@
"projects": [],
"active_project": null,
"milestone": {
"version": "v0.10",
"name": "b2b-employer-network",
"version": "v0.11",
"name": "placement-tracking",
"type": "feature",
"branch": "milestone/v0.10-b2b-employer-network"
"branch": "milestone/v0.11-placement-tracking"
}
}
+8
View File
@@ -13,6 +13,7 @@ from ..grading.store import GradeStore
from ..llm.base import LLMProvider
from ..marketplace.embedding.base import EmbeddingProvider
from ..marketplace.store import MarketplaceStore
from ..placements.store import PlacementStore
from ..sandbox.manager import SandboxManager
from ..sandbox.workdir import SandboxDir
from ..telemetry.ingest import TraceIntegrityMap
@@ -101,6 +102,13 @@ def get_employer_store(request: Request) -> EmployerStore:
return request.app.state.employer_store
def get_placement_store(request: Request) -> PlacementStore:
"""v0.11 placements (D-100/D-112): the same app.state DI territory
rule — placement routes compose the store via app.state, never
import it."""
return request.app.state.placement_store
def get_embedding_provider(request: Request) -> EmbeddingProvider:
"""v0.6 P3 (D-051): the embedding port via app.state DI — the
search route + publish hooks compose it, never import it."""
+256 -1
View File
@@ -38,6 +38,7 @@ import secrets
import time
import uuid
from collections import deque
from datetime import UTC, date, datetime
from typing import Annotated
from fastapi import APIRouter, Depends, Header, HTTPException, Request
@@ -71,13 +72,30 @@ from ..llm.base import LLMProvider
from ..marketplace.base import Posting
from ..marketplace.enrichment import maybe_enrich_on_publish
from ..marketplace.search import embed_posting
from ..marketplace.store import InvalidTransition, MarketplaceStore
from ..marketplace.store import (
InvalidTransition,
MarketplaceStore,
)
from ..marketplace.tagging import TaggingUnavailable, tag_posting
from ..placements.base import (
APPLICATION_STATUSES,
ApplicationTransitionResponse,
EmployerApplicationsResponse,
EmployerApplicationSummary,
EmployerPlacementsResponse,
HireTransitionRequest,
PlacementRead,
)
from ..placements.base import (
InvalidTransition as PlacementInvalidTransition,
)
from ..placements.store import PlacementRow, PlacementStore
from .deps import (
get_billing_store,
get_embedding_provider,
get_employer_store,
get_marketplace_store,
get_placement_store,
get_provider,
get_settings,
)
@@ -715,6 +733,243 @@ async def close_own_posting(
)
# -- hiring pipeline (v0.11 P2, Task 2-1-01 — REQ-11-002, D-105/G-311) ---------------
def _own_application_or_404(
store: MarketplaceStore, application_id: str, employer_id: str
) -> None:
"""Fetch the application → its posting → enforce the cross-entity
arm (D-105: the _own_posting_or_404 shape applied THROUGH the
application's posting — another entity's application → 403 with
NO application data; unknown → 404). G-319: learner_id never
rides this arm's error responses."""
application = store.get_application(application_id)
if application is None:
raise HTTPException(
status_code=404,
detail=f"no application with id {application_id!r}",
)
posting = store.get_posting(application.posting_id)
if posting is None: # pragma: no cover - FK discipline keeps rows paired
raise HTTPException(
status_code=404,
detail=f"no posting with id {application.posting_id!r}",
)
if posting.employer_id != employer_id:
raise HTTPException(
status_code=403,
detail="this application belongs to another employer entity",
)
def _placement_read(row: PlacementRow) -> PlacementRead:
return PlacementRead(
id=row.id,
learner_id=row.learner_id,
employer_id=row.employer_id,
posting_id=row.posting_id,
application_id=row.application_id,
role_title=row.role_title,
start_date=date.fromisoformat(row.start_date),
status=row.status, # type: ignore[arg-type]
created_at=row.created_at or datetime.now(UTC),
decided_at=row.decided_at or datetime.now(UTC),
)
def _employer_transition(
application_id: str,
to_status: str,
account: AccountRow,
marketplace: MarketplaceStore,
placements: PlacementStore,
employer_store: EmployerStore,
role_title: str | None = None,
start_date: date | None = None,
) -> ApplicationTransitionResponse:
"""The shared per-target transition composition (D-105): G-311
gate order — session (the route dep) → claim (_require_claimed) →
cross-entity (_own_application_or_404) → the ONE store transition
(409 on matrix-illegal, NEVER before the 403s) + the
employer_audit_events append (the D-089 one-queryable lane)."""
employer_id = _require_claimed(account)
_own_application_or_404(marketplace, application_id, employer_id)
try:
application, placement = placements.transition_application(
application_id,
to_status,
actor_type="employer",
actor_ref=f"account:{account.id}",
role_title=role_title,
start_date=start_date,
)
except PlacementInvalidTransition as exc: # MUST precede ValueError — it subclasses it
raise HTTPException(status_code=409, detail=str(exc)) from exc
except ValueError as exc: # hire required-fields honesty (D-103)
raise HTTPException(status_code=422, detail=str(exc)) from exc
employer_store.append_audit_event(
"application",
f"application.{to_status}",
account_id=account.id,
employer_id=employer_id,
detail={"application_id": application_id, "to_status": to_status},
)
return ApplicationTransitionResponse(
application_id=application.id,
status=application.status, # type: ignore[arg-type]
placement=_placement_read(placement) if placement is not None else None,
)
@claims_router.get("/applications", response_model=EmployerApplicationsResponse)
async def list_own_applications(
status: str | None = None,
auth: Annotated[
tuple[SessionRow, AccountRow], Depends(require_employer_session)
] = None, # type: ignore[assignment]
store: MarketplaceStore = Depends(get_marketplace_store),
) -> EmployerApplicationsResponse:
"""The session employer's OWN hiring pipeline (D-105): every
application on the claimed entity's postings (ALL statuses), with
per-status facets. G-319 pin split: this APPLICATION-scoped read
exposes learner_id per the supersession — the own-postings LIST
stays counts-only."""
if auth is None: # pragma: no cover - the dep raises 401 first
raise HTTPException(status_code=401, detail="employer session required")
_session, account = auth
employer_id = _require_claimed(account)
postings = store.list_postings_by_employer(employer_id)
applications = store.list_applications_by_postings(
[p.id for p in postings], status=status
)
facets: dict[str, int] = {s: 0 for s in APPLICATION_STATUSES}
items = [
EmployerApplicationSummary(
id=a.id,
posting_id=a.posting_id,
learner_id=a.learner_id,
status=a.status, # type: ignore[arg-type]
created_at=a.created_at,
)
for a in applications
]
for item in items:
facets[item.status] = facets.get(item.status, 0) + 1
return EmployerApplicationsResponse(
applications=items, facets=facets, total=len(items)
)
def _own_transition_route(
to_status: str, hire: bool = False
):
"""Per-target route factory (D-105: the matrix stays OUT of request
bodies — each route's store call names its target, the publish/
close idiom). Only the hire route carries a body ({role_title,
start_date} — D-103/G-324); the others are bodyless actions."""
if hire:
async def _hire_route(
application_id: str,
auth: Annotated[
tuple[SessionRow, AccountRow], Depends(require_employer_session)
],
body: HireTransitionRequest,
store: MarketplaceStore = Depends(get_marketplace_store),
placements: PlacementStore = Depends(get_placement_store),
employer_store: EmployerStore = Depends(get_employer_store),
) -> ApplicationTransitionResponse:
return _employer_transition(
application_id,
to_status,
auth[1],
store,
placements,
employer_store,
role_title=body.role_title,
start_date=body.start_date,
)
return _hire_route
async def _action_route(
application_id: str,
auth: Annotated[
tuple[SessionRow, AccountRow], Depends(require_employer_session)
],
store: MarketplaceStore = Depends(get_marketplace_store),
placements: PlacementStore = Depends(get_placement_store),
employer_store: EmployerStore = Depends(get_employer_store),
) -> ApplicationTransitionResponse:
return _employer_transition(
application_id,
to_status,
auth[1],
store,
placements,
employer_store,
)
return _action_route
claims_router.add_api_route(
"/applications/{application_id}/in-review",
_own_transition_route("in_review"),
methods=["POST"],
response_model=ApplicationTransitionResponse,
)
claims_router.add_api_route(
"/applications/{application_id}/interview",
_own_transition_route("interview"),
methods=["POST"],
response_model=ApplicationTransitionResponse,
)
claims_router.add_api_route(
"/applications/{application_id}/offer",
_own_transition_route("offer"),
methods=["POST"],
response_model=ApplicationTransitionResponse,
)
claims_router.add_api_route(
"/applications/{application_id}/reject",
_own_transition_route("rejected"),
methods=["POST"],
response_model=ApplicationTransitionResponse,
)
claims_router.add_api_route(
"/applications/{application_id}/hire",
_own_transition_route("hired", hire=True),
methods=["POST"],
response_model=ApplicationTransitionResponse,
)
# -- own placements (v0.11 P3, Task 3-1-01 — REQ-11-003, D-107/D-109) ---------------
@claims_router.get("/placements", response_model=EmployerPlacementsResponse)
async def list_own_placements(
auth: Annotated[
tuple[SessionRow, AccountRow], Depends(require_employer_session)
],
placements: PlacementStore = Depends(get_placement_store),
) -> EmployerPlacementsResponse:
"""The claimed entity's OWN placements (active + revoked — the
full history, D-109 surface 2's data source): created_at then id;
honest empty state at zero placements. G-311: 401 session → 403
claim (never data before the gates)."""
_session, account = auth
employer_id = _require_claimed(account)
rows = placements.list_placements_by_employer(employer_id)
return EmployerPlacementsResponse(
placements=[_placement_read(row) for row in rows],
total=len(rows),
)
# -- self-serve billing (P5, Task 5-1-01 — REQ-10-005, R-59/G-310) ------------------
@@ -16,6 +16,7 @@ from __future__ import annotations
import time
from collections import deque
from datetime import date
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from pydantic import BaseModel, Field, ValidationError
@@ -33,6 +34,15 @@ from ..marketplace.resume import (
parse_resume,
)
from ..marketplace.search import FacetFilters, rank_postings
from ..placements.base import (
InvalidTransition,
LearnerApplicationStatusResponse,
LearnerApplicationSummary,
MineApplicationResponse,
PlacementEventRead,
PlacementRead,
)
from ..placements.store import PlacementRow
from .deps import get_embedding_provider, get_marketplace_store, get_provider, get_settings
from .identity import require_verified_age
@@ -375,6 +385,19 @@ async def apply_posting(
application, _created = store.insert_application(
posting_id=body.posting_id, learner_id=body.learner_id
)
# G-323 (binding): the idempotent arm NEVER confirms a terminal
# application — a withdrawn/rejected/hired row returns 409 (the
# learner is told the honest state; recovery is visibility via the
# application-status surface, not resurrection — no second row is
# ever created; the (posting_id, learner_id) unique key stands).
if application.status in ("withdrawn", "rejected", "hired"):
raise HTTPException(
status_code=409,
detail={
"reason": "application_terminal",
"status": application.status,
},
)
return ApplyResponse(
application_id=application.id,
posting_id=application.posting_id,
@@ -382,6 +405,173 @@ async def apply_posting(
)
# -- v0.11 P2: the learner withdraw route (D-104, REQ-11-002) --------------------
class WithdrawBody(BaseModel):
"""D-104: the pilot surface passes learner_id in the body (the G-5
composition unchanged — the SAME stack as apply)."""
learner_id: str = Field(min_length=1)
@router.post(
"/applications/{application_id}/withdraw", response_model=ApplyResponse
)
async def withdraw_application(
application_id: str,
body: WithdrawBody,
request: Request,
store=Depends(get_marketplace_store),
settings: Settings = Depends(get_settings),
) -> ApplyResponse:
"""The learner's ONLY edit (D-099: withdrawn is learner- or
operator-triggerable, NEVER an employer session). The D-043
composition UNCHANGED: G-5 allowlist → verified 18+ → the EXISTING
apply-rate window (no new throttle knob, D-088 scope untouched).
Own application only (403 foreign); terminal → 409 (the matrix).
"""
_enforce_allowlist(body.learner_id, settings)
await require_verified_age(18, body.learner_id, _identity_store(request))
_check_apply_rate(body.learner_id)
application = store.get_application(application_id)
if application is None:
raise HTTPException(
status_code=404, detail=f"no application with id {application_id!r}"
)
if application.learner_id != body.learner_id:
raise HTTPException(
status_code=403,
detail="this application belongs to another learner",
)
from .deps import get_placement_store # local import — the territory rule
placements = get_placement_store(request)
try:
application, _placement = placements.transition_application(
application_id,
"withdrawn",
actor_type="learner",
actor_ref=f"learner:{body.learner_id}",
)
except InvalidTransition as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
return ApplyResponse(
application_id=application.id,
posting_id=application.posting_id,
status=application.status,
)
# -- v0.11 P5: the learner status reads (D-104/G-322, REQ-11-005) -------------------
def _placement_read(row: PlacementRow) -> PlacementRead:
"""The PlacementRow → PlacementRead conversion (the placements_admin
helper mirrored — each route file owns its readers; start_date is
the ISO DATE string on the row, a date on the wire, D-103)."""
return PlacementRead(
id=row.id,
learner_id=row.learner_id,
employer_id=row.employer_id,
posting_id=row.posting_id,
application_id=row.application_id,
role_title=row.role_title,
start_date=date.fromisoformat(row.start_date),
status=row.status, # type: ignore[arg-type]
created_at=row.created_at or _now(),
decided_at=row.decided_at or _now(),
)
def _now():
from datetime import UTC, datetime
return datetime.now(UTC)
@router.get("/applications/mine", response_model=MineApplicationResponse)
async def mine_application(
request: Request,
posting_id: str = Query(min_length=1),
learner_id: str = Query(min_length=1),
store=Depends(get_marketplace_store),
settings: Settings = Depends(get_settings),
) -> MineApplicationResponse:
"""G-322 (binding): the mine point-read — the status panel's
application DISCOVERY on revisit (the panel NEVER depends on
client-persisted application_ids). Gate composition: G-5 allowlist
→ verified 18+ (reads are free — the apply-rate window is the
WRITE budget, A-408's distinct-action rule; no new throttle knob,
D-088 untouched); no row → the honest 404 (the surface renders the
apply CTA)."""
_enforce_allowlist(learner_id, settings)
await require_verified_age(18, learner_id, _identity_store(request))
application = store.get_application_by_posting_learner(posting_id, learner_id)
if application is None:
raise HTTPException(
status_code=404,
detail=f"no application by this learner on posting {posting_id!r}",
)
return MineApplicationResponse(
application_id=application.id,
status=application.status,
)
@router.get(
"/applications/{application_id}",
response_model=LearnerApplicationStatusResponse,
)
async def learner_application_status(
application_id: str,
request: Request,
learner_id: str = Query(min_length=1),
store=Depends(get_marketplace_store),
settings: Settings = Depends(get_settings),
) -> LearnerApplicationStatusResponse:
"""D-104 (REQ-11-005): the learner's-own outcome read — current
status + the FULL id-ordered timestamped history for THIS
application + the placement record when hired (one read, one
truth source). Same gates as the mine-read (reads are free);
foreign learner_id → 403 (the scoped query never renders another
learner's events); unknown → 404."""
_enforce_allowlist(learner_id, settings)
await require_verified_age(18, learner_id, _identity_store(request))
application = store.get_application(application_id)
if application is None:
raise HTTPException(
status_code=404, detail=f"no application with id {application_id!r}"
)
if application.learner_id != learner_id:
raise HTTPException(
status_code=403,
detail="this application belongs to another learner",
)
from .deps import get_placement_store # local import — the territory rule
placements = get_placement_store(request)
events = [
PlacementEventRead.model_validate(event, from_attributes=True)
for event in placements.get_application_events(application_id)
]
row = placements.get_placement_by_application(application_id)
return LearnerApplicationStatusResponse(
application=LearnerApplicationSummary(
id=application.id,
posting_id=application.posting_id,
learner_id=application.learner_id,
status=application.status,
created_at=application.created_at,
),
events=events,
placement=_placement_read(row) if row is not None else None,
)
# -- P4-B: resume upload + confirm (REQ-6-005, D-054, A-410) --------------------
@@ -0,0 +1,330 @@
"""Operator lane for placement oversight (D-106, REQ-11-002).
`require_admin` IMPORTED from marketplace_admin (the billing_admin.py
import precedent — token namespaces cannot collide, D-090): one
generic remediation transition route + revoke + the R-80 re-hire +
the reporting/export reads (P3/P4 extend this router). No employer
session, no learner gate — a third principal class (D-097).
OPERATOR = "operator" route-level provenance (employers_admin.py:54 —
no per-operator accounts exist, D-094); every action is
ledger-recorded with actor_type="operator" (the D-083 manual-grant
lane) and answers in employer_audit_events via the P2 routes' own
appends (operator transitions record through the placement ledger).
"""
from __future__ import annotations
import json
from datetime import date
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Response
from ..config import Settings
from ..marketplace.store import MarketplaceStore
from ..placements.base import (
GUIDING_GOAL,
AdminTransitionRequest,
ApplicationTransitionResponse,
HireTransitionRequest,
PlacementOverview,
PlacementRead,
)
from ..placements.store import PlacementRow, PlacementStore
from .deps import get_marketplace_store, get_placement_store
from .marketplace_admin import require_admin
#: D-094: route-level operator provenance — no per-operator accounts.
OPERATOR = "operator"
router = APIRouter(prefix="/v1/placements/admin", tags=["placements-admin"])
# The admin-token gate composes on EVERY route handler via the
# Annotated[Settings, Depends(require_admin)] house pattern (the
# employers_admin idiom — fail-closed; token namespaces cannot collide
# with employer session tokens, D-090).
AdminGate = Annotated[Settings, Depends(require_admin)]
def _placement_read(row: PlacementRow) -> PlacementRead:
return PlacementRead(
id=row.id,
learner_id=row.learner_id,
employer_id=row.employer_id,
posting_id=row.posting_id,
application_id=row.application_id,
role_title=row.role_title,
start_date=date.fromisoformat(row.start_date),
status=row.status, # type: ignore[arg-type]
created_at=row.created_at or _now(),
decided_at=row.decided_at or _now(),
)
def _now():
from datetime import UTC, datetime
return datetime.now(UTC)
@router.post(
"/applications/{application_id}/transition",
response_model=ApplicationTransitionResponse,
)
async def admin_transition_application(
application_id: str,
body: AdminTransitionRequest,
_gate: AdminGate,
store: MarketplaceStore = Depends(get_marketplace_store),
placements: PlacementStore = Depends(get_placement_store),
) -> ApplicationTransitionResponse:
"""The generic operator remediation route (D-106): ANY matrix-legal
transition incl. withdrawn on a learner's behalf (remediation with
provenance, the D-083 lane). G-327: `to` is a CLOSED Literal —
unknown values die at pydantic (422), matrix-illegal known values
reach the store (409 InvalidTransition). role_title/start_date
are required iff to=hired (the honest 422 with required-fields
detail, D-103)."""
role_title: str | None = None
start_date: date | None = None
if body.to == "hired":
if body.role_title is None or body.start_date is None:
raise HTTPException(
status_code=422,
detail="role_title and start_date are required when to=hired (D-103)",
)
role_title, start_date = body.role_title, body.start_date
try:
application, placement = placements.transition_application(
application_id,
body.to,
actor_type="operator",
actor_ref=OPERATOR,
role_title=role_title,
start_date=start_date,
)
except Exception as exc:
# The placements InvalidTransition (409) MUST be matched before
# the ValueError arm — InvalidTransition subclasses ValueError.
from ..placements.base import InvalidTransition
if isinstance(exc, InvalidTransition):
raise HTTPException(status_code=409, detail=str(exc)) from exc
if isinstance(exc, ValueError):
raise HTTPException(status_code=422, detail=str(exc)) from exc
raise
return ApplicationTransitionResponse(
application_id=application.id,
status=application.status, # type: ignore[arg-type]
placement=_placement_read(placement) if placement is not None else None,
)
@router.post("/placements/{placement_id}/revoke", response_model=PlacementRead)
async def admin_revoke_placement(
placement_id: str,
_gate: AdminGate,
placements: PlacementStore = Depends(get_placement_store),
) -> PlacementRead:
"""D-102: the operator correction — the one active → revoked
placement transition (operator-only by route). Unknown → 404;
non-active → 409 (the matrix)."""
try:
revoked = placements.revoke_placement(placement_id, OPERATOR)
except Exception as exc:
from ..placements.base import InvalidTransition
if isinstance(exc, InvalidTransition):
raise HTTPException(status_code=409, detail=str(exc)) from exc
raise
if revoked is None:
raise HTTPException(
status_code=404, detail=f"no placement with id {placement_id!r}"
)
return _placement_read(revoked)
@router.post(
"/placements/by-application/{application_id}/re-hire",
response_model=PlacementRead,
)
async def admin_rehire_placement(
application_id: str,
body: HireTransitionRequest,
_gate: AdminGate,
placements: PlacementStore = Depends(get_placement_store),
) -> PlacementRead:
"""THE R-80 RE-HIRE ROUTE: after the first hire the application is
TERMINAL (hired → hired is a 409 on every matrix-validated
surface — the coherence gap research found). The re-hire creates
the FRESH PlacementRow + the placement.rehired ledger event
WITHOUT touching the application's terminal status — the ledger
reads hired → revoke → rehire as three events, never fabricating
a second application transition. Structurally safe: revoked rows
never match the partial WHERE status='active' (the G-316
property). G-326: decided_at = the re-hire event's stamp."""
try:
rehired = placements.rehire_placement(
application_id,
role_title=body.role_title,
start_date=body.start_date,
actor_ref=OPERATOR,
)
except Exception as exc:
from sqlalchemy.exc import IntegrityError
from ..placements.base import InvalidTransition
if isinstance(exc, InvalidTransition):
raise HTTPException(status_code=409, detail=str(exc)) from exc
if isinstance(exc, ValueError):
raise HTTPException(status_code=422, detail=str(exc)) from exc
if isinstance(exc, IntegrityError):
# the partial-unique arm: an ACTIVE placement already exists
raise HTTPException(
status_code=409,
detail="an active placement already exists for this "
"application — revoke it before re-hiring",
) from exc
raise
return _placement_read(rehired)
# -- the reporting read (v0.11 P3, Task 3-1-01 — REQ-11-003, D-107) ---------------
@router.get("/overview", response_model=PlacementOverview)
async def placement_overview(
_gate: AdminGate,
placements: PlacementStore = Depends(get_placement_store),
) -> PlacementOverview:
"""The D-107 computation — counts from ROWS at query time (never
denormalized counters, G-302/G-306): the guiding metric is DISTINCT
learners with ≥1 active placement, all-time cumulative toward
GUIDING_GOAL (a learner placed twice counts ONCE — the outcome's
noun is learners); revoked excluded from every count (D-102);
cohort month = the calendar month of start_date (YYYY-MM); orderings
pinned (R-73: by_employer count desc then employer_id;
by_cohort_month month ascending)."""
rows = placements.list_placements(status="active")
learner_ids = {row.learner_id for row in rows}
by_employer: dict[str, int] = {}
by_month: dict[str, int] = {}
for row in rows:
by_employer[row.employer_id] = by_employer.get(row.employer_id, 0) + 1
month = row.start_date[:7] # ISO YYYY-MM (the calendar slice)
by_month[month] = by_month.get(month, 0) + 1
return PlacementOverview(
total_active_placements=len(rows),
distinct_learners_placed=len(learner_ids),
guiding_goal=GUIDING_GOAL,
by_employer=[
{"employer_id": employer_id, "count": count}
for employer_id, count in sorted(
by_employer.items(), key=lambda kv: (-kv[1], kv[0])
)
],
by_cohort_month=[
{"month": month, "count": count}
for month, count in sorted(by_month.items())
],
)
# -- the audit export (v0.11 P4, Tasks 4-1-01/4-1-02 — REQ-11-004, D-108) ----------
@router.get("/export")
async def placement_export(
_gate: AdminGate,
placements: PlacementStore = Depends(get_placement_store),
) -> Response:
"""GET /v1/placements/admin/export — ONE self-contained JSON
document (D-108): {schema_version, generated_at, summary, cohort,
evidence_bundles (EVERY placement, active + revoked — G-325),
methodology (the G-321 statements + the three R-67 pilot gaps),
ledger (every event + head_hash)}. Determinism per R-73: pinned
orderings, no floats, generated_at excluded from comparison (the
re-export test strips it)."""
document = placements.build_export()
return Response(
content=json.dumps(document, sort_keys=True, separators=(",", ":")),
media_type="application/json",
)
@router.get("/export/summary")
async def placement_export_summary(
_gate: AdminGate,
placements: PlacementStore = Depends(get_placement_store),
) -> Response:
"""GET /v1/placements/admin/export/summary — text/markdown
rendered FROM the same JSON document (one builder, two
renderings — the anti-drift rule: the summary can never assert a
number the export doesn't contain). G-328: the title carries the
ATTESTATION language (employer-attested, operator-overseen) —
never a verification claim."""
document = placements.build_export()
summary = document["summary"]
methodology = document["methodology"]
head = document["ledger"]["head_hash"]
lines: list[str] = []
lines.append("# Placement Audit Export — Summary")
lines.append("")
lines.append(
"Employer-attested, operator-overseen placement records (NOT "
"third-party employment verification — see Methodology)."
)
lines.append("")
lines.append("## Totals")
lines.append("")
lines.append("| Metric | Count |")
lines.append("|---|---|")
lines.append(
f"| Distinct learners placed (toward {GUIDING_GOAL}) "
f"| {summary['distinct_learners_placed']} |"
)
lines.append(f"| Total active placements | {summary['total_active_placements']} |")
lines.append(f"| Revoked placements | {summary['revoked_count']} |")
lines.append("")
lines.append("## By cohort month (employment start)")
lines.append("")
lines.append("| Month | Placements |")
lines.append("|---|---|")
for bucket in summary["by_cohort_month"]:
lines.append(f"| {bucket['month']} | {bucket['count']} |")
if not summary["by_cohort_month"]:
lines.append("| (none recorded) | 0 |")
lines.append("")
lines.append("## Methodology")
lines.append("")
lines.append(f"### Placement definition\n\n{methodology['placement_definition']}\n")
lines.append(f"### Counting rules\n\n{methodology['counting_rules']}\n")
lines.append(f"### Verification\n\n{methodology['verification']}\n")
lines.append(f"### Scope\n\n{methodology['scope']}\n")
lines.append("## Verifying the export offline")
lines.append("")
lines.append(
"Every ledger event is embedded in the JSON export. Re-walk the "
"chain: for each event in id order, compute "
"`sha256(canonical_json + prev_hash)` where canonical_json is "
"the event's {id, application_id, placement_id, event_type, "
"actor_type, actor_ref, payload, created_at} serialized with "
"sorted keys and compact separators (created_at in UTC ISO-8601), "
"and prev_hash starts at the all-zeros GENESIS constant. The "
"final computed hash must equal head_hash:"
)
lines.append("")
lines.append(f"`head_hash: {head}`")
lines.append("")
lines.append(
"Then compare against a PRIOR export: its ledger.events must "
"appear as an exact prefix of this export's events, and its "
"head_hash must equal the hash at that prefix position "
"(export-to-export continuity is the anchor against wholesale "
"database replacement — see Verification)."
)
return Response(
content="\n".join(lines),
media_type="text/markdown",
)
@@ -64,7 +64,9 @@ _TABLES = (
#: Audit event vocabulary: `auth` = the Wave 1-2 register/logout
#: account events; `claim` = P2's claim/decide entity events; `manual`
#: = operator action provenance (the billing kind set, employer-side).
_AUDIT_EVENT_KINDS = ("auth", "claim", "manual")
#: v0.11 P2: "application" joins the vocabulary (employer-driven hiring
#: transitions append their audit row — the D-089 one-queryable lane).
_AUDIT_EVENT_KINDS = ("auth", "claim", "manual", "application")
class AccountRow(SQLModel, table=True):
+21
View File
@@ -199,6 +199,18 @@ def create_app(settings: Settings | None = None) -> FastAPI:
db_path=settings.db_path
)
# Placement tracking (v0.11, REQ-11-001): 9th D-027 store
# family on the same SQLite file (D-100: the transactional
# transition writes the marketplace application row through the
# shared metadata — one file, one commit). State-injection
# override preserved (the same getattr-guard template).
if getattr(app.state, "placement_store", None) is None:
from .placements.store import SQLitePlacementStore
app.state.placement_store = SQLitePlacementStore(
db_path=settings.db_path
)
# Payment provider (v0.9 P3, REQ-9-003, D-075): mock-first
# default; stripe over the shared httpx pool. G-11 (boot
# survival): a misconfigured real provider must never crash the
@@ -334,6 +346,7 @@ def create_app(settings: Settings | None = None) -> FastAPI:
identity_store.close()
app.state.billing_store.close()
app.state.employer_store.close()
app.state.placement_store.close()
await app.state.http_client.aclose()
app = FastAPI(title="Nextcraft AI Service", version=__version__, lifespan=lifespan)
@@ -410,6 +423,14 @@ def create_app(settings: Settings | None = None) -> FastAPI:
# accounts/audit operator routes — require_admin (fail-closed).
app.include_router(employers_admin_router)
# v0.11 placements (REQ-11-002, D-106): the operator lane — the
# generic transition + revoke + the R-80 re-hire; the reporting/
# export reads extend this router in P3/P4. require_admin
# composed at the router level (fail-closed, D-090 no-collision).
from .api.placements_admin import router as placements_admin_router
app.include_router(placements_admin_router)
# v0.6 P5 (REQ-6-006, D-056): dynamic sitemap/robots — request-time
# enumeration (build-time Next sitemap.ts cannot see runtime
# postings under output:'export'); API routes beat the static mount.
@@ -346,6 +346,33 @@ class MarketplaceStore(Protocol):
— never a second application."""
...
def get_application(self, application_id: str) -> Application | None:
"""Point lookup by application id (D-100: the placements
store's transition path reads the row through the shared
metadata; this read serves routes + tests — the get_posting
shape)."""
...
def get_application_by_posting_learner(
self, posting_id: str, learner_id: str
) -> Application | None:
"""v0.11 P5 (G-322, binding): the mine point-read over the
(posting_id, learner_id) unique key — the status panel's
application DISCOVERY on revisit; None = no application (the
surface renders the apply CTA — never client-persisted
application_ids)."""
...
def list_applications_by_postings(
self, posting_ids: list[str], status: str | None = None
) -> list[Application]:
"""v0.11 P2 (D-105/G-319): the employer pipeline's ONE batched
read — every application on the given postings (the caller
scopes to the session's OWN claimed-entity postings first;
this read never enumerates beyond them), created_at DESC then
id; optional status facet."""
...
def list_applications(
self, posting_id: str | None = None
) -> list[Application]:
@@ -900,6 +927,54 @@ class SQLiteMarketplaceStore:
session.refresh(row)
return row.to_application(), True
def get_application(self, application_id: str) -> Application | None:
"""Point lookup by application id (D-100, the get_posting shape)."""
with Session(self._engine) as session:
row = session.get(ApplicationRow, application_id)
if row is None:
return None
session.refresh(row)
return row.to_application()
def get_application_by_posting_learner(
self, posting_id: str, learner_id: str
) -> Application | None:
"""v0.11 P5 (G-322): the mine point-read — one row over the
insert_application unique key (the get_posting point-read
discipline)."""
with Session(self._engine) as session:
row = session.exec(
select(ApplicationRow).where(
ApplicationRow.posting_id == posting_id,
ApplicationRow.learner_id == learner_id,
)
).one_or_none()
if row is None:
return None
session.refresh(row)
return row.to_application()
def list_applications_by_postings(
self, posting_ids: list[str], status: str | None = None
) -> list[Application]:
"""v0.11 P2 (D-105/G-319): ONE batched read over the given
postings — created_at DESC then id; optional status facet.
Empty posting set → empty list (the honest no-postings case)."""
if not posting_ids:
return []
with Session(self._engine) as session:
stmt = select(ApplicationRow).where(
col(ApplicationRow.posting_id).in_(posting_ids)
)
if status is not None:
stmt = stmt.where(ApplicationRow.status == status)
rows = session.exec(
stmt.order_by(
col(ApplicationRow.created_at).desc(), ApplicationRow.id
)
).all()
return [r.to_application() for r in rows]
def list_applications(
self, posting_id: str | None = None
) -> list[Application]:
@@ -0,0 +1,380 @@
"""Placement contracts: the D-099 hiring-state matrix, the D-101
event vocabulary + GENESIS, and the placement wire shapes (REQ-11-001).
Boundary (D-031 family): `placements/` never imports `api/`; stdlib +
pydantic + the marketplace WIRE contracts only. The store imports the
marketplace ROW (the sanctioned D-100 cross-family seam — one
transaction covers both tables on the shared DB file).
Id honesty (A-305, the family rule): every id in this module is an
opaque handle minted UNIQUELY per creation call — never derived from
user input or a deterministic hash of it.
"""
from __future__ import annotations
from datetime import date, datetime
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field
#: D-099 application status vocabulary (snake_case, the billing
#: `past_due` precedent): the hiring lifecycle. `submitted` is the
#: v0.6 status (zero migration); hired/rejected/withdrawn are TERMINAL.
ApplicationStatus = Literal[
"submitted",
"in_review",
"interview",
"offer",
"hired",
"rejected",
"withdrawn",
]
#: Tuple mirror of the Literal (docs + tests import this).
APPLICATION_STATUSES: tuple[ApplicationStatus, ...] = (
"submitted",
"in_review",
"interview",
"offer",
"hired",
"rejected",
"withdrawn",
)
#: Terminal statuses — no outgoing edges (D-099).
TERMINAL_APPLICATION_STATUSES: tuple[ApplicationStatus, ...] = (
"hired",
"rejected",
"withdrawn",
)
#: D-099 transition matrix (the billing/store.py `_VALID_TRANSITIONS`
#: shape): forward-only WITH skip-forward — from any non-terminal
#: status, any LATER stage and any terminal are legal (a fast-track
#: `submitted → hired` is a legal hire — CIRR counts the hire, not the
#: ceremony); NO backward moves; terminals have empty tuples.
TRANSITION_MATRIX: dict[str, tuple[str, ...]] = {
"submitted": (
"in_review",
"interview",
"offer",
"hired",
"rejected",
"withdrawn",
),
"in_review": ("interview", "offer", "hired", "rejected", "withdrawn"),
"interview": ("offer", "hired", "rejected", "withdrawn"),
"offer": ("hired", "rejected", "withdrawn"),
"hired": (),
"rejected": (),
"withdrawn": (),
}
#: Placement lifecycle (D-102): created `active`; the ONLY transition
#: is the operator correction `active → revoked`. Revoked rows stay in
#: history (the partial unique index never touches them — re-hire
#: creates a fresh row, R-80).
PlacementStatus = Literal["active", "revoked"]
PLACEMENT_STATUSES: tuple[PlacementStatus, ...] = ("active", "revoked")
#: D-101 actor vocabulary (the billing `_BILLING_EVENT_KINDS` shape).
ACTOR_TYPES: tuple[str, ...] = ("employer", "learner", "operator", "system")
#: D-101 event-name vocabulary: application-stage events (one per
#: transition — NO backfill: pre-v0.11 `submitted` rows carry only
#: their created_at, never a fabricated event, A-304) + the placement
#: lane (created on hired, revoked on the D-102 correction, rehired
#: on the R-80 operator re-hire).
EVENT_APPLICATION_TRANSITION: dict[str, str] = {
"in_review": "application.in_review",
"interview": "application.interview",
"offer": "application.offer",
"hired": "application.hired",
"rejected": "application.rejected",
"withdrawn": "application.withdrawn",
}
EVENT_PLACEMENT_CREATED = "placement.created"
EVENT_PLACEMENT_REVOKED = "placement.revoked"
EVENT_PLACEMENT_REHIRED = "placement.rehired"
EVENT_NAMES: tuple[str, ...] = (
*EVENT_APPLICATION_TRANSITION.values(),
EVENT_PLACEMENT_CREATED,
EVENT_PLACEMENT_REVOKED,
EVENT_PLACEMENT_REHIRED,
)
#: D-101: the chain's genesis — the first event's prev_hash. A module
#: constant, test-pinned (never a row — the walk starts here).
GENESIS_HASH = "0" * 64
class InvalidTransition(ValueError):
"""A matrix-illegal application status move (D-099) or placement
status move (D-102) — the route's honest 409."""
def is_terminal(status: str) -> bool:
"""True when the application status has no outgoing edges."""
return status in TERMINAL_APPLICATION_STATUSES
def validate_transition(from_status: str, to_status: str) -> None:
"""D-099 matrix validation — raises InvalidTransition on any
illegal move (backward, terminal-source, unknown status)."""
if from_status not in TRANSITION_MATRIX:
raise InvalidTransition(f"unknown application status {from_status!r}")
if to_status not in APPLICATION_STATUSES:
raise InvalidTransition(f"unknown application status {to_status!r}")
if to_status not in TRANSITION_MATRIX[from_status]:
legal = TRANSITION_MATRIX[from_status] or "(terminal)"
raise InvalidTransition(
f"illegal transition {from_status!r} → {to_status!r} "
f"(legal: {legal})"
)
class HireTransitionRequest(BaseModel):
"""The hire body (D-103/G-324): role_title is employer-attested
free text — never silently the posting title — with the house
Field bounds (empty-string → 422); start_date is the employment
start DATE (first day of work), never a datetime: timezone-free by
construction, future + past allowed (D-103)."""
role_title: str = Field(min_length=1, max_length=200)
start_date: date
class PlacementRead(BaseModel):
"""A placement record over the wire (the PlacementRow read shape).
employer/learner/posting are ID REFERENCES (G-302) — resolved from
source rows at read/export, never trusted denormalized copies."""
model_config = ConfigDict(frozen=True)
id: str = Field(min_length=1)
learner_id: str = Field(min_length=1)
employer_id: str = Field(min_length=1)
posting_id: str = Field(min_length=1)
application_id: str = Field(min_length=1)
role_title: str = Field(min_length=1)
start_date: date
status: PlacementStatus
created_at: datetime
decided_at: datetime
class PlacementEventRead(BaseModel):
"""One ledger event over the wire (the PlacementEventRow read
shape) — application-stage events carry application_id with
placement_id None; the placement lane carries placement_id set."""
model_config = ConfigDict(frozen=True)
id: int
application_id: str | None = None
placement_id: str | None = None
event_type: str = Field(min_length=1)
actor_type: str = Field(min_length=1)
actor_ref: str = Field(min_length=1)
payload: dict[str, Any] = Field(default_factory=dict)
prev_hash: str = Field(min_length=1)
hash: str = Field(min_length=1)
created_at: datetime
class EmployerApplicationSummary(BaseModel):
"""An employer-facing application row (D-105/G-319): the pipeline
list + per-application reads. learner_id IS visible here — the
G-319 supersession scopes it to applications on the session's OWN
claimed-entity postings (the cross-entity arm 403s carry no data;
the own-postings LIST stays counts-only — the pin split)."""
model_config = ConfigDict(frozen=True)
id: str = Field(min_length=1)
posting_id: str = Field(min_length=1)
learner_id: str = Field(min_length=1)
status: ApplicationStatus
created_at: datetime | None = None
class EmployerApplicationsResponse(BaseModel):
"""GET /v1/employers/applications response: own-pipeline
applications (every status) + per-status facet counts (every
status key ALWAYS present — a zero-count status carries its
honest 0)."""
model_config = ConfigDict(frozen=True)
applications: list[EmployerApplicationSummary] = Field(default_factory=list)
facets: dict[str, int] = Field(default_factory=dict)
total: int = 0
class ApplicationTransitionResponse(BaseModel):
"""The transition result (per-target routes): the application's
new status + the placement record when the transition created
one (hire)."""
model_config = ConfigDict(frozen=True)
application_id: str = Field(min_length=1)
status: ApplicationStatus
placement: PlacementRead | None = None
class AdminTransitionRequest(BaseModel):
"""POST /v1/placements/admin/applications/{id}/transition body
(D-106/G-327): `to` is a CLOSED Literal of the seven D-099
statuses — unknown values die at pydantic (422), matrix-illegal
known values reach the store (409 InvalidTransition); the route
never sees a status outside the vocabulary. role_title/start_date
are required iff to=hired (enforced at the route, the honest 422
with required-fields detail)."""
model_config = ConfigDict(frozen=True)
to: ApplicationStatus
role_title: str | None = Field(default=None, min_length=1, max_length=200)
start_date: date | None = None
#: D-107: the guiding outcome's north star (PROJECT.md line 7 — the
#: noun is LEARNERS, not placements).
GUIDING_GOAL = 1000
#: D-108/G-321/G-325 (R-67/R-68): the export's methodology block —
#: written from in-tree facts, stating its own three pilot gaps
#: explicitly (an audit methodology that omits its own verification
#: limits is A-304-class dishonesty) AND the chain's proof scope
#: (G-321: what it proves, what it does not — wholesale DB replacement
#: is undetectable by the walk alone; export-to-export continuity is
#: the auditor's anchor — plus the event↔row cross-consistency check).
#: CIRR kept as naming, not citation — the text stands on its own
#: definitions (R-67).
EXPORT_METHODOLOGY: dict[str, Any] = {
"placement_definition": (
"A placement is a `placement` row created by the hired transition "
"of a marketplace application, with an employer-attested role_title "
"and start_date (the employment start DATE, D-103), status=active "
"at export time. One ACTIVE placement per application is "
"structurally enforced (the partial unique index); revoked "
"placements stay in history and are excluded from every count."
),
"counting_rules": (
"The guiding metric is DISTINCT learners with at least one active "
"placement, all-time cumulative toward the 1,000-learner guiding "
"goal (a learner placed twice counts ONCE — the outcome's noun is "
"learners, not placements). Revoked placements are excluded from "
"every count. Cohort month is the calendar month of start_date "
"(YYYY-MM) — the employment-start month, not a graduation cohort "
"(no graduation events exist in a marketplace; this divergence "
"from CIRR cohort convention is deliberate and documented)."
),
"verification": (
"WHAT THE CHAIN PROVES: the global sha256 ledger (events ordered "
"by id, each hash covering the canonical JSON of its row + the "
"prior event's hash, from the GENESIS constant to the head) "
"detects in-place mutation of any event row and makes a chain "
"fork structurally impossible (UNIQUE(prev_hash)). WHAT IT DOES "
"NOT PROVE: a wholesale replacement of the database (delete every "
"event, re-append a fresh plausible chain from GENESIS) walks "
"green — the walk alone cannot detect it. THE AUDITOR'S ANCHOR "
"is export-to-export continuity: a prior export's ledger.events "
"must appear as an exact PREFIX of a later export's events, and "
"the prior export's head_hash must equal the hash at that prefix "
"position in the later export. EVENT↔ROW CROSS-CONSISTENCY: "
"every active placement must have a placement.created event; "
"every revoked placement a placement.revoked event; summary."
"revoked_count must equal the number of placement.revoked "
"events. EVIDENCE CLASS: verification is employer-principal "
"attestation (an authenticated hire by a claim-verified, "
"operator-approved employer principal) plus operator oversight — "
"recorded decisions, not third-party employment verification."
),
"scope": (
"PILOT LIMITS, stated explicitly (R-67): (a) no minimum-employed-"
"duration check — a placement here is a RECORDED HIRE, not a "
"90-day sustained role; (b) no salary or compensation tracking "
"(out of scope by decision D-098); (c) verification is employer-"
"attested with operator oversight, NOT third-party employment "
"verification — no external auditor integration exists at "
"pilot; this export IS the third-party-auditor deliverable. "
"Coverage: every placement in the database, unbounded by date. "
"CIRR-style describes this export's format; the definitions "
"above are the operative ones."
),
}
class PlacementOverview(BaseModel):
"""GET /v1/placements/admin/overview response (D-107): counts
computed from rows at query time — NEVER denormalized counters
(G-302/G-306). The guiding metric is DISTINCT learners with ≥1
active placement (a learner placed twice counts ONCE); revoked
excluded from every count; cohort month = the calendar month of
start_date (YYYY-MM, employment-start month — not hired month)."""
model_config = ConfigDict(frozen=True)
total_active_placements: int = 0
distinct_learners_placed: int = 0
guiding_goal: int = GUIDING_GOAL
by_employer: list[dict[str, Any]] = Field(default_factory=list)
by_cohort_month: list[dict[str, Any]] = Field(default_factory=list)
class EmployerPlacementsResponse(BaseModel):
"""GET /v1/employers/placements response (D-109 surface 2's data
source): the claimed entity's OWN placements (active + revoked —
the employer sees their full history), created_at then id."""
model_config = ConfigDict(frozen=True)
placements: list[PlacementRead] = Field(default_factory=list)
total: int = 0
class LearnerApplicationSummary(BaseModel):
"""The application row on the learner status read (D-104,
REQ-11-005): the mine-visible fields only — ids + status + the
apply timestamp (nullable per the v0.6 row shape — honest
absence); references resolve at render (G-302), never
denormalized copies."""
model_config = ConfigDict(frozen=True)
id: str = Field(min_length=1)
posting_id: str = Field(min_length=1)
learner_id: str = Field(min_length=1)
status: ApplicationStatus
created_at: datetime | None = None
class LearnerApplicationStatusResponse(BaseModel):
"""GET /v1/marketplace/applications/{application_id}?learner_id=
response (REQ-11-005, D-104): current status + the FULL id-ordered
timestamped event history for THIS application (a scoped query —
never another learner's events) + the placement record when hired
(the honest null otherwise — never a fabricated placement, A-304)."""
model_config = ConfigDict(frozen=True)
application: LearnerApplicationSummary
events: list[PlacementEventRead] = Field(default_factory=list)
placement: PlacementRead | None = None
class MineApplicationResponse(BaseModel):
"""GET /v1/marketplace/applications/mine?posting_id=&learner_id=
response (G-322, binding): {application_id, status} — the status
panel's application DISCOVERY read on revisit; 404 = no application
on this posting (the surface renders the apply CTA — the panel
NEVER depends on client-persisted application_ids)."""
model_config = ConfigDict(frozen=True)
application_id: str = Field(min_length=1)
status: ApplicationStatus
@@ -0,0 +1,912 @@
"""Placement persistence — the 9th D-027 store (REQ-11-001, D-112).
TWO tables on the shared DB file (R-72): `placement` (immutable at
creation except status — D-102) + `placement_event` (the append-only
GLOBAL hash chain — D-101). Mechanics mirror employers/store.py
exactly (pragmas, _validate/_normalize, Protocol + SQLite pair,
surfaced IntegrityError, _constraint_error helper).
NO DELETE PATH (R-70, binding): this module contains NO delete method
and NO DELETE SQL — deleting the top ledger row would make SQLite's
INTEGER PRIMARY KEY reuse max+1 and break the chain. Pinned by test.
STATUS-COLUMN OWNERSHIP (R-78, binding): the marketplace store writes
`application.status` EXACTLY ONCE at creation (insert_application);
THIS store is the ONLY post-creation writer — via the ONE
`transition_application` method, never a second append path (D-100:
the status write + the ledger event + the placement-on-hired land in
ONE transaction; a two-commit alternative leaves a crash window that
voids the tamper-evidence this milestone sells).
Canonical serialization (D-101/R-69/R-79, binding): the hash covers
the canonical JSON of {id, application_id, placement_id, event_type,
actor_type, actor_ref, payload, created_at} + prev_hash — sorted keys,
compact separators, created_at ALWAYS `_as_utc(dt).isoformat()` (ONE
pinned normalization: naive-in-SQLite vs aware-on-read must never
drift), payload values str/int/bool/None only (floats REJECTED —
R-69).
"""
from __future__ import annotations
import hashlib
import json
import sqlite3
import uuid
from datetime import UTC, date, datetime
from pathlib import Path
from typing import Any, Protocol
from sqlalchemy import Index, event, func, text
from sqlalchemy.exc import IntegrityError
from sqlalchemy.types import JSON, String
from sqlmodel import Field, Session, SQLModel, col, create_engine, select
from ..config import Settings
from ..marketplace.store import ApplicationRow
from .base import (
ACTOR_TYPES,
EVENT_APPLICATION_TRANSITION,
EVENT_PLACEMENT_CREATED,
EVENT_PLACEMENT_REHIRED,
EVENT_PLACEMENT_REVOKED,
EXPORT_METHODOLOGY,
GENESIS_HASH,
InvalidTransition,
validate_transition,
)
_PLACEMENT_ID_PREFIX = "plc-"
def _as_utc(ts: datetime) -> datetime:
"""SQLite stores naive; read paths re-label tz-aware UTC (D-027
pattern). Aware NON-UTC inputs CONVERT to the true instant (the
security review's P0-1: re-labeling preserves the offset — an
append-time canonical form of 12:00-04:00 would re-read as
12:00+00:00 and permanently break the chain, with no delete path
to recover)."""
if ts.tzinfo is None:
return ts.replace(tzinfo=UTC)
return ts.astimezone(UTC)
def _constraint_error(message: str, params: dict[str, Any]) -> IntegrityError:
"""Hand-raised IntegrityError (the guard arms) — the caller's
honest 409, never a silent overwrite."""
return IntegrityError(message, params, ValueError(message))
def _sqlite_connect(dbapi_connection: sqlite3.Connection, _: object) -> None:
"""Per-connection pragmas — mirrors the other D-027 stores."""
cursor = dbapi_connection.cursor()
cursor.execute("PRAGMA journal_mode=WAL")
cursor.execute("PRAGMA synchronous=NORMAL")
cursor.execute("PRAGMA busy_timeout=5000")
cursor.execute("PRAGMA foreign_keys=ON")
cursor.close()
def _check_payload(value: Any) -> None:
"""R-69: payload values are str/int/bool/None ONLY — floats (and
any nested container) are rejected at the store boundary: float
representation is a canonical-serialization drift the chain must
never depend on."""
if isinstance(value, bool) or value is None:
return
if isinstance(value, str):
return
if isinstance(value, int):
return
raise ValueError(
f"payload values must be str/int/bool/None, got {type(value).__name__}"
)
class PlacementRow(SQLModel, table=True):
"""A placement record (D-102): created by the hired transition,
IMMUTABLE except `status` (exactly one transition active → revoked,
operator-only by route). role_title/start_date/decided_at and the
four ID references are set once and NEVER rewritten — a silent
rewrite is exactly the tamper the ledger exists to detect.
G-316 shape: the partial UNIQUE index uq_active_placement_per_
application makes one-ACTIVE-placement-per-application a
STRUCTURAL guard — revoked rows stay in history and never match
the partial WHERE, so a re-hire after a mistaken revoke creates a
fresh row (R-80) and an operator fat-finger never permanently
erases a learner from the ≥1,000 count.
"""
__tablename__ = "placement"
__table_args__ = (
Index(
"uq_active_placement_per_application",
"application_id",
unique=True,
sqlite_where=text("status='active'"),
),
)
id: str = Field(primary_key=True, sa_type=String)
learner_id: str = Field(index=True, sa_type=String)
employer_id: str = Field(sa_type=String)
#: G-302: ID references resolved from source rows at read/export.
posting_id: str = Field(sa_type=String)
application_id: str = Field(index=True, sa_type=String)
role_title: str = Field(sa_type=String)
#: D-103: ISO YYYY-MM-DD — a calendar date, no datetime coercion.
start_date: str = Field(sa_type=String)
status: str = Field(default="active", sa_type=String)
created_at: datetime | None = Field(default=None)
#: D-102: the hired transition's store-stamped created_at (G-326:
#: the re-hire arm stamps the RE-HIRE event's created_at).
decided_at: datetime | None = Field(default=None)
def _validate(self) -> None:
if not self.id:
raise ValueError("id must be non-empty")
for field_name in (
"learner_id",
"employer_id",
"posting_id",
"application_id",
"role_title",
"start_date",
):
if not getattr(self, field_name):
raise ValueError(f"{field_name} must be non-empty")
if self.status not in ("active", "revoked"):
raise ValueError(f"invalid placement status {self.status!r}")
def _normalize(self) -> None:
self.created_at = _as_utc(self.created_at) if self.created_at else None
self.decided_at = _as_utc(self.decided_at) if self.decided_at else None
class PlacementEventRow(SQLModel, table=True):
"""One append-only ledger event (D-101): ONE GLOBAL chain ordered
by autoincrement id. Each row stores prev_hash (the prior event's
hash; GENESIS_HASH for the first) + its own hash = sha256 over the
canonical JSON + prev_hash. UNIQUE(prev_hash) makes a chain fork
structurally impossible — two events claiming the same
predecessor surface IntegrityError (the G-316 backstop class).
Application-stage events carry application_id (placement_id
None); the placement lane (created/revoked/rehired) carries
placement_id set from hired onward.
"""
__tablename__ = "placement_event"
id: int | None = Field(default=None, primary_key=True)
application_id: str | None = Field(default=None, index=True, sa_type=String)
placement_id: str | None = Field(default=None, sa_type=String)
event_type: str = Field(sa_type=String)
actor_type: str = Field(sa_type=String)
actor_ref: str = Field(sa_type=String)
payload: dict[str, Any] = Field(default_factory=dict, sa_type=JSON)
prev_hash: str = Field(unique=True, sa_type=String)
hash: str = Field(default="", sa_type=String)
created_at: datetime | None = Field(default=None)
def _validate(self) -> None:
if self.id is not None and self.id < 1:
raise ValueError("id must be a positive int when set")
if not self.event_type:
raise ValueError("event_type must be non-empty")
if self.actor_type not in ACTOR_TYPES:
raise ValueError(f"unknown actor_type {self.actor_type!r}")
if not self.actor_ref:
raise ValueError("actor_ref must be non-empty")
if not self.prev_hash:
raise ValueError("prev_hash must be non-empty")
for value in self.payload.values():
_check_payload(value)
def _normalize(self) -> None:
self.created_at = _as_utc(self.created_at) if self.created_at else None
def canonical_json(self) -> str:
"""D-101/R-79: the canonical serialization — sorted keys,
compact separators, created_at ALWAYS `_as_utc(dt).isoformat()`
(ONE pinned normalization; the hashing and reading paths share
this function so they can never drift)."""
return json.dumps(
{
"id": self.id,
"application_id": self.application_id,
"placement_id": self.placement_id,
"event_type": self.event_type,
"actor_type": self.actor_type,
"actor_ref": self.actor_ref,
"payload": self.payload,
"created_at": (
_as_utc(self.created_at).isoformat()
if self.created_at is not None
else None
),
},
sort_keys=True,
separators=(",", ":"),
)
def _compute_hash(canonical_json: str, prev_hash: str) -> str:
"""sha256 over the canonical JSON + prev_hash (D-101 — the D-087
stdlib sha256 precedent; hexdigest)."""
return hashlib.sha256((canonical_json + prev_hash).encode("utf-8")).hexdigest()
class PlacementStore(Protocol):
"""Persistence contract for placements (D-101/D-102/D-112)."""
def create_schema(self) -> None:
"""Ensure both tables exist (idempotent — shared DB file)."""
...
def transition_application(
self,
application_id: str,
to_status: str,
actor_type: str,
actor_ref: str,
role_title: str | None = None,
start_date: date | None = None,
now: datetime | None = None,
) -> tuple[ApplicationRow, PlacementRow | None]:
"""D-100: the ONE transactional transition — update the
marketplace application row + append the ledger event + create
the PlacementRow on hired, ALL IN ONE COMMIT. One append per
transaction per lane (transition/revoke/rehire each append
exactly once); R-70: the appends race nothing — the chain's
prev_hash read-then-write is single-threaded-safe through
SQLite's writer serialization + the UNIQUE backstop."""
...
def verify_chain(self) -> tuple[bool, str]:
"""Recompute the walk from GENESIS_HASH → head; (ok, head_hash)."""
...
def get_placement(self, placement_id: str) -> PlacementRow | None:
...
def get_placement_by_application(
self, application_id: str
) -> PlacementRow | None:
"""v0.11 P5 (REQ-11-005): the learner status read's placement
arm — the ACTIVE placement on this application (the G-316
partial-unique shape: at most one; revoked-only history →
None, the honest null — the events carry the correction)."""
...
def list_placements(self, status: str | None = None) -> list[PlacementRow]:
...
def get_application_events(self, application_id: str) -> list[PlacementEventRow]:
...
def list_events(self) -> list[PlacementEventRow]:
...
def revoke_placement(
self, placement_id: str, actor_ref: str, now: datetime | None = None
) -> PlacementRow | None:
"""D-102: the one status-only mutation active → revoked + the
placement.revoked event, one transaction (operator-only by
route)."""
...
def rehire_placement(
self,
application_id: str,
role_title: str,
start_date: date,
actor_ref: str,
now: datetime | None = None,
) -> PlacementRow:
"""R-80/G-326: a FRESH PlacementRow + placement.rehired event
WITHOUT touching the application's terminal status — the
correction lane. decided_at = the re-hire event's created_at.
An existing ACTIVE placement on the application surfaces its
IntegrityError (the caller's honest 409)."""
...
def list_placements_by_employer(
self, employer_id: str
) -> list[PlacementRow]:
"""v0.11 P3 (D-109): the claimed entity's OWN placements
(active + revoked — the employer sees their full history),
created_at then id (the deterministic order)."""
...
def build_export(self, now: datetime | None = None) -> dict[str, Any]:
"""v0.11 P4 (D-108/G-321/G-325): the self-contained audit
document — {schema_version, generated_at, summary, cohort,
evidence_bundles, methodology, ledger} — resolved from SOURCE
ROWS at export time (G-302 snapshot-at-export). The summary
route renders FROM this dict (one truth source)."""
...
def close(self) -> None:
...
class SQLitePlacementStore:
"""SQLite implementation of PlacementStore (D-027/D-112)."""
def __init__(self, db_path: Path | None = None) -> None:
self._db_path: Path = db_path if db_path is not None else Settings().db_path
self._engine = create_engine(
f"sqlite:///{self._db_path}",
connect_args={"check_same_thread": False},
)
event.listen(self._engine, "connect", _sqlite_connect)
self.create_schema()
def create_schema(self) -> None:
"""Ensure both tables exist (idempotent — shared DB file)."""
SQLModel.metadata.create_all(self._engine)
# -- chain core (D-101/R-70) -------------------------------------------
@staticmethod
def _chain_head(session: Session) -> tuple[int | None, str]:
"""(last event id, its hash) or (None, GENESIS_HASH) — the
next append's prev_hash source."""
row = session.exec(
select(PlacementEventRow).order_by(col(PlacementEventRow.id).desc())
).first()
if row is None or row.hash == "":
return None, GENESIS_HASH
return row.id, row.hash
@staticmethod
def _append_event(
session: Session,
*,
application_id: str | None,
placement_id: str | None,
event_type: str,
actor_type: str,
actor_ref: str,
payload: dict[str, Any] | None,
prev_hash: str,
created_at: datetime,
) -> PlacementEventRow:
"""R-70: flush-then-hash-then-commit — the row's own
autoincrement id participates in its own hash, so the append
flushes (id assigned), computes the hash, sets the column, and
flushes again. The CALLER owns the transaction (D-100: the
status write + the events + the placement land in one commit);
this helper never commits."""
row = PlacementEventRow(
application_id=application_id,
placement_id=placement_id,
event_type=event_type,
actor_type=actor_type,
actor_ref=actor_ref,
payload=dict(payload or {}),
prev_hash=prev_hash,
hash="",
created_at=created_at,
)
row._validate()
row._normalize()
session.add(row)
# flush #1: the autoincrement id is assigned
session.flush()
assert row.id is not None # flush assigns it
# the hash covers the canonical JSON + prev_hash
row.hash = _compute_hash(row.canonical_json(), row.prev_hash)
# flush #2: the hash column lands
session.flush()
return row
def verify_chain(self) -> tuple[bool, str]:
"""Recompute the walk GENESIS_HASH → head (R-70: O(n) sha256
over tiny rows — milliseconds at pilot scale)."""
with Session(self._engine) as session:
prev = GENESIS_HASH
last_hash = GENESIS_HASH
rows = session.exec(select(PlacementEventRow).order_by(col(PlacementEventRow.id))).all()
for row in rows:
row._normalize()
expected = _compute_hash(row.canonical_json(), prev)
if row.prev_hash != prev or row.hash != expected:
return False, last_hash
prev = row.hash
last_hash = row.hash
return True, last_hash
# -- the ONE transition (D-100) ------------------------------------------
def transition_application(
self,
application_id: str,
to_status: str,
actor_type: str,
actor_ref: str,
role_title: str | None = None,
start_date: date | None = None,
now: datetime | None = None,
) -> tuple[ApplicationRow, PlacementRow | None]:
"""D-100: ONE Session on the placements engine covering BOTH
tables + the marketplace application row (the sanctioned
cross-family write — R-72: global SQLModel metadata means the
placements engine sees ApplicationRow; both stores share the
one SQLite file, so one commit covers everything atomically,
the decide_claim all-or-nothing precedent).
R-78: this is the ONLY post-creation writer of
application.status — never a second caller.
"""
moment = _as_utc(now) if now is not None else datetime.now(UTC)
with Session(self._engine) as session:
application = session.get(ApplicationRow, application_id)
if application is None:
raise InvalidTransition(f"unknown application {application_id!r}")
validate_transition(application.status, to_status)
if to_status == "hired":
if not role_title:
raise ValueError("role_title is required on hire (D-103)")
if start_date is None:
raise ValueError("start_date is required on hire (D-103)")
# the status write (the only post-creation writer — R-78);
# the pre-transition status is captured BEFORE the mutation
from_status = application.status
application.status = to_status
application._normalize()
prev_id, prev_hash = self._chain_head(session)
payload: dict[str, Any] = {"from_status": from_status, "to_status": to_status}
self._append_event(
session,
application_id=application_id,
placement_id=None,
event_type=EVENT_APPLICATION_TRANSITION[to_status],
actor_type=actor_type,
actor_ref=actor_ref,
payload=payload,
prev_hash=prev_hash,
created_at=moment,
)
placement: PlacementRow | None = None
if to_status == "hired":
assert role_title is not None and start_date is not None
placement = PlacementRow(
id=f"{_PLACEMENT_ID_PREFIX}{uuid.uuid4().hex[:16]}",
learner_id=application.learner_id,
employer_id=self._posting_employer_id(session, application.posting_id),
posting_id=application.posting_id,
application_id=application.id,
role_title=role_title,
start_date=start_date.isoformat(),
status="active",
created_at=moment,
decided_at=moment,
)
placement._validate()
placement._normalize()
session.add(placement)
# the placement row must exist before its event references it
session.flush()
_, prev_hash_2 = self._chain_head(session)
self._append_event(
session,
application_id=application_id,
placement_id=placement.id,
event_type=EVENT_PLACEMENT_CREATED,
actor_type=actor_type,
actor_ref=actor_ref,
payload={
"role_title": role_title,
"start_date": start_date.isoformat(),
},
prev_hash=prev_hash_2,
created_at=moment,
)
session.commit()
session.refresh(application)
application._normalize()
if placement is not None:
session.refresh(placement)
placement._normalize()
return application, placement
@staticmethod
def _posting_employer_id(session: Session, posting_id: str) -> str:
"""G-302: resolve the posting's employer_id from the SOURCE row
at placement-creation time — never trusted from the caller."""
from ..marketplace.store import PostingRow
posting = session.get(PostingRow, posting_id)
if posting is None:
raise _constraint_error(
f"guard: application references unknown posting "
f"{posting_id!r} — refusing to create placement",
{"posting_id": posting_id},
)
return posting.employer_id
# -- reads (consume-free, G-302 ID references) ---------------------------
def get_placement(self, placement_id: str) -> PlacementRow | None:
with Session(self._engine) as session:
row = session.get(PlacementRow, placement_id)
if row is None:
return None
row._normalize()
return row
def get_placement_by_application(
self, application_id: str
) -> PlacementRow | None:
"""v0.11 P5 (REQ-11-005): the ACTIVE placement on this
application — the G-316 partial unique index makes
at-most-one structural; revoked-only history → None (the
honest null — never a fabricated placement, A-304)."""
with Session(self._engine) as session:
row = session.exec(
select(PlacementRow).where(
PlacementRow.application_id == application_id,
PlacementRow.status == "active",
)
).one_or_none()
if row is None:
return None
row._normalize()
return row
def list_placements(self, status: str | None = None) -> list[PlacementRow]:
"""created_at then id (the list_postings ordering discipline,
R-73 — deterministic export ordering)."""
with Session(self._engine) as session:
stmt = select(PlacementRow)
if status is not None:
stmt = stmt.where(PlacementRow.status == status)
rows = session.exec(
stmt.order_by(col(PlacementRow.created_at), PlacementRow.id)
).all()
for row in rows:
row._normalize()
return list(rows)
def get_application_events(self, application_id: str) -> list[PlacementEventRow]:
"""id-ordered (the chain order) — scoped to ONE application."""
with Session(self._engine) as session:
rows = session.exec(
select(PlacementEventRow)
.where(PlacementEventRow.application_id == application_id)
.order_by(col(PlacementEventRow.id))
).all()
for row in rows:
row._normalize()
return list(rows)
def list_events(self) -> list[PlacementEventRow]:
"""The whole ledger, id-ordered (the export embeds every event
so the auditor's walk is offline — R-70)."""
with Session(self._engine) as session:
rows = session.exec(
select(PlacementEventRow).order_by(col(PlacementEventRow.id))
).all()
for row in rows:
row._normalize()
return list(rows)
def count_events(self, event_type: str | None = None) -> int:
with Session(self._engine) as session:
stmt = select(func.count()).select_from(PlacementEventRow)
if event_type is not None:
stmt = stmt.where(PlacementEventRow.event_type == event_type)
return session.exec(stmt).one()
# -- the D-102 correction lane ------------------------------------------
def revoke_placement(
self, placement_id: str, actor_ref: str, now: datetime | None = None
) -> PlacementRow | None:
"""D-102: the one status-only mutation active → revoked + the
placement.revoked event, one transaction (operator-only by
route). None = unknown placement (route 404s)."""
moment = _as_utc(now) if now is not None else datetime.now(UTC)
with Session(self._engine) as session:
row = session.get(PlacementRow, placement_id)
if row is None:
return None
if row.status != "active":
raise InvalidTransition(
f"placement {placement_id!r} is {row.status!r} — "
"only an active placement can be revoked"
)
row.status = "revoked"
row._validate()
row._normalize()
_, prev_hash = self._chain_head(session)
self._append_event(
session,
application_id=row.application_id,
placement_id=row.id,
event_type=EVENT_PLACEMENT_REVOKED,
actor_type="operator",
actor_ref=actor_ref,
payload={"placement_id": placement_id},
prev_hash=prev_hash,
created_at=moment,
)
session.commit()
session.refresh(row)
row._normalize()
return row
def rehire_placement(
self,
application_id: str,
role_title: str,
start_date: date,
actor_ref: str,
now: datetime | None = None,
) -> PlacementRow:
"""R-80/G-326: the operator re-hire — a FRESH PlacementRow +
placement.rehired event WITHOUT touching the application's
terminal status (hired → hired is a 409 on every
matrix-validated surface; placements, not applications, are
the correction lane). decided_at = the re-hire event's
store-stamped created_at (G-326 — D-102's stamp rule extended).
The partial unique index is the guard: an existing ACTIVE
placement on the application refuses the insert and its
IntegrityError SURFACES (the caller's honest 409 — revoked
rows never match the partial WHERE, the exact G-316 property).
"""
if not role_title:
raise ValueError("role_title is required on re-hire (D-103)")
if start_date is None:
raise ValueError("start_date is required on re-hire (D-103)")
moment = _as_utc(now) if now is not None else datetime.now(UTC)
with Session(self._engine) as session:
application = session.get(ApplicationRow, application_id)
if application is None:
raise InvalidTransition(f"unknown application {application_id!r}")
if application.status != "hired":
raise InvalidTransition(
f"application {application_id!r} is {application.status!r} — "
"re-hire is the correction lane for a hired application"
)
placement = PlacementRow(
id=f"{_PLACEMENT_ID_PREFIX}{uuid.uuid4().hex[:16]}",
learner_id=application.learner_id,
employer_id=self._posting_employer_id(session, application.posting_id),
posting_id=application.posting_id,
application_id=application.id,
role_title=role_title,
start_date=start_date.isoformat(),
status="active",
created_at=moment,
# G-326: the re-hire event's stamp, not the original hire's
decided_at=moment,
)
placement._validate()
placement._normalize()
session.add(placement)
# the partial unique index refuses a second ACTIVE row —
# its IntegrityError SURFACES (never swallowed, the
# insert_claim discipline)
session.flush()
_, prev_hash = self._chain_head(session)
self._append_event(
session,
application_id=application.id,
placement_id=placement.id,
event_type=EVENT_PLACEMENT_REHIRED,
actor_type="operator",
actor_ref=actor_ref,
payload={
"role_title": role_title,
"start_date": start_date.isoformat(),
},
prev_hash=prev_hash,
created_at=moment,
)
session.commit()
session.refresh(placement)
placement._normalize()
return placement
def list_placements_by_employer(
self, employer_id: str
) -> list[PlacementRow]:
"""v0.11 P3 (D-109): the claimed entity's OWN placements
(active + revoked — the full history), created_at then id."""
with Session(self._engine) as session:
rows = session.exec(
select(PlacementRow)
.where(PlacementRow.employer_id == employer_id)
.order_by(col(PlacementRow.created_at), PlacementRow.id)
).all()
for row in rows:
row._normalize()
return list(rows)
# -- the audit export (v0.11 P4, D-108/G-321/G-325/R-73) -----------------
EXPORT_SCHEMA_VERSION = 1
def build_export(self, now: datetime | None = None) -> dict[str, Any]:
"""D-108: ONE self-contained JSON document via this single
builder (the export route AND the summary route render from
the SAME dict — the summary can never drift from the export).
G-325: evidence_bundles cover EVERY placement (active AND
revoked) — the correction history (hired → revoked → rehired)
is precisely what an auditor most wants to inspect; cohort
stays active-only; revoked_count equals the placement.revoked
events (cross-checkable, rides the G-321(c) methodology).
R-73 determinism: events by id (chain order); by_cohort_month
ascending; cohort/evidence ordered created_at then id; float
absence (counts are ints by construction); generated_at is
export-only metadata (a function of the request, not the rows
— R-79) and is EXCLUDED from determinism comparison.
"""
moment = _as_utc(now) if now is not None else datetime.now(UTC)
all_placements = self.list_placements() # created_at then id
active = [row for row in all_placements if row.status == "active"]
revoked = [row for row in all_placements if row.status == "revoked"]
events = self.list_events() # id order — the chain order
# summary (the D-107 computation — one implementation, reused)
learner_ids = {row.learner_id for row in active}
by_month: dict[str, int] = {}
for row in active:
month = row.start_date[:7]
by_month[month] = by_month.get(month, 0) + 1
# evidence bundles: G-302 — resolved from SOURCE ROWS at export
with Session(self._engine) as session:
from ..marketplace.store import ApplicationRow, PostingRow
evidence_bundles: list[dict[str, Any]] = []
cohort: list[dict[str, Any]] = []
for row in all_placements:
application = session.get(ApplicationRow, row.application_id)
posting = session.get(PostingRow, row.posting_id)
transition_history = [
{
"id": event.id,
"event_type": event.event_type,
"actor_type": event.actor_type,
"actor_ref": event.actor_ref,
"created_at": (
_as_utc(event.created_at).isoformat()
if event.created_at is not None
else None
),
}
for event in events
if event.application_id == row.application_id
]
hired_at: str | None = None
for e in events:
if (
e.application_id == row.application_id
and e.event_type == "application.hired"
and e.created_at is not None
):
hired_at = _as_utc(e.created_at).isoformat()
break
decision_provenance = {
"hired_event": hired_at,
"decided_at": (
_as_utc(row.decided_at).isoformat()
if row.decided_at is not None
else None
),
}
bundle: dict[str, Any] = {
"placement_id": row.id,
"placement_status": row.status,
"role_title": row.role_title,
"start_date": row.start_date,
"learner_id": row.learner_id,
"employer_id": row.employer_id,
"posting_record": (
{
"id": posting.id,
"title": posting.title,
"employer_id": posting.employer_id,
"status": posting.status,
}
if posting is not None
else None
),
"application_record": (
{
"id": application.id,
"learner_id": application.learner_id,
"posting_id": application.posting_id,
"status": application.status,
"created_at": (
_as_utc(application.created_at).isoformat()
if application.created_at is not None
else None
),
}
if application is not None
else None
),
# D-101/A-304: NO backfill — pre-v0.11 submitted rows
# carry only their created_at, never a fabricated
# application.submitted event
"transition_history": transition_history,
"decision_provenance": decision_provenance,
}
evidence_bundles.append(bundle)
if row.status == "active":
cohort.append(
{
"placement_id": row.id,
"learner_id": row.learner_id,
"employer_id": row.employer_id,
"posting_id": row.posting_id,
"application_id": row.application_id,
"role_title": row.role_title,
"start_date": row.start_date,
}
)
ok, head_hash = self.verify_chain()
if not ok:
# P4 review P2-1: a raise (not an assert — stripped under
# python -O) keeps the export FAIL-CLOSED on a broken
# chain: no document is ever served that would not walk.
raise RuntimeError(
"placement ledger chain verification failed — "
"refusing to build the audit export"
)
return {
"schema_version": self.EXPORT_SCHEMA_VERSION,
"generated_at": moment.isoformat(),
"summary": {
"distinct_learners_placed": len(learner_ids),
"total_active_placements": len(active),
"revoked_count": len(revoked),
"by_cohort_month": [
{"month": month, "count": count}
for month, count in sorted(by_month.items())
],
},
"cohort": cohort,
"evidence_bundles": evidence_bundles,
"methodology": EXPORT_METHODOLOGY,
"ledger": {
"events": [
{
"id": event.id,
"application_id": event.application_id,
"placement_id": event.placement_id,
"event_type": event.event_type,
"actor_type": event.actor_type,
"actor_ref": event.actor_ref,
"payload": event.payload,
"created_at": (
_as_utc(event.created_at).isoformat()
if event.created_at is not None
else None
),
"prev_hash": event.prev_hash,
"hash": event.hash,
}
for event in events
],
"head_hash": head_hash,
},
}
def close(self) -> None:
self._engine.dispose()
@@ -0,0 +1,116 @@
"""Placement test fixtures (hermeticity rule: db_path pins tmp_path —
never ~/.nextcraft, never the cloud).
Mirrors the tests/marketplace pattern: tmp-path stores injected into
a real create_app via state-injection (the G-9 precedent) — the
transition routes see the exact composition production runs.
G-313 (D-111): the autouse `_APPLY_TIMES` reset — the learner
withdraw route (P2) rides the marketplace apply-rate window; without
this reset the suite self-429s (the lesson applied BEFORE it ships).
"""
from __future__ import annotations
from collections.abc import Iterator
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from pydantic import SecretStr
from ai_service.config import Settings
from ai_service.identity.store import SQLiteIdentityStore
from ai_service.main import create_app
from ai_service.marketplace.store import SQLiteMarketplaceStore
from ai_service.placements.store import SQLitePlacementStore
from ..conftest import SUITE_LEARNERS, seed_verified_identity
#: The pilot learner roster for gated-route tests (G-5 allowlist
#: coverage + verified 18+ identity records).
PLACEMENT_LEARNERS = ["pilot-learner", "pilot-learner-2"]
ADMIN_TOKEN = "test-admin-token"
@pytest.fixture()
def marketplace_store(tmp_path: Path) -> Iterator[SQLiteMarketplaceStore]:
"""Hermetic store: D-100's topology — marketplace + placements
share ONE SQLite file (the cross-store transaction depends on
it)."""
store = SQLiteMarketplaceStore(db_path=tmp_path / "shared.db")
yield store
store.close()
@pytest.fixture()
def identity_store(tmp_path: Path) -> Iterator[SQLiteIdentityStore]:
store = SQLiteIdentityStore(db_path=tmp_path / "identity.db")
seed_verified_identity(store, PLACEMENT_LEARNERS)
yield store
store.close()
@pytest.fixture()
def placement_store(tmp_path: Path) -> Iterator[SQLitePlacementStore]:
"""The 9th D-027 store on the SAME shared file as the marketplace
store (D-100/R-72: one SQLite file, two engines — the shipped
multi-store topology)."""
store = SQLitePlacementStore(db_path=tmp_path / "shared.db")
yield store
store.close()
def make_client(
tmp_path: Path,
identity_store: SQLiteIdentityStore,
marketplace_store: SQLiteMarketplaceStore,
admin_token: str | None = ADMIN_TOKEN,
) -> TestClient:
"""Full app with state-injected stores; admin_token=None exercises
the D-057 fail-closed matrix. P1-2 (security review): ALL stores
share the ONE tmp file (marketplace rides shared.db; the app DB +
placement store point there too) — D-100's cross-store transition
depends on the single-file topology, exactly as production runs."""
settings = Settings(
provider="mock",
learner_allowlist=SUITE_LEARNERS,
db_path=tmp_path / "shared.db",
sandbox_dir=tmp_path / "sandboxes",
admin_token=SecretStr(admin_token or ""),
)
app = create_app(settings)
app.state.identity_store = identity_store # state-injection (G-9)
app.state.marketplace_store = marketplace_store
app.state.placement_store = SQLitePlacementStore(db_path=tmp_path / "shared.db")
return TestClient(app)
@pytest.fixture(autouse=True)
def _reset_apply_rate_window() -> Iterator[None]:
"""The apply rate cap is module-level process state (the D-019
precedent); tests must not inherit each other's windows — the P2
withdraw route rides this limiter (G-313 applied BEFORE it
ships)."""
from ai_service.api.marketplace import _APPLY_TIMES
_APPLY_TIMES.clear()
yield
_APPLY_TIMES.clear()
@pytest.fixture()
def client(
tmp_path: Path,
identity_store: SQLiteIdentityStore,
marketplace_store: SQLiteMarketplaceStore,
) -> Iterator[TestClient]:
with make_client(
tmp_path, identity_store, marketplace_store, admin_token=ADMIN_TOKEN
) as c:
yield c
def admin_headers(token: str = ADMIN_TOKEN) -> dict[str, str]:
return {"X-Admin-Token": token}
@@ -0,0 +1,249 @@
"""The operator lane tests (D-106/G-326/G-327, REQ-11-002) — the
fail-closed matrix, the generic transition arms, revoke, and the
R-80 re-hire route."""
from __future__ import annotations
import pytest
from fastapi.testclient import TestClient
from ai_service.marketplace.base import Posting
from ai_service.marketplace.store import SQLiteMarketplaceStore
from .conftest import ADMIN_TOKEN, make_client
def _seed_posting(store: SQLiteMarketplaceStore, employer_id: str = "emp-test") -> str:
import uuid
posting = store.insert_posting(
Posting(
id=f"post-{uuid.uuid4().hex[:12]}",
source="admin",
external_id=f"test-{uuid.uuid4().hex[:8]}",
employer_id=employer_id,
title="AI Orchestration Engineer",
)
)
# the learner apply route requires published (409 on drafts)
store.set_posting_status(posting.id, "published")
return posting.id
def _apply(client: TestClient, posting_id: str, learner_id: str = "pilot-learner") -> str:
resp = client.post(
"/v1/marketplace/apply", json={"posting_id": posting_id, "learner_id": learner_id}
)
assert resp.status_code == 200, resp.text
return resp.json()["application_id"]
def _admin(token: str = ADMIN_TOKEN) -> dict[str, str]:
return {"X-Admin-Token": token}
@pytest.fixture()
def scaffold(tmp_path, identity_store, marketplace_store):
client = make_client(tmp_path, identity_store, marketplace_store)
with client as entered:
posting_id = _seed_posting(marketplace_store)
application_id = _apply(entered, posting_id)
yield {
"client": entered,
"posting_id": posting_id,
"application_id": application_id,
}
# -- the fail-closed matrix (the R-56 shape) -------------------------------------
def test_fail_closed_no_token(scaffold) -> None:
resp = scaffold["client"].post(
f"/v1/placements/admin/applications/{scaffold['application_id']}/transition",
json={"to": "in_review"},
)
assert resp.status_code == 403
def test_fail_closed_bad_token(scaffold) -> None:
resp = scaffold["client"].post(
f"/v1/placements/admin/applications/{scaffold['application_id']}/transition",
json={"to": "in_review"},
headers=_admin("wrong-token"),
)
assert resp.status_code == 403
def test_fail_closed_revoke_and_rehire(scaffold) -> None:
client = scaffold["client"]
# no token → 403 on every route (fail-closed)
resp = client.post(
"/v1/placements/admin/placements/plc-x/revoke",
)
assert resp.status_code == 403
# invalid token → 403 (never a 404/409 — the gate runs first)
resp = client.post(
"/v1/placements/admin/placements/plc-x/revoke",
headers=_admin("wrong-token"),
)
assert resp.status_code == 403
resp = client.post(
f"/v1/placements/admin/placements/by-application/{scaffold['application_id']}/re-hire",
json={"role_title": "Engineer", "start_date": "2026-12-01"},
headers=_admin("wrong-token"),
)
assert resp.status_code == 403
# -- the generic transition route --------------------------------------------------
def test_admin_transition_every_legal_arm(scaffold) -> None:
client, app_id = scaffold["client"], scaffold["application_id"]
for to_status in ("in_review", "interview", "offer", "rejected"):
resp = client.post(
f"/v1/placements/admin/applications/{app_id}/transition",
json={"to": to_status},
headers=_admin(),
)
# rejected is terminal — the loop ends there
assert resp.status_code == 200, resp.text
assert resp.json()["status"] == to_status
if to_status == "rejected":
break
def test_admin_garbage_to_422(scaffold) -> None:
"""G-327: `to` is a CLOSED Literal — garbage dies at pydantic
(422), never a 500/409."""
resp = scaffold["client"].post(
f"/v1/placements/admin/applications/{scaffold['application_id']}/transition",
json={"to": "garbage"},
headers=_admin(),
)
assert resp.status_code == 422
def test_admin_matrix_illegal_409(scaffold) -> None:
client, app_id = scaffold["client"], scaffold["application_id"]
client.post(
f"/v1/placements/admin/applications/{app_id}/transition",
json={"to": "offer"},
headers=_admin(),
)
resp = client.post(
f"/v1/placements/admin/applications/{app_id}/transition",
json={"to": "in_review"}, # backward
headers=_admin(),
)
assert resp.status_code == 409
def test_admin_hire_requires_fields_422(scaffold) -> None:
resp = scaffold["client"].post(
f"/v1/placements/admin/applications/{scaffold['application_id']}/transition",
json={"to": "hired"},
headers=_admin(),
)
assert resp.status_code == 422
def test_admin_withdraw_on_learners_behalf(scaffold) -> None:
"""The operator can drive ANY matrix-legal transition incl.
withdrawn (remediation with provenance, the D-083 lane)."""
resp = scaffold["client"].post(
f"/v1/placements/admin/applications/{scaffold['application_id']}/transition",
json={"to": "withdrawn"},
headers=_admin(),
)
assert resp.status_code == 200
assert resp.json()["status"] == "withdrawn"
def _admin_hire(scaffold) -> str:
"""The shared hire-through-the-operator-lane helper → placement id."""
resp = scaffold["client"].post(
f"/v1/placements/admin/applications/{scaffold['application_id']}/transition",
json={"to": "hired", "role_title": "Engineer", "start_date": "2026-11-01"},
headers=_admin(),
)
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["status"] == "hired"
assert body["placement"]["role_title"] == "Engineer"
assert body["placement"]["status"] == "active"
return body["placement"]["id"]
def test_admin_hire_creates_placement(scaffold) -> None:
_admin_hire(scaffold)
# -- revoke + the R-80 re-hire ------------------------------------------------------
def test_revoke_then_rehire_three_event_ledger(scaffold) -> None:
client, app_id = scaffold["client"], scaffold["application_id"]
placement_id = _admin_hire(scaffold)
# revoke
resp = client.post(
f"/v1/placements/admin/placements/{placement_id}/revoke",
headers=_admin(),
)
assert resp.status_code == 200, resp.text
assert resp.json()["status"] == "revoked"
# the revoked placement is out of the active set (P3's overview now
# exists — the D-102/D-107 exclusion arm)
resp = client.get("/v1/placements/admin/overview", headers=_admin())
assert resp.status_code == 200
assert resp.json()["total_active_placements"] == 0
# re-hire: the FRESH active row (R-80) + decided_at = the re-hire
# event's stamp (G-326 — asserted at the store layer in test_store)
resp = client.post(
f"/v1/placements/admin/placements/by-application/{app_id}/re-hire",
json={"role_title": "Engineer II", "start_date": "2026-12-15"},
headers=_admin(),
)
assert resp.status_code == 200, resp.text
rehired = resp.json()
assert rehired["status"] == "active"
assert rehired["id"] != placement_id
assert rehired["role_title"] == "Engineer II"
def test_revoke_unknown_404(scaffold) -> None:
resp = scaffold["client"].post(
"/v1/placements/admin/placements/plc-nonexistent/revoke",
headers=_admin(),
)
assert resp.status_code == 404
def test_rehire_second_active_409(scaffold) -> None:
"""The partial-unique arm: re-hire WITHOUT revoke → the honest 409."""
client, app_id = scaffold["client"], scaffold["application_id"]
client.post(
f"/v1/placements/admin/applications/{app_id}/transition",
json={"to": "hired", "role_title": "Engineer", "start_date": "2026-11-01"},
headers=_admin(),
)
resp = client.post(
f"/v1/placements/admin/placements/by-application/{app_id}/re-hire",
json={"role_title": "Engineer II", "start_date": "2026-12-15"},
headers=_admin(),
)
assert resp.status_code == 409
assert "revoke it before re-hiring" in resp.json()["detail"]
def test_rehire_requires_hired_application_409(scaffold) -> None:
resp = scaffold["client"].post(
f"/v1/placements/admin/placements/by-application/{scaffold['application_id']}/re-hire",
json={"role_title": "Engineer", "start_date": "2026-11-01"},
headers=_admin(),
)
assert resp.status_code == 409 # not hired yet — the correction lane refuses
@@ -0,0 +1,298 @@
"""Employer application transition tests (D-105, REQ-11-002) — the
G-311 gate-order pins + the D-099 matrix 409 arms + the G-319 pin
split + the hire arms."""
from __future__ import annotations
import pytest
from fastapi.testclient import TestClient
from ai_service.employers.store import SQLiteEmployerStore
from ai_service.marketplace.base import Posting
from ai_service.marketplace.store import SQLiteMarketplaceStore
from .conftest import make_client
# -- fixtures: the employer-principal + application scaffold -------------------
def _seed_posting(store: SQLiteMarketplaceStore, employer_id: str) -> str:
import uuid
# the claim route requires the EmployerRow entity to exist (404 unknown)
from ai_service.marketplace.base import Employer
store.upsert_employer(
Employer(
id=employer_id,
source="seed-corpus",
external_id=employer_id,
name="Test Labs",
website="https://example.com/test",
)
)
posting = store.insert_posting(
Posting(
id=f"post-{uuid.uuid4().hex[:12]}",
source="admin",
external_id=f"test-{uuid.uuid4().hex[:8]}",
employer_id=employer_id,
title="AI Orchestration Engineer",
description="Test posting",
)
)
# the learner apply route requires published (409 on drafts)
store.set_posting_status(posting.id, "published")
return posting.id
def _apply(client: TestClient, posting_id: str, learner_id: str = "pilot-learner") -> str:
resp = client.post(
"/v1/marketplace/apply", json={"posting_id": posting_id, "learner_id": learner_id}
)
assert resp.status_code == 200, resp.text
return resp.json()["application_id"]
def _register_and_claim(
client: TestClient, employer_store: SQLiteEmployerStore, email: str
) -> str:
"""Register + claim + admin-approve → (token, claimed employer_id)."""
resp = client.post(
"/v1/employers/auth/register",
json={"email": email, "password": "hunter2hunter2"},
)
assert resp.status_code == 201, resp.text
token = resp.json()["token"]
resp = client.post(
"/v1/employers/claims",
json={"employer_id": "emp-test"},
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code in (200, 201), resp.text
claim_id = resp.json()["id"]
resp = client.post(
f"/v1/employers/admin/claims/{claim_id}/approve",
headers={"X-Admin-Token": "test-admin-token"},
)
assert resp.status_code == 200, resp.text
return token
def _make(tmp_path, identity_store, marketplace_store):
"""Build the client + the employer store seam (the P2 scaffold)."""
client = make_client(tmp_path, identity_store, marketplace_store)
employer_store = SQLiteEmployerStore(db_path=tmp_path / "shared.db")
client.app.state.employer_store = ( # type: ignore[attr-defined] # TestClient.app: ASGIApp-typed, runtime FastAPI
employer_store
)
return client, employer_store
@pytest.fixture()
def scaffold(tmp_path, identity_store, marketplace_store):
client, employer_store = _make(tmp_path, identity_store, marketplace_store)
with client as entered:
posting_id = _seed_posting(marketplace_store, "emp-test")
token = _register_and_claim(entered, employer_store, "hiring@example.com")
application_id = _apply(entered, posting_id)
yield {
"client": entered,
"token": token,
"posting_id": posting_id,
"application_id": application_id,
}
def _hdr(token: str) -> dict[str, str]:
return {"Authorization": f"Bearer {token}"}
# -- G-311 gate order (never a 409 before a 403; never a 403 before the 401) -----
def test_gate_order_no_token_401(scaffold) -> None:
resp = scaffold["client"].post(
f"/v1/employers/applications/{scaffold['application_id']}/in-review"
)
assert resp.status_code == 401
def test_gate_order_unclaimed_403(tmp_path, identity_store, marketplace_store) -> None:
client, _ = _make(tmp_path, identity_store, marketplace_store)
with client as entered:
posting_id = _seed_posting(marketplace_store, "emp-test")
resp = entered.post(
"/v1/employers/auth/register",
json={"email": "unclaimed@example.com", "password": "hunter2hunter2"},
)
token = resp.json()["token"]
application_id = _apply(entered, posting_id)
resp = entered.post(
f"/v1/employers/applications/{application_id}/in-review",
headers=_hdr(token),
)
assert resp.status_code == 403
assert "no_claimed_employer" in str(resp.json()["detail"])
def test_gate_order_foreign_application_403(scaffold, tmp_path, identity_store,
marketplace_store) -> None:
"""The cross-entity arm: another entity's posting's application →
403 with NO application data (anti-enumeration, G-319)."""
client = scaffold["client"]
foreign_posting = _seed_posting(marketplace_store, "emp-other")
foreign_app = _apply(client, foreign_posting)
resp = client.post(
f"/v1/employers/applications/{foreign_app}/in-review",
headers=_hdr(scaffold["token"]),
)
assert resp.status_code == 403
assert resp.json()["detail"] == (
"this application belongs to another employer entity"
)
# -- the pipeline list + G-319 pin split ----------------------------------------
def test_list_own_applications_with_learner_id(scaffold) -> None:
client = scaffold["client"]
resp = client.get("/v1/employers/applications", headers=_hdr(scaffold["token"]))
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["total"] == 1
app_row = body["applications"][0]
# G-319 supersession: learner_id IS visible on the application-scoped read
assert app_row["learner_id"] == "pilot-learner"
assert app_row["status"] == "submitted"
assert body["facets"]["submitted"] == 1
assert body["facets"]["hired"] == 0
def test_own_postings_list_stays_counts_only(scaffold) -> None:
"""The G-319 pin split: the own-postings LIST never carries learner
identifiers (the v0.10 boundary holds there — unchanged)."""
client = scaffold["client"]
resp = client.get("/v1/employers/postings", headers=_hdr(scaffold["token"]))
assert resp.status_code == 200
assert "pilot-learner" not in resp.text
# -- per-target transitions + the D-099 matrix 409s -----------------------------
def test_full_pipeline_transitions(scaffold) -> None:
client, token = scaffold["client"], scaffold["token"]
app_id = scaffold["application_id"]
for target in ("in-review", "interview", "offer"):
resp = client.post(
f"/v1/employers/applications/{app_id}/{target}", headers=_hdr(token)
)
assert resp.status_code == 200, resp.text
assert resp.json()["status"] == target.replace("-", "_")
resp = client.post(
f"/v1/employers/applications/{app_id}/hire",
json={"role_title": "AI Orchestration Engineer", "start_date": "2026-11-01"},
headers=_hdr(token),
)
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["status"] == "hired"
assert body["placement"]["role_title"] == "AI Orchestration Engineer"
assert body["placement"]["start_date"] == "2026-11-01"
assert body["placement"]["employer_id"] == "emp-test"
def test_skip_forward_hire_from_submitted(scaffold) -> None:
"""R-71: submitted → hired is a legal fast-track."""
client, token = scaffold["client"], scaffold["token"]
resp = client.post(
f"/v1/employers/applications/{scaffold['application_id']}/hire",
json={"role_title": "Engineer", "start_date": "2026-12-01"},
headers=_hdr(token),
)
assert resp.status_code == 200
assert resp.json()["status"] == "hired"
def test_matrix_backward_move_409(scaffold) -> None:
client, token = scaffold["client"], scaffold["token"]
app_id = scaffold["application_id"]
client.post(
f"/v1/employers/applications/{app_id}/offer", headers=_hdr(token)
)
resp = client.post(
f"/v1/employers/applications/{app_id}/in-review", headers=_hdr(token)
)
assert resp.status_code == 409
def test_matrix_terminal_source_409(scaffold) -> None:
client, token = scaffold["client"], scaffold["token"]
app_id = scaffold["application_id"]
client.post(
f"/v1/employers/applications/{app_id}/hire",
json={"role_title": "Engineer", "start_date": "2026-12-01"},
headers=_hdr(token),
)
resp = client.post(
f"/v1/employers/applications/{app_id}/reject", headers=_hdr(token)
)
assert resp.status_code == 409
def test_hire_requires_role_title_and_start_date_422(scaffold) -> None:
client, token = scaffold["client"], scaffold["token"]
resp = client.post(
f"/v1/employers/applications/{scaffold['application_id']}/hire",
json={"role_title": "", "start_date": "2026-12-01"},
headers=_hdr(token),
)
# G-324: min_length=1 → the empty string dies at pydantic (422)
assert resp.status_code == 422
resp = client.post(
f"/v1/employers/applications/{scaffold['application_id']}/hire",
json={"role_title": "Engineer"},
headers=_hdr(token),
)
assert resp.status_code == 422
def test_unknown_application_404(scaffold) -> None:
resp = scaffold["client"].post(
"/v1/employers/applications/app-nonexistent/in-review",
headers=_hdr(scaffold["token"]),
)
assert resp.status_code == 404
def test_withdraw_never_offered_to_employers(scaffold) -> None:
"""D-099: employers reject — there is NO employer withdraw route."""
client = scaffold["client"]
resp = client.post(
f"/v1/employers/applications/{scaffold['application_id']}/withdraw",
headers=_hdr(scaffold["token"]),
)
assert resp.status_code in (404, 405)
# -- the P2-review pin (P2-2 disposition): the D-089 audit lane -------------------
def test_transition_appends_employer_audit_row(scaffold) -> None:
"""P2-2 pin: every employer transition appends its
employer_audit_events row (kind='application' — the vocabulary
extended in this phase; the one-queryable lane)."""
scaffold["client"].post(
f"/v1/employers/applications/{scaffold['application_id']}/in-review",
headers=_hdr(scaffold["token"]),
)
store = scaffold["client"].app.state.employer_store
events = store.list_audit_events(kind="application")
assert len(events) == 1
assert events[0].name == "application.in_review"
assert events[0].application_id if False else True # noqa: the id rides detail
assert events[0].detail["application_id"] == scaffold["application_id"]
assert events[0].detail["to_status"] == "in_review"
@@ -0,0 +1,283 @@
"""The audit-export tests (D-108/G-321/G-325, REQ-11-004) — re-export
determinism, chain-head consistency, methodology completeness, the
G-325 evidence bundles, fail-closed gates."""
from __future__ import annotations
import json
import uuid
import pytest
from fastapi.testclient import TestClient
from ai_service.marketplace.base import Employer, Posting
from ai_service.marketplace.store import SQLiteMarketplaceStore
from .conftest import ADMIN_TOKEN, make_client
def _seed_posting(store: SQLiteMarketplaceStore, employer_id: str) -> str:
store.upsert_employer(
Employer(
id=employer_id,
source="seed-corpus",
external_id=employer_id,
name=f"Labs {employer_id}",
)
)
posting = store.insert_posting(
Posting(
id=f"post-{uuid.uuid4().hex[:10]}",
source="admin",
external_id=f"t-{uuid.uuid4().hex[:10]}",
employer_id=employer_id,
title="AI Orchestration Engineer",
)
)
store.set_posting_status(posting.id, "published")
return posting.id
def _hire_direct(
client: TestClient,
marketplace: SQLiteMarketplaceStore,
posting_id: str,
learner_id: str,
start_date: str = "2026-11-01",
) -> str:
from datetime import date
placements = client.app.state.placement_store # type: ignore[attr-defined] # TestClient.app: ASGIApp-typed, runtime FastAPI
application, _ = marketplace.insert_application(posting_id, learner_id)
_, placement = placements.transition_application(
application.id,
"hired",
actor_type="operator",
actor_ref="operator",
role_title="Engineer",
start_date=date.fromisoformat(start_date),
)
assert placement is not None
return placement.id
@pytest.fixture()
def scaffold(tmp_path, identity_store, marketplace_store):
client = make_client(tmp_path, identity_store, marketplace_store)
with client as entered:
yield {"client": entered, "marketplace": marketplace_store}
def _export(client: TestClient, token: str = ADMIN_TOKEN) -> dict:
resp = client.get(
"/v1/placements/admin/export", headers={"X-Admin-Token": token}
)
assert resp.status_code == 200, resp.text
assert resp.headers["content-type"].startswith("application/json")
return json.loads(resp.text)
# -- the G-321/G-325/R-73 contract -----------------------------------------------
def test_reexport_determinism(scaffold) -> None:
"""R-73: two exports on an unchanged DB → identical ledger chains
+ identical head_hash, generated_at EXCLUDED from comparison."""
s = scaffold
posting_id = _seed_posting(s["marketplace"], "emp-a")
_hire_direct(s["client"], s["marketplace"], posting_id, "pilot-learner")
first = _export(s["client"])
second = _export(s["client"])
# generated_at differs (a function of the request) — everything
# else is byte-identical (the TOTAL-exclusion rule: strip the key)
first.pop("generated_at")
second.pop("generated_at")
assert first == second
assert first["ledger"]["events"] == second["ledger"]["events"]
assert first["ledger"]["head_hash"] == second["ledger"]["head_hash"]
def test_chain_head_consistency(scaffold) -> None:
"""head_hash = the last event's hash = verify_chain's output."""
s = scaffold
posting_id = _seed_posting(s["marketplace"], "emp-a")
_hire_direct(s["client"], s["marketplace"], posting_id, "pilot-learner")
body = _export(s["client"])
events = body["ledger"]["events"]
assert body["ledger"]["head_hash"] == events[-1]["hash"]
placements = s["client"].app.state.placement_store
ok, head = placements.verify_chain()
assert ok is True
assert head == body["ledger"]["head_hash"]
def test_methodology_completeness(scaffold) -> None:
"""G-321 + R-67/R-68: all four methodology keys present; the three
pilot gaps stated explicitly; the chain proof-scope + continuity
anchor + cross-check stated."""
body = _export(scaffold["client"])
methodology = body["methodology"]
for key in ("placement_definition", "counting_rules", "verification", "scope"):
assert key in methodology, f"methodology missing {key}"
verification = methodology["verification"]
scope = methodology["scope"]
# the chain's proof scope + limit + anchor (G-321)
assert "WHAT THE CHAIN PROVES" in verification
assert "WHAT IT DOES NOT PROVE" in verification
assert "export-to-export continuity" in verification or "PREFIX" in verification
assert "revoked_count" in verification # the (c) cross-check
# the three R-67 pilot gaps (a) (b) (c) stated in scope
assert "minimum-employed-duration" in scope or "duration" in scope
assert "salary" in scope
assert "NOT third-party employment verification" in scope
# employer-attested evidence class named
assert "attestation" in verification
def test_evidence_bundles_cover_revoked_too(scaffold) -> None:
"""G-325: bundles cover EVERY placement (active AND revoked) with
the full event history; cohort stays active-only."""
s = scaffold
posting_id = _seed_posting(s["marketplace"], "emp-a")
placement_id = _hire_direct(s["client"], s["marketplace"], posting_id,
"pilot-learner")
placements = s["client"].app.state.placement_store
placements.revoke_placement(placement_id, "operator")
body = _export(s["client"])
assert body["summary"]["revoked_count"] == 1
assert body["cohort"] == [] # active-only
assert len(body["evidence_bundles"]) == 1 # the revoked row IS bundled
bundle = body["evidence_bundles"][0]
assert bundle["placement_status"] == "revoked"
assert bundle["posting_record"] is not None
assert bundle["application_record"] is not None
event_types = [e["event_type"] for e in bundle["transition_history"]]
assert "application.hired" in event_types
assert "placement.created" in event_types
assert "placement.revoked" in event_types
# the (c) cross-check: revoked_count == the placement.revoked events
revoked_events = [
e for e in body["ledger"]["events"] if e["event_type"] == "placement.revoked"
]
assert body["summary"]["revoked_count"] == len(revoked_events)
def test_export_fail_closed(scaffold) -> None:
resp = scaffold["client"].get("/v1/placements/admin/export")
assert resp.status_code == 403
resp = scaffold["client"].get("/v1/placements/admin/export/summary")
assert resp.status_code == 403
def test_summary_renders_from_the_same_document(scaffold) -> None:
"""D-108's anti-drift rule: the summary's totals equal the export's
summary block on the same DB state; the content type is markdown;
the G-328 attestation title + the head hash present."""
s = scaffold
posting_id = _seed_posting(s["marketplace"], "emp-a")
_hire_direct(s["client"], s["marketplace"], posting_id, "pilot-learner")
export = _export(s["client"])
resp = s["client"].get(
"/v1/placements/admin/export/summary", headers={"X-Admin-Token": ADMIN_TOKEN}
)
assert resp.status_code == 200
assert resp.headers["content-type"].startswith("text/markdown")
text = resp.text
assert "Employer-attested, operator-overseen" in text # G-328 title
assert f"| {export['summary']['distinct_learners_placed']} |" in text
assert export["ledger"]["head_hash"] in text # the walk instructions
assert "PREFIX" in text # the export-to-export continuity anchor
# -- the full-chain E2E proof (the milestone's reason to exist) -------------------
def test_e2e_audit_proof(scaffold) -> None:
"""The machine-checked E2E (D-111): register → claim → admin
approve → posting → verified learner applies → transitions →
hire → placement created → chain green → export reproduces →
counts the learner ONCE."""
from ai_service.employers.store import SQLiteEmployerStore
s = scaffold
client = s["client"]
# fresh employer store on the shared file (the scaffold's client
# wires one only through the lifespan default — build it here)
import pathlib
db_path = pathlib.Path(
str(client.app.state.marketplace_store._db_path) # the shared tmp file (test seam)
)
employer_store = SQLiteEmployerStore(db_path=db_path)
client.app.state.employer_store = employer_store
posting_id = _seed_posting(s["marketplace"], "emp-a")
# employer registers → claims → operator approves
resp = client.post(
"/v1/employers/auth/register",
json={"email": "e2e@example.com", "password": "hunter2hunter2"},
)
assert resp.status_code == 201
token = resp.json()["token"]
resp = client.post(
"/v1/employers/claims",
json={"employer_id": "emp-a"},
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code in (200, 201)
claim_id = resp.json()["id"]
resp = client.post(
f"/v1/employers/admin/claims/{claim_id}/approve",
headers={"X-Admin-Token": ADMIN_TOKEN},
)
assert resp.status_code == 200
# verified learner applies
resp = client.post(
"/v1/marketplace/apply",
json={"posting_id": posting_id, "learner_id": "pilot-learner"},
)
assert resp.status_code == 200
application_id = resp.json()["application_id"]
# employer drives in_review → interview → offer → hire
for target in ("in-review", "interview", "offer"):
resp = client.post(
f"/v1/employers/applications/{application_id}/{target}",
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200, resp.text
resp = client.post(
f"/v1/employers/applications/{application_id}/hire",
json={"role_title": "AI Orchestration Engineer", "start_date": "2026-11-01"},
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200, resp.text
assert resp.json()["placement"]["status"] == "active"
# the chain walks green
placements = client.app.state.placement_store
ok, _ = placements.verify_chain()
assert ok is True
# the export reproduces (chain + head identical, generated_at excluded)
first = _export(client)
first.pop("generated_at")
second = _export(client)
second.pop("generated_at")
assert first == second
# the overview counts the learner ONCE toward 1,000
resp = client.get(
"/v1/placements/admin/overview", headers={"X-Admin-Token": ADMIN_TOKEN}
)
assert resp.status_code == 200
overview = resp.json()
assert overview["distinct_learners_placed"] == 1
assert overview["total_active_placements"] == 1
assert overview["guiding_goal"] == 1000
assert overview["by_cohort_month"] == [{"month": "2026-11", "count": 1}]
@@ -0,0 +1,260 @@
"""Learner application-status tests (D-104/G-322, REQ-11-005) — the
mine-read discovery arms, the status read's scoped-history + hired
placement arms, the revoked honest-null arm, and the reads-stay-free
pin (no self-429, G-313)."""
from __future__ import annotations
import pytest
from fastapi.testclient import TestClient
from ai_service.marketplace.base import Posting
from ai_service.marketplace.store import SQLiteMarketplaceStore
from .conftest import ADMIN_TOKEN, PLACEMENT_LEARNERS, make_client
def _seed_posting(store: SQLiteMarketplaceStore, employer_id: str = "emp-test") -> str:
import uuid
posting = store.insert_posting(
Posting(
id=f"post-{uuid.uuid4().hex[:12]}",
source="admin",
external_id=f"test-{uuid.uuid4().hex[:8]}",
employer_id=employer_id,
title="AI Orchestration Engineer",
)
)
# the learner apply route requires published (409 on drafts)
store.set_posting_status(posting.id, "published")
return posting.id
def _apply(client: TestClient, posting_id: str, learner_id: str = "pilot-learner") -> str:
resp = client.post(
"/v1/marketplace/apply", json={"posting_id": posting_id, "learner_id": learner_id}
)
assert resp.status_code == 200, resp.text
return resp.json()["application_id"]
def _admin(token: str = ADMIN_TOKEN) -> dict[str, str]:
return {"X-Admin-Token": token}
def _transition(client: TestClient, application_id: str, body: dict) -> None:
resp = client.post(
f"/v1/placements/admin/applications/{application_id}/transition",
json=body,
headers=_admin(),
)
assert resp.status_code == 200, resp.text
def _status(client: TestClient, application_id: str, learner_id: str):
return client.get(
f"/v1/marketplace/applications/{application_id}",
params={"learner_id": learner_id},
)
@pytest.fixture()
def scaffold(tmp_path, identity_store, marketplace_store):
client = make_client(tmp_path, identity_store, marketplace_store)
with client as entered:
posting_id = _seed_posting(marketplace_store)
application_id = _apply(entered, posting_id)
yield {
"client": entered,
"posting_id": posting_id,
"application_id": application_id,
}
# -- the mine-read (G-322): discovery on revisit -------------------------------
def test_mine_own_200(scaffold) -> None:
resp = scaffold["client"].get(
"/v1/marketplace/applications/mine",
params={"posting_id": scaffold["posting_id"], "learner_id": "pilot-learner"},
)
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["application_id"] == scaffold["application_id"]
assert body["status"] == "submitted"
def test_mine_none_404(scaffold) -> None:
resp = scaffold["client"].get(
"/v1/marketplace/applications/mine",
params={"posting_id": scaffold["posting_id"], "learner_id": "pilot-learner-2"},
)
assert resp.status_code == 404
def test_mine_unallowlisted_403(scaffold) -> None:
resp = scaffold["client"].get(
"/v1/marketplace/applications/mine",
params={"posting_id": scaffold["posting_id"], "learner_id": "intruder"},
)
assert resp.status_code == 403
def test_mine_unverified_403(tmp_path, identity_store, marketplace_store) -> None:
"""A learner on the roster but WITHOUT a verified identity record →
the verified-18+ arm refuses (the D-043 composition)."""
client = make_client(tmp_path, identity_store, marketplace_store)
with client as entered:
posting_id = _seed_posting(marketplace_store)
from ..conftest import SUITE_LEARNERS
unverified = next(name for name in SUITE_LEARNERS if name not in PLACEMENT_LEARNERS)
resp = entered.get(
"/v1/marketplace/applications/mine",
params={"posting_id": posting_id, "learner_id": unverified},
)
assert resp.status_code == 403
# -- the status read (D-104): status + scoped history + placement ---------------
def test_status_own_200_with_events(scaffold) -> None:
client = scaffold["client"]
_transition(client, scaffold["application_id"], {"to": "in_review"})
resp = _status(client, scaffold["application_id"], "pilot-learner")
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["application"]["id"] == scaffold["application_id"]
assert body["application"]["posting_id"] == scaffold["posting_id"]
assert body["application"]["learner_id"] == "pilot-learner"
assert body["application"]["status"] == "in_review"
assert [e["event_type"] for e in body["events"]] == ["application.in_review"]
assert body["placement"] is None
def test_status_events_scoped_and_id_ordered(scaffold) -> None:
"""The events arm is a SCOPED query: another learner's events never
ride this read, and the history renders id-ordered (the chain
order)."""
client = scaffold["client"]
import uuid
store = client.app.state.marketplace_store
posting2 = store.insert_posting(
Posting(
id=f"post-b-{uuid.uuid4().hex[:6]}",
source="admin",
external_id=f"b-{uuid.uuid4().hex[:8]}",
employer_id="emp-test",
title="Second posting",
)
)
store.set_posting_status(posting2.id, "published")
other = _apply(client, posting2.id, learner_id="pilot-learner-2")
_transition(client, other, {"to": "interview"})
_transition(client, scaffold["application_id"], {"to": "in_review"})
_transition(client, scaffold["application_id"], {"to": "interview"})
resp = _status(client, scaffold["application_id"], "pilot-learner")
assert resp.status_code == 200, resp.text
body = resp.json()
assert [e["event_type"] for e in body["events"]] == [
"application.in_review",
"application.interview",
]
assert all(
e["application_id"] == scaffold["application_id"] for e in body["events"]
)
def test_status_hired_carries_placement(scaffold) -> None:
client = scaffold["client"]
_transition(
client,
scaffold["application_id"],
{"to": "hired", "role_title": "Placement Engineer", "start_date": "2026-11-02"},
)
resp = _status(client, scaffold["application_id"], "pilot-learner")
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["application"]["status"] == "hired"
placement = body["placement"]
assert placement is not None
assert placement["role_title"] == "Placement Engineer"
assert placement["start_date"] == "2026-11-02"
assert placement["application_id"] == scaffold["application_id"]
assert placement["learner_id"] == "pilot-learner"
assert placement["employer_id"] == "emp-test"
assert placement["posting_id"] == scaffold["posting_id"]
# hired → the two-event chain: application.hired + placement.created
assert [e["event_type"] for e in body["events"]] == [
"application.hired",
"placement.created",
]
def test_status_revoked_placement_is_honest_null(scaffold) -> None:
"""A-304: no active placement record renders after the operator
revoke — the honest null (the events carry the correction trail,
never a fabricated badge)."""
client = scaffold["client"]
app_id = scaffold["application_id"]
_transition(
client,
app_id,
{"to": "hired", "role_title": "Placement Engineer", "start_date": "2026-11-02"},
)
placement_id = _status(client, app_id, "pilot-learner").json()["placement"]["id"]
resp = client.post(
f"/v1/placements/admin/placements/{placement_id}/revoke",
headers=_admin(),
)
assert resp.status_code == 200, resp.text
resp = _status(client, app_id, "pilot-learner")
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["placement"] is None
assert body["events"][-1]["event_type"] == "placement.revoked"
def test_status_foreign_learner_403(scaffold) -> None:
resp = _status(scaffold["client"], scaffold["application_id"], "pilot-learner-2")
assert resp.status_code == 403
def test_status_unallowlisted_403(scaffold) -> None:
resp = _status(scaffold["client"], scaffold["application_id"], "intruder")
assert resp.status_code == 403
def test_status_unverified_403(tmp_path, identity_store, marketplace_store) -> None:
client = make_client(tmp_path, identity_store, marketplace_store)
with client as entered:
posting_id = _seed_posting(marketplace_store)
from ..conftest import SUITE_LEARNERS
unverified = next(name for name in SUITE_LEARNERS if name not in PLACEMENT_LEARNERS)
marketplace_store.insert_application(posting_id, unverified)
application = marketplace_store.list_applications(posting_id)[0]
resp = _status(entered, application.id, unverified)
assert resp.status_code == 403
def test_status_unknown_404(scaffold) -> None:
resp = _status(scaffold["client"], "app-nonexistent", "pilot-learner")
assert resp.status_code == 404
# -- G-313: reads never consume the apply-rate budget ----------------------------
def test_status_reads_stay_free_no_self_429(scaffold) -> None:
"""A-408's distinct-WRITE rule: status reads stay free — a revisit
storm of reads never 429s (the conftest reset proves the next
test starts clean either way)."""
client = scaffold["client"]
for _ in range(10):
resp = _status(client, scaffold["application_id"], "pilot-learner")
assert resp.status_code == 200, resp.text
@@ -0,0 +1,226 @@
"""Learner withdraw tests (D-104/G-323, REQ-11-002) — the D-043 gate
stack + the terminal arms + the apply-route terminal guard."""
from __future__ import annotations
import pytest
from fastapi.testclient import TestClient
from ai_service.marketplace.base import Posting
from ai_service.marketplace.store import SQLiteMarketplaceStore
from .conftest import PLACEMENT_LEARNERS, make_client
def _seed_posting(store: SQLiteMarketplaceStore, employer_id: str = "emp-test") -> str:
import uuid
posting = store.insert_posting(
Posting(
id=f"post-{uuid.uuid4().hex[:12]}",
source="admin",
external_id=f"test-{uuid.uuid4().hex[:8]}",
employer_id=employer_id,
title="AI Orchestration Engineer",
)
)
# the learner apply route requires published (409 on drafts)
store.set_posting_status(posting.id, "published")
return posting.id
def _apply(client: TestClient, posting_id: str, learner_id: str = "pilot-learner") -> str:
resp = client.post(
"/v1/marketplace/apply", json={"posting_id": posting_id, "learner_id": learner_id}
)
assert resp.status_code == 200, resp.text
return resp.json()["application_id"]
@pytest.fixture()
def scaffold(tmp_path, identity_store, marketplace_store):
client = make_client(tmp_path, identity_store, marketplace_store)
with client as entered:
posting_id = _seed_posting(marketplace_store)
application_id = _apply(entered, posting_id)
yield {
"client": entered,
"posting_id": posting_id,
"application_id": application_id,
}
# -- the D-043 gate stack (G-5 → verified 18+ → the apply-rate window) ------------
def test_withdraw_own_submitted(scaffold) -> None:
resp = scaffold["client"].post(
f"/v1/marketplace/applications/{scaffold['application_id']}/withdraw",
json={"learner_id": "pilot-learner"},
)
assert resp.status_code == 200, resp.text
assert resp.json()["status"] == "withdrawn"
def test_withdraw_unallowlisted_403(scaffold) -> None:
resp = scaffold["client"].post(
f"/v1/marketplace/applications/{scaffold['application_id']}/withdraw",
json={"learner_id": "intruder"},
)
assert resp.status_code == 403
def test_withdraw_unverified_403(tmp_path, identity_store, marketplace_store) -> None:
"""A learner on the roster but WITHOUT a verified identity record →
the verified-18+ arm refuses (the D-043 composition)."""
client = make_client(tmp_path, identity_store, marketplace_store)
with client as entered:
posting_id = _seed_posting(marketplace_store)
# an allowlisted-but-unverified learner: the suite roster carries
# learners beyond PLACEMENT_LEARNERS (the tests/conftest SUITE
# list); the identity store only seeds PLACEMENT_LEARNERS.
from ..conftest import SUITE_LEARNERS
unverified = next(name for name in SUITE_LEARNERS if name not in PLACEMENT_LEARNERS)
# even the APPLY is refused for an unverified learner (the same
# D-043 stack) — the withdraw arm pins identically: build the
# application row directly through the store, then attempt the
# withdraw through the route.
marketplace_store.insert_application(posting_id, unverified)
application = marketplace_store.list_applications(posting_id)[0]
resp = entered.post(
f"/v1/marketplace/applications/{application.id}/withdraw",
json={"learner_id": unverified},
)
assert resp.status_code == 403
def test_withdraw_foreign_learner_403(scaffold) -> None:
resp = scaffold["client"].post(
f"/v1/marketplace/applications/{scaffold['application_id']}/withdraw",
json={"learner_id": "pilot-learner-2"},
)
assert resp.status_code == 403
def test_withdraw_terminal_409(scaffold) -> None:
client = scaffold["client"]
client.post(
f"/v1/marketplace/applications/{scaffold['application_id']}/withdraw",
json={"learner_id": "pilot-learner"},
)
resp = client.post(
f"/v1/marketplace/applications/{scaffold['application_id']}/withdraw",
json={"learner_id": "pilot-learner"},
)
assert resp.status_code == 409
def test_withdraw_unknown_404(scaffold) -> None:
resp = scaffold["client"].post(
"/v1/marketplace/applications/app-nonexistent/withdraw",
json={"learner_id": "pilot-learner"},
)
assert resp.status_code == 404
# -- G-323: apply on a terminal application → 409, never a 200 lie --------------
def test_reapply_after_withdraw_409(scaffold) -> None:
client = scaffold["client"]
client.post(
f"/v1/marketplace/applications/{scaffold['application_id']}/withdraw",
json={"learner_id": "pilot-learner"},
)
resp = client.post(
"/v1/marketplace/apply",
json={"posting_id": scaffold["posting_id"], "learner_id": "pilot-learner"},
)
assert resp.status_code == 409
assert resp.json()["detail"]["reason"] == "application_terminal"
assert resp.json()["detail"]["status"] == "withdrawn"
def test_apply_idempotent_still_200_on_submitted(scaffold) -> None:
"""A-408 unchanged: a duplicate apply on a LIVE application still
returns the 200 with the existing row (G-323 guards only terminals)."""
resp = scaffold["client"].post(
"/v1/marketplace/apply",
json={"posting_id": scaffold["posting_id"], "learner_id": "pilot-learner"},
)
assert resp.status_code == 200
assert resp.json()["application_id"] == scaffold["application_id"]
# -- the P2-review pins (P2-1/P2-2 disposition): provenance + budget -------------
def test_withdraw_ledger_actor_is_learner(scaffold) -> None:
"""D-104 pin: the withdraw transition's ledger event records
actor_type='learner' with the learner's actor_ref."""
scaffold["client"].post(
f"/v1/marketplace/applications/{scaffold['application_id']}/withdraw",
json={"learner_id": "pilot-learner"},
)
placements = scaffold["client"].app.state.placement_store
events = placements.get_application_events(scaffold["application_id"])
assert len(events) == 1
assert events[0].event_type == "application.withdrawn"
assert events[0].actor_type == "learner"
assert events[0].actor_ref == "learner:pilot-learner"
def test_withdraw_rides_the_apply_rate_window(scaffold) -> None:
"""D-104/G-313 pin: withdraw shares the apply-rate window. Budget:
the scaffold's apply consumed 1; two withdraws (2,3) + one apply
(4) exhaust the 5/60s window; the NEXT gated action 429s honestly
(the conftest reset proves the next test starts clean)."""
client = scaffold["client"]
import uuid
from ai_service.marketplace.base import Posting
store = client.app.state.marketplace_store
# withdraw the scaffold application (action 2)
resp = client.post(
f"/v1/marketplace/applications/{scaffold['application_id']}/withdraw",
json={"learner_id": "pilot-learner"},
)
assert resp.status_code == 200, resp.text
# a second application: apply (action 3) + withdraw (action 4)
posting = store.insert_posting(
Posting(
id=f"post-w-{uuid.uuid4().hex[:6]}",
source="admin",
external_id=f"w-{uuid.uuid4().hex[:8]}",
employer_id="emp-test",
title="Posting",
)
)
store.set_posting_status(posting.id, "published")
application_id = _apply(client, posting.id)
resp = client.post(
f"/v1/marketplace/applications/{application_id}/withdraw",
json={"learner_id": "pilot-learner"},
)
assert resp.status_code == 200, resp.text
# a third application: the apply is the 5th gated action — green;
# its withdraw would be the 6th → 429
posting2 = store.insert_posting(
Posting(
id=f"post-w2-{uuid.uuid4().hex[:6]}",
source="admin",
external_id=f"w2-{uuid.uuid4().hex[:8]}",
employer_id="emp-test",
title="Posting",
)
)
store.set_posting_status(posting2.id, "published")
application2 = _apply(client, posting2.id)
resp = client.post(
f"/v1/marketplace/applications/{application2}/withdraw",
json={"learner_id": "pilot-learner"},
)
assert resp.status_code == 429
@@ -0,0 +1,220 @@
"""Reporting tests (D-107, REQ-11-003) — the distinct-learner rule,
cohort bucketing, revoke exclusion, the per-employer view's gates."""
from __future__ import annotations
import uuid
import pytest
from fastapi.testclient import TestClient
from ai_service.marketplace.base import Employer, Posting
from ai_service.marketplace.store import SQLiteMarketplaceStore
from ai_service.placements.store import SQLitePlacementStore
from .conftest import ADMIN_TOKEN, make_client
def _seed_posting(store: SQLiteMarketplaceStore, employer_id: str) -> str:
store.upsert_employer(
Employer(
id=employer_id,
source="seed-corpus",
external_id=employer_id,
name=f"Labs {employer_id}",
)
)
posting = store.insert_posting(
Posting(
id=f"post-{uuid.uuid4().hex[:10]}",
source="admin",
external_id=f"t-{uuid.uuid4().hex[:10]}",
employer_id=employer_id,
title="AI Orchestration Engineer",
)
)
store.set_posting_status(posting.id, "published")
return posting.id
def _hire_direct(
placements: SQLitePlacementStore,
marketplace: SQLiteMarketplaceStore,
posting_id: str,
learner_id: str,
role_title: str = "Engineer",
start_date: str = "2026-11-01",
) -> str:
"""Direct store-level hire (the route arms are P2-tested; here we
exercise the REPORTING computation over placement rows)."""
application, _ = marketplace.insert_application(posting_id, learner_id)
_, placement = placements.transition_application(
application.id,
"hired",
actor_type="operator",
actor_ref="operator",
role_title=role_title,
start_date=__import__("datetime").date.fromisoformat(start_date),
)
assert placement is not None
return placement.id
@pytest.fixture()
def scaffold(tmp_path, identity_store, marketplace_store):
client = make_client(tmp_path, identity_store, marketplace_store)
with client as entered:
placements = entered.app.state.placement_store # type: ignore[attr-defined] # TestClient.app: ASGIApp-typed, runtime FastAPI
yield {
"client": entered,
"placements": placements,
"marketplace": marketplace_store,
}
def _overview(client: TestClient) -> dict:
resp = client.get(
"/v1/placements/admin/overview", headers={"X-Admin-Token": ADMIN_TOKEN}
)
assert resp.status_code == 200, resp.text
return resp.json()
def test_empty_overview_honest_zeroes(scaffold) -> None:
body = _overview(scaffold["client"])
assert body["total_active_placements"] == 0
assert body["distinct_learners_placed"] == 0
assert body["guiding_goal"] == 1000
assert body["by_employer"] == []
assert body["by_cohort_month"] == []
def test_distinct_learner_rule(scaffold) -> None:
"""D-107: a learner placed TWICE counts ONCE toward the guiding
goal (the outcome's noun is learners); both placements count in
total_active_placements."""
s = scaffold
posting_a = _seed_posting(s["marketplace"], "emp-a")
posting_b = _seed_posting(s["marketplace"], "emp-b")
_hire_direct(s["placements"], s["marketplace"], posting_a, "pilot-learner")
_hire_direct(s["placements"], s["marketplace"], posting_b, "pilot-learner")
body = _overview(s["client"])
assert body["total_active_placements"] == 2
assert body["distinct_learners_placed"] == 1 # placed twice, counted once
assert body["by_employer"] == [
{"employer_id": "emp-a", "count": 1},
{"employer_id": "emp-b", "count": 1},
]
def test_cohort_month_bucketing(scaffold) -> None:
"""D-107/D-103: cohort month = the calendar month of start_date —
two starts in one month share a bucket; a future-dated start
lands in its own month."""
s = scaffold
posting_a = _seed_posting(s["marketplace"], "emp-a")
posting_b = _seed_posting(s["marketplace"], "emp-a")
posting_c = _seed_posting(s["marketplace"], "emp-a")
_hire_direct(s["placements"], s["marketplace"], posting_a, "pilot-learner",
start_date="2026-11-01")
_hire_direct(s["placements"], s["marketplace"], posting_b, "pilot-learner-2",
start_date="2026-11-20")
_hire_direct(s["placements"], s["marketplace"], posting_c, "api-learner",
start_date="2027-02-05")
body = _overview(s["client"])
assert body["by_cohort_month"] == [
{"month": "2026-11", "count": 2},
{"month": "2027-02", "count": 1},
]
def test_revoke_excluded_from_counts(scaffold) -> None:
"""D-102/D-107: revoked placements are gone from every count; a
learner whose ONLY placement was revoked is back to 0."""
s = scaffold
posting = _seed_posting(s["marketplace"], "emp-a")
placement_id = _hire_direct(s["placements"], s["marketplace"], posting,
"pilot-learner")
before = _overview(s["client"])
assert before["distinct_learners_placed"] == 1
s["placements"].revoke_placement(placement_id, "operator")
after = _overview(s["client"])
assert after["total_active_placements"] == 0
assert after["distinct_learners_placed"] == 0 # back to 0
assert after["by_employer"] == []
assert after["by_cohort_month"] == []
def test_overview_fail_closed(scaffold) -> None:
resp = scaffold["client"].get("/v1/placements/admin/overview")
assert resp.status_code == 403
def test_per_employer_view_own_rows(scaffold) -> None:
"""The employer placement view: session-gated, own claimed entity
only; honest empty state (403 + claim-CTA unclaimed; 401 no token)."""
s = scaffold
posting = _seed_posting(s["marketplace"], "emp-a")
_hire_direct(s["placements"], s["marketplace"], posting, "pilot-learner")
# 401 without a session
resp = s["client"].get("/v1/employers/placements")
assert resp.status_code == 401
# 403 + claim-CTA when unclaimed
resp = s["client"].post(
"/v1/employers/auth/register",
json={"email": "viewer@example.com", "password": "hunter2hunter2"},
)
token = resp.json()["token"]
resp = s["client"].get(
"/v1/employers/placements", headers={"Authorization": f"Bearer {token}"}
)
assert resp.status_code == 403
assert "no_claimed_employer" in str(resp.json()["detail"])
# the honest empty state for a claimed entity with zero placements
store = s["client"].app.state.marketplace_store
store.upsert_employer(
Employer(
id="emp-b",
source="seed-corpus",
external_id="emp-b",
name="Labs emp-b",
)
)
resp = s["client"].post(
"/v1/employers/claims",
json={"employer_id": "emp-b"},
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code in (200, 201), resp.text
claim_id = resp.json()["id"]
s["client"].post(
f"/v1/employers/admin/claims/{claim_id}/approve",
headers={"X-Admin-Token": ADMIN_TOKEN},
)
resp = s["client"].get(
"/v1/employers/placements", headers={"Authorization": f"Bearer {token}"}
)
assert resp.status_code == 200
assert resp.json()["total"] == 0
assert resp.json()["placements"] == []
def test_by_employer_ordering(scaffold) -> None:
"""R-73: by_employer is count desc then employer_id ascending."""
s = scaffold
posting_a1 = _seed_posting(s["marketplace"], "emp-a")
posting_b1 = _seed_posting(s["marketplace"], "emp-b")
posting_b2 = _seed_posting(s["marketplace"], "emp-b")
_hire_direct(s["placements"], s["marketplace"], posting_a1, "pilot-learner")
_hire_direct(s["placements"], s["marketplace"], posting_b1, "pilot-learner-2")
_hire_direct(s["placements"], s["marketplace"], posting_b2, "api-learner")
body = _overview(s["client"])
assert body["by_employer"] == [
{"employer_id": "emp-b", "count": 2},
{"employer_id": "emp-a", "count": 1},
]
@@ -0,0 +1,585 @@
"""Placement store tests (D-111, REQ-11-001) — the ledger's adversarial
suite: chain verification, tamper, fork, concurrency, no-delete-path,
the D-099 matrix, the D-102 revoke/re-hire lane.
Real stores on tmp_path DBs, zero network by construction (no
MockTransport needed — no external calls exist), real scrypt-free
paths, honest assertions (behavior over bytes).
"""
from __future__ import annotations
import sqlite3
import uuid
from datetime import UTC, date, datetime, timedelta, timezone
from pathlib import Path
import pytest
from sqlalchemy.exc import IntegrityError
from sqlmodel import Session
from ai_service.marketplace.base import Posting
from ai_service.marketplace.store import (
ApplicationRow,
SQLiteMarketplaceStore,
)
from ai_service.placements.base import (
APPLICATION_STATUSES,
GENESIS_HASH,
TRANSITION_MATRIX,
InvalidTransition,
validate_transition,
)
from ai_service.placements.store import PlacementEventRow, SQLitePlacementStore
# -- helpers -----------------------------------------------------------------
def _seed_posting(store: SQLiteMarketplaceStore, employer_id: str = "emp-test") -> str:
"""Insert a draft posting via the store's own path → its id."""
posting = store.insert_posting(
Posting(
id=f"post-{uuid.uuid4().hex[:12]}",
source="admin",
external_id=f"test-{uuid.uuid4().hex[:8]}",
employer_id=employer_id,
title="AI Orchestration Engineer",
description="Test posting",
required_competencies=["orchestration"],
skills=["python"],
)
)
return posting.id
def _apply(store: SQLiteMarketplaceStore, posting_id: str, learner_id: str) -> ApplicationRow:
application, _created = store.insert_application(posting_id, learner_id)
with Session(store._engine) as session:
row = session.get(ApplicationRow, application.id)
assert row is not None
return row
def _hire(
store: SQLitePlacementStore,
application_id: str,
actor: str = "operator",
now: datetime | None = None,
) -> object:
return store.transition_application(
application_id,
"hired",
actor_type="operator",
actor_ref=actor,
role_title="AI Orchestration Engineer",
start_date=date(2026, 11, 1),
now=now,
)
# -- D-099 matrix (base contracts) ---------------------------------------------
def test_genesis_hash_pinned() -> None:
assert GENESIS_HASH == "0" * 64
def test_transition_matrix_forward_only_with_skip_forward() -> None:
# forward legal
assert "in_review" in TRANSITION_MATRIX["submitted"]
# skip-forward legal (the fast-track hire — R-71)
assert "hired" in TRANSITION_MATRIX["submitted"]
assert "interview" in TRANSITION_MATRIX["submitted"]
# terminals empty
assert TRANSITION_MATRIX["hired"] == ()
assert TRANSITION_MATRIX["rejected"] == ()
assert TRANSITION_MATRIX["withdrawn"] == ()
# every status is a matrix key
for status in APPLICATION_STATUSES:
assert status in TRANSITION_MATRIX
def test_validate_transition_arms() -> None:
validate_transition("submitted", "hired") # skip-forward
validate_transition("in_review", "rejected")
with pytest.raises(InvalidTransition):
validate_transition("offer", "in_review") # backward — illegal
with pytest.raises(InvalidTransition):
validate_transition("hired", "rejected") # terminal-source
with pytest.raises(InvalidTransition):
validate_transition("submitted", "garbage") # unknown target
with pytest.raises(InvalidTransition):
validate_transition("garbage", "hired") # unknown source
# -- schema pins ---------------------------------------------------------------
def test_tables_exist_in_sqlite_master(placement_store: SQLitePlacementStore) -> None:
with sqlite3.connect(placement_store._db_path) as conn:
rows = {
name
for (name,) in conn.execute(
"SELECT name FROM sqlite_master WHERE type='table'"
)
}
assert "placement" in rows
assert "placement_event" in rows
def test_partial_unique_index_pinned(placement_store: SQLitePlacementStore) -> None:
"""G-316 shape: the partial UNIQUE index exists in sqlite_master
(the test_claims_store template)."""
with sqlite3.connect(placement_store._db_path) as conn:
rows = {
sql
for (sql,) in conn.execute(
"SELECT sql FROM sqlite_master WHERE type='index' AND name=?",
("uq_active_placement_per_application",),
)
}
assert rows, "partial index missing"
sql = rows.pop()
assert "CREATE UNIQUE INDEX" in sql
assert "status='active'" in sql
def test_no_delete_path_in_source() -> None:
"""R-70: the module carries NO delete method and NO DELETE SQL —
deleting the top ledger row would make INTEGER PRIMARY KEY reuse
max+1 (chain-integrity hole)."""
from ai_service.placements import store as placements_store_module
source = Path(placements_store_module.__file__).read_text()
assert "DELETE FROM" not in source
assert "def delete" not in source
assert "session.delete" not in source
# -- the transition + chain -----------------------------------------------------
def test_transition_flow_creates_chained_events(
marketplace_store: SQLiteMarketplaceStore,
placement_store: SQLitePlacementStore,
) -> None:
posting_id = _seed_posting(marketplace_store)
application = _apply(marketplace_store, posting_id, "pilot-learner")
app1, placement_none = placement_store.transition_application(
application.id, "in_review", actor_type="employer", actor_ref="account:a1"
)
assert app1.status == "in_review"
assert placement_none is None
app2, _ = placement_store.transition_application(
application.id, "interview", actor_type="employer", actor_ref="account:a1"
)
assert app2.status == "interview"
app3, placement = placement_store.transition_application(
application.id,
"hired",
actor_type="employer",
actor_ref="account:a1",
role_title="AI Orchestration Engineer",
start_date=date(2026, 11, 1),
)
assert app3.status == "hired"
assert placement is not None
assert placement.status == "active"
assert placement.role_title == "AI Orchestration Engineer"
assert placement.start_date == "2026-11-01"
assert placement.learner_id == "pilot-learner"
assert placement.employer_id == "emp-test"
events = placement_store.get_application_events(application.id)
assert [e.event_type for e in events] == [
"application.in_review",
"application.interview",
"application.hired",
"placement.created",
]
# the chain walks green end-to-end
ok, head = placement_store.verify_chain()
assert ok is True
assert head == events[-1].hash
def test_hire_requires_role_title_and_start_date(
marketplace_store: SQLiteMarketplaceStore,
placement_store: SQLitePlacementStore,
) -> None:
posting_id = _seed_posting(marketplace_store)
application = _apply(marketplace_store, posting_id, "pilot-learner")
with pytest.raises(ValueError, match="role_title"):
placement_store.transition_application(
application.id, "hired", actor_type="employer", actor_ref="account:a1"
)
with pytest.raises(ValueError, match="start_date"):
placement_store.transition_application(
application.id,
"hired",
actor_type="employer",
actor_ref="account:a1",
role_title="Engineer",
)
def test_matrix_illegal_moves_raise(
marketplace_store: SQLiteMarketplaceStore,
placement_store: SQLitePlacementStore,
) -> None:
posting_id = _seed_posting(marketplace_store)
application = _apply(marketplace_store, posting_id, "pilot-learner")
placement_store.transition_application(
application.id, "offer", actor_type="employer", actor_ref="account:a1"
)
# backward — 409 class
with pytest.raises(InvalidTransition):
placement_store.transition_application(
application.id, "in_review", actor_type="employer", actor_ref="account:a1"
)
# terminal-source after hire
placement_store.transition_application(
application.id,
"hired",
actor_type="employer",
actor_ref="account:a1",
role_title="Engineer",
start_date=date(2026, 12, 1),
)
with pytest.raises(InvalidTransition):
placement_store.transition_application(
application.id, "rejected", actor_type="employer", actor_ref="account:a1"
)
# unknown application
with pytest.raises(InvalidTransition):
placement_store.transition_application(
"app-nonexistent", "in_review", actor_type="operator", actor_ref="operator"
)
def test_unknown_status_rejected(
marketplace_store: SQLiteMarketplaceStore,
placement_store: SQLitePlacementStore,
) -> None:
posting_id = _seed_posting(marketplace_store)
application = _apply(marketplace_store, posting_id, "pilot-learner")
with pytest.raises(InvalidTransition):
placement_store.transition_application(
application.id, "garbage", actor_type="operator", actor_ref="operator"
)
def test_tz_round_trip_canonical_stable(
marketplace_store: SQLiteMarketplaceStore,
placement_store: SQLitePlacementStore,
) -> None:
"""R-79: naive stored → _as_utc read → canonical isoformat stable —
the hashing path and the reading path share canonical_json()."""
posting_id = _seed_posting(marketplace_store)
application = _apply(marketplace_store, posting_id, "pilot-learner")
moment = datetime(2026, 10, 1, 12, 0, 0, tzinfo=UTC)
placement_store.transition_application(
application.id,
"in_review",
actor_type="employer",
actor_ref="account:a1",
now=moment,
)
events = placement_store.list_events()
assert len(events) == 1
raw = events[0]
# the canonical form is a pure function of the row — recompute twice
assert raw.canonical_json() == raw.canonical_json()
ok, _ = placement_store.verify_chain()
assert ok is True
def test_aware_non_utc_now_converted_chain_stays_green(
marketplace_store: SQLiteMarketplaceStore,
placement_store: SQLitePlacementStore,
) -> None:
"""P0-1 (security review): an aware NON-UTC `now` CONVERTS to the
true instant — the append-time and read-back canonical forms are
identical, so the chain walks green (a re-labeled offset would
hash 12:00-04:00 and re-read 12:00+00:00 — permanently broken)."""
posting_id = _seed_posting(marketplace_store)
application = _apply(marketplace_store, posting_id, "pilot-learner")
edt = timezone(timedelta(hours=-4))
placement_store.transition_application(
application.id,
"in_review",
actor_type="employer",
actor_ref="account:a1",
now=datetime(2026, 10, 1, 12, 0, 0, tzinfo=edt),
)
ok, _ = placement_store.verify_chain()
assert ok is True
def test_actor_type_vocabulary_enforced(
marketplace_store: SQLiteMarketplaceStore,
placement_store: SQLitePlacementStore,
) -> None:
"""P1-1 (security review): the store boundary rejects an actor_type
outside the closed vocabulary — a bad label would be frozen in
immutable history."""
posting_id = _seed_posting(marketplace_store)
application = _apply(marketplace_store, posting_id, "pilot-learner")
with pytest.raises(ValueError, match="actor_type"):
placement_store.transition_application(
application.id,
"in_review",
actor_type="ghost-actor",
actor_ref="account:a1",
)
def test_payload_floats_rejected(
marketplace_store: SQLiteMarketplaceStore,
placement_store: SQLitePlacementStore,
) -> None:
"""R-69: payload values str/int/bool/None only — floats REJECTED."""
posting_id = _seed_posting(marketplace_store)
application = _apply(marketplace_store, posting_id, "pilot-learner")
session = Session(placement_store._engine)
try:
row = PlacementEventRow(
application_id=application.id,
placement_id=None,
event_type="application.in_review",
actor_type="employer",
actor_ref="account:a1",
payload={"ratio": 0.5},
prev_hash=GENESIS_HASH,
hash="",
created_at=datetime.now(UTC),
)
with pytest.raises(ValueError, match="str/int/bool/None"):
row._validate()
finally:
session.close()
# -- tamper / fork / concurrency (the adversarial arms, D-111) ------------------
def test_tamper_detected_via_second_engine(
tmp_path: Path,
marketplace_store: SQLiteMarketplaceStore,
placement_store: SQLitePlacementStore,
) -> None:
"""Direct row mutation through a SECOND engine on the same file →
the verification walk FAILS (R-78: the mutation commits before the
walk reads — WAL snapshot isolation)."""
posting_id = _seed_posting(marketplace_store)
application = _apply(marketplace_store, posting_id, "pilot-learner")
placement_store.transition_application(
application.id, "in_review", actor_type="employer", actor_ref="account:a1"
)
ok_before, _ = placement_store.verify_chain()
assert ok_before is True
with sqlite3.connect(placement_store._db_path) as conn:
conn.execute(
"UPDATE placement_event SET actor_ref = 'account:evil' WHERE id = 1"
)
conn.commit()
ok_after, _ = placement_store.verify_chain()
assert ok_after is False
def test_fork_prev_hash_surfaces_integrity_error(
placement_store: SQLitePlacementStore,
) -> None:
"""G-316 backstop class: a second row claiming the same prev_hash
→ UNIQUE refuses it, IntegrityError SURFACES."""
with pytest.raises(IntegrityError):
with Session(placement_store._engine) as session:
for _i in range(2):
row = PlacementEventRow(
application_id="app-x",
placement_id=None,
event_type="application.in_review",
actor_type="employer",
actor_ref="account:a1",
payload={},
prev_hash=GENESIS_HASH,
hash="",
created_at=datetime.now(UTC),
)
session.add(row)
session.flush()
row.hash = "computed-later"
session.flush()
session.commit()
def test_concurrent_transition_one_winner(
marketplace_store: SQLiteMarketplaceStore,
tmp_path: Path,
) -> None:
"""R-78: two stores (separate engines) race the same application —
one winner commits; the loser surfaces an honest error (never two
events on one prev_hash)."""
db_path = tmp_path / "shared.db"
store_a = SQLitePlacementStore(db_path=db_path)
store_b = SQLitePlacementStore(db_path=db_path)
marketplace = SQLiteMarketplaceStore(db_path=db_path)
try:
posting_id = _seed_posting(marketplace)
application = _apply(marketplace, posting_id, "pilot-learner")
# both read the same submitted status; A commits first
store_a.transition_application(
application.id, "in_review", actor_type="employer", actor_ref="account:a"
)
with pytest.raises((InvalidTransition, IntegrityError)):
store_b.transition_application(
application.id, "in_review", actor_type="employer", actor_ref="account:b"
)
ok, _ = store_a.verify_chain()
assert ok is True
# exactly ONE in_review event exists
events = store_a.get_application_events(application.id)
assert [e.event_type for e in events] == ["application.in_review"]
finally:
store_a.close()
store_b.close()
marketplace.close()
# -- the D-102 correction lane ---------------------------------------------------
def test_revoke_and_rehire(
marketplace_store: SQLiteMarketplaceStore,
placement_store: SQLitePlacementStore,
) -> None:
posting_id = _seed_posting(marketplace_store)
application = _apply(marketplace_store, posting_id, "pilot-learner")
_, placement = placement_store.transition_application(
application.id,
"hired",
actor_type="employer",
actor_ref="account:a1",
role_title="Engineer",
start_date=date(2026, 11, 1),
)
assert placement is not None
# revoke: active → revoked + event
revoked = placement_store.revoke_placement(placement.id, "operator")
assert revoked is not None and revoked.status == "revoked"
# rehire after revoke: a FRESH active row (R-80) — decided_at =
# the re-hire event's stamp (G-326)
moment = datetime(2026, 10, 2, 9, 30, 0, tzinfo=UTC)
rehired = placement_store.rehire_placement(
application.id,
role_title="Engineer II",
start_date=date(2026, 12, 15),
actor_ref="operator",
now=moment,
)
assert rehired.status == "active"
assert rehired.id != placement.id
assert rehired.decided_at == moment
# the three-event ledger sequence: hired → revoked → rehired
events = placement_store.get_application_events(application.id)
assert [e.event_type for e in events] == [
"application.hired",
"placement.created",
"placement.revoked",
"placement.rehired",
]
# the revoked placement stays in history (D-102)
statuses = {p.status for p in placement_store.list_placements()}
assert statuses == {"active", "revoked"}
ok, _ = placement_store.verify_chain()
assert ok is True
def test_revoke_only_active(
marketplace_store: SQLiteMarketplaceStore,
placement_store: SQLitePlacementStore,
) -> None:
posting_id = _seed_posting(marketplace_store)
application = _apply(marketplace_store, posting_id, "pilot-learner")
_, placement = placement_store.transition_application(
application.id,
"hired",
actor_type="employer",
actor_ref="account:a1",
role_title="Engineer",
start_date=date(2026, 11, 1),
)
assert placement is not None
placement_store.revoke_placement(placement.id, "operator")
with pytest.raises(InvalidTransition):
placement_store.revoke_placement(placement.id, "operator")
assert placement_store.revoke_placement("plc-nonexistent", "operator") is None
def test_rehire_requires_hired_application(
marketplace_store: SQLiteMarketplaceStore,
placement_store: SQLitePlacementStore,
) -> None:
posting_id = _seed_posting(marketplace_store)
application = _apply(marketplace_store, posting_id, "pilot-learner")
# not hired yet → re-hire is the correction lane for a hired app only
with pytest.raises(InvalidTransition):
placement_store.rehire_placement(
application.id,
role_title="Engineer",
start_date=date(2026, 11, 1),
actor_ref="operator",
)
def test_rehire_second_active_surfaces_integrity_error(
marketplace_store: SQLiteMarketplaceStore,
placement_store: SQLitePlacementStore,
) -> None:
"""The partial-unique arm: re-hire WITHOUT revoke → the partial
UNIQUE index refuses the second ACTIVE row (the caller's honest
409)."""
posting_id = _seed_posting(marketplace_store)
application = _apply(marketplace_store, posting_id, "pilot-learner")
placement_store.transition_application(
application.id,
"hired",
actor_type="employer",
actor_ref="account:a1",
role_title="Engineer",
start_date=date(2026, 11, 1),
)
with pytest.raises(IntegrityError):
placement_store.rehire_placement(
application.id,
role_title="Engineer II",
start_date=date(2026, 12, 1),
actor_ref="operator",
)
def test_chain_walk_after_n_appends(
marketplace_store: SQLiteMarketplaceStore,
placement_store: SQLitePlacementStore,
) -> None:
posting_id = _seed_posting(marketplace_store)
application = _apply(marketplace_store, posting_id, "pilot-learner")
for to_status in ("in_review", "interview", "offer", "rejected"):
placement_store.transition_application(
application.id, to_status, actor_type="employer", actor_ref="account:a1"
)
events = placement_store.list_events()
assert len(events) == 4
ok, head = placement_store.verify_chain()
assert ok is True
assert head == events[-1].hash
@@ -0,0 +1,31 @@
import { Badge } from '@nextcraft/ui';
import { EmployerApplicationsClient } from '../../../../components/employer/employer-applications-client';
/**
* The hiring pipeline (v0.11, REQ-11-002/003, D-109 surface 1): own
* applications with the seven D-099 status badges, per-target
* transition CTAs, and the hire form {role_title, start_date}
* (employer-attested, D-103). Session-gated shell with honest
* loading/error/empty/claim states (the A-406 board-client
* discipline). learner_id IS visible here — the G-319 supersession
* scopes it to the session's own claimed-entity postings (D-105).
*/
export default function ApplicationsPage() {
return (
<div className="flex flex-col gap-6">
<header className="flex flex-col gap-2">
<Badge variant="info">Hiring Pipeline</Badge>
<h1 className="text-3xl font-bold tracking-tight text-slate-900 dark:text-slate-100">
Hiring Pipeline
</h1>
<p className="max-w-2xl text-slate-600 dark:text-slate-400">
Review applicants on your postings and drive them through the hiring
lifecycle — in review, interview, offer, reject, or hire. A hire
records the placement with the role title and start date you attest.
</p>
</header>
<EmployerApplicationsClient />
</div>
);
}
@@ -8,6 +8,7 @@ import type {
AdminClaimsResponse,
EmployerClaim,
EmployerClaimEvidence,
PlacementOverview,
} from '@nextcraft/types';
import {
listEmployerAdminAccounts,
@@ -15,6 +16,9 @@ import {
listEmployerAdminAudit,
approveEmployerAdminClaim,
rejectEmployerAdminClaim,
getPlacementOverview,
getPlacementExport,
getPlacementExportSummary,
setAdminToken,
EngineError,
} from '../../lib/engine-client';
@@ -58,6 +62,10 @@ export function EmployerNetworkPanel() {
const [accounts, setAccounts] = useState<AdminAccountsResponse | null>(null);
const [claims, setClaims] = useState<AdminClaimsResponse | null>(null);
const [audit, setAudit] = useState<AdminAuditResponse | null>(null);
const [overview, setOverview] = useState<PlacementOverview | null>(null);
const [exportSummary, setExportSummary] = useState<string | null>(null);
const [exportBusy, setExportBusy] = useState(false);
const [exportError, setExportError] = useState<string | null>(null);
const [locked, setLocked] = useState(false);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
@@ -75,14 +83,18 @@ export function EmployerNetworkPanel() {
try {
// All three operator reads ride the SAME admin transport; a 403
// on any of them locks the whole overview (one token, one gate).
const [acct, queue, trail] = await Promise.all([
const [acct, queue, trail, placed] = await Promise.all([
listEmployerAdminAccounts(controller.signal),
listEmployerAdminClaims(controller.signal),
listEmployerAdminAudit(controller.signal),
// v0.11 P3/P4: the placements overview rides the same admin
// transport + gate; a fetch failure is honest (the rest loads).
getPlacementOverview(controller.signal).catch(() => null),
]);
setAccounts(acct);
setClaims(queue);
setAudit(trail);
setOverview(placed);
setLocked(false);
} catch (err) {
if (err instanceof EngineError && err.status === 403) {
@@ -142,6 +154,39 @@ export function EmployerNetworkPanel() {
// for the operator's explicit load — the entitlement-panel shape).
}, []);
/** The export actions (D-109 surface 4): the summary opens IN-TAB
* (the markdown renders in the panel); the JSON DOWNLOADS as a
* blob. G-328: the surrounding copy says RECORDED placements —
* never a verification claim. */
const runExport = async (kind: 'summary' | 'download') => {
setExportBusy(true);
setExportError(null);
const controller = new AbortController();
try {
if (kind === 'summary') {
const text = await getPlacementExportSummary(controller.signal);
setExportSummary(text);
} else {
const text = await getPlacementExport(controller.signal);
const blob = new Blob([text], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `placement-audit-export-${new Date().toISOString().slice(0, 10)}.json`;
link.click();
URL.revokeObjectURL(url);
}
} catch (err) {
if (err instanceof EngineError && err.status === 403) {
setLocked(true);
} else {
setExportError(err instanceof Error ? err.message : 'The export failed.');
}
} finally {
setExportBusy(false);
}
};
return (
<Card>
<CardBody className="flex flex-col gap-4">
@@ -174,6 +219,67 @@ export function EmployerNetworkPanel() {
{accounts && claims && audit && (
<div className="flex flex-col gap-6">
{/* v0.11 P3/P4 (D-109 surface 4): the placements block —
RECORDED placements toward the 1,000 guiding goal +
per-employer lists + the export links. G-328: recorded
placements, never a verification claim. */}
{overview && (
<div className="flex flex-col gap-2 rounded-lg border border-slate-200 p-3 dark:border-slate-800">
<h4 className="text-sm font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">
Recorded placements
</h4>
<p className="text-sm text-slate-700 dark:text-slate-300">
<span className="text-2xl font-bold">
{overview.distinct_learners_placed}
</span>{' '}
distinct learners placed (employer-attested records, toward
the {overview.guiding_goal} guiding goal) ·{' '}
{overview.total_active_placements} active placements
</p>
{overview.by_employer.length > 0 && (
<ul className="flex flex-wrap gap-2 text-xs text-slate-600 dark:text-slate-400">
{overview.by_employer.map((bucket) => (
<li
key={bucket.employer_id}
className="rounded-full bg-slate-100 px-2 py-1 dark:bg-slate-800/60"
>
<span className="font-mono">{bucket.employer_id}</span>: {bucket.count}
</li>
))}
</ul>
)}
<div className="flex flex-wrap gap-2">
<Button
variant="secondary"
size="sm"
disabled={exportBusy}
onClick={() => {
void runExport('summary');
}}
>
{exportBusy ? 'Working…' : 'Open export summary (in-tab)'}
</Button>
<Button
variant="outline"
size="sm"
disabled={exportBusy}
onClick={() => {
void runExport('download');
}}
>
{exportBusy ? 'Working…' : 'Download audit export (JSON)'}
</Button>
</div>
{exportError && (
<p className="text-sm text-red-700 dark:text-red-300">{exportError}</p>
)}
{exportSummary && (
<pre className="max-h-80 overflow-auto whitespace-pre-wrap rounded-md bg-slate-50 p-3 text-xs text-slate-700 dark:bg-slate-800/60 dark:text-slate-300">
{exportSummary}
</pre>
)}
</div>
)}
{/* Accounts list — email, claimed entity, created_at (the
UNVERIFIED marker renders as such, D-097). */}
<div className="flex flex-col gap-2">
@@ -0,0 +1,342 @@
'use client';
import { useEffect, useState } from 'react';
import { Badge, Button, Card, CardBody, Input } from '@nextcraft/ui';
import type {
ApplicationStatus,
EmployerApplicationSummary,
} from '@nextcraft/types';
import {
listOwnEmployerApplications,
employerMarkInReview,
employerMarkInterview,
employerMarkOffer,
employerRejectApplication,
employerHireApplication,
SessionRequiredError,
ClaimRequiredError,
EngineError,
} from '../../lib/engine-client';
/**
* The hiring-pipeline client (v0.11, REQ-11-002/003, D-109 surface
* 1): the session employer's OWN applications (every status, per-
* status facets) with per-target transition CTAs — the matrix stays
* OUT of the request bodies (D-105: each call names its target).
* The hire form carries the employer-attested {role_title,
* start_date} (D-103/G-324). Withdraw is NEVER offered here (D-099:
* employers reject; only the learner or an operator can withdraw).
*
* Honest states (A-406): loading/error/empty/claim, per-action
* pending + error; a 403 cross-entity or 409 matrix arm renders from
* EngineError.status — NO new error classes (D-110). learner_id IS
* visible (the G-319 supersession, own applicants only).
*/
const STATUS_LABELS: Record<ApplicationStatus, string> = {
submitted: 'Submitted',
in_review: 'In Review',
interview: 'Interview',
offer: 'Offer',
hired: 'Hired',
rejected: 'Rejected',
withdrawn: 'Withdrawn',
};
const STATUS_BADGE: Record<ApplicationStatus, 'default' | 'info' | 'success' | 'warning' | 'error'> = {
submitted: 'default',
in_review: 'info',
interview: 'info',
offer: 'warning',
hired: 'success',
rejected: 'error',
withdrawn: 'default',
};
/** The next legal targets per the D-099 matrix (forward-only with
* skip-forward — the CTA set matches the store's transition set). */
const NEXT_TARGETS: Record<string, Array<{ key: string; label: string }>> = {
submitted: [
{ key: 'in-review', label: 'In Review' },
{ key: 'interview', label: 'Interview' },
{ key: 'reject', label: 'Reject' },
{ key: 'hire', label: 'Hire' },
],
in_review: [
{ key: 'interview', label: 'Interview' },
{ key: 'offer', label: 'Offer' },
{ key: 'reject', label: 'Reject' },
{ key: 'hire', label: 'Hire' },
],
interview: [
{ key: 'offer', label: 'Offer' },
{ key: 'reject', label: 'Reject' },
{ key: 'hire', label: 'Hire' },
],
offer: [
{ key: 'reject', label: 'Reject' },
{ key: 'hire', label: 'Hire' },
],
};
export function EmployerApplicationsClient() {
const [applications, setApplications] = useState<EmployerApplicationSummary[] | null>(null);
const [loggedOut, setLoggedOut] = useState(false);
const [claimPrompt, setClaimPrompt] = useState(false);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
// per-application action state: the honest pending/error pair
const [pending, setPending] = useState<string | null>(null);
const [actionError, setActionError] = useState<string | null>(null);
// the hire form: which application id is being hired + its fields
const [hireFor, setHireFor] = useState<string | null>(null);
const [roleTitle, setRoleTitle] = useState('');
const [startDate, setStartDate] = useState('');
useEffect(() => {
const controller = new AbortController();
(async () => {
try {
const body = await listOwnEmployerApplications(controller.signal);
if (!controller.signal.aborted) setApplications(body.applications);
} catch (err) {
if (controller.signal.aborted) return;
if (err instanceof SessionRequiredError) {
setLoggedOut(true); // honest logged-out state — never a retry loop
} else if (err instanceof ClaimRequiredError) {
setClaimPrompt(true); // the honest claim-CTA state
} else {
setError(err instanceof Error ? err.message : 'Failed to load applications.');
}
} finally {
if (!controller.signal.aborted) setLoading(false);
}
})();
return () => controller.abort();
}, []);
async function runTransition(
application: EmployerApplicationSummary,
target: 'in-review' | 'interview' | 'offer' | 'reject',
): Promise<void> {
setActionError(null);
setPending(application.id);
try {
const dispatch = {
'in-review': employerMarkInReview,
interview: employerMarkInterview,
offer: employerMarkOffer,
reject: employerRejectApplication,
}[target];
const body = await dispatch(application.id);
if (body.application_id === application.id) {
setApplications((prev) =>
(prev ?? []).map((a) => (a.id === application.id ? { ...a, status: body.status } : a)),
);
}
} catch (err) {
if (err instanceof SessionRequiredError) {
setLoggedOut(true);
} else if (err instanceof ClaimRequiredError) {
setClaimPrompt(true);
} else if (err instanceof EngineError) {
// the 403 cross-entity + 409 matrix arms — the honest status
setActionError(`${application.id}: ${err.message}`);
} else {
setActionError(err instanceof Error ? err.message : 'The transition failed.');
}
} finally {
setPending(null);
}
}
async function submitHire(application: EmployerApplicationSummary): Promise<void> {
setActionError(null);
setPending(application.id);
try {
const body = await employerHireApplication(application.id, {
role_title: roleTitle,
start_date: startDate,
});
setApplications((prev) =>
(prev ?? []).map((a) => (a.id === application.id ? { ...a, status: body.status } : a)),
);
setHireFor(null);
setRoleTitle('');
setStartDate('');
} catch (err) {
if (err instanceof SessionRequiredError) {
setLoggedOut(true);
} else if (err instanceof ClaimRequiredError) {
setClaimPrompt(true);
} else {
// 422 (missing/empty fields, G-324) + 409 (matrix) render here
setActionError(err instanceof Error ? err.message : 'The hire failed.');
}
} finally {
setPending(null);
}
}
if (loading) {
return (
<Card>
<CardBody className="py-10 text-center text-slate-600 dark:text-slate-400">
Loading your hiring pipeline…
</CardBody>
</Card>
);
}
if (loggedOut) {
return (
<Card>
<CardBody className="flex flex-col items-center gap-3 py-10 text-center">
<p className="text-slate-700 dark:text-slate-300">
You are signed out — your session is missing or expired.
</p>
<a href="/employer/login">
<Button variant="primary" size="md">Sign in</Button>
</a>
</CardBody>
</Card>
);
}
if (claimPrompt) {
return (
<Card>
<CardBody className="flex flex-col items-center gap-3 py-10 text-center">
<p className="text-slate-700 dark:text-slate-300">
No employer entity is claimed on this account yet.
</p>
<p className="max-w-md text-sm text-slate-600 dark:text-slate-400">
Claim your company&apos;s entity first — the hiring pipeline lists
applicants on your claimed entity&apos;s postings.
</p>
</CardBody>
</Card>
);
}
if (error) {
return (
<Card>
<CardBody className="flex flex-col items-center gap-2 py-10 text-center">
<p className="text-slate-700 dark:text-slate-300">{error}</p>
<p className="text-sm text-slate-500 dark:text-slate-400">
The hiring pipeline is unavailable — please try again shortly.
</p>
</CardBody>
</Card>
);
}
if (!applications || applications.length === 0) {
return (
<Card>
<CardBody className="py-10 text-center text-sm text-slate-500 dark:text-slate-400">
No applications yet — applicants on your published postings appear
here as they apply.
</CardBody>
</Card>
);
}
return (
<div className="flex flex-col gap-4">
{actionError && (
<Card>
<CardBody className="py-3 text-sm text-red-700 dark:text-red-300">{actionError}</CardBody>
</Card>
)}
{applications.map((application) => {
const targets = NEXT_TARGETS[application.status] ?? [];
return (
<Card key={application.id}>
<CardBody className="flex flex-col gap-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2">
<Badge variant={STATUS_BADGE[application.status]}>
{STATUS_LABELS[application.status]}
</Badge>
<span className="text-sm font-medium text-slate-900 dark:text-slate-100">
{application.learner_id}
</span>
<span className="text-xs text-slate-500 dark:text-slate-400">
applied {application.created_at ? application.created_at.slice(0, 10) : ''}
</span>
</div>
<span className="text-xs text-slate-400 dark:text-slate-500">
{application.id}
</span>
</div>
{hireFor === application.id ? (
<div className="flex flex-col gap-3 rounded-lg border border-slate-200 p-3 dark:border-slate-800">
<p className="text-sm font-medium text-slate-800 dark:text-slate-200">
Record the placement you attest (visible to the learner and
the audit export):
</p>
<Input
label="Role title"
value={roleTitle}
onChange={(e) => setRoleTitle(e.target.value)}
placeholder="AI Orchestration Engineer"
maxLength={200}
/>
<Input
label="Employment start date"
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
/>
<div className="flex gap-2">
<Button
variant="primary"
size="sm"
disabled={pending === application.id || roleTitle === '' || startDate === ''}
onClick={() => {
void submitHire(application);
}}
>
{pending === application.id ? 'Recording…' : 'Confirm hire'}
</Button>
<Button variant="outline" size="sm" onClick={() => setHireFor(null)}>
Cancel
</Button>
</div>
</div>
) : (
<div className="flex flex-wrap gap-2">
{targets.map((target) => (
<Button
key={target.key}
variant={target.key === 'reject' ? 'outline' : 'secondary'}
size="sm"
disabled={pending === application.id}
onClick={() => {
if (target.key === 'hire') {
setHireFor(application.id);
setActionError(null);
} else {
void runTransition(
application,
target.key as 'in-review' | 'interview' | 'offer' | 'reject',
);
}
}}
>
{pending === application.id ? '…' : target.label}
</Button>
))}
{/* D-099: withdraw is NEVER offered to employers */}
</div>
)}
</CardBody>
</Card>
);
})}
</div>
);
}
@@ -4,10 +4,16 @@ import { useEffect, useState } from 'react';
import Link from 'next/link';
import { Briefcase, Users, TrendingUp, Target, ArrowRight } from 'lucide-react';
import { Badge, Button, Card, CardBody, Avatar } from '@nextcraft/ui';
import type { EmployerProfile, EmployerMe, OwnPostingsResponse } from '@nextcraft/types';
import type {
EmployerProfile,
EmployerMe,
OwnPostingsResponse,
EmployerPlacementsResponse,
} from '@nextcraft/types';
import {
getEmployerMe,
listOwnEmployerPostings,
listOwnEmployerPlacements,
getEmployer,
SessionRequiredError,
ClaimRequiredError,
@@ -33,6 +39,7 @@ export function EmployerOverviewClient() {
const [me, setMe] = useState<EmployerMe | null>(null);
const [postings, setPostings] = useState<OwnPostingsResponse | null>(null);
const [profile, setProfile] = useState<EmployerProfile | null>(null);
const [placements, setPlacements] = useState<EmployerPlacementsResponse | null>(null);
const [loggedOut, setLoggedOut] = useState(false);
const [claimPrompt, setClaimPrompt] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -45,12 +52,18 @@ export function EmployerOverviewClient() {
const meBody = await getEmployerMe(controller.signal);
setMe(meBody);
if (meBody.claimed_employer_id) {
const [own, prof] = await Promise.all([
const [own, prof, placed] = await Promise.all([
listOwnEmployerPostings(controller.signal),
getEmployer(meBody.claimed_employer_id, controller.signal).catch(() => null),
// v0.11 P3 (D-109 surface 2): REAL placements — the mock
// metric block is REPLACED (D-092: never leave a convertible
// surface on mock). A placements fetch failure is honest —
// the rest of the dashboard still renders.
listOwnEmployerPlacements(controller.signal).catch(() => null),
]);
setPostings(own);
setProfile(prof);
setPlacements(placed);
}
} catch (err) {
if (controller.signal.aborted) return;
@@ -160,6 +173,10 @@ export function EmployerOverviewClient() {
const activePostings = facets?.published ?? 0;
const totalApplicants =
postings?.postings.reduce((sum, p) => sum + p.applicant_count, 0) ?? 0;
// v0.11 P3 (D-107): the active-placement count from the rows at
// query time — the honest 0 when none exist.
const activePlacements =
placements?.placements.filter((p) => p.status === 'active').length ?? 0;
return (
<div className="flex flex-col gap-8">
@@ -187,13 +204,20 @@ export function EmployerOverviewClient() {
</p>
</header>
{/* Metric cards — postings/applicants REAL (GET /v1/employers/
postings); talent-match/placement stay mock-labeled (D-092). */}
{/* Metric cards — postings/applicants/placements REAL (GET
/v1/employers/postings + /placements, v0.11 P3);
talent-match stays mock-labeled (D-092 — no backend computes
it). */}
<section className="grid grid-cols-2 gap-4 lg:grid-cols-4">
<MetricCard label="Active Postings" value={activePostings} icon={Briefcase} tone="primary" />
<MetricCard label="Total Applicants" value={totalApplicants} icon={Users} tone="accent" />
<MetricCard
label="Placements Recorded"
value={activePlacements}
icon={TrendingUp}
tone="amber"
/>
<MockMetricCard label="Talent Matches" value="—" icon={Target} tone="violet" />
<MockMetricCard label="Placement Rate" value="—" icon={TrendingUp} tone="amber" />
</section>
{/* Middle row — per-posting applicant table + claimed-entity profile */}
@@ -286,6 +310,66 @@ export function EmployerOverviewClient() {
</div>
</section>
{/* v0.11 P3 (D-109 surface 2, REQ-11-003): REAL placements — the
hired applications with role + start date. The mock
placement block is REPLACED (D-092); talent-match analytics
stay mock-labeled (no backend computes them). */}
<section>
<div className="mb-3 flex items-center justify-between">
<h2 className="text-lg font-semibold text-slate-900 dark:text-slate-100">
Placements
</h2>
<Link
href="/employer/applications"
className="inline-flex items-center gap-1 text-sm font-medium text-primary-700 hover:underline dark:text-primary-300"
>
Manage hiring pipeline <ArrowRight className="h-3.5 w-3.5" />
</Link>
</div>
<Card>
{placements && placements.placements.length > 0 ? (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-slate-200 text-left text-xs uppercase tracking-wide text-slate-500 dark:border-slate-800 dark:text-slate-400">
<th className="p-3 font-medium">Role</th>
<th className="p-3 font-medium">Learner</th>
<th className="p-3 font-medium">Start Date</th>
<th className="p-3 font-medium">Status</th>
</tr>
</thead>
<tbody>
{placements.placements.map((p) => (
<tr
key={p.id}
className="border-b border-slate-100 last:border-0 dark:border-slate-800/60"
>
<td className="p-3 font-medium text-slate-900 dark:text-slate-100">
{p.role_title}
</td>
<td className="p-3 text-slate-700 dark:text-slate-300">{p.learner_id}</td>
<td className="p-3 text-slate-700 dark:text-slate-300">{p.start_date}</td>
<td className="p-3">
{p.status === 'active' ? (
<Badge variant="success">Active</Badge>
) : (
<Badge variant="default">Revoked</Badge>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<CardBody className="py-8 text-center text-sm text-slate-500 dark:text-slate-400">
No placements recorded yet — hires you record in the hiring
pipeline appear here.
</CardBody>
)}
</Card>
</section>
{/* Mock-labeled analytics (D-092: no backend computes these). */}
<section>
<h2 className="mb-3 text-lg font-semibold text-slate-900 dark:text-slate-100">
@@ -364,12 +448,14 @@ function PostingStatusBadge({ status }: { status: 'draft' | 'published' | 'close
}
/** D-092 honest mock label for the analytics section — the charts ride
* nowhere until a backend computes them; the copy says so. */
* nowhere until a backend computes them; the copy says so. v0.11:
* placement COUNTS are now real (the section above) — only the
* talent-match analytics remain mock. */
function MockAnalyticsNotice() {
return (
<p className="text-sm text-slate-500 dark:text-slate-400">
Talent-match and placement analytics are sample data at pilot — no backend
computes them yet. Real posting and applicant counts appear above.
Talent-match analytics are sample data at pilot — no backend computes them yet.
Real posting, applicant, and placement counts appear above.
</p>
);
}
@@ -6,6 +6,8 @@ import { notFound } from 'next/navigation';
import { ArrowLeft, Building2, Check, MapPin, Wallet } from 'lucide-react';
import type {
ApplicationConfirmation,
ApplicationStatus,
LearnerApplicationStatus,
PostingDetail,
EmployerProfile,
SearchResult,
@@ -24,8 +26,11 @@ import {
VerifyRequiredError,
applyPosting,
getEmployer,
getLearnerApplicationStatus,
getMyApplication,
getPosting,
readSearchSelection,
withdrawApplication,
} from '../../lib/engine-client';
import { segmentFromPath } from '../../lib/route-id';
@@ -54,6 +59,12 @@ export function JobDetailClient() {
// P3 G-106: the board's search handoff, read ONCE on mount (empty
// when the visit is not from a search result — no score, no panel).
const [searchSelection, setSearchSelection] = useState<SearchResult | null>(null);
// P5 (G-322): the learner's application on THIS posting — composed
// via the mine-read + the status read on every visit; never a
// client-persisted application id.
const [appStatus, setAppStatus] = useState<LearnerApplicationStatus | null>(null);
const [withdrawing, setWithdrawing] = useState(false);
const [withdrawError, setWithdrawError] = useState<string | null>(null);
useEffect(() => {
const controller = new AbortController();
@@ -76,6 +87,28 @@ export function JobDetailClient() {
} catch {
/* employer optional for the detail view */
}
// P5 (G-322): mine-read discovery — the panel composes from this
// read, never a client-persisted application_id; 404 = no
// application → the apply CTA renders (honest absence); gate
// arms render their existing states.
try {
const mine = await getMyApplication(p.id, undefined, controller.signal);
if (controller.signal.aborted) return;
const status = await getLearnerApplicationStatus(
mine.application_id,
undefined,
controller.signal,
);
if (!controller.signal.aborted) setAppStatus(status);
} catch (mineErr) {
if (controller.signal.aborted) return;
if (mineErr instanceof EngineError && mineErr.status === 404) return;
if (mineErr instanceof VerifyRequiredError) {
setVerifyCta(mineErr.verifyCta);
}
// any other failure leaves the apply CTA as the honest
// surface — its own flow re-surfaces the gate states.
}
} catch (err) {
if (controller.signal.aborted) return;
if (err instanceof EngineError && err.status === 404) {
@@ -97,6 +130,14 @@ export function JobDetailClient() {
try {
const c = await applyPosting(posting.id);
setConfirmation(c);
// P5: compose the status panel from the real read (the direct
// response's application_id — never persisted).
try {
const status = await getLearnerApplicationStatus(c.application_id);
setAppStatus(status);
} catch {
/* the confirmation box renders the honest state regardless */
}
} catch (err) {
if (err instanceof VerifyRequiredError) {
setVerifyCta(err.verifyCta);
@@ -112,6 +153,35 @@ export function JobDetailClient() {
}
}
async function onWithdraw() {
if (!appStatus) return;
setWithdrawing(true);
setWithdrawError(null);
try {
const c = await withdrawApplication(appStatus.application.id);
const status = await getLearnerApplicationStatus(c.application_id);
setAppStatus(status);
} catch (err) {
if (err instanceof EngineError && err.status === 409) {
// G-323: the matrix refused — the honest terminal copy; the
// state refreshes instead of a stale withdraw button.
try {
const status = await getLearnerApplicationStatus(appStatus.application.id);
setAppStatus(status);
} catch {
/* the current render stays — the 409 copy below is honest */
}
setWithdrawError('This application is terminal — it can no longer be withdrawn.');
} else if (err instanceof EngineError && err.status === 403) {
setWithdrawError('This application belongs to another learner.');
} else {
setWithdrawError(err instanceof Error ? err.message : 'Withdraw failed.');
}
} finally {
setWithdrawing(false);
}
}
if (missing) {
notFound();
}
@@ -242,8 +312,86 @@ export function JobDetailClient() {
</div>
</div>
<div className="flex flex-col items-start gap-3 md:items-end">
{/* Apply flow (D-058): every state renders honestly. */}
{confirmation ? (
{/* Apply flow (D-058): every state renders honestly. P5
* (G-322/D-104): the status panel renders when the
* mine-read finds the application; the apply CTA only
* when it does not. */}
{appStatus ? (
<div
role="status"
className="flex w-full flex-col gap-3 rounded-lg border border-slate-300 bg-slate-50 px-4 py-3 text-sm dark:border-slate-700 dark:bg-slate-900/40"
>
<div className="flex items-center gap-2">
<span className="text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">
Your application
</span>
<Badge
variant={appStatus.application.status === 'hired' ? 'success' : 'default'}
>
{applicationStatusLabel(appStatus.application.status)}
</Badge>
</div>
<span className="text-xs text-slate-500 dark:text-slate-400">
{appStatus.application.created_at
? `Applied ${relativeTime(appStatus.application.created_at)} · `
: ''}
application {appStatus.application.id}
</span>
{appStatus.events.length > 0 ? (
<ul className="flex flex-col gap-1.5">
{appStatus.events.map((event) => (
<li
key={event.id}
className="flex items-center justify-between gap-2 text-xs"
>
<span className="font-medium text-slate-700 dark:text-slate-300">
{applicationEventLabel(event.event_type)}
</span>
<span className="text-slate-500 dark:text-slate-400">
{relativeTime(event.created_at)}
</span>
</li>
))}
</ul>
) : (
<span className="text-xs text-slate-500 dark:text-slate-400">
No status updates yet.
</span>
)}
{appStatus.placement && (
<div
role="status"
className="flex flex-col gap-1 rounded-lg border border-emerald-300 bg-emerald-50 px-3 py-2 text-sm text-emerald-800 dark:border-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-200"
>
<span className="font-semibold">Placement recorded</span>
<span className="text-xs">
{appStatus.placement.role_title} at {employerName} · starts{' '}
{appStatus.placement.start_date}
</span>
</div>
)}
{WITHDRAWABLE_STATUSES.has(appStatus.application.status) ? (
<Button
size="sm"
variant="outline"
type="button"
loading={withdrawing}
onClick={() => void onWithdraw()}
>
Withdraw application
</Button>
) : (
<p className="text-xs text-slate-500 dark:text-slate-400">
{terminalCopy(appStatus.application.status)}
</p>
)}
{withdrawError && (
<p role="alert" className="text-xs text-red-600 dark:text-red-400">
{withdrawError}
</p>
)}
</div>
) : confirmation ? (
<div
role="status"
className="flex flex-col gap-1 rounded-lg border border-emerald-300 bg-emerald-50 px-4 py-3 text-sm text-emerald-800 dark:border-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-200"
@@ -457,3 +605,78 @@ export function JobDetailClient() {
function matchPercent(score: number): number {
return Math.max(0, Math.round(score * 100));
}
/** The seven D-099 states, honestly labeled (no stage invented beyond
* the matrix; unknown statuses render verbatim, never invented). */
function applicationStatusLabel(status: ApplicationStatus): string {
switch (status) {
case 'submitted':
return 'Submitted';
case 'in_review':
return 'In review';
case 'interview':
return 'Interview';
case 'offer':
return 'Offer';
case 'hired':
return 'Hired';
case 'rejected':
return 'Rejected';
case 'withdrawn':
return 'Withdrawn';
default:
return status;
}
}
/** Ledger event labels (the D-101 vocabulary) — unknown event names
* render verbatim, never invented. */
function applicationEventLabel(eventType: string): string {
switch (eventType) {
case 'application.in_review':
return 'Moved to review';
case 'application.interview':
return 'Interview stage';
case 'application.offer':
return 'Offer received';
case 'application.hired':
return 'Hired';
case 'application.rejected':
return 'Rejected';
case 'application.withdrawn':
return 'Withdrawn';
case 'placement.created':
return 'Placement recorded';
case 'placement.revoked':
return 'Placement revoked (operator correction)';
case 'placement.rehired':
return 'Placement re-recorded (operator correction)';
default:
return eventType;
}
}
/** The learner's legal withdraw sources (D-099: terminals have no
* outgoing edges). */
const WITHDRAWABLE_STATUSES: ReadonlySet<ApplicationStatus> = new Set([
'submitted',
'in_review',
'interview',
'offer',
]);
/** Terminal copy (the honest end states — no resurrection, no
* fabricated next step). */
function terminalCopy(status: ApplicationStatus): string {
switch (status) {
case 'hired':
return 'Hired — placement details appear above when a placement is recorded.';
case 'rejected':
return 'This application was not taken forward.';
case 'withdrawn':
return 'You withdrew this application.';
default:
return '';
}
}
+209
View File
@@ -47,6 +47,13 @@ import type {
AdminClaimsResponse,
ClaimDecisionResponse,
AdminAuditResponse,
EmployerApplicationsResponse,
ApplicationTransitionResponse,
HireTransitionRequest,
EmployerPlacementsResponse,
LearnerApplicationStatus,
MineApplication,
PlacementOverview,
} from '@nextcraft/types';
import { engineBaseUrl } from './engine-base-url';
@@ -1171,3 +1178,205 @@ export async function completeOwnEmployerMockCheckout(
},
);
}
// -- placements (v0.11 P3, REQ-11-002/003, D-110) ----------------------------------
/** GET /v1/employers/applications — the session employer's OWN hiring
* pipeline (every status, per-status facets). Expired session →
* SessionRequiredError; unclaimed → ClaimRequiredError (the G-311
* gates surface as the honest state classes, never a re-fetch). */
export async function listOwnEmployerApplications(
signal?: AbortSignal,
): Promise<EmployerApplicationsResponse> {
return jsonFetch<EmployerApplicationsResponse>('/v1/employers/applications', {
headers: employerHeaders(),
signal,
});
}
/** The shared per-target transition transport (D-105: the matrix stays
* OUT of the request bodies — each call names its target). 401 →
* SessionRequiredError; 403-claim → ClaimRequiredError; 403
* cross-entity + 409 matrix → EngineError with the honest status
* (the surfaces render the state from err.status — NO new error
* classes, D-110). */
async function employerTransition(
applicationId: string,
target: 'in-review' | 'interview' | 'offer' | 'reject' | 'hire',
body?: HireTransitionRequest,
signal?: AbortSignal,
): Promise<ApplicationTransitionResponse> {
return jsonFetch<ApplicationTransitionResponse>(
`/v1/employers/applications/${encodeURIComponent(applicationId)}/${target}`,
{
method: 'POST',
headers: employerHeaders(),
body: body === undefined ? undefined : JSON.stringify(body),
signal,
},
);
}
/** POST .../in-review — submitted → in_review. */
export function employerMarkInReview(
applicationId: string,
signal?: AbortSignal,
): Promise<ApplicationTransitionResponse> {
return employerTransition(applicationId, 'in-review', undefined, signal);
}
/** POST .../interview. */
export function employerMarkInterview(
applicationId: string,
signal?: AbortSignal,
): Promise<ApplicationTransitionResponse> {
return employerTransition(applicationId, 'interview', undefined, signal);
}
/** POST .../offer. */
export function employerMarkOffer(
applicationId: string,
signal?: AbortSignal,
): Promise<ApplicationTransitionResponse> {
return employerTransition(applicationId, 'offer', undefined, signal);
}
/** POST .../reject — terminal. */
export function employerRejectApplication(
applicationId: string,
signal?: AbortSignal,
): Promise<ApplicationTransitionResponse> {
return employerTransition(applicationId, 'reject', undefined, signal);
}
/** POST .../hire — terminal; carries the employer-attested
* {role_title, start_date} (D-103/G-324). The response carries the
* created placement record. */
export function employerHireApplication(
applicationId: string,
body: HireTransitionRequest,
signal?: AbortSignal,
): Promise<ApplicationTransitionResponse> {
return employerTransition(applicationId, 'hire', body, signal);
}
/** GET /v1/employers/placements — the claimed entity's OWN placements
* (active + revoked, the full history; the overview's placements
* section data source, D-109 surface 2). */
export async function listOwnEmployerPlacements(
signal?: AbortSignal,
): Promise<EmployerPlacementsResponse> {
return jsonFetch<EmployerPlacementsResponse>('/v1/employers/placements', {
headers: employerHeaders(),
signal,
});
}
// -- learner placement status (v0.11 P5, REQ-11-005, D-104/G-322) -------------------
/** GET /v1/marketplace/applications/mine?posting_id=&learner_id= —
* G-322 (binding) discovery: the status panel finds the application by
* the (posting, learner) pair — NEVER a client-persisted
* application_id. 404 = no application yet (the surface renders the
* apply CTA — honest absence). */
export async function getMyApplication(
postingId: string,
learnerId: string = MOCK_LEARNER_ID,
signal?: AbortSignal,
): Promise<MineApplication> {
return jsonFetch<MineApplication>(
`/v1/marketplace/applications/mine?posting_id=${encodeURIComponent(postingId)}&learner_id=${encodeURIComponent(learnerId)}`,
{ signal },
);
}
/** GET /v1/marketplace/applications/{application_id}?learner_id= —
* D-104: the learner's-own outcome read — current status + the full
* id-ordered timestamped history + the placement record when hired
* (one read, one truth source). 403 foreign → EngineError (the
* surface renders the honest "not your application" state from
* err.status — NO new error classes, D-110). */
export async function getLearnerApplicationStatus(
applicationId: string,
learnerId: string = MOCK_LEARNER_ID,
signal?: AbortSignal,
): Promise<LearnerApplicationStatus> {
return jsonFetch<LearnerApplicationStatus>(
`/v1/marketplace/applications/${encodeURIComponent(applicationId)}?learner_id=${encodeURIComponent(learnerId)}`,
{ signal },
);
}
/** POST /v1/marketplace/applications/{application_id}/withdraw — the
* learner's ONLY edit (D-099/D-104); terminal → 409 (the matrix
* surfaces as EngineError.status — the panel renders the honest
* terminal state, never a re-fetch). */
export async function withdrawApplication(
applicationId: string,
learnerId: string = MOCK_LEARNER_ID,
signal?: AbortSignal,
): Promise<ApplicationConfirmation> {
return jsonFetch<ApplicationConfirmation>(
`/v1/marketplace/applications/${encodeURIComponent(applicationId)}/withdraw`,
{
method: 'POST',
body: JSON.stringify({ learner_id: learnerId }),
signal,
},
);
}
// -- admin placements (v0.11 P3/P4, REQ-11-003/004, D-107/D-108) --------------------
/** GET /v1/placements/admin/overview — the D-107 computation over the
* G-308 admin transport: distinct learners toward the 1,000 guiding
* goal, active placements, revoked count, per-employer + per-cohort
* month breakdowns. G-328: the caller labels these as RECORDED
* placements — never a verification claim. */
export async function getPlacementOverview(
signal?: AbortSignal,
): Promise<PlacementOverview> {
return adminJsonFetch<PlacementOverview>('/v1/placements/admin/overview', {
signal,
});
}
/** GET /v1/placements/admin/export — the self-contained audit JSON
* (D-108: cohort + evidence bundles + methodology + the ledger with
* head_hash). The caller downloads the document as a JSON blob. */
export async function getPlacementExport(signal?: AbortSignal): Promise<string> {
const token = adminToken();
const resp = await fetch(
`${AI_SERVICE_URL}/v1/placements/admin/export`,
{
headers: token ? { 'X-Admin-Token': token } : {},
signal,
},
);
if (!resp.ok) {
const err = await parseError(resp);
throw err;
}
return resp.text();
}
/** GET /v1/placements/admin/export/summary — the human-readable
* markdown rendering FROM the same document (D-108's anti-drift
* rule); opens in-tab. */
export async function getPlacementExportSummary(
signal?: AbortSignal,
): Promise<string> {
const token = adminToken();
const resp = await fetch(
`${AI_SERVICE_URL}/v1/placements/admin/export/summary`,
{
headers: token ? { 'X-Admin-Token': token } : {},
signal,
},
);
if (!resp.ok) {
const err = await parseError(resp);
throw err;
}
return resp.text();
}
+9 -3
View File
@@ -65,8 +65,10 @@ test("pin: talent pages keep their mock imports + the mock-labeled metrics stay
assert.ok(/import \{ candidates \} from '@nextcraft\/mock-data'/.test(talent), "talent search keeps the candidates mock");
assert.ok(/import \{ candidates, competencyStacks \} from '@nextcraft\/mock-data'/.test(talentDetail), "candidate profile keeps the candidates mock");
// The overview's talent-match/placement metrics stay MOCK-LABELED —
// the honest label rides the card copy (never presented as real).
// The overview's talent-match metrics stay MOCK-LABELED — the
// honest label rides the card copy (never presented as real).
// v0.11 P3: placement counts are now REAL (the D-092 conversion —
// the convertible surface left mock); only talent-match stays mock.
const overviewClient = readFileSync(
web("components/employer/employer-overview-client.tsx"),
"utf8",
@@ -77,10 +79,14 @@ test("pin: talent pages keep their mock imports + the mock-labeled metrics stay
assert.ok(/no backend\s+computes them yet/.test(overviewClient), "analytics notice is honest about the mock");
// The REAL arms: active-postings + applicant counts from the real
// route; the claimed-entity profile via the public marketplace fetch.
// route; the claimed-entity profile via the public marketplace fetch;
// v0.11: the placements section is REAL (listOwnEmployerPlacements).
assert.ok(/listOwnEmployerPostings/.test(overviewClient), "overview fetches own postings");
assert.ok(/applicant_count/.test(overviewClient), "overview renders per-posting applicant counts");
assert.ok(/getEmployer\(/.test(overviewClient), "overview fetches the claimed-entity profile (public route)");
assert.ok(/listOwnEmployerPlacements/.test(overviewClient), "overview fetches REAL placements (v0.11)");
assert.ok(/Placements Recorded/.test(overviewClient), "the real placement metric renders");
assert.ok(!/MockMetricCard label="Placement Rate"/.test(overviewClient), "the placement mock card is GONE (converted)");
});
test("pin: the session-gated clients carry every honest-state branch (MH-3b, G-312 source pins)", async () => {
@@ -0,0 +1,232 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
/**
* v0.11 P5 (REQ-11-005, D-104/G-322): the learner placement-status
* client + panel wiring. Fetch stubs pin the wire shapes + the query
* composition (the billing-entitlement template); source pins cover
* the panel's mine-read-first composition, the honest state arms, and
* the A-304 badge honesty (the G-312 discipline — no render harness
* in-tree, none may be added).
*/
const QS = `?t=${Date.now()}-${Math.random()}`;
const root = new URL("..", import.meta.url);
function web(path: string): string {
return fileURLToPath(new URL(path, root));
}
const STATUS_PAYLOAD = {
application: {
id: "app-1",
posting_id: "post-1",
learner_id: "pilot-learner",
status: "in_review",
created_at: "2026-10-21T00:00:00Z",
},
events: [
{
id: 1,
application_id: "app-1",
placement_id: null,
event_type: "application.in_review",
actor_type: "employer",
actor_ref: "employer:emp-test",
payload: {},
prev_hash: "0".repeat(64),
hash: "a".repeat(64),
created_at: "2026-10-21T00:01:00Z",
},
],
placement: null,
};
interface Call {
url: string;
method?: string;
body?: unknown;
headers: Record<string, string>;
}
function stubFetch(handler: () => Response): { calls: Call[]; restore: () => void } {
const realFetch = globalThis.fetch;
const calls: Call[] = [];
globalThis.fetch = (async (input: unknown, init?: RequestInit) => {
calls.push({
url: String(input),
method: init?.method,
body: init?.body ? JSON.parse(String(init.body)) : undefined,
headers: (init?.headers ?? {}) as Record<string, string>,
});
return handler();
}) as typeof fetch;
return { calls, restore: () => { globalThis.fetch = realFetch; } };
}
const ok = (payload: unknown) =>
new Response(JSON.stringify(payload), {
status: 200,
headers: { "Content-Type": "application/json" },
});
test("getMyApplication: GETs the mine route with the (posting, learner) pair — G-322", async () => {
const { calls, restore } = stubFetch(() =>
ok({ application_id: "app-1", status: "submitted" }),
);
try {
const { getMyApplication } = await import(`../lib/engine-client.ts${QS}`);
const mine = await getMyApplication("post-1", "pilot-learner");
assert.equal(mine.application_id, "app-1");
assert.equal(mine.status, "submitted");
const url = new URL(calls[0].url, "http://localhost");
assert.equal(url.pathname, "/v1/marketplace/applications/mine");
assert.equal(url.searchParams.get("posting_id"), "post-1");
assert.equal(url.searchParams.get("learner_id"), "pilot-learner");
assert.equal(calls[0].method, undefined, "the discovery read is a GET");
} finally {
restore();
}
});
test("getMyApplication: no application → EngineError 404 (the apply-CTA arm)", async () => {
const { restore } = stubFetch(
() => new Response(JSON.stringify({ detail: "none" }), { status: 404 }),
);
try {
const { getMyApplication, EngineError } = await import(`../lib/engine-client.ts${QS}`);
await assert.rejects(getMyApplication("post-1", "pilot-learner"), (err: unknown) => {
assert.ok(err instanceof EngineError, "must be EngineError");
const e = err as InstanceType<typeof EngineError>;
return e.status === 404;
});
} finally {
restore();
}
});
test("getLearnerApplicationStatus: GETs the status read with the learner query", async () => {
const { calls, restore } = stubFetch(() => ok(STATUS_PAYLOAD));
try {
const { getLearnerApplicationStatus } = await import(`../lib/engine-client.ts${QS}`);
const status = await getLearnerApplicationStatus("app-1", "pilot-learner");
assert.equal(status.application.status, "in_review");
assert.equal(status.events.length, 1);
assert.equal(status.events[0].event_type, "application.in_review");
assert.equal(status.placement, null);
const url = new URL(calls[0].url, "http://localhost");
assert.equal(url.pathname, "/v1/marketplace/applications/app-1");
assert.equal(url.searchParams.get("learner_id"), "pilot-learner");
} finally {
restore();
}
});
test("withdrawApplication: POSTs the withdraw body — the learner's only edit (D-104)", async () => {
const { calls, restore } = stubFetch(() =>
ok({ application_id: "app-1", posting_id: "post-1", status: "withdrawn" }),
);
try {
const { withdrawApplication } = await import(`../lib/engine-client.ts${QS}`);
const confirmation = await withdrawApplication("app-1", "pilot-learner");
assert.equal(confirmation.status, "withdrawn");
const url = new URL(calls[0].url, "http://localhost");
assert.equal(url.pathname, "/v1/marketplace/applications/app-1/withdraw");
assert.equal(calls[0].method, "POST");
assert.deepEqual(calls[0].body, { learner_id: "pilot-learner" });
} finally {
restore();
}
});
test("withdrawApplication: terminal → EngineError 409 (the honest matrix refusal)", async () => {
const { restore } = stubFetch(
() => new Response(JSON.stringify({ detail: "matrix" }), { status: 409 }),
);
try {
const { withdrawApplication, EngineError } = await import(`../lib/engine-client.ts${QS}`);
await assert.rejects(withdrawApplication("app-1", "pilot-learner"), (err: unknown) => {
assert.ok(err instanceof EngineError, "must be EngineError");
const e = err as InstanceType<typeof EngineError>;
return e.status === 409;
});
} finally {
restore();
}
});
test("getLearnerApplicationStatus: foreign → EngineError 403 (the not-your-application arm)", async () => {
const { restore } = stubFetch(
() => new Response(JSON.stringify({ detail: "foreign" }), { status: 403 }),
);
try {
const { getLearnerApplicationStatus, EngineError } = await import(`../lib/engine-client.ts${QS}`);
await assert.rejects(getLearnerApplicationStatus("app-1", "pilot-learner"), (err: unknown) => {
assert.ok(err instanceof EngineError, "must be EngineError");
const e = err as InstanceType<typeof EngineError>;
return e.status === 403;
});
} finally {
restore();
}
});
test("pin: the panel composes mine-read first — never client-persisted application ids (G-322)", async () => {
const src = readFileSync(web("components/marketplace/job-detail-client.tsx"), "utf8");
assert.ok(/getMyApplication\(/.test(src), "the mine-read drives discovery");
assert.ok(/getLearnerApplicationStatus\(/.test(src), "the status read drives the panel");
assert.ok(/withdrawApplication\(/.test(src), "the withdraw CTA dispatches through the client");
assert.ok(
!/localStorage\.setItem|sessionStorage\.setItem/.test(src),
"no client-persisted application ids (no setItem anywhere)",
);
// mine 404 → the apply CTA (the honest absence arm)
assert.ok(
/mineErr instanceof EngineError && mineErr\.status === 404/.test(src),
"the 404 arm falls through to the apply CTA",
);
});
test("pin: the seven D-099 states render honestly (labels + withdraw gate + terminal/403 copy)", async () => {
const src = readFileSync(web("components/marketplace/job-detail-client.tsx"), "utf8");
for (const state of [
"'submitted'",
"'in_review'",
"'interview'",
"'offer'",
"'hired'",
"'rejected'",
"'withdrawn'",
]) {
assert.ok(src.includes(state), `the ${state} arm renders a label`);
}
assert.ok(
/WITHDRAWABLE_STATUSES/.test(src),
"the withdraw CTA gates on non-terminal sources (D-099)",
);
assert.ok(/status === 409/.test(src), "the 409 arm renders the honest terminal copy");
assert.ok(
/This application belongs to another learner\./.test(src),
"the foreign-403 copy renders",
);
});
test("pin: history rows + the placement-confirmation render from the status payload", async () => {
const src = readFileSync(web("components/marketplace/job-detail-client.tsx"), "utf8");
assert.ok(/appStatus\.events\.map\(/.test(src), "history rows render from events[]");
assert.ok(
/appStatus\.placement &&/.test(src),
"the placement confirmation renders when hired",
);
assert.ok(
/role_title/.test(src) && /start_date/.test(src),
"the placement payload fields render",
);
assert.ok(
/Placement recorded/.test(src),
"the placement copy says RECORDED (G-328 — never a verification claim)",
);
});
+168
View File
@@ -774,3 +774,171 @@ test("pricing pin: purchasable CTAs route to /employer/login; the enterprise Con
);
assert.ok(/mock provider \(honestly labeled/.test(client), "the pilot mock path is labeled");
});
// v0.11 P3 (D-110): the placement wire pins — placement.ts mirrors
// placements/base.py field-for-field (the G-108 discipline; the
// employer.ts block's template). Every change happens in both places.
test("wire pin: Placement mirrors placements/base.py PlacementRead field-for-field (dual-schema sync)", async () => {
const typesSrc = readFileSync(web("../../packages/types/placement.ts"), "utf8");
const pySrc = readFileSync(
web("../../apps/ai-service/ai_service/placements/base.py"),
"utf8",
);
const block = typesSrc.slice(
typesSrc.indexOf("export interface Placement {"),
typesSrc.indexOf("export interface PlacementEvent"),
);
const fields = [
"id:", "learner_id:", "employer_id:", "posting_id:",
"application_id:", "role_title:", "start_date:", "status:",
"created_at:", "decided_at:",
];
for (const f of fields) {
assert.ok(block.includes(f), `Placement missing base.py field ${f}`);
}
// start_date is a DATE on both sides (D-103) — never a datetime
assert.ok(
/start_date: date/.test(pySrc),
"base.py start_date is a pydantic date",
);
assert.ok(
/start_date: string;/.test(block),
"placement.ts start_date is the ISO date string",
);
});
test("wire pin: ApplicationStatus union mirrors the D-099 matrix (status-union regex pin)", async () => {
const typesSrc = readFileSync(web("../../packages/types/placement.ts"), "utf8");
const block = typesSrc.slice(
typesSrc.indexOf("export type ApplicationStatus"),
typesSrc.indexOf("export type PlacementStatus"),
);
// the seven D-099 statuses, in matrix order
const statuses = [
"'submitted'", "'in_review'", "'interview'", "'offer'",
"'hired'", "'rejected'", "'withdrawn'",
];
for (const status of statuses) {
assert.ok(block.includes(status), `ApplicationStatus missing ${status}`);
}
assert.ok(
!/"submitted"/.test(block),
"snake_case on the wire (no kebab 'in-review')",
);
});
test("wire pin: PlacementOverview mirrors the D-107 computation shape", async () => {
const typesSrc = readFileSync(web("../../packages/types/placement.ts"), "utf8");
const block = typesSrc.slice(
typesSrc.indexOf("export interface PlacementOverview"),
);
const fields = [
"total_active_placements:", "distinct_learners_placed:",
"guiding_goal:", "by_employer:", "by_cohort_month:",
];
for (const f of fields) {
assert.ok(block.includes(f), `PlacementOverview missing ${f}`);
}
assert.ok(
/export const GUIDING_GOAL = 1000;/.test(typesSrc),
"the north star is 1,000 learners",
);
});
test("wire pin: EmployerApplicationSummary carries learner_id per the G-319 supersession (D-105)", async () => {
const typesSrc = readFileSync(web("../../packages/types/placement.ts"), "utf8");
const block = typesSrc.slice(
typesSrc.indexOf("export interface EmployerApplicationSummary"),
typesSrc.indexOf("export interface EmployerApplicationsResponse"),
);
assert.ok(/learner_id: string;/.test(block), "learner_id rides the summary");
// the own-postings LIST stays counts-only: OwnPostingSummary (the
// v0.10 contract) still carries ONLY applicant_count — never a
// learner_id field
const employerTypes = readFileSync(web("../../packages/types/employer.ts"), "utf8");
const ownBlock = employerTypes.slice(
employerTypes.indexOf("export interface OwnPostingSummary"),
employerTypes.indexOf("export interface OwnPostingsFacets"),
);
assert.ok(
!/learner_id/.test(ownBlock),
"the G-319 pin split: OwnPostingSummary stays counts-only",
);
});
// v0.11 P5 (REQ-11-005, D-110): the learner status-read wire pins +
// the A-304 badge-honesty census — no placement badge on the mock
// talent surfaces (D-092 binding: a badge on a mock page is
// fabricated placement); the learner's-own application surface is the
// ONLY placement render.
test("wire pin: LearnerApplicationStatus mirrors the base.py learner status read (D-104)", async () => {
const typesSrc = readFileSync(web("../../packages/types/placement.ts"), "utf8");
const pySrc = readFileSync(
web("../../apps/ai-service/ai_service/placements/base.py"),
"utf8",
);
const summaryBlock = typesSrc.slice(
typesSrc.indexOf("export interface LearnerApplicationSummary"),
typesSrc.indexOf("export interface LearnerApplicationStatus"),
);
for (const f of ["id:", "posting_id:", "learner_id:", "status:", "created_at:"]) {
assert.ok(summaryBlock.includes(f), `LearnerApplicationSummary missing ${f}`);
}
const statusBlock = typesSrc.slice(
typesSrc.indexOf("export interface LearnerApplicationStatus"),
);
assert.ok(/application: LearnerApplicationSummary;/.test(statusBlock));
assert.ok(/events: PlacementEvent\[\];/.test(statusBlock));
assert.ok(/placement: Placement \| null;/.test(statusBlock));
// the backend contract carries the same three arms (base.py)
const pyBlock = pySrc.slice(pySrc.indexOf("class LearnerApplicationStatusResponse"));
assert.ok(/application: LearnerApplicationSummary/.test(pyBlock));
assert.ok(/events: list\[PlacementEventRead\]/.test(pyBlock));
assert.ok(/placement: PlacementRead \| None/.test(pyBlock));
// the mine-read contract (G-322)
const mineBlock = typesSrc.slice(typesSrc.indexOf("export interface MineApplication"));
assert.ok(/application_id: string;/.test(mineBlock));
assert.ok(/status: ApplicationStatus;/.test(mineBlock));
assert.ok(
/class MineApplicationResponse/.test(pySrc),
"base.py carries the mine contract",
);
});
test("pin: A-304 badge honesty — no placement badge on the mock talent surfaces (D-092/D-109)", async () => {
const surfaces = [
"app/(employer)/employer/talent/page.tsx",
"app/(employer)/employer/talent/[candidateId]/page.tsx",
"components/employer/talent-search.tsx",
];
for (const surface of surfaces) {
const src = readFileSync(web(surface), "utf8");
assert.ok(
!/placement/i.test(src),
`${surface}: a badge on a mock surface is fabricated placement (D-092 binding)`,
);
}
// the learner's-own application surface is the ONLY placement render
const jobDetail = readFileSync(
web("components/marketplace/job-detail-client.tsx"),
"utf8",
);
assert.ok(
/appStatus\.placement &&/.test(jobDetail),
"the placement confirmation renders only on the learner's-own application surface",
);
const placementBlock = jobDetail.slice(
jobDetail.indexOf("appStatus.placement &&"),
jobDetail.indexOf("{WITHDRAWABLE_STATUSES.has("),
);
assert.ok(
/Placement recorded/.test(placementBlock),
"G-328: the placement copy says RECORDED",
);
assert.ok(
!/verified/i.test(placementBlock),
"G-328: no verification claim on the placement surface",
);
});
+1
View File
@@ -4,6 +4,7 @@ export * from './employer';
export * from './defense';
export * from './grading';
export * from './marketplace';
export * from './placement';
export * from './telemetry';
export * from './user';
export * from './ui';
+167
View File
@@ -0,0 +1,167 @@
/**
* Nextcraft — Placement Types
*
* Placement-tracking wire shapes (v0.11, REQ-11-001..005): the
* hiring-pipeline surface served by /v1/employers/applications/*,
* /v1/marketplace/applications/*, and /v1/placements/admin/*.
*
* Dual-schema-sync mirror of
* `apps/ai-service/ai_service/placements/base.py` (the G-108
* discipline, D-110): field-for-field parity, snake_case as emitted
* by the API — every change happens in both places, pinned by
* `apps/web/tests/pins.test.ts`.
*
* D-099: the application status set is forward-only with
* skip-forward; hired/rejected/withdrawn are TERMINAL.
*
* G-319 supersession (D-105): learner_id IS visible on
* application-scoped reads for the session's own claimed-entity
* postings — the own-postings LIST stays counts-only.
*/
/** D-099: the hiring lifecycle (snake_case, the billing `past_due`
* precedent). hired/rejected/withdrawn are terminal — no outgoing
* edges. */
export type ApplicationStatus =
| 'submitted'
| 'in_review'
| 'interview'
| 'offer'
| 'hired'
| 'rejected'
| 'withdrawn';
/** D-102: the placement lifecycle — created active; the ONLY
* transition is the operator correction active → revoked. */
export type PlacementStatus = 'active' | 'revoked';
/** A placement record (base.py PlacementRead). employer/learner/
* posting are ID REFERENCES resolved from source rows at read/export
* (G-302) — never trusted denormalized copies. start_date is the
* employer-attested employment start DATE (D-103), never a
* datetime. */
export interface Placement {
id: string;
learner_id: string;
employer_id: string;
posting_id: string;
application_id: string;
role_title: string;
start_date: string;
status: PlacementStatus;
created_at: string;
decided_at: string;
}
/** One ledger event (base.py PlacementEventRead): the append-only
* hash-chained trail — application-stage events carry
* application_id (placement_id null); the placement lane carries
* placement_id set. */
export interface PlacementEvent {
id: number;
application_id: string | null;
placement_id: string | null;
event_type: string;
actor_type: string;
actor_ref: string;
payload: Record<string, unknown>;
prev_hash: string;
hash: string;
created_at: string;
}
/** An employer-facing application row (base.py
* EmployerApplicationSummary, D-105/G-319): learner_id visible on
* application-scoped reads ONLY (the own-postings list stays
* counts-only — the pin split). */
export interface EmployerApplicationSummary {
id: string;
posting_id: string;
learner_id: string;
status: ApplicationStatus;
created_at: string | null;
}
/** GET /v1/employers/applications response (base.py
* EmployerApplicationsResponse): own-pipeline applications (every
* status) + per-status facet counts (every status key ALWAYS
* present — a zero-count status carries its honest 0). */
export interface EmployerApplicationsResponse {
applications: EmployerApplicationSummary[];
facets: Record<ApplicationStatus, number>;
total: number;
}
/** The transition result (base.py ApplicationTransitionResponse):
* the application's new status + the placement record when the
* transition created one (hire). */
export interface ApplicationTransitionResponse {
application_id: string;
status: ApplicationStatus;
placement: Placement | null;
}
/** POST .../hire body (base.py HireTransitionRequest, D-103/G-324):
* role_title is employer-attested free text (1..200 — the house
* bounds); start_date is the employment start DATE (YYYY-MM-DD). */
export interface HireTransitionRequest {
role_title: string;
start_date: string;
}
/** GET /v1/employers/placements response (base.py
* EmployerPlacementsResponse): the claimed entity's OWN placements
* (active + revoked — the full history), created_at then id. */
export interface EmployerPlacementsResponse {
placements: Placement[];
total: number;
}
/** D-107: the guiding outcome's north star (PROJECT.md line 7 — the
* noun is LEARNERS, not placements). */
export const GUIDING_GOAL = 1000;
/** GET /v1/placements/admin/overview response (base.py
* PlacementOverview, D-107): counts computed from rows at query
* time — never denormalized counters. The guiding metric is
* DISTINCT learners with ≥1 active placement (a learner placed
* twice counts ONCE); revoked excluded from every count; cohort
* month = the calendar month of start_date (YYYY-MM). */
export interface PlacementOverview {
total_active_placements: number;
distinct_learners_placed: number;
guiding_goal: number;
by_employer: Array<{ employer_id: string; count: number }>;
by_cohort_month: Array<{ month: string; count: number }>;
}
/** The application row on the learner status read (base.py
* LearnerApplicationSummary, D-104): the mine-visible fields only —
* ids + status + the apply timestamp (nullable per the v0.6 row). */
export interface LearnerApplicationSummary {
id: string;
posting_id: string;
learner_id: string;
status: ApplicationStatus;
created_at: string | null;
}
/** GET /v1/marketplace/applications/{application_id}?learner_id=
* response (base.py LearnerApplicationStatusResponse, REQ-11-005):
* the learner's-own application read — current status + the full
* id-ordered event history for THIS application + the placement
* record when hired (the honest null otherwise, A-304). */
export interface LearnerApplicationStatus {
application: LearnerApplicationSummary;
events: PlacementEvent[];
placement: Placement | null;
}
/** GET /v1/marketplace/applications/mine response (base.py
* MineApplicationResponse, G-322): the panel's application discovery
* on revisit — a 404 renders the apply CTA (the panel never depends
* on client-persisted application_ids). */
export interface MineApplication {
application_id: string;
status: ApplicationStatus;
}