diff --git a/.ciagent/CHECKPOINT.json b/.ciagent/CHECKPOINT.json new file mode 100644 index 0000000..ef6a4ca --- /dev/null +++ b/.ciagent/CHECKPOINT.json @@ -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" +} \ No newline at end of file diff --git a/.ciagent/GRILL.md b/.ciagent/GRILL.md index 0442929..cffe797 100644 --- a/.ciagent/GRILL.md +++ b/.ciagent/GRILL.md @@ -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 diff --git a/.ciagent/PERSONAS.md b/.ciagent/PERSONAS.md index 339c2dd..511e623 100644 --- a/.ciagent/PERSONAS.md +++ b/.ciagent/PERSONAS.md @@ -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 | \ No newline at end of file diff --git a/.ciagent/PLAN.md b/.ciagent/PLAN.md index 743ed55..ab5d4c3 100644 --- a/.ciagent/PLAN.md +++ b/.ciagent/PLAN.md @@ -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$$` (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 ` 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 `
` 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. \ No newline at end of file +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). \ No newline at end of file diff --git a/.ciagent/PROJECT.md b/.ciagent/PROJECT.md index f52f3cf..f0835f1 100644 --- a/.ciagent/PROJECT.md +++ b/.ciagent/PROJECT.md @@ -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). diff --git a/.ciagent/REQUIREMENTS.md b/.ciagent/REQUIREMENTS.md index e02de1d..5366846 100644 --- a/.ciagent/REQUIREMENTS.md +++ b/.ciagent/REQUIREMENTS.md @@ -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 | diff --git a/.ciagent/RESEARCH.md b/.ciagent/RESEARCH.md index 45a659c..58e657c 100644 --- a/.ciagent/RESEARCH.md +++ b/.ciagent/RESEARCH.md @@ -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) diff --git a/.ciagent/ROADMAP.md b/.ciagent/ROADMAP.md index e95746f..13381c6 100644 --- a/.ciagent/ROADMAP.md +++ b/.ciagent/ROADMAP.md @@ -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 | |---|------|--------|------------|--------------|------------------| diff --git a/.ciagent/config.json b/.ciagent/config.json index 1de22e0..305087c 100644 --- a/.ciagent/config.json +++ b/.ciagent/config.json @@ -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" } } diff --git a/apps/ai-service/ai_service/api/deps.py b/apps/ai-service/ai_service/api/deps.py index 55f2865..fa39fd0 100644 --- a/apps/ai-service/ai_service/api/deps.py +++ b/apps/ai-service/ai_service/api/deps.py @@ -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.""" diff --git a/apps/ai-service/ai_service/api/employers.py b/apps/ai-service/ai_service/api/employers.py index 9c9acc6..d2e8eb2 100644 --- a/apps/ai-service/ai_service/api/employers.py +++ b/apps/ai-service/ai_service/api/employers.py @@ -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) ------------------ diff --git a/apps/ai-service/ai_service/api/marketplace.py b/apps/ai-service/ai_service/api/marketplace.py index ad6e9fe..12110c5 100644 --- a/apps/ai-service/ai_service/api/marketplace.py +++ b/apps/ai-service/ai_service/api/marketplace.py @@ -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) -------------------- diff --git a/apps/ai-service/ai_service/api/placements_admin.py b/apps/ai-service/ai_service/api/placements_admin.py new file mode 100644 index 0000000..d559a13 --- /dev/null +++ b/apps/ai-service/ai_service/api/placements_admin.py @@ -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", + ) diff --git a/apps/ai-service/ai_service/employers/store.py b/apps/ai-service/ai_service/employers/store.py index c61ec4f..368ef9d 100644 --- a/apps/ai-service/ai_service/employers/store.py +++ b/apps/ai-service/ai_service/employers/store.py @@ -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): diff --git a/apps/ai-service/ai_service/main.py b/apps/ai-service/ai_service/main.py index ec31923..3817cae 100644 --- a/apps/ai-service/ai_service/main.py +++ b/apps/ai-service/ai_service/main.py @@ -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. diff --git a/apps/ai-service/ai_service/marketplace/store.py b/apps/ai-service/ai_service/marketplace/store.py index aaf6785..9d87bad 100644 --- a/apps/ai-service/ai_service/marketplace/store.py +++ b/apps/ai-service/ai_service/marketplace/store.py @@ -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]: diff --git a/apps/ai-service/ai_service/placements/__init__.py b/apps/ai-service/ai_service/placements/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/ai-service/ai_service/placements/base.py b/apps/ai-service/ai_service/placements/base.py new file mode 100644 index 0000000..2318090 --- /dev/null +++ b/apps/ai-service/ai_service/placements/base.py @@ -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 diff --git a/apps/ai-service/ai_service/placements/store.py b/apps/ai-service/ai_service/placements/store.py new file mode 100644 index 0000000..998a6f4 --- /dev/null +++ b/apps/ai-service/ai_service/placements/store.py @@ -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() diff --git a/apps/ai-service/tests/employers/test_auth.py b/apps/ai-service/tests/employers/test_auth.py index 749845e..8b106f6 100644 --- a/apps/ai-service/tests/employers/test_auth.py +++ b/apps/ai-service/tests/employers/test_auth.py @@ -120,7 +120,9 @@ class TestSessionGate: T+8d → the session reads as absent → 401 (no sleeps).""" from .conftest import make_client - login_at = datetime(2026, 9, 30, 12, 0, 0, tzinfo=UTC) + # Wall-clock-relative mint: the 200 arm below must hold on ANY + # run date (the fixed 2026-09-30 mint turned red at 2026-10-07+). + login_at = datetime.now(UTC) - timedelta(days=1) employer_store.insert_account( email=TEST_EMAIL, password_hash="scrypt$16384$8$1$c2FsdA$aGFzaA" ) @@ -140,7 +142,7 @@ class TestSessionGate: ) assert row is None # expired — reads as absent resp = c.get("/v1/employers/auth/me", headers=bearer("expired-token")) - # Wall clock (2026-09-30) is INSIDE the 7-day TTL → 200. + # Wall clock (1d after mint) is INSIDE the 7-day TTL → 200. assert resp.status_code == 200 # The lazy-TTL route 401 pin: a session whose expires_at is in # the WALL-CLOCK past reads as absent at the route. diff --git a/apps/ai-service/tests/placements/__init__.py b/apps/ai-service/tests/placements/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/ai-service/tests/placements/conftest.py b/apps/ai-service/tests/placements/conftest.py new file mode 100644 index 0000000..cf2adca --- /dev/null +++ b/apps/ai-service/tests/placements/conftest.py @@ -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} diff --git a/apps/ai-service/tests/placements/test_admin_lane.py b/apps/ai-service/tests/placements/test_admin_lane.py new file mode 100644 index 0000000..295c716 --- /dev/null +++ b/apps/ai-service/tests/placements/test_admin_lane.py @@ -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 diff --git a/apps/ai-service/tests/placements/test_employer_transitions.py b/apps/ai-service/tests/placements/test_employer_transitions.py new file mode 100644 index 0000000..7a4cd1d --- /dev/null +++ b/apps/ai-service/tests/placements/test_employer_transitions.py @@ -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" diff --git a/apps/ai-service/tests/placements/test_export.py b/apps/ai-service/tests/placements/test_export.py new file mode 100644 index 0000000..f88417f --- /dev/null +++ b/apps/ai-service/tests/placements/test_export.py @@ -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}] diff --git a/apps/ai-service/tests/placements/test_learner_status.py b/apps/ai-service/tests/placements/test_learner_status.py new file mode 100644 index 0000000..afe9996 --- /dev/null +++ b/apps/ai-service/tests/placements/test_learner_status.py @@ -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 diff --git a/apps/ai-service/tests/placements/test_learner_withdraw.py b/apps/ai-service/tests/placements/test_learner_withdraw.py new file mode 100644 index 0000000..95ee167 --- /dev/null +++ b/apps/ai-service/tests/placements/test_learner_withdraw.py @@ -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 diff --git a/apps/ai-service/tests/placements/test_reporting.py b/apps/ai-service/tests/placements/test_reporting.py new file mode 100644 index 0000000..c110b9c --- /dev/null +++ b/apps/ai-service/tests/placements/test_reporting.py @@ -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}, + ] diff --git a/apps/ai-service/tests/placements/test_store.py b/apps/ai-service/tests/placements/test_store.py new file mode 100644 index 0000000..cdfdde9 --- /dev/null +++ b/apps/ai-service/tests/placements/test_store.py @@ -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 diff --git a/apps/web/app/(employer)/employer/applications/page.tsx b/apps/web/app/(employer)/employer/applications/page.tsx new file mode 100644 index 0000000..7325c9e --- /dev/null +++ b/apps/web/app/(employer)/employer/applications/page.tsx @@ -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 ( +
+
+ Hiring Pipeline +

+ Hiring Pipeline +

+

+ 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. +

+
+ + +
+ ); +} \ No newline at end of file diff --git a/apps/web/components/admin/employer-network-panel.tsx b/apps/web/components/admin/employer-network-panel.tsx index f173b2c..aca11de 100644 --- a/apps/web/components/admin/employer-network-panel.tsx +++ b/apps/web/components/admin/employer-network-panel.tsx @@ -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(null); const [claims, setClaims] = useState(null); const [audit, setAudit] = useState(null); + const [overview, setOverview] = useState(null); + const [exportSummary, setExportSummary] = useState(null); + const [exportBusy, setExportBusy] = useState(false); + const [exportError, setExportError] = useState(null); const [locked, setLocked] = useState(false); const [error, setError] = useState(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 ( @@ -174,6 +219,67 @@ export function EmployerNetworkPanel() { {accounts && claims && audit && (
+ {/* 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 && ( +
+

+ Recorded placements +

+

+ + {overview.distinct_learners_placed} + {' '} + distinct learners placed (employer-attested records, toward + the {overview.guiding_goal} guiding goal) ·{' '} + {overview.total_active_placements} active placements +

+ {overview.by_employer.length > 0 && ( +
    + {overview.by_employer.map((bucket) => ( +
  • + {bucket.employer_id}: {bucket.count} +
  • + ))} +
+ )} +
+ + +
+ {exportError && ( +

{exportError}

+ )} + {exportSummary && ( +
+                    {exportSummary}
+                  
+ )} +
+ )} {/* Accounts list — email, claimed entity, created_at (the UNVERIFIED marker renders as such, D-097). */}
diff --git a/apps/web/components/employer/employer-applications-client.tsx b/apps/web/components/employer/employer-applications-client.tsx new file mode 100644 index 0000000..44ebef5 --- /dev/null +++ b/apps/web/components/employer/employer-applications-client.tsx @@ -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 = { + submitted: 'Submitted', + in_review: 'In Review', + interview: 'Interview', + offer: 'Offer', + hired: 'Hired', + rejected: 'Rejected', + withdrawn: 'Withdrawn', +}; + +const STATUS_BADGE: Record = { + 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> = { + 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(null); + const [loggedOut, setLoggedOut] = useState(false); + const [claimPrompt, setClaimPrompt] = useState(false); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(true); + // per-application action state: the honest pending/error pair + const [pending, setPending] = useState(null); + const [actionError, setActionError] = useState(null); + // the hire form: which application id is being hired + its fields + const [hireFor, setHireFor] = useState(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 { + 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 { + 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 ( + + + Loading your hiring pipeline… + + + ); + } + + if (loggedOut) { + return ( + + +

+ You are signed out — your session is missing or expired. +

+ + + +
+
+ ); + } + + if (claimPrompt) { + return ( + + +

+ No employer entity is claimed on this account yet. +

+

+ Claim your company's entity first — the hiring pipeline lists + applicants on your claimed entity's postings. +

+
+
+ ); + } + + if (error) { + return ( + + +

{error}

+

+ The hiring pipeline is unavailable — please try again shortly. +

+
+
+ ); + } + + if (!applications || applications.length === 0) { + return ( + + + No applications yet — applicants on your published postings appear + here as they apply. + + + ); + } + + return ( +
+ {actionError && ( + + {actionError} + + )} + {applications.map((application) => { + const targets = NEXT_TARGETS[application.status] ?? []; + return ( + + +
+
+ + {STATUS_LABELS[application.status]} + + + {application.learner_id} + + + applied {application.created_at ? application.created_at.slice(0, 10) : ''} + +
+ + {application.id} + +
+ + {hireFor === application.id ? ( +
+

+ Record the placement you attest (visible to the learner and + the audit export): +

+ setRoleTitle(e.target.value)} + placeholder="AI Orchestration Engineer" + maxLength={200} + /> + setStartDate(e.target.value)} + /> +
+ + +
+
+ ) : ( +
+ {targets.map((target) => ( + + ))} + {/* D-099: withdraw is NEVER offered to employers */} +
+ )} +
+
+ ); + })} +
+ ); +} \ No newline at end of file diff --git a/apps/web/components/employer/employer-overview-client.tsx b/apps/web/components/employer/employer-overview-client.tsx index 74da39a..4e143d0 100644 --- a/apps/web/components/employer/employer-overview-client.tsx +++ b/apps/web/components/employer/employer-overview-client.tsx @@ -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(null); const [postings, setPostings] = useState(null); const [profile, setProfile] = useState(null); + const [placements, setPlacements] = useState(null); const [loggedOut, setLoggedOut] = useState(false); const [claimPrompt, setClaimPrompt] = useState(false); const [error, setError] = useState(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 (
@@ -187,13 +204,20 @@ export function EmployerOverviewClient() {

- {/* 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). */}
+ -
{/* Middle row — per-posting applicant table + claimed-entity profile */} @@ -286,6 +310,66 @@ export function EmployerOverviewClient() {
+ {/* 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). */} +
+
+

+ Placements +

+ + Manage hiring pipeline + +
+ + {placements && placements.placements.length > 0 ? ( +
+ + + + + + + + + + + {placements.placements.map((p) => ( + + + + + + + ))} + +
RoleLearnerStart DateStatus
+ {p.role_title} + {p.learner_id}{p.start_date} + {p.status === 'active' ? ( + Active + ) : ( + Revoked + )} +
+
+ ) : ( + + No placements recorded yet — hires you record in the hiring + pipeline appear here. + + )} +
+
+ {/* Mock-labeled analytics (D-092: no backend computes these). */}

@@ -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 (

- 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.

); } diff --git a/apps/web/components/marketplace/job-detail-client.tsx b/apps/web/components/marketplace/job-detail-client.tsx index 26c76b4..0cb0d58 100644 --- a/apps/web/components/marketplace/job-detail-client.tsx +++ b/apps/web/components/marketplace/job-detail-client.tsx @@ -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(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(null); + const [withdrawing, setWithdrawing] = useState(false); + const [withdrawError, setWithdrawError] = useState(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() {
- {/* 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 ? ( +
+
+ + Your application + + + {applicationStatusLabel(appStatus.application.status)} + +
+ + {appStatus.application.created_at + ? `Applied ${relativeTime(appStatus.application.created_at)} · ` + : ''} + application {appStatus.application.id} + + {appStatus.events.length > 0 ? ( +
    + {appStatus.events.map((event) => ( +
  • + + {applicationEventLabel(event.event_type)} + + + {relativeTime(event.created_at)} + +
  • + ))} +
+ ) : ( + + No status updates yet. + + )} + {appStatus.placement && ( +
+ Placement recorded + + {appStatus.placement.role_title} at {employerName} · starts{' '} + {appStatus.placement.start_date} + +
+ )} + {WITHDRAWABLE_STATUSES.has(appStatus.application.status) ? ( + + ) : ( +

+ {terminalCopy(appStatus.application.status)} +

+ )} + {withdrawError && ( +

+ {withdrawError} +

+ )} +
+ ) : confirmation ? (
= 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 ''; + } +} diff --git a/apps/web/lib/engine-client.ts b/apps/web/lib/engine-client.ts index 793b7f3..7fd8baf 100644 --- a/apps/web/lib/engine-client.ts +++ b/apps/web/lib/engine-client.ts @@ -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 { + return jsonFetch('/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 { + return jsonFetch( + `/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 { + return employerTransition(applicationId, 'in-review', undefined, signal); +} + +/** POST .../interview. */ +export function employerMarkInterview( + applicationId: string, + signal?: AbortSignal, +): Promise { + return employerTransition(applicationId, 'interview', undefined, signal); +} + +/** POST .../offer. */ +export function employerMarkOffer( + applicationId: string, + signal?: AbortSignal, +): Promise { + return employerTransition(applicationId, 'offer', undefined, signal); +} + +/** POST .../reject — terminal. */ +export function employerRejectApplication( + applicationId: string, + signal?: AbortSignal, +): Promise { + 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 { + 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 { + return jsonFetch('/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 { + return jsonFetch( + `/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 { + return jsonFetch( + `/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 { + return jsonFetch( + `/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 { + return adminJsonFetch('/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 { + 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 { + 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(); +} diff --git a/apps/web/tests/employer-dashboard.test.ts b/apps/web/tests/employer-dashboard.test.ts index 8524688..4b67603 100644 --- a/apps/web/tests/employer-dashboard.test.ts +++ b/apps/web/tests/employer-dashboard.test.ts @@ -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 () => { diff --git a/apps/web/tests/learner-placement-status.test.ts b/apps/web/tests/learner-placement-status.test.ts new file mode 100644 index 0000000..ab902f8 --- /dev/null +++ b/apps/web/tests/learner-placement-status.test.ts @@ -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; +} + +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, + }); + 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; + 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; + 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; + 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)", + ); +}); diff --git a/apps/web/tests/pins.test.ts b/apps/web/tests/pins.test.ts index e0474e3..f672277 100644 --- a/apps/web/tests/pins.test.ts +++ b/apps/web/tests/pins.test.ts @@ -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", + ); +}); diff --git a/packages/types/index.ts b/packages/types/index.ts index 05a07ca..8ce224c 100644 --- a/packages/types/index.ts +++ b/packages/types/index.ts @@ -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'; diff --git a/packages/types/placement.ts b/packages/types/placement.ts new file mode 100644 index 0000000..7a54378 --- /dev/null +++ b/packages/types/placement.ts @@ -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; + 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; + 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; +}