Compare commits

..

3 Commits

Author SHA1 Message Date
CIAgent 6c9198ca56 docs(P01): complete employer-auth-foundation phase
CI / node (push) Successful in 40s
Release / release (push) Successful in 2m51s
CI / python (push) Successful in 2m38s
Employers module (ai_service/employers/): pydantic contracts,
8th D-027 SQLite store (4 tables), stdlib scrypt KDF (params pinned),
hashed server-side sessions (7-day lazy TTL), register/login/logout/me
routes, dual D-088 throttles, CORS Authorization preflight, PII scrub
in 422s. Security review: 1 P0 fixed (whole-body scrub), 3 P1
dispositioned. 888 fast-suite green, zero warnings. Grill bindings
G-311..G-320 honored.

---ci---
phase: 1
milestone: v0.10
status: complete
requirements:
  covered: [REQ-10-001]
  partial: []
---/ci---
2026-09-30 18:27:47 +00:00
CIAgent aed709b956 chore(P00): phase 0 complete — v0.9.0 shipped (release via tag lane)
CI / node (push) Successful in 44s
CI / python (push) Successful in 2m11s
---ci---
phase: 0
milestone: v0.10
status: complete
---/ci---
2026-09-30 15:19:48 +00:00
CIAgent 36cd9fa71f docs(P00): complete b2b-employer-network phase 0 — v0.9.0
CI / node (push) Successful in 43s
CI / python (push) Successful in 2m23s
Release / release (push) Successful in 2m54s
Specify (D-085 scope lock: REQ-F-019 activated as REQ-10-001..005),
clarify (D-086..D-097), research (R-55..R-66 — session-auth patterns,
scrypt, the claim-evidence seam; in-tree integration probes), plan
(6 vertical-slice phases, waves binding, must-haves falsifiable),
grill (G-311..G-320 binding decisions applied), MVP/UX gate PASS.
Tags on the v0.9.x line; branch
milestone/v0.10-b2b-employer-network.

---ci---
phase: 0
milestone: v0.10
status: complete
---/ci---
2026-09-30 15:19:25 +00:00
78 changed files with 1000 additions and 17078 deletions
+9
View File
@@ -0,0 +1,9 @@
{
"phase": 1,
"stage": "ship_pending",
"milestone": "v0.10",
"milestone_name": "b2b-employer-network",
"phase_role": "execution",
"attempts": 0,
"updated_at": "2026-09-30T17:00:00Z"
}
-62
View File
@@ -331,65 +331,3 @@ 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
+37 -35
View File
@@ -2,13 +2,13 @@
## Persona Roster
> **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).
> **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).
### lead-developer
```yaml
active: true
phase_specific: false
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)
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)
domain: coordination
frameworks:
- turborepo
@@ -27,7 +27,7 @@ territory:
```yaml
active: false
phase_specific: false
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.
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.
domain: devops
frameworks:
- docker
@@ -53,7 +53,7 @@ territory:
```yaml
active: false
phase_specific: false
reason: v0.11 DEACTIVATED — no CLI changes; the placement surface is server+web only. Roster + v0.8 territory retained.
reason: v0.10 DEACTIVATED — no CLI changes; the employer 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.11 DEACTIVATED — no sandbox fabric changes; the D-068 degrade surface ships unchanged. Roster + v0.8 territory retained.
reason: v0.10 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.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)
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)
domain: security
frameworks:
- pytest
@@ -116,19 +116,19 @@ constraints:
- secrets-via-env-only (D-014)
- secrets-never-logged
- fail-closed-on-unconfigured
- never-display-forgeable-as-verified (A-304 — the export methodology states its own limits)
- tamper-evidence-tested (an audit ledger nobody has tried to break is theater, D-111)
- tokens-hashed-at-rest (D-087)
- never-display-forgeable-as-verified (D-089/A-304)
territory:
- "apps/ai-service/ai_service/placements/**"
- "apps/ai-service/ai_service/api/placements_admin.py"
- "apps/ai-service/tests/placements/**"
- "apps/ai-service/ai_service/employers/**"
- "apps/ai-service/ai_service/api/employers*.py"
- "apps/ai-service/tests/employers/**"
```
### backend-engineer
```yaml
active: true
phase_specific: false
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)
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)
domain: backend
frameworks:
- fastapi
@@ -141,14 +141,16 @@ constraints:
- secrets-via-env-only (D-014)
- idempotent-scripts
- fail-closed-admin
- 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)
- 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)
territory:
- "apps/ai-service/ai_service/placements/**"
- "apps/ai-service/ai_service/employers/**"
- "apps/ai-service/ai_service/config.py"
- "apps/ai-service/ai_service/main.py"
- "apps/ai-service/ai_service/api/**"
- "apps/ai-service/tests/placements/**"
- "apps/ai-service/tests/employers/**"
- "apps/ai-service/.env.example"
- "apps/ai-service/pyproject.toml"
```
@@ -156,7 +158,7 @@ territory:
```yaml
active: true
phase_specific: false
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)
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)
domain: frontend
frameworks:
- react
@@ -172,14 +174,14 @@ constraints:
territory:
- "apps/web/**"
- "packages/ui/**"
- "packages/types/placement.ts"
- "packages/types/employer.ts"
```
### ai-engineer
```yaml
active: false
phase_specific: false
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).
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).
domain: ai
frameworks:
- pydantic
@@ -200,7 +202,7 @@ territory:
```yaml
active: true
phase_specific: false
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
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
domain: data
frameworks:
- sqlmodel
@@ -210,12 +212,12 @@ constraints:
- schema-first
- type-safe
- dual-schema-sync
- 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)
- idempotency-by-constraint (UniqueConstraint + check-first)
- tokens-never-stored-raw (sha256 only, D-087)
- audit-append-only (provenance rows never mutated)
territory:
- "apps/ai-service/ai_service/placements/store.py"
- "apps/ai-service/tests/placements/test_store.py"
- "apps/ai-service/ai_service/employers/store.py"
- "apps/ai-service/tests/employers/test_store.py"
- "packages/types/**"
```
@@ -223,7 +225,7 @@ territory:
```yaml
active: false
phase_specific: false
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.
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.
domain: identity
frameworks:
- fastapi
@@ -242,7 +244,7 @@ territory:
```yaml
active: false
phase_specific: false
reason: v0.5 territory — voice shipped complete; no v0.11 voice work.
reason: v0.5 territory — voice shipped complete; no v0.10 voice work.
domain: ai-media
frameworks:
- httpx
@@ -263,7 +265,7 @@ territory:
```yaml
active: false
phase_specific: false
reason: No design-token or primitive work in v0.11 (the applications page reuses existing Badge/Button/Card primitives); roster retained.
reason: No design-token or primitive work in v0.10 (login/register pages reuse existing Input/Button/Card primitives); roster retained.
domain: frontend
frameworks:
- tailwindcss
@@ -279,7 +281,7 @@ territory:
| Persona | Phases | Removed After |
|---------|--------|---------------|
| 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 |
| 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 |
All other personas span the milestone. Deactivated personas receive no tasks.
@@ -287,9 +289,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 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 |
| 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 |
| lead-developer vs any | lead-developer coordinates only, does not directly modify code files |
+114 -114
View File
@@ -1,198 +1,198 @@
# Nextcraft — PLAN.md (v0.11 CIRR-Style Placement Tracking and Audit)
# Nextcraft — PLAN.md (v0.10 B2B Employer Network)
## Overview
**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)
**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
**Phase list (requirement mapping):**
| Phase | Requirement | Focus |
|-------|-------------|-------|
| 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) |
| 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) |
**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.
**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.
**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`.
**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/`.
**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).
**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).
**Verification commands (phase gates — the repo's own scripts):**
**Verification commands (phase gates — the repo's own scripts, R-66):**
- `pnpm lint` (turbo lint — eslint across all TS workspaces) and `pnpm ai:lint` (ruff on ai-service)
- `pnpm typecheck` (turbo typecheck — tsc workspaces) and `pnpm ai:typecheck` (mypy — zero new overrides; stdlib-only, D-112 keeps the D-086 regime intact)
- `pnpm ai:test:fast` (FAST=1 — `-m "not real_server and not userns"` per D-063; tests/placements/ joins the fast lane)
- `pnpm 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 web:test` (node:test incl. pins.test.ts) + `pnpm cli:test` (unchanged surface, regression guard)
- `pnpm --filter web build` (static export, D-039 unchanged) on web-touching phases (P3, P4, P5)
**NFR note:** this is a Feature milestone — no purity gate; `feat:` commits are expected and scoped to ai_service/placements/, api/placements_admin.py + the employers.py/marketplace.py route sections + deps.py/main.py wiring, tests/placements/, packages/types/placement.ts, the employer/job-detail/admin web surfaces.
**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.
**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.
**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.
---
## Phase 1: Placement Store + Evidence Ledger (REQ-11-001)
## Phase 1: Employer Auth Foundation (REQ-10-001)
**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.
**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.
### Wave 1-1: Module core — contracts + matrix + schema (backend-engineer + data-engineer, sequential)
### Wave 1-1: Module core — store + KDF + contracts (backend-engineer + data-engineer, sequential)
- **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).
- **Task 1-1-01** (backend-engineer): `ai_service/employers/__init__.py` + `base.py` — pydantic contracts (D-093's list, dual-schema-pinned later in P3): `EmployerAccount` (id, email, employer_id nullable, created_at, `email_verified: Literal[False]` — UNVERIFIED per D-097, displayed as such), `EmployerClaim` (id, account_id, employer_id, status pending|approved|rejected, evidence dict, created_at, decided_by/decided_at), `RegisterEmployerRequest`/`LoginEmployerRequest` (email + password, honest validation), `TokenResponse` (token, expires_at), `EmployerMe` (account + claimed employer_id + claim state). No service.py (R-57) — routes compose the store via deps DI. (G-315: email fields are plain `str` with stdlib-only validation — non-empty, exactly one "@", non-empty domain, stripped + lowercased on normalize; pydantic `EmailStr` is BANNED — email-validator is not in the venv and adding it would be the first new dependency, breaking R-66/D-086.)
- **Task 1-1-02** (data-engineer): `employers/store.py` — 8th D-027 store (R-57, D-095): 4 tables, all `employer_`-prefixed (bare `employer` is marketplace's EmployerRow, marketplace/store.py:144): `employer_accounts` (id PK `acct-`-style, email UNIQUE indexed, password_hash self-describing string, employer_id UNIQUE nullable-until-claim-approval, created_at), `employer_sessions` (id PK, token_hash UNIQUE — stores ONLY sha256(token), account_id indexed, created_at, expires_at = login + `SESSION_TTL_DAYS = 7` module constant), `employer_claims` (schema per D-089, built in P2 — table lands now per D-095's one-time create_all), `employer_audit_events` (auto id PK, account_id/employer_id nullable indexed, kind/name, detail JSON, operator, created_at — the BillingEventRow audit shape, billing/store.py:337-359). Mechanics mirror billing/store.py exactly: `_sqlite_connect` pragmas (WAL / synchronous=NORMAL / busy_timeout=5000 / foreign_keys=ON), `sa_type=String/JSON` portable columns, `_validate()`/`_normalize()` hooks, `_as_utc` tz re-labeling, Protocol `EmployerStore` + `SQLiteEmployerStore` pair, surfaced IntegrityError; `SESSION_TTL_DAYS` pinned by test (the PAST_DUE_GRACE_DAYS shape, D-087/D-083). Store methods: insert_account (check-first → (row, created)), get_account_by_email, mint_session (token_hash=sha256(token), expires_at=now+TTL, `now` injectable), get_session_by_token_hash (**lazy TTL — expired row reads as absent, G-306, no timer**), revoke_session (DELETE), append_audit_event, close. Tests: `tests/employers/test_store.py` — tmp_path DB, table-existence via sqlite_master (4 employer_ tables on the shared db_path), pragma pins, UNIQUE violations surfaced (duplicate email, duplicate token_hash), **token never stored raw (row holds sha256 only)**, lazy TTL with injected now (login+6d present, +8d absent — no sleeps), revoke → lookup miss.
- **Task 1-1-03** (backend-engineer): `employers/passwords.py` — stdlib scrypt KDF (D-086, R-55): module constants `SCRYPT_N=2**14, SCRYPT_R=8, SCRYPT_P=1, SCRYPT_DKLEN=32` (**pinned by test**); per-password salt `secrets.token_bytes(16)`; stored self-describing `scrypt$16384$8$1$<salt_b64>$<hash_b64>` (params evolve without invalidating rows); verify parses params from the stored string, derives, compares via `hmac.compare_digest`; **no maxmem argument below ~17 MiB** (R-55 probe: default 32 MiB admits the 16 MiB working set; if pinned for explicitness use 33554432); module docstring records the ~50 ms/hash budget + throttle-bounded burst risk (open-risk-1). Tests: `tests/employers/test_passwords.py` — REAL KDF, no fake-hash path (D-096): hash→verify accept, wrong password reject, two hashes of the same password differ (random salt), params-pinned constants test, self-describing round-trip (old-format row with explicit params still verifies), **tests never assert hash bytes — accept/reject behavior only**.
### Wave 1-2: main.py wiring + deps + conftest (backend-engineer)
### Wave 1-2: Auth routes + throttles + main.py wiring (backend-engineer)
- **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)**.
- **Task 1-2-01** (backend-engineer): `api/employers.py` — `require_employer_session` dependency (D-090, R-56: the require_admin extract→verify→return shape, single-header `Authorization: Bearer <token>` via partition + scheme check; sha256(token) hash-lookup; **401** missing/expired/unknown — credential failure; **403** reserved for cross-entity authorization; returns session + account + claimed employer_id; token namespaces cannot collide with admin tokens — one class fails the other's gate) + `deps.py` `get_employer_store` (app.state DI). Routes: `POST /v1/employers/auth/register` (email+password → scrypt hash → insert_account; duplicate email → 409 honest; account UNVERIFIED per D-097 — no learner gates ever composed on employer routes), `POST /v1/employers/auth/login` (verify → mint token → `{token, expires_at}`; bad credentials → 401 honest, no user-enumeration difference in response body beyond the 401), `POST /v1/employers/auth/logout` (revoke: DELETE row + audit event; unknown/revoked token → 401), `GET /v1/employers/auth/me` (session + account + claimed employer_id + unverified email displayed as such). Tests: `tests/employers/test_auth.py` — **register→login→me→logout flow green**; duplicate register 409; wrong password 401; logout revokes (subsequent me → 401); expired token → 401 (injected now); no-token → 401; concurrent sessions per account allowed (two logins, both me-green, one logout leaves the other alive). REAL scrypt throughout (≈2 hashes ≈100 ms/test, R-64 budget).
- **Task 1-2-02** (backend-engineer): throttles (D-088, R-63 — both copy the proven in-tree mechanics, keyed by SUBMITTED EMAIL, never client IP): (a) per-EMAIL failed-login window — module dict[email]→timestamps, `time.monotonic()` slice-trim, **default 5 failures / rolling 15 min (900 s, window seconds parameterized — identity hardcodes 60.0) → 429 with the honest "wait" detail**; failures-only append; **successful login CLEARS the email's window**; (b) global per-min cap on login+register attempts — module `deque[float]`, while-popleft older-than-60s, `len >= settings.employer_auth_attempts_per_min` → 429, **append only after ALL checks pass (a rejected attempt never consumes budget)**. Settings knobs as plain ints (the `identity_submits_per_min` shape, R-60d): `employer_login_failures_per_window: int = 5`, `employer_auth_attempts_per_min: int = 30`; `.env.example` entries beside the AI_ADMIN_TOKEN block (commented, default-off, R-66). Tests: `tests/employers/test_throttle.py` — 5 failures → 429 with wait detail; success clears the email window; global cap → 429; rejected attempts don't consume the global budget; **both limiter module states reset via fixtures (the _reset_apply_rate_window pattern, R-63)**. (G-313: an autouse fixture in a NEW tests/employers/conftest.py clears BOTH limiter module states before/after EVERY employers test — the whole suite runs inside one 60s window and self-429s otherwise; the sanctioned deviation from billing's no-conftest layout, R-64. G-314: the global deque appends every well-formed login+register attempt — FAILED PASSWORDS COUNT; only 422-invalid bodies and the cap's own 429s are exempt, or D-088's credential-stuffing bound is void; the "rejected attempts consume nothing" test is reworded to "422-invalid consumes nothing; failed-password attempts DO count; N distinct emails inside the window → the (cap+1)-th 429s — the stuffing test". G-320a: the login route order is body-validation → global cap → per-email window → scrypt verify → failure-append — a 429'd attempt performs ZERO KDF derivations, pinned.)
- **Task 1-2-03** (backend-engineer): main.py wiring (R-60, all four sites): (a) employer_store lifespan block directly after billing_store (~line 191, same getattr-guard) + `close()` in the finally block; (b) employers router mount beside the billing block (inline-import pattern); (c) **CORS `allow_headers` gains `"Authorization"` (main.py:341) + the `tests/api/test_cors.py` preflight pin lands in THIS task** (the E87 lesson applied before it ships — open-risk-2: without it every cross-origin employer fetch dies preflight in the two-port dev + D-038 network-mode topologies); (d) **pii_fields gains `"password"` + `"email"`** (main.py:401, R-56/open-risk-3 — a pydantic 422 on register would echo the raw password into err["input"] today) + the pin: register with an invalid body → 422 whose detail never contains the submitted password (added to tests/employers/test_auth.py). Tests: test_cors.py Authorization-preflight green; 422-echo pin green.
### Wave 1-3: P1 security review + phase gate (security-auditor + lead-developer)
- **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.
- **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.
## Phase 2: Application State Machine (REQ-11-002)
## Phase 2: Employer Entity Claim (REQ-10-002)
**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.
**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.
### Wave 2-1: Employer application routes + G-319 pin split (backend-engineer)
### Wave 2-1: Claim core — evidence + store + route (backend-engineer + data-engineer)
- **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.
- **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.
### Wave 2-2: Learner withdraw + operator lane incl. the R-80 re-hire (backend-engineer)
### Wave 2-2: Admin claim review routes + security review (backend-engineer + security-auditor)
- **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**.
- **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.
### Wave 2-3: P2 security review + phase gate (security-auditor + lead-developer)
## Phase 3: Employer Account Surface (REQ-10-003)
- **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.
**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.
## Phase 3: Placement Reporting (REQ-11-003)
### Wave 3-1: Own-postings read route (backend-engineer)
**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).
- **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.)
### Wave 3-1: Reporting reads (backend-engineer)
### Wave 3-2: Wire types + engine-client employer section (frontend-engineer)
- **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.
- **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.
### Wave 3-2: Wire types + engine-client placements section (frontend-engineer + data-engineer)
### Wave 3-3: Pages — login/register + dashboard conversion (frontend-engineer)
- **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.
- **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.)
### Wave 3-3: Employer web surfaces (frontend-engineer)
## Phase 4: Self-Serve Posting Management (REQ-10-004)
- **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.
**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.
### Wave 3-4: P3 phase gate (lead-developer)
### Wave 4-1: Shared entitlement helper + employer posting routes (backend-engineer + lead-developer)
- **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.
- **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.
## Phase 4: Audit Export (REQ-11-004)
### Wave 4-2: PostingManager write actions (frontend-engineer)
**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.
- **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.)
### Wave 4-1: Export + summary routes + methodology + E2E (backend-engineer)
## Phase 5: Self-Serve Checkout + Admin Overview (REQ-10-005)
- **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.
**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).
### Wave 4-2: Admin placements block + export links + security review + phase gate (frontend-engineer + security-auditor + lead-developer)
### Wave 5-1: Employer checkout + entitlement view + mock-complete (backend-engineer)
- **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.
- **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).
## Phase 5: Learner Placement Visibility (REQ-11-005)
### Wave 5-2: Employer billing surface + pricing CTAs + admin network overview (frontend-engineer)
**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.
- **Task 5-2-01** (frontend-engineer): employer billing surface + pricing CTA conversion: the dashboard gains the entitlement view (current package, posts used/limit bar, expiry — honest empty/expired states) + buy flow (catalog from the public `/v1/billing/catalog`, own-checkout trigger, **in-app mock-complete confirmation honestly labeled as the pilot mock path**); **pricing-client.tsx CTAs convert from contact-path to employer-login path (superseding D-079) — the "Contact Sales" enterprise pin at pins.test.ts:250 STAYS (R-61)**; the operator-mediated copy is replaced by the self-serve truth with the admin-fallback noted honestly. Tests: web fetch-stub — entitlement render, checkout dispatch, mock-complete confirmation copy, CTA → /employer/login path, enterprise Contact Sales retained.
- **Task 5-2-02** (frontend-engineer): admin network overview (D-094, R-62): the moderation page (app/(admin)/admin/moderation/page.tsx) gains sibling `<section>` blocks below the v0.9 EmployerEntitlementPanel: employer accounts list (email, claimed entity, entitlement state columns), claim queue (**evidence + domain-match signal rendered ADVISORY — never as verification, A-304**; approve/reject actions), audit view — fed by the P2 operator routes via engine-client admin transport over the existing `adminToken()` G-308 wrapper; **403 renders an honest locked state with the token prompt, never a retry loop (the entitlement-panel load() pattern, R-62)**. Tests: web fetch-stub — locked state on 403, claim queue renders evidence + advisory signal, approve/reject dispatch, accounts list columns.
### Wave 5-1: Learner application-status read route (backend-engineer)
### Wave 5-3: P5 security review (security-auditor)
- **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).
- **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.
### Wave 5-2: Job-detail status panel (frontend-engineer)
## Phase 6: Final Review + Ship (milestone release, v0.9.x line)
- **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.
**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.
### Wave 6-1: Multi-persona final review (lead-developer + security-auditor)
- **Task 6-1-01** (lead-developer → all active personas): full milestone-branch diff review — the **D-100 one-transaction discipline holds end-to-end** (one transition_application, no second status writer, no second append path — R-78), the **R-80 re-hire coherence verdict** (placements, not applications, are the correction lane; the ledger reads hired → revoke → rehire as three events, never a fabricated second application transition), the **G-319 pin-split verdict** (counts-only stays on the own-postings list — test_postings_read.py:216 unchanged; learner_id application-scoped only), the **D-110 wire-pin review** (placement.ts ↔ placements/base.py field-for-field + the status-union regex), the D-092 honest-surface census (the overview mock block replaced, talent mocks retained + labeled, no fake-converted surface, no convertible surface left on mock). P0s fixed in-phase; P1+ findings dispositioned with recorded follow-ups — never loop back to EXECUTE.
- **Task 6-1-02** (security-auditor): final review — no credentials or identity PII in logs/releases/assets (learner_id is an opaque handle everywhere, R-79); gate orders pinned on every principal class (employer/learner/operator); the ledger's no-delete path intact; tamper/fork/concurrency arms green; export determinism + methodology honesty re-verified; no secret echoed by any exception path.
- **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.
### Wave 6-2: Audit + ship (lead-developer)
- **Task 6-2-01** (lead-developer → ship): audit (git log ↔ .ciagent/ reconstruction; file discipline — the D-112 addition census: ai_service/placements/, api/placements_admin.py + the employers.py/marketplace.py route sections + deps.py get_placement_store, the marketplace store's get_application point read + grouped reads, main.py wiring, tests/placements/, packages/types/placement.ts + index.ts export, engine-client placements section, /employer/applications page, overview placements section, job-detail panel, admin placements block; branch/commit discipline — feat: scoped to the placement surface) + ship: merge milestone → main; tag v0.10.6; Gitea release with full summary + the complete asset matrix + registry image (the existing tag-triggered lane — zero workflow changes); delete milestone branches (tags preserve history); mark REQ-11-001..005 complete in REQUIREMENTS.md; ROADMAP milestone entry; checkpoint cleared.
- **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.
---
## 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-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.
- 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.
- Suite counts: pytest fast lane + web + cli all green, zero warnings (the v0.7 regime holds).
- Docs: REQUIREMENTS/ROADMAP updated; deploy/README.md unaffected (no admin-box changes — recorded explicitly); the mock-first doctrine intact (no external systems, no third-party auditor integration — the export IS the deliverable; tests never call a cloud).
- 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).
## Risks
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.
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).
## User-Facing Surface
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).
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).
## Happy Path (end-to-end, written BEFORE execute)
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.
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.
## 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. **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).
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.
+1 -28
View File
@@ -16,34 +16,7 @@ Nextcraft is an AI-native outcome school where graduates prove what they can bui
---
## 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)
## Milestone v0.10 — B2B Employer Network (ACTIVE, Feature, started 2026-09-30)
**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).
+8 -54
View File
@@ -1,74 +1,38 @@
# Nextcraft — REQUIREMENTS.md
## v0.11 Requirements (Complete — Placement Tracking and Audit, Feature; shipped as v0.10.6)
## v0.10 Requirements (Active — B2B Employer Network, Feature; started 2026-09-30)
**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).
**Milestone type: Feature** — real employer auth/accounts + employer self-serve (the B2B employer network; activated per D-085). Tags on the v0.9.x line (P0 → v0.9.0 … P6 → v0.9.6 = milestone release); branch `milestone/v0.10-b2b-employer-network`. Auth is session-based with hashed credentials (mock-first doctrine: no external auth provider, tests never call a cloud); admin operator routes are retained unchanged (D-043 composition untouched).
### Employer Auth Foundation
| ID | Description | Priority | Phase | Status |
|----|-------------|----------|-------|--------|
| REQ-10-001 | Employer auth foundation: `ai_service/employers/` module (D-031 extend pattern): employer account store (8th D-027 family — `employer_accounts`, `employer_sessions`, `employer_claims`, `employer_audit_events` tables, D-095) with SQLModel typed access; password hashing via stdlib `hashlib.scrypt` (D-086 — zero new deps, per-password random salt, self-describing params); session tokens as server-side store rows with the token stored hashed, fixed 7-day TTL evaluated lazily, revoke-on-logout (D-087); register/login/logout/me routes (`/v1/employers/auth/*`, Bearer session transport per D-090); rate limiting on auth routes (D-088 — per-email failed-login window + global attempt cap, the G-5 in-memory pattern); credentials + secrets env-only (D-014); fail-closed when unconfigured | critical | 1 | complete |
| REQ-10-001 | Employer auth foundation: `ai_service/employers/` module (D-031 extend pattern): employer account store (8th D-027 family — `employer_accounts`, `employer_sessions`, `employer_claims`, `employer_audit_events` tables, D-095) with SQLModel typed access; password hashing via stdlib `hashlib.scrypt` (D-086 — zero new deps, per-password random salt, self-describing params); session tokens as server-side store rows with the token stored hashed, fixed 7-day TTL evaluated lazily, revoke-on-logout (D-087); register/login/logout/me routes (`/v1/employers/auth/*`, Bearer session transport per D-090); rate limiting on auth routes (D-088 — per-email failed-login window + global attempt cap, the G-5 in-memory pattern); credentials + secrets env-only (D-014); fail-closed when unconfigured | critical | 1 | active |
### Employer Entity Claim
| ID | Description | Priority | Phase | Status |
|----|-------------|----------|-------|--------|
| REQ-10-002 | Employer-entity claim/verify: an authenticated employer account claims an existing `EmployerRow` entity (the v0.6 ingested data) via a claim route; claims enter a pending state with recorded evidence (email-domain vs entity-website comparison — advisory signal, D-089) and activate via admin approval at pilot; claims recorded with provenance (`employer_claims`); one account : one entity (UniqueConstraint); admin routes for claim review/approve/reject (operator oversight retained, audit-logged per D-089); claimed-entity authorization: employers can only manage postings/purchases for their own entity | critical | 2 | complete |
| REQ-10-002 | Employer-entity claim/verify: an authenticated employer account claims an existing `EmployerRow` entity (the v0.6 ingested data) via a claim route; claims enter a pending state with recorded evidence (email-domain vs entity-website comparison — advisory signal, D-089) and activate via admin approval at pilot; claims recorded with provenance (`employer_claims`); one account : one entity (UniqueConstraint); admin routes for claim review/approve/reject (operator oversight retained, audit-logged per D-089); claimed-entity authorization: employers can only manage postings/purchases for their own entity | critical | 2 | active |
### Employer Account Surface
| ID | Description | Priority | Phase | Status |
|----|-------------|----------|-------|--------|
| REQ-10-003 | Employer dashboard real-data conversion: the employer surface switches from v0.1 mock imports to runtime client fetches (A-406 pattern — AbortController, honest loading/error/empty states) via the engine-client employer section + sessionStorage session state (D-092); employer auth UI (login/register pages, session-gated dashboard shell, honest logged-out states); posting management reads the employer's own postings via the authenticated surface (draft → published → closed lifecycle visible); employer profile read from the claimed entity (enrichment retained); talent search + candidate profile pages stay the v0.1 mock (no candidate backend exists — honest out-of-scope, D-092); wire types in `packages/types/employer.ts` dual-schema pinned (G-108 discipline, D-093) | critical | 3 | complete (v0.9.3, release 951) |
| REQ-10-003 | Employer dashboard real-data conversion: the employer surface switches from v0.1 mock imports to runtime client fetches (A-406 pattern — AbortController, honest loading/error/empty states) via the engine-client employer section + sessionStorage session state (D-092); employer auth UI (login/register pages, session-gated dashboard shell, honest logged-out states); posting management reads the employer's own postings via the authenticated surface (draft → published → closed lifecycle visible); employer profile read from the claimed entity (enrichment retained); talent search + candidate profile pages stay the v0.1 mock (no candidate backend exists — honest out-of-scope, D-092); wire types in `packages/types/employer.ts` dual-schema pinned (G-108 discipline, D-093) | critical | 3 | active |
### Self-Serve Posting Management
| ID | Description | Priority | Phase | Status |
|----|-------------|----------|-------|--------|
| REQ-10-004 | Self-serve posting management: employer-principal posting routes (`/v1/employers/postings/*`) — create draft, edit own drafts, publish own drafts, close own postings, list own postings with status facets; authorization: the entitlement check composes AFTER the employer session gate (401/403 precede any 402 — the admin-gate-first order preserved, G-311) with the 402 CTA unchanged, D-080 consumption model intact, via the shared api-layer helper extracting the v0.9 gate (D-091 — no duplication); posting creation consumes from the employer's own packages (the v0.9 entitlement check parameterized by the session's employer_id instead of request body); the admin posting routes remain (operator path unchanged, provenance-recorded); 18+ gate N/A (employer surface is not learner-facing; the D-043 learner gates untouched, D-097) | high | 4 | complete (v0.9.4, release 952) |
| REQ-10-004 | Self-serve posting management: employer-principal posting routes (`/v1/employers/postings/*`) — create draft, edit own drafts, publish own drafts, close own postings, list own postings with status facets; authorization: the entitlement check composes AFTER the employer session gate (401/403 precede any 402 — the admin-gate-first order preserved, G-311) with the 402 CTA unchanged, D-080 consumption model intact, via the shared api-layer helper extracting the v0.9 gate (D-091 — no duplication); posting creation consumes from the employer's own packages (the v0.9 entitlement check parameterized by the session's employer_id instead of request body); the admin posting routes remain (operator path unchanged, provenance-recorded); 18+ gate N/A (employer surface is not learner-facing; the D-043 learner gates untouched, D-097) | high | 4 | active |
### Self-Serve Checkout
| ID | Description | Priority | Phase | Status |
|----|-------------|----------|-------|--------|
| REQ-10-005 | Self-serve checkout + admin network overview: employer-principal checkout (`/v1/employers/billing/checkout`) creating provider sessions for the catalog (the v0.9 billing module reused — provider protocol, webhooks, entitlement grants unchanged; buyer = session employer instead of operator mediation, superseding D-076 while admin routes stay as the operator fallback; D-091 composes the existing checkout route logic with the session principal); entitlement view for the employer's own purchases/subscriptions (current package, posts used/limit, expiry — honest states); pricing page CTAs convert from contact-path to employer-login-path (superseding D-079); admin surface gains the network overview on the marketplace moderation page (employer accounts list, claim queue with evidence signals, per-account entity + entitlement state — the operator oversight lane, D-094) | critical | 5 | complete (v0.9.5, release 953) |
| REQ-10-005 | Self-serve checkout + admin network overview: employer-principal checkout (`/v1/employers/billing/checkout`) creating provider sessions for the catalog (the v0.9 billing module reused — provider protocol, webhooks, entitlement grants unchanged; buyer = session employer instead of operator mediation, superseding D-076 while admin routes stay as the operator fallback; D-091 composes the existing checkout route logic with the session principal); entitlement view for the employer's own purchases/subscriptions (current package, posts used/limit, expiry — honest states); pricing page CTAs convert from contact-path to employer-login-path (superseding D-079); admin surface gains the network overview on the marketplace moderation page (employer accounts list, claim queue with evidence signals, per-account entity + entitlement state — the operator oversight lane, D-094) | critical | 5 | active |
---
@@ -398,7 +362,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+ | activated → REQ-11-001..005 |
| REQ-F-020 | CIRR-style placement tracking and audit | medium | v0.5+ | deferred |
---
@@ -418,16 +382,6 @@
## Traceability Matrix
### v0.11 (complete)
| Requirement | Phase | Status |
|-------------|-------|--------|
| REQ-11-001 | 1 | complete |
| REQ-11-002 | 2 | complete |
| REQ-11-003 | 3 | complete |
| REQ-11-004 | 4 | complete |
| REQ-11-005 | 5 | complete |
### v0.6 (complete)
| Requirement | Phase | Status |
-302
View File
@@ -1,305 +1,3 @@
# 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)
+8 -24
View File
@@ -2,9 +2,7 @@
## Overview
**Milestone v0.11 — COMPLETE (shipped as v0.10.6, 2026-10-22).** Feature milestone: CIRR-style placement tracking and audit (REQ-F-020 activated per D-098) — the machinery that makes the guiding outcome (≥1,000 placements, CIRR-style third-party audit) measurable: the placement store + hash-chained evidence ledger, the employer-side application state machine (submitted → in_review → interview → offer → hired/rejected/withdrawn), placement reporting surfaces (per-employer, per-learner, aggregate toward ≥1,000), the audit-grade export (cohort snapshot + evidence bundles + methodology + chain head), learner-side placement visibility. Tags on the v0.10.x line (P0 → v0.10.0 … P6 → v0.10.6 = milestone release); branch `milestone/v0.11-placement-tracking`.
**Milestone v0.10 — B2B Employer Network (COMPLETE, shipped as v0.9.6, 2026-10-01).** Feature milestone: real employer auth/accounts + self-serve (D-085). Employers become principals — registered accounts with sessions, employer-entity claim/verify, the employer dashboard converting from the v0.1 mockup to real data, self-serve posting management with the v0.9 entitlement enforcement intact, and self-serve checkout over the v0.9 billing catalog (superseding D-076 operator mediation; admin operator routes retained as fallback + oversight). Tags on the v0.9.x line (P0 → v0.9.0 … P6 → v0.9.6 = milestone release); branch `milestone/v0.10-b2b-employer-network`.
**Milestone v0.10 — B2B Employer Network (ACTIVE, started 2026-09-30).** 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`.
@@ -38,31 +36,17 @@ Payment processing and subscription management (REQ-F-018 activated per D-074, a
## Phase List
### v0.11 (complete — shipped as v0.10.6, 2026-10-22)
| # | Name | Status | Depends On | Requirements | Success Criteria |
|---|------|--------|------------|--------------|------------------|
| 0 | Pre-execution | complete (v0.10.0) | — | — | Specify (D-098), clarify, research, plan, grill, MVP/UX check complete (shipped as v0.10.0) |
| 1 | Placement store + evidence ledger | complete (v0.10.1) | 0 | REQ-11-001 | `ai_service/placements/` module: 9th D-027 store (PlacementRow + PlacementEventRow, hash-chained append-only ledger), evidence resolved from source rows only (G-302 doctrine), `create_all` idempotent on the shared DB file, Protocol + SQLite pair |
| 2 | Application state machine | complete (v0.10.2) | 1 | REQ-11-002 | Employer routes extend the v0.6 application status from submitted-only to the full hiring lifecycle; claimed-entity authorization (403 cross-entity, G-311 gate order); every transition ledger-logged; hired creates the placement; admin oversight retained |
| 3 | Placement reporting | complete (v0.10.3) | 2 | REQ-11-003 | Aggregate counts from the ledger (per employer, per cohort month, cumulative toward ≥1,000); employer dashboard placements section; learner outcome states; admin network overview counts |
| 4 | Audit export | complete (v0.10.4) | 3 | REQ-11-004 | Admin-gated export: cohort snapshot + per-placement evidence bundles + methodology doc + chain head hash; JSON + human-readable summary; re-export reproducibility test |
| 5 | Learner placement visibility | complete (v0.10.5) | 4 | REQ-11-005 | Learner application outcomes + full state history; hired learners see their placement record; honest no-badge-until-placed (A-304) |
| 6 | Final review + ship | complete (v0.10.6) | 1-5 | — | Review SHIP — 0 P0s, 2 P1 doc-level findings dispositioned with recorded follow-ups (the PLAN Must-Have test_e2e_audit.py name vs test_export.py::test_e2e_audit_proof; the D-112 census omitting the employers/store.py two-line audit-vocabulary addition); audit PASS — D-112 reconstruction clean (27 branch commits vs the census), branch/commit discipline green; ship: v0.10.5 tagged at the ship per the recorded deferral (e32e54e7aa), milestone merged to main (PR #6), v0.10.6 tagged at the merge, release lanes fired, milestone branch deleted |
---
## Phase List (v0.10 — complete)
### v0.10 (active)
| # | Name | Status | Depends On | Requirements | Success Criteria |
|---|------|--------|------------|--------------|------------------|
| 0 | Pre-execution | complete (v0.9.0) | — | — | Specify (D-085), clarify (D-086..D-097), research (R-55..R-66), plan, grill (G-311..G-320), MVP/UX check complete (shipped as v0.9.0, release 948) |
| 1 | Employer auth foundation | complete (v0.9.1) | 0 | REQ-10-001 | `ai_service/employers/` module: account store (8th D-027 family, 4 tables) + scrypt hashed credentials (params pinned) + hashed server-side sessions (7-day lazy TTL) + register/login/logout/me routes + dual D-088 auth throttles + CORS Authorization preflight + PII scrub; fail-closed secrets; tests never call a cloud (shipped as v0.9.1, release 949) |
| 2 | Employer entity claim | complete (v0.9.2) | 1 | REQ-10-002 | Claim route over existing EmployerRow entities with stdlib-only claim_evidence normalization (advisory-only per D-089/A-304); G-316 partial UNIQUE index closes the pending-claim TOCTOU; one account : one entity bound at approve with same-transaction audit provenance; admin claim review/approve/reject — 5 operator routes fail-closed, 403 matrix 20 probes; rejection frees entities; claimed-entity authorization enforced (shipped as v0.9.2, release 950) |
| 3 | Employer account surface | complete (v0.9.3) | 2 | REQ-10-003 | Employer dashboard real-data conversion (mock imports → runtime fetches, A-406 pattern); login/register UI; session-gated shell; own-postings view; dual-schema wire pins (shipped as v0.9.3, release 951) |
| 4 | Self-serve posting management | complete (v0.9.4) | 3 | REQ-10-004 | Employer-principal posting routes with own-entity authorization; D-091 shared entitlement gate (one helper, byte-identical principal gates); create/edit/publish/close on the claimed entity with G-311 gate order, G-317 provenance stamps, G-318 own-drafts guard + admin re-tag parity; PostingManager honest 402 CTA / 403 claim states per G-320b; admin routes retained (shipped as v0.9.4, release 952) |
| 5 | Self-serve checkout + admin overview | complete (v0.9.5) | 4 | REQ-10-005 | Employer-principal checkout over the v0.9 catalog via the REAL webhook path with buyer = the session's claimed entity (supersedes D-076; admin operator routes retained as fallback); own-session mock-complete closes the G-301 class; own entitlement view with honest states; pricing CTAs → employer-login per D-079-supersession with enterprise Contact Sales pin retained; admin network overview — accounts, advisory-evidence claim queue, audit — per D-094/A-304 (shipped as v0.9.5, release 953) |
| 6 | Final review + ship | complete (v0.9.6) | 1-5 | — | Final review verdict SHIP — 2 P0s found + fixed (the /me claim-state leak over stale sessions, the dead billing confirm button), 3 P1s dispositioned; audit PASS — reconstruction clean, discipline green, 2 minor notes; ship gates green (1031 ai-service + 101 web tests, build clean); milestone tagged v0.9.6 (final v0.9.x patch = milestone release, release 954); release with full asset matrix + registry image via the tag lane (D-064); milestone merged to main |
| 1 | Employer auth foundation | pending | 0 | REQ-10-001 | `ai_service/employers/` module: account store (8th D-027 family) + hashed credentials + session tokens + register/login/logout/me routes + auth rate limiting; fail-closed secrets; tests never call a cloud |
| 2 | Employer entity claim | pending | 1 | REQ-10-002 | Claim route over existing EmployerRow entities; provenance-recorded claims; one account : one entity; admin claim review/approve/reject; claimed-entity authorization enforced |
| 3 | Employer account surface | pending | 2 | REQ-10-003 | Employer dashboard real-data conversion (mock imports → runtime fetches, A-406 pattern); login/register UI; session-gated shell; own-postings view; dual-schema wire pins |
| 4 | Self-serve posting management | pending | 3 | REQ-10-004 | Employer-principal posting routes with own-entity authorization; entitlement enforcement parameterized by session employer (402 CTA unchanged); admin routes retained |
| 5 | Self-serve checkout + admin overview | pending | 4 | REQ-10-005 | Employer-principal checkout over the v0.9 catalog; own entitlement view; pricing-page CTAs → employer-login path; admin network overview (accounts, claim queue, entitlement state) |
| 6 | Final review + ship | pending | 1-5 | — | Review clean; audit pass; milestone tagged v0.9.6 (final v0.9.x patch = milestone release); release with full asset matrix + registry image; milestone merged to main |
---
+3 -3
View File
@@ -46,9 +46,9 @@
"projects": [],
"active_project": null,
"milestone": {
"version": "v0.11",
"name": "placement-tracking",
"version": "v0.10",
"name": "b2b-employer-network",
"type": "feature",
"branch": "milestone/v0.11-placement-tracking"
"branch": "milestone/v0.10-b2b-employer-network"
}
}
-97
View File
@@ -278,100 +278,3 @@ jobs:
|| { echo "FAIL: embedded CLI does not run"; exit 1; }
echo "assert 5/5: embedded CLI alive + clean stop"
docker rm -f "$CID" >/dev/null && CID=""
# -- R1 deploy leg (REQ-v1-003 machinery set c18619; hep APPROVE c18623;
# vehicle call c18634: the last mile is THIS pipeline, not an SRE infra
# detour). Pipeline-only host refresh of the product host - no manual
# host pokes. Provenance = this job's run id + the post-deploy identity
# read below. The footer is retired as a freshness marker (it is the
# code's own label - c18624 correction, confirmed by hep): freshness
# reads are machine-read (/health + / serving HTML + the /v1/placements/
# admin route registration in /openapi.json).
#
# One-time plumbing preconditions (declared for the MR review; NOT a
# routing of the refresh to SRE - SRE keeps the R3 re-probe lane only):
# * DEPLOY_SSH_KEY - repo Actions secret: the least-scope deploy key
# authorized on the product host's deploy account (deploy@10.0.0.105
# per deploy/README.md topology). One-time grant via the SRE/console
# precedent (deploy/ci README G-206 pattern; self-serve secret PUT
# precedent: REGISTRY_TOKEN, HTTP 201 on 2026-09-27).
# * (optional hardening) DEPLOY_HOSTKEY - repo Actions secret holding
# the product host's complete ssh public host-key line; when set it
# is PINNED (StrictHostKeyChecking=yes), else accept-new.
# A missing DEPLOY_SSH_KEY at run time fails this leg LOUDLY - a red
# Actions tab on a tag is a red deploy by design (deploy/README.md);
# it is never silently skipped (set R1: the gate stays clearable on
# evidence). The release/asset path is untouched: a deploy-leg
# precondition failure cannot damage artifact shipping.
deploy:
needs: release
runs-on: release
steps:
- name: Preflight - fail loud when the leg is unconfigured
env:
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
run: |
set -euo pipefail
echo "deploy preflight: run id $GITHUB_RUN_ID / run #$GITHUB_RUN_NUMBER / job $GITHUB_JOB_ID / sha $GITHUB_SHA"
[ -n "$DEPLOY_SSH_KEY" ] || { echo "FATAL: DEPLOY_SSH_KEY unset - deploy leg unconfigured; host NOT refreshed this release; release artifacts stand (release job already green)"; exit 1; }
- name: Refresh the product host over the declared ssh channel
env:
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
DEPLOY_HOSTKEY: ${{ secrets.DEPLOY_HOSTKEY }}
run: |
set -euo pipefail
trap 'rm -f /tmp/dk /tmp/kh' EXIT
apt-get update -qq && apt-get install -y -qq openssh-client >/dev/null
install -m 600 /dev/null /tmp/dk
printf '%s\n' "$DEPLOY_SSH_KEY" > /tmp/dk
unset DEPLOY_SSH_KEY
if [ -n "${DEPLOY_HOSTKEY:-}" ]; then
printf '%s\n' "$DEPLOY_HOSTKEY" > /tmp/kh
SSH_PIN="-oUserKnownHostsFile=/tmp/kh -oStrictHostKeyChecking=yes"
else
SSH_PIN="-oStrictHostKeyChecking=accept-new"
fi
TAG="${{ gitea.ref_name }}"
ssh -i /tmp/dk -o BatchMode=yes $SSH_PIN deploy@10.0.0.105 bash -s "$TAG" << 'REMOTE'
set -euo pipefail
TAG="$1"
if command -v nextcraft >/dev/null 2>&1; then
echo "channel: binary/deb - the one-command upgrade (idempotent, self-verifying per deploy/README.md)"
nextcraft deploy --service --ref "$TAG"
elif [ -n "$(docker ps --format '{{.Names}}' | grep -x nextcraft || true)" ]; then
echo "channel: docker - README upgrade (pull + recreate; nextcraft-state persists)"
docker pull git.coreci.dev/coreci/nextcraft:"$TAG"
docker rm -f nextcraft
docker run -d --name nextcraft -p 8420:8420 -v nextcraft-state:/home/nextcraft/.nextcraft git.coreci.dev/coreci/nextcraft:"$TAG"
else
echo "FATAL: no nextcraft channel on $(hostname) (no nextcraft on PATH; no docker container named nextcraft) - failing loud"
exit 1
fi
echo "--- channel verify (advisory; the identity read below is canonical)"
command -v nextcraft >/dev/null 2>&1 && nextcraft --version || true
REMOTE
RC=$?
echo "host refresh done (rc=$RC); provenance: run id $GITHUB_RUN_ID / run #$GITHUB_RUN_NUMBER / job $GITHUB_JOB_ID / tag $TAG / sha $GITHUB_SHA"
exit $RC
- name: Post-deploy identity read - the R1 provenance surface
run: |
set -euo pipefail
TAG="${{ gitea.ref_name }}"
echo "waiting for the stack to settle..."
OK=""
for i in $(seq 1 12); do
CODE_H="$(curl -sS -o /tmp/h -m 15 -w '%{http_code}' https://nextcraft.coreci.dev/health || true)"
if [ "$CODE_H" = "200" ] && grep -q '"status"' /tmp/h; then OK=1; break; fi
sleep 5
done
[ -n "$OK" ] || { echo "FAIL: /health never returned 200 within the window"; exit 1; }
echo "assert 1/3: /health 200 - $(head -c 200 /tmp/h)"
CODE_R="$(curl -sS -o /tmp/r -m 15 -w '%{http_code}' https://nextcraft.coreci.dev/)"
head -c 64 /tmp/r | tr -d '[:space:]' | head -c 9 | grep -qiE '^<!doctype|^<html' || { echo "FAIL: / is not HTML (code $CODE_R)"; exit 1; }
echo "assert 2/3: / serves HTML ($CODE_R)"
CODE_O="$(curl -sS -o /tmp/o -m 25 -w '%{http_code}' https://nextcraft.coreci.dev/openapi.json)"
grep -q '"/v1/placements/admin' /tmp/o || { echo "FAIL: counting surface NOT registered on the served build (/openapi.json has no /v1/placements/admin routes) - honest red; R2 root-cause owns the diagnosis per c18619"; exit 1; }
echo "assert 3/3: /v1/placements/admin registered in /openapi.json"
echo "(advisory served version: $(grep -o '"version":"[^"]*"' /tmp/o | head -1))"
echo "PROVENANCE (R1): deploy run id $GITHUB_RUN_ID / run #$GITHUB_RUN_NUMBER / job $GITHUB_JOB_ID / tag $TAG / head $GITHUB_SHA - identity reads green"
+1 -1
View File
@@ -1,6 +1,6 @@
# Nextcraft
**TL;DR** — AI-native outcome school + marketplace — graduates prove what they can build, not what they can write. This file is the front door: three install channels, dev setup from a clone, and the CLI reference; the admin runbook for standing the stack up on a server lives in [deploy/README.md](deploy/README.md).
AI-native outcome school + marketplace — graduates prove what they can build, not what they can write.
## Quickstart
-8
View File
@@ -13,7 +13,6 @@ 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
@@ -102,13 +101,6 @@ 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."""
+7 -940
View File
@@ -1,4 +1,4 @@
"""/v1/employers — employer session + claim surface (REQ-10-001/002, D-087..D-097).
"""/v1/employers/auth — employer session surface (REQ-10-001, D-087..D-097).
The employer principal (D-090): `require_employer_session` is the
require_admin extract→verify→return shape (R-56) with a SINGLE
@@ -6,7 +6,7 @@ header — `Authorization: Bearer <token>` via partition + scheme
check. sha256(token) is looked up against `employer_sessions`
(only the hash is ever stored, D-087); missing/expired/unknown →
**401** (credential failure — the token namespaces cannot collide
with admin tokens: this gate hash-looks-up, admin compares bytes);
with admin tokens: this gate hash-lookups, admin compares bytes);
**403 is reserved** for cross-entity authorization (a valid session
touching another employer's resource — P4).
@@ -15,17 +15,6 @@ D-097: employer routes NEVER compose the learner gates
The account's email is UNVERIFIED and /me displays it as such
(email_verified: Literal[False], no email verification exists).
Claims (P2, D-089): POST /v1/employers/claims targets an EXISTING
marketplace EmployerRow (404 unknown); one PENDING claim per entity
(409, the G-316 partial index backstops the race); an account that
already holds an entity → 409 honest (one account : one entity);
evidence is computed AT CLAIM TIME (claim_evidence.py, R-65) and
stored as provenance JSON on the claim row. Auto-approval is
DEFERRED (D-089: without email verification the match is forgeable;
A-304: never present unverified as verified) — the operator decision
activates. The marketplace store composes via app.state DI
(get_marketplace_store — the territory rule), never a direct import.
Tokens: `secrets.token_urlsafe(32)` — 256-bit CSPRNG, handed to the
client EXACTLY ONCE on login; only sha256(token) persists. TTL is
lazy (G-306): an expired row reads as absent, no timer.
@@ -36,79 +25,25 @@ from __future__ import annotations
import hashlib
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
from fastapi import APIRouter, Depends, Header, HTTPException
from ..billing.store import BillingStore
from ..config import Settings
from ..employers.base import (
ClaimEmployerRequest,
EmployerAccount,
EmployerCheckoutCreate,
EmployerCheckoutResponse,
EmployerClaim,
EmployerEntitlementResponse,
EmployerMe,
EmployerMockCompleteRequest,
EmployerMockCompleteResponse,
EmployerPostingCreate,
EmployerPostingEdit,
EmployerPostingWriteResponse,
LoginEmployerRequest,
OwnPostingsFacets,
OwnPostingsResponse,
OwnPostingSummary,
RegisterEmployerRequest,
TokenResponse,
)
from ..employers.claim_evidence import compute_claim_evidence
from ..employers.passwords import hash_password, verify_password
from ..employers.store import AccountRow, ClaimRow, EmployerStore, SessionRow
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.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,
)
from .entitlement import consume_posting_entitlement
from ..employers.store import AccountRow, EmployerStore, SessionRow
from .deps import get_employer_store, get_settings
router = APIRouter(prefix="/v1/employers/auth", tags=["employers-auth"])
#: Claims router (P2, D-089): sibling prefix under /v1/employers —
#: the claim route is session-gated, not auth-prefixed. P3 (Task
#: 3-1-01): the own-postings read route rides the SAME sibling
#: prefix — session-gated, not auth-prefixed.
claims_router = APIRouter(prefix="/v1/employers", tags=["employers-claims"])
#: Per-EMAIL failed-login window (D-088): rolling 15 min (900 s —
#: parameterized; identity hardcodes 60.0), monotonic timestamps,
#: failures-only append, SUCCESS CLEARS the email's window. Keyed by
@@ -308,11 +243,8 @@ async def employer_me(
) -> EmployerMe:
"""The session's account (email UNVERIFIED, displayed as such —
D-097/Literal[False]) + the claimed employer_id + the claim state
(the account's most recent claim — None when none exists; a
REJECTED claim stays visible as rejected until a newer claim
supersedes it, mirroring the P3 surfaces' claimPending branch)."""
(both None until P2's claim flow)."""
_session, account = auth
claim_row = store.get_claim_by_account(account.id)
return EmployerMe(
account=EmployerAccount(
id=account.id,
@@ -322,870 +254,5 @@ async def employer_me(
email_verified=False,
),
claimed_employer_id=account.employer_id,
claim=_claim_to_contract(claim_row) if claim_row is not None else None,
)
# -- claims (P2, D-089 — Task 2-1-03) ---------------------------------------------
def _claim_to_contract(row: ClaimRow) -> EmployerClaim:
"""Store row → the wire contract (created_at is always set on a
row the store returned — insert/decide stamp it)."""
assert row.created_at is not None # the store always stamps it
return EmployerClaim(
id=row.id,
account_id=row.account_id,
employer_id=row.employer_id,
status=row.status, # type: ignore[arg-type]
evidence=dict(row.evidence or {}),
created_at=row.created_at,
decided_by=row.decided_by,
decided_at=row.decided_at,
)
@claims_router.post(
"/claims", status_code=201, response_model=None
)
async def claim_employer_entity(
body: ClaimEmployerRequest,
auth: Annotated[
tuple[SessionRow, AccountRow], Depends(require_employer_session)
],
store: Annotated[EmployerStore, Depends(get_employer_store)],
marketplace_store=Depends(get_marketplace_store),
) -> EmployerClaim:
"""Claim an existing marketplace EmployerRow (D-089): session-gated;
404 unknown entity (claims target the v0.6 ingested EmployerRow
data — resolved via the marketplace store's app.state DI, never a
direct import); 409 when the account already holds an entity (one
account : one entity) or a claim is already PENDING on the entity
(G-316's partial index backstops the race — the IntegrityError
maps to the SAME honest 409). Evidence is computed AT CLAIM TIME
from the account email + the entity website (R-65) and stored as
provenance JSON on the claim row; the response carries the pending
claim with its evidence. Auto-approval is DEFERRED — the operator
decision activates (D-089; A-304: never present unverified as
verified)."""
_session, account = auth
employer = marketplace_store.get_employer(body.employer_id)
if employer is None:
raise HTTPException(
status_code=404,
detail=f"no employer with id {body.employer_id!r}",
)
if account.employer_id is not None:
# One account : one entity — the honest 409 (the account
# already holds an entity; approving a second claim would be
# refused by the store's UNIQUE constraint anyway).
raise HTTPException(
status_code=409,
detail="account already holds an employer entity",
)
evidence = compute_claim_evidence(account.email, employer.website)
from sqlalchemy.exc import IntegrityError
try:
row, created = store.insert_claim(
account_id=account.id,
employer_id=body.employer_id,
evidence=evidence,
)
except IntegrityError:
# G-316: the raced insert refused by the partial UNIQUE index
# — the SAME honest 409 as the check-first fast path.
raise HTTPException(
status_code=409,
detail="a claim is already pending for this employer",
) from None
if not created:
raise HTTPException(
status_code=409,
detail="a claim is already pending for this employer",
)
store.append_audit_event(
"claim",
"claim.submitted",
account_id=account.id,
employer_id=body.employer_id,
detail={
"claim_id": row.id,
"evidence_match": evidence["match"],
},
)
return _claim_to_contract(row)
# -- own postings (P3, Task 3-1-01 — REQ-10-003, D-092/G-319) ----------------------
@claims_router.get("/postings", response_model=OwnPostingsResponse)
async def list_own_postings(
auth: Annotated[
tuple[SessionRow, AccountRow], Depends(require_employer_session)
],
marketplace_store=Depends(get_marketplace_store),
) -> OwnPostingsResponse:
"""GET /v1/employers/postings — the claimed employer's OWN postings
(D-092's own-postings lifecycle view: draft → published → closed +
real applicant counts). Session-gated (401 missing/unknown/expired
token); UNCLAIMED account → **403 + claim-CTA detail** — the D-090
semantics: 401 is credential failure, 403 is the authorization class
(a valid session whose account holds no claimed entity is not
authorized to any employer's postings).
Postings resolve from the marketplace store via app.state DI
(get_marketplace_store — the territory rule), filtered to the
session's employer_id; per-posting applicant_count rides ONE
batched count_applications_by_posting read (never a per-posting
N+1). G-319 PII boundary (binding): applicant COUNTS ONLY —
learner identifiers never ride any /v1/employers/* response."""
_session, account = auth
if account.employer_id is None:
# Unclaimed account — the honest claim-CTA 403 (D-090).
raise HTTPException(
status_code=403,
detail={
"reason": "no_claimed_employer",
"claim_cta": (
"This account has not claimed an employer entity yet "
"— submit a claim via POST /v1/employers/claims."
),
},
)
postings = marketplace_store.list_postings_by_employer(account.employer_id)
counts = marketplace_store.count_applications_by_posting(
[p.id for p in postings]
)
# Honest facet counts over every status the lifecycle defines —
# each key rides the response even at 0.
status_counts: dict[str, int] = dict.fromkeys(
("draft", "published", "closed"), 0
)
items: list[OwnPostingSummary] = []
for posting in postings:
status_counts[posting.status] += 1
items.append(
OwnPostingSummary(
**posting.model_dump(),
applicant_count=counts.get(posting.id, 0),
)
)
return OwnPostingsResponse(
postings=items,
facets=OwnPostingsFacets(**status_counts),
total=len(items),
)
# -- own postings write (P4, Task 4-1-02 — REQ-10-004, D-091/G-311/317/318) ---------
def _require_claimed(account: AccountRow) -> str:
"""The claim gate shared by every write route (G-311 order:
session 401 → claim 403 → entitlement 402 → insert). Returns the
claimed employer_id — a valid session whose account holds no
entity is not authorized to any employer's postings (the D-090
403 class, never a 402)."""
if account.employer_id is None:
raise HTTPException(
status_code=403,
detail={
"reason": "no_claimed_employer",
"claim_cta": (
"This account has not claimed an employer entity yet "
"— submit a claim via POST /v1/employers/claims."
),
},
)
return account.employer_id
def _own_posting_or_404(
store: MarketplaceStore, posting_id: str, employer_id: str
) -> Posting:
"""Fetch the posting + enforce the cross-entity authorization arm
(D-090: another entity's posting → 403; unknown → 404)."""
posting = store.get_posting(posting_id)
if posting is None:
raise HTTPException(
status_code=404,
detail=f"no posting with id {posting_id!r}",
)
if posting.employer_id != employer_id:
raise HTTPException(
status_code=403,
detail="this posting belongs to another employer entity",
)
return posting
@claims_router.post("/postings", status_code=201, response_model=None)
async def create_own_posting(
body: EmployerPostingCreate,
request: Request,
auth: Annotated[
tuple[SessionRow, AccountRow], Depends(require_employer_session)
],
store: MarketplaceStore = Depends(get_marketplace_store),
) -> EmployerPostingWriteResponse:
"""Create a DRAFT on the CLAIMED entity (G-311: session gate →
claim gate → entitlement gate → insert; unauthenticated → 401 and
unclaimed → 403, never a 402 — the R-58 admin-gate-first order).
The entitlement gate is the SHARED helper (D-091 no-drift): the
SAME consume_posting_entitlement the admin route composes, with
the employer checkout pointer; a 402 consumes nothing, creates
nothing (gate-before-insert).
G-317: employer authorship stamps its OWN provenance —
source="employer" + emp- id/external_id schemes (the admin route
stamps source="admin"; authorship stays answerable across the
seeded/admin/employer classes). The admin routes are untouched."""
_session, account = auth
employer_id = _require_claimed(account)
consume_posting_entitlement(
getattr(request.app.state, "billing_store", None),
employer_id,
"/v1/employers/billing/checkout",
)
posting = Posting(
id=f"emp-{uuid.uuid4().hex[:16]}",
source="employer",
external_id=f"emp-{uuid.uuid4().hex[:16]}",
employer_id=employer_id,
title=body.title,
description=body.description,
required_competencies=body.required_competencies,
skills=body.skills,
seniority=body.seniority, # type: ignore[arg-type]
category=body.category,
location=body.location,
remote=body.remote,
salary_min=body.salary_min,
salary_max=body.salary_max,
status="draft",
)
try:
stored = store.insert_posting(posting)
except Exception as exc: # surfaced IntegrityError from the store
raise HTTPException(status_code=409, detail=str(exc)) from exc
return EmployerPostingWriteResponse(
id=stored.id,
source=stored.source,
external_id=stored.external_id,
employer_id=stored.employer_id,
title=stored.title,
status=stored.status,
)
@claims_router.patch(
"/postings/{posting_id}", response_model=EmployerPostingWriteResponse
)
async def edit_own_posting(
posting_id: str,
body: EmployerPostingEdit,
auth: Annotated[
tuple[SessionRow, AccountRow], Depends(require_employer_session)
],
store: MarketplaceStore = Depends(get_marketplace_store),
settings: Settings = Depends(get_settings),
provider: LLMProvider = Depends(get_provider),
) -> EmployerPostingWriteResponse:
"""Edit the OWN DRAFT's content (G-318: own drafts ONLY — the
published/closed guard evaluates BEFORE any mutation, so a 409
leaves fields AND tags untouched; cross-entity → 403; unknown →
404). The content edit + re-tag composition is the IDENTICAL
update_posting_fields + tag_posting path as the admin edit
(D-052: tags are derived data — the new extraction OVERWRITES;
tag failure is honest: the edit stands, tags keep their previous
value, never a 500)."""
_session, account = auth
employer_id = _require_claimed(account)
posting = _own_posting_or_404(store, posting_id, employer_id)
if posting.status != "draft":
# Guard BEFORE any mutation — no field/tag side effects.
raise HTTPException(
status_code=409,
detail=(
f"only drafts can be edited — this posting is "
f"{posting.status}"
),
)
updated = store.update_posting_fields(
posting_id,
title=body.title,
description=body.description,
skills=body.skills,
)
if updated is None: # pragma: no cover - fetched above in-transaction
raise HTTPException(
status_code=404, detail=f"no posting with id {posting_id!r}"
)
try:
tags = await tag_posting(
provider,
model=settings.model,
title=updated.title,
description=updated.description,
skills_hint=updated.skills,
)
updated = store.set_posting_tags(posting_id, tags) or updated
except TaggingUnavailable:
# Honest: the EDIT landed; tags keep their previous value (the
# warning already logged in tagging.py). Never 500 on tag
# failure — the admin edit's identical arm.
pass
return EmployerPostingWriteResponse(
id=updated.id,
source=updated.source,
external_id=updated.external_id,
employer_id=updated.employer_id,
title=updated.title,
status=updated.status,
)
@claims_router.post(
"/postings/{posting_id}/publish", response_model=EmployerPostingWriteResponse
)
async def publish_own_posting(
posting_id: str,
auth: Annotated[
tuple[SessionRow, AccountRow], Depends(require_employer_session)
],
store: MarketplaceStore = Depends(get_marketplace_store),
settings: Settings = Depends(get_settings),
embedder=Depends(get_embedding_provider),
provider: LLMProvider = Depends(get_provider),
) -> EmployerPostingWriteResponse:
"""Own draft → published (G-311 order: the own-entity + draft
guards run before any transition). The composition behind publish
is the admin publish's IDENTICAL hooks: embed-on-publish + the
one-shot enrich-on-first-publish (D-053) — failures are honest
warnings, the publish stands."""
_session, account = auth
employer_id = _require_claimed(account)
posting = _own_posting_or_404(store, posting_id, employer_id)
if posting.status != "draft":
raise HTTPException(
status_code=409,
detail=f"only drafts can be published — this posting is "
f"{posting.status}",
)
try:
stored = store.set_posting_status(posting_id, "published")
except InvalidTransition as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
if stored is None: # pragma: no cover - fetched above in-transaction
raise HTTPException(
status_code=404, detail=f"no posting with id {posting_id!r}"
)
await embed_posting(store, embedder, stored)
try:
await maybe_enrich_on_publish(
store, provider, model=settings.model, employer_id=stored.employer_id
)
except Exception: # pragma: no cover - maybe_enrich swallows; belt+braces
pass # honest warning path — the publish stands (admin precedent)
return EmployerPostingWriteResponse(
id=stored.id,
source=stored.source,
external_id=stored.external_id,
employer_id=stored.employer_id,
title=stored.title,
status=stored.status,
)
@claims_router.post(
"/postings/{posting_id}/close", response_model=EmployerPostingWriteResponse
)
async def close_own_posting(
posting_id: str,
auth: Annotated[
tuple[SessionRow, AccountRow], Depends(require_employer_session)
],
store: MarketplaceStore = Depends(get_marketplace_store),
) -> EmployerPostingWriteResponse:
"""Own posting → closed (published → closed is the only valid move;
409 on closing a draft or re-closing — the store's transition
matrix; cross-entity → 403; unknown → 404)."""
_session, account = auth
employer_id = _require_claimed(account)
_own_posting_or_404(store, posting_id, employer_id)
try:
stored = store.set_posting_status(posting_id, "closed")
except InvalidTransition as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
if stored is None: # pragma: no cover - fetched above in-transaction
raise HTTPException(
status_code=404, detail=f"no posting with id {posting_id!r}"
)
return EmployerPostingWriteResponse(
id=stored.id,
source=stored.source,
external_id=stored.external_id,
employer_id=stored.employer_id,
title=stored.title,
status=stored.status,
)
# -- 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) ------------------
@claims_router.post(
"/billing/checkout", status_code=201, response_model=EmployerCheckoutResponse
)
async def create_own_checkout(
body: EmployerCheckoutCreate,
request: Request,
auth: Annotated[
tuple[SessionRow, AccountRow], Depends(require_employer_session)
],
store: Annotated[BillingStore, Depends(get_billing_store)],
settings: Annotated[Settings, Depends(get_settings)],
) -> EmployerCheckoutResponse:
"""Create a provider checkout session for a catalog item (R-59:
billing_admin.create_checkout transposed VERBATIM — only the
principal derivation + metadata provenance differ). The buyer is
the session's CLAIMED entity (unclaimed → 403 + claim-CTA, the
D-090 class); the catalog item snapshot (name + price_cents,
D-078) rides the session and the local reconciliation row;
success/cancel URLs default from public_base_url (G-310). The
webhook route + _grant_entitlement serve employer checkouts
verbatim (G-303 per-session exactly-once holds)."""
from uuid import uuid4
from ..billing.base import CheckoutSessionSpec
_session, account = auth
employer_id = _require_claimed(account)
package, plan, item = store.get_catalog_item(body.item_id)
if item is None:
raise HTTPException(status_code=404, detail="unknown catalog item")
mode = "subscription" if item["kind"] == "plan" else "payment"
interval: str = str(item.get("interval", "") or "")
if settings.public_base_url:
success_url = (
f"{settings.public_base_url}/marketplace/pricing?checkout=success"
)
cancel_url = (
f"{settings.public_base_url}/marketplace/pricing?checkout=cancelled"
)
else:
# Mock pilot path: the mock provider never redirects; empty is
# honest (stripe factory validation rejects the empty case —
# G-310's loud boot warning covers the stripe selection).
success_url = ""
cancel_url = ""
# The reconciliation key is the LOCAL row id (R-34): persist the
# snapshot row FIRST with a UNIQUE pending placeholder, then mint
# the spec against it, then bind the provider session id (the
# verbatim billing_admin order).
row = store.insert_checkout_session(
provider_session_id=f"pending-{uuid4().hex[:16]}",
employer_id=employer_id,
item_id=str(item["id"]),
item_name=str(item["name"]),
price_cents=int(item["price_cents"]),
mode=mode,
)
spec = CheckoutSessionSpec(
mode=mode, # type: ignore[arg-type]
item_id=str(item["id"]),
price_cents=int(item["price_cents"]),
item_name=str(item["name"]),
interval=interval,
success_url=success_url,
cancel_url=cancel_url,
client_reference_id=row.id,
metadata={
"employer_id": employer_id,
# D-076's "operator" becomes ACCOUNT provenance: the
# employer principal bought on their own claimed entity's
# behalf — the account id answers the authorship question.
"operator_ref": f"account:{account.id}",
},
)
provider = request.app.state.payment_provider
session = await provider.create_checkout_session(spec)
# Pin the provider session id onto the local row (upsert by local
# id — the row IS the reconciliation record, G-302).
store.bind_provider_session_id(row.id, session.id)
return EmployerCheckoutResponse(
session_id=row.id,
provider_session_id=session.id,
url=session.url,
mode=mode,
)
@claims_router.get(
"/billing/entitlement",
response_model=EmployerEntitlementResponse,
)
def get_own_entitlement(
auth: Annotated[
tuple[SessionRow, AccountRow], Depends(require_employer_session)
],
store: Annotated[BillingStore, Depends(get_billing_store)],
) -> EmployerEntitlementResponse:
"""GET /v1/employers/billing/entitlement — the session employer's
OWN entitlement state (the v0.9 REQ-9-004 shape resolved for the
CLAIMED entity only): active package (posts used/limit/expiry —
lazy past_due grace per G-306) + subscription. Honest states:
purchase/subscription stay None until a real grant exists (the
shape cannot distinguish never-purchased from expired — both
read None; surfaces render the honest empty state from this)."""
_session, account = auth
employer_id = _require_claimed(account)
entitlement = store.get_entitlement(employer_id)
return EmployerEntitlementResponse(
employer_id=entitlement["employer_id"],
purchase=entitlement["purchase"],
subscription=entitlement["subscription"],
)
# -- own-session mock-complete (P5, Task 5-1-02 — D-082 transpose, G-301/G-302) -----
@claims_router.post(
"/billing/mock/complete",
status_code=200,
response_model=EmployerMockCompleteResponse,
)
async def complete_own_checkout(
body: EmployerMockCompleteRequest,
request: Request,
auth: Annotated[
tuple[SessionRow, AccountRow], Depends(require_employer_session)
],
store: Annotated[BillingStore, Depends(get_billing_store)],
) -> EmployerMockCompleteResponse:
"""Complete one's OWN MOCK checkout session (D-082 transposed,
R-59/G-301): employer-SESSION-gated, mock-only via the isinstance
arm below — the router mounts WHOLE (main.py include_router, no
factory-conditional mount exists), so this arm IS the gate: a
stripe-configured box 404s (pinned in test_checkout_e2e).
G-302: the payload's provider_session_id is an UNTRUSTED ID
reference — the session resolves from LOCAL rows and must be one
of the session's own checkout rows (the claimed entity's), else
403 (the G-301 hole class: completing another buyer's session
must be refused, never granted). The completion mints a properly-
signed event through the REAL webhook processing path (the D-082
proof shape — mint_event → process_webhook_event, direct handler
call)."""
from ..billing.mock import MockPaymentProvider
from .billing import process_webhook_event
_session, account = auth
employer_id = _require_claimed(account)
provider = request.app.state.payment_provider
if not isinstance(provider, MockPaymentProvider):
# The router mounts whole — THIS arm is the mock-only gate (no
# factory-conditional mount exists; the billing_admin.mock_complete
# precedent, pinned 404-under-stripe in test_checkout_e2e).
raise HTTPException(
status_code=404, detail="mock-complete exists only under provider=mock"
)
# G-302: resolve from LOCAL rows only — the payload is an untrusted
# ID reference. G-301: the row must belong to the CLAIMED entity
# (the session's own checkout), never another buyer's.
session = store.get_checkout_session_by_provider_id(body.provider_session_id)
if session is not None and session.employer_id != employer_id:
raise HTTPException(
status_code=403,
detail="this checkout session belongs to another employer entity",
)
if session is None:
raise HTTPException(status_code=404, detail="unknown checkout session")
try:
session_after = await provider.confirm_completion(
body.provider_session_id
)
except ValueError as exc:
# G-301 idempotency (the mock's documented contract: the ROUTE
# owns it): the provider refuses a re-completion ("not open" —
# a double-click) or lost its in-memory state across a restart
# ("unknown session"). The provider's CURRENT view is
# authoritative — resolve via retrieve_session and re-run the
# REAL webhook path (idempotent per session, G-303); NEVER a
# 500 on the payment surface (G-306).
recovered = await provider.retrieve_session(body.provider_session_id)
if recovered is None:
raise HTTPException(
status_code=404,
detail=(
"the provider no longer knows this session (a restart "
"clears mock state) — create a new checkout"
),
) from exc
if recovered.status != "complete":
raise HTTPException(
status_code=409,
detail=f"session is {recovered.status!r}, not completable",
) from exc
# Already complete at the provider: fall through with the
# recovered truth — the re-minted event re-runs the grant path,
# and G-303's per-session exactly-once refuses a second grant.
session_after = recovered
# Mint + process the signed event through the REAL webhook path
# (direct handler call — same code path, the D-082 proof shape).
raw_body, header, event_id = provider.mint_event(
"checkout.completed",
{
"session_id": body.provider_session_id,
"payment_status": session_after.payment_status,
},
)
await process_webhook_event(
store=store,
provider=provider,
raw_body=raw_body,
signature_header=header,
)
return EmployerMockCompleteResponse(
completed=session_after.status == "complete",
payment_status=session_after.payment_status,
claim=None, # P2's claim flow fills this (D-089)
)
@@ -1,287 +0,0 @@
"""/v1/employers/admin — employer claim operator surface (REQ-10-002, D-089/D-094).
Every route sits behind `require_admin` IMPORTED from
marketplace_admin (the billing_admin.py:27 precedent, D-094 — one
guard, never reimplemented): fail-closed 403 on no/missing/invalid
token — the R-56 matrix; an UNSET AI_ADMIN_TOKEN 403s every route
even with a candidate (the main.py boot warning covers the config
class). Operator routes compose NO learner gate (D-097) and no
employer-session gate — a different principal class, and the token
namespaces cannot collide (this gate compares bytes; the session
gate hash-looks-up — D-090).
D-089: evidence is ADVISORY — the queue returns the stored claim-time
provenance snapshot as-is; approval is the EXPLICIT operator decision
(auto-approval deferred: without email verification the match is
forgeable, A-304 — never present unverified as verified). Every
decision appends an employer_audit_events row with operator
provenance in the SAME transaction as the claim update (decide_claim
— all-or-nothing). The accounts wire contract carries NO password_hash
(hash-at-rest, D-087 — never returned anywhere).
409 discipline (the decide arms' honest constraint 409s — details
carry constraint names only, never account identifiers):
- claim not pending (decided twice) → "claim is already decided"
- the deciding account already holds an entity (the one-account-
one-entity guard) → "the claim's account already holds an employer
entity"
- the entity already owned (the employer_accounts.employer_id UNIQUE
surfacing on the approve flush) → "the employer entity is already
owned" — surfaced, NEVER silently overwritten.
Any OTHER IntegrityError re-raises (a 500) — never mask an unknown
constraint violation as a decision.
"""
from __future__ import annotations
from typing import Annotated, Literal
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy.exc import IntegrityError
from ..config import Settings
from ..employers.base import EmployerClaim
from ..employers.store import ClaimRow, EmployerStore
from .deps import get_employer_store
from .marketplace_admin import require_admin
router = APIRouter(prefix="/v1/employers/admin", tags=["employers-admin"])
#: Operator provenance stamped on every decision + audit event (the
#: billing_admin "operator" precedent — route-level provenance; no
#: per-operator accounts exist at pilot, D-094).
OPERATOR = "operator"
# -- contracts ----------------------------------------------------------------
class AdminEmployerAccount(BaseModel):
"""The accounts-list wire row (D-094): email, claimed entity,
created_at, the UNVERIFIED marker. `email_verified` is
`Literal[False]` (D-097 — structurally incapable of True: no email
verification exists in v0.10, so the field can never carry it).
password_hash is NEVER on the wire (D-087)."""
id: str
email: str
employer_id: str | None
created_at: str
#: D-097: the account is UNVERIFIED — displayed as such, never as
#: verified (A-304's doctrine applied to the account surface).
email_verified: Literal[False] = False
class AdminAccountsResponse(BaseModel):
accounts: list[AdminEmployerAccount]
count: int
class AdminClaimsResponse(BaseModel):
"""The claim queue (D-089): status, the stored claim-time evidence
snapshot — the domain-match signal rides evidence["match"],
ADVISORY, never a verification — and the decided state
(decided_by/decided_at, None while pending)."""
claims: list[EmployerClaim]
count: int
class ClaimDecisionResponse(BaseModel):
"""The decided claim (status approved/rejected + operator
provenance) — either decision arm returns the same shape."""
claim: EmployerClaim
class AdminAuditEvent(BaseModel):
"""The audit-trail wire row (D-089's queryable operator trail —
the BillingEventRow_ shape, billing_admin.py:488)."""
id: int
account_id: str | None
employer_id: str | None
kind: str
name: str
detail: dict[str, object]
operator: str | None
created_at: str | None
class AdminAuditResponse(BaseModel):
events: list[AdminAuditEvent]
count: int
def _claim_to_contract(row: ClaimRow) -> EmployerClaim:
"""Store row → wire contract (created_at is always set on a row
the store returned — insert/decide stamp it)."""
assert row.created_at is not None # the store always stamps it
return EmployerClaim(
id=row.id,
account_id=row.account_id,
employer_id=row.employer_id,
status=row.status, # type: ignore[arg-type]
evidence=dict(row.evidence or {}),
created_at=row.created_at,
decided_by=row.decided_by,
decided_at=row.decided_at,
)
# -- queue reads (D-094) ---------------------------------------------------------
@router.get("/accounts", response_model=AdminAccountsResponse)
def list_accounts(
store: Annotated[EmployerStore, Depends(get_employer_store)],
settings: Annotated[Settings, Depends(require_admin)],
) -> AdminAccountsResponse:
"""The accounts list: email, claimed entity, created_at, and the
UNVERIFIED marker (D-097 — account emails are unverified and
displayed as such; a claimed entity column shows the APPROVED
ownership binding, never account verification)."""
rows = store.list_accounts()
return AdminAccountsResponse(
accounts=[
AdminEmployerAccount(
id=account.id,
email=account.email,
employer_id=account.employer_id,
created_at=account.created_at.isoformat() if account.created_at else "",
email_verified=False, # D-097: UNVERIFIED, always False
)
for account in rows
],
count=len(rows),
)
@router.get("/claims", response_model=AdminClaimsResponse)
def list_claims(
store: Annotated[EmployerStore, Depends(get_employer_store)],
settings: Annotated[Settings, Depends(require_admin)],
status: str | None = None,
) -> AdminClaimsResponse:
"""The claim queue: status, the stored evidence snapshot (the
domain-match signal is ADVISORY — the signal informs, the operator
decides; match=false is the example.com corpus norm, R-65), and
the decided state. Optional ?status= facet (the admin queue's
consume-free read path)."""
rows = store.list_claims(status=status)
return AdminClaimsResponse(
claims=[_claim_to_contract(row) for row in rows],
count=len(rows),
)
# -- decisions (D-089 — the explicit operator action) ----------------------------
def _decide(store: EmployerStore, claim_id: str, decision: str) -> EmployerClaim:
"""The shared decide path: unknown claim → 404; the store's guard
arms + the employer_accounts.employer_id UNIQUE surface as the
honest 409s (constraint names only — never account identifiers);
decide_claim appends the operator audit event in the SAME
transaction as the decision."""
try:
row = store.decide_claim(claim_id, decision=decision, operator=OPERATOR)
except IntegrityError as exc:
message = str(getattr(exc, "orig", exc))
if "employer_accounts.employer_id already set" in message:
# decide_claim's account-already-owner guard — the
# one-account-one-entity arm, hand-raised by the store.
raise HTTPException(
status_code=409,
detail=("the claim's account already holds an employer entity"),
) from None
if "UNIQUE constraint failed: employer_accounts.employer_id" in (message):
# The approve flush refused: a DIFFERENT account already
# owns the entity — surfaced, never silently overwritten.
raise HTTPException(
status_code=409,
detail="the employer entity is already owned",
) from None
if "is already" in message:
# decide_claim's not-pending guard — deciding a decided
# claim, the IntegrityError shape (the honest 409).
raise HTTPException(
status_code=409,
detail="claim is already decided",
) from None
# Unmapped IntegrityError — re-raise (a 500): never mask an
# unknown constraint violation as a decision.
raise
if row is None:
raise HTTPException(status_code=404, detail="unknown claim")
return _claim_to_contract(row)
@router.post("/claims/{claim_id}/approve", response_model=ClaimDecisionResponse)
def approve_claim(
claim_id: str,
store: Annotated[EmployerStore, Depends(get_employer_store)],
settings: Annotated[Settings, Depends(require_admin)],
) -> ClaimDecisionResponse:
"""Approve → the claim's account.employer_id is set (one account :
one entity — the UNIQUE activates) + decided_by/decided_at + the
operator audit event, all in one transaction. Evidence never
auto-activates anything: the operator decision is the action."""
row = _decide(store, claim_id, "approved")
return ClaimDecisionResponse(claim=row)
@router.post("/claims/{claim_id}/reject", response_model=ClaimDecisionResponse)
def reject_claim(
claim_id: str,
store: Annotated[EmployerStore, Depends(get_employer_store)],
settings: Annotated[Settings, Depends(require_admin)],
) -> ClaimDecisionResponse:
"""Reject → decided_by/decided_at + the operator audit event (same
transaction); the entity is FREED for a new claim (the partial
pending index never touches rejected rows, G-316)."""
row = _decide(store, claim_id, "rejected")
return ClaimDecisionResponse(claim=row)
# -- audit trail (D-089's queryable operator trail) ------------------------------
@router.get("/audit", response_model=AdminAuditResponse)
def list_audit(
store: Annotated[EmployerStore, Depends(get_employer_store)],
settings: Annotated[Settings, Depends(require_admin)],
account_id: str | None = None,
employer_id: str | None = None,
kind: str | None = None,
name: str | None = None,
limit: int = 100,
) -> AdminAuditResponse:
"""The queryable audit trail: every auth/claim event with operator
provenance — the claim.submitted/claim.approved/claim.rejected
names; decided_by/decided_at ride the claim rows the decision
events reference (detail.claim_id joins them)."""
rows = store.list_audit_events(
account_id=account_id,
employer_id=employer_id,
kind=kind,
name=name,
limit=limit,
)
return AdminAuditResponse(
events=[
AdminAuditEvent(
id=event.id or 0,
account_id=event.account_id,
employer_id=event.employer_id,
kind=event.kind,
name=event.name,
detail=dict(event.detail or {}),
operator=event.operator,
created_at=event.created_at.isoformat() if event.created_at else None,
)
for event in rows
],
count=len(rows),
)
@@ -1,99 +0,0 @@
"""The D-091 shared posting-entitlement gate (REQ-10-004, R-58).
ONE helper, TWO principal gates: the admin create route (v0.9,
marketplace_admin) and the employer create route (v0.10 Task 4-1-02)
both compose THIS — never a re-implemented copy (the no-drift shape;
extraction is the only way "the exact v0.9 check parameterized" holds).
The helper lives in api/ beside both callers — no billing/→api/
boundary violation (billing/ stays ignorant of routes, D-027/D-048).
Wire contract (R-58 — byte-stable, D-080/D-084): the 402 detail dict is
{reason, posts_used: 0, posts_limit: 0, upgrade_cta} with posts_used and
posts_limit HARDCODED 0 in BOTH arms. The zeros are the v0.9 wire shape
the web client's UpgradeRequiredError ignores (it consumes reason +
upgrade_cta only, apps/web/lib/engine-client.ts) — do NOT "improve" them
into live counts; the payload-parity test pins the bytes.
Edge ordering (mirrors the extracted v0.9 gate exactly):
1. store-missing (None) → 503 fail-closed BEFORE any consume attempt —
a payment gate never silently skips (the final-review fix).
2. consume_posting → NoActivePackage → the no_active_package 402 arm.
3. consume_posting → PackageExhausted → the posting_limit_reached arm.
The gate runs BEFORE the caller's insert — a 402 consumes nothing,
creates nothing. `consume_posting` itself (G-304 single conditional
UPDATE) is untouched; this helper only maps its exceptions to the
honest 402 arms.
"""
from __future__ import annotations
from fastapi import HTTPException
from ..billing.store import BillingStore, NoActivePackage, PackageExhausted
def consume_posting_entitlement(
store: BillingStore | None,
employer_id: str,
upgrade_cta: str,
) -> None:
"""Spend one posting post for `employer_id`, or raise the honest 402.
Args:
store: the billing store from app.state (None = a wiring miss →
503 fail-closed — posting creation is refused until the
service is restarted, never an ungated insert).
employer_id: the entity whose package is charged.
upgrade_cta: the caller-principal's checkout pointer
("/v1/billing/admin/checkout" from the admin route; the
employer route passes its own) — interpolated into the two
FIXED per-arm sentences, so the rendered upgrade_cta field
differs per principal while reason/posts_used/posts_limit
stay byte-identical across both gates (the D-091 parity).
Raises:
HTTPException 503: the store is missing (fail-closed).
HTTPException 402: no active package / all posts spent — the
canonical dict payload (hardcoded zeros, R-58).
"""
if store is None:
raise HTTPException(
status_code=503,
detail=(
"billing entitlement store is unavailable — posting "
"creation is refused until the service is restarted "
"(the store wires at boot)"
),
)
try:
store.consume_posting(employer_id)
except NoActivePackage:
raise HTTPException(
status_code=402,
detail={
"reason": "no_active_package",
# R-58: hardcoded zeros — the wire contract, never live
# counts (real limits render only from the entitlement
# view; the client's UpgradeRequiredError consumes
# reason + upgrade_cta alone).
"posts_used": 0,
"posts_limit": 0,
"upgrade_cta": (
"This employer has no active posting package — "
f"purchase one via {upgrade_cta}."
),
},
) from None
except PackageExhausted:
raise HTTPException(
status_code=402,
detail={
"reason": "posting_limit_reached",
"posts_used": 0,
"posts_limit": 0,
"upgrade_cta": (
"This employer's posting package is exhausted — "
f"buy more posts or upgrade via {upgrade_cta}."
),
},
) from None
@@ -16,7 +16,6 @@ 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
@@ -34,15 +33,6 @@ 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
@@ -385,19 +375,6 @@ 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,
@@ -405,173 +382,6 @@ async def apply_posting(
)
# -- v0.11 P2: the learner withdraw route (D-104, REQ-11-002) --------------------
class WithdrawBody(BaseModel):
"""D-104: the pilot surface passes learner_id in the body (the G-5
composition unchanged — the SAME stack as apply)."""
learner_id: str = Field(min_length=1)
@router.post(
"/applications/{application_id}/withdraw", response_model=ApplyResponse
)
async def withdraw_application(
application_id: str,
body: WithdrawBody,
request: Request,
store=Depends(get_marketplace_store),
settings: Settings = Depends(get_settings),
) -> ApplyResponse:
"""The learner's ONLY edit (D-099: withdrawn is learner- or
operator-triggerable, NEVER an employer session). The D-043
composition UNCHANGED: G-5 allowlist → verified 18+ → the EXISTING
apply-rate window (no new throttle knob, D-088 scope untouched).
Own application only (403 foreign); terminal → 409 (the matrix).
"""
_enforce_allowlist(body.learner_id, settings)
await require_verified_age(18, body.learner_id, _identity_store(request))
_check_apply_rate(body.learner_id)
application = store.get_application(application_id)
if application is None:
raise HTTPException(
status_code=404, detail=f"no application with id {application_id!r}"
)
if application.learner_id != body.learner_id:
raise HTTPException(
status_code=403,
detail="this application belongs to another learner",
)
from .deps import get_placement_store # local import — the territory rule
placements = get_placement_store(request)
try:
application, _placement = placements.transition_application(
application_id,
"withdrawn",
actor_type="learner",
actor_ref=f"learner:{body.learner_id}",
)
except InvalidTransition as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
return ApplyResponse(
application_id=application.id,
posting_id=application.posting_id,
status=application.status,
)
# -- v0.11 P5: the learner status reads (D-104/G-322, REQ-11-005) -------------------
def _placement_read(row: PlacementRow) -> PlacementRead:
"""The PlacementRow → PlacementRead conversion (the placements_admin
helper mirrored — each route file owns its readers; start_date is
the ISO DATE string on the row, a date on the wire, D-103)."""
return PlacementRead(
id=row.id,
learner_id=row.learner_id,
employer_id=row.employer_id,
posting_id=row.posting_id,
application_id=row.application_id,
role_title=row.role_title,
start_date=date.fromisoformat(row.start_date),
status=row.status, # type: ignore[arg-type]
created_at=row.created_at or _now(),
decided_at=row.decided_at or _now(),
)
def _now():
from datetime import UTC, datetime
return datetime.now(UTC)
@router.get("/applications/mine", response_model=MineApplicationResponse)
async def mine_application(
request: Request,
posting_id: str = Query(min_length=1),
learner_id: str = Query(min_length=1),
store=Depends(get_marketplace_store),
settings: Settings = Depends(get_settings),
) -> MineApplicationResponse:
"""G-322 (binding): the mine point-read — the status panel's
application DISCOVERY on revisit (the panel NEVER depends on
client-persisted application_ids). Gate composition: G-5 allowlist
→ verified 18+ (reads are free — the apply-rate window is the
WRITE budget, A-408's distinct-action rule; no new throttle knob,
D-088 untouched); no row → the honest 404 (the surface renders the
apply CTA)."""
_enforce_allowlist(learner_id, settings)
await require_verified_age(18, learner_id, _identity_store(request))
application = store.get_application_by_posting_learner(posting_id, learner_id)
if application is None:
raise HTTPException(
status_code=404,
detail=f"no application by this learner on posting {posting_id!r}",
)
return MineApplicationResponse(
application_id=application.id,
status=application.status,
)
@router.get(
"/applications/{application_id}",
response_model=LearnerApplicationStatusResponse,
)
async def learner_application_status(
application_id: str,
request: Request,
learner_id: str = Query(min_length=1),
store=Depends(get_marketplace_store),
settings: Settings = Depends(get_settings),
) -> LearnerApplicationStatusResponse:
"""D-104 (REQ-11-005): the learner's-own outcome read — current
status + the FULL id-ordered timestamped history for THIS
application + the placement record when hired (one read, one
truth source). Same gates as the mine-read (reads are free);
foreign learner_id → 403 (the scoped query never renders another
learner's events); unknown → 404."""
_enforce_allowlist(learner_id, settings)
await require_verified_age(18, learner_id, _identity_store(request))
application = store.get_application(application_id)
if application is None:
raise HTTPException(
status_code=404, detail=f"no application with id {application_id!r}"
)
if application.learner_id != learner_id:
raise HTTPException(
status_code=403,
detail="this application belongs to another learner",
)
from .deps import get_placement_store # local import — the territory rule
placements = get_placement_store(request)
events = [
PlacementEventRead.model_validate(event, from_attributes=True)
for event in placements.get_application_events(application_id)
]
row = placements.get_placement_by_application(application_id)
return LearnerApplicationStatusResponse(
application=LearnerApplicationSummary(
id=application.id,
posting_id=application.posting_id,
learner_id=application.learner_id,
status=application.status,
created_at=application.created_at,
),
events=events,
placement=_placement_read(row) if row is not None else None,
)
# -- P4-B: resume upload + confirm (REQ-6-005, D-054, A-410) --------------------
@@ -37,7 +37,6 @@ from .deps import (
get_provider,
get_settings,
)
from .entitlement import consume_posting_entitlement
logger = logging.getLogger(__name__)
@@ -165,23 +164,60 @@ async def create_posting(
"""Create a DRAFT posting (founder authoring path, D-050 admin_api
adapter — source=admin). Publish is a separate lifecycle call.
v0.10 P4 (D-091, Task 4-1-01): the inline gate is extracted into the
shared api-layer helper `consume_posting_entitlement` (api/entitlement.
py) — this admin route and the employer create route (Task 4-1-02)
compose the SAME gate, never re-implemented copies. The admin
principal passes the admin-checkout pointer; the 402 dict (hardcoded
posts_used/posts_limit zeros, R-58) is byte-stable across both
gates — the wire contract UpgradeRequiredError consumes unchanged.
v0.9 P4 (REQ-9-004): the employer's billing entitlement composes
AFTER the admin gate — the package limit is enforced here (one
post per create), honest 402 + upgrade-CTA payload when the
employer has no active package or is out of posts. G-309: the
seeded-corpus ingest (and any future adapter) bypasses this check
by construction — enforcement exists ONLY in this admin route.
Final-review fix preserved (fail-closed): the store is fetched via
getattr(app.state); a wiring miss (None) makes the helper refuse
with 503 — never an ungated 201.
Final-review fix (fail-closed): the store is fetched through the
SAME app.state DI the billing routes use (deps.get_billing_store's
direct attribute read). The old getattr+hasattr soft-guard made a
wiring miss silently SKIP the entitlement check — an unbilled
posting. A payment gate must fail closed: no store → 503 with the
fix-pointer, never an ungated 201.
"""
consume_posting_entitlement(
getattr(request.app.state, "billing_store", None),
body.employer_id,
"/v1/billing/admin/checkout",
)
from ..billing.store import NoActivePackage, PackageExhausted
billing_store = getattr(request.app.state, "billing_store", None)
if billing_store is None:
raise HTTPException(
status_code=503,
detail=(
"billing entitlement store is unavailable — posting "
"creation is refused until the service is restarted "
"(the store wires at boot)"
),
)
try:
billing_store.consume_posting(body.employer_id)
except NoActivePackage:
raise HTTPException(
status_code=402,
detail={
"reason": "no_active_package",
"posts_used": 0,
"posts_limit": 0,
"upgrade_cta": (
"This employer has no active posting package — "
"purchase one via /v1/billing/admin/checkout."
),
},
) from None
except PackageExhausted:
raise HTTPException(
status_code=402,
detail={
"reason": "posting_limit_reached",
"posts_used": 0,
"posts_limit": 0,
"upgrade_cta": (
"This employer's posting package is exhausted — "
"buy more posts or upgrade via /v1/billing/admin/checkout."
),
},
) from None
posting = Posting(
id=f"adm-{uuid.uuid4().hex[:16]}",
source="admin",
@@ -1,330 +0,0 @@
"""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",
)
@@ -21,38 +21,22 @@ or `api/`; this package imports stdlib + pydantic + `config` only.
from .base import (
CLAIM_STATUSES,
OWN_POSTING_STATUSES,
ClaimEmployerRequest,
ClaimStatus,
EmployerAccount,
EmployerClaim,
EmployerMe,
EmployerPostingCreate,
EmployerPostingEdit,
EmployerPostingWriteResponse,
LoginEmployerRequest,
OwnPostingsFacets,
OwnPostingsResponse,
OwnPostingSummary,
RegisterEmployerRequest,
TokenResponse,
)
__all__ = [
"CLAIM_STATUSES",
"ClaimEmployerRequest",
"ClaimStatus",
"EmployerAccount",
"EmployerClaim",
"EmployerMe",
"EmployerPostingCreate",
"EmployerPostingEdit",
"EmployerPostingWriteResponse",
"LoginEmployerRequest",
"OWN_POSTING_STATUSES",
"OwnPostingSummary",
"OwnPostingsFacets",
"OwnPostingsResponse",
"RegisterEmployerRequest",
"TokenResponse",
]
+1 -180
View File
@@ -18,15 +18,8 @@ Id honesty (A-305, the family rule): every id in this module is an
opaque handle minted UNIQUELY per creation call — never derived from
user input or a deterministic hash of it.
P3 (REQ-10-003, G-319): `OwnPostingSummary` REUSES the PostingSummary
wire shape by subclassing marketplace.base.Posting (the PostingSummary
serialization contract — never duplicated) + `applicant_count`. The PII
boundary is binding: applicant COUNTS are the only applications-derived
datum — learner identifiers never ride any employer-facing response.
Boundary (D-031 family): `employers/` never imports `agents/` or `api/`;
stdlib + pydantic + the marketplace WIRE contracts (G-319's PostingSummary
reuse — a pure-pydantic module, never a store/engine import) only.
stdlib + pydantic only.
"""
from __future__ import annotations
@@ -36,8 +29,6 @@ from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator
from ..marketplace.base import Posting
#: Claim lifecycle states (D-089): every claim starts pending; an
#: operator approve/reject decides it. Approved activates ownership
#: (one account : one entity); rejection frees the entity for a new
@@ -147,16 +138,6 @@ class LoginEmployerRequest(RegisterEmployerRequest):
contract and drift is structurally impossible."""
class ClaimEmployerRequest(BaseModel):
"""POST /v1/employers/claims body (Task 2-1-03, D-089): the
marketplace EmployerRow id being claimed. The entity's existence
is checked against the marketplace store at the route (404
unknown); evidence is computed from the session account's email +
the entity's website AT CLAIM TIME (R-65)."""
employer_id: str = Field(min_length=1)
class TokenResponse(BaseModel):
"""Login/register session response (D-087): the bearer token (raw,
exactly once on this response — only sha256(token) is stored) and
@@ -181,163 +162,3 @@ class EmployerMe(BaseModel):
#: None until a claim is filed; the claim state (pending/approved/
#: rejected) once one exists (P2).
claim: EmployerClaim | None = None
#: Own-postings status facets (D-092/G-319): the posting lifecycle set
#: (marketplace.base.PostingStatus) — every facet key is one of these.
OWN_POSTING_STATUSES: tuple[str, ...] = ("draft", "published", "closed")
class OwnPostingSummary(Posting):
"""G-319: the employer-facing posting summary (Task 3-1-01) —
the PostingSummary wire shape REUSED (subclass of marketplace.base.
Posting — one serialization contract, never duplicated) + the
per-posting applicant COUNT (the D-092 overview data source, read
through the batched count_applications_by_posting — never a
per-posting N+1).
PII boundary (binding): applicant_count is the ONLY applications-
derived datum on this contract — learner identifiers never ride
any /v1/employers/* response."""
model_config = ConfigDict(frozen=True)
applicant_count: int = 0
class OwnPostingsFacets(BaseModel):
"""Status facet counts over the session employer's OWN postings
(draft|published|closed — the full lifecycle, unlike the public
board's published-only view). Every status key is ALWAYS present
(a status with zero postings carries its honest 0)."""
model_config = ConfigDict(frozen=True)
draft: int = 0
published: int = 0
closed: int = 0
class OwnPostingsResponse(BaseModel):
"""GET /v1/employers/postings response (Task 3-1-01, G-319): the
claimed employer's OWN postings (all lifecycle statuses, posted_at
DESC then id — the list_postings order) + per-status facet counts
+ the honest total. Unclaimed accounts never see this shape (the
route's 403 claim-CTA precedes it)."""
model_config = ConfigDict(frozen=True)
postings: list[OwnPostingSummary] = Field(default_factory=list)
facets: OwnPostingsFacets
total: int = 0
class EmployerPostingCreate(BaseModel):
"""POST /v1/employers/postings body (Task 4-1-02, REQ-10-004): the
employer-authored draft (the AdminPostingCreate shape minus
employer_id — the entity derives from the session's CLAIM, never
the body; G-317 stamps source="employer" at the route). Starts as
a DRAFT; publish is an explicit call (the admin-route lifecycle
discipline)."""
model_config = ConfigDict(frozen=True)
title: str = Field(min_length=1)
description: str = ""
required_competencies: list[str] = Field(default_factory=list)
skills: list[str] = Field(default_factory=list)
seniority: str | None = None
category: str | None = None
location: str = ""
remote: bool = False
salary_min: int | None = None
salary_max: int | None = None
class EmployerPostingEdit(BaseModel):
"""PATCH /v1/employers/postings/{id} body (Task 4-1-02, G-318): the
AdminPostingEdit shape — partial content edit, fields omitted stay
put; a successful edit RE-TAGS the posting (tags are derived data,
D-052 — the tagger overwrites). Own DRAFTS only."""
model_config = ConfigDict(frozen=True)
title: str | None = Field(default=None, min_length=1)
description: str | None = None
skills: list[str] | None = None
class EmployerPostingWriteResponse(BaseModel):
"""Create/edit/publish/close response (Task 4-1-02): the
AdminPostingResponse shape (G-317's source stamp rides it —
"employer" on every employer-authored row; seeded/admin rows keep
their own)."""
model_config = ConfigDict(frozen=True)
id: str
source: str
external_id: str
employer_id: str
title: str
status: str
# P5 (REQ-10-005, Task 5-1-01): the checkout/entitlement contracts
# REUSE the v0.9 billing shapes where they coincide (never duplicated —
# CheckoutResponse here is only the wire handle; the session spec +
# provider session live in billing/base.py as the ONE provider seam).
class EmployerCheckoutCreate(BaseModel):
"""POST /v1/employers/billing/checkout body (Task 5-1-01): which
catalog item — the buyer derives from the session's CLAIMED entity
(D-076's operator-mediated employer_id body field is GONE; the
claimed entity is the only buyer the route will ever record)."""
item_id: str = Field(min_length=1)
class EmployerCheckoutResponse(BaseModel):
"""The created session handle + the local reconciliation id (the
v0.9 billing_admin.CheckoutResponse shape REUSED verbatim — one
wire contract for both checkout principals, the D-091 doctrine
applied to the response rather than restated)."""
session_id: str
provider_session_id: str
url: str
mode: str
class EmployerEntitlementResponse(BaseModel):
"""GET /v1/employers/billing/entitlement response (Task 5-1-01):
the session employer's OWN entitlement summary — the v0.9
EntitlementResponse shape (REQ-9-004) resolved for the CLAIMED
entity only: active package (posts used/limit/expiry — lazy
past_due grace per G-306) + subscription state. Honest states:
purchase/subscription are None until a real grant exists; the
shape cannot distinguish never-purchased from expired (both read
None — the store skips expired windows lazily), so surfaces never
assert expiry provenance from a None purchase."""
employer_id: str
purchase: dict[str, Any] | None = None
subscription: dict[str, Any] | None = None
class EmployerMockCompleteRequest(BaseModel):
"""POST /v1/employers/billing/mock/complete body (Task 5-1-02,
D-082/G-301): the provider session id being completed. The id is
an UNTRUSTED reference (G-302) — the route resolves it against
the session's OWN checkout rows, never takes it on faith."""
provider_session_id: str = Field(min_length=1)
class EmployerMockCompleteResponse(BaseModel):
"""The completion result (the v0.9 MockCompleteResponse shape
REUSED verbatim)."""
completed: bool
payment_status: str
@@ -1,115 +0,0 @@
"""Advisory claim evidence — stdlib registrable-domain normalization (R-65, D-089).
The signal this module computes is ADVISORY and never displayed as
verification (A-304): the operator decision activates a claim, the
domain match merely informs it. Auto-approval is deferred (D-089) —
without email verification the match is forgeable.
R-65 normalization is stdlib-only (zero-dep discipline, R-66): no PSL
library, no tldextract. `urllib.parse.urlsplit(website).hostname`
lowercases and strips scheme/path ("https://example.com/openai" →
"example.com"); the email domain is taken after the last "@" and
lowercased (never the local part — it rides nowhere in the evidence).
The comparison is a simple SUFFIX match: two domains match when equal
or when one endswith "." + the other. KNOWN LIMIT (documented per the
task, accepted for an advisory signal): a multi-label public suffix
over-matches — "mail.co.uk" vs "co.uk" counts as a match even though
"co.uk" is a public suffix, not a registrable domain, because a
subdomain of a registrable domain is the intended match
("shop.acme.com" vs "acme.com") and suffix equality cannot tell the
two cases apart. That over-match is ACCEPTABLE HERE: the seeded
corpus's websites are example.com placeholders (match=false
corpus-wide at pilot, R-65) and the signal never gates anything — it
is recorded provenance an operator reads. A PSL would be the first
new dependency (R-66) — rejected.
Evidence shape per D-089: {account_email_domain, entity_website_domain,
match} — computed AT CLAIM TIME (Task 2-1-03) and stored as provenance
JSON on the claim row; it is a snapshot, never recomputed on read.
Boundary (D-031 family): stdlib + pydantic only; this module never
imports agents/ or api/ — it is a pure function family over strings.
"""
from __future__ import annotations
from urllib.parse import urlsplit
def normalize_email_domain(email: str) -> str:
"""The email's domain after the last "@" — lowercased (never the
local part). The last-@ split tolerates local parts containing "@"
under a quoting convention; honest callers pass validated emails
(G-315: exactly one "@" by the time an account exists), so an
"@"-less string is an honest-absence case: ""."""
if "@" not in email:
return ""
_, _, domain = email.rpartition("@")
return domain.lower()
def normalize_website_domain(website: str) -> str:
"""The website's host, lowercased — scheme, path, port, and any
userinfo stripped ("HTTPS://Example.COM/openai" → "example.com").
urlsplit parses a SCHEME-qualified URL: a bare "example.com" has no
netloc, so a scheme is prepended for the parse ("example.com" →
"example.com"; "example.com:8443" likewise — the port drops with
the netloc, evidence is registrable not origin-scoped). An
unparseable/empty website yields "" (the honest absence the route
records, never an exception)."""
candidate = website.strip()
if not candidate:
return ""
try:
parts = urlsplit(candidate)
if parts.scheme or parts.netloc:
# Scheme-qualified ("https://…", "//host/…") — parse as-is.
# Non-URL schemes ("mailto:…") land here with no netloc →
# hostname None (honest absence).
hostname = parts.hostname
else:
# Bare host (no scheme): urlsplit would treat it as a
# path — prepending "//" makes it an authority-only URL.
hostname = urlsplit(f"//{candidate}").hostname
except ValueError:
# e.g. an unterminated IPv6 literal — evidence stays advisory,
# never a 500 on the claim route.
return ""
if hostname is None:
return ""
if any(char.isspace() for char in hostname):
return "" # not a domain — honest absence, never junk
return hostname
def domains_match(account_email_domain: str, entity_website_domain: str) -> bool:
"""Simple SUFFIX match (R-65): True when equal or when one endswith
"." + the other. Honest absent input: either side empty → False
(nothing claimed is nothing matched)."""
if not account_email_domain or not entity_website_domain:
return False
if account_email_domain == entity_website_domain:
return True
if account_email_domain.endswith("." + entity_website_domain):
return True
if entity_website_domain.endswith("." + account_email_domain):
return True
return False
def compute_claim_evidence(email: str, website: str) -> dict:
"""The D-089 evidence dict, computed at claim time:
{account_email_domain, entity_website_domain, match}
Pure function over the account email + the entity website — the
claim route snapshots it onto the claim row as provenance JSON.
"""
account_email_domain = normalize_email_domain(email)
entity_website_domain = normalize_website_domain(website)
return {
"account_email_domain": account_email_domain,
"entity_website_domain": entity_website_domain,
"match": domains_match(account_email_domain, entity_website_domain),
}
+15 -309
View File
@@ -24,9 +24,8 @@ Guards baked into the schema (D-087):
- G-306: session expiry is evaluated LAZILY at read time against
`expires_at` (login + SESSION_TTL_DAYS) — an expired row reads as
absent; no background timer, no writes on evaluation.
- `employer_claims` landed in P1 per D-095's one-time create_all;
its claim methods (insert_claim/decide_claim/list_claims/get_claim)
+ the G-316 partial UNIQUE index arrive with P2 (D-089).
- `employer_claims` lands now per D-095's one-time create_all; its
claim methods arrive with P2 (D-089).
Boundary (D-027 family): `employers/` never imports `agents/`/`api/`;
this module imports config + base only.
@@ -41,10 +40,9 @@ from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import Any, Protocol
from sqlalchemy import Index, UniqueConstraint, event, text
from sqlalchemy.exc import IntegrityError
from sqlalchemy import UniqueConstraint, event
from sqlalchemy.types import JSON, String
from sqlmodel import Field, Session, SQLModel, col, create_engine, select
from sqlmodel import Field, Session, SQLModel, create_engine, select
from ..config import Settings
from .base import CLAIM_STATUSES, validate_email
@@ -64,9 +62,7 @@ _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).
#: 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")
_AUDIT_EVENT_KINDS = ("auth", "claim", "manual")
class AccountRow(SQLModel, table=True):
@@ -118,7 +114,9 @@ class SessionRow(SQLModel, table=True):
absent; no timer ever rewrites or deletes it."""
__tablename__ = "employer_sessions"
__table_args__ = (UniqueConstraint("token_hash", name="uq_session_token_hash"),)
__table_args__ = (
UniqueConstraint("token_hash", name="uq_session_token_hash"),
)
id: str = Field(primary_key=True, sa_type=String)
token_hash: str = Field(sa_type=String)
@@ -147,26 +145,9 @@ class ClaimRow(SQLModel, table=True):
`evidence` is the advisory claim-time provenance JSON
{account_email_domain, entity_website_domain, match} (R-65). The
decided_* pair carries operator provenance and is valid only on a
decided row (the past_due_at pairing discipline).
G-316: the partial UNIQUE index uq_pending_claim_per_entity on
employer_id WHERE status='pending' makes one-PENDING-claim-per-
entity a STRUCTURAL guard — check-first alone is TOCTOU. The
approved-owner arm is already structural: employer_accounts.
employer_id UNIQUE (one account : one entity). Rejection frees
the entity — a partial index over status='pending' never touches
rejected rows.
"""
decided row (the past_due_at pairing discipline)."""
__tablename__ = "employer_claims"
__table_args__ = (
Index(
"uq_pending_claim_per_entity",
"employer_id",
unique=True,
sqlite_where=text("status='pending'"),
),
)
id: str = Field(primary_key=True, sa_type=String)
account_id: str = Field(index=True, sa_type=String)
@@ -232,13 +213,6 @@ def _hash_token(token: str) -> str:
return hashlib.sha256(token.encode("utf-8")).hexdigest()
def _constraint_error(message: str, params: dict[str, Any]) -> IntegrityError:
"""Hand-raised IntegrityError (the guard arms of decide_claim) —
the caller's honest 409, never a silent overwrite. The original
exception is the raised guard itself."""
return IntegrityError(message, params, ValueError(message))
def _sqlite_connect(dbapi_connection: sqlite3.Connection, _: object) -> None:
"""Per-connection pragmas — mirrors the other D-027 stores."""
cursor = dbapi_connection.cursor()
@@ -306,80 +280,6 @@ class EmployerStore(Protocol):
"""Audit log append (auth/claim/manual)."""
...
# -- claims (D-089, REQ-10-002) ---------------------------------------
def insert_claim(
self,
*,
account_id: str,
employer_id: str,
evidence: dict[str, Any] | None = None,
) -> tuple[ClaimRow, bool]:
"""Check-first pending-claim insert (the insert_application
precedent, marketplace/store.py:341-347): an existing PENDING
claim on the entity returns (existing_row, False) — the route's
honest 409 fast path. G-316: the partial UNIQUE index
uq_pending_claim_per_entity backstops the check-first TOCTOU —
a raced duplicate insert SURFACES its IntegrityError (never
swallowed, never re-masked as created=False); the caller maps
it to the SAME 409. Rejected/decided rows never block: rejection
frees the entity for a new claim."""
...
def decide_claim(
self,
claim_id: str,
*,
decision: str,
operator: str,
now: datetime | None = None,
) -> ClaimRow | None:
"""Operator decision on a PENDING claim (D-089): approve → set
employer_accounts.employer_id + decided_by/decided_at + a
`claim` audit event; reject → decided_by/decided_at + the audit
event (the entity stays free). The one-account-one-entity arm
is STRUCTURAL: approving when the account already holds an
entity, or the entity is already owned, raises a hand-raised
IntegrityError (the caller's honest 409 — never a silent
overwrite). Unknown claim id → None (route 404s)."""
...
def list_claims(self, status: str | None = None) -> list[ClaimRow]:
"""Claims newest-first; optional status filter (pending/
approved/rejected — the admin queue's consume-free read path)."""
...
def list_accounts(self) -> list[AccountRow]:
"""All accounts, created-oldest-first (the admin accounts list —
consume-free read path; the queue is pilot-sized)."""
...
def list_audit_events(
self,
*,
account_id: str | None = None,
employer_id: str | None = None,
kind: str | None = None,
name: str | None = None,
limit: int = 100,
) -> list[AuditEventRow]:
"""Audit trail, newest-first (the list_billing_events shape,
billing/store.py:1122-1145); optional account/employer/kind/name
filters — the queryable operator trail (D-089)."""
...
def get_claim(self, claim_id: str) -> ClaimRow | None:
"""Point lookup by id (the admin review path)."""
...
def get_claim_by_account(
self, account_id: str, *, exclude_statuses: tuple[str, ...] = ()
) -> ClaimRow | None:
"""The account's most recent claim NOT in exclude_statuses
(newest-first — created_at DESC then id; the /me claim-state
read). An account with no non-excluded claim → None."""
...
def close(self) -> None:
"""Dispose the engine (app shutdown)."""
...
@@ -403,7 +303,9 @@ class SQLiteEmployerStore:
# -- accounts -----------------------------------------------------------
def insert_account(self, *, email: str, password_hash: str) -> tuple[AccountRow, bool]:
def insert_account(
self, *, email: str, password_hash: str
) -> tuple[AccountRow, bool]:
"""Check-first insert (the insert_application pattern): an
existing email returns (existing_row, False) — the register
route's honest 409. The UNIQUE constraint backstops the
@@ -433,7 +335,9 @@ class SQLiteEmployerStore:
def get_account_by_email(self, email: str) -> AccountRow | None:
with Session(self._engine) as session:
row = session.exec(select(AccountRow).where(AccountRow.email == email)).one_or_none()
row = session.exec(
select(AccountRow).where(AccountRow.email == email)
).one_or_none()
if row is None:
return None
row._normalize()
@@ -537,203 +441,5 @@ class SQLiteEmployerStore:
row._normalize()
return row
# -- claims (D-089, REQ-10-002) ---------------------------------------
def insert_claim(
self,
*,
account_id: str,
employer_id: str,
evidence: dict[str, Any] | None = None,
) -> tuple[ClaimRow, bool]:
"""Check-first pending-claim insert (the insert_application
precedent): an existing PENDING claim on the entity rides
created=False — never a second pending row. G-316: the partial
UNIQUE index backstops the race — its IntegrityError SURFACES
(the caller maps it to the same honest 409)."""
row = ClaimRow(
id=f"clm-{uuid.uuid4().hex[:16]}",
account_id=account_id,
employer_id=employer_id,
status="pending",
evidence=dict(evidence or {}),
created_at=datetime.now(UTC),
)
row._validate()
row._normalize()
with Session(self._engine) as session:
existing = session.exec(
select(ClaimRow).where(
ClaimRow.employer_id == employer_id,
ClaimRow.status == "pending",
)
).one_or_none()
if existing is not None:
# Fast path: the pending claim exists — the existing
# row rides, created=False (the route's 409).
existing._normalize()
return existing, False
session.add(row)
# G-316: a raced insert lands here — the partial UNIQUE
# index refuses the second PENDING row and its
# IntegrityError SURFACES, never swallowed here.
session.commit()
session.refresh(row)
row._normalize()
return row, True
def decide_claim(
self,
claim_id: str,
*,
decision: str,
operator: str,
now: datetime | None = None,
) -> ClaimRow | None:
"""Approve → employer_accounts.employer_id set + decided_* + a
`claim` audit event; reject → decided_* + the audit event.
The one-owner arms are STRUCTURAL: employer_accounts.employer_id
UNIQUE (approve hands its IntegrityError to the caller) and a
hand-raised guard when the claim is not pending (the caller's
honest 409 — never a silent overwrite)."""
if decision not in ("approved", "rejected"):
raise ValueError(f"invalid decision {decision!r}")
moment = _as_utc(now) if now is not None else datetime.now(UTC)
with Session(self._engine) as session:
row = session.get(ClaimRow, claim_id)
if row is None:
return None
if row.status != "pending":
# Hand-raised IntegrityError — the caller's honest 409
# (deciding a decided claim, the IntegrityError shape).
raise _constraint_error(
f"guard: claim {claim_id!r} is already {row.status!r}",
{"claim_id": claim_id},
)
row.status = decision
row.decided_by = operator
row.decided_at = moment
row._validate()
row._normalize()
if decision == "approved":
account = session.get(AccountRow, row.account_id)
if account is None: # pragma: no cover - FK discipline
raise _constraint_error(
f"guard: claim {claim_id!r} references unknown "
"account — refusing to approve",
{"claim_id": claim_id},
)
if account.employer_id is not None:
# One account : one entity — the account already
# holds an entity. Hand-raised (the account's
# employer_id UNIQUE never sees this UPDATE).
raise _constraint_error(
"guard: employer_accounts.employer_id already set",
{"account_id": row.account_id},
)
account.employer_id = row.employer_id
session.flush()
# The audit row rides the SAME transaction: the decision
# and its provenance land together (all-or-nothing).
event = AuditEventRow(
account_id=row.account_id,
employer_id=row.employer_id,
kind="claim",
name=f"claim.{decision}",
detail={
"claim_id": row.id,
"employer_id": row.employer_id,
"operator": operator,
},
operator=operator,
created_at=moment,
)
event._validate()
event._normalize()
session.add(event)
session.commit()
session.refresh(row)
row._normalize()
return row
def list_claims(self, status: str | None = None) -> list[ClaimRow]:
"""Newest-first; optional status filter (the admin queue)."""
with Session(self._engine) as session:
stmt = select(ClaimRow)
if status is not None:
stmt = stmt.where(ClaimRow.status == status)
rows = session.exec(stmt.order_by(col(ClaimRow.created_at).desc(), ClaimRow.id)).all()
for row in rows:
row._normalize()
return list(rows)
def list_accounts(self) -> list[AccountRow]:
"""Created-oldest-first (the admin accounts list)."""
with Session(self._engine) as session:
rows = session.exec(
select(AccountRow).order_by(col(AccountRow.created_at), AccountRow.id)
).all()
for row in rows:
row._normalize()
return list(rows)
def list_audit_events(
self,
*,
account_id: str | None = None,
employer_id: str | None = None,
kind: str | None = None,
name: str | None = None,
limit: int = 100,
) -> list[AuditEventRow]:
"""Newest-first (the list_billing_events shape); optional
account/employer/kind/name filters — D-089's operator trail."""
with Session(self._engine) as session:
stmt = select(AuditEventRow)
if account_id is not None:
stmt = stmt.where(AuditEventRow.account_id == account_id)
if employer_id is not None:
stmt = stmt.where(AuditEventRow.employer_id == employer_id)
if kind is not None:
stmt = stmt.where(AuditEventRow.kind == kind)
if name is not None:
stmt = stmt.where(AuditEventRow.name == name)
rows = session.exec(
stmt.order_by(
col(AuditEventRow.created_at).desc(),
col(AuditEventRow.id),
).limit(limit)
).all()
for row in rows:
row._normalize()
return list(rows)
def get_claim(self, claim_id: str) -> ClaimRow | None:
with Session(self._engine) as session:
row = session.get(ClaimRow, claim_id)
if row is None:
return None
row._normalize()
return row
def get_claim_by_account(
self, account_id: str, *, exclude_statuses: tuple[str, ...] = ()
) -> ClaimRow | None:
"""The account's most recent claim not in exclude_statuses
(newest-first; the /me claim-state read)."""
with Session(self._engine) as session:
stmt = select(ClaimRow).where(ClaimRow.account_id == account_id)
if exclude_statuses:
stmt = stmt.where(
col(ClaimRow.status).not_in(exclude_statuses)
)
row = session.exec(
stmt.order_by(col(ClaimRow.created_at).desc(), ClaimRow.id)
).first()
if row is None:
return None
row._normalize()
return row
def close(self) -> None:
self._engine.dispose()
+1 -29
View File
@@ -199,18 +199,6 @@ 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
@@ -346,7 +334,6 @@ 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)
@@ -411,25 +398,10 @@ def create_app(settings: Settings | None = None) -> FastAPI:
app.include_router(billing_admin_router)
# v0.10 employers (REQ-10-001): register/login/logout/me session
# surface (D-090 Bearer transport; throttles D-088). P2 (REQ-10-002):
# the claims route rides the sibling router (session-gated, D-089).
from .api.employers import claims_router as employers_claims_router
# surface (D-090 Bearer transport; throttles D-088).
from .api.employers import router as employers_router
from .api.employers_admin import router as employers_admin_router
app.include_router(employers_router)
app.include_router(employers_claims_router)
# P2 (REQ-10-002, D-089/D-094): the claim review/approve/reject +
# accounts/audit operator routes — require_admin (fail-closed).
app.include_router(employers_admin_router)
# v0.11 placements (REQ-11-002, D-106): the operator lane — the
# generic transition + revoke + the R-80 re-hire; the reporting/
# export reads extend this router in P3/P4. require_admin
# composed at the router level (fail-closed, D-090 no-collision).
from .api.placements_admin import router as placements_admin_router
app.include_router(placements_admin_router)
# v0.6 P5 (REQ-6-006, D-056): dynamic sitemap/robots — request-time
# enumeration (build-time Next sitemap.ts cannot see runtime
@@ -346,59 +346,12 @@ class MarketplaceStore(Protocol):
— never a second application."""
...
def get_application(self, application_id: str) -> Application | None:
"""Point lookup by application id (D-100: the placements
store's transition path reads the row through the shared
metadata; this read serves routes + tests — the get_posting
shape)."""
...
def get_application_by_posting_learner(
self, posting_id: str, learner_id: str
) -> Application | None:
"""v0.11 P5 (G-322, binding): the mine point-read over the
(posting_id, learner_id) unique key — the status panel's
application DISCOVERY on revisit; None = no application (the
surface renders the apply CTA — never client-persisted
application_ids)."""
...
def list_applications_by_postings(
self, posting_ids: list[str], status: str | None = None
) -> list[Application]:
"""v0.11 P2 (D-105/G-319): the employer pipeline's ONE batched
read — every application on the given postings (the caller
scopes to the session's OWN claimed-entity postings first;
this read never enumerates beyond them), created_at DESC then
id; optional status facet."""
...
def list_applications(
self, posting_id: str | None = None
) -> list[Application]:
"""Admin review list, newest first; filterable by posting."""
...
def count_applications_by_posting(
self, posting_ids: list[str]
) -> dict[str, int]:
"""v0.10 P3 (G-319/D-092): batched per-posting application
COUNTS — ONE grouped read for the whole id set (never a
per-posting N+1). Postings with zero applications are absent
from the dict (the caller's .get(id, 0) carries the honest 0).
Employer-facing surfaces compose this for applicant COUNTS
ONLY — learner identifiers never ride any /v1/employers/* read."""
...
def list_postings_by_employer(
self, employer_id: str
) -> list[Posting]:
"""v0.10 P3 (Task 3-1-01): the employer's OWN postings — ALL
lifecycle statuses (draft|published|closed — the owner sees the
full lifecycle, unlike the public board's published-only page),
posted_at DESC then id (the list_postings order)."""
...
def set_posting_tags(self, posting_id: str, tags: PostingTags) -> Posting | None:
"""P2 (D-052): persist derived tags — OVERWRITES the tags column
(single source of truth; re-tag replaces, never appends)."""
@@ -927,54 +880,6 @@ 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]:
@@ -987,44 +892,6 @@ class SQLiteMarketplaceStore:
).all()
return [r.to_application() for r in rows]
def count_applications_by_posting(
self, posting_ids: list[str]
) -> dict[str, int]:
"""G-319: ONE grouped read — SELECT posting_id, COUNT(*) ...
GROUP BY posting_id — for the whole id set. Zero-application
postings absent (honest 0 via the caller's .get(id, 0))."""
if not posting_ids:
return {}
with Session(self._engine) as session:
rows = session.exec(
select(
ApplicationRow.posting_id,
func.count(),
)
.where(col(ApplicationRow.posting_id).in_(posting_ids))
.group_by(ApplicationRow.posting_id)
).all()
return {posting_id: count for posting_id, count in rows}
def list_postings_by_employer(
self, employer_id: str
) -> list[Posting]:
"""The owner's full-lifecycle read (Task 3-1-01): ALL statuses,
posted_at DESC then id (the list_postings order — drafts with
no posted_at sort last within the None-ordered tie band)."""
with Session(self._engine) as session:
rows = (
session.exec(
select(PostingRow)
.where(PostingRow.employer_id == employer_id)
.order_by(
col(PostingRow.posted_at).desc(), PostingRow.id
)
)
.all()
)
return [r.to_posting() for r in rows]
# -- P4-A: enrichment (append-only, D-053) --------------------------------
def append_enrichment(
@@ -1,380 +0,0 @@
"""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
@@ -1,912 +0,0 @@
"""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()
@@ -1,231 +0,0 @@
"""D-091 payload-parity tests (REQ-10-004, MH-4a, R-58).
The shared gate's 402 wire contract is BYTE-STABLE: {reason,
posts_used: 0, posts_limit: 0, upgrade_cta} — the zeros are the v0.9
wire shape the web client's UpgradeRequiredError ignores (it consumes
reason + upgrade_cta only, apps/web/lib/engine-client.ts), never live
counts. These tests pin the exact dict through the admin route (the
v0.9 caller) and prove the principal parameterization: only the
upgrade_cta field carries the caller's checkout pointer — reason and
the hardcoded zeros are identical across principals. The employer
route (Task 4-1-02) will compose the SAME helper, extending this file
with the employer-gate arm.
Fixtures mirror tests/billing/test_entitlement_enforcement.py (the
R-64 state-injection make_client + grant_posts precedent) — the
enforcement suite itself stays UNCHANGED (the no-drift proof).
"""
from __future__ import annotations
from collections.abc import Iterator
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from pydantic import SecretStr
from ai_service.api.entitlement import consume_posting_entitlement
from ai_service.billing.store import SQLiteBillingStore
from ai_service.config import Settings
from ai_service.main import create_app
from ..conftest import SUITE_LEARNERS
ADMIN_TOKEN = "test-parity-admin"
@pytest.fixture()
def billing_store(tmp_path: Path) -> Iterator[SQLiteBillingStore]:
store = SQLiteBillingStore(db_path=tmp_path / "billing.db")
yield store
store.close()
def make_client(
tmp_path: Path, billing_store: SQLiteBillingStore
) -> TestClient:
settings = Settings(
provider="mock",
learner_allowlist=SUITE_LEARNERS,
db_path=tmp_path / "app.db",
sandbox_dir=tmp_path / "sandboxes",
admin_token=SecretStr(ADMIN_TOKEN),
)
app = create_app(settings)
app.state.billing_store = billing_store # state-injection (G-9)
return TestClient(app)
@pytest.fixture()
def client(
tmp_path: Path, billing_store: SQLiteBillingStore
) -> Iterator[TestClient]:
with make_client(tmp_path, billing_store) as c:
yield c
def create_posting(client: TestClient, employer: str):
return client.post(
"/v1/marketplace/admin/postings",
headers={"X-Admin-Token": ADMIN_TOKEN},
json={"employer_id": employer, "title": "Parity Test Role"},
)
def grant_posts(
billing_store: SQLiteBillingStore, employer: str, limit: int | None
) -> None:
"""A direct purchase grant (the store path the webhook would take;
the test_entitlement_enforcement.py:70-85 precedent)."""
session_row = billing_store.insert_checkout_session(
provider_session_id=f"cs_parity_{employer}",
employer_id=employer,
item_id="pkg-single" if limit == 1 else "pkg-bundle",
item_name="Test Grant",
price_cents=0,
mode="payment",
)
billing_store.create_purchase_from_session(
session_row, posts_limit=limit, duration_days=30
)
class TestPayloadParity:
def test_no_package_402_exact_dict(
self, client: TestClient, billing_store: SQLiteBillingStore
) -> None:
"""MH-4a parity: no package → the EXACT canonical 402 dict —
hardcoded posts_used/posts_limit zeros (R-58), admin CTA."""
response = create_posting(client, "emp-parity-none")
assert response.status_code == 402
assert response.json()["detail"] == {
"reason": "no_active_package",
"posts_used": 0,
"posts_limit": 0,
"upgrade_cta": (
"This employer has no active posting package — "
"purchase one via /v1/billing/admin/checkout."
),
}
def test_exhausted_402_exact_dict(
self, client: TestClient, billing_store: SQLiteBillingStore
) -> None:
"""MH-4a parity (exhausted arm): the EXACT canonical dict — the
zeros stay byte-stable even though the employer has a REAL
package with real counts (posts_used=1, posts_limit=1 in the
store; the payload never carries live limits)."""
grant_posts(billing_store, "emp-parity-spent", 1)
first = create_posting(client, "emp-parity-spent")
assert first.status_code == 201
second = create_posting(client, "emp-parity-spent")
assert second.status_code == 402
detail = second.json()["detail"]
assert detail == {
"reason": "posting_limit_reached",
"posts_used": 0,
"posts_limit": 0,
"upgrade_cta": (
"This employer's posting package is exhausted — "
"buy more posts or upgrade via /v1/billing/admin/checkout."
),
}
# The store's truth differs from the payload's zeros — the
# wire contract is NOT a limits view (R-58 pin). The exhausted
# row is invisible to get_entitlement (no active purchase
# left), so the raw row read carries the truth (the
# test_expired_degrades_to_402 engine-session precedent).
from sqlalchemy import text
from sqlmodel import Session
with Session(billing_store._engine) as session:
row = session.execute(
text(
"SELECT posts_used, posts_limit FROM billing_purchases "
"WHERE employer_id = 'emp-parity-spent'"
)
).one()
assert tuple(row) == (1, 1)
def test_402_consumes_nothing_creates_nothing(
self, client: TestClient, billing_store: SQLiteBillingStore
) -> None:
"""R-58 gate-before-insert: a 402 consumes nothing (posts_used
unchanged) and creates nothing (no marketplace row) — the gate
runs BEFORE the insert."""
grant_posts(billing_store, "emp-parity-gate", 1)
assert create_posting(client, "emp-parity-gate").status_code == 201
marketplace_store = client.app.state.marketplace_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
before = marketplace_store.list_postings_by_employer(
"emp-parity-gate"
)
assert len(before) == 1
denied = create_posting(client, "emp-parity-gate")
assert denied.status_code == 402
# Nothing consumed: posts_used still 1 (the failed create spent
# nothing — the exception aborted before any second UPDATE; the
# row is now exhausted → invisible to get_entitlement, so the
# raw row read carries the truth).
from sqlalchemy import text
from sqlmodel import Session
with Session(billing_store._engine) as session:
posts_used = session.execute(
text(
"SELECT posts_used FROM billing_purchases "
"WHERE employer_id = 'emp-parity-gate'"
)
).scalar_one()
assert posts_used == 1
# Nothing created: the failed create inserted no marketplace row.
after = marketplace_store.list_postings_by_employer(
"emp-parity-gate"
)
assert len(after) == 1
assert [p.id for p in after] == [p.id for p in before]
def test_only_cta_differs_per_principal(
self, billing_store: SQLiteBillingStore
) -> None:
"""The D-091 parity core: the SAME store state through the
shared helper with two principals' CTAs → reason/posts_used/
posts_limit identical, ONLY upgrade_cta differs (the employer
route, Task 4-1-02, composes this same arm in-route)."""
from fastapi import HTTPException
payloads: list[dict[str, object]] = []
for cta in (
"/v1/billing/admin/checkout",
"/v1/employers/billing/checkout",
):
with pytest.raises(HTTPException) as exc_info:
consume_posting_entitlement(
billing_store, "emp-parity-principal", cta
)
assert exc_info.value.status_code == 402
payloads.append(dict(exc_info.value.detail)) # type: ignore[arg-type]
admin_payload, employer_payload = payloads
assert admin_payload["reason"] == employer_payload["reason"]
assert admin_payload["posts_used"] == 0
assert admin_payload["posts_limit"] == 0
assert employer_payload["posts_used"] == 0
assert employer_payload["posts_limit"] == 0
assert admin_payload["upgrade_cta"] != employer_payload["upgrade_cta"]
assert "/v1/billing/admin/checkout" in str(admin_payload["upgrade_cta"])
assert "/v1/employers/billing/checkout" in str(
employer_payload["upgrade_cta"]
)
def test_store_missing_fails_closed(
self, tmp_path: Path, billing_store: SQLiteBillingStore
) -> None:
"""The fail-closed 503 guard rides along with the extraction:
a wiring miss (None store) refuses creation — never an ungated
201 (the final-review fix, preserved in the helper)."""
from fastapi import HTTPException
with pytest.raises(HTTPException) as exc_info:
consume_posting_entitlement(None, "emp-parity-unwired", "x")
assert exc_info.value.status_code == 503
assert "unavailable" in str(exc_info.value.detail)
+4 -123
View File
@@ -32,8 +32,8 @@ class TestAuthFlow:
body = me.json()
assert body["account"]["email"] == TEST_EMAIL
assert body["account"]["email_verified"] is False # D-097
assert body["claimed_employer_id"] is None # no claim filed
assert body["claim"] is None # no claim filed
assert body["claimed_employer_id"] is None # P2 fills this
assert body["claim"] is None
out = client.post("/v1/employers/auth/logout", headers=bearer(log["token"]))
assert out.status_code == 200
assert out.json() == {"revoked": True}
@@ -120,9 +120,7 @@ class TestSessionGate:
T+8d → the session reads as absent → 401 (no sleeps)."""
from .conftest import make_client
# Wall-clock-relative mint: the 200 arm below must hold on ANY
# run date (the fixed 2026-09-30 mint turned red at 2026-10-07+).
login_at = datetime.now(UTC) - timedelta(days=1)
login_at = datetime(2026, 9, 30, 12, 0, 0, tzinfo=UTC)
employer_store.insert_account(
email=TEST_EMAIL, password_hash="scrypt$16384$8$1$c2FsdA$aGFzaA"
)
@@ -142,7 +140,7 @@ class TestSessionGate:
)
assert row is None # expired — reads as absent
resp = c.get("/v1/employers/auth/me", headers=bearer("expired-token"))
# Wall clock (1d after mint) is INSIDE the 7-day TTL → 200.
# Wall clock (2026-09-30) 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.
@@ -174,123 +172,6 @@ class TestSessionGate:
assert client.post("/v1/employers/auth/logout").status_code == 401
class TestMeClaimState:
"""P6-review fix pin: /me carries the account's CLAIM state — the
P3 surfaces' claim-pending branch (employer-overview-client.tsx's
claimPending) consumed me.claim, but the route hardcoded None: a
pending claimer saw the dishonest no-entity claim-CTA. The claim
state rides /me end-to-end here."""
def test_me_pending_claim_visible(self, client: TestClient) -> None:
"""A filed-but-undecided claim rides /me as pending — the
dashboard renders the honest awaiting-review state from it."""
from ai_service.marketplace.base import Employer
store = client.app.state.marketplace_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
store.upsert_employer(
Employer(
id="emp-me-pin",
source="seed-corpus",
external_id="emp-me-pin",
name="Me Labs",
website="https://example.com/me",
)
)
token = register(client)["token"]
resp = client.post(
"/v1/employers/claims",
json={"employer_id": "emp-me-pin"},
headers=bearer(token),
)
assert resp.status_code == 201
me = client.get("/v1/employers/auth/me", headers=bearer(token))
assert me.status_code == 200
body = me.json()
assert body["claimed_employer_id"] is None # still pending
claim = body["claim"]
assert claim is not None # the P6 fix — the state is visible
assert claim["status"] == "pending"
assert claim["employer_id"] == "emp-me-pin"
assert claim["evidence"]["match"] is False # advisory, stored
def test_me_approved_claim_rides_with_entity(
self, client: TestClient
) -> None:
"""Approved: claimed_employer_id AND the approved claim both
ride /me (one honest read for the claimed surface)."""
from ai_service.marketplace.base import Employer
store = client.app.state.marketplace_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
store.upsert_employer(
Employer(
id="emp-me-approved",
source="seed-corpus",
external_id="emp-me-approved",
name="Approved Labs",
website="https://example.com/approved",
)
)
token = register(client)["token"]
resp = client.post(
"/v1/employers/claims",
json={"employer_id": "emp-me-approved"},
headers=bearer(token),
)
assert resp.status_code == 201
employer_store = client.app.state.employer_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
decided = employer_store.decide_claim(
resp.json()["id"], decision="approved", operator="op-1"
)
assert decided is not None
me = client.get("/v1/employers/auth/me", headers=bearer(token))
body = me.json()
assert body["claimed_employer_id"] == "emp-me-approved"
assert body["claim"]["status"] == "approved"
def test_me_rejected_claim_stays_visible_until_superseded(
self, client: TestClient
) -> None:
"""A rejected claim is the honest claim state (never hidden —
the account owns no entity but its latest claim was rejected);
a NEW claim on another entity supersedes it in /me."""
from ai_service.marketplace.base import Employer
store = client.app.state.marketplace_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
for eid in ("emp-me-rej-a", "emp-me-rej-b"):
store.upsert_employer(
Employer(
id=eid,
source="seed-corpus",
external_id=eid,
name=f"Rej {eid}",
website=f"https://example.com/{eid}",
)
)
token = register(client)["token"]
first = client.post(
"/v1/employers/claims",
json={"employer_id": "emp-me-rej-a"},
headers=bearer(token),
)
assert first.status_code == 201
employer_store = client.app.state.employer_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
employer_store.decide_claim(
first.json()["id"], decision="rejected", operator="op-1"
)
me = client.get("/v1/employers/auth/me", headers=bearer(token))
assert me.json()["claim"]["status"] == "rejected"
# A new claim supersedes: the newest claim rides /me.
second = client.post(
"/v1/employers/claims",
json={"employer_id": "emp-me-rej-b"},
headers=bearer(token),
)
assert second.status_code == 201
me = client.get("/v1/employers/auth/me", headers=bearer(token))
assert me.json()["claim"]["status"] == "pending"
assert me.json()["claim"]["employer_id"] == "emp-me-rej-b"
class TestConcurrentSessions:
def test_two_logins_both_me_green_one_logout_leaves_other(
self, client: TestClient
@@ -1,339 +0,0 @@
"""Self-serve checkout + entitlement view (Task 5-1-01, REQ-10-005, R-59).
Pins the employer-principal checkout transpose: session gate → claim
gate (403 + claim-CTA) → catalog 404 → the R-59 verbatim order (unique
pending placeholder → spec → provider call → bind) with the ONLY
changes the plan sanctions — the buyer derives from the session's
CLAIMED entity and metadata carries the account provenance. The
entitlement view must be the v0.9 shape, own-entity only, with honest
none/active/expired states.
Fixtures: the conftest make_client/register/bearer + the
test_postings_write.py claim_and_approve/seed precedents.
"""
from __future__ import annotations
from collections.abc import Iterator
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from ai_service.billing.store import SQLiteBillingStore
from ai_service.marketplace.base import Employer
from .conftest import bearer, make_client, register
OWN_ENTITY = "emp-checkout-self"
OTHER_ENTITY = "emp-checkout-rival"
def seed_employer_rows(client: TestClient) -> None:
"""Both entities exist in the marketplace corpus (claim targets the
v0.6 ingested EmployerRow data)."""
store = client.app.state.marketplace_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
store.upsert_employer(
Employer(
id=OWN_ENTITY,
source="seed-corpus",
external_id=OWN_ENTITY,
name="Checkout Labs",
website="https://example.com/checkout",
)
)
store.upsert_employer(
Employer(
id=OTHER_ENTITY,
source="seed-corpus",
external_id=OTHER_ENTITY,
name="Rival Labs",
website="https://example.com/rival",
)
)
def claim_and_approve(client: TestClient, token: str, employer_id: str) -> None:
"""File the claim + drive the operator approve (the store path the
2-2 admin route composes) — binds account.employer_id."""
resp = client.post(
"/v1/employers/claims",
json={"employer_id": employer_id},
headers=bearer(token),
)
assert resp.status_code == 201, resp.text
store = client.app.state.employer_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
decided = store.decide_claim(
resp.json()["id"], decision="approved", operator="op-1"
)
assert decided is not None and decided.status == "approved"
def checkout(client: TestClient, token: str, item_id: str):
return client.post(
"/v1/employers/billing/checkout",
headers=bearer(token),
json={"item_id": item_id},
)
@pytest.fixture()
def billing_store(tmp_path: Path) -> Iterator[SQLiteBillingStore]:
store = SQLiteBillingStore(db_path=tmp_path / "billing.db")
yield store
store.close()
@pytest.fixture()
def claimed(
tmp_path: Path, billing_store: SQLiteBillingStore
) -> Iterator[tuple[TestClient, str]]:
"""One registered + claimed-and-approved employer session with the
billing store state-injected (G-9). The store replaces app.state
AFTER the lifespan seeded the default store, so the catalog seed
runs explicitly against the injected store (D-078 idempotent —
exactly what the lifespan boot does)."""
from ai_service.billing.catalog import seed_billing_catalog
seed_billing_catalog(billing_store)
with make_client(tmp_path) as client:
client.app.state.billing_store = billing_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
seed_employer_rows(client)
token = register(client)["token"]
claim_and_approve(client, token, OWN_ENTITY)
yield client, token
class TestCheckoutRoute:
def test_no_token_401(self, tmp_path: Path) -> None:
"""G-311 order: unauthenticated → 401, never a catalog read."""
with make_client(tmp_path) as client:
resp = client.post(
"/v1/employers/billing/checkout", json={"item_id": "pkg-single"}
)
assert resp.status_code == 401
def test_unclaimed_403_claim_cta(self, tmp_path: Path) -> None:
"""Unclaimed session → 403 + the claim-CTA (D-090 class — the
buyer cannot derive from an account with no entity)."""
with make_client(tmp_path) as client:
token = register(client)["token"]
resp = checkout(client, token, "pkg-single")
assert resp.status_code == 403
detail = resp.json()["detail"]
assert detail["reason"] == "no_claimed_employer"
assert "claim_cta" in detail
def test_unknown_item_404(
self, claimed: tuple[TestClient, str]
) -> None:
"""The catalog lookup precedes every side effect (404 unknown
item — nothing persisted)."""
client, token = claimed
resp = checkout(client, token, "pkg-nonexistent")
assert resp.status_code == 404
assert resp.json()["detail"] == "unknown catalog item"
# Nothing persisted: no checkout row for the claimed entity.
assert client.app.state.billing_store.get_checkout_session_by_provider_id( # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
"pkg-nonexistent"
) is None
def test_checkout_201_shape_and_row(
self,
claimed: tuple[TestClient, str],
billing_store: SQLiteBillingStore,
) -> None:
"""The R-59 transpose: 201 with the v0.9 CheckoutResponse shape
+ the persisted reconciliation row (snapshot fields + open/
unpaid status + the buyer = the CLAIMED entity)."""
client, token = claimed
resp = checkout(client, token, "pkg-single")
assert resp.status_code == 201, resp.text
body = resp.json()
assert set(body.keys()) == {
"session_id",
"provider_session_id",
"url",
"mode",
}
assert body["mode"] == "payment"
assert body["url"]
row = billing_store.get_checkout_session(body["session_id"])
assert row is not None
assert row.employer_id == OWN_ENTITY # buyer = claimed entity
assert row.item_id == "pkg-single"
assert row.item_name # D-078 snapshot
assert row.price_cents == 4900 # the seeded snapshot price
assert row.provider_session_id == body["provider_session_id"]
assert row.status == "open"
assert row.payment_status == "unpaid"
def test_subscription_mode_for_plans(
self, claimed: tuple[TestClient, str]
) -> None:
"""mode = subscription iff kind==plan (the R-59 derivation)."""
client, token = claimed
resp = checkout(client, token, "plan-pro")
assert resp.status_code == 201
assert resp.json()["mode"] == "subscription"
def test_metadata_provenance(
self,
claimed: tuple[TestClient, str],
billing_store: SQLiteBillingStore,
) -> None:
"""R-59's ONLY sanctioned change: metadata employer_id = the
CLAIMED entity + operator_ref = the ACCOUNT provenance (D-076's
"operator" becomes account provenance). Pinned through the
spec the provider received (the mock provider has no spec
log — assert the account id shape lands in the spec via the
route's account row, and the row's employer binding)."""
client, token = claimed
resp = checkout(client, token, "pkg-bundle")
assert resp.status_code == 201
# The account's employer binding is what the route recorded.
store = client.app.state.employer_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
me = client.get("/v1/employers/auth/me", headers=bearer(token))
account_id = me.json()["account"]["id"]
assert account_id.startswith("acct-")
row = billing_store.get_checkout_session(resp.json()["session_id"])
assert row is not None
assert row.employer_id == OWN_ENTITY
# The metadata provenance is pinned end-to-end in the E2E
# (mock-complete resolves the session by the provider id this
# row bound); here the account must exist and own the entity.
account = store.get_account(account_id)
assert account is not None
assert account.employer_id == OWN_ENTITY
def test_two_checkouts_never_collide(
self,
claimed: tuple[TestClient, str],
billing_store: SQLiteBillingStore,
) -> None:
"""The unique-pending placeholder: two rapid checkouts both
persist (the final-review fix — a bare 'pending' collided on
the provider_session_id UNIQUE constraint)."""
client, token = claimed
first = checkout(client, token, "pkg-single")
second = checkout(client, token, "pkg-single")
assert first.status_code == 201
assert second.status_code == 201
assert (
first.json()["session_id"] != second.json()["session_id"]
)
class TestEntitlementView:
def test_unclaimed_403_claim_cta(self, tmp_path: Path) -> None:
with make_client(tmp_path) as client:
token = register(client)["token"]
resp = client.get(
"/v1/employers/billing/entitlement", headers=bearer(token)
)
assert resp.status_code == 403
assert resp.json()["detail"]["reason"] == "no_claimed_employer"
def test_no_token_401(self, tmp_path: Path) -> None:
with make_client(tmp_path) as client:
resp = client.get("/v1/employers/billing/entitlement")
assert resp.status_code == 401
def test_honest_none_state(
self, claimed: tuple[TestClient, str]
) -> None:
"""No grant → purchase/subscription None (the honest empty
state, never fabricated)."""
client, token = claimed
resp = client.get(
"/v1/employers/billing/entitlement", headers=bearer(token)
)
assert resp.status_code == 200
body = resp.json()
assert body["employer_id"] == OWN_ENTITY
assert body["purchase"] is None
assert body["subscription"] is None
def test_active_state(
self,
claimed: tuple[TestClient, str],
billing_store: SQLiteBillingStore,
) -> None:
"""A real grant renders: posts used/limit/expiry from the
store's truth (the v0.9 REQ-9-004 shape)."""
client, token = claimed
from ..billing.test_entitlement_parity import grant_posts
grant_posts(billing_store, OWN_ENTITY, 10)
resp = client.get(
"/v1/employers/billing/entitlement", headers=bearer(token)
)
assert resp.status_code == 200
purchase = resp.json()["purchase"]
assert purchase is not None
assert purchase["posts_limit"] == 10
assert purchase["posts_used"] == 0
assert purchase["expires_at"] is not None
def test_expired_state_is_honest(
self,
tmp_path: Path,
billing_store: SQLiteBillingStore,
) -> None:
"""An expired purchase never renders as active: get_entitlement
skips it (the lazy G-306 read) — the honest None, not a stale
package."""
with make_client(tmp_path) as client:
client.app.state.billing_store = billing_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
seed_employer_rows(client)
token = register(client)["token"]
claim_and_approve(client, token, OWN_ENTITY)
grant_row = billing_store.insert_checkout_session(
provider_session_id="cs_expired_pin",
employer_id=OWN_ENTITY,
item_id="pkg-single",
item_name="Single Post",
price_cents=4900,
mode="payment",
)
purchase = billing_store.create_purchase_from_session(
grant_row, posts_limit=5, duration_days=30
)
# Force the expiry into the past.
from sqlalchemy import text
from sqlmodel import Session
with Session(billing_store._engine) as session:
session.execute(
text(
"UPDATE billing_purchases SET expires_at = :past "
"WHERE id = :pid"
),
{"past": "2000-01-01 00:00:00", "pid": purchase.id},
)
session.commit()
assert purchase.id # the grant existed
resp = client.get(
"/v1/employers/billing/entitlement", headers=bearer(token)
)
assert resp.status_code == 200
assert resp.json()["purchase"] is None
def test_rival_entity_never_visible(
self,
claimed: tuple[TestClient, str],
billing_store: SQLiteBillingStore,
) -> None:
"""Own-entity only: the rival's grant never renders in the
session employer's view."""
client, token = claimed
from ..billing.test_entitlement_parity import grant_posts
grant_posts(billing_store, OTHER_ENTITY, 10)
resp = client.get(
"/v1/employers/billing/entitlement", headers=bearer(token)
)
assert resp.status_code == 200
body = resp.json()
assert body["employer_id"] == OWN_ENTITY
assert body["purchase"] is None
@@ -1,327 +0,0 @@
"""Own-session mock-complete E2E (Task 5-1-02, REQ-10-005, MH-5a/5b).
The B2B loop closes machine-checked: register → login → claim →
admin approve → checkout → OWN mock-complete → the REAL webhook path →
purchase exists with posts available. The G-301 hole class is closed:
cross-session completion → 403, the route 404s under a stripe
provider, and a duplicate complete falls through already-complete —
G-303's per-session exactly-once refuses the double grant.
The admin-approve step drives the REAL operator route (the 2-2 admin
surface, X-Admin-Token) — the E2E proof runs the honest loop, not a
store shortcut.
"""
from __future__ import annotations
from collections.abc import Iterator
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from pydantic import SecretStr
from ai_service.billing.store import SQLiteBillingStore
from ai_service.marketplace.base import Employer
from .conftest import TEST_EMAIL, TEST_PASSWORD, bearer, make_client, register
OWN_ENTITY = "emp-e2e-self"
OTHER_ENTITY = "emp-e2e-rival"
ADMIN_TOKEN = "test-checkout-e2e-admin"
SECRET = "whsec_test_employer_e2e"
def seed_employer_rows(client: TestClient) -> None:
store = client.app.state.marketplace_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
store.upsert_employer(
Employer(
id=OWN_ENTITY,
source="seed-corpus",
external_id=OWN_ENTITY,
name="E2E Labs",
website="https://example.com/e2e",
)
)
store.upsert_employer(
Employer(
id=OTHER_ENTITY,
source="seed-corpus",
external_id=OTHER_ENTITY,
name="Rival Labs",
website="https://example.com/rival",
)
)
def admin_headers() -> dict[str, str]:
return {"X-Admin-Token": ADMIN_TOKEN}
def claim_approve_admin_route(
client: TestClient, token: str, employer_id: str
) -> None:
"""File the claim then drive the REAL operator approve route (the
2-2 admin surface — the honest loop)."""
resp = client.post(
"/v1/employers/claims",
json={"employer_id": employer_id},
headers=bearer(token),
)
assert resp.status_code == 201, resp.text
approved = client.post(
f"/v1/employers/admin/claims/{resp.json()['id']}/approve",
headers=admin_headers(),
)
assert approved.status_code == 200, approved.text
assert approved.json()["claim"]["status"] == "approved"
def checkout(client: TestClient, token: str, item_id: str) -> dict:
resp = client.post(
"/v1/employers/billing/checkout",
headers=bearer(token),
json={"item_id": item_id},
)
assert resp.status_code == 201, resp.text
return resp.json()
def mock_complete(client: TestClient, token: str, provider_session_id: str):
return client.post(
"/v1/employers/billing/mock/complete",
headers=bearer(token),
json={"provider_session_id": provider_session_id},
)
@pytest.fixture()
def billing_store(tmp_path: Path) -> Iterator[SQLiteBillingStore]:
store = SQLiteBillingStore(db_path=tmp_path / "billing.db")
yield store
store.close()
@pytest.fixture()
def claimed(
tmp_path: Path, billing_store: SQLiteBillingStore
) -> Iterator[tuple[TestClient, str]]:
"""The full honest principal: registered → claim filed → operator
approved via the REAL admin route. The webhook secret is pinned so
minted events verify against the provider the app selected."""
from ai_service.billing.catalog import seed_billing_catalog
seed_billing_catalog(billing_store)
with make_client(
tmp_path, admin_token=SecretStr(ADMIN_TOKEN), webhook_secret=SECRET
) as client:
client.app.state.billing_store = billing_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
seed_employer_rows(client)
token = register(client)["token"]
claim_approve_admin_route(client, token, OWN_ENTITY)
yield client, token
def _purchase_count(store: SQLiteBillingStore, employer_id: str) -> int:
from sqlalchemy import text
from sqlmodel import Session
with Session(store._engine) as session:
return int(
session.execute(
text(
"SELECT COUNT(*) FROM billing_purchases "
"WHERE employer_id = :eid"
),
{"eid": employer_id},
).scalar_one()
)
class TestCheckoutE2E:
def test_full_loop_purchase_granted(
self,
claimed: tuple[TestClient, str],
billing_store: SQLiteBillingStore,
) -> None:
"""MH-5a: register → login (the register mint IS the session) →
claim → admin approve → checkout → own mock-complete → the
REAL webhook path → purchase exists with posts available —
and the employer can CREATE a posting from the granted package
(the loop's proof point)."""
client, token = claimed
# Login proves the credential path (register mints the session;
# login re-derives one from the password).
login_resp = client.post(
"/v1/employers/auth/login",
json={"email": TEST_EMAIL, "password": TEST_PASSWORD},
)
assert login_resp.status_code == 200
token = login_resp.json()["token"]
body = checkout(client, token, "pkg-single")
complete = mock_complete(client, token, body["provider_session_id"])
assert complete.status_code == 200, complete.text
assert complete.json() == {"completed": True, "payment_status": "paid"}
# The REAL webhook path ran: the entitlement view (own-entity)
# renders the granted package with posts available.
view = client.get(
"/v1/employers/billing/entitlement", headers=bearer(token)
)
assert view.status_code == 200
purchase = view.json()["purchase"]
assert purchase is not None
assert purchase["posts_limit"] == 1
assert purchase["posts_used"] == 0
assert purchase["expires_at"] is not None
# The grant answers the loop: a posting create succeeds (the
# package is live in the shared entitlement gate).
created = client.post(
"/v1/employers/postings",
headers=bearer(token),
json={"title": "E2E Role", "description": "d", "skills": ["go"]},
)
assert created.status_code == 201, created.text
assert _purchase_count(billing_store, OWN_ENTITY) == 1
# The signed delivery landed (provider event recorded).
events = billing_store.list_billing_events(kind="provider")
assert any(e.name == "checkout.completed" for e in events)
def test_cross_session_complete_403(
self,
claimed: tuple[TestClient, str],
billing_store: SQLiteBillingStore,
) -> None:
"""MH-5b (the G-301 hole class): a rival's provider_session_id
must 403 — completing another buyer's session is refused
BEFORE any provider state change or grant; the rival's session
stays open and ungranted."""
client, token = claimed
# The rival buys a session directly through the admin operator
# route (the v0.9 fallback — provenance: operator).
rival_checkout = client.post(
"/v1/billing/admin/checkout",
json={"item_id": "pkg-single", "employer_id": OTHER_ENTITY},
headers=admin_headers(),
)
assert rival_checkout.status_code == 201
rival_id = rival_checkout.json()["provider_session_id"]
resp = mock_complete(client, token, rival_id)
assert resp.status_code == 403
assert "another employer entity" in resp.json()["detail"]
# Nothing granted, nothing flipped: the rival has no purchase.
assert _purchase_count(billing_store, OTHER_ENTITY) == 0
rival_row = billing_store.get_checkout_session_by_provider_id(rival_id)
assert rival_row is not None
assert rival_row.status == "open"
def test_unknown_session_404(self, claimed: tuple[TestClient, str]) -> None:
client, token = claimed
resp = mock_complete(client, token, "cs_mock_ghost")
assert resp.status_code == 404
assert resp.json()["detail"] == "unknown checkout session"
def test_unclaimed_403_claim_cta(self, tmp_path: Path) -> None:
"""The claim gate precedes everything (G-311 order)."""
with make_client(tmp_path) as client:
token = register(client)["token"]
resp = client.post(
"/v1/employers/billing/mock/complete",
headers=bearer(token),
json={"provider_session_id": "cs_mock_any"},
)
assert resp.status_code == 403
assert resp.json()["detail"]["reason"] == "no_claimed_employer"
def test_no_token_401(self, tmp_path: Path) -> None:
with make_client(tmp_path) as client:
resp = client.post(
"/v1/employers/billing/mock/complete",
json={"provider_session_id": "cs_mock_any"},
)
assert resp.status_code == 401
def test_mock_complete_404_under_stripe_provider(
self, claimed: tuple[TestClient, str]
) -> None:
"""MH-5b (second arm): on a stripe-configured box the route
must be GONE — the isinstance gate is the defense-in-depth
behind the mount story (the router mounts whole; the route
refuses under a non-mock provider)."""
import httpx
from ai_service.billing.stripe import StripePaymentProvider
client, token = claimed
body = checkout(client, token, "pkg-single")
client.app.state.payment_provider = StripePaymentProvider( # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
http_client=httpx.AsyncClient(
transport=httpx.MockTransport(
lambda request: httpx.Response(200, json={})
)
),
api_key="sk_test_never_sent",
webhook_secret=SECRET,
)
resp = mock_complete(client, token, body["provider_session_id"])
assert resp.status_code == 404 # never reachable under stripe
def test_duplicate_complete_falls_through_no_double_grant(
self,
claimed: tuple[TestClient, str],
billing_store: SQLiteBillingStore,
) -> None:
"""G-303: a duplicate complete (double-click) re-mints + re-
runs the REAL webhook path with a FRESH event id — the grant
is refused by the per-session UNIQUE constraint; 200 honest
(already complete), exactly ONE purchase, never a 500."""
client, token = claimed
body = checkout(client, token, "pkg-single")
first = mock_complete(client, token, body["provider_session_id"])
assert first.status_code == 200
second = mock_complete(client, token, body["provider_session_id"])
assert second.status_code == 200
assert second.json()["completed"] is True
assert _purchase_count(billing_store, OWN_ENTITY) == 1
# The refused re-grant is RECORDED (audit answers it).
events = billing_store.list_billing_events(kind="unmatched")
assert any(
e.name == "checkout.completed" and "already granted" in str(e.detail)
for e in events
)
# The posts are still available exactly once: one create
# consumes the single post, a second 402s (no phantom grant).
first_create = client.post(
"/v1/employers/postings",
headers=bearer(token),
json={"title": "Role A", "description": "d", "skills": []},
)
assert first_create.status_code == 201
second_create = client.post(
"/v1/employers/postings",
headers=bearer(token),
json={"title": "Role B", "description": "d", "skills": []},
)
assert second_create.status_code == 402
def test_subscription_checkout_completes_to_subscription(
self,
claimed: tuple[TestClient, str],
billing_store: SQLiteBillingStore,
) -> None:
"""The plan arm: a subscription-mode own checkout completes
into an active subscription through the REAL webhook path."""
client, token = claimed
body = checkout(client, token, "plan-pro")
complete = mock_complete(client, token, body["provider_session_id"])
assert complete.status_code == 200
view = client.get(
"/v1/employers/billing/entitlement", headers=bearer(token)
)
subscription = view.json()["subscription"]
assert subscription is not None
assert subscription["status"] == "active"
assert subscription["plan_name"] == "Pro"
@@ -1,212 +0,0 @@
"""claim_evidence tests — R-65 normalization + the advisory match
signal (Task 2-1-01, REQ-10-002).
Zero-dep discipline pinned: the module imports stdlib urllib.parse
only (no PSL library, no tldextract — R-66). The example.com corpus
(R-65) pins match=false corpus-wide: every seeded employer website is
an example.com placeholder, so a real registrant's email domain never
matches.
"""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from ai_service.employers.claim_evidence import (
compute_claim_evidence,
domains_match,
normalize_email_domain,
normalize_website_domain,
)
CORPUS_PATH = (
Path(__file__).resolve().parents[2]
/ "ai_service"
/ "marketplace"
/ "seed_corpus.json"
)
class TestNormalizeWebsiteDomain:
"""The normalization table — scheme/path stripped, case-folded."""
@pytest.mark.parametrize(
("website", "expected"),
[
# The PLAN's verbatim example: scheme + path stripped.
("https://example.com/openai", "example.com"),
# Case folding (host, scheme, and a mixed-case path).
("HTTPS://Example.COM/OpenAI", "example.com"),
("https://EXAMPLE.com", "example.com"),
# Bare host without a scheme — the // prepended for the
# netloc parse (urlsplit alone would read it as a path).
("example.com", "example.com"),
("Example.COM", "example.com"),
# Path + query + fragment stripped.
("https://example.com/careers?tab=1#top", "example.com"),
# Deep path stripped to the bare host.
("https://sub.example.com/jobs/openai/123", "sub.example.com"),
# Port dropped (evidence is registrable, not origin-scoped
# — port is irrelevant to a domain match).
("https://example.com:8443/careers", "example.com"),
# The seed corpus's placeholder shape (R-65).
("https://example.com/openai", "example.com"),
],
)
def test_table(self, website: str, expected: str) -> None:
assert normalize_website_domain(website) == expected
@pytest.mark.parametrize(
"website",
["", "not a url", "https://", "mailto:x@example.com"],
)
def test_honest_absence(self, website: str) -> None:
"""Unparseable/empty → "" — never an exception (the claim route
must never 500 over an advisory signal). mailto: carries no
hostname — honest absence."""
assert normalize_website_domain(website) == ""
def test_unparseable_never_raises(self) -> None:
"""An unterminated IPv6 literal raises ValueError inside
urlsplit — swallowed into the honest empty string (evidence is
advisory, never a 500 on the claim route)."""
assert normalize_website_domain("https://[::1") == ""
class TestNormalizeEmailDomain:
@pytest.mark.parametrize(
("email", "expected"),
[
("founder@openai.example", "openai.example"),
# Lowercased (G-315 already normalizes at the account
# boundary; this re-derives, never trusts).
("Founder@OpenAI.Example", "openai.example"),
# The local part rides NOWHERE in the evidence.
("weird+tag@acme.com", "acme.com"),
],
)
def test_table(self, email: str, expected: str) -> None:
assert normalize_email_domain(email) == expected
@pytest.mark.parametrize("email", ["", "no-at-sign"])
def test_honest_absence(self, email: str) -> None:
assert normalize_email_domain(email) == ""
class TestDomainsMatch:
def test_same_registrable_domain_true(self) -> None:
"""The PLAN's match case: identical domains."""
assert domains_match("acme.com", "acme.com") is True
@pytest.mark.parametrize(
("email_domain", "website_domain"),
[
# Subdomain of the registrable domain — one endswith "." +
# the other, either orientation.
("acme.com", "shop.acme.com"),
("shop.acme.com", "acme.com"),
("acme.co.uk", "www.acme.co.uk"),
],
)
def test_suffix_match_true(
self, email_domain: str, website_domain: str
) -> None:
assert domains_match(email_domain, website_domain) is True
@pytest.mark.parametrize(
("email_domain", "website_domain"),
[
# Unrelated domains.
("acme.com", "openai.example"),
("acme.com", "notacme.com"),
# Prefix-lookalike is NOT a suffix ("acme.com" vs
# "fake-acme.com" — no boundary dot).
("acme.com", "fake-acme.com"),
# Either side empty → False (nothing claimed is nothing
# matched).
("", "acme.com"),
("acme.com", ""),
("", ""),
],
)
def test_no_match_false(self, email_domain: str, website_domain: str) -> None:
assert domains_match(email_domain, website_domain) is False
def test_multi_label_suffix_overmatch_documented(self) -> None:
"""The documented over-match (module docstring): "co.uk" is a
multi-label public suffix, not a registrable domain, yet the
suffix rule counts it. Accepted for an advisory signal — no
PSL, zero deps (R-66). This test PINS the behavior so a future
tightening can't drift silently."""
assert domains_match("mail.co.uk", "co.uk") is True
class TestComputeClaimEvidence:
def test_exact_shape(self) -> None:
"""The D-089 evidence dict — exact keys, computed at claim
time, stored as provenance (Task 2-1-03 pins the route)."""
evidence = compute_claim_evidence(
"founder@openai.example", "https://openai.example/careers"
)
assert evidence == {
"account_email_domain": "openai.example",
"entity_website_domain": "openai.example",
"match": True,
}
assert set(evidence) == {
"account_email_domain",
"entity_website_domain",
"match",
}
# The local part rides nowhere (PII boundary).
assert "founder" not in json.dumps(evidence)
def test_match_false_on_unrelated(self) -> None:
evidence = compute_claim_evidence(
"founder@acme.com", "https://openai.example/careers"
)
assert evidence == {
"account_email_domain": "acme.com",
"entity_website_domain": "openai.example",
"match": False,
}
def test_match_false_on_the_example_com_corpus(self) -> None:
"""R-65: every seeded employer website is an example.com
placeholder — a real registrant's email domain does NOT match
any seeded entity. match=false corpus-wide (D-089's
advisory-display premise)."""
corpus = json.loads(CORPUS_PATH.read_text(encoding="utf-8"))
employers = corpus["employers"]
assert len(employers) > 0
for employer in employers:
website = employer["website"]
assert "example.com" in website # the R-65 premise itself
evidence = compute_claim_evidence(
"founder@openai.example", website
)
assert evidence["entity_website_domain"] == "example.com"
assert evidence["match"] is False
class TestZeroDepDiscipline:
def test_module_is_stdlib_only(self) -> None:
"""R-66: the evidence module imports nothing beyond stdlib
(urllib.parse) — no PSL, no tldextract, never a new dep."""
import ai_service.employers.claim_evidence as module
imported = {
name
for name in vars(module)
if not name.startswith("_")
and getattr(getattr(module, name, None), "__module__", "")
== module.__name__
}
assert imported # the module's own names are present
# The ONLY module-level import is urllib.parse (stdlib).
import urllib.parse
assert module.urlsplit is urllib.parse.urlsplit
@@ -1,315 +0,0 @@
"""Claim route tests — POST /v1/employers/claims (Task 2-1-03, REQ-10-002).
MH-2b pinned here: the exact evidence dict
{account_email_domain, entity_website_domain, match} computed AT CLAIM
TIME, stored as provenance JSON on the claim row, returned with the
pending claim. The entity must exist in the marketplace store (404);
the account must not already hold an entity (409 honest); one pending
claim per entity (409 — G-316's index backstops the race); the route is
session-gated (401 unauthenticated). Auto-approval is deferred (D-089).
REAL scrypt throughout (register mints a real hash — the conftest
fixtures handle limiter resets).
"""
from __future__ import annotations
from fastapi.testclient import TestClient
from ai_service.marketplace.base import Employer
from .conftest import (
TEST_EMAIL,
bearer,
make_client,
register,
)
ENTITY_ID = "emp-openai"
def seed_employer(client: TestClient, website: str = "https://example.com/openai") -> None:
"""Upsert a marketplace EmployerRow through the app's own store
(app.state.marketplace_store — the same DI the route composes)."""
store = client.app.state.marketplace_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
store.upsert_employer(
Employer(
id=ENTITY_ID,
source="seed-corpus",
external_id=ENTITY_ID,
name="OpenAI Labs",
website=website,
)
)
def claim(client: TestClient, token: str, employer_id: str = ENTITY_ID):
return client.post(
"/v1/employers/claims",
json={"employer_id": employer_id},
headers=bearer(token),
)
class TestClaimRoute:
def test_claim_pending_with_exact_evidence(self, client: TestClient) -> None:
"""Claim → 201 pending with the EXACT evidence dict (MH-2b):
the R-65 example.com corpus makes match=false corpus-wide."""
seed_employer(client)
token = register(client)["token"]
resp = claim(client, token)
assert resp.status_code == 201, resp.text
claim_row = resp.json()
assert claim_row["status"] == "pending"
assert claim_row["employer_id"] == ENTITY_ID
assert claim_row["evidence"] == {
"account_email_domain": "nextcraft.example",
"entity_website_domain": "example.com",
"match": False,
}
assert claim_row["decided_by"] is None
assert claim_row["decided_at"] is None
assert claim_row["account_id"]
def test_evidence_true_on_matching_domain(
self, client: TestClient
) -> None:
"""A matching website domain (R-65's registrable case) — the
evidence signal is ADVISORY: match=true still files a PENDING
claim (auto-approval deferred, D-089/A-304)."""
seed_employer(client, website="https://nextcraft.example/careers")
token = register(client)["token"]
resp = claim(client, token)
assert resp.status_code == 201, resp.text
assert resp.json()["evidence"] == {
"account_email_domain": "nextcraft.example",
"entity_website_domain": "nextcraft.example",
"match": True,
}
assert resp.json()["status"] == "pending"
def test_unknown_entity_404(self, client: TestClient) -> None:
token = register(client)["token"]
resp = claim(client, token, employer_id="emp-does-not-exist")
assert resp.status_code == 404
def test_account_already_holds_entity_409(
self, client: TestClient
) -> None:
"""One account : one entity — an owner claiming a SECOND
(free) entity gets the honest 409. The ownership binds via the
store's decide_claim (the same transition the admin route
drives in Wave 2-2)."""
seed_employer(client)
store = client.app.state.employer_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
token = register(client)["token"]
first = claim(client, token)
assert first.status_code == 201
# Operator approve (the store path the 2-2 route composes).
decided = store.decide_claim(
first.json()["id"], decision="approved", operator="op-1"
)
assert decided is not None and decided.status == "approved"
# A second, otherwise-free entity — still refused.
store2 = client.app.state.marketplace_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
store2.upsert_employer(
Employer(
id="emp-other",
source="seed-corpus",
external_id="emp-other",
name="Other Labs",
website="https://example.com/other",
)
)
resp = claim(client, token, employer_id="emp-other")
assert resp.status_code == 409
def test_second_pending_claim_same_entity_409(
self, client: TestClient
) -> None:
"""One PENDING claim per entity — the check-first 409 fast
path, by ANOTHER account (the same account's duplicate also
409s; the other-account arm is the stronger pin)."""
seed_employer(client)
first_token = register(client)["token"]
assert claim(client, first_token).status_code == 201
second_token = register(client, email="other@example.com")["token"]
resp = claim(client, second_token)
assert resp.status_code == 409
def test_unauthenticated_401(self, client: TestClient) -> None:
seed_employer(client)
resp = client.post(
"/v1/employers/claims", json={"employer_id": ENTITY_ID}
)
assert resp.status_code == 401
def test_invalid_token_401(self, client: TestClient) -> None:
seed_employer(client)
resp = client.post(
"/v1/employers/claims",
json={"employer_id": ENTITY_ID},
headers=bearer("not-a-real-token"),
)
assert resp.status_code == 401
def test_claim_evidence_persisted_as_provenance(
self, client: TestClient
) -> None:
"""The evidence snapshot rides the claim ROW (stored at claim
time, never recomputed on read — D-089) + a claim.submitted
audit event lands."""
from sqlalchemy import text
seed_employer(client)
token = register(client)["token"]
resp = claim(client, token)
assert resp.status_code == 201
store = client.app.state.employer_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
with store._engine.connect() as conn:
evidence = conn.execute(
text("SELECT evidence FROM employer_claims")
).scalar_one()
audit = conn.execute(
text(
"SELECT kind, name FROM employer_audit_events "
"WHERE kind = 'claim'"
)
).fetchall()
assert evidence == (
'{"account_email_domain": "nextcraft.example", '
'"entity_website_domain": "example.com", "match": false}'
)
assert audit == [("claim", "claim.submitted")]
def test_empty_employer_id_422(self, client: TestClient) -> None:
token = register(client)["token"]
resp = client.post(
"/v1/employers/claims", json={"employer_id": ""}, headers=bearer(token)
)
assert resp.status_code == 422
class TestRacedInsert:
def test_raced_pending_claim_same_honest_409(
self, tmp_path
) -> None:
"""G-316 at the route: a second pending row raced past the
check-first read is refused by the partial UNIQUE index — the
surfaced IntegrityError maps to the SAME honest 409, never a
duplicate pending row (proven by inserting the rival row
directly through the store, bypassing the route's check)."""
with make_client(tmp_path) as client:
seed_employer(client)
token = register(client)["token"]
employer_store = client.app.state.employer_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
# The rival pending claim lands BETWEEN this route's
# check-first read and its insert only under a race; a
# direct store insert simulates the raced writer.
rival_account, _ = employer_store.insert_account(
email="rival@example.com",
password_hash="scrypt$16384$8$1$c2FsdA$aGFzaA",
)
employer_store.insert_claim(
account_id=rival_account.id,
employer_id=ENTITY_ID,
evidence={},
)
resp = claim(client, token)
assert resp.status_code == 409
pending = employer_store.list_claims(status="pending")
assert len(pending) == 1 # never a duplicate pending row
class TestClaimDiscipline:
def test_marketplace_store_via_di_never_imported(
self,
) -> None:
"""Territory rule: the route composes the marketplace store
via app.state DI (get_marketplace_store) — api/employers.py
never CONSTRUCTS a store. P4 (Task 4-1-02): the write routes
compose the admin routes' own imports (InvalidTransition,
MarketplaceStore annotations — the marketplace_admin.py:32
precedent), so the pin asserts the CONSTRUCTION ban, not the
import ban: no SQLiteMarketplaceStore anywhere in the
module, the DEP composed on every store touch."""
import inspect
from ai_service.api import employers as employers_api
source = inspect.getsource(employers_api)
assert "SQLiteMarketplaceStore" not in source
assert "get_marketplace_store" in source
def test_no_learner_gate_imported(self) -> None:
"""D-097: no learner gate composed on any employer route —
pinned at the IMPORT sites (the api.identity imports are what
compose gates; docstring mentions are copy, not code)."""
import inspect
from ai_service.api import employers as employers_api
source = inspect.getsource(employers_api)
assert "from .identity import" not in source
assert "from ..identity" not in source
assert "learner_allowlist" not in source
def test_route_order_entity_check_before_owner_check(
self, tmp_path
) -> None:
"""The 404 (unknown entity) wins over the 409 (already an
owner): the entity is resolved first — an owner probing an
unknown id learns nothing about ownership state."""
with make_client(tmp_path) as client:
token = register(client)["token"]
store = client.app.state.employer_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
account = store.get_account_by_email(TEST_EMAIL)
assert account is not None
with store._engine.connect() as conn:
from sqlalchemy import text
conn.execute(
text(
"UPDATE employer_accounts SET employer_id = "
"'emp-owned' WHERE id = :acct"
),
{"acct": account.id},
)
conn.commit()
resp = claim(client, token, employer_id="emp-unknown")
assert resp.status_code == 404
def test_no_learner_gate_composed(self) -> None:
"""D-097: no learner gate (require_verified_age/allowlist)
composed on any employer route — pinned at the import sites
(docstring copy is not code)."""
import inspect
from ai_service.api import employers as employers_api
source = inspect.getsource(employers_api)
assert "from .identity import" not in source
assert "from ..identity" not in source
assert "learner_allowlist" not in source
def test_claim_route_registered(tmp_path) -> None:
"""The claims_router is mounted (main.py wiring pin): the route
exists in the OpenAPI schema — POST /v1/employers/claims with the
session-gated claim body (the _IncludedRouter wrappers hide
APIRoutes from app.routes in this starlette, so the schema is the
honest registry)."""
with make_client(tmp_path) as client:
schema = client.get("/openapi.json").json()
path = schema["paths"]["/v1/employers/claims"]
assert set(path) == {"post"}
body_schema = path["post"]["requestBody"]["content"][
"application/json"
]["schema"]
# $ref → ClaimEmployerRequest, whose only required field is
# employer_id.
assert body_schema["$ref"].endswith("ClaimEmployerRequest")
request = schema["components"]["schemas"]["ClaimEmployerRequest"]
assert request["required"] == ["employer_id"]
assert request["properties"]["employer_id"]["minLength"] == 1
@@ -1,604 +0,0 @@
"""Admin claim review/operator-route tests (Task 2-2-01, REQ-10-002, MH-2a/2d).
Pinned: approve → account.employer_id set + audit row + claim approved;
reject frees the entity (a new claim from ANOTHER account succeeds);
decided twice → 409 honest (one decision audit row — no double
activation); the entity-owned arm surfaces as 409, never an overwrite;
fail-closed 403 on ALL five routes (no token / wrong token /
unset-config / cross-principal session token); a 403'd decision
writes NOTHING; the audit query returns claim decisions with operator
provenance; the five routes are mounted + OpenAPI-visible. The
admin-token pattern is the billing one (X-Admin-Token + a SecretStr'd
Settings knob, tests/billing/test_lifecycle_admin.py:26-63).
REAL scrypt throughout (register mints a real hash; the conftest
autouse fixture resets the auth limiter module states — the admin
routes add no limiter state of their own).
"""
from __future__ import annotations
from collections.abc import Iterator
from contextlib import contextmanager
from pathlib import Path
from fastapi.testclient import TestClient
from pydantic import SecretStr
from ai_service.marketplace.base import Employer
from .conftest import TEST_EMAIL, make_client, register
ADMIN_TOKEN = "test-claims-admin"
ENTITY_ID = "emp-openai"
#: The five-route fail-closed matrix surface (R-56): the three GETs
#: plus the two decision POSTs (formatted with a claim id).
ROUTES = (
"/v1/employers/admin/accounts",
"/v1/employers/admin/claims",
"/v1/employers/admin/audit",
)
DECISION_ROUTES = (
"/v1/employers/admin/claims/{claim_id}/approve",
"/v1/employers/admin/claims/{claim_id}/reject",
)
def admin_headers(token: str = ADMIN_TOKEN) -> dict[str, str]:
return {"X-Admin-Token": token}
@contextmanager
def make_admin_client(
tmp_path: Path, admin_token: str | None = ADMIN_TOKEN
) -> Iterator[TestClient]:
"""The full app with the admin token CONFIGURED (the billing
pattern: SecretStr knob; admin_token=None exercises the UNSET
fail-closed class — the empty SecretStr)."""
with make_client(tmp_path, admin_token=SecretStr(admin_token or "")) as client:
yield client
def seed_employer(client: TestClient, employer_id: str = ENTITY_ID, website: str = "") -> None:
store = client.app.state.marketplace_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
store.upsert_employer(
Employer(
id=employer_id,
source="seed-corpus",
external_id=employer_id,
name="OpenAI Labs",
website=website or f"https://example.com/{employer_id}",
)
)
def claim(client: TestClient, token: str, employer_id: str = ENTITY_ID) -> dict:
"""Session-gated claim through the REAL route (409/404 arms raise
— the callers pin the happy path)."""
resp = client.post(
"/v1/employers/claims",
json={"employer_id": employer_id},
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 201, resp.text
return resp.json()
def decide(client: TestClient, claim_id: str, arm: str = "approve") -> TestClient:
"""Convenience: POST the decision with the configured token; the
CLIENT returns for status asserts."""
client.post(
f"/v1/employers/admin/claims/{claim_id}/{arm}",
headers=admin_headers(),
)
return client
class TestApprove:
def test_approve_sets_ownership_and_audit(self, tmp_path: Path) -> None:
"""MH-2a: approve → claim approved with operator provenance +
account.employer_id set (read through /me — the same store the
route wrote) + the audit row with operator provenance."""
with make_admin_client(tmp_path) as client:
seed_employer(client)
token = register(client)["token"]
row = claim(client, token)
resp = client.post(
f"/v1/employers/admin/claims/{row['id']}/approve",
headers=admin_headers(),
)
assert resp.status_code == 200, resp.text
decided = resp.json()["claim"]
assert decided["status"] == "approved"
assert decided["id"] == row["id"]
assert decided["decided_by"] == "operator"
assert decided["decided_at"] is not None
me = client.get(
"/v1/employers/auth/me",
headers={"Authorization": f"Bearer {token}"},
)
assert me.status_code == 200
body = me.json()
assert body["claimed_employer_id"] == ENTITY_ID
assert body["account"]["employer_id"] == ENTITY_ID
audit = client.get(
"/v1/employers/admin/audit?kind=claim",
headers=admin_headers(),
)
events = audit.json()["events"]
approve = [e for e in events if e["name"] == "claim.approved"]
assert len(approve) == 1
assert approve[0]["operator"] == "operator"
assert approve[0]["detail"]["claim_id"] == row["id"]
assert approve[0]["detail"]["employer_id"] == ENTITY_ID
assert approve[0]["account_id"]
def test_approve_pops_pending_queue(self, tmp_path: Path) -> None:
with make_admin_client(tmp_path) as client:
seed_employer(client)
token = register(client, email="q@example.com")["token"]
row = claim(client, token)
pending = client.get(
"/v1/employers/admin/claims?status=pending",
headers=admin_headers(),
)
assert pending.json()["count"] == 1
assert (
client.post(
f"/v1/employers/admin/claims/{row['id']}/approve",
headers=admin_headers(),
).status_code
== 200
)
pending = client.get(
"/v1/employers/admin/claims?status=pending",
headers=admin_headers(),
)
assert pending.json()["count"] == 0
approved = client.get(
"/v1/employers/admin/claims?status=approved",
headers=admin_headers(),
)
assert approved.json()["count"] == 1
class TestReject:
def test_reject_frees_entity_for_new_claim(self, tmp_path: Path) -> None:
"""Reject → decided + audited; the entity is FREE — a new
claim from ANOTHER account succeeds; the rejected account
owns nothing."""
with make_admin_client(tmp_path) as client:
seed_employer(client)
first_token = register(client, email="first@example.com")["token"]
row = claim(client, first_token)
resp = client.post(
f"/v1/employers/admin/claims/{row['id']}/reject",
headers=admin_headers(),
)
assert resp.status_code == 200, resp.text
rejected = resp.json()["claim"]
assert rejected["status"] == "rejected"
assert rejected["decided_by"] == "operator"
second_token = register(client, email="second@example.com")["token"]
second = client.post(
"/v1/employers/claims",
json={"employer_id": ENTITY_ID},
headers={"Authorization": f"Bearer {second_token}"},
)
assert second.status_code == 201, second.text
store = client.app.state.employer_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
first = store.get_account_by_email("first@example.com")
assert first is not None and first.employer_id is None
audit = client.get(
"/v1/employers/admin/audit?name=claim.rejected",
headers=admin_headers(),
)
assert audit.json()["count"] == 1
assert audit.json()["events"][0]["operator"] == "operator"
class TestDecideDiscipline:
def test_decide_twice_409_honest(self, tmp_path: Path) -> None:
"""Deciding a DECIDED claim → 409 for BOTH arms (the store's
not-pending guard); exactly ONE decision audit row."""
with make_admin_client(tmp_path) as client:
seed_employer(client)
token = register(client, email="t@example.com")["token"]
row = claim(client, token)
first = client.post(
f"/v1/employers/admin/claims/{row['id']}/approve",
headers=admin_headers(),
)
assert first.status_code == 200
again = client.post(
f"/v1/employers/admin/claims/{row['id']}/approve",
headers=admin_headers(),
)
assert again.status_code == 409
assert again.json()["detail"] == "claim is already decided"
flip = client.post(
f"/v1/employers/admin/claims/{row['id']}/reject",
headers=admin_headers(),
)
assert flip.status_code == 409
audit = client.get(
"/v1/employers/admin/audit?kind=claim",
headers=admin_headers(),
)
decisions = [
e
for e in audit.json()["events"]
if e["name"] in ("claim.approved", "claim.rejected")
]
assert len(decisions) == 1
def test_unknown_claim_404(self, tmp_path: Path) -> None:
with make_admin_client(tmp_path) as client:
for route in DECISION_ROUTES:
resp = client.post(
route.format(claim_id="clm-does-not-exist"),
headers=admin_headers(),
)
assert resp.status_code == 404, route
def test_approve_account_holds_entity_409(self, tmp_path: Path) -> None:
"""The route arm of one-account-one-entity: a pending claim
whose account ALREADY holds an entity (the account became an
owner on another entity after filing) — approve surfaces the
store's hand-raised guard as the honest 409, never an
overwrite. The rival claim is inserted directly through the
store seam (a second pending claim on a DIFFERENT free entity
whose account is the owner)."""
with make_admin_client(tmp_path) as client:
seed_employer(client, "emp-a")
seed_employer(client, "emp-b")
token = register(client, email="owner@example.com")["token"]
first = claim(client, token, "emp-a")
store = client.app.state.employer_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
decided = store.decide_claim(first["id"], decision="approved", operator="op")
assert decided is not None
# The now-owner files a second claim on a free entity via
# the store seam (the route would 409 the owner first).
row, created = store.insert_claim(
account_id=decided.account_id,
employer_id="emp-b",
evidence={},
)
assert created
resp = client.post(
f"/v1/employers/admin/claims/{row.id}/approve",
headers=admin_headers(),
)
assert resp.status_code == 409
assert resp.json()["detail"] == ("the claim's account already holds an employer entity")
# Nothing was overwritten: the account still owns emp-a
# only, and the claim row stays pending (undecided).
account = store.get_account(decided.account_id)
assert account is not None
assert account.employer_id == "emp-a"
still = store.get_claim(row.id)
assert still is not None and still.status == "pending"
def test_entity_owned_by_another_409_no_overwrite(self, tmp_path: Path) -> None:
"""The one-APPROVED-owner arm at the route: a pending claim on
an entity whose owner is ANOTHER account (the ownership raced
past the claim route's checks) — the approve flush surfaces the
employer_accounts.employer_id UNIQUE as the honest 409; the
EXISTING owner is never displaced. The rival ownership is set
directly on the account through the store seam (a second
PENDING claim on the entity is impossible — the partial index
refuses it, so the claim-route race is not the reachable seam;
the OWNERSHIP race is)."""
with make_admin_client(tmp_path) as client:
seed_employer(client, "emp-race")
token = register(client, email="racer@example.com")["token"]
row = claim(client, token, "emp-race")
# A rival account becomes the entity's owner through the
# store seam AFTER the claim was filed (direct account
# UPDATE — the same write decide_claim performs, bypassing
# its guards: the seam simulates an ownership that raced).
store = client.app.state.employer_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
rival, _ = store.insert_account(
email="rival-owner@example.com",
password_hash="scrypt$16384$8$1$c2FsdA$aGFzaA",
)
from sqlalchemy import text
with store._engine.connect() as conn: # test seam into the store's engine
conn.execute(
text("UPDATE employer_accounts SET employer_id = :entity WHERE id = :acct"),
{"entity": "emp-race", "acct": rival.id},
)
conn.commit()
# Now the OPERATOR approves the pending claim on the same
# entity: the UNIQUE refuses — the honest 409, the rival
# keeps the ownership, the claim row stays pending.
resp = client.post(
f"/v1/employers/admin/claims/{row['id']}/approve",
headers=admin_headers(),
)
assert resp.status_code == 409
assert resp.json()["detail"] == ("the employer entity is already owned")
owner = store.get_account(rival.id)
assert owner is not None and owner.employer_id == "emp-race"
loser = store.get_account_by_email("racer@example.com")
assert loser is not None and loser.employer_id is None
still = store.get_claim(row["id"])
assert still is not None and still.status == "pending"
class TestAccountsSurface:
def test_accounts_list_columns_and_no_hash(self, tmp_path: Path) -> None:
"""The accounts list: email, claimed entity, created_at, the
UNVERIFIED marker — and password_hash NEVER on the wire."""
with make_admin_client(tmp_path) as client:
seed_employer(client)
token = register(client)["token"]
row = claim(client, token)
rows = client.get("/v1/employers/admin/accounts", headers=admin_headers())
assert rows.status_code == 200
body = rows.json()
assert body["count"] == 1
account = body["accounts"][0]
assert account["email"] == TEST_EMAIL
assert account["employer_id"] is None # pending — unclaimed
assert account["created_at"]
assert account["email_verified"] is False
assert "password_hash" not in account
assert "password" not in account
decide(client, row["id"])
rows = client.get("/v1/employers/admin/accounts", headers=admin_headers())
assert rows.json()["accounts"][0]["employer_id"] == ENTITY_ID
class TestClaimsQueueSurface:
def test_queue_carries_advisory_evidence(self, tmp_path: Path) -> None:
"""The queue renders the stored evidence snapshot + the
domain-match signal as ADVISORY (A-304/R-65: match=false is
the example.com corpus norm — advisory, never verification);
decided state None while pending. The snapshot is the STORE's
claim-time provenance, never recomputed on queue read."""
with make_admin_client(tmp_path) as client:
seed_employer(client)
token = register(client)["token"]
row = claim(client, token)
queue = client.get("/v1/employers/admin/claims", headers=admin_headers())
assert queue.status_code == 200
entry = queue.json()["claims"][0]
assert entry["id"] == row["id"]
assert entry["status"] == "pending"
assert entry["evidence"] == {
"account_email_domain": "nextcraft.example",
"entity_website_domain": "example.com",
"match": False,
}
assert entry["decided_by"] is None
assert entry["decided_at"] is None
store = client.app.state.employer_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
stored = store.get_claim(row["id"])
assert stored is not None
assert dict(stored.evidence) == entry["evidence"]
def test_status_facet(self, tmp_path: Path) -> None:
with make_admin_client(tmp_path) as client:
seed_employer(client)
token = register(client, email="facet@example.com")["token"]
claim(client, token)
for facet, expected in (
("pending", 1),
("approved", 0),
("rejected", 0),
):
rows = client.get(
f"/v1/employers/admin/claims?status={facet}",
headers=admin_headers(),
)
assert rows.status_code == 200
assert rows.json()["count"] == expected
class TestAuditTrail:
def test_audit_query_provenance(self, tmp_path: Path) -> None:
"""The audit query returns claim decisions with operator
provenance: kind/account/employer/name filters + the
submitted → approved chain, decided_by/decided_at joined via
detail.claim_id."""
with make_admin_client(tmp_path) as client:
seed_employer(client)
token = register(client)["token"]
row = claim(client, token)
assert (
client.post(
f"/v1/employers/admin/claims/{row['id']}/approve",
headers=admin_headers(),
).status_code
== 200
)
claims = client.get(
"/v1/employers/admin/audit?kind=claim",
headers=admin_headers(),
)
assert claims.status_code == 200
names = [e["name"] for e in claims.json()["events"]]
assert names == ["claim.approved", "claim.submitted"]
approved = client.get(
"/v1/employers/admin/audit?name=claim.approved",
headers=admin_headers(),
)
assert approved.json()["count"] == 1
event = approved.json()["events"][0]
assert event["operator"] == "operator"
assert event["detail"]["claim_id"] == row["id"]
assert event["detail"]["employer_id"] == ENTITY_ID
by_entity = client.get(
f"/v1/employers/admin/audit?employer_id={ENTITY_ID}",
headers=admin_headers(),
)
assert by_entity.json()["count"] == 2
store = client.app.state.employer_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
account = store.get_account_by_email(TEST_EMAIL)
assert account is not None
by_account = client.get(
f"/v1/employers/admin/audit?account_id={account.id}",
headers=admin_headers(),
)
# register + claim.submitted + claim.approved all carry the
# account's id (register stamps account_id too).
assert by_account.json()["count"] == 3
# The submitted event's detail snapshots the advisory
# evidence_match signal (P2-SEC: advisory, claim-time).
submitted = client.get(
"/v1/employers/admin/audit?name=claim.submitted",
headers=admin_headers(),
)
assert submitted.json()["events"][0]["detail"]["evidence_match"] is False
# The DECIDED claim carries decided_by/decided_at (the
# operator provenance the decision events reference).
decided_row = store.get_claim(row["id"])
assert decided_row is not None
assert decided_row.decided_by == "operator"
assert decided_row.decided_at is not None
def test_audit_no_filters_returns_all(self, tmp_path: Path) -> None:
with make_admin_client(tmp_path) as client:
seed_employer(client)
token = register(client, email="all@example.com")["token"]
claim(client, token)
everything = client.get("/v1/employers/admin/audit", headers=admin_headers())
assert everything.status_code == 200
names = {e["name"] for e in everything.json()["events"]}
assert names == {"register", "claim.submitted"}
class TestFailClosed:
"""MH-2d: all five operator routes fail-closed — no token,
wrong token, the unset-config class (an empty admin_token 403s
EVERY request even with a candidate), and the cross-principal
class (a VALID employer session token is NOT an admin token)."""
def test_all_five_no_token_403(self, tmp_path: Path) -> None:
with make_admin_client(tmp_path) as client:
for route in ROUTES:
assert client.get(route).status_code == 403, route
for route in DECISION_ROUTES:
assert client.post(route.format(claim_id="clm-x")).status_code == 403, route
def test_all_five_wrong_token_403(self, tmp_path: Path) -> None:
with make_admin_client(tmp_path) as client:
bad = admin_headers("wrong-token")
for route in ROUTES:
assert client.get(route, headers=bad).status_code == 403, route
for route in DECISION_ROUTES:
assert (
client.post(route.format(claim_id="clm-x"), headers=bad).status_code == 403
), route
def test_unset_token_fails_closed_on_all_five(self, tmp_path: Path) -> None:
"""The R-56 matrix's unset-config arm: AI_ADMIN_TOKEN unset →
every route 403s EVEN WITH a candidate token."""
with make_admin_client(tmp_path, admin_token=None) as client:
candidate = admin_headers("anything")
for route in ROUTES:
assert client.get(route, headers=candidate).status_code == 403, route
for route in DECISION_ROUTES:
assert (
client.post(route.format(claim_id="clm-x"), headers=candidate).status_code
== 403
), route
def test_employer_session_token_is_not_admin(self, tmp_path: Path) -> None:
"""Token namespaces cannot collide (D-090): a VALID employer
session token in the admin header fails the admin gate — the
principal classes stay disjoint (one class fails the other's
gate)."""
with make_admin_client(tmp_path) as client:
seed_employer(client)
session_token = register(client)["token"]
swapped = {"X-Admin-Token": session_token}
for route in ROUTES:
assert client.get(route, headers=swapped).status_code == 403, route
for route in DECISION_ROUTES:
assert (
client.post(route.format(claim_id="clm-x"), headers=swapped).status_code == 403
), route
def test_decisions_fail_closed_leave_no_side_effects(self, tmp_path: Path) -> None:
"""A 403'd decision writes NOTHING: the claim stays pending,
no decision audit row (the gate runs before any store call)."""
with make_admin_client(tmp_path) as client:
seed_employer(client)
token = register(client, email="s@example.com")["token"]
row = claim(client, token)
for route in DECISION_ROUTES:
assert client.post(route.format(claim_id=row["id"])).status_code == 403, route
store = client.app.state.employer_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
still = store.get_claim(row["id"])
assert still is not None and still.status == "pending"
audit = client.get(
"/v1/employers/admin/audit?kind=claim",
headers=admin_headers(),
)
# claim.submitted only — no decision rows from the 403s.
names = [e["name"] for e in audit.json()["events"]]
assert names == ["claim.submitted"]
class TestMounted:
def test_all_five_routes_openapi_visible(self, tmp_path: Path) -> None:
"""The operator routes are mounted + OpenAPI-visible (the
_IncludedRouter wrappers hide APIRoutes from app.routes in
this starlette — the schema is the honest registry)."""
with make_admin_client(tmp_path) as client:
schema = client.get("/openapi.json").json()
paths = schema["paths"]
for route in ROUTES:
assert route in paths, route
assert set(paths[route]) == {"get"}, route
for route in DECISION_ROUTES:
formatted = route.format(claim_id="{claim_id}")
assert formatted in paths, formatted
assert set(paths[formatted]) == {"post"}, formatted
def test_accounts_contract_no_hash_field(self, tmp_path: Path) -> None:
"""Wire-contract pin: AdminEmployerAccount exposes exactly the
D-094 columns — password_hash is structurally absent."""
with make_admin_client(tmp_path) as client:
schema = client.get("/openapi.json").json()
account = schema["components"]["schemas"]["AdminEmployerAccount"]
assert set(account["properties"]) == {
"id",
"email",
"employer_id",
"created_at",
"email_verified",
}
# D-097: the marker is a constant False on the wire.
assert account["properties"]["email_verified"]["default"] is False
def test_no_learner_gate_composed() -> None:
"""D-097: no learner gate composed on the operator routes —
pinned at the import sites (docstring copy is not code)."""
import inspect
from ai_service.api import employers_admin
source = inspect.getsource(employers_admin)
assert "from .identity import" not in source
assert "from ..identity" not in source
assert "learner_allowlist" not in source
def test_require_admin_imported_not_reimplemented() -> None:
"""D-094: the guard is IMPORTED from marketplace_admin (the
billing_admin.py:27 precedent) — no compare/digest in territory."""
import inspect
from ai_service.api import employers_admin
from ai_service.api.marketplace_admin import require_admin
source = inspect.getsource(employers_admin)
assert "from .marketplace_admin import require_admin" in source
assert "compare_digest" not in source
assert employers_admin.require_admin is require_admin
@@ -1,407 +0,0 @@
"""EmployerStore claim-method tests (Task 2-1-02, REQ-10-002).
G-316 pins: the partial UNIQUE index uq_pending_claim_per_entity lands
in sqlite_master on fresh DBs (create_all emits it — tests use fresh
tmp_path DBs, never a P1-era file); a raced second pending insert →
IntegrityError (the caller's 409), never a duplicate pending row.
MH-2c pins: one PENDING claim per entity + one APPROVED owner per
entity + rejection frees the entity. decided_by/decided_at provenance
pinned on both arms.
"""
from __future__ import annotations
import json
from datetime import UTC, datetime
from pathlib import Path
import pytest
from sqlalchemy import text
from sqlalchemy.exc import IntegrityError
from sqlmodel import Session
from ai_service.employers.store import SQLiteEmployerStore
PASSWORD_HASH = "scrypt$16384$8$1$c2FsdA$aGFzaA" # self-describing string
EVIDENCE = {
"account_email_domain": "openai.example",
"entity_website_domain": "example.com",
"match": False,
}
def make_store(tmp_path: Path) -> SQLiteEmployerStore:
return SQLiteEmployerStore(db_path=tmp_path / "claims-store.db")
def add_account(
store: SQLiteEmployerStore, email: str
) -> str:
row, _ = store.insert_account(email=email, password_hash=PASSWORD_HASH)
return row.id
def add_claim(
store: SQLiteEmployerStore,
*,
account_id: str,
employer_id: str,
evidence: dict | None = None,
) -> tuple[str, bool]:
row, created = store.insert_claim(
account_id=account_id,
employer_id=employer_id,
evidence=evidence if evidence is not None else EVIDENCE,
)
return row.id, created
def count_pending(
store: SQLiteEmployerStore, employer_id: str
) -> int:
with Session(store._engine) as session:
return int(
session.execute(
text(
"SELECT COUNT(*) FROM employer_claims "
"WHERE employer_id = :eid AND status = 'pending'"
),
{"eid": employer_id},
).scalar_one()
)
class TestPartialIndex:
def test_uq_pending_claim_per_entity_in_sqlite_master(
self, tmp_path: Path
) -> None:
"""G-316 pinned: create_all emits the partial UNIQUE index —
check-first alone is TOCTOU; the index makes the guard
structural."""
store = make_store(tmp_path)
with store._engine.connect() as conn:
row = conn.execute(
text(
"SELECT name, sql FROM sqlite_master "
"WHERE type='index' "
"AND tbl_name='employer_claims' "
"AND name='uq_pending_claim_per_entity'"
)
).fetchone()
assert row is not None
assert "UNIQUE INDEX" in str(row[1])
assert "WHERE status='pending'" in str(row[1])
class TestInsertClaim:
def test_first_claim_created_true_pending(
self, tmp_path: Path
) -> None:
store = make_store(tmp_path)
account_id = add_account(store, "founder@example.com")
claim_id, created = add_claim(
store, account_id=account_id, employer_id="emp-1"
)
assert created is True
row = store.get_claim(claim_id)
assert row is not None
assert row.status == "pending"
assert row.account_id == account_id
assert row.employer_id == "emp-1"
assert row.evidence == EVIDENCE
assert row.decided_by is None and row.decided_at is None
def test_second_pending_claim_rejected_fast_path(
self, tmp_path: Path
) -> None:
"""MH-2c arm 1: one PENDING claim per entity — the check-first
409 fast path (the insert_application precedent)."""
store = make_store(tmp_path)
first = add_account(store, "first@example.com")
second = add_account(store, "second@example.com")
first_id, created_first = add_claim(
store, account_id=first, employer_id="emp-1"
)
second_row, created_second = store.insert_claim(
account_id=second, employer_id="emp-1", evidence=EVIDENCE
)
assert created_first is True
assert created_second is False
assert second_row.id == first_id # the EXISTING row rides
assert count_pending(store, "emp-1") == 1
def test_raced_second_pending_insert_surfaces_integrity_error(
self, tmp_path: Path
) -> None:
"""G-316 pinned: a raced insert (check-first read misses, the
index refuses the write) SURFACES IntegrityError — the caller
maps it to the SAME honest 409; never a duplicate pending row.
Raw SQL via a direct Session simulates the race window after
the check-first read."""
store = make_store(tmp_path)
account_id = add_account(store, "founder@example.com")
add_claim(store, account_id=account_id, employer_id="emp-1")
with Session(store._engine) as session:
with pytest.raises(IntegrityError):
session.execute(
text(
"INSERT INTO employer_claims "
"(id, account_id, employer_id, status, evidence, "
"created_at) "
"VALUES ('clm-raced', :acct, 'emp-1', 'pending', "
"'{}', :now)"
),
{"acct": account_id, "now": datetime.now(UTC)},
)
session.flush()
session.rollback()
assert count_pending(store, "emp-1") == 1
def test_evidence_stored_as_provenance_json(
self, tmp_path: Path
) -> None:
"""The advisory evidence snapshot rides the claim row (D-089
— computed at claim time, never recomputed on read)."""
store = make_store(tmp_path)
account_id = add_account(store, "founder@example.com")
claim_id, _ = add_claim(
store,
account_id=account_id,
employer_id="emp-1",
evidence={
"account_email_domain": "acme.com",
"entity_website_domain": "acme.com",
"match": True,
},
)
with Session(store._engine) as session:
stored = session.execute(
text(
"SELECT evidence FROM employer_claims WHERE id = :cid"
),
{"cid": claim_id},
).scalar_one()
# The sa_type=JSON column round-trips through the ORM as a
# dict (insert_claim's own read path proves the stored shape);
# raw SQL returns the serialized JSON string — parse it.
assert json.loads(stored) == {
"account_email_domain": "acme.com",
"entity_website_domain": "acme.com",
"match": True,
}
# And the ORM read path hands the dict back.
row = store.get_claim(claim_id)
assert row is not None
assert row.evidence == {
"account_email_domain": "acme.com",
"entity_website_domain": "acme.com",
"match": True,
}
class TestDecideClaim:
def test_approve_sets_employer_id_and_audit_row(
self, tmp_path: Path
) -> None:
"""MH-2c/MH-2a store half: approve → employer_accounts.
employer_id set + the `claim` audit row with operator
provenance."""
store = make_store(tmp_path)
account_id = add_account(store, "founder@example.com")
claim_id, _ = add_claim(
store, account_id=account_id, employer_id="emp-1"
)
decided = store.decide_claim(
claim_id,
decision="approved",
operator="op-1",
now=datetime(2026, 9, 30, 12, 0, 0, tzinfo=UTC),
)
assert decided is not None
assert decided.status == "approved"
assert decided.decided_by == "op-1"
assert decided.decided_at == datetime(2026, 9, 30, 12, 0, 0, tzinfo=UTC)
account = store.get_account(account_id)
assert account is not None
assert account.employer_id == "emp-1"
with Session(store._engine) as session:
audit = session.execute(
text(
"SELECT kind, name, operator, detail "
"FROM employer_audit_events ORDER BY id"
)
).fetchall()
assert len(audit) == 1
kind, name, operator, detail = audit[0]
# Raw SQL hands the serialized JSON string back — parse it.
assert kind == "claim"
assert name == "claim.approved"
assert operator == "op-1"
assert json.loads(detail) == {
"claim_id": claim_id,
"employer_id": "emp-1",
"operator": "op-1",
}
def test_reject_sets_provenance_keeps_account_free(
self, tmp_path: Path
) -> None:
store = make_store(tmp_path)
account_id = add_account(store, "founder@example.com")
claim_id, _ = add_claim(
store, account_id=account_id, employer_id="emp-1"
)
decided = store.decide_claim(
claim_id,
decision="rejected",
operator="op-2",
now=datetime(2026, 9, 30, 12, 0, 0, tzinfo=UTC),
)
assert decided is not None
assert decided.status == "rejected"
assert decided.decided_by == "op-2"
assert decided.decided_at == datetime(2026, 9, 30, 12, 0, 0, tzinfo=UTC)
account = store.get_account(account_id)
assert account is not None
assert account.employer_id is None # rejection never binds
def test_rejection_frees_entity_for_new_claim(
self, tmp_path: Path
) -> None:
"""MH-2c arm 3: rejection frees the entity — a new claim on the
same entity succeeds after a reject."""
store = make_store(tmp_path)
first = add_account(store, "first@example.com")
second = add_account(store, "second@example.com")
first_id, _ = add_claim(
store, account_id=first, employer_id="emp-1"
)
store.decide_claim(
first_id, decision="rejected", operator="op-1"
)
second_id, created = add_claim(
store, account_id=second, employer_id="emp-1"
)
assert created is True
assert second_id != first_id
row = store.get_claim(second_id)
assert row is not None and row.status == "pending"
def test_second_approve_entity_already_owned_surfaces_integrity_error(
self, tmp_path: Path
) -> None:
"""MH-2c arm 2: one APPROVED owner per entity. Account A's
claim on emp-1 approves (A owns emp-1); account B then claims
the now-unblocked entity and the second approve collides on
employer_accounts.employer_id UNIQUE — B's UPDATE would bind a
second owner to emp-1. The raw-SQL arm below pins the
STRUCTURAL constraint itself; the decide_claim arm (next test)
pins the store path."""
store = make_store(tmp_path)
first = add_account(store, "first@example.com")
second = add_account(store, "second@example.com")
first_id, _ = add_claim(
store, account_id=first, employer_id="emp-1"
)
store.decide_claim(first_id, decision="approved", operator="op-1")
# B claims the entity after the pending window closed.
second_id, _ = add_claim(
store, account_id=second, employer_id="emp-1"
)
with pytest.raises(IntegrityError):
store.decide_claim(second_id, decision="approved", operator="op-2")
# The refused approve bound nothing: B still holds no entity,
# A still owns emp-1, and the second claim stays pending.
a = store.get_account(first)
b = store.get_account(second)
assert a is not None and a.employer_id == "emp-1"
assert b is not None and b.employer_id is None
still = store.get_claim(second_id)
assert still is not None and still.status == "pending"
assert count_pending(store, "emp-1") == 1
def test_decide_unknown_claim_miss(self, tmp_path: Path) -> None:
store = make_store(tmp_path)
assert (
store.decide_claim(
"clm-nope", decision="approved", operator="op-1"
)
is None
)
def test_decide_already_decided_claim_surfaces_integrity_error(
self, tmp_path: Path
) -> None:
"""Deciding a decided claim refuses — the hand-raised guard
(IntegrityError shape, the caller's honest 409)."""
store = make_store(tmp_path)
account_id = add_account(store, "founder@example.com")
claim_id, _ = add_claim(
store, account_id=account_id, employer_id="emp-1"
)
store.decide_claim(claim_id, decision="rejected", operator="op-1")
with pytest.raises(IntegrityError):
store.decide_claim(
claim_id, decision="approved", operator="op-1"
)
def test_approve_when_account_already_holds_entity_integrity_error(
self, tmp_path: Path
) -> None:
"""One ACCOUNT : one entity — approving B's claim when B
already holds emp-2 refuses (B's employer_id is set; the
hand-raised guard surfaces IntegrityError)."""
store = make_store(tmp_path)
account_id = add_account(store, "founder@example.com")
first_id, _ = add_claim(
store, account_id=account_id, employer_id="emp-2"
)
store.decide_claim(first_id, decision="approved", operator="op-1")
second_id, _ = add_claim(
store, account_id=account_id, employer_id="emp-9"
)
with pytest.raises(IntegrityError):
store.decide_claim(
second_id, decision="approved", operator="op-1"
)
def test_invalid_decision_rejected(self, tmp_path: Path) -> None:
store = make_store(tmp_path)
account_id = add_account(store, "founder@example.com")
claim_id, _ = add_claim(
store, account_id=account_id, employer_id="emp-1"
)
with pytest.raises(ValueError, match="invalid decision"):
store.decide_claim(
claim_id, decision="maybe", operator="op-1"
)
class TestListClaims:
def test_status_filter_and_newest_first(
self, tmp_path: Path
) -> None:
store = make_store(tmp_path)
a = add_account(store, "a@example.com")
b = add_account(store, "b@example.com")
add_claim(store, account_id=a, employer_id="emp-1")
add_claim(store, account_id=b, employer_id="emp-2")
pending = store.list_claims(status="pending")
assert len(pending) == 2
assert all(row.status == "pending" for row in pending)
# Newest first (created_at DESC).
first_created = pending[0].created_at
second_created = pending[1].created_at
assert first_created is not None and second_created is not None
assert first_created >= second_created
# Approve one, then the filter arms.
store.decide_claim(pending[0].id, decision="approved", operator="op-1")
assert len(store.list_claims(status="pending")) == 1
approved = store.list_claims(status="approved")
assert len(approved) == 1
assert approved[0].status == "approved"
assert len(store.list_claims()) == 2 # no filter → all
def test_get_claim_miss(self, tmp_path: Path) -> None:
store = make_store(tmp_path)
assert store.get_claim("clm-nope") is None
@@ -1,421 +0,0 @@
"""Own-postings read route tests — GET /v1/employers/postings
(Task 3-1-01, REQ-10-003, D-092/G-319).
Pinned here: own-ENTITY rows only (a second claimed employer's postings
never appear); unclaimed → 403 with the claim-CTA detail dict (the
D-090 authorization class — 401 stays credential failure); status
facets draft|published|closed with per-status counts; per-posting
applicant_count matching seeded applications (the batched
count_applications_by_posting — never a per-posting N+1); no-token
→ 401; the G-319 PII boundary — learner identifiers NEVER ride any
/v1/employers/* response (negative assertion over the full JSON).
REAL scrypt throughout (register mints a real hash — the conftest
fixtures handle limiter resets).
"""
from __future__ import annotations
import json
from typing import Any
from fastapi.testclient import TestClient
from ai_service.marketplace.base import Employer, Posting
from .conftest import bearer, make_client, register
OWN_ENTITY = "emp-self"
OTHER_ENTITY = "emp-rival"
#: Learner ids that seed applications — the PII the boundary bans.
LEARNER_IDS = ["learner-alpha", "learner-beta", "learner-gamma"]
def seed_posting(
client: TestClient,
posting_id: str,
employer_id: str,
*,
status: str = "draft",
) -> None:
"""Upsert a marketplace posting row through the app's own store
(app.state.marketplace_store — the same DI the route composes),
then drive the lifecycle to `status` via the store's transitions."""
store = client.app.state.marketplace_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
row = store.insert_posting(
Posting(
id=posting_id,
source="seed-corpus",
external_id=posting_id,
employer_id=employer_id,
title=f"Posting {posting_id}",
)
)
if status == "published":
store.set_posting_status(row.id, "published")
elif status == "closed":
store.set_posting_status(row.id, "published")
store.set_posting_status(row.id, "closed")
def seed_application(
client: TestClient, posting_id: str, learner_id: str
) -> None:
store = client.app.state.marketplace_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
store.insert_application(posting_id, learner_id)
def claim_and_approve(
client: TestClient, token: str, employer_id: str
) -> None:
"""File the claim + drive the operator approve (the store path the
2-2 admin route composes) — binds account.employer_id."""
resp = client.post(
"/v1/employers/claims",
json={"employer_id": employer_id},
headers=bearer(token),
)
assert resp.status_code == 201, resp.text
store = client.app.state.employer_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
decided = store.decide_claim(
resp.json()["id"], decision="approved", operator="op-1"
)
assert decided is not None and decided.status == "approved"
def seed_employer_rows(client: TestClient) -> None:
"""Both entities exist in the marketplace corpus (claim targets the
v0.6 ingested EmployerRow data)."""
store = client.app.state.marketplace_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
store.upsert_employer(
Employer(
id=OWN_ENTITY,
source="seed-corpus",
external_id=OWN_ENTITY,
name="Self Labs",
website="https://example.com/self",
)
)
store.upsert_employer(
Employer(
id=OTHER_ENTITY,
source="seed-corpus",
external_id=OTHER_ENTITY,
name="Rival Labs",
website="https://example.com/rival",
)
)
def get_own_postings(client: TestClient, token: str):
return client.get("/v1/employers/postings", headers=bearer(token))
class TestOwnPostingsRoute:
def test_own_entity_rows_only(self, client: TestClient) -> None:
"""MH-3a's route arm: the response carries ONLY the session
employer's postings — a second claimed employer's rows (all
statuses) never appear."""
seed_employer_rows(client)
token = register(client)["token"]
claim_and_approve(client, token, OWN_ENTITY)
# The OWN employer's postings, one per lifecycle status.
seed_posting(client, "own-draft", OWN_ENTITY, status="draft")
seed_posting(client, "own-pub", OWN_ENTITY, status="published")
seed_posting(client, "own-closed", OWN_ENTITY, status="closed")
# A SECOND claimed employer with postings of every status.
rival_token = register(client, email="rival@example.com")["token"]
claim_and_approve(client, rival_token, OTHER_ENTITY)
seed_posting(client, "rival-draft", OTHER_ENTITY, status="draft")
seed_posting(client, "rival-pub", OTHER_ENTITY, status="published")
resp = get_own_postings(client, token)
assert resp.status_code == 200, resp.text
body = resp.json()
ids = [p["id"] for p in body["postings"]]
assert sorted(ids) == ["own-closed", "own-draft", "own-pub"]
assert all(p["employer_id"] == OWN_ENTITY for p in body["postings"])
def test_unclaimed_403_claim_cta(self, client: TestClient) -> None:
"""D-090 403 semantics: a VALID session whose account holds no
claimed entity → 403 + the claim-CTA detail dict (never 401 —
the credential is fine; the authorization is not)."""
token = register(client)["token"]
resp = get_own_postings(client, token)
assert resp.status_code == 403, resp.text
detail = resp.json()["detail"]
assert detail["reason"] == "no_claimed_employer"
assert "claim" in detail["claim_cta"]
def test_no_token_401(self, client: TestClient) -> None:
resp = client.get("/v1/employers/postings")
assert resp.status_code == 401
def test_invalid_token_401(self, client: TestClient) -> None:
resp = get_own_postings(client, "not-a-real-token")
assert resp.status_code == 401
def test_status_facets_pinned(self, client: TestClient) -> None:
"""Per-status facet counts over the OWN postings — all three
lifecycle keys always present, honest zeros included; the rival
employer's rows contribute NOTHING (own-entity scope)."""
seed_employer_rows(client)
token = register(client)["token"]
claim_and_approve(client, token, OWN_ENTITY)
seed_posting(client, "own-a", OWN_ENTITY, status="draft")
seed_posting(client, "own-b", OWN_ENTITY, status="draft")
seed_posting(client, "own-c", OWN_ENTITY, status="published")
seed_posting(client, "own-d", OWN_ENTITY, status="closed")
rival_token = register(client, email="rival@example.com")["token"]
claim_and_approve(client, rival_token, OTHER_ENTITY)
seed_posting(client, "rival-a", OTHER_ENTITY, status="published")
resp = get_own_postings(client, token)
assert resp.status_code == 200
body = resp.json()
assert body["facets"] == {"draft": 2, "published": 1, "closed": 1}
assert body["total"] == 4
assert len(body["postings"]) == 4
def test_facets_zero_keys_honest(self, client: TestClient) -> None:
"""Every status key rides the response even at 0 (an employer
with zero postings still gets the full facet shape)."""
seed_employer_rows(client)
token = register(client)["token"]
claim_and_approve(client, token, OWN_ENTITY)
resp = get_own_postings(client, token)
assert resp.status_code == 200
body = resp.json()
assert body["postings"] == []
assert body["facets"] == {"draft": 0, "published": 0, "closed": 0}
assert body["total"] == 0
def test_applicant_count_matches_seeded_applications(
self, client: TestClient
) -> None:
"""applicant_count == the seeded application rows per posting
(the D-092 overview data source); zero-application postings
carry the honest 0."""
seed_employer_rows(client)
token = register(client)["token"]
claim_and_approve(client, token, OWN_ENTITY)
seed_posting(client, "own-pub", OWN_ENTITY, status="published")
seed_posting(client, "own-draft", OWN_ENTITY, status="draft")
seed_application(client, "own-pub", LEARNER_IDS[0])
seed_application(client, "own-pub", LEARNER_IDS[1])
seed_application(client, "own-draft", LEARNER_IDS[2])
# A rival posting's applications must not leak into counts.
rival_token = register(client, email="rival@example.com")["token"]
claim_and_approve(client, rival_token, OTHER_ENTITY)
seed_posting(client, "rival-pub", OTHER_ENTITY, status="published")
seed_application(client, "rival-pub", LEARNER_IDS[0])
resp = get_own_postings(client, token)
assert resp.status_code == 200
counts = {p["id"]: p["applicant_count"] for p in resp.json()["postings"]}
assert counts == {"own-pub": 2, "own-draft": 1}
def test_pii_never_rides_the_response(self, client: TestClient) -> None:
"""G-319 PII boundary (binding, negative assertion): the FULL
response JSON contains NO learner identifier — neither the
seeded learner ids nor any per-application datum beyond counts.
Employer-facing responses expose applicant COUNTS ONLY."""
seed_employer_rows(client)
token = register(client)["token"]
claim_and_approve(client, token, OWN_ENTITY)
seed_posting(client, "own-pub", OWN_ENTITY, status="published")
for learner_id in LEARNER_IDS:
seed_application(client, "own-pub", learner_id)
resp = get_own_postings(client, token)
assert resp.status_code == 200
payload: str = resp.text
for learner_id in LEARNER_IDS:
assert learner_id not in payload
# Belt + suspenders: the structured walk finds no per-application
# object anywhere in the tree (only applicant_count integers).
body: dict[str, Any] = resp.json()
assert json.dumps(body).count("applicant_count") == len(
body["postings"]
)
assert all(
isinstance(p["applicant_count"], int) for p in body["postings"]
)
class TestBatchedReadDiscipline:
def test_count_applications_by_posting_single_grouped_read(
self, tmp_path
) -> None:
"""G-319: the counts ride ONE grouped read for the whole id set
(never a per-posting N+1) — pinned at the STORE with an engine
event counter over a many-posting id set."""
from ai_service.marketplace.store import SQLiteMarketplaceStore
store = SQLiteMarketplaceStore(db_path=tmp_path / "marketplace.db")
try:
store.insert_posting(
Posting(
id="p1",
source="seed-corpus",
external_id="p1",
employer_id=OWN_ENTITY,
title="P1",
)
)
store.insert_posting(
Posting(
id="p2",
source="seed-corpus",
external_id="p2",
employer_id=OWN_ENTITY,
title="P2",
)
)
store.insert_application("p1", "learner-a")
store.insert_application("p1", "learner-b")
store.insert_application("p2", "learner-a")
# Count the SELECTs the batched read issues.
selects: list[str] = []
from sqlalchemy import event
@event.listens_for(store._engine, "before_cursor_execute")
def _capture(
conn: object,
cursor: object,
statement: str,
parameters: object,
context: object,
executemany: bool,
) -> None:
if statement.strip().upper().startswith("SELECT"):
selects.append(statement)
result = store.count_applications_by_posting(["p1", "p2", "p-missing"])
assert result == {"p1": 2, "p2": 1}
assert len(selects) == 1 # ONE grouped read, not N per-posting
assert "GROUP BY" in selects[0].upper()
finally:
store.close()
def test_empty_id_set_returns_empty_dict(self, tmp_path) -> None:
from ai_service.marketplace.store import SQLiteMarketplaceStore
store = SQLiteMarketplaceStore(db_path=tmp_path / "marketplace.db")
try:
assert store.count_applications_by_posting([]) == {}
finally:
store.close()
def test_list_postings_by_employer_all_statuses(
self, tmp_path
) -> None:
"""The owner's read sees the FULL lifecycle (draft rows are
visible to their owner, absent from every published-only path),
scoped to the employer_id, posted_at DESC then id."""
from ai_service.marketplace.store import SQLiteMarketplaceStore
store = SQLiteMarketplaceStore(db_path=tmp_path / "marketplace.db")
try:
for pid in ("own-2", "own-1"):
store.insert_posting(
Posting(
id=pid,
source="seed-corpus",
external_id=pid,
employer_id=OWN_ENTITY,
title=pid,
)
)
store.set_posting_status(pid, "published")
store.insert_posting(
Posting(
id="own-draft",
source="seed-corpus",
external_id="own-draft",
employer_id=OWN_ENTITY,
title="own-draft",
)
)
store.insert_posting(
Posting(
id="rival-1",
source="seed-corpus",
external_id="rival-1",
employer_id=OTHER_ENTITY,
title="rival-1",
)
)
store.set_posting_status("rival-1", "published")
rows = store.list_postings_by_employer(OWN_ENTITY)
assert [r.id for r in rows] == ["own-1", "own-2", "own-draft"]
assert {r.status for r in rows} == {"published", "draft"}
finally:
store.close()
class TestRouteDiscipline:
def test_marketplace_store_via_di_never_imported(self) -> None:
"""Territory rule: the route composes the marketplace store via
app.state DI (get_marketplace_store) — api/employers.py never
CONSTRUCTS a store. P4 (Task 4-1-02): the write routes compose
the admin routes' own imports (InvalidTransition, MarketplaceStore
annotations — the marketplace_admin.py:32 precedent), so the pin
asserts the CONSTRUCTION ban, not the import ban."""
import inspect
from ai_service.api import employers as employers_api
source = inspect.getsource(employers_api)
assert "SQLiteMarketplaceStore" not in source
assert "get_marketplace_store" in source
def test_route_registered_in_openapi(self, tmp_path) -> None:
"""The read route rides the mounted sibling router (main.py
wiring pin) — /v1/employers/postings exists in the OpenAPI
schema (the _IncludedRouter wrappers hide APIRoutes from
app.routes in this starlette, so the schema is the honest
registry). P4 (Task 4-1-02): the write routes share the path —
POST (create) joined; PATCH/publish/close ride the {id}
subpaths."""
with make_client(tmp_path) as client:
schema = client.get("/openapi.json").json()
path = schema["paths"]["/v1/employers/postings"]
assert set(path) == {"get", "post"}
assert set(
schema["paths"]["/v1/employers/postings/{posting_id}"]
) == {"patch"}
assert set(
schema["paths"][
"/v1/employers/postings/{posting_id}/publish"
]
) == {"post"}
assert set(
schema["paths"][
"/v1/employers/postings/{posting_id}/close"
]
) == {"post"}
def test_own_postings_contract_shapes(self) -> None:
"""G-319: OwnPostingSummary REUSES the PostingSummary wire shape
(every marketplace.base.Posting field present, unchanged) +
applicant_count; the response contract carries the list +
facets/total."""
from ai_service.employers.base import (
OwnPostingsFacets,
OwnPostingsResponse,
OwnPostingSummary,
)
from ai_service.marketplace.base import Posting
posting_fields = set(Posting.model_fields)
assert posting_fields < set(OwnPostingSummary.model_fields)
assert set(OwnPostingSummary.model_fields) - posting_fields == {
"applicant_count"
}
assert set(OwnPostingsFacets.model_fields) == {
"draft",
"published",
"closed",
}
assert set(OwnPostingsResponse.model_fields) == {
"postings",
"facets",
"total",
}
@@ -1,445 +0,0 @@
"""Own-postings write routes (Task 4-1-02, REQ-10-004, G-311/317/318).
Pins the employer-principal posting lifecycle: create consumes from
the employer's OWN package through the SHARED entitlement gate (the
D-091 helper — the no-drift proof rides the admin/employer payload
parity asserted in tests/billing/test_entitlement_parity.py); the
G-311 gate ORDER (session 401 → claim 403 → entitlement 402 →
insert); the G-317 employer provenance stamp; the G-318 own-drafts
edit guard with the identical admin re-tag composition.
Fixtures: the conftest make_client/register/bearer + the
test_postings_read.py claim_and_approve/seed precedents; the billing
grant via the test_entitlement_parity.py grant_posts shape.
"""
from __future__ import annotations
from collections.abc import Iterator
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from ai_service.billing.store import SQLiteBillingStore
from ai_service.marketplace.base import Employer
from ..billing.test_entitlement_parity import grant_posts
from .conftest import bearer, make_client, register
OWN_ENTITY = "emp-write-self"
OTHER_ENTITY = "emp-write-rival"
EMPLOYER_CTA_NO_PACKAGE = (
"This employer has no active posting package — "
"purchase one via /v1/employers/billing/checkout."
)
EMPLOYER_CTA_EXHAUSTED = (
"This employer's posting package is exhausted — "
"buy more posts or upgrade via /v1/employers/billing/checkout."
)
def seed_employer_rows(client: TestClient) -> None:
"""Both entities exist in the marketplace corpus (claim targets
the v0.6 ingested EmployerRow data)."""
store = client.app.state.marketplace_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
store.upsert_employer(
Employer(
id=OWN_ENTITY,
source="seed-corpus",
external_id=OWN_ENTITY,
name="Write Labs",
website="https://example.com/write",
)
)
store.upsert_employer(
Employer(
id=OTHER_ENTITY,
source="seed-corpus",
external_id=OTHER_ENTITY,
name="Rival Labs",
website="https://example.com/rival",
)
)
def claim_and_approve(
client: TestClient, token: str, employer_id: str
) -> None:
"""File the claim + drive the operator approve (the store path
the 2-2 admin route composes) — binds account.employer_id."""
resp = client.post(
"/v1/employers/claims",
json={"employer_id": employer_id},
headers=bearer(token),
)
assert resp.status_code == 201, resp.text
store = client.app.state.employer_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
decided = store.decide_claim(
resp.json()["id"], decision="approved", operator="op-1"
)
assert decided is not None and decided.status == "approved"
def create_own_posting(
client: TestClient, token: str, title: str = "Owned Role"
):
return client.post(
"/v1/employers/postings",
headers=bearer(token),
json={"title": title, "description": "g", "skills": ["python"]},
)
@pytest.fixture()
def billing_store(tmp_path: Path) -> Iterator[SQLiteBillingStore]:
store = SQLiteBillingStore(db_path=tmp_path / "billing.db")
yield store
store.close()
@pytest.fixture()
def claimed(
tmp_path: Path, billing_store: SQLiteBillingStore
) -> Iterator[tuple[TestClient, str]]:
"""One registered + claimed-and-approved employer session with the
billing store state-injected (G-9). The with-block runs the
lifespan so app.state stores wire before any seeding."""
with make_client(tmp_path) as client:
client.app.state.billing_store = billing_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
seed_employer_rows(client)
token = register(client)["token"]
claim_and_approve(client, token, OWN_ENTITY)
yield client, token
def _raw_posts_used(store: SQLiteBillingStore, employer_id: str) -> int:
"""The raw row read carries the store truth an exhausted purchase
hides from get_entitlement (the parity-test precedent)."""
from sqlalchemy import text
from sqlmodel import Session
with Session(store._engine) as session:
used = session.execute(
text(
"SELECT posts_used FROM billing_purchases "
"WHERE employer_id = :eid ORDER BY created_at DESC"
),
{"eid": employer_id},
).scalar()
assert used is not None # the row exists (granted in-test)
return int(used)
class TestCreateOwnPosting:
def test_create_consumes_own_package(
self,
claimed: tuple[TestClient, str],
billing_store: SQLiteBillingStore,
) -> None:
"""The employer's OWN package is charged (posts_used 0 → 1 via
the raw row read) and the response carries the G-317 stamp."""
client, token = claimed
grant_posts(billing_store, OWN_ENTITY, 2)
resp = create_own_posting(client, token)
assert resp.status_code == 201, resp.text
body = resp.json()
assert body["source"] == "employer"
assert body["id"].startswith("emp-")
assert body["external_id"].startswith("emp-")
assert body["employer_id"] == OWN_ENTITY
assert body["status"] == "draft"
assert _raw_posts_used(billing_store, OWN_ENTITY) == 1
def test_no_package_402_exact_dict(
self,
claimed: tuple[TestClient, str],
billing_store: SQLiteBillingStore,
) -> None:
"""No package → the canonical 402 dict with the EMPLOYER CTA
(the D-091 payload parity, employer arm, through the route)."""
client, token = claimed
resp = create_own_posting(client, token)
assert resp.status_code == 402
assert resp.json()["detail"] == {
"reason": "no_active_package",
"posts_used": 0,
"posts_limit": 0,
"upgrade_cta": EMPLOYER_CTA_NO_PACKAGE,
}
def test_exhausted_402_exact_dict(
self,
claimed: tuple[TestClient, str],
billing_store: SQLiteBillingStore,
) -> None:
"""All posts spent → the exhausted arm with the EMPLOYER CTA."""
client, token = claimed
grant_posts(billing_store, OWN_ENTITY, 1)
assert create_own_posting(client, token).status_code == 201
denied = create_own_posting(client, token, title="Second Role")
assert denied.status_code == 402
assert denied.json()["detail"] == {
"reason": "posting_limit_reached",
"posts_used": 0,
"posts_limit": 0,
"upgrade_cta": EMPLOYER_CTA_EXHAUSTED,
}
def test_402_consumes_nothing_creates_nothing(
self,
claimed: tuple[TestClient, str],
billing_store: SQLiteBillingStore,
) -> None:
"""R-58 gate-before-insert: the denied create leaves
posts_used unchanged and inserts no marketplace row."""
client, token = claimed
marketplace_store = client.app.state.marketplace_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
grant_posts(billing_store, OWN_ENTITY, 1)
assert create_own_posting(client, token).status_code == 201
before = marketplace_store.list_postings_by_employer(OWN_ENTITY)
assert len(before) == 1
denied = create_own_posting(client, token, title="Denied Role")
assert denied.status_code == 402
assert _raw_posts_used(billing_store, OWN_ENTITY) == 1
after = marketplace_store.list_postings_by_employer(OWN_ENTITY)
assert len(after) == 1
assert [p.id for p in after] == [p.id for p in before]
def test_unlimited_grant_bypasses_402(
self,
claimed: tuple[TestClient, str],
billing_store: SQLiteBillingStore,
) -> None:
"""An unlimited (enterprise) manual grant never 402s (the
admin-route bypass precedent, employer arm)."""
client, token = claimed
grant_posts(billing_store, OWN_ENTITY, None)
for i in range(3):
assert (
create_own_posting(client, token, title=f"Role {i}").status_code
== 201
)
def test_no_token_401_never_402(self, tmp_path: Path) -> None:
"""G-311 order: unauthenticated → 401, never a 402 (the
entitlement state never leaks to anonymous callers)."""
with make_client(tmp_path) as client:
resp = client.post(
"/v1/employers/postings", json={"title": "X"}
)
assert resp.status_code == 401
def test_unclaimed_403_never_402(self, tmp_path: Path) -> None:
"""G-311 order: unclaimed session → 403 + claim-CTA, never a
402 (a logged-out visitor must not see an upgrade CTA)."""
with make_client(tmp_path) as client:
token = register(client)["token"]
resp = client.post(
"/v1/employers/postings",
headers=bearer(token),
json={"title": "X"},
)
assert resp.status_code == 403
assert resp.json()["detail"]["reason"] == "no_claimed_employer"
class TestEditOwnPosting:
def test_edit_retags_on_mock_provider(
self, claimed: tuple[TestClient, str], billing_store: SQLiteBillingStore
) -> None:
"""G-318: the edit composes the IDENTICAL update_posting_fields
+ tag_posting re-tag path as the admin edit (D-052 — derived
tags). The plain MockProvider's canned reply does not validate
as PostingTags → the re-tag fails honestly (the admin suite's
test_edit_retags_and_old_tags_gone contract): the edit LANDS,
pre-existing tags are KEPT, never fabricated — and the G-317
stamp survives the edit."""
from ai_service.marketplace.base import PostingTags
client, token = claimed
grant_posts(billing_store, OWN_ENTITY, 1)
created = create_own_posting(client, token).json()
store = client.app.state.marketplace_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
store.set_posting_tags(
created["id"],
PostingTags(skills=["stale-skill"], seniority=None, category=None),
)
resp = client.patch(
f"/v1/employers/postings/{created['id']}",
headers=bearer(token),
json={"title": "Edited Title", "skills": ["typescript"]},
)
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["title"] == "Edited Title"
assert body["source"] == "employer"
stored = store.get_posting(created["id"])
assert stored is not None
assert stored.title == "Edited Title"
assert stored.skills == ["typescript"]
# Honest tag failure: the edit stands, the OLD tags remain
# (never erased by a bad parse, never fabricated).
assert stored.tags is not None
assert stored.tags.skills == ["stale-skill"]
def test_edit_published_409_no_side_effects(
self, claimed: tuple[TestClient, str], billing_store: SQLiteBillingStore
) -> None:
"""G-318: the own-drafts guard evaluates BEFORE any mutation —
editing a PUBLISHED posting 409s with fields AND tags
untouched."""
client, token = claimed
grant_posts(billing_store, OWN_ENTITY, 1)
created = create_own_posting(client, token).json()
assert (
client.post(
f"/v1/employers/postings/{created['id']}/publish",
headers=bearer(token),
).status_code
== 200
)
resp = client.patch(
f"/v1/employers/postings/{created['id']}",
headers=bearer(token),
json={"title": "Must Not Land"},
)
assert resp.status_code == 409
store = client.app.state.marketplace_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
stored = store.get_posting(created["id"])
assert stored is not None
assert stored.title == "Owned Role" # unchanged
assert stored.status == "published"
def test_edit_cross_entity_403(
self, claimed: tuple[TestClient, str]
) -> None:
"""D-090 authorization arm: another entity's posting → 403."""
client, token = claimed
store = client.app.state.marketplace_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
rival_row = store.insert_posting(
__import__(
"ai_service.marketplace.base", fromlist=["Posting"]
).Posting(
id="rival-posting-1",
source="seed-corpus",
external_id="rival-posting-1",
employer_id=OTHER_ENTITY,
title="Rival Role",
)
)
resp = client.patch(
f"/v1/employers/postings/{rival_row.id}",
headers=bearer(token),
json={"title": "Hostile Takeover"},
)
assert resp.status_code == 403
assert store.get_posting(rival_row.id).title == "Rival Role"
def test_edit_unknown_404(
self, claimed: tuple[TestClient, str]
) -> None:
client, token = claimed
resp = client.patch(
"/v1/employers/postings/does-not-exist",
headers=bearer(token),
json={"title": "X"},
)
assert resp.status_code == 404
class TestPublishCloseLifecycle:
def test_publish_close_applicants_accrue(
self, claimed: tuple[TestClient, str], billing_store: SQLiteBillingStore
) -> None:
"""draft → publish (posted_at stamped, hooks composed) → an
application lands → close; the read route's applicant_count
reflects the application (the P3 batched read over the
lifecycle)."""
client, token = claimed
grant_posts(billing_store, OWN_ENTITY, 1)
created = create_own_posting(client, token).json()
published = client.post(
f"/v1/employers/postings/{created['id']}/publish",
headers=bearer(token),
)
assert published.status_code == 200, published.text
assert published.json()["status"] == "published"
store = client.app.state.marketplace_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
stored = store.get_posting(created["id"])
assert stored is not None and stored.posted_at is not None
store.insert_application(created["id"], "learner-alpha")
listing = client.get(
"/v1/employers/postings", headers=bearer(token)
)
assert listing.status_code == 200
own = listing.json()["postings"]
assert [p for p in own if p["id"] == created["id"]][0][
"applicant_count"
] == 1
closed = client.post(
f"/v1/employers/postings/{created['id']}/close",
headers=bearer(token),
)
assert closed.status_code == 200
assert closed.json()["status"] == "closed"
def test_publish_published_409(
self, claimed: tuple[TestClient, str], billing_store: SQLiteBillingStore
) -> None:
"""Re-publishing a published posting → 409 (the store's
transition matrix, honestly surfaced)."""
client, token = claimed
grant_posts(billing_store, OWN_ENTITY, 1)
created = create_own_posting(client, token).json()
first = client.post(
f"/v1/employers/postings/{created['id']}/publish",
headers=bearer(token),
)
assert first.status_code == 200
second = client.post(
f"/v1/employers/postings/{created['id']}/publish",
headers=bearer(token),
)
assert second.status_code == 409
def test_close_draft_409(
self, claimed: tuple[TestClient, str], billing_store: SQLiteBillingStore
) -> None:
"""Closing a DRAFT → 409 (published → closed is the only valid
move)."""
client, token = claimed
grant_posts(billing_store, OWN_ENTITY, 1)
created = create_own_posting(client, token).json()
resp = client.post(
f"/v1/employers/postings/{created['id']}/close",
headers=bearer(token),
)
assert resp.status_code == 409
def test_publish_cross_entity_403(
self, claimed: tuple[TestClient, str]
) -> None:
"""The own-entity guard on publish/close too (D-090)."""
client, token = claimed
store = client.app.state.marketplace_store # type: ignore[attr-defined] # P5: TestClient.app is typed ASGIApp; runtime FastAPI
rival_row = store.insert_posting(
__import__(
"ai_service.marketplace.base", fromlist=["Posting"]
).Posting(
id="rival-posting-2",
source="seed-corpus",
external_id="rival-posting-2",
employer_id=OTHER_ENTITY,
title="Rival Draft",
)
)
resp = client.post(
f"/v1/employers/postings/{rival_row.id}/publish",
headers=bearer(token),
)
assert resp.status_code == 403
@@ -1,116 +0,0 @@
"""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}
@@ -1,249 +0,0 @@
"""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
@@ -1,298 +0,0 @@
"""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"
@@ -1,283 +0,0 @@
"""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}]
@@ -1,260 +0,0 @@
"""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
@@ -1,226 +0,0 @@
"""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
@@ -1,220 +0,0 @@
"""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},
]
@@ -1,585 +0,0 @@
"""Placement store tests (D-111, REQ-11-001) — the ledger's adversarial
suite: chain verification, tamper, fork, concurrency, no-delete-path,
the D-099 matrix, the D-102 revoke/re-hire lane.
Real stores on tmp_path DBs, zero network by construction (no
MockTransport needed — no external calls exist), real scrypt-free
paths, honest assertions (behavior over bytes).
"""
from __future__ import annotations
import sqlite3
import uuid
from datetime import UTC, date, datetime, timedelta, timezone
from pathlib import Path
import pytest
from sqlalchemy.exc import IntegrityError
from sqlmodel import Session
from ai_service.marketplace.base import Posting
from ai_service.marketplace.store import (
ApplicationRow,
SQLiteMarketplaceStore,
)
from ai_service.placements.base import (
APPLICATION_STATUSES,
GENESIS_HASH,
TRANSITION_MATRIX,
InvalidTransition,
validate_transition,
)
from ai_service.placements.store import PlacementEventRow, SQLitePlacementStore
# -- helpers -----------------------------------------------------------------
def _seed_posting(store: SQLiteMarketplaceStore, employer_id: str = "emp-test") -> str:
"""Insert a draft posting via the store's own path → its id."""
posting = store.insert_posting(
Posting(
id=f"post-{uuid.uuid4().hex[:12]}",
source="admin",
external_id=f"test-{uuid.uuid4().hex[:8]}",
employer_id=employer_id,
title="AI Orchestration Engineer",
description="Test posting",
required_competencies=["orchestration"],
skills=["python"],
)
)
return posting.id
def _apply(store: SQLiteMarketplaceStore, posting_id: str, learner_id: str) -> ApplicationRow:
application, _created = store.insert_application(posting_id, learner_id)
with Session(store._engine) as session:
row = session.get(ApplicationRow, application.id)
assert row is not None
return row
def _hire(
store: SQLitePlacementStore,
application_id: str,
actor: str = "operator",
now: datetime | None = None,
) -> object:
return store.transition_application(
application_id,
"hired",
actor_type="operator",
actor_ref=actor,
role_title="AI Orchestration Engineer",
start_date=date(2026, 11, 1),
now=now,
)
# -- D-099 matrix (base contracts) ---------------------------------------------
def test_genesis_hash_pinned() -> None:
assert GENESIS_HASH == "0" * 64
def test_transition_matrix_forward_only_with_skip_forward() -> None:
# forward legal
assert "in_review" in TRANSITION_MATRIX["submitted"]
# skip-forward legal (the fast-track hire — R-71)
assert "hired" in TRANSITION_MATRIX["submitted"]
assert "interview" in TRANSITION_MATRIX["submitted"]
# terminals empty
assert TRANSITION_MATRIX["hired"] == ()
assert TRANSITION_MATRIX["rejected"] == ()
assert TRANSITION_MATRIX["withdrawn"] == ()
# every status is a matrix key
for status in APPLICATION_STATUSES:
assert status in TRANSITION_MATRIX
def test_validate_transition_arms() -> None:
validate_transition("submitted", "hired") # skip-forward
validate_transition("in_review", "rejected")
with pytest.raises(InvalidTransition):
validate_transition("offer", "in_review") # backward — illegal
with pytest.raises(InvalidTransition):
validate_transition("hired", "rejected") # terminal-source
with pytest.raises(InvalidTransition):
validate_transition("submitted", "garbage") # unknown target
with pytest.raises(InvalidTransition):
validate_transition("garbage", "hired") # unknown source
# -- schema pins ---------------------------------------------------------------
def test_tables_exist_in_sqlite_master(placement_store: SQLitePlacementStore) -> None:
with sqlite3.connect(placement_store._db_path) as conn:
rows = {
name
for (name,) in conn.execute(
"SELECT name FROM sqlite_master WHERE type='table'"
)
}
assert "placement" in rows
assert "placement_event" in rows
def test_partial_unique_index_pinned(placement_store: SQLitePlacementStore) -> None:
"""G-316 shape: the partial UNIQUE index exists in sqlite_master
(the test_claims_store template)."""
with sqlite3.connect(placement_store._db_path) as conn:
rows = {
sql
for (sql,) in conn.execute(
"SELECT sql FROM sqlite_master WHERE type='index' AND name=?",
("uq_active_placement_per_application",),
)
}
assert rows, "partial index missing"
sql = rows.pop()
assert "CREATE UNIQUE INDEX" in sql
assert "status='active'" in sql
def test_no_delete_path_in_source() -> None:
"""R-70: the module carries NO delete method and NO DELETE SQL —
deleting the top ledger row would make INTEGER PRIMARY KEY reuse
max+1 (chain-integrity hole)."""
from ai_service.placements import store as placements_store_module
source = Path(placements_store_module.__file__).read_text()
assert "DELETE FROM" not in source
assert "def delete" not in source
assert "session.delete" not in source
# -- the transition + chain -----------------------------------------------------
def test_transition_flow_creates_chained_events(
marketplace_store: SQLiteMarketplaceStore,
placement_store: SQLitePlacementStore,
) -> None:
posting_id = _seed_posting(marketplace_store)
application = _apply(marketplace_store, posting_id, "pilot-learner")
app1, placement_none = placement_store.transition_application(
application.id, "in_review", actor_type="employer", actor_ref="account:a1"
)
assert app1.status == "in_review"
assert placement_none is None
app2, _ = placement_store.transition_application(
application.id, "interview", actor_type="employer", actor_ref="account:a1"
)
assert app2.status == "interview"
app3, placement = placement_store.transition_application(
application.id,
"hired",
actor_type="employer",
actor_ref="account:a1",
role_title="AI Orchestration Engineer",
start_date=date(2026, 11, 1),
)
assert app3.status == "hired"
assert placement is not None
assert placement.status == "active"
assert placement.role_title == "AI Orchestration Engineer"
assert placement.start_date == "2026-11-01"
assert placement.learner_id == "pilot-learner"
assert placement.employer_id == "emp-test"
events = placement_store.get_application_events(application.id)
assert [e.event_type for e in events] == [
"application.in_review",
"application.interview",
"application.hired",
"placement.created",
]
# the chain walks green end-to-end
ok, head = placement_store.verify_chain()
assert ok is True
assert head == events[-1].hash
def test_hire_requires_role_title_and_start_date(
marketplace_store: SQLiteMarketplaceStore,
placement_store: SQLitePlacementStore,
) -> None:
posting_id = _seed_posting(marketplace_store)
application = _apply(marketplace_store, posting_id, "pilot-learner")
with pytest.raises(ValueError, match="role_title"):
placement_store.transition_application(
application.id, "hired", actor_type="employer", actor_ref="account:a1"
)
with pytest.raises(ValueError, match="start_date"):
placement_store.transition_application(
application.id,
"hired",
actor_type="employer",
actor_ref="account:a1",
role_title="Engineer",
)
def test_matrix_illegal_moves_raise(
marketplace_store: SQLiteMarketplaceStore,
placement_store: SQLitePlacementStore,
) -> None:
posting_id = _seed_posting(marketplace_store)
application = _apply(marketplace_store, posting_id, "pilot-learner")
placement_store.transition_application(
application.id, "offer", actor_type="employer", actor_ref="account:a1"
)
# backward — 409 class
with pytest.raises(InvalidTransition):
placement_store.transition_application(
application.id, "in_review", actor_type="employer", actor_ref="account:a1"
)
# terminal-source after hire
placement_store.transition_application(
application.id,
"hired",
actor_type="employer",
actor_ref="account:a1",
role_title="Engineer",
start_date=date(2026, 12, 1),
)
with pytest.raises(InvalidTransition):
placement_store.transition_application(
application.id, "rejected", actor_type="employer", actor_ref="account:a1"
)
# unknown application
with pytest.raises(InvalidTransition):
placement_store.transition_application(
"app-nonexistent", "in_review", actor_type="operator", actor_ref="operator"
)
def test_unknown_status_rejected(
marketplace_store: SQLiteMarketplaceStore,
placement_store: SQLitePlacementStore,
) -> None:
posting_id = _seed_posting(marketplace_store)
application = _apply(marketplace_store, posting_id, "pilot-learner")
with pytest.raises(InvalidTransition):
placement_store.transition_application(
application.id, "garbage", actor_type="operator", actor_ref="operator"
)
def test_tz_round_trip_canonical_stable(
marketplace_store: SQLiteMarketplaceStore,
placement_store: SQLitePlacementStore,
) -> None:
"""R-79: naive stored → _as_utc read → canonical isoformat stable —
the hashing path and the reading path share canonical_json()."""
posting_id = _seed_posting(marketplace_store)
application = _apply(marketplace_store, posting_id, "pilot-learner")
moment = datetime(2026, 10, 1, 12, 0, 0, tzinfo=UTC)
placement_store.transition_application(
application.id,
"in_review",
actor_type="employer",
actor_ref="account:a1",
now=moment,
)
events = placement_store.list_events()
assert len(events) == 1
raw = events[0]
# the canonical form is a pure function of the row — recompute twice
assert raw.canonical_json() == raw.canonical_json()
ok, _ = placement_store.verify_chain()
assert ok is True
def test_aware_non_utc_now_converted_chain_stays_green(
marketplace_store: SQLiteMarketplaceStore,
placement_store: SQLitePlacementStore,
) -> None:
"""P0-1 (security review): an aware NON-UTC `now` CONVERTS to the
true instant — the append-time and read-back canonical forms are
identical, so the chain walks green (a re-labeled offset would
hash 12:00-04:00 and re-read 12:00+00:00 — permanently broken)."""
posting_id = _seed_posting(marketplace_store)
application = _apply(marketplace_store, posting_id, "pilot-learner")
edt = timezone(timedelta(hours=-4))
placement_store.transition_application(
application.id,
"in_review",
actor_type="employer",
actor_ref="account:a1",
now=datetime(2026, 10, 1, 12, 0, 0, tzinfo=edt),
)
ok, _ = placement_store.verify_chain()
assert ok is True
def test_actor_type_vocabulary_enforced(
marketplace_store: SQLiteMarketplaceStore,
placement_store: SQLitePlacementStore,
) -> None:
"""P1-1 (security review): the store boundary rejects an actor_type
outside the closed vocabulary — a bad label would be frozen in
immutable history."""
posting_id = _seed_posting(marketplace_store)
application = _apply(marketplace_store, posting_id, "pilot-learner")
with pytest.raises(ValueError, match="actor_type"):
placement_store.transition_application(
application.id,
"in_review",
actor_type="ghost-actor",
actor_ref="account:a1",
)
def test_payload_floats_rejected(
marketplace_store: SQLiteMarketplaceStore,
placement_store: SQLitePlacementStore,
) -> None:
"""R-69: payload values str/int/bool/None only — floats REJECTED."""
posting_id = _seed_posting(marketplace_store)
application = _apply(marketplace_store, posting_id, "pilot-learner")
session = Session(placement_store._engine)
try:
row = PlacementEventRow(
application_id=application.id,
placement_id=None,
event_type="application.in_review",
actor_type="employer",
actor_ref="account:a1",
payload={"ratio": 0.5},
prev_hash=GENESIS_HASH,
hash="",
created_at=datetime.now(UTC),
)
with pytest.raises(ValueError, match="str/int/bool/None"):
row._validate()
finally:
session.close()
# -- tamper / fork / concurrency (the adversarial arms, D-111) ------------------
def test_tamper_detected_via_second_engine(
tmp_path: Path,
marketplace_store: SQLiteMarketplaceStore,
placement_store: SQLitePlacementStore,
) -> None:
"""Direct row mutation through a SECOND engine on the same file →
the verification walk FAILS (R-78: the mutation commits before the
walk reads — WAL snapshot isolation)."""
posting_id = _seed_posting(marketplace_store)
application = _apply(marketplace_store, posting_id, "pilot-learner")
placement_store.transition_application(
application.id, "in_review", actor_type="employer", actor_ref="account:a1"
)
ok_before, _ = placement_store.verify_chain()
assert ok_before is True
with sqlite3.connect(placement_store._db_path) as conn:
conn.execute(
"UPDATE placement_event SET actor_ref = 'account:evil' WHERE id = 1"
)
conn.commit()
ok_after, _ = placement_store.verify_chain()
assert ok_after is False
def test_fork_prev_hash_surfaces_integrity_error(
placement_store: SQLitePlacementStore,
) -> None:
"""G-316 backstop class: a second row claiming the same prev_hash
→ UNIQUE refuses it, IntegrityError SURFACES."""
with pytest.raises(IntegrityError):
with Session(placement_store._engine) as session:
for _i in range(2):
row = PlacementEventRow(
application_id="app-x",
placement_id=None,
event_type="application.in_review",
actor_type="employer",
actor_ref="account:a1",
payload={},
prev_hash=GENESIS_HASH,
hash="",
created_at=datetime.now(UTC),
)
session.add(row)
session.flush()
row.hash = "computed-later"
session.flush()
session.commit()
def test_concurrent_transition_one_winner(
marketplace_store: SQLiteMarketplaceStore,
tmp_path: Path,
) -> None:
"""R-78: two stores (separate engines) race the same application —
one winner commits; the loser surfaces an honest error (never two
events on one prev_hash)."""
db_path = tmp_path / "shared.db"
store_a = SQLitePlacementStore(db_path=db_path)
store_b = SQLitePlacementStore(db_path=db_path)
marketplace = SQLiteMarketplaceStore(db_path=db_path)
try:
posting_id = _seed_posting(marketplace)
application = _apply(marketplace, posting_id, "pilot-learner")
# both read the same submitted status; A commits first
store_a.transition_application(
application.id, "in_review", actor_type="employer", actor_ref="account:a"
)
with pytest.raises((InvalidTransition, IntegrityError)):
store_b.transition_application(
application.id, "in_review", actor_type="employer", actor_ref="account:b"
)
ok, _ = store_a.verify_chain()
assert ok is True
# exactly ONE in_review event exists
events = store_a.get_application_events(application.id)
assert [e.event_type for e in events] == ["application.in_review"]
finally:
store_a.close()
store_b.close()
marketplace.close()
# -- the D-102 correction lane ---------------------------------------------------
def test_revoke_and_rehire(
marketplace_store: SQLiteMarketplaceStore,
placement_store: SQLitePlacementStore,
) -> None:
posting_id = _seed_posting(marketplace_store)
application = _apply(marketplace_store, posting_id, "pilot-learner")
_, placement = placement_store.transition_application(
application.id,
"hired",
actor_type="employer",
actor_ref="account:a1",
role_title="Engineer",
start_date=date(2026, 11, 1),
)
assert placement is not None
# revoke: active → revoked + event
revoked = placement_store.revoke_placement(placement.id, "operator")
assert revoked is not None and revoked.status == "revoked"
# rehire after revoke: a FRESH active row (R-80) — decided_at =
# the re-hire event's stamp (G-326)
moment = datetime(2026, 10, 2, 9, 30, 0, tzinfo=UTC)
rehired = placement_store.rehire_placement(
application.id,
role_title="Engineer II",
start_date=date(2026, 12, 15),
actor_ref="operator",
now=moment,
)
assert rehired.status == "active"
assert rehired.id != placement.id
assert rehired.decided_at == moment
# the three-event ledger sequence: hired → revoked → rehired
events = placement_store.get_application_events(application.id)
assert [e.event_type for e in events] == [
"application.hired",
"placement.created",
"placement.revoked",
"placement.rehired",
]
# the revoked placement stays in history (D-102)
statuses = {p.status for p in placement_store.list_placements()}
assert statuses == {"active", "revoked"}
ok, _ = placement_store.verify_chain()
assert ok is True
def test_revoke_only_active(
marketplace_store: SQLiteMarketplaceStore,
placement_store: SQLitePlacementStore,
) -> None:
posting_id = _seed_posting(marketplace_store)
application = _apply(marketplace_store, posting_id, "pilot-learner")
_, placement = placement_store.transition_application(
application.id,
"hired",
actor_type="employer",
actor_ref="account:a1",
role_title="Engineer",
start_date=date(2026, 11, 1),
)
assert placement is not None
placement_store.revoke_placement(placement.id, "operator")
with pytest.raises(InvalidTransition):
placement_store.revoke_placement(placement.id, "operator")
assert placement_store.revoke_placement("plc-nonexistent", "operator") is None
def test_rehire_requires_hired_application(
marketplace_store: SQLiteMarketplaceStore,
placement_store: SQLitePlacementStore,
) -> None:
posting_id = _seed_posting(marketplace_store)
application = _apply(marketplace_store, posting_id, "pilot-learner")
# not hired yet → re-hire is the correction lane for a hired app only
with pytest.raises(InvalidTransition):
placement_store.rehire_placement(
application.id,
role_title="Engineer",
start_date=date(2026, 11, 1),
actor_ref="operator",
)
def test_rehire_second_active_surfaces_integrity_error(
marketplace_store: SQLiteMarketplaceStore,
placement_store: SQLitePlacementStore,
) -> None:
"""The partial-unique arm: re-hire WITHOUT revoke → the partial
UNIQUE index refuses the second ACTIVE row (the caller's honest
409)."""
posting_id = _seed_posting(marketplace_store)
application = _apply(marketplace_store, posting_id, "pilot-learner")
placement_store.transition_application(
application.id,
"hired",
actor_type="employer",
actor_ref="account:a1",
role_title="Engineer",
start_date=date(2026, 11, 1),
)
with pytest.raises(IntegrityError):
placement_store.rehire_placement(
application.id,
role_title="Engineer II",
start_date=date(2026, 12, 1),
actor_ref="operator",
)
def test_chain_walk_after_n_appends(
marketplace_store: SQLiteMarketplaceStore,
placement_store: SQLitePlacementStore,
) -> None:
posting_id = _seed_posting(marketplace_store)
application = _apply(marketplace_store, posting_id, "pilot-learner")
for to_status in ("in_review", "interview", "offer", "rejected"):
placement_store.transition_application(
application.id, to_status, actor_type="employer", actor_ref="account:a1"
)
events = placement_store.list_events()
assert len(events) == 4
ok, head = placement_store.verify_chain()
assert ok is True
assert head == events[-1].hash
@@ -1,6 +1,5 @@
import { ModerationTabs } from '../../../../components/admin/moderation-tabs';
import { EmployerEntitlementPanel } from '../../../../components/admin/employer-entitlement-panel';
import { EmployerNetworkPanel } from '../../../../components/admin/employer-network-panel';
export default function MarketplaceModerationPage() {
return (
@@ -22,18 +21,6 @@ export default function MarketplaceModerationPage() {
</h2>
<EmployerEntitlementPanel employerId="emp-openai" />
</section>
{/* v0.10 P5 (REQ-10-005, D-094): the employer network overview —
* sibling sections BELOW the v0.9 entitlement panel. Employer
* accounts (email, claimed entity), the claim queue (evidence
* rendered ADVISORY, A-304; explicit approve/reject), and the
* audit trail — fed by the P2 operator routes over the admin
* transport. */}
<section className="flex flex-col gap-3">
<h2 className="text-lg font-semibold text-slate-900 dark:text-slate-100">
Employer network overview
</h2>
<EmployerNetworkPanel />
</section>
</div>
);
}
@@ -1,31 +0,0 @@
import { Badge } from '@nextcraft/ui';
import { EmployerApplicationsClient } from '../../../../components/employer/employer-applications-client';
/**
* The hiring pipeline (v0.11, REQ-11-002/003, D-109 surface 1): own
* applications with the seven D-099 status badges, per-target
* transition CTAs, and the hire form {role_title, start_date}
* (employer-attested, D-103). Session-gated shell with honest
* loading/error/empty/claim states (the A-406 board-client
* discipline). learner_id IS visible here — the G-319 supersession
* scopes it to the session's own claimed-entity postings (D-105).
*/
export default function ApplicationsPage() {
return (
<div className="flex flex-col gap-6">
<header className="flex flex-col gap-2">
<Badge variant="info">Hiring Pipeline</Badge>
<h1 className="text-3xl font-bold tracking-tight text-slate-900 dark:text-slate-100">
Hiring Pipeline
</h1>
<p className="max-w-2xl text-slate-600 dark:text-slate-400">
Review applicants on your postings and drive them through the hiring
lifecycle — in review, interview, offer, reject, or hire. A hire
records the placement with the role title and start date you attest.
</p>
</header>
<EmployerApplicationsClient />
</div>
);
}
@@ -1,40 +0,0 @@
import type { Metadata } from 'next';
import { Badge } from '@nextcraft/ui';
import { EmployerBillingClient } from '../../../../components/employer/employer-billing-client';
/** Employer billing surface (P5, REQ-10-005, Task 5-2-01): the
* entitlement view (current package, posts used/limit bar, expiry —
* honest empty/expired states) + the self-serve buy flow over the
* v0.9 catalog, including the in-app mock-complete confirmation
* honestly labeled as the pilot mock path. A server shell with
* build-time metadata (the login-page precedent, A-411) rendering the
* session-gated client (the A-406 pattern). */
export const metadata: Metadata = {
title: 'Billing — Nextcraft Employer',
description: 'Your posting package, usage, and self-serve checkout.',
openGraph: {
title: 'Billing — Nextcraft Employer',
description: 'Your posting package, usage, and self-serve checkout.',
type: 'website',
},
};
export default function EmployerBillingPage() {
return (
<div className="flex flex-col gap-6">
<header className="flex flex-col gap-2">
<Badge variant="info">Employer · Billing</Badge>
<h1 className="text-3xl font-bold tracking-tight text-slate-900 dark:text-slate-100">
Billing
</h1>
<p className="max-w-2xl text-slate-600 dark:text-slate-400">
Your posting package, usage, and checkout — real data from your
employer account. Purchases you complete yourself land here
immediately.
</p>
</header>
<EmployerBillingClient />
</div>
);
}
@@ -1,38 +0,0 @@
import type { Metadata } from 'next';
import { Badge, Card, CardBody } from '@nextcraft/ui';
import { LoginClient } from '../../../../components/employer/login-client';
/** Employer sign-in (REQ-10-003, MH-3e): a server shell with build-time
* metadata (the pricing-page precedent, A-411) rendering the client
* (the A-406 pattern) — the login POST + honest states live there. */
export const metadata: Metadata = {
title: 'Employer Sign In — Nextcraft',
description: 'Sign in to your Nextcraft employer account.',
openGraph: {
title: 'Employer Sign In — Nextcraft',
description: 'Sign in to your Nextcraft employer account.',
type: 'website',
},
};
export default function EmployerLoginPage() {
return (
<div className="mx-auto flex max-w-md flex-col gap-6">
<header className="flex flex-col gap-2">
<Badge variant="info">Employer · Sign in</Badge>
<h1 className="text-3xl font-bold tracking-tight text-slate-900 dark:text-slate-100">
Sign in to your employer account
</h1>
<p className="text-slate-600 dark:text-slate-400">
Manage your postings, view real applicant counts, and track your entity&apos;s
presence on the marketplace.
</p>
</header>
<Card>
<CardBody>
<LoginClient />
</CardBody>
</Card>
</div>
);
}
+223 -17
View File
@@ -1,28 +1,234 @@
import { Badge } from '@nextcraft/ui';
import { EmployerOverviewClient } from '../../../components/employer/employer-overview-client';
import Link from 'next/link';
import {
Briefcase,
Users,
TrendingUp,
Target,
ArrowRight,
} from 'lucide-react';
import { Badge, Card, CardBody, Avatar } from '@nextcraft/ui';
import { candidates, employers } from '@nextcraft/mock-data';
import { AnalyticsCharts } from '../../../components/employer/analytics-charts';
import { matchBadgeClasses } from '../../../lib/format';
/* -------------------------------------------------------------------------- */
/* Mock pipeline + metric data */
/* -------------------------------------------------------------------------- */
const METRICS = [
{ label: 'Active Postings', value: 7, trend: '+12%', icon: Briefcase, tone: 'primary' as const },
{ label: 'Total Applicants', value: 142, trend: '+24%', icon: Users, tone: 'accent' as const },
{ label: 'Talent Matches', value: 28, trend: '+8%', icon: Target, tone: 'violet' as const },
{ label: 'Placement Rate', value: '18%', trend: '+3%', icon: TrendingUp, tone: 'amber' as const },
];
const TONE_CLASSES: Record<string, string> = {
primary: 'bg-primary-100 text-primary-700 dark:bg-primary-900/40 dark:text-primary-300',
accent: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300',
violet: 'bg-violet-100 text-violet-700 dark:bg-violet-900/40 dark:text-violet-300',
amber: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300',
};
interface PipelineStage {
name: string;
count: number;
cards: Array<{ name: string; position: string; score: number }>;
}
const PIPELINE: PipelineStage[] = [
{
name: 'Applied',
count: 8,
cards: [
{ name: 'Maya Okonkwo', position: 'AI Orchestration Engineer', score: 96 },
{ name: 'Devon Park', position: 'AI Safety Researcher', score: 91 },
{ name: 'Priya Iyer', position: 'Human-AI Designer', score: 89 },
],
},
{
name: 'Screening',
count: 5,
cards: [
{ name: 'Tomás Vega', position: 'LLM App Developer', score: 93 },
{ name: 'Sofia Marchetti', position: 'AI Governance Lead', score: 85 },
],
},
{
name: 'Interview',
count: 3,
cards: [
{ name: 'Liam Chen', position: 'Agent Reliability Eng', score: 90 },
{ name: 'Yuki Tanaka', position: 'Evaluation Engineer', score: 86 },
],
},
{
name: 'Offer',
count: 2,
cards: [{ name: 'Ingrid Solberg', position: 'AI Red Team Lead', score: 88 }],
},
{
name: 'Hired',
count: 1,
cards: [{ name: 'Hana Lindqvist', position: 'Comp Biologist', score: 88 }],
},
];
/* -------------------------------------------------------------------------- */
/* Page */
/* -------------------------------------------------------------------------- */
export default function EmployerDashboardPage() {
const employer = employers[0];
const topMatches = [...candidates].sort((a, b) => b.matchScore - a.matchScore).slice(0, 5);
/**
* Employer dashboard overview (REQ-10-003, D-092): the mock imports
* + inline METRICS/PIPELINE constants are GONE — the page is a server
* shell rendering the session-gated client with REAL active-postings +
* applicant counts, the claimed-entity profile, and mock-labeled
* talent metrics (the A-406 pattern).
*/
export default function PostingsPage() {
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-8">
{/* Header */}
<header className="flex flex-col gap-2">
<Badge variant="info">Employer Dashboard</Badge>
<h1 className="text-3xl font-bold tracking-tight text-slate-900 dark:text-slate-100">
Overview
Employer Dashboard
</h1>
<p className="max-w-2xl text-slate-600 dark:text-slate-400">
Your postings, applicants, and claimed entity at a glance — real data
from your employer account.
<p className="text-slate-600 dark:text-slate-400">
Welcome back, <span className="font-semibold text-slate-800 dark:text-slate-200">{employer.name}</span>.
Here is your talent pipeline at a glance.
</p>
</header>
<EmployerOverviewClient />
{/* Metric cards */}
<section className="grid grid-cols-2 gap-4 lg:grid-cols-4">
{METRICS.map((m) => {
const Icon = m.icon;
return (
<Card key={m.label}>
<CardBody className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<span
className={`inline-flex h-10 w-10 items-center justify-center rounded-full ${TONE_CLASSES[m.tone]}`}
>
<Icon className="h-5 w-5" />
</span>
<span className="text-xs font-medium text-emerald-600 dark:text-emerald-400">
{m.trend}
</span>
</div>
<div className="flex flex-col">
<span className="text-2xl font-bold text-slate-900 dark:text-slate-100">
{m.value}
</span>
<span className="text-sm text-slate-500 dark:text-slate-400">{m.label}</span>
</div>
</CardBody>
</Card>
);
})}
</section>
{/* Middle row — pipeline + talent matches */}
<section className="grid grid-cols-1 gap-6 lg:grid-cols-3">
{/* Applicant pipeline — 2/3 width */}
<div className="lg:col-span-2">
<div className="mb-3 flex items-center justify-between">
<h2 className="text-lg font-semibold text-slate-900 dark:text-slate-100">
Applicant Pipeline
</h2>
<span className="text-sm text-slate-500 dark:text-slate-400">
{PIPELINE.reduce((s, c) => s + c.count, 0)} candidates in flight
</span>
</div>
<div className="flex gap-3 overflow-x-auto pb-2">
{PIPELINE.map((stage) => (
<div
key={stage.name}
className="flex w-56 shrink-0 flex-col gap-2 rounded-lg border border-slate-200 bg-slate-50 p-3 dark:border-slate-800 dark:bg-slate-900/60"
>
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-slate-800 dark:text-slate-200">
{stage.name}
</h3>
<span className="inline-flex h-6 min-w-6 items-center justify-center rounded-full bg-slate-200 px-1.5 text-xs font-medium text-slate-700 dark:bg-slate-700 dark:text-slate-200">
{stage.count}
</span>
</div>
<div className="flex flex-col gap-2">
{stage.cards.map((c) => (
<div
key={c.name}
className="flex items-center gap-2 rounded-md border border-slate-200 bg-white p-2 dark:border-slate-800 dark:bg-slate-900"
>
<Avatar name={c.name} size="sm" />
<div className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-xs font-medium text-slate-800 dark:text-slate-100">
{c.name}
</span>
<span className="truncate text-xs text-slate-500 dark:text-slate-400">
{c.position}
</span>
</div>
<span
className={`inline-flex h-7 w-9 shrink-0 items-center justify-center rounded-full text-xs font-bold ring-2 ${matchBadgeClasses(
c.score,
)}`}
>
{c.score}
</span>
</div>
))}
</div>
</div>
))}
</div>
</div>
{/* Talent matches — 1/3 width */}
<div>
<div className="mb-3 flex items-center justify-between">
<h2 className="text-lg font-semibold text-slate-900 dark:text-slate-100">
Talent Matches
</h2>
<Link
href="/employer/talent"
className="inline-flex items-center gap-1 text-sm font-medium text-primary-700 hover:underline dark:text-primary-300"
>
See all <ArrowRight className="h-3.5 w-3.5" />
</Link>
</div>
<Card>
<CardBody className="flex flex-col gap-2 p-3">
{topMatches.map((cand) => (
<Link
key={cand.id}
href={`/employer/talent/${cand.id}`}
className="flex items-center gap-3 rounded-md p-2 transition-colors hover:bg-slate-50 dark:hover:bg-slate-800"
>
<Avatar name={cand.name} src={cand.avatar} size="md" />
<div className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-sm font-medium text-slate-800 dark:text-slate-100">
{cand.name}
</span>
<span className="truncate text-xs text-slate-500 dark:text-slate-400">
{cand.headline}
</span>
</div>
<span
className={`inline-flex h-8 w-12 shrink-0 items-center justify-center rounded-full text-xs font-bold ring-2 ${matchBadgeClasses(
cand.matchScore,
)}`}
>
{cand.matchScore}%
</span>
</Link>
))}
</CardBody>
</Card>
</div>
</section>
{/* Analytics charts */}
<section>
<h2 className="mb-3 text-lg font-semibold text-slate-900 dark:text-slate-100">Analytics</h2>
<AnalyticsCharts />
</section>
</div>
);
}
}
@@ -1,13 +1,7 @@
import { Badge } from '@nextcraft/ui';
import { OwnPostingsClient } from '../../../../components/employer/own-postings-client';
import { jobs } from '@nextcraft/mock-data';
import { PostingManager } from '../../../../components/employer/posting-manager';
/**
* Posting management (REQ-10-003/004, D-092): the mock job-list import
* is GONE — the page is a server shell rendering the own-postings
* lifecycle client (draft → published → closed, real applicant
* counts) with the P4 write actions: create draft, edit own draft,
* publish, close. The A-406 pattern.
*/
export default function PostingsPage() {
return (
<div className="flex flex-col gap-6">
@@ -17,13 +11,12 @@ export default function PostingsPage() {
Posting Management
</h1>
<p className="max-w-2xl text-slate-600 dark:text-slate-400">
Track and manage your postings across their lifecycle — draft, published,
closed — with real applicant counts. Create drafts, edit your own drafts,
publish, and close, all from this page.
Create, edit, and track your job postings. View the applicant pipeline for each posting
and manage interview stages.
</p>
</header>
<OwnPostingsClient />
<PostingManager jobs={jobs} />
</div>
);
}
}
@@ -1,42 +0,0 @@
import type { Metadata } from 'next';
import { Badge, Card, CardBody } from '@nextcraft/ui';
import { RegisterClient } from '../../../../components/employer/register-client';
/** Employer registration (REQ-10-003, MH-3e): a server shell with
* build-time metadata (A-411) rendering the client (the A-406
* pattern) — the register POST + honest 409/429/422 states live
* there. The created account's email is UNVERIFIED (D-097) and the
* dashboard displays it as such; registration lands the account
* logged-in. */
export const metadata: Metadata = {
title: 'Employer Registration — Nextcraft',
description: 'Create a Nextcraft employer account.',
openGraph: {
title: 'Employer Registration — Nextcraft',
description: 'Create a Nextcraft employer account.',
type: 'website',
},
};
export default function EmployerRegisterPage() {
return (
<div className="mx-auto flex max-w-md flex-col gap-6">
<header className="flex flex-col gap-2">
<Badge variant="info">Employer · Register</Badge>
<h1 className="text-3xl font-bold tracking-tight text-slate-900 dark:text-slate-100">
Create your employer account
</h1>
<p className="text-slate-600 dark:text-slate-400">
Register with your work email, then claim your company&apos;s entity on the
marketplace. Email addresses are unverified at pilot — claims are reviewed
by an operator before they activate.
</p>
</header>
<Card>
<CardBody>
<RegisterClient />
</CardBody>
</Card>
</div>
);
}
-1
View File
@@ -9,7 +9,6 @@ const LINKS = [
{ label: 'Overview', href: '/employer' },
{ label: 'Talent search', href: '/employer/talent' },
{ label: 'Postings', href: '/employer/postings' },
{ label: 'Billing', href: '/employer/billing' },
];
export default function EmployerLayout({ children }: { children: ReactNode }) {
@@ -1,504 +0,0 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import { Badge, Button, Card, CardBody, Input } from '@nextcraft/ui';
import type {
AdminAccountsResponse,
AdminAuditResponse,
AdminClaimsResponse,
EmployerClaim,
EmployerClaimEvidence,
PlacementOverview,
} from '@nextcraft/types';
import {
listEmployerAdminAccounts,
listEmployerAdminClaims,
listEmployerAdminAudit,
approveEmployerAdminClaim,
rejectEmployerAdminClaim,
getPlacementOverview,
getPlacementExport,
getPlacementExportSummary,
setAdminToken,
EngineError,
} from '../../lib/engine-client';
/**
* Employer network overview (REQ-10-005, D-094, R-62): the operator's
* three-section view over the P2 operator routes — employer accounts
* (email, claimed entity, created_at columns), the claim queue
* (evidence + domain-match signal rendered ADVISORY — never
* verification, A-304; explicit approve/reject actions), and the
* audit trail. Fed by the engine-client admin transport over the
* existing adminToken() G-308 wrapper.
*
* Locked-state design (the entitlement-panel load() pattern, R-62):
* every route is admin-gated (fail-closed 403), so 403 renders the
* honest locked state with the token prompt — NEVER a retry loop. The
* token is persisted via setAdminToken (sessionStorage, never
* logged); an empty token never dispatches a fetch.
*/
type Decision = 'approve' | 'reject';
/** Read the advisory domain-match signal off a claim's stored evidence
* snapshot (A-304: the signal informs, the operator decides — never
* presented as verification). The evidence is claim-time provenance
* JSON; a missing/malformed snapshot renders as unavailable, never a
* fabricated match. */
function evidenceSignal(
claim: EmployerClaim,
): { evidence: Partial<EmployerClaimEvidence> | null; match: boolean | null } {
const evidence = claim.evidence as Partial<EmployerClaimEvidence> | null;
if (!evidence || typeof evidence !== 'object') return { evidence: null, match: null };
return {
evidence,
match: typeof evidence.match === 'boolean' ? evidence.match : null,
};
}
export function EmployerNetworkPanel() {
const [token, setToken] = useState('');
const [accounts, setAccounts] = useState<AdminAccountsResponse | null>(null);
const [claims, setClaims] = useState<AdminClaimsResponse | null>(null);
const [audit, setAudit] = useState<AdminAuditResponse | null>(null);
const [overview, setOverview] = useState<PlacementOverview | null>(null);
const [exportSummary, setExportSummary] = useState<string | null>(null);
const [exportBusy, setExportBusy] = useState(false);
const [exportError, setExportError] = useState<string | null>(null);
const [locked, setLocked] = useState(false);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [decisionBusy, setDecisionBusy] = useState<string | null>(null);
const [decisionError, setDecisionError] = useState<string | null>(null);
const load = useCallback(async () => {
const trimmed = token.trim();
if (!trimmed) return;
setAdminToken(trimmed);
setError(null);
setDecisionError(null);
setLoading(true);
const controller = new AbortController();
try {
// All three operator reads ride the SAME admin transport; a 403
// on any of them locks the whole overview (one token, one gate).
const [acct, queue, trail, placed] = await Promise.all([
listEmployerAdminAccounts(controller.signal),
listEmployerAdminClaims(controller.signal),
listEmployerAdminAudit(controller.signal),
// v0.11 P3/P4: the placements overview rides the same admin
// transport + gate; a fetch failure is honest (the rest loads).
getPlacementOverview(controller.signal).catch(() => null),
]);
setAccounts(acct);
setClaims(queue);
setAudit(trail);
setOverview(placed);
setLocked(false);
} catch (err) {
if (err instanceof EngineError && err.status === 403) {
setLocked(true); // honest locked state — the token prompt stays
} else if (err instanceof EngineError && err.status === 401) {
setLocked(true); // credential failure on an operator route
} else {
setError(err instanceof Error ? err.message : 'Failed to load the network overview.');
}
} finally {
setLoading(false);
}
return () => controller.abort();
}, [token]);
/** The explicit operator decision (approve/reject). The audit event
* + provenance land server-side in the same transaction; the queue
* re-reads after every decision (no optimistic updates). */
const decide = async (claimId: string, decision: Decision) => {
const trimmed = token.trim();
if (!trimmed) return;
setAdminToken(trimmed);
setDecisionBusy(`${decision}:${claimId}`);
setDecisionError(null);
const controller = new AbortController();
try {
if (decision === 'approve') {
await approveEmployerAdminClaim(claimId, controller.signal);
} else {
await rejectEmployerAdminClaim(claimId, controller.signal);
}
// Re-read the queue + audit — the decision's effects render from
// the operator routes, never from optimistic local state.
const [queue, trail] = await Promise.all([
listEmployerAdminClaims(controller.signal),
listEmployerAdminAudit(controller.signal),
]);
setClaims(queue);
setAudit(trail);
} catch (err) {
if (err instanceof EngineError && err.status === 403) {
setLocked(true);
} else if (err instanceof EngineError && err.status === 409) {
setDecisionError(err.message); // the honest constraint 409s
} else if (err instanceof EngineError && err.status === 404) {
setDecisionError('This claim no longer exists — the queue below is the current truth.');
} else {
setDecisionError(err instanceof Error ? err.message : 'The decision failed — try again.');
}
} finally {
setDecisionBusy(null);
}
};
useEffect(() => {
// No auto-fetch: an empty token never dispatches (the panel waits
// for the operator's explicit load — the entitlement-panel shape).
}, []);
/** The export actions (D-109 surface 4): the summary opens IN-TAB
* (the markdown renders in the panel); the JSON DOWNLOADS as a
* blob. G-328: the surrounding copy says RECORDED placements —
* never a verification claim. */
const runExport = async (kind: 'summary' | 'download') => {
setExportBusy(true);
setExportError(null);
const controller = new AbortController();
try {
if (kind === 'summary') {
const text = await getPlacementExportSummary(controller.signal);
setExportSummary(text);
} else {
const text = await getPlacementExport(controller.signal);
const blob = new Blob([text], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `placement-audit-export-${new Date().toISOString().slice(0, 10)}.json`;
link.click();
URL.revokeObjectURL(url);
}
} catch (err) {
if (err instanceof EngineError && err.status === 403) {
setLocked(true);
} else {
setExportError(err instanceof Error ? err.message : 'The export failed.');
}
} finally {
setExportBusy(false);
}
};
return (
<Card>
<CardBody className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<h3 className="text-lg font-semibold text-slate-900 dark:text-slate-100">
Employer network overview
</h3>
<div className="flex flex-col gap-2 sm:flex-row">
<Input
type="password"
placeholder="Operator token"
value={token}
onChange={(e) => setToken(e.target.value)}
className="sm:max-w-xs"
/>
<Button type="button" variant="outline" size="md" onClick={() => void load()}>
{loading ? 'Loading…' : 'Load overview'}
</Button>
</div>
</div>
{locked && (
<p className="text-sm text-slate-600 dark:text-slate-400">
Locked — the admin token is missing or invalid. Enter the operator token
to view employer accounts, the claim queue, and the audit trail.
</p>
)}
{error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>}
{decisionError && <p className="text-sm text-red-600 dark:text-red-400">{decisionError}</p>}
{accounts && claims && audit && (
<div className="flex flex-col gap-6">
{/* v0.11 P3/P4 (D-109 surface 4): the placements block —
RECORDED placements toward the 1,000 guiding goal +
per-employer lists + the export links. G-328: recorded
placements, never a verification claim. */}
{overview && (
<div className="flex flex-col gap-2 rounded-lg border border-slate-200 p-3 dark:border-slate-800">
<h4 className="text-sm font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">
Recorded placements
</h4>
<p className="text-sm text-slate-700 dark:text-slate-300">
<span className="text-2xl font-bold">
{overview.distinct_learners_placed}
</span>{' '}
distinct learners placed (employer-attested records, toward
the {overview.guiding_goal} guiding goal) ·{' '}
{overview.total_active_placements} active placements
</p>
{overview.by_employer.length > 0 && (
<ul className="flex flex-wrap gap-2 text-xs text-slate-600 dark:text-slate-400">
{overview.by_employer.map((bucket) => (
<li
key={bucket.employer_id}
className="rounded-full bg-slate-100 px-2 py-1 dark:bg-slate-800/60"
>
<span className="font-mono">{bucket.employer_id}</span>: {bucket.count}
</li>
))}
</ul>
)}
<div className="flex flex-wrap gap-2">
<Button
variant="secondary"
size="sm"
disabled={exportBusy}
onClick={() => {
void runExport('summary');
}}
>
{exportBusy ? 'Working…' : 'Open export summary (in-tab)'}
</Button>
<Button
variant="outline"
size="sm"
disabled={exportBusy}
onClick={() => {
void runExport('download');
}}
>
{exportBusy ? 'Working…' : 'Download audit export (JSON)'}
</Button>
</div>
{exportError && (
<p className="text-sm text-red-700 dark:text-red-300">{exportError}</p>
)}
{exportSummary && (
<pre className="max-h-80 overflow-auto whitespace-pre-wrap rounded-md bg-slate-50 p-3 text-xs text-slate-700 dark:bg-slate-800/60 dark:text-slate-300">
{exportSummary}
</pre>
)}
</div>
)}
{/* Accounts list — email, claimed entity, created_at (the
UNVERIFIED marker renders as such, D-097). */}
<div className="flex flex-col gap-2">
<h4 className="text-sm font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">
Accounts ({accounts.count})
</h4>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-slate-200 text-left text-xs uppercase tracking-wide text-slate-500 dark:border-slate-800 dark:text-slate-400">
<th className="p-2 font-medium">Email</th>
<th className="p-2 font-medium">Email verified</th>
<th className="p-2 font-medium">Claimed entity</th>
<th className="p-2 font-medium">Created</th>
</tr>
</thead>
<tbody>
{accounts.accounts.map((a) => (
<tr
key={a.id}
className="border-b border-slate-100 last:border-0 dark:border-slate-800/60"
>
<td className="p-2 font-medium text-slate-900 dark:text-slate-100">{a.email}</td>
<td className="p-2 text-slate-600 dark:text-slate-400">
<Badge variant="warning">Unverified</Badge>
</td>
<td className="p-2 text-slate-600 dark:text-slate-400">
{a.employer_id ?? '—'}
</td>
<td className="p-2 text-slate-600 dark:text-slate-400">
{a.created_at ? new Date(a.created_at).toLocaleDateString() : '—'}
</td>
</tr>
))}
{accounts.accounts.length === 0 && (
<tr>
<td colSpan={4} className="p-3 text-center text-slate-500 dark:text-slate-400">
No employer accounts yet.
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
{/* Claim queue — the stored evidence snapshot rendered
ADVISORY (A-304: the domain-match signal is a signal,
never a verification; match=false is the corpus norm).
Approve/reject are the EXPLICIT operator actions. */}
<div className="flex flex-col gap-2">
<h4 className="text-sm font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">
Claim queue ({claims.count})
</h4>
<div className="flex flex-col gap-3">
{claims.claims.map((claim) => {
const { evidence, match } = evidenceSignal(claim);
const busyKey = `${claim.id}`;
const isBusy =
decisionBusy === `approve:${claim.id}` || decisionBusy === `reject:${claim.id}`;
return (
<div
key={claim.id}
className="flex flex-col gap-2 rounded-lg border border-slate-200 p-3 dark:border-slate-800"
>
<div className="flex flex-wrap items-center gap-2">
<span className="font-mono text-xs text-slate-500 dark:text-slate-400">
{claim.id}
</span>
<ClaimStatusBadge status={claim.status} />
<span className="text-sm text-slate-700 dark:text-slate-300">
entity <span className="font-mono">{claim.employer_id}</span>
</span>
<span className="text-xs text-slate-500 dark:text-slate-400">
submitted {new Date(claim.created_at).toLocaleDateString()}
</span>
</div>
{/* A-304 binding: the evidence renders as ADVISORY
input with an explicit label — the operator
decision activates, the signal never does. */}
<div className="rounded-md bg-slate-50 p-2 text-xs dark:bg-slate-800/60">
<p className="font-medium text-slate-600 dark:text-slate-300">
Advisory evidence (not verification — the operator decision activates):
</p>
{evidence ? (
<ul className="mt-1 flex flex-col gap-0.5 text-slate-600 dark:text-slate-400">
<li>
claim email domain:{' '}
<span className="font-mono">
{evidence.account_email_domain ?? 'unavailable'}
</span>
</li>
<li>
entity website domain:{' '}
<span className="font-mono">
{evidence.entity_website_domain ?? 'unavailable'}
</span>
</li>
<li>
domain-match signal:{' '}
{match === null ? (
'unavailable'
) : match ? (
<Badge variant="info">match</Badge>
) : (
<Badge variant="default">no match</Badge>
)}
</li>
</ul>
) : (
<p className="mt-1 text-slate-600 dark:text-slate-400">
Evidence snapshot unavailable on this claim.
</p>
)}
</div>
{claim.decided_by && (
<p className="text-xs text-slate-500 dark:text-slate-400">
decided by {claim.decided_by}
{claim.decided_at
? ` at ${new Date(claim.decided_at).toLocaleDateString()}`
: ''}
</p>
)}
{claim.status === 'pending' && (
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="primary"
size="sm"
onClick={() => void decide(claim.id, 'approve')}
loading={decisionBusy === `approve:${busyKey}`}
disabled={decisionBusy !== null && !isBusy}
>
Approve claim
</Button>
<Button
type="button"
variant="destructive"
size="sm"
onClick={() => void decide(claim.id, 'reject')}
loading={decisionBusy === `reject:${busyKey}`}
disabled={decisionBusy !== null && !isBusy}
>
Reject claim
</Button>
</div>
)}
</div>
);
})}
{claims.claims.length === 0 && (
<p className="text-sm text-slate-600 dark:text-slate-400">
No claims in the queue.
</p>
)}
</div>
</div>
{/* Audit trail — every decision with operator provenance. */}
<div className="flex flex-col gap-2">
<h4 className="text-sm font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">
Audit trail ({audit.count})
</h4>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-slate-200 text-left text-xs uppercase tracking-wide text-slate-500 dark:border-slate-800 dark:text-slate-400">
<th className="p-2 font-medium">Event</th>
<th className="p-2 font-medium">Account</th>
<th className="p-2 font-medium">Entity</th>
<th className="p-2 font-medium">Operator</th>
<th className="p-2 font-medium">When</th>
</tr>
</thead>
<tbody>
{audit.events.map((event) => (
<tr
key={event.id}
className="border-b border-slate-100 last:border-0 dark:border-slate-800/60"
>
<td className="p-2 font-medium text-slate-900 dark:text-slate-100">
{event.name}
</td>
<td className="p-2 font-mono text-xs text-slate-600 dark:text-slate-400">
{event.account_id ?? '—'}
</td>
<td className="p-2 font-mono text-xs text-slate-600 dark:text-slate-400">
{event.employer_id ?? '—'}
</td>
<td className="p-2 text-slate-600 dark:text-slate-400">
{event.operator ?? '—'}
</td>
<td className="p-2 text-slate-600 dark:text-slate-400">
{event.created_at
? new Date(event.created_at).toLocaleString()
: '—'}
</td>
</tr>
))}
{audit.events.length === 0 && (
<tr>
<td colSpan={5} className="p-3 text-center text-slate-500 dark:text-slate-400">
No audit events yet.
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</div>
)}
</CardBody>
</Card>
);
}
function ClaimStatusBadge({ status }: { status: EmployerClaim['status'] }) {
if (status === 'approved') return <Badge variant="success">Approved</Badge>;
if (status === 'pending') return <Badge variant="warning">Pending</Badge>;
return <Badge variant="default">Rejected</Badge>;
}
@@ -0,0 +1,157 @@
'use client';
import {
Bar,
BarChart,
CartesianGrid,
Cell,
Legend,
Line,
LineChart,
Pie,
PieChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import { colors } from '@nextcraft/ui';
/* -------------------------------------------------------------------------- */
/* Mock chart data */
/* -------------------------------------------------------------------------- */
const postingsData = [
{ month: 'Apr', postings: 4, applications: 38 },
{ month: 'May', postings: 6, applications: 62 },
{ month: 'Jun', postings: 5, applications: 51 },
{ month: 'Jul', postings: 8, applications: 84 },
{ month: 'Aug', postings: 9, applications: 112 },
{ month: 'Sep', postings: 7, applications: 142 },
];
const sourceData = [
{ name: 'Direct', value: 40, color: colors.primary[500] },
{ name: 'AI Match', value: 30, color: colors.accent[500] },
{ name: 'Referral', value: 20, color: colors.violet[400] },
{ name: 'Job Board', value: 10, color: colors.amber[400] },
];
const placementData = [
{ month: 'Apr', placements: 2 },
{ month: 'May', placements: 5 },
{ month: 'Jun', placements: 9 },
{ month: 'Jul', placements: 14 },
{ month: 'Aug', placements: 21 },
{ month: 'Sep', placements: 28 },
];
/* -------------------------------------------------------------------------- */
/* Shared styling */
/* -------------------------------------------------------------------------- */
const tooltipStyle = {
borderRadius: 8,
border: `1px solid ${colors.neutral[200]}`,
fontSize: 12,
background: '#ffffff',
} as const;
const axisTick = { fontSize: 12, fill: colors.neutral[500] } as const;
/* -------------------------------------------------------------------------- */
/* Component */
/* -------------------------------------------------------------------------- */
export function AnalyticsCharts() {
return (
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
{/* Bar chart — Postings vs Applications */}
<div className="rounded-lg border border-slate-200 bg-white p-5 dark:border-slate-800 dark:bg-slate-900">
<h3 className="mb-1 text-sm font-semibold text-slate-900 dark:text-slate-100">
Postings vs Applications
</h3>
<p className="mb-4 text-xs text-slate-500 dark:text-slate-400">Last 6 months</p>
<div className="h-56 w-full">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={postingsData} margin={{ top: 4, right: 8, left: -16, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke={colors.neutral[200]} className="dark:opacity-20" />
<XAxis dataKey="month" tick={axisTick} stroke={colors.neutral[300]} />
<YAxis tick={axisTick} stroke={colors.neutral[300]} />
<Tooltip
contentStyle={tooltipStyle}
cursor={{ fill: 'rgba(99,102,241,0.06)' }}
/>
<Legend wrapperStyle={{ fontSize: 12 }} />
<Bar dataKey="postings" fill={colors.primary[500]} radius={[4, 4, 0, 0]} name="Postings" />
<Bar dataKey="applications" fill={colors.accent[500]} radius={[4, 4, 0, 0]} name="Applications" />
</BarChart>
</ResponsiveContainer>
</div>
</div>
{/* Donut chart — Applicant Sources */}
<div className="rounded-lg border border-slate-200 bg-white p-5 dark:border-slate-800 dark:bg-slate-900">
<h3 className="mb-1 text-sm font-semibold text-slate-900 dark:text-slate-100">
Applicant Sources
</h3>
<p className="mb-4 text-xs text-slate-500 dark:text-slate-400">Where candidates come from</p>
<div className="h-56 w-full">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={sourceData}
dataKey="value"
nameKey="name"
cx="50%"
cy="50%"
innerRadius={50}
outerRadius={80}
paddingAngle={3}
>
{sourceData.map((entry) => (
<Cell key={entry.name} fill={entry.color} />
))}
</Pie>
<Tooltip contentStyle={tooltipStyle} formatter={(value) => `${value}%`} />
<Legend wrapperStyle={{ fontSize: 12 }} />
</PieChart>
</ResponsiveContainer>
</div>
</div>
{/* Line chart — Placement Trends */}
<div className="rounded-lg border border-slate-200 bg-white p-5 dark:border-slate-800 dark:bg-slate-900">
<h3 className="mb-1 text-sm font-semibold text-slate-900 dark:text-slate-100">
Placement Trends
</h3>
<p className="mb-4 text-xs text-slate-500 dark:text-slate-400">Cumulative placements</p>
<div className="h-56 w-full">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={placementData} margin={{ top: 4, right: 8, left: -16, bottom: 0 }}>
<defs>
<linearGradient id="placementStroke" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stopColor={colors.primary[500]} />
<stop offset="100%" stopColor={colors.accent[500]} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke={colors.neutral[200]} />
<XAxis dataKey="month" tick={axisTick} stroke={colors.neutral[300]} />
<YAxis tick={axisTick} stroke={colors.neutral[300]} />
<Tooltip contentStyle={tooltipStyle} />
<Line
type="monotone"
dataKey="placements"
stroke="url(#placementStroke)"
strokeWidth={3}
dot={{ r: 4, fill: colors.primary[600] }}
activeDot={{ r: 6 }}
name="Cumulative placements"
/>
</LineChart>
</ResponsiveContainer>
</div>
</div>
</div>
);
}
@@ -1,342 +0,0 @@
'use client';
import { useEffect, useState } from 'react';
import { Badge, Button, Card, CardBody, Input } from '@nextcraft/ui';
import type {
ApplicationStatus,
EmployerApplicationSummary,
} from '@nextcraft/types';
import {
listOwnEmployerApplications,
employerMarkInReview,
employerMarkInterview,
employerMarkOffer,
employerRejectApplication,
employerHireApplication,
SessionRequiredError,
ClaimRequiredError,
EngineError,
} from '../../lib/engine-client';
/**
* The hiring-pipeline client (v0.11, REQ-11-002/003, D-109 surface
* 1): the session employer's OWN applications (every status, per-
* status facets) with per-target transition CTAs — the matrix stays
* OUT of the request bodies (D-105: each call names its target).
* The hire form carries the employer-attested {role_title,
* start_date} (D-103/G-324). Withdraw is NEVER offered here (D-099:
* employers reject; only the learner or an operator can withdraw).
*
* Honest states (A-406): loading/error/empty/claim, per-action
* pending + error; a 403 cross-entity or 409 matrix arm renders from
* EngineError.status — NO new error classes (D-110). learner_id IS
* visible (the G-319 supersession, own applicants only).
*/
const STATUS_LABELS: Record<ApplicationStatus, string> = {
submitted: 'Submitted',
in_review: 'In Review',
interview: 'Interview',
offer: 'Offer',
hired: 'Hired',
rejected: 'Rejected',
withdrawn: 'Withdrawn',
};
const STATUS_BADGE: Record<ApplicationStatus, 'default' | 'info' | 'success' | 'warning' | 'error'> = {
submitted: 'default',
in_review: 'info',
interview: 'info',
offer: 'warning',
hired: 'success',
rejected: 'error',
withdrawn: 'default',
};
/** The next legal targets per the D-099 matrix (forward-only with
* skip-forward — the CTA set matches the store's transition set). */
const NEXT_TARGETS: Record<string, Array<{ key: string; label: string }>> = {
submitted: [
{ key: 'in-review', label: 'In Review' },
{ key: 'interview', label: 'Interview' },
{ key: 'reject', label: 'Reject' },
{ key: 'hire', label: 'Hire' },
],
in_review: [
{ key: 'interview', label: 'Interview' },
{ key: 'offer', label: 'Offer' },
{ key: 'reject', label: 'Reject' },
{ key: 'hire', label: 'Hire' },
],
interview: [
{ key: 'offer', label: 'Offer' },
{ key: 'reject', label: 'Reject' },
{ key: 'hire', label: 'Hire' },
],
offer: [
{ key: 'reject', label: 'Reject' },
{ key: 'hire', label: 'Hire' },
],
};
export function EmployerApplicationsClient() {
const [applications, setApplications] = useState<EmployerApplicationSummary[] | null>(null);
const [loggedOut, setLoggedOut] = useState(false);
const [claimPrompt, setClaimPrompt] = useState(false);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
// per-application action state: the honest pending/error pair
const [pending, setPending] = useState<string | null>(null);
const [actionError, setActionError] = useState<string | null>(null);
// the hire form: which application id is being hired + its fields
const [hireFor, setHireFor] = useState<string | null>(null);
const [roleTitle, setRoleTitle] = useState('');
const [startDate, setStartDate] = useState('');
useEffect(() => {
const controller = new AbortController();
(async () => {
try {
const body = await listOwnEmployerApplications(controller.signal);
if (!controller.signal.aborted) setApplications(body.applications);
} catch (err) {
if (controller.signal.aborted) return;
if (err instanceof SessionRequiredError) {
setLoggedOut(true); // honest logged-out state — never a retry loop
} else if (err instanceof ClaimRequiredError) {
setClaimPrompt(true); // the honest claim-CTA state
} else {
setError(err instanceof Error ? err.message : 'Failed to load applications.');
}
} finally {
if (!controller.signal.aborted) setLoading(false);
}
})();
return () => controller.abort();
}, []);
async function runTransition(
application: EmployerApplicationSummary,
target: 'in-review' | 'interview' | 'offer' | 'reject',
): Promise<void> {
setActionError(null);
setPending(application.id);
try {
const dispatch = {
'in-review': employerMarkInReview,
interview: employerMarkInterview,
offer: employerMarkOffer,
reject: employerRejectApplication,
}[target];
const body = await dispatch(application.id);
if (body.application_id === application.id) {
setApplications((prev) =>
(prev ?? []).map((a) => (a.id === application.id ? { ...a, status: body.status } : a)),
);
}
} catch (err) {
if (err instanceof SessionRequiredError) {
setLoggedOut(true);
} else if (err instanceof ClaimRequiredError) {
setClaimPrompt(true);
} else if (err instanceof EngineError) {
// the 403 cross-entity + 409 matrix arms — the honest status
setActionError(`${application.id}: ${err.message}`);
} else {
setActionError(err instanceof Error ? err.message : 'The transition failed.');
}
} finally {
setPending(null);
}
}
async function submitHire(application: EmployerApplicationSummary): Promise<void> {
setActionError(null);
setPending(application.id);
try {
const body = await employerHireApplication(application.id, {
role_title: roleTitle,
start_date: startDate,
});
setApplications((prev) =>
(prev ?? []).map((a) => (a.id === application.id ? { ...a, status: body.status } : a)),
);
setHireFor(null);
setRoleTitle('');
setStartDate('');
} catch (err) {
if (err instanceof SessionRequiredError) {
setLoggedOut(true);
} else if (err instanceof ClaimRequiredError) {
setClaimPrompt(true);
} else {
// 422 (missing/empty fields, G-324) + 409 (matrix) render here
setActionError(err instanceof Error ? err.message : 'The hire failed.');
}
} finally {
setPending(null);
}
}
if (loading) {
return (
<Card>
<CardBody className="py-10 text-center text-slate-600 dark:text-slate-400">
Loading your hiring pipeline…
</CardBody>
</Card>
);
}
if (loggedOut) {
return (
<Card>
<CardBody className="flex flex-col items-center gap-3 py-10 text-center">
<p className="text-slate-700 dark:text-slate-300">
You are signed out — your session is missing or expired.
</p>
<a href="/employer/login">
<Button variant="primary" size="md">Sign in</Button>
</a>
</CardBody>
</Card>
);
}
if (claimPrompt) {
return (
<Card>
<CardBody className="flex flex-col items-center gap-3 py-10 text-center">
<p className="text-slate-700 dark:text-slate-300">
No employer entity is claimed on this account yet.
</p>
<p className="max-w-md text-sm text-slate-600 dark:text-slate-400">
Claim your company&apos;s entity first — the hiring pipeline lists
applicants on your claimed entity&apos;s postings.
</p>
</CardBody>
</Card>
);
}
if (error) {
return (
<Card>
<CardBody className="flex flex-col items-center gap-2 py-10 text-center">
<p className="text-slate-700 dark:text-slate-300">{error}</p>
<p className="text-sm text-slate-500 dark:text-slate-400">
The hiring pipeline is unavailable — please try again shortly.
</p>
</CardBody>
</Card>
);
}
if (!applications || applications.length === 0) {
return (
<Card>
<CardBody className="py-10 text-center text-sm text-slate-500 dark:text-slate-400">
No applications yet — applicants on your published postings appear
here as they apply.
</CardBody>
</Card>
);
}
return (
<div className="flex flex-col gap-4">
{actionError && (
<Card>
<CardBody className="py-3 text-sm text-red-700 dark:text-red-300">{actionError}</CardBody>
</Card>
)}
{applications.map((application) => {
const targets = NEXT_TARGETS[application.status] ?? [];
return (
<Card key={application.id}>
<CardBody className="flex flex-col gap-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2">
<Badge variant={STATUS_BADGE[application.status]}>
{STATUS_LABELS[application.status]}
</Badge>
<span className="text-sm font-medium text-slate-900 dark:text-slate-100">
{application.learner_id}
</span>
<span className="text-xs text-slate-500 dark:text-slate-400">
applied {application.created_at ? application.created_at.slice(0, 10) : ''}
</span>
</div>
<span className="text-xs text-slate-400 dark:text-slate-500">
{application.id}
</span>
</div>
{hireFor === application.id ? (
<div className="flex flex-col gap-3 rounded-lg border border-slate-200 p-3 dark:border-slate-800">
<p className="text-sm font-medium text-slate-800 dark:text-slate-200">
Record the placement you attest (visible to the learner and
the audit export):
</p>
<Input
label="Role title"
value={roleTitle}
onChange={(e) => setRoleTitle(e.target.value)}
placeholder="AI Orchestration Engineer"
maxLength={200}
/>
<Input
label="Employment start date"
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
/>
<div className="flex gap-2">
<Button
variant="primary"
size="sm"
disabled={pending === application.id || roleTitle === '' || startDate === ''}
onClick={() => {
void submitHire(application);
}}
>
{pending === application.id ? 'Recording…' : 'Confirm hire'}
</Button>
<Button variant="outline" size="sm" onClick={() => setHireFor(null)}>
Cancel
</Button>
</div>
</div>
) : (
<div className="flex flex-wrap gap-2">
{targets.map((target) => (
<Button
key={target.key}
variant={target.key === 'reject' ? 'outline' : 'secondary'}
size="sm"
disabled={pending === application.id}
onClick={() => {
if (target.key === 'hire') {
setHireFor(application.id);
setActionError(null);
} else {
void runTransition(
application,
target.key as 'in-review' | 'interview' | 'offer' | 'reject',
);
}
}}
>
{pending === application.id ? '…' : target.label}
</Button>
))}
{/* D-099: withdraw is NEVER offered to employers */}
</div>
)}
</CardBody>
</Card>
);
})}
</div>
);
}
@@ -1,446 +0,0 @@
'use client';
import { useCallback, useEffect, useRef, useState } from 'react';
import Link from 'next/link';
import { Badge, Button, Card, CardBody } from '@nextcraft/ui';
import type {
CatalogResponse,
EmployerEntitlementResponse,
EmployerCheckoutResponse,
Package,
} from '@nextcraft/types';
import {
getOwnEmployerEntitlement,
listBillingCatalog,
startOwnEmployerCheckout,
completeOwnEmployerMockCheckout,
SessionRequiredError,
ClaimRequiredError,
EngineError,
} from '../../lib/engine-client';
/**
* Employer billing surface (REQ-10-005, Task 5-2-01 — the B2B loop's
* buy arm): the entitlement view (current package, posts used/limit
* bar, expiry) + the self-serve buy flow.
*
* Honest states throughout: 401 → the logged-out state with a login
* CTA (SessionRequiredError — never a retry loop); 403 claim-CTA →
* the claim prompt (the buyer must be a claimed entity, D-076
* superseded); no active package → the honest empty state (a null
* purchase NEVER asserts expiry provenance — the wire shape cannot
* distinguish never-purchased from expired; expired windows are
* skipped lazily server-side, G-306).
*
* The buy flow: the catalog from the PUBLIC /v1/billing/catalog (the
* v0.9 listBillingCatalog REUSED — one source of truth), the
* own-checkout trigger POSTs /v1/employers/billing/checkout, and the
* in-app mock-complete confirmation is HONESTLY LABELED as the pilot
* mock path (D-082): under provider=mock no real payment exists — the
* confirmation completes one's OWN checkout session and the grant
* lands through the REAL webhook path. The admin operator path
* remains the fallback (oversight, D-094). Real limits render
* exclusively from the entitlement view (G-320b — the 402 payload's
* zeros are never rendered here or anywhere).
*/
const ENTERPRISE_CENTS = 0;
function formatPrice(cents: number): string {
if (cents === ENTERPRISE_CENTS) return 'Custom';
return `$${(cents / 100).toFixed(0)}`;
}
export function EmployerBillingClient() {
const [entitlement, setEntitlement] = useState<EmployerEntitlementResponse | null>(null);
const [catalog, setCatalog] = useState<CatalogResponse | null>(null);
const [loggedOut, setLoggedOut] = useState(false);
const [claimPrompt, setClaimPrompt] = useState(false);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
// The open checkout awaiting the pilot mock-complete confirmation.
const [checkout, setCheckout] = useState<EmployerCheckoutResponse | null>(null);
const [checkoutItemName, setCheckoutItemName] = useState<string>('');
const [busyId, setBusyId] = useState<string | null>(null);
const [actionError, setActionError] = useState<string | null>(null);
const [confirming, setConfirming] = useState(false);
const [confirmed, setConfirmed] = useState(false);
const [, reload] = useState(0);
const abortRef = useRef<AbortController | null>(null);
const refetch = useCallback((signal?: AbortSignal) => {
return getOwnEmployerEntitlement(signal).then(setEntitlement);
}, []);
useEffect(() => {
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
(async () => {
try {
const ent = await getOwnEmployerEntitlement(controller.signal);
setEntitlement(ent);
// The catalog is PUBLIC (no session needed) — fetched beside
// the entitlement so the buy flow is ready the moment the
// session is valid.
setCatalog(await listBillingCatalog(controller.signal));
} catch (err) {
if (controller.signal.aborted) return;
if (err instanceof SessionRequiredError) {
setLoggedOut(true); // honest logged-out state — never a retry loop
} else if (err instanceof ClaimRequiredError) {
setClaimPrompt(true); // honest claim-CTA state — no buyer without a claim
} else {
setError(err instanceof Error ? err.message : 'Failed to load billing.');
}
} finally {
if (!controller.signal.aborted) setLoading(false);
}
})();
return () => controller.abort();
// `reload` re-runs the read effect after a confirmed purchase.
}, [reload]);
/** Start the own-checkout for one catalog item. */
const buy = async (item: Package) => {
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
setBusyId(item.id);
setActionError(null);
setConfirmed(false);
try {
const created = await startOwnEmployerCheckout({ item_id: item.id }, controller.signal);
setCheckout(created);
setCheckoutItemName(item.name);
if (!created.url) {
// The mock provider's honest empty-url path — the in-app
// confirmation below is the pilot completion arm.
setActionError(null);
}
} catch (err) {
if (controller.signal.aborted) return;
if (err instanceof SessionRequiredError) {
setLoggedOut(true);
} else if (err instanceof ClaimRequiredError) {
setClaimPrompt(true);
} else if (err instanceof EngineError && err.status === 404) {
setActionError('That package is no longer in the catalog — reload the page.');
} else {
setActionError(err instanceof Error ? err.message : 'Checkout failed — try again.');
}
} finally {
if (!controller.signal.aborted) setBusyId(null);
}
};
/** The in-app pilot confirmation: complete one's OWN checkout
* session through the honest mock path. */
const confirmMockComplete = async () => {
if (!checkout) return;
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
setConfirming(true);
setActionError(null);
try {
const result = await completeOwnEmployerMockCheckout(
{ provider_session_id: checkout.provider_session_id },
controller.signal,
);
if (result.completed) {
setConfirmed(true);
setCheckout(null);
await refetch(controller.signal);
reload((n) => n + 1); // the entitlement view reflects the grant
} else {
setActionError(
`The checkout did not complete (payment status: ${result.payment_status}) — try again.`,
);
}
} catch (err) {
if (controller.signal.aborted) return;
if (err instanceof SessionRequiredError) {
setLoggedOut(true);
} else if (err instanceof EngineError && err.status === 404) {
setActionError(
'Mock completion is unavailable on this deployment (it exists only under provider=mock).',
);
} else if (err instanceof EngineError && err.status === 403) {
setActionError('This checkout session belongs to another employer — it cannot be completed here.');
} else {
setActionError(err instanceof Error ? err.message : 'The completion failed — try again.');
}
} finally {
if (!controller.signal.aborted) setConfirming(false);
}
};
const cancelCheckout = () => {
setCheckout(null);
setCheckoutItemName('');
setActionError(null);
};
if (loading) {
return (
<Card>
<CardBody className="py-10 text-center text-slate-600 dark:text-slate-400">
Loading your billing…
</CardBody>
</Card>
);
}
if (loggedOut) {
return (
<Card>
<CardBody className="flex flex-col items-center gap-3 py-10 text-center">
<p className="text-slate-700 dark:text-slate-300">
You are signed out — your session is missing or expired.
</p>
<div className="flex gap-2">
<Link href="/employer/login">
<Button variant="primary" size="md">Sign in</Button>
</Link>
<Link href="/employer/register">
<Button variant="outline" size="md">Register</Button>
</Link>
</div>
</CardBody>
</Card>
);
}
if (claimPrompt) {
return (
<Card>
<CardBody className="flex flex-col items-center gap-3 py-10 text-center">
<p className="text-slate-700 dark:text-slate-300">
No employer entity is claimed on this account yet.
</p>
<p className="max-w-md text-sm text-slate-600 dark:text-slate-400">
Checkout buys on behalf of your claimed employer entity — claim your
company&apos;s entity first. Claims are reviewed by an operator before
they activate.
</p>
</CardBody>
</Card>
);
}
if (error) {
return (
<Card>
<CardBody className="flex flex-col items-center gap-2 py-10 text-center">
<p className="text-slate-700 dark:text-slate-300">{error}</p>
<p className="text-sm text-slate-500 dark:text-slate-400">
The billing service is unavailable — please retry shortly.
</p>
</CardBody>
</Card>
);
}
const purchase = entitlement?.purchase ?? null;
return (
<div className="flex flex-col gap-6">
{/* Entitlement view — the ONLY place real limits render (G-320b). */}
<section className="flex flex-col gap-3">
<h2 className="text-lg font-semibold text-slate-900 dark:text-slate-100">
Your posting package
</h2>
<Card>
<CardBody className="flex flex-col gap-3">
{purchase ? (
<>
<div className="flex items-center justify-between">
<span className="font-medium text-slate-900 dark:text-slate-100">
{purchase.package_name}
</span>
<Badge variant="success">Active</Badge>
</div>
<div className="flex items-center justify-between text-sm text-slate-700 dark:text-slate-300">
<span>Posts used</span>
<span className="font-semibold">
{purchase.posts_limit === null
? `${purchase.posts_used} (unlimited)`
: `${purchase.posts_used} / ${purchase.posts_limit}`}
</span>
</div>
{purchase.posts_limit !== null && (
<div className="h-2 w-full overflow-hidden rounded-full bg-slate-100 dark:bg-slate-800">
<div
className="h-full bg-primary-500"
style={{
width: `${Math.min(
100,
(purchase.posts_used / purchase.posts_limit) * 100,
)}%`,
}}
/>
</div>
)}
{purchase.expires_at && (
<p className="text-xs text-slate-500 dark:text-slate-400">
Expires {new Date(purchase.expires_at).toLocaleDateString()}
</p>
)}
</>
) : (
<p className="text-sm text-slate-600 dark:text-slate-400">
No active posting package — buy one below to post jobs.
</p>
)}
{entitlement?.subscription ? (
<div className="flex items-center justify-between border-t border-slate-100 pt-2 text-sm dark:border-slate-800">
<span className="font-medium">{entitlement.subscription.plan_name}</span>
<Badge variant={entitlement.subscription.status === 'active' ? 'success' : 'warning'}>
{entitlement.subscription.status}
</Badge>
</div>
) : (
<p className="text-xs text-slate-500 dark:text-slate-400">
No talent-access subscription.
</p>
)}
</CardBody>
</Card>
</section>
{/* The pilot mock-complete confirmation — honestly labeled (D-082). */}
{checkout && (
<Card>
<CardBody className="flex flex-col gap-3">
<div className="flex items-center gap-2">
<Badge variant="info">Checkout open</Badge>
<span className="text-sm font-medium text-slate-900 dark:text-slate-100">
{checkoutItemName}
</span>
</div>
<p className="text-sm text-slate-600 dark:text-slate-400">
Checkout session <code className="text-xs">{checkout.provider_session_id}</code> is
open (mode: {checkout.mode}).
</p>
<p className="text-sm text-slate-700 dark:text-slate-300">
<span className="font-semibold">Pilot mock path:</span> this
deployment uses the mock payment provider — no real charge
happens. Confirming below completes your own checkout session and
grants the package through the real webhook path immediately.
</p>
{actionError && <p className="text-sm text-red-600 dark:text-red-400">{actionError}</p>}
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="primary"
size="md"
onClick={() => void confirmMockComplete()}
loading={confirming}
disabled={confirming}
>
Confirm purchase (mock — no charge)
</Button>
<Button
type="button"
variant="outline"
size="md"
onClick={cancelCheckout}
disabled={confirming}
>
Cancel checkout
</Button>
</div>
</CardBody>
</Card>
)}
{/* Grant confirmation — the honest post-webhook state. */}
{confirmed && (
<Card>
<CardBody className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<Badge variant="success">Purchase complete</Badge>
</div>
<p className="text-sm text-slate-700 dark:text-slate-300">
Your purchase completed through the mock payment path and the
webhook granted your package — the entitlement view above now
reflects it.
</p>
</CardBody>
</Card>
)}
{/* Buy flow — the PUBLIC v0.9 catalog (listBillingCatalog REUSED). */}
<section className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-slate-900 dark:text-slate-100">
Posting packages
</h2>
<p className="text-xs text-slate-500 dark:text-slate-400">
Prices from the live catalog — self-serve checkout buys on your
claimed entity&apos;s behalf.
</p>
</div>
{actionError && !checkout && (
<p className="text-sm text-red-600 dark:text-red-400">{actionError}</p>
)}
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
{(catalog?.packages ?? []).map((pkg) => (
<Card key={pkg.id} className="flex h-full flex-col">
<CardBody className="flex flex-1 flex-col gap-3">
<div className="flex flex-col gap-1">
<h3 className="text-base font-semibold text-slate-900 dark:text-slate-100">
{pkg.name}
</h3>
<div className="flex items-baseline gap-1">
<span className="text-2xl font-bold text-slate-900 dark:text-slate-100">
{formatPrice(pkg.price_cents)}
</span>
<span className="text-xs text-slate-500 dark:text-slate-400">
{pkg.price_cents === ENTERPRISE_CENTS
? 'contact sales'
: `${pkg.posts_limit} posts · ${pkg.duration_days} days`}
</span>
</div>
</div>
<ul className="flex flex-col gap-1 text-sm text-slate-600 dark:text-slate-400">
{pkg.features.map((f) => (
<li key={f}>{f}</li>
))}
</ul>
<div className="mt-auto pt-1">
<Button
type="button"
variant={pkg.price_cents === ENTERPRISE_CENTS ? 'outline' : 'primary'}
size="md"
className="w-full"
onClick={() => void buy(pkg)}
loading={busyId === pkg.id}
disabled={busyId !== null && busyId !== pkg.id}
>
{pkg.price_cents === ENTERPRISE_CENTS ? 'Contact Sales' : 'Buy package'}
</Button>
</div>
</CardBody>
</Card>
))}
</div>
{catalog && catalog.packages.length === 0 && (
<p className="text-sm text-slate-600 dark:text-slate-400">
No posting packages are configured in the catalog yet.
</p>
)}
</section>
<p className="text-xs text-slate-500 dark:text-slate-400">
Pilot payments run through the mock provider (honestly labeled above) —
the grant lands through the real webhook path. Operator-assisted
checkout remains available as a fallback: contact the Nextcraft team
and an operator can complete the purchase on your behalf.
</p>
</div>
);
}
@@ -1,462 +0,0 @@
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { Briefcase, Users, TrendingUp, Target, ArrowRight } from 'lucide-react';
import { Badge, Button, Card, CardBody, Avatar } from '@nextcraft/ui';
import type {
EmployerProfile,
EmployerMe,
OwnPostingsResponse,
EmployerPlacementsResponse,
} from '@nextcraft/types';
import {
getEmployerMe,
listOwnEmployerPostings,
listOwnEmployerPlacements,
getEmployer,
SessionRequiredError,
ClaimRequiredError,
} from '../../lib/engine-client';
/**
* Employer dashboard overview (REQ-10-003, D-092 — the v0.1 mockup
* converts to real data). Session-gated client shell: no/expired
* session → the honest logged-out state with a login CTA
* (SessionRequiredError — NEVER a retry loop); unclaimed account →
* the honest claim-CTA state; claim pending → the honest
* "claim submitted, awaiting review" state (never presented as
* verified before the operator decides, A-304/D-089).
*
* REAL data: active-postings count + per-posting applicant counts
* from GET /v1/employers/postings; the claimed entity's profile via
* the public marketplace employer fetch. Talent-match/placement
* metrics STAY mock-labeled (D-092 — no backend computes them; the
* honest label rides the card). The sessionStorage session is
* tab-scoped — the token dies with the tab (documented tradeoff).
*/
export function EmployerOverviewClient() {
const [me, setMe] = useState<EmployerMe | null>(null);
const [postings, setPostings] = useState<OwnPostingsResponse | null>(null);
const [profile, setProfile] = useState<EmployerProfile | null>(null);
const [placements, setPlacements] = useState<EmployerPlacementsResponse | null>(null);
const [loggedOut, setLoggedOut] = useState(false);
const [claimPrompt, setClaimPrompt] = useState(false);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const controller = new AbortController();
(async () => {
try {
const meBody = await getEmployerMe(controller.signal);
setMe(meBody);
if (meBody.claimed_employer_id) {
const [own, prof, placed] = await Promise.all([
listOwnEmployerPostings(controller.signal),
getEmployer(meBody.claimed_employer_id, controller.signal).catch(() => null),
// v0.11 P3 (D-109 surface 2): REAL placements — the mock
// metric block is REPLACED (D-092: never leave a convertible
// surface on mock). A placements fetch failure is honest —
// the rest of the dashboard still renders.
listOwnEmployerPlacements(controller.signal).catch(() => null),
]);
setPostings(own);
setProfile(prof);
setPlacements(placed);
}
} catch (err) {
if (controller.signal.aborted) return;
if (err instanceof SessionRequiredError) {
setLoggedOut(true); // honest logged-out state — never a retry loop
} else if (err instanceof ClaimRequiredError) {
setClaimPrompt(true); // honest claim-CTA state
} else {
setError(err instanceof Error ? err.message : 'Failed to load the dashboard.');
}
} finally {
if (!controller.signal.aborted) setLoading(false);
}
})();
return () => controller.abort();
}, []);
if (loading) {
return (
<Card>
<CardBody className="py-10 text-center text-slate-600 dark:text-slate-400">
Loading your employer dashboard…
</CardBody>
</Card>
);
}
if (loggedOut) {
return (
<Card>
<CardBody className="flex flex-col items-center gap-3 py-10 text-center">
<p className="text-slate-700 dark:text-slate-300">
You are signed out — your session is missing or expired.
</p>
<div className="flex gap-2">
<Link href="/employer/login">
<Button variant="primary" size="md">Sign in</Button>
</Link>
<Link href="/employer/register">
<Button variant="outline" size="md">Register</Button>
</Link>
</div>
<p className="text-xs text-slate-500 dark:text-slate-400">
Sessions are tab-scoped: signing in keeps the token in this tab&apos;s
sessionStorage only.
</p>
</CardBody>
</Card>
);
}
if (claimPrompt) {
return (
<Card>
<CardBody className="flex flex-col items-center gap-3 py-10 text-center">
<p className="text-slate-700 dark:text-slate-300">
No employer entity is claimed on this account yet.
</p>
<p className="max-w-md text-sm text-slate-600 dark:text-slate-400">
Claim your company&apos;s entity from the marketplace so your postings,
applicant counts, and profile appear here. Claims are reviewed by an
operator before they activate.
</p>
</CardBody>
</Card>
);
}
if (error) {
return (
<Card>
<CardBody className="flex flex-col items-center gap-2 py-10 text-center">
<p className="text-slate-700 dark:text-slate-300">{error}</p>
<p className="text-sm text-slate-500 dark:text-slate-400">
The dashboard service is unavailable — please retry shortly.
</p>
</CardBody>
</Card>
);
}
if (!me) return null;
// Claim-pending: a claim exists and is not approved — the honest
// "submitted, awaiting review" state (A-304: never shown as verified).
const claimPending =
me.claim !== null && me.claim.status === 'pending' && me.claimed_employer_id === null;
if (claimPending) {
return (
<Card>
<CardBody className="flex flex-col items-center gap-3 py-10 text-center">
<p className="text-slate-700 dark:text-slate-300">
Claim submitted, awaiting review.
</p>
<p className="max-w-md text-sm text-slate-600 dark:text-slate-400">
Your claim on this employer entity is pending an operator decision.
Your postings and applicant counts appear here once the claim is
approved.
</p>
</CardBody>
</Card>
);
}
const facets = postings?.facets;
const activePostings = facets?.published ?? 0;
const totalApplicants =
postings?.postings.reduce((sum, p) => sum + p.applicant_count, 0) ?? 0;
// v0.11 P3 (D-107): the active-placement count from the rows at
// query time — the honest 0 when none exist.
const activePlacements =
placements?.placements.filter((p) => p.status === 'active').length ?? 0;
return (
<div className="flex flex-col gap-8">
{/* Header */}
<header className="flex flex-col gap-2">
<Badge variant="info">Employer Dashboard</Badge>
<h1 className="text-3xl font-bold tracking-tight text-slate-900 dark:text-slate-100">
Employer Dashboard
</h1>
<p className="text-slate-600 dark:text-slate-400">
{profile ? (
<>
Welcome back,{' '}
<span className="font-semibold text-slate-800 dark:text-slate-200">
{profile.name}
</span>
. Here is your posting pipeline at a glance.
</>
) : (
<>Your posting pipeline at a glance.</>
)}
</p>
<p className="text-xs text-slate-500 dark:text-slate-400">
Signed in as {me.account.email} — email unverified.
</p>
</header>
{/* Metric cards — postings/applicants/placements REAL (GET
/v1/employers/postings + /placements, v0.11 P3);
talent-match stays mock-labeled (D-092 — no backend computes
it). */}
<section className="grid grid-cols-2 gap-4 lg:grid-cols-4">
<MetricCard label="Active Postings" value={activePostings} icon={Briefcase} tone="primary" />
<MetricCard label="Total Applicants" value={totalApplicants} icon={Users} tone="accent" />
<MetricCard
label="Placements Recorded"
value={activePlacements}
icon={TrendingUp}
tone="amber"
/>
<MockMetricCard label="Talent Matches" value="—" icon={Target} tone="violet" />
</section>
{/* Middle row — per-posting applicant table + claimed-entity profile */}
<section className="grid grid-cols-1 gap-6 lg:grid-cols-3">
<div className="lg:col-span-2">
<div className="mb-3 flex items-center justify-between">
<h2 className="text-lg font-semibold text-slate-900 dark:text-slate-100">
Postings &amp; Applicants
</h2>
<Link
href="/employer/postings"
className="inline-flex items-center gap-1 text-sm font-medium text-primary-700 hover:underline dark:text-primary-300"
>
Manage postings <ArrowRight className="h-3.5 w-3.5" />
</Link>
</div>
<Card>
{postings && postings.postings.length > 0 ? (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-slate-200 text-left text-xs uppercase tracking-wide text-slate-500 dark:border-slate-800 dark:text-slate-400">
<th className="p-3 font-medium">Posting</th>
<th className="p-3 font-medium">Status</th>
<th className="p-3 text-right font-medium">Applicants</th>
</tr>
</thead>
<tbody>
{postings.postings.map((p) => (
<tr key={p.id} className="border-b border-slate-100 last:border-0 dark:border-slate-800/60">
<td className="p-3 font-medium text-slate-900 dark:text-slate-100">{p.title}</td>
<td className="p-3"><PostingStatusBadge status={p.status} /></td>
<td className="p-3 text-right font-semibold text-slate-700 dark:text-slate-200">
{p.applicant_count}
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<CardBody className="py-8 text-center text-slate-600 dark:text-slate-400">
No postings yet — your created postings and their applicant
counts appear here.
</CardBody>
)}
</Card>
{facets && (
<p className="mt-2 text-xs text-slate-500 dark:text-slate-400">
{facets.draft} draft · {facets.published} published · {facets.closed} closed
</p>
)}
</div>
{/* Claimed-entity profile — the public marketplace fetch
(enrichment retained, R-61). */}
<div>
<div className="mb-3 flex items-center justify-between">
<h2 className="text-lg font-semibold text-slate-900 dark:text-slate-100">
Entity Profile
</h2>
</div>
<Card>
{profile ? (
<CardBody className="flex flex-col gap-3">
<div className="flex items-center gap-3">
<Avatar name={profile.name} src={profile.logo} size="md" />
<div className="flex min-w-0 flex-col">
<span className="truncate text-sm font-medium text-slate-800 dark:text-slate-100">
{profile.name}
</span>
<span className="truncate text-xs text-slate-500 dark:text-slate-400">
{profile.industry || 'Industry unknown'}
</span>
</div>
</div>
<p className="line-clamp-3 text-xs text-slate-600 dark:text-slate-400">
{profile.description}
</p>
<p className="text-xs text-slate-500 dark:text-slate-400">
{profile.location || 'Location unknown'} · {profile.size || 'Size unknown'}
</p>
</CardBody>
) : (
<CardBody className="py-8 text-center text-slate-600 dark:text-slate-400">
Profile unavailable right now.
</CardBody>
)}
</Card>
</div>
</section>
{/* v0.11 P3 (D-109 surface 2, REQ-11-003): REAL placements — the
hired applications with role + start date. The mock
placement block is REPLACED (D-092); talent-match analytics
stay mock-labeled (no backend computes them). */}
<section>
<div className="mb-3 flex items-center justify-between">
<h2 className="text-lg font-semibold text-slate-900 dark:text-slate-100">
Placements
</h2>
<Link
href="/employer/applications"
className="inline-flex items-center gap-1 text-sm font-medium text-primary-700 hover:underline dark:text-primary-300"
>
Manage hiring pipeline <ArrowRight className="h-3.5 w-3.5" />
</Link>
</div>
<Card>
{placements && placements.placements.length > 0 ? (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-slate-200 text-left text-xs uppercase tracking-wide text-slate-500 dark:border-slate-800 dark:text-slate-400">
<th className="p-3 font-medium">Role</th>
<th className="p-3 font-medium">Learner</th>
<th className="p-3 font-medium">Start Date</th>
<th className="p-3 font-medium">Status</th>
</tr>
</thead>
<tbody>
{placements.placements.map((p) => (
<tr
key={p.id}
className="border-b border-slate-100 last:border-0 dark:border-slate-800/60"
>
<td className="p-3 font-medium text-slate-900 dark:text-slate-100">
{p.role_title}
</td>
<td className="p-3 text-slate-700 dark:text-slate-300">{p.learner_id}</td>
<td className="p-3 text-slate-700 dark:text-slate-300">{p.start_date}</td>
<td className="p-3">
{p.status === 'active' ? (
<Badge variant="success">Active</Badge>
) : (
<Badge variant="default">Revoked</Badge>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<CardBody className="py-8 text-center text-sm text-slate-500 dark:text-slate-400">
No placements recorded yet — hires you record in the hiring
pipeline appear here.
</CardBody>
)}
</Card>
</section>
{/* Mock-labeled analytics (D-092: no backend computes these). */}
<section>
<h2 className="mb-3 text-lg font-semibold text-slate-900 dark:text-slate-100">
Analytics <span className="text-sm font-normal text-slate-500 dark:text-slate-400">(sample data)</span>
</h2>
<MockAnalyticsNotice />
</section>
</div>
);
}
/* -- metric cards ------------------------------------------------ */
const TONE_CLASSES: Record<string, string> = {
primary: 'bg-primary-100 text-primary-700 dark:bg-primary-900/40 dark:text-primary-300',
accent: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300',
violet: 'bg-violet-100 text-violet-700 dark:bg-violet-900/40 dark:text-violet-300',
amber: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300',
};
function MetricCard({
label,
value,
icon: Icon,
tone,
}: {
label: string;
value: number;
icon: typeof Briefcase;
tone: string;
}) {
return (
<Card>
<CardBody className="flex flex-col gap-3">
<span className={`inline-flex h-10 w-10 items-center justify-center rounded-full ${TONE_CLASSES[tone]}`}>
<Icon className="h-5 w-5" />
</span>
<div className="flex flex-col">
<span className="text-2xl font-bold text-slate-900 dark:text-slate-100">{value}</span>
<span className="text-sm text-slate-500 dark:text-slate-400">{label}</span>
</div>
</CardBody>
</Card>
);
}
/** D-092: mock-labeled metric — the honest label rides the card. */
function MockMetricCard(props: {
label: string;
value: string;
icon: typeof Briefcase;
tone: string;
}) {
return (
<Card>
<CardBody className="flex flex-col gap-3">
<span className={`inline-flex h-10 w-10 items-center justify-center rounded-full ${TONE_CLASSES[props.tone]}`}>
<props.icon className="h-5 w-5" />
</span>
<div className="flex flex-col">
<span className="text-2xl font-bold text-slate-900 dark:text-slate-100">{props.value}</span>
<span className="text-sm text-slate-500 dark:text-slate-400">
{props.label} <span className="text-xs">(mock — no backend computes this)</span>
</span>
</div>
</CardBody>
</Card>
);
}
/** The real posting lifecycle badges (draft → published → closed). */
function PostingStatusBadge({ status }: { status: 'draft' | 'published' | 'closed' }) {
if (status === 'published') return <Badge variant="success">Published</Badge>;
if (status === 'draft') return <Badge variant="warning">Draft</Badge>;
return <Badge variant="default">Closed</Badge>;
}
/** D-092 honest mock label for the analytics section — the charts ride
* nowhere until a backend computes them; the copy says so. v0.11:
* placement COUNTS are now real (the section above) — only the
* talent-match analytics remain mock. */
function MockAnalyticsNotice() {
return (
<p className="text-sm text-slate-500 dark:text-slate-400">
Talent-match analytics are sample data at pilot — no backend computes them yet.
Real posting, applicant, and placement counts appear above.
</p>
);
}
@@ -1,111 +0,0 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { Button, Input } from '@nextcraft/ui';
import {
loginEmployer,
setEmployerSessionToken,
SessionRequiredError,
RateLimitedError,
EngineError,
} from '../../lib/engine-client';
/**
* Employer sign-in client (REQ-10-003, R-61 — the first login page in
* any route group). Honest at every state: the route's single 401
* body (no account enumeration, D-090) renders as invalid-credentials
* copy; 429 renders the throttle wait; 422 renders static validation
* copy that NEVER echoes the submitted password (the server-side
* pii_fields scrub is pinned already — the client never echoes it
* either). Success stores the tab-scoped session token and redirects
* to the dashboard.
*/
export function LoginClient() {
const router = useRouter();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const abortRef = useRef<AbortController | null>(null);
useEffect(() => () => abortRef.current?.abort(), []);
const submit = async (event: React.FormEvent) => {
event.preventDefault();
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
setError(null);
setLoading(true);
try {
const session = await loginEmployer({ email, password }, controller.signal);
setEmployerSessionToken(session.token);
router.push('/employer');
} catch (err) {
if (controller.signal.aborted) return;
setError(authErrorMessage(err));
} finally {
if (!controller.signal.aborted) setLoading(false);
}
};
return (
<form onSubmit={submit} className="flex flex-col gap-4" noValidate>
<Input
label="Work email"
name="email"
type="email"
autoComplete="email"
placeholder="you@company.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
<Input
label="Password"
name="password"
type="password"
autoComplete="current-password"
placeholder="Your password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
{error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>}
<Button type="submit" variant="primary" size="md" loading={loading}>
{loading ? 'Signing in…' : 'Sign in'}
</Button>
<p className="text-sm text-slate-600 dark:text-slate-400">
No employer account yet?{' '}
<Link
href="/employer/register"
className="font-medium text-primary-700 hover:underline dark:text-primary-300"
>
Register
</Link>
</p>
</form>
);
}
/** The honest error copy — every branch is a static literal: the
* submitted password is never interpolated into any message. */
function authErrorMessage(err: unknown): string {
if (err instanceof SessionRequiredError) {
// The login route's single 401 (unknown email and wrong password
// share one body — no account enumeration, D-090).
return 'Invalid email or password.';
}
if (err instanceof RateLimitedError) {
return 'Too many attempts — wait a minute and try again.';
}
if (err instanceof EngineError) {
if (err.status === 422) {
return 'That email or password is not valid yet — check both and try again.';
}
return err.message; // honest server detail (never contains the password)
}
return 'Sign-in failed — please try again.';
}
@@ -1,699 +0,0 @@
'use client';
import { useCallback, useEffect, useRef, useState } from 'react';
import Link from 'next/link';
import { Badge, Button, Card, CardBody, Input } from '@nextcraft/ui';
import type {
EmployerMe,
OwnPostingsResponse,
OwnPostingSummary,
EmployerPostingCreate,
EmployerPostingEdit,
Seniority,
} from '@nextcraft/types';
import { formatSalaryRange, relativeTime, seniorityLabel } from '../../lib/format';
import {
getEmployerMe,
listOwnEmployerPostings,
createOwnEmployerPosting,
editOwnEmployerPosting,
publishOwnEmployerPosting,
closeOwnEmployerPosting,
SessionRequiredError,
ClaimRequiredError,
UpgradeRequiredError,
EngineError,
} from '../../lib/engine-client';
/**
* Own-postings lifecycle view (REQ-10-003/004, D-092 — the jobs mock
* is GONE): the claimed employer's OWN postings from GET
* /v1/employers/postings — draft → published → closed all visible,
* real per-posting applicant counts. P4 adds the WRITE actions
* (create draft, edit own draft, publish, close) over the
* engine-client employer section.
*
* Honest write states (Task 4-2-01): loading per-action, error
* per-action; 402 → the upgrade CTA rendered from the payload's
* reason + upgrade_cta ONLY (G-320b: the payload's zeroed usage
* fields are byte-stable wire bytes and are NEVER rendered as
* limits — real limits render exclusively from the entitlement
* view, P5); 403 claim-CTA → the claim prompt; 409 → the honest
* own-drafts-only copy; 404 honest. NO optimistic updates — the
* list is REFETCHED after every successful action (the read route is
* the truth source). Session-gated: logged-out → the login CTA
* (never a retry loop); unclaimed → the claim-CTA state;
* claim-pending → the honest awaiting-review state.
*/
export function OwnPostingsClient() {
const [me, setMe] = useState<EmployerMe | null>(null);
const [postings, setPostings] = useState<OwnPostingsResponse | null>(null);
const [loggedOut, setLoggedOut] = useState(false);
const [claimPrompt, setClaimPrompt] = useState(false);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [, reload] = useState(0);
const [selectedId, setSelectedId] = useState<string>('');
// Per-action honest states (Task 4-2-01): one busy key + one error
// message per action — never a global spinner, never a silent
// failure. The 402 upgrade state renders reason + CTA only (G-320b).
const [actionBusy, setActionBusy] = useState<string | null>(null);
const [actionError, setActionError] = useState<{ key: string; message: string } | null>(null);
const [upgradeRequired, setUpgradeRequired] = useState<{
reason: string;
upgradeCta: string;
} | null>(null);
const [showCreate, setShowCreate] = useState(false);
const abortRef = useRef<AbortController | null>(null);
const refetch = useCallback((signal?: AbortSignal) => {
return listOwnEmployerPostings(signal).then(setPostings);
}, []);
useEffect(() => {
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
(async () => {
try {
const meBody = await getEmployerMe(controller.signal);
setMe(meBody);
const own = await listOwnEmployerPostings(controller.signal);
setPostings(own);
} catch (err) {
if (controller.signal.aborted) return;
if (err instanceof SessionRequiredError) {
setLoggedOut(true); // honest logged-out state — never a retry loop
} else if (err instanceof ClaimRequiredError) {
setClaimPrompt(true); // honest claim-CTA state
} else {
setError(err instanceof Error ? err.message : 'Failed to load postings.');
}
} finally {
if (!controller.signal.aborted) setLoading(false);
}
})();
return () => controller.abort();
// `reload` is the refetch trigger: runAction bumps it AFTER a
// successful write so the read route re-runs (no optimistic
// updates — the list is the truth source).
}, [reload]);
const selected: OwnPostingSummary | null =
postings?.postings.find((p) => p.id === selectedId) ?? postings?.postings[0] ?? null;
/** The shared write-action runner: per-action busy/error states, the
* 402/403/404/409 discrimination, and a REFETCH after every success
* (no optimistic updates — the read route is the truth source). */
const runAction = useCallback(
async (key: string, action: (signal: AbortSignal) => Promise<unknown>) => {
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
setActionBusy(key);
setActionError(null);
setUpgradeRequired(null);
try {
await action(controller.signal);
await refetch(controller.signal);
reload((n) => n + 1); // re-run the read effect (truth source)
return true;
} catch (err) {
if (controller.signal.aborted) return false;
if (err instanceof UpgradeRequiredError) {
// G-320b (binding): the 402 branch renders reason + the
// upgrade CTA ONLY — the payload's zeroed usage fields are
// byte-stable wire bytes and are NEVER rendered as limits;
// real limits render exclusively from the entitlement view.
setUpgradeRequired({ reason: err.reason, upgradeCta: err.upgradeCta });
} else if (err instanceof SessionRequiredError) {
setLoggedOut(true); // honest logged-out state — never a retry loop
} else if (err instanceof ClaimRequiredError) {
setClaimPrompt(true); // honest claim-prompt on 403
} else if (err instanceof EngineError) {
setActionError({ key, message: writeActionMessage(err) });
} else {
setActionError({ key, message: 'The action failed — please try again.' });
}
return false;
} finally {
if (!controller.signal.aborted) setActionBusy(null);
}
},
[refetch],
);
const publish = (id: string) =>
runAction(`publish:${id}`, (signal) => publishOwnEmployerPosting(id, signal));
const close = (id: string) =>
runAction(`close:${id}`, (signal) => closeOwnEmployerPosting(id, signal));
const createDraft = (body: EmployerPostingCreate) =>
runAction('create', (signal) => createOwnEmployerPosting(body, signal)).then((ok) => {
if (ok) setShowCreate(false);
return ok;
});
const editDraft = (id: string, body: EmployerPostingEdit) =>
runAction(`edit:${id}`, (signal) => editOwnEmployerPosting(id, body, signal));
if (loading) {
return (
<Card>
<CardBody className="py-10 text-center text-slate-600 dark:text-slate-400">
Loading your postings…
</CardBody>
</Card>
);
}
if (loggedOut) {
return (
<Card>
<CardBody className="flex flex-col items-center gap-3 py-10 text-center">
<p className="text-slate-700 dark:text-slate-300">
You are signed out — your session is missing or expired.
</p>
<div className="flex gap-2">
<Link href="/employer/login">
<Button variant="primary" size="md">Sign in</Button>
</Link>
<Link href="/employer/register">
<Button variant="outline" size="md">Register</Button>
</Link>
</div>
</CardBody>
</Card>
);
}
if (claimPrompt) {
return (
<Card>
<CardBody className="flex flex-col items-center gap-3 py-10 text-center">
<p className="text-slate-700 dark:text-slate-300">
No employer entity is claimed on this account yet.
</p>
<p className="max-w-md text-sm text-slate-600 dark:text-slate-400">
Claim your company&apos;s entity to see and manage its postings here.
Claims are reviewed by an operator before they activate.
</p>
</CardBody>
</Card>
);
}
if (error) {
return (
<Card>
<CardBody className="flex flex-col items-center gap-2 py-10 text-center">
<p className="text-slate-700 dark:text-slate-300">{error}</p>
<p className="text-sm text-slate-500 dark:text-slate-400">
The postings service is unavailable — please retry shortly.
</p>
</CardBody>
</Card>
);
}
const claimPending =
me?.claim !== null && me?.claim !== undefined && me.claim.status === 'pending';
return (
<div className="flex flex-col gap-6">
{claimPending && (
<Card>
<CardBody className="text-sm text-slate-600 dark:text-slate-400">
Claim submitted, awaiting review — your entity&apos;s postings appear
here once an operator approves the claim.
</CardBody>
</Card>
)}
{upgradeRequired && <UpgradeNotice reason={upgradeRequired.reason} cta={upgradeRequired.upgradeCta} />}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[360px_1fr]">
{/* Left panel — the real posting list */}
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">
Postings
</h2>
{/* P4 (REQ-10-004): the write actions are live — create
draft (form below), edit own draft, publish, close. */}
<Button
size="sm"
variant="primary"
type="button"
onClick={() => { setShowCreate((v) => !v); setActionError(null); }}
disabled={actionBusy !== null}
>
New draft
</Button>
</div>
<div className="flex flex-col gap-2">
{postings && postings.postings.length > 0 ? (
postings.postings.map((p) => {
const active = selected?.id === p.id;
return (
<button
key={p.id}
type="button"
onClick={() => setSelectedId(p.id)}
className={[
'flex flex-col gap-1.5 rounded-lg border p-3 text-left transition-colors',
active
? 'border-primary-500 bg-primary-50 dark:border-primary-700 dark:bg-primary-900/30'
: 'border-slate-200 bg-white hover:bg-slate-50 dark:border-slate-800 dark:bg-slate-900 dark:hover:bg-slate-800',
].join(' ')}
>
<div className="flex items-start justify-between gap-2">
<span className="text-sm font-semibold text-slate-900 dark:text-slate-100">
{p.title}
</span>
<PostingStatusBadge status={p.status} />
</div>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-slate-500 dark:text-slate-400">
<span>{p.posted_at ? relativeTime(p.posted_at) : 'not published'}</span>
<span>·</span>
<span>{p.applicant_count} applicants</span>
</div>
</button>
);
})
) : (
<div className="flex h-32 items-center justify-center rounded-lg border border-dashed border-slate-300 text-center text-sm text-slate-500 dark:border-slate-700 dark:text-slate-400">
No postings yet — created postings appear here.
</div>
)}
</div>
{postings && (
<p className="text-xs text-slate-500 dark:text-slate-400">
{postings.facets.draft} draft · {postings.facets.published} published ·{' '}
{postings.facets.closed} closed
</p>
)}
</div>
{/* Right panel — detail + the P4 write actions */}
<div>
{showCreate ? (
<CreateDraftForm
busy={actionBusy === 'create'}
error={actionError?.key === 'create' ? actionError.message : null}
onSubmit={createDraft}
onCancel={() => setShowCreate(false)}
/>
) : selected ? (
<PostingDetail
key={selected.id}
posting={selected}
busyKey={actionBusy}
error={actionError}
onPublish={publish}
onClose={close}
onEdit={editDraft}
/>
) : (
<div className="flex h-64 items-center justify-center rounded-lg border border-dashed border-slate-300 text-slate-500 dark:border-slate-700 dark:text-slate-400">
Select a posting to view details
</div>
)}
</div>
</div>
</div>
);
}
/** The G-320b 402 surface: reason + upgrade CTA ONLY. The payload's
* zeroed usage fields are byte-stable wire bytes (the D-091 wire
* contract) and are NEVER rendered as limits — real limits render
* exclusively from the entitlement view (P5's route). The CTA routes
* to the employer billing surface. */
function UpgradeNotice({ reason, cta }: { reason: string; cta: string }) {
return (
<Card>
<CardBody className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<Badge variant="warning">Upgrade required</Badge>
</div>
<p className="text-slate-700 dark:text-slate-300">{cta}</p>
<p className="text-sm text-slate-500 dark:text-slate-400">
Reason: {reason === 'no_active_package'
? 'your employer account has no active posting package.'
: reason === 'posting_limit_reached'
? 'your posting package has no posts left.'
: reason}
</p>
<div>
<Link href="/employer/billing">
<Button variant="primary" size="md">View packages</Button>
</Link>
</div>
<p className="text-xs text-slate-500 dark:text-slate-400">
Self-serve checkout lives on the billing surface — buy a package
yourself, or contact your operator to purchase one on your behalf.
</p>
</CardBody>
</Card>
);
}
/** The honest write-action error copy — every branch a literal, never
* a fabricated detail (409 own-drafts-only, 404, 422, otherwise the
* server's own honest detail). */
function writeActionMessage(err: EngineError): string {
if (err.status === 409) {
return 'Only your own drafts can be edited or published — this posting is not an editable draft.';
}
if (err.status === 404) {
return 'This posting no longer exists — it may have been removed. The list below is the current truth.';
}
if (err.status === 403) {
return 'This posting belongs to another employer entity — you can only manage your own.';
}
if (err.status === 422) {
return 'The posting details are not valid yet — check the title and salary fields.';
}
return err.message; // the honest server detail
}
function PostingStatusBadge({ status }: { status: 'draft' | 'published' | 'closed' }) {
if (status === 'published') return <Badge variant="success">Published</Badge>;
if (status === 'draft') return <Badge variant="warning">Draft</Badge>;
return <Badge variant="default">Closed</Badge>;
}
interface ActionError {
key: string;
message: string;
}
function PostingDetail({
posting,
busyKey,
error,
onPublish,
onClose,
onEdit,
}: {
posting: OwnPostingSummary;
busyKey: string | null;
error: ActionError | null;
onPublish: (id: string) => Promise<boolean>;
onClose: (id: string) => Promise<boolean>;
onEdit: (id: string, body: EmployerPostingEdit) => Promise<boolean>;
}) {
const [editing, setEditing] = useState(false);
const [title, setTitle] = useState(posting.title);
const [description, setDescription] = useState(posting.description);
const editError = error?.key === `edit:${posting.id}` ? error.message : null;
const publishBusy = busyKey === `publish:${posting.id}`;
const closeBusy = busyKey === `close:${posting.id}`;
const submitEdit = async (event: React.FormEvent) => {
event.preventDefault();
const ok = await onEdit(posting.id, { title, description });
if (ok) setEditing(false);
};
return (
<div className="flex flex-col gap-6">
<div className="rounded-lg border border-slate-200 bg-white p-5 dark:border-slate-800 dark:bg-slate-900">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold text-slate-900 dark:text-slate-100">
{posting.title}
</h2>
<PostingStatusBadge status={posting.status} />
</div>
{editing ? (
<form onSubmit={submitEdit} className="flex flex-col gap-4" noValidate>
<Input
label="Title"
name="title"
value={title}
onChange={(e) => setTitle(e.target.value)}
required
/>
<div className="flex flex-col gap-1.5">
<label htmlFor="edit-description" className="text-sm font-medium text-slate-700 dark:text-slate-200">
Description
</label>
<textarea
id="edit-description"
className="min-h-24 w-full rounded-md border border-slate-300 bg-white p-3 text-sm text-slate-900 focus:border-primary-500 focus:ring-2 focus:ring-primary-500/30 focus:outline-none dark:border-slate-700 dark:bg-slate-800 dark:text-slate-100"
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
{editError && <p className="text-sm text-red-600 dark:text-red-400">{editError}</p>}
<div className="flex flex-wrap gap-2">
<Button type="submit" variant="primary" size="md" loading={busyKey === `edit:${posting.id}`}>
Save changes
</Button>
<Button
type="button"
variant="outline"
size="md"
onClick={() => setEditing(false)}
disabled={busyKey !== null}
>
Cancel
</Button>
</div>
</form>
) : (
<div className="flex flex-col gap-4 text-sm text-slate-700 dark:text-slate-300">
<p className="whitespace-pre-line">{posting.description}</p>
{posting.required_competencies.length > 0 && (
<div className="flex flex-wrap gap-2">
{posting.required_competencies.map((c) => (
<span
key={c}
className="inline-flex items-center gap-1 rounded-full bg-primary-100 px-2.5 py-1 text-xs font-medium text-primary-700 dark:bg-primary-900/40 dark:text-primary-300"
>
{c}
</span>
))}
</div>
)}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<DetailRow label="Seniority">
{posting.seniority ? seniorityLabel(posting.seniority) : '—'}
</DetailRow>
<DetailRow label="Location">
{posting.location}
{posting.remote ? ' · Remote' : ''}
</DetailRow>
<DetailRow label="Salary">
{posting.salary_min !== null && posting.salary_max !== null
? formatSalaryRange(posting.salary_min, posting.salary_max)
: '—'}
</DetailRow>
<DetailRow label="Published">
{posting.posted_at ? relativeTime(posting.posted_at) : 'not published yet'}
</DetailRow>
</div>
{editError && <p className="text-sm text-red-600 dark:text-red-400">{editError}</p>}
{/* P4 write actions (REQ-10-004): edit own draft, publish,
close — real dispatch, per-action loading/error states,
a refetch after every success (no optimistic updates). */}
<div className="flex flex-wrap gap-2 pt-2">
{posting.status === 'draft' && (
<>
<Button
variant="primary"
size="md"
type="button"
onClick={() => setEditing(true)}
disabled={busyKey !== null}
>
Edit draft
</Button>
<Button
variant="outline"
size="md"
type="button"
onClick={() => void onPublish(posting.id)}
loading={publishBusy}
disabled={busyKey !== null && !publishBusy}
>
Publish
</Button>
</>
)}
{posting.status === 'published' && (
<Button
variant="destructive"
size="md"
type="button"
onClick={() => void onClose(posting.id)}
loading={closeBusy}
disabled={busyKey !== null && !closeBusy}
>
Close posting
</Button>
)}
</div>
</div>
)}
</div>
{/* Applicant COUNT (G-319 PII boundary: counts only — learner
identifiers never ride any employer response). */}
<div className="rounded-lg border border-slate-200 bg-white p-5 dark:border-slate-800 dark:bg-slate-900">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold text-slate-900 dark:text-slate-100">Applicants</h2>
<span className="text-sm text-slate-500 dark:text-slate-400">
{posting.applicant_count} total
</span>
</div>
<p className="text-sm text-slate-600 dark:text-slate-400">
{posting.applicant_count === 0
? 'No applicants yet — counts accrue as learners apply.'
: `${posting.applicant_count} ${posting.applicant_count === 1 ? 'learner has' : 'learners have'} applied to this posting.`}
</p>
</div>
</div>
);
}
const SENIORITY_OPTIONS: Seniority[] = ['entry', 'mid', 'senior', 'staff', 'principal'];
function CreateDraftForm({
busy,
error,
onSubmit,
onCancel,
}: {
busy: boolean;
error: string | null;
onSubmit: (body: EmployerPostingCreate) => Promise<boolean>;
onCancel: () => void;
}) {
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [location, setLocation] = useState('');
const [remote, setRemote] = useState(false);
const [seniority, setSeniority] = useState<Seniority | ''>('');
const [salaryMin, setSalaryMin] = useState('');
const [salaryMax, setSalaryMax] = useState('');
const submit = async (event: React.FormEvent) => {
event.preventDefault();
const ok = await onSubmit({
title,
description,
location,
remote,
seniority: seniority || null,
salary_min: salaryMin ? Number(salaryMin) : null,
salary_max: salaryMax ? Number(salaryMax) : null,
});
if (ok) {
setTitle('');
setDescription('');
setLocation('');
setRemote(false);
setSeniority('');
setSalaryMin('');
setSalaryMax('');
}
};
return (
<div className="rounded-lg border border-slate-200 bg-white p-5 dark:border-slate-800 dark:bg-slate-900">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold text-slate-900 dark:text-slate-100">
New draft posting
</h2>
</div>
<form onSubmit={submit} className="flex flex-col gap-4" noValidate>
<Input
label="Title"
name="new-title"
value={title}
onChange={(e) => setTitle(e.target.value)}
required
/>
<div className="flex flex-col gap-1.5">
<label htmlFor="new-description" className="text-sm font-medium text-slate-700 dark:text-slate-200">
Description
</label>
<textarea
id="new-description"
className="min-h-24 w-full rounded-md border border-slate-300 bg-white p-3 text-sm text-slate-900 focus:border-primary-500 focus:ring-2 focus:ring-primary-500/30 focus:outline-none dark:border-slate-700 dark:bg-slate-800 dark:text-slate-100"
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Input label="Location" name="new-location" value={location}
onChange={(e) => setLocation(e.target.value)} />
<div className="flex flex-col gap-1.5">
<label htmlFor="new-seniority" className="text-sm font-medium text-slate-700 dark:text-slate-200">
Seniority
</label>
<select
id="new-seniority"
className="h-10 w-full rounded-md border border-slate-300 bg-white px-3 text-sm text-slate-900 focus:border-primary-500 focus:ring-2 focus:ring-primary-500/30 focus:outline-none dark:border-slate-700 dark:bg-slate-800 dark:text-slate-100"
value={seniority}
onChange={(e) => setSeniority(e.target.value as Seniority | '')}
>
<option value="">Not set</option>
{SENIORITY_OPTIONS.map((s) => (
<option key={s} value={s}>{seniorityLabel(s)}</option>
))}
</select>
</div>
<Input label="Salary minimum" name="new-salary-min" type="number" min={0}
value={salaryMin} onChange={(e) => setSalaryMin(e.target.value)} />
<Input label="Salary maximum" name="new-salary-max" type="number" min={0}
value={salaryMax} onChange={(e) => setSalaryMax(e.target.value)} />
</div>
<label className="flex cursor-pointer items-center gap-2 text-sm text-slate-700 dark:text-slate-200">
<input
type="checkbox"
checked={remote}
onChange={(e) => setRemote(e.target.checked)}
className="h-4 w-4 rounded border-slate-300 text-primary-600 dark:border-slate-600"
/>
Remote eligible
</label>
{error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>}
<div className="flex flex-wrap gap-2">
<Button type="submit" variant="primary" size="md" loading={busy}>
Create draft
</Button>
<Button type="button" variant="outline" size="md" onClick={onCancel} disabled={busy}>
Cancel
</Button>
</div>
<p className="text-xs text-slate-500 dark:text-slate-400">
Drafts are private until you publish them — creating a draft uses one post
from your package.
</p>
</form>
</div>
);
}
function DetailRow({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="flex flex-col gap-1">
<span className="text-xs uppercase tracking-wide text-slate-500 dark:text-slate-400">
{label}
</span>
<span className="font-medium text-slate-800 dark:text-slate-200">{children}</span>
</div>
);
}
@@ -0,0 +1,343 @@
'use client';
import { useState } from 'react';
import { Plus, Trash2, Save } from 'lucide-react';
import { Badge, Button } from '@nextcraft/ui';
import type { Job, Seniority } from '@nextcraft/types';
import { formatSalaryK, relativeTime, seniorityLabel } from '../../lib/format';
/* -------------------------------------------------------------------------- */
/* Mock posting list (status + applicant count derived from the Job) */
/* -------------------------------------------------------------------------- */
type PostingStatus = 'active' | 'draft' | 'expired';
interface PostingRow {
job: Job;
status: PostingStatus;
applicants: number;
}
interface ApplicantRow {
name: string;
appliedAt: string;
stage: 'Screening' | 'Interview' | 'Offer';
matchScore: number;
}
/* Deterministically derive a posting status per job id. */
function deriveStatus(job: Job, idx: number): PostingStatus {
if (idx < 8) return 'active';
if (idx < 11) return 'draft';
return 'expired';
}
function deriveApplicants(job: Job, idx: number): number {
return 4 + ((job.matchScore + idx * 7) % 18);
}
function deriveApplicantList(job: Job): ApplicantRow[] {
const names = [
'Maya Okonkwo',
'Devon Park',
'Priya Iyer',
'Tomás Vega',
'Liam Chen',
'Yuki Tanaka',
'Sofia Marchetti',
];
const stages: ApplicantRow['stage'][] = ['Screening', 'Interview', 'Offer'];
const count = 5 + (job.matchScore % 2);
const rows: ApplicantRow[] = [];
for (let i = 0; i < count; i++) {
const stage = stages[i % stages.length];
rows.push({
name: names[(job.matchScore + i) % names.length],
appliedAt: new Date(Date.UTC(2026, 7, 28 - i * 2)).toISOString(),
stage,
matchScore: 78 + ((job.matchScore + i * 3) % 18),
});
}
return rows;
}
/* -------------------------------------------------------------------------- */
/* Status badge */
/* -------------------------------------------------------------------------- */
function StatusBadge({ status }: { status: PostingStatus }) {
if (status === 'active') return <Badge variant="success">Active</Badge>;
if (status === 'draft') return <Badge variant="warning">Draft</Badge>;
return <Badge variant="default">Expired</Badge>;
}
function StageBadge({ stage }: { stage: ApplicantRow['stage'] }) {
if (stage === 'Offer') return <Badge variant="success">Offer</Badge>;
if (stage === 'Interview') return <Badge variant="info">Interview</Badge>;
return <Badge variant="warning">Screening</Badge>;
}
/* -------------------------------------------------------------------------- */
/* Component */
/* -------------------------------------------------------------------------- */
export interface PostingManagerProps {
jobs: Job[];
}
export function PostingManager({ jobs }: PostingManagerProps) {
const postings: PostingRow[] = jobs.map((job, idx) => ({
job,
status: deriveStatus(job, idx),
applicants: deriveApplicants(job, idx),
}));
const [selectedId, setSelectedId] = useState<string>(postings[0]?.job.id ?? '');
const selected = postings.find((p) => p.job.id === selectedId) ?? null;
return (
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[360px_1fr]">
{/* Left panel — posting list */}
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">
Postings
</h2>
<Button size="sm" variant="primary" icon={<Plus className="h-3.5 w-3.5" />} type="button">
New
</Button>
</div>
<div className="flex flex-col gap-2">
{postings.map((p) => {
const active = p.job.id === selectedId;
return (
<button
key={p.job.id}
type="button"
onClick={() => setSelectedId(p.job.id)}
className={[
'flex flex-col gap-1.5 rounded-lg border p-3 text-left transition-colors',
active
? 'border-primary-500 bg-primary-50 dark:border-primary-700 dark:bg-primary-900/30'
: 'border-slate-200 bg-white hover:bg-slate-50 dark:border-slate-800 dark:bg-slate-900 dark:hover:bg-slate-800',
].join(' ')}
>
<div className="flex items-start justify-between gap-2">
<span className="text-sm font-semibold text-slate-900 dark:text-slate-100">
{p.job.title}
</span>
<StatusBadge status={p.status} />
</div>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-slate-500 dark:text-slate-400">
<span>{relativeTime(p.job.postedAt)}</span>
<span>·</span>
<span>{p.applicants} applicants</span>
</div>
</button>
);
})}
</div>
</div>
{/* Right panel — detail */}
<div>
{selected ? (
<PostingDetail posting={selected} />
) : (
<div className="flex h-64 items-center justify-center rounded-lg border border-dashed border-slate-300 text-slate-500 dark:border-slate-700 dark:text-slate-400">
Select a posting to view details
</div>
)}
</div>
</div>
);
}
function PostingDetail({ posting }: { posting: PostingRow }) {
const { job, applicants } = posting;
const applicantList = deriveApplicantList(job);
const seniorityOptions: Seniority[] = ['entry', 'mid', 'senior', 'staff', 'principal'];
const inputClass =
'h-10 w-full rounded-md border border-slate-300 bg-white px-3 text-sm text-slate-900 focus:border-primary-500 focus:ring-2 focus:ring-primary-500/30 focus:outline-none dark:border-slate-700 dark:bg-slate-800 dark:text-slate-100';
return (
<div className="flex flex-col gap-6">
{/* Posting form */}
<div className="rounded-lg border border-slate-200 bg-white p-5 dark:border-slate-800 dark:bg-slate-900">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold text-slate-900 dark:text-slate-100">
Edit Posting
</h2>
<StatusBadge status={posting.status} />
</div>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium text-slate-700 dark:text-slate-200">Title</label>
<input className={inputClass} defaultValue={job.title} aria-label="Posting title" />
</div>
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium text-slate-700 dark:text-slate-200">Description</label>
<textarea
className="min-h-24 w-full rounded-md border border-slate-300 bg-white p-3 text-sm text-slate-900 focus:border-primary-500 focus:ring-2 focus:ring-primary-500/30 focus:outline-none dark:border-slate-700 dark:bg-slate-800 dark:text-slate-100"
defaultValue={job.description}
aria-label="Posting description"
/>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium text-slate-700 dark:text-slate-200">
Required Competencies
</label>
<div className="flex flex-wrap gap-2">
{job.requiredCompetencies.map((c) => (
<span
key={c}
className="inline-flex items-center gap-1 rounded-full bg-primary-100 px-2.5 py-1 text-xs font-medium text-primary-700 dark:bg-primary-900/40 dark:text-primary-300"
>
{c}
</span>
))}
</div>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium text-slate-700 dark:text-slate-200">Seniority</label>
<select className={inputClass} defaultValue={job.seniority} aria-label="Seniority">
{seniorityOptions.map((s) => (
<option key={s} value={s}>
{seniorityLabel(s)}
</option>
))}
</select>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium text-slate-700 dark:text-slate-200">Location</label>
<input className={inputClass} defaultValue={job.location} aria-label="Location" />
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium text-slate-700 dark:text-slate-200">
Salary Min
</label>
<input
className={inputClass}
defaultValue={formatSalaryK(job.salaryMin)}
aria-label="Salary minimum"
/>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium text-slate-700 dark:text-slate-200">
Salary Max
</label>
<input
className={inputClass}
defaultValue={formatSalaryK(job.salaryMax)}
aria-label="Salary maximum"
/>
</div>
</div>
<label className="flex cursor-pointer items-center justify-between gap-2 text-sm text-slate-700 dark:text-slate-200">
<span>Remote eligible</span>
<button
type="button"
role="switch"
aria-checked={job.remote}
className={[
'relative h-6 w-11 rounded-full transition-colors',
job.remote ? 'bg-primary-600' : 'bg-slate-300 dark:bg-slate-700',
].join(' ')}
>
<span
className={[
'absolute top-0.5 h-5 w-5 rounded-full bg-white shadow transition-transform',
job.remote ? 'translate-x-5' : 'translate-x-0.5',
].join(' ')}
/>
</button>
</label>
<div className="flex flex-wrap gap-2 pt-2">
<Button variant="primary" size="md" icon={<Save className="h-4 w-4" />} type="button">
Save Changes
</Button>
<Button variant="destructive" size="md" icon={<Trash2 className="h-4 w-4" />} type="button">
Delete Posting
</Button>
</div>
</div>
</div>
{/* Applicant list */}
<div className="rounded-lg border border-slate-200 bg-white p-5 dark:border-slate-800 dark:bg-slate-900">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold text-slate-900 dark:text-slate-100">
Applicants
</h2>
<span className="text-sm text-slate-500 dark:text-slate-400">
{applicants} total · {applicantList.length} shown
</span>
</div>
{/* Table on desktop, cards on mobile */}
<div className="overflow-x-auto">
<table className="hidden w-full md:table">
<thead>
<tr className="border-b border-slate-200 text-left text-xs uppercase tracking-wide text-slate-500 dark:border-slate-800 dark:text-slate-400">
<th className="pb-2 pr-4 font-medium">Applicant</th>
<th className="pb-2 pr-4 font-medium">Applied</th>
<th className="pb-2 pr-4 font-medium">Stage</th>
<th className="pb-2 pr-4 font-medium text-right">Match</th>
</tr>
</thead>
<tbody>
{applicantList.map((a, i) => (
<tr key={i} className="border-b border-slate-100 last:border-0 dark:border-slate-800/60">
<td className="py-2.5 pr-4 text-sm font-medium text-slate-900 dark:text-slate-100">
{a.name}
</td>
<td className="py-2.5 pr-4 text-sm text-slate-600 dark:text-slate-400">
{relativeTime(a.appliedAt)}
</td>
<td className="py-2.5 pr-4"><StageBadge stage={a.stage} /></td>
<td className="py-2.5 pr-4 text-right text-sm font-semibold text-slate-700 dark:text-slate-200">
{a.matchScore}%
</td>
</tr>
))}
</tbody>
</table>
{/* Mobile cards */}
<div className="flex flex-col gap-2 md:hidden">
{applicantList.map((a, i) => (
<div
key={i}
className="flex items-center justify-between rounded-md border border-slate-200 p-3 dark:border-slate-800"
>
<div className="flex flex-col">
<span className="text-sm font-medium text-slate-900 dark:text-slate-100">{a.name}</span>
<span className="text-xs text-slate-500 dark:text-slate-400">
{relativeTime(a.appliedAt)}
</span>
</div>
<div className="flex items-center gap-2">
<StageBadge stage={a.stage} />
<span className="text-sm font-semibold text-slate-700 dark:text-slate-200">
{a.matchScore}%
</span>
</div>
</div>
))}
</div>
</div>
</div>
</div>
);
}
@@ -1,112 +0,0 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { Button, Input } from '@nextcraft/ui';
import {
registerEmployer,
setEmployerSessionToken,
SessionRequiredError,
RateLimitedError,
EngineError,
} from '../../lib/engine-client';
/**
* Employer registration client (REQ-10-003): creates the account
* (email UNVERIFIED — the route pins email_verified=false, D-097)
* and lands the user LOGGED-IN (the 201 carries a fresh session —
* store the tab-scoped token, redirect to the dashboard). Honest
* states: 409 duplicate email, 429 throttle wait, 422 validation
* copy that NEVER echoes the submitted password.
*/
export function RegisterClient() {
const router = useRouter();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const abortRef = useRef<AbortController | null>(null);
useEffect(() => () => abortRef.current?.abort(), []);
const submit = async (event: React.FormEvent) => {
event.preventDefault();
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
setError(null);
setLoading(true);
try {
const session = await registerEmployer({ email, password }, controller.signal);
setEmployerSessionToken(session.token);
router.push('/employer');
} catch (err) {
if (controller.signal.aborted) return;
setError(registerErrorMessage(err));
} finally {
if (!controller.signal.aborted) setLoading(false);
}
};
return (
<form onSubmit={submit} className="flex flex-col gap-4" noValidate>
<Input
label="Work email"
name="email"
type="email"
autoComplete="email"
placeholder="you@company.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
<Input
label="Password"
name="password"
type="password"
autoComplete="new-password"
placeholder="At least 8 characters"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={8}
/>
{error && <p className="text-sm text-red-600 dark:text-red-400">{error}</p>}
<Button type="submit" variant="primary" size="md" loading={loading}>
{loading ? 'Creating account…' : 'Create account'}
</Button>
<p className="text-sm text-slate-600 dark:text-slate-400">
Already registered?{' '}
<Link
href="/employer/login"
className="font-medium text-primary-700 hover:underline dark:text-primary-300"
>
Sign in
</Link>
</p>
</form>
);
}
/** The honest error copy — every branch a static literal: the
* submitted password is never echoed (the 422 pii scrub is the
* server's arm; this is the client's). */
function registerErrorMessage(err: unknown): string {
if (err instanceof SessionRequiredError) {
return 'Sign-in required — your session is missing or expired.';
}
if (err instanceof RateLimitedError) {
return 'Too many attempts — wait a minute and try again.';
}
if (err instanceof EngineError) {
if (err.status === 409) {
return 'An account with this email already exists — sign in instead.';
}
if (err.status === 422) {
return 'Enter a valid email and a password of at least 8 characters.';
}
return err.message; // honest server detail (never contains the password)
}
return 'Registration failed — please try again.';
}
@@ -6,8 +6,6 @@ import { notFound } from 'next/navigation';
import { ArrowLeft, Building2, Check, MapPin, Wallet } from 'lucide-react';
import type {
ApplicationConfirmation,
ApplicationStatus,
LearnerApplicationStatus,
PostingDetail,
EmployerProfile,
SearchResult,
@@ -26,11 +24,8 @@ import {
VerifyRequiredError,
applyPosting,
getEmployer,
getLearnerApplicationStatus,
getMyApplication,
getPosting,
readSearchSelection,
withdrawApplication,
} from '../../lib/engine-client';
import { segmentFromPath } from '../../lib/route-id';
@@ -59,12 +54,6 @@ export function JobDetailClient() {
// P3 G-106: the board's search handoff, read ONCE on mount (empty
// when the visit is not from a search result — no score, no panel).
const [searchSelection, setSearchSelection] = useState<SearchResult | null>(null);
// P5 (G-322): the learner's application on THIS posting — composed
// via the mine-read + the status read on every visit; never a
// client-persisted application id.
const [appStatus, setAppStatus] = useState<LearnerApplicationStatus | null>(null);
const [withdrawing, setWithdrawing] = useState(false);
const [withdrawError, setWithdrawError] = useState<string | null>(null);
useEffect(() => {
const controller = new AbortController();
@@ -87,28 +76,6 @@ 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) {
@@ -130,14 +97,6 @@ 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);
@@ -153,35 +112,6 @@ 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();
}
@@ -312,86 +242,8 @@ export function JobDetailClient() {
</div>
</div>
<div className="flex flex-col items-start gap-3 md:items-end">
{/* Apply flow (D-058): every state renders honestly. P5
* (G-322/D-104): the status panel renders when the
* mine-read finds the application; the apply CTA only
* when it does not. */}
{appStatus ? (
<div
role="status"
className="flex w-full flex-col gap-3 rounded-lg border border-slate-300 bg-slate-50 px-4 py-3 text-sm dark:border-slate-700 dark:bg-slate-900/40"
>
<div className="flex items-center gap-2">
<span className="text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">
Your application
</span>
<Badge
variant={appStatus.application.status === 'hired' ? 'success' : 'default'}
>
{applicationStatusLabel(appStatus.application.status)}
</Badge>
</div>
<span className="text-xs text-slate-500 dark:text-slate-400">
{appStatus.application.created_at
? `Applied ${relativeTime(appStatus.application.created_at)} · `
: ''}
application {appStatus.application.id}
</span>
{appStatus.events.length > 0 ? (
<ul className="flex flex-col gap-1.5">
{appStatus.events.map((event) => (
<li
key={event.id}
className="flex items-center justify-between gap-2 text-xs"
>
<span className="font-medium text-slate-700 dark:text-slate-300">
{applicationEventLabel(event.event_type)}
</span>
<span className="text-slate-500 dark:text-slate-400">
{relativeTime(event.created_at)}
</span>
</li>
))}
</ul>
) : (
<span className="text-xs text-slate-500 dark:text-slate-400">
No status updates yet.
</span>
)}
{appStatus.placement && (
<div
role="status"
className="flex flex-col gap-1 rounded-lg border border-emerald-300 bg-emerald-50 px-3 py-2 text-sm text-emerald-800 dark:border-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-200"
>
<span className="font-semibold">Placement recorded</span>
<span className="text-xs">
{appStatus.placement.role_title} at {employerName} · starts{' '}
{appStatus.placement.start_date}
</span>
</div>
)}
{WITHDRAWABLE_STATUSES.has(appStatus.application.status) ? (
<Button
size="sm"
variant="outline"
type="button"
loading={withdrawing}
onClick={() => void onWithdraw()}
>
Withdraw application
</Button>
) : (
<p className="text-xs text-slate-500 dark:text-slate-400">
{terminalCopy(appStatus.application.status)}
</p>
)}
{withdrawError && (
<p role="alert" className="text-xs text-red-600 dark:text-red-400">
{withdrawError}
</p>
)}
</div>
) : confirmation ? (
{/* Apply flow (D-058): every state renders honestly. */}
{confirmation ? (
<div
role="status"
className="flex flex-col gap-1 rounded-lg border border-emerald-300 bg-emerald-50 px-4 py-3 text-sm text-emerald-800 dark:border-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-200"
@@ -605,78 +457,3 @@ export function JobDetailClient() {
function matchPercent(score: number): number {
return Math.max(0, Math.round(score * 100));
}
/** The seven D-099 states, honestly labeled (no stage invented beyond
* the matrix; unknown statuses render verbatim, never invented). */
function applicationStatusLabel(status: ApplicationStatus): string {
switch (status) {
case 'submitted':
return 'Submitted';
case 'in_review':
return 'In review';
case 'interview':
return 'Interview';
case 'offer':
return 'Offer';
case 'hired':
return 'Hired';
case 'rejected':
return 'Rejected';
case 'withdrawn':
return 'Withdrawn';
default:
return status;
}
}
/** Ledger event labels (the D-101 vocabulary) — unknown event names
* render verbatim, never invented. */
function applicationEventLabel(eventType: string): string {
switch (eventType) {
case 'application.in_review':
return 'Moved to review';
case 'application.interview':
return 'Interview stage';
case 'application.offer':
return 'Offer received';
case 'application.hired':
return 'Hired';
case 'application.rejected':
return 'Rejected';
case 'application.withdrawn':
return 'Withdrawn';
case 'placement.created':
return 'Placement recorded';
case 'placement.revoked':
return 'Placement revoked (operator correction)';
case 'placement.rehired':
return 'Placement re-recorded (operator correction)';
default:
return eventType;
}
}
/** The learner's legal withdraw sources (D-099: terminals have no
* outgoing edges). */
const WITHDRAWABLE_STATUSES: ReadonlySet<ApplicationStatus> = new Set([
'submitted',
'in_review',
'interview',
'offer',
]);
/** Terminal copy (the honest end states — no resurrection, no
* fabricated next step). */
function terminalCopy(status: ApplicationStatus): string {
switch (status) {
case 'hired':
return 'Hired — placement details appear above when a placement is recorded.';
case 'rejected':
return 'This application was not taken forward.';
case 'withdrawn':
return 'You withdrew this application.';
default:
return '';
}
}
@@ -1,7 +1,6 @@
'use client';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { Check, Crown, Star, X, Zap } from 'lucide-react';
import { Badge, Button, Card, CardBody } from '@nextcraft/ui';
import type { Package, Plan } from '@nextcraft/types';
@@ -19,12 +18,8 @@ function formatPrice(cents: number): string {
return `$${(cents / 100).toFixed(0)}`;
}
/** P5 (REQ-10-005, Task 5-2-01 — superseding D-079): self-serve
* checkout is REAL. Purchasable tiers CTA to the employer-login path
* (the employer buys on their claimed entity's behalf from the
* billing surface); the ENTERPRISE contact tier stays "Contact Sales"
* (price_cents=0 rows are operator-mediated by design — the pin at
* pins.test.ts:250 holds). */
/** CTA copy per D-079: the honest pilot path — operator-mediated
* checkout, no fake self-serve purchase buttons. */
function ctaFor(id: string, isEnterprise: boolean): string {
if (isEnterprise) return 'Contact Sales';
if (id === 'pkg-bundle') return 'Buy Bundle';
@@ -99,19 +94,9 @@ function PricingCard({ item, kind }: { item: Package | Plan; kind: 'package' | '
))}
</ul>
<div className="mt-auto pt-2">
{/* P5 (Task 5-2-01): purchasable tiers route to the
* employer-login path (self-serve checkout is real — the
* employer buys from their billing surface); the enterprise
* contact tier keeps the Contact Sales path (operator-mediated
* by design). */}
<Link
href={isEnterprise ? '/marketplace/pricing#contact' : '/employer/login'}
className="block"
>
<Button type="button" variant={isPopular ? 'primary' : 'outline'} size="md" className="w-full">
{ctaFor(item.id, isEnterprise)}
</Button>
</Link>
<Button type="button" variant={isPopular ? 'primary' : 'outline'} size="md" className="w-full">
{ctaFor(item.id, isEnterprise)}
</Button>
</div>
</CardBody>
</Card>
@@ -210,10 +195,8 @@ export function PricingClient() {
</section>
<p className="text-sm text-slate-500 dark:text-slate-400">
Self-serve checkout is live: sign in with your employer account and buy
packages or plans on your claimed entity&apos;s behalf from the billing
surface. Pilot payments run through the mock provider (honestly labeled
there); operator-assisted checkout remains available as a fallback.
Purchases are currently operator-mediated during the pilot — contact the Nextcraft
team to buy a package or plan for your employer account.
</p>
</div>
);
+1 -596
View File
@@ -28,32 +28,6 @@ import type {
SearchResponse,
SearchResult,
CatalogResponse,
EmployerMe,
EmployerTokenResponse,
EmployerClaim,
OwnPostingsResponse,
RegisterEmployerRequest,
LoginEmployerRequest,
ClaimEmployerRequest,
EmployerPostingCreate,
EmployerPostingEdit,
EmployerPostingWriteResponse,
EmployerCheckoutCreate,
EmployerCheckoutResponse,
EmployerEntitlementResponse,
EmployerMockCompleteRequest,
EmployerMockCompleteResponse,
AdminAccountsResponse,
AdminClaimsResponse,
ClaimDecisionResponse,
AdminAuditResponse,
EmployerApplicationsResponse,
ApplicationTransitionResponse,
HireTransitionRequest,
EmployerPlacementsResponse,
LearnerApplicationStatus,
MineApplication,
PlacementOverview,
} from '@nextcraft/types';
import { engineBaseUrl } from './engine-base-url';
@@ -126,38 +100,8 @@ export class UpgradeRequiredError extends EngineError {
}
}
/** v0.10 (REQ-10-003): a 401 from an employer session route — the
* session is missing/expired/unknown. The honest logged-out state
* consumes this (a login CTA, NEVER a retry loop — R-61: no 401
* discrimination existed before the employer surface). */
export class SessionRequiredError extends EngineError {
constructor(message = 'Sign in to continue — your session is missing or expired.') {
super(message, 401);
this.name = 'SessionRequiredError';
}
}
/** A 403 carrying the unclaimed-account claim-CTA (D-090: 403 is the
* authorization class — a valid session whose account holds no claimed
* entity). The dashboard renders the claim prompt from it. */
export class ClaimRequiredError extends EngineError {
constructor(
public readonly reason: string,
public readonly claimCta: string,
) {
super('Claim your employer entity to continue.', 403);
this.name = 'ClaimRequiredError';
}
}
async function parseError(resp: Response): Promise<EngineError> {
if (resp.status === 503) return new EngineBusyError();
if (resp.status === 401) {
// v0.10: the 401 discrimination arm — an employer-session
// credential failure maps to SessionRequiredError so the surface
// renders the honest logged-out state (never a retry loop).
return new SessionRequiredError();
}
if (resp.status === 403) {
// G-10: discriminate 403 reasons — the payload shape decides.
// verify_cta present → identity gate (actionable enrollment prompt);
@@ -169,13 +113,6 @@ async function parseError(resp: Response): Promise<EngineError> {
/* non-JSON 403 body */
}
const detail = (payload as { detail?: unknown } | null)?.detail;
if (detail && typeof detail === 'object' && 'claim_cta' in (detail as object)) {
// v0.10 (D-090): the unclaimed-account 403 — a valid session
// whose account holds no claimed entity. The claim-CTA payload
// drives the dashboard's claim prompt.
const cta = detail as { reason: string; claim_cta: string };
return new ClaimRequiredError(cta.reason, cta.claim_cta);
}
if (detail && typeof detail === 'object' && 'verify_cta' in (detail as object)) {
const cta = detail as {
reason: string;
@@ -847,536 +784,4 @@ export async function getEntitlement(
return (await resp.json()) as EntitlementResponse;
}
// -- employer admin operator routes (v0.10 P5, REQ-10-005, D-094) -------------------
/** The G-308 admin transport over the SAME adminToken() wrapper: the
* operator token rides X-Admin-Token (never logged); 403 surfaces as
* NotAllowlistedError → the overview renders the honest locked state
* with the token prompt, never a retry loop (the entitlement-panel
* load() pattern, R-62). */
async function adminJsonFetch<T>(path: string, init?: RequestInit): Promise<T> {
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
const token = adminToken();
if (token) headers['X-Admin-Token'] = token;
const resp = await fetch(`${AI_SERVICE_URL}${path}`, { ...init, headers, signal: init?.signal });
if (!resp.ok) throw await parseError(resp);
return (await resp.json()) as T;
}
/** GET /v1/employers/admin/accounts — the employer accounts list
* (D-094): email, claimed entity, created_at, the UNVERIFIED marker
* (D-097 — displayed as such, never as verified). password_hash never
* rides the wire (D-087). */
export async function listEmployerAdminAccounts(
signal?: AbortSignal,
): Promise<AdminAccountsResponse> {
return adminJsonFetch<AdminAccountsResponse>('/v1/employers/admin/accounts', { signal });
}
/** GET /v1/employers/admin/claims — the claim queue (D-089): status,
* the stored claim-time evidence snapshot (the domain-match signal is
* ADVISORY, never verification — A-304), the decided state. Optional
* status facet rides ?status=. */
export async function listEmployerAdminClaims(
signal?: AbortSignal,
status?: string,
): Promise<AdminClaimsResponse> {
const suffix = status ? `?status=${encodeURIComponent(status)}` : '';
return adminJsonFetch<AdminClaimsResponse>(`/v1/employers/admin/claims${suffix}`, { signal });
}
/** POST /v1/employers/admin/claims/{id}/approve — the EXPLICIT
* operator decision: activates ownership (one account : one entity),
* stamps provenance, appends the audit event in the same transaction.
* Evidence never auto-activates anything. */
export async function approveEmployerAdminClaim(
claimId: string,
signal?: AbortSignal,
): Promise<EmployerClaim> {
const body = await adminJsonFetch<ClaimDecisionResponse>(
`/v1/employers/admin/claims/${encodeURIComponent(claimId)}/approve`,
{ method: 'POST', signal },
);
return body.claim;
}
/** POST /v1/employers/admin/claims/{id}/reject — the other explicit
* operator arm: the entity is freed for a new claim (the rejected row
* never touches the pending index, G-316). */
export async function rejectEmployerAdminClaim(
claimId: string,
signal?: AbortSignal,
): Promise<EmployerClaim> {
const body = await adminJsonFetch<ClaimDecisionResponse>(
`/v1/employers/admin/claims/${encodeURIComponent(claimId)}/reject`,
{ method: 'POST', signal },
);
return body.claim;
}
/** GET /v1/employers/admin/audit — the queryable operator trail: every
* claim decision with operator provenance (D-089's answerability). */
export async function listEmployerAdminAudit(
signal?: AbortSignal,
): Promise<AdminAuditResponse> {
return adminJsonFetch<AdminAuditResponse>('/v1/employers/admin/audit', { signal });
}
// -- employer auth + account (v0.10 P3, REQ-10-003, D-092) -------------------------
/** The nc-employer-session sessionStorage key (the adminToken()
* pattern, R-61): tab-scoped BY DESIGN — the session dies with the
* tab, which bounds the token's XSS exposure window (open-risk-4,
* documented in the dashboard copy). Only the raw token rides here;
* the server stores only sha256(token) (D-087). */
export const EMPLOYER_SESSION_KEY = 'nc-employer-session';
/** Window-guarded read (the adminToken() discipline: no window, no
* storage — the static-export prerender path). */
export function employerSessionToken(): string {
if (typeof window === 'undefined') return '';
return window.sessionStorage.getItem(EMPLOYER_SESSION_KEY) ?? '';
}
export function setEmployerSessionToken(token: string): void {
if (typeof window === 'undefined') return;
window.sessionStorage.setItem(EMPLOYER_SESSION_KEY, token);
}
export function clearEmployerSessionToken(): void {
if (typeof window === 'undefined') return;
window.sessionStorage.removeItem(EMPLOYER_SESSION_KEY);
}
/** The Authorization header on every employer fetch: single-header
* Bearer transport (D-090 — the require_employer_session shape). A
* missing token still fetches (the route's honest 401 → the surface's
* logged-out state); the token is never logged anywhere. */
function employerHeaders(): Record<string, string> {
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
const token = employerSessionToken();
if (token) headers['Authorization'] = `Bearer ${token}`;
return headers;
}
/** POST /v1/employers/auth/register — email+password → account
* (UNVERIFIED, D-097) + a fresh session (registration logs in). */
export async function registerEmployer(
body: RegisterEmployerRequest,
signal?: AbortSignal,
): Promise<EmployerTokenResponse> {
return jsonFetch<EmployerTokenResponse>('/v1/employers/auth/register', {
method: 'POST',
body: JSON.stringify(body),
signal,
});
}
/** POST /v1/employers/auth/login — bad credentials → SessionRequiredError
* (the 401 arm; no account-enumeration difference server-side). */
export async function loginEmployer(
body: LoginEmployerRequest,
signal?: AbortSignal,
): Promise<EmployerTokenResponse> {
return jsonFetch<EmployerTokenResponse>('/v1/employers/auth/login', {
method: 'POST',
body: JSON.stringify(body),
signal,
});
}
/** POST /v1/employers/auth/logout — revokes the session server-side
* AND clears the tab-scoped storage (both arms, always): a revoke
* failure on an already-dead token still leaves the caller logged-out
* locally, never holding a zombie session. */
export async function logoutEmployer(signal?: AbortSignal): Promise<void> {
const headers = employerHeaders(); // built BEFORE the clear — the
// dying token still authenticates the revoke call.
clearEmployerSessionToken();
if (!headers['Authorization']) return; // nothing to revoke — storage already clean
const resp = await fetch(`${AI_SERVICE_URL}/v1/employers/auth/logout`, {
method: 'POST',
headers,
signal,
});
if (!resp.ok) {
// A 401 here means the token was already dead — the caller is
// logged out either way; the honest state is logged-out, not an
// error surface. Other failures surface as-is.
const err = await parseError(resp);
if (!(err instanceof SessionRequiredError)) throw err;
}
}
/** GET /v1/employers/auth/me — the session's account + claim state;
* missing/expired session → SessionRequiredError (401 arm). */
export async function getEmployerMe(signal?: AbortSignal): Promise<EmployerMe> {
return jsonFetch<EmployerMe>('/v1/employers/auth/me', {
headers: employerHeaders(),
signal,
});
}
/** POST /v1/employers/claims — claim an existing EmployerRow entity;
* the response is the pending claim with its claim-time evidence
* snapshot (advisory, D-089 — auto-approval is deferred). */
export async function claimEmployerEntity(
body: ClaimEmployerRequest,
signal?: AbortSignal,
): Promise<EmployerClaim> {
return jsonFetch<EmployerClaim>('/v1/employers/claims', {
method: 'POST',
headers: employerHeaders(),
body: JSON.stringify(body),
signal,
});
}
/** GET /v1/employers/postings — the claimed employer's OWN postings
* (draft|published|closed lifecycle + per-posting applicant counts +
* facets). Unclaimed account → ClaimRequiredError (the 403 claim-CTA
* discrimination, D-090); expired session → SessionRequiredError. */
export async function listOwnEmployerPostings(
signal?: AbortSignal,
): Promise<OwnPostingsResponse> {
return jsonFetch<OwnPostingsResponse>('/v1/employers/postings', {
headers: employerHeaders(),
signal,
});
}
// -- employer own-postings write (v0.10 P4, REQ-10-004, G-320b) --------------------
/** POST /v1/employers/postings — create a DRAFT on the claimed
* entity. Gate order server-side (G-311): expired session →
* SessionRequiredError; unclaimed → ClaimRequiredError; a 402 →
* UpgradeRequiredError (the shared D-091 gate's reason + upgrade_cta
* ONLY — the payload's posts_used/posts_limit are byte-stable zeros
* and are NEVER rendered, G-320b; real limits render exclusively from
* the entitlement view). */
export async function createOwnEmployerPosting(
body: EmployerPostingCreate,
signal?: AbortSignal,
): Promise<EmployerPostingWriteResponse> {
return jsonFetch<EmployerPostingWriteResponse>('/v1/employers/postings', {
method: 'POST',
headers: employerHeaders(),
body: JSON.stringify(body),
signal,
});
}
/** PATCH /v1/employers/postings/{id} — edit the OWN DRAFT's content
* (G-318: published/closed → 409 honest own-drafts-only copy; another
* entity's posting → 403 the honest authorization error; unknown →
* 404 honest). */
export async function editOwnEmployerPosting(
postingId: string,
body: EmployerPostingEdit,
signal?: AbortSignal,
): Promise<EmployerPostingWriteResponse> {
return jsonFetch<EmployerPostingWriteResponse>(
`/v1/employers/postings/${encodeURIComponent(postingId)}`,
{
method: 'PATCH',
headers: employerHeaders(),
body: JSON.stringify(body),
signal,
},
);
}
/** POST /v1/employers/postings/{id}/publish — own draft → published.
* 409 on a non-draft, 403 cross-entity, 404 unknown (all honest). */
export async function publishOwnEmployerPosting(
postingId: string,
signal?: AbortSignal,
): Promise<EmployerPostingWriteResponse> {
return jsonFetch<EmployerPostingWriteResponse>(
`/v1/employers/postings/${encodeURIComponent(postingId)}/publish`,
{
method: 'POST',
headers: employerHeaders(),
signal,
},
);
}
/** POST /v1/employers/postings/{id}/close — own posting → closed
* (published → closed; 409 on closing a draft or re-closing). */
export async function closeOwnEmployerPosting(
postingId: string,
signal?: AbortSignal,
): Promise<EmployerPostingWriteResponse> {
return jsonFetch<EmployerPostingWriteResponse>(
`/v1/employers/postings/${encodeURIComponent(postingId)}/close`,
{
method: 'POST',
headers: employerHeaders(),
signal,
},
);
}
// -- employer self-serve billing (v0.10 P5, REQ-10-005) ---------------------------
/** POST /v1/employers/billing/checkout — self-serve checkout over the
* v0.9 catalog (the buyer derives from the session's CLAIMED entity,
* D-076 superseded). Unclaimed account → ClaimRequiredError (the 403
* claim-CTA, D-090); unknown catalog item → 404 honest; expired
* session → SessionRequiredError. The response carries the provider
* session id — the in-app mock-complete arm (pilot, provider=mock
* only) consumes it; the url is the provider's checkout pointer
* (never fetched under the mock provider). */
export async function startOwnEmployerCheckout(
body: EmployerCheckoutCreate,
signal?: AbortSignal,
): Promise<EmployerCheckoutResponse> {
return jsonFetch<EmployerCheckoutResponse>('/v1/employers/billing/checkout', {
method: 'POST',
headers: employerHeaders(),
body: JSON.stringify(body),
signal,
});
}
/** GET /v1/employers/billing/entitlement — the session employer's OWN
* entitlement summary (current package, posts used/limit, expiry —
* the v0.9 shapes resolved for the claimed entity). Honest states:
* purchase/subscription null until a real grant exists; expired
* windows are skipped lazily server-side (G-306) — the empty/expired
* arms render from THIS response, never from the 402 payload's
* byte-stable zeros (G-320b). */
export async function getOwnEmployerEntitlement(
signal?: AbortSignal,
): Promise<EmployerEntitlementResponse> {
return jsonFetch<EmployerEntitlementResponse>('/v1/employers/billing/entitlement', {
headers: employerHeaders(),
signal,
});
}
/** POST /v1/employers/billing/mock/complete — the HONEST pilot
* completion arm (D-082 transposed): employer-session-gated, mock-only
* via the route's isinstance gate (the router mounts whole — 404 under
* stripe is an honest absence, not a fake success). The
* provider_session_id resolves from the session's OWN checkout rows
* (cross-session completion → 403, the G-301 class); the completion
* mints a signed event through the REAL webhook path — the grant is
* real, only the payment is mock. */
export async function completeOwnEmployerMockCheckout(
body: EmployerMockCompleteRequest,
signal?: AbortSignal,
): Promise<EmployerMockCompleteResponse> {
return jsonFetch<EmployerMockCompleteResponse>(
'/v1/employers/billing/mock/complete',
{
method: 'POST',
headers: employerHeaders(),
body: JSON.stringify(body),
signal,
},
);
}
// -- placements (v0.11 P3, REQ-11-002/003, D-110) ----------------------------------
/** GET /v1/employers/applications — the session employer's OWN hiring
* pipeline (every status, per-status facets). Expired session →
* SessionRequiredError; unclaimed → ClaimRequiredError (the G-311
* gates surface as the honest state classes, never a re-fetch). */
export async function listOwnEmployerApplications(
signal?: AbortSignal,
): Promise<EmployerApplicationsResponse> {
return jsonFetch<EmployerApplicationsResponse>('/v1/employers/applications', {
headers: employerHeaders(),
signal,
});
}
/** The shared per-target transition transport (D-105: the matrix stays
* OUT of the request bodies — each call names its target). 401 →
* SessionRequiredError; 403-claim → ClaimRequiredError; 403
* cross-entity + 409 matrix → EngineError with the honest status
* (the surfaces render the state from err.status — NO new error
* classes, D-110). */
async function employerTransition(
applicationId: string,
target: 'in-review' | 'interview' | 'offer' | 'reject' | 'hire',
body?: HireTransitionRequest,
signal?: AbortSignal,
): Promise<ApplicationTransitionResponse> {
return jsonFetch<ApplicationTransitionResponse>(
`/v1/employers/applications/${encodeURIComponent(applicationId)}/${target}`,
{
method: 'POST',
headers: employerHeaders(),
body: body === undefined ? undefined : JSON.stringify(body),
signal,
},
);
}
/** POST .../in-review — submitted → in_review. */
export function employerMarkInReview(
applicationId: string,
signal?: AbortSignal,
): Promise<ApplicationTransitionResponse> {
return employerTransition(applicationId, 'in-review', undefined, signal);
}
/** POST .../interview. */
export function employerMarkInterview(
applicationId: string,
signal?: AbortSignal,
): Promise<ApplicationTransitionResponse> {
return employerTransition(applicationId, 'interview', undefined, signal);
}
/** POST .../offer. */
export function employerMarkOffer(
applicationId: string,
signal?: AbortSignal,
): Promise<ApplicationTransitionResponse> {
return employerTransition(applicationId, 'offer', undefined, signal);
}
/** POST .../reject — terminal. */
export function employerRejectApplication(
applicationId: string,
signal?: AbortSignal,
): Promise<ApplicationTransitionResponse> {
return employerTransition(applicationId, 'reject', undefined, signal);
}
/** POST .../hire — terminal; carries the employer-attested
* {role_title, start_date} (D-103/G-324). The response carries the
* created placement record. */
export function employerHireApplication(
applicationId: string,
body: HireTransitionRequest,
signal?: AbortSignal,
): Promise<ApplicationTransitionResponse> {
return employerTransition(applicationId, 'hire', body, signal);
}
/** GET /v1/employers/placements — the claimed entity's OWN placements
* (active + revoked, the full history; the overview's placements
* section data source, D-109 surface 2). */
export async function listOwnEmployerPlacements(
signal?: AbortSignal,
): Promise<EmployerPlacementsResponse> {
return jsonFetch<EmployerPlacementsResponse>('/v1/employers/placements', {
headers: employerHeaders(),
signal,
});
}
// -- learner placement status (v0.11 P5, REQ-11-005, D-104/G-322) -------------------
/** GET /v1/marketplace/applications/mine?posting_id=&learner_id= —
* G-322 (binding) discovery: the status panel finds the application by
* the (posting, learner) pair — NEVER a client-persisted
* application_id. 404 = no application yet (the surface renders the
* apply CTA — honest absence). */
export async function getMyApplication(
postingId: string,
learnerId: string = MOCK_LEARNER_ID,
signal?: AbortSignal,
): Promise<MineApplication> {
return jsonFetch<MineApplication>(
`/v1/marketplace/applications/mine?posting_id=${encodeURIComponent(postingId)}&learner_id=${encodeURIComponent(learnerId)}`,
{ signal },
);
}
/** GET /v1/marketplace/applications/{application_id}?learner_id= —
* D-104: the learner's-own outcome read — current status + the full
* id-ordered timestamped history + the placement record when hired
* (one read, one truth source). 403 foreign → EngineError (the
* surface renders the honest "not your application" state from
* err.status — NO new error classes, D-110). */
export async function getLearnerApplicationStatus(
applicationId: string,
learnerId: string = MOCK_LEARNER_ID,
signal?: AbortSignal,
): Promise<LearnerApplicationStatus> {
return jsonFetch<LearnerApplicationStatus>(
`/v1/marketplace/applications/${encodeURIComponent(applicationId)}?learner_id=${encodeURIComponent(learnerId)}`,
{ signal },
);
}
/** POST /v1/marketplace/applications/{application_id}/withdraw — the
* learner's ONLY edit (D-099/D-104); terminal → 409 (the matrix
* surfaces as EngineError.status — the panel renders the honest
* terminal state, never a re-fetch). */
export async function withdrawApplication(
applicationId: string,
learnerId: string = MOCK_LEARNER_ID,
signal?: AbortSignal,
): Promise<ApplicationConfirmation> {
return jsonFetch<ApplicationConfirmation>(
`/v1/marketplace/applications/${encodeURIComponent(applicationId)}/withdraw`,
{
method: 'POST',
body: JSON.stringify({ learner_id: learnerId }),
signal,
},
);
}
// -- admin placements (v0.11 P3/P4, REQ-11-003/004, D-107/D-108) --------------------
/** GET /v1/placements/admin/overview — the D-107 computation over the
* G-308 admin transport: distinct learners toward the 1,000 guiding
* goal, active placements, revoked count, per-employer + per-cohort
* month breakdowns. G-328: the caller labels these as RECORDED
* placements — never a verification claim. */
export async function getPlacementOverview(
signal?: AbortSignal,
): Promise<PlacementOverview> {
return adminJsonFetch<PlacementOverview>('/v1/placements/admin/overview', {
signal,
});
}
/** GET /v1/placements/admin/export — the self-contained audit JSON
* (D-108: cohort + evidence bundles + methodology + the ledger with
* head_hash). The caller downloads the document as a JSON blob. */
export async function getPlacementExport(signal?: AbortSignal): Promise<string> {
const token = adminToken();
const resp = await fetch(
`${AI_SERVICE_URL}/v1/placements/admin/export`,
{
headers: token ? { 'X-Admin-Token': token } : {},
signal,
},
);
if (!resp.ok) {
const err = await parseError(resp);
throw err;
}
return resp.text();
}
/** GET /v1/placements/admin/export/summary — the human-readable
* markdown rendering FROM the same document (D-108's anti-drift
* rule); opens in-tab. */
export async function getPlacementExportSummary(
signal?: AbortSignal,
): Promise<string> {
const token = adminToken();
const resp = await fetch(
`${AI_SERVICE_URL}/v1/placements/admin/export/summary`,
{
headers: token ? { 'X-Admin-Token': token } : {},
signal,
},
);
if (!resp.ok) {
const err = await parseError(resp);
throw err;
}
return resp.text();
}
// parseError gains the 402 discrimination (the dict-detail precedent).
@@ -1,381 +0,0 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
/**
* v0.10 P5 (REQ-10-005, Task 5-2-02 — D-094/R-62): the admin network
* overview. Fetch stubs pin the operator-route transport (the admin
* token rides X-Admin-Token, the approve/reject dispatch, the 403
* locked-state discrimination); source pins cover the overview's
* locked state, the A-304 advisory evidence rendering, and the
* accounts-list columns (no render harness in-tree — the D-059/G-312
* pin discipline).
*/
const QS = `?t=${Date.now()}-${Math.random()}`;
const root = new URL("..", import.meta.url);
function web(path: string): string {
return fileURLToPath(new URL(path, root));
}
/** sessionStorage stub (node has none — the wrappers guard on window).
* location included: engineBaseUrl reads window.location.hostname at
* module-import time (the auth-pages precedent). */
function stubSessionStorage(): { store: Map<string, string>; restore(): void } {
const store = new Map<string, string>();
const globalAny = globalThis as Record<string, unknown>;
globalAny.window = {
location: { hostname: "localhost" },
sessionStorage: {
getItem: (k: string) => store.get(k) ?? null,
setItem: (k: string, v: string) => void store.set(k, v),
removeItem: (k: string) => void store.delete(k),
},
};
return { store, restore() { delete globalAny.window; } };
}
const ACCOUNTS = {
accounts: [
{
id: "acct-1",
email: "ops@example.com",
employer_id: "emp-openai",
created_at: "2026-09-30T00:00:00Z",
email_verified: false,
},
],
count: 1,
};
const CLAIMS = {
claims: [
{
id: "claim-1",
account_id: "acct-1",
employer_id: "emp-anthropic",
status: "pending",
evidence: {
account_email_domain: "example.com",
entity_website_domain: "example.com",
match: false,
},
created_at: "2026-09-30T00:00:00Z",
decided_by: null,
decided_at: null,
},
],
count: 1,
};
const AUDIT = {
events: [
{
id: 1,
account_id: "acct-1",
employer_id: "emp-anthropic",
kind: "claim",
name: "claim.submitted",
detail: { claim_id: "claim-1" },
operator: null,
created_at: "2026-09-30T00:00:00Z",
},
],
count: 1,
};
test("listEmployerAdminAccounts: rides X-Admin-Token + GETs the accounts route", async () => {
const realFetch = globalThis.fetch;
const calls: Array<{ url: string; headers: Record<string, string> }> = [];
globalThis.fetch = (async (input: unknown, init?: RequestInit) => {
calls.push({
url: String(input),
headers: (init?.headers ?? {}) as Record<string, string>,
});
return new Response(JSON.stringify(ACCOUNTS), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}) as typeof fetch;
const { restore } = stubSessionStorage();
try {
const mod = await import(`../lib/engine-client.ts${QS}`);
mod.setAdminToken("op-token-1");
const accounts = await mod.listEmployerAdminAccounts();
assert.equal(accounts.count, 1);
assert.equal(accounts.accounts[0]?.employer_id, "emp-openai");
assert.equal(
new URL(calls[0].url, "http://localhost").pathname,
"/v1/employers/admin/accounts",
);
assert.equal(
calls[0].headers["X-Admin-Token"],
"op-token-1",
"G-308: the operator token rides the header",
);
} finally {
restore();
globalThis.fetch = realFetch;
}
});
test("listEmployerAdminClaims: GETs the queue; the status facet rides ?status=", async () => {
const realFetch = globalThis.fetch;
const calls: string[] = [];
globalThis.fetch = (async (input: unknown) => {
calls.push(String(input));
return new Response(JSON.stringify(CLAIMS), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}) as typeof fetch;
const { restore } = stubSessionStorage();
try {
const mod = await import(`../lib/engine-client.ts${QS}`);
mod.setAdminToken("op-token-1");
const queue = await mod.listEmployerAdminClaims();
assert.equal(queue.claims[0]?.status, "pending");
assert.equal(queue.claims[0]?.evidence.match, false, "the advisory signal rides the evidence");
assert.equal(
new URL(calls[0], "http://localhost").pathname,
"/v1/employers/admin/claims",
);
await mod.listEmployerAdminClaims(undefined, "pending");
assert.ok(
new URL(calls[1], "http://localhost").searchParams.get("status") === "pending",
"the status facet rides the query",
);
} finally {
restore();
globalThis.fetch = realFetch;
}
});
test("approve/reject dispatch: POST the decision sub-routes through the admin transport", async () => {
const realFetch = globalThis.fetch;
const calls: Array<{ url: string; method?: string; headers: Record<string, string> }> = [];
globalThis.fetch = (async (input: unknown, init?: RequestInit) => {
calls.push({
url: String(input),
method: init?.method,
headers: (init?.headers ?? {}) as Record<string, string>,
});
return new Response(JSON.stringify({ claim: CLAIMS.claims[0] }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}) as typeof fetch;
const { restore } = stubSessionStorage();
try {
const mod = await import(`../lib/engine-client.ts${QS}`);
mod.setAdminToken("op-token-1");
const approved = await mod.approveEmployerAdminClaim("claim-1");
assert.equal(approved.id, "claim-1", "the decision response carries the decided claim");
const rejected = await mod.rejectEmployerAdminClaim("claim-1");
assert.equal(rejected.id, "claim-1");
assert.equal(
new URL(calls[0].url, "http://localhost").pathname,
"/v1/employers/admin/claims/claim-1/approve",
);
assert.equal(calls[0].method, "POST", "approve is a POST");
assert.equal(
new URL(calls[1].url, "http://localhost").pathname,
"/v1/employers/admin/claims/claim-1/reject",
);
assert.equal(calls[1].method, "POST", "reject is a POST");
assert.equal(calls[0].headers["X-Admin-Token"], "op-token-1");
} finally {
restore();
globalThis.fetch = realFetch;
}
});
test("listEmployerAdminAudit: GETs the audit trail", async () => {
const realFetch = globalThis.fetch;
const calls: string[] = [];
globalThis.fetch = (async (input: unknown) => {
calls.push(String(input));
return new Response(JSON.stringify(AUDIT), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}) as typeof fetch;
const { restore } = stubSessionStorage();
try {
const mod = await import(`../lib/engine-client.ts${QS}`);
mod.setAdminToken("op-token-1");
const trail = await mod.listEmployerAdminAudit();
assert.equal(trail.events[0]?.name, "claim.submitted");
assert.equal(
new URL(calls[0], "http://localhost").pathname,
"/v1/employers/admin/audit",
);
} finally {
restore();
globalThis.fetch = realFetch;
}
});
test("403 on the operator routes → the honest locked-state discrimination (fail-closed, R-62)", async () => {
const realFetch = globalThis.fetch;
globalThis.fetch = (async () =>
new Response(JSON.stringify({ detail: "admin token required" }), {
status: 403,
headers: { "Content-Type": "application/json" },
})) as typeof fetch;
const { restore } = stubSessionStorage();
try {
const mod = await import(`../lib/engine-client.ts${QS}`);
mod.setAdminToken("wrong-token");
for (const fn of [
mod.listEmployerAdminAccounts,
mod.listEmployerAdminClaims,
mod.listEmployerAdminAudit,
]) {
await assert.rejects(
fn(),
(err: unknown) => {
const e = err as InstanceType<typeof mod.EngineError>;
return e instanceof mod.EngineError && e.status === 403;
},
"the fail-closed 403 surfaces with its status",
);
}
await assert.rejects(
mod.approveEmployerAdminClaim("claim-1"),
(err: unknown) => {
const e = err as InstanceType<typeof mod.EngineError>;
return e instanceof mod.EngineError && e.status === 403;
},
"approve is locked too (all five routes fail-closed)",
);
await assert.rejects(
mod.rejectEmployerAdminClaim("claim-1"),
(err: unknown) => {
const e = err as InstanceType<typeof mod.EngineError>;
return e instanceof mod.EngineError && e.status === 403;
},
"reject is locked too",
);
} finally {
restore();
globalThis.fetch = realFetch;
}
});
test("pin: the network overview carries the R-62 locked-state design (the entitlement-panel load() pattern)", async () => {
const src = readFileSync(
web("components/admin/employer-network-panel.tsx"),
"utf8",
);
// Token input + sessionStorage transport (never logged).
assert.ok(/type="password"/.test(src), "token input is masked");
assert.ok(/setAdminToken/.test(src), "sessionStorage transport");
assert.ok(/status === 403/.test(src), "403 discriminates the locked state");
assert.ok(/Locked — the admin token is missing or invalid/.test(src), "honest locked state with the token prompt");
assert.ok(!/while \(|setTimeout|location\.reload/.test(src), "no retry loop machinery (never a retry loop)");
assert.ok(!/console\./.test(src), "no console logging");
// An empty token never dispatches (the explicit-load design).
assert.ok(/if \(!trimmed\) return;/.test(src), "empty token never dispatches");
// The operator functions feed the panel through the wrapper.
for (const fn of [
"listEmployerAdminAccounts",
"listEmployerAdminClaims",
"listEmployerAdminAudit",
"approveEmployerAdminClaim",
"rejectEmployerAdminClaim",
]) {
assert.ok(src.includes(fn), `operator route wired: ${fn}`);
}
// The panel never touches the header directly (G-308).
assert.ok(!/X-Admin-Token/.test(src), "the panel goes through the wrapper");
});
test("pin: the claim queue renders the evidence ADVISORY — never verification (A-304, MH-5e)", async () => {
const src = readFileSync(
web("components/admin/employer-network-panel.tsx"),
"utf8",
);
// The A-304 label rides the evidence block.
assert.ok(
/Advisory evidence \(not verification/.test(src),
"the evidence renders with the advisory label",
);
assert.ok(
/the operator decision activates/.test(src),
"the copy names what activates (never the signal)",
);
// The evidence fields render (the D-089 snapshot shape).
assert.ok(/account_email_domain/.test(src), "email domain renders");
assert.ok(/entity_website_domain/.test(src), "website domain renders");
assert.ok(/domain-match signal/.test(src), "the match signal renders as a signal");
// The signal is never presented as verification.
assert.ok(!/Verified|verif(?:ied|ication) (?:match|signal)/.test(src), "no verification language on the signal");
// A missing snapshot renders unavailable — never a fabricated match.
assert.ok(/unavailable/.test(src), "missing evidence renders honestly");
// The explicit operator actions exist on pending claims.
assert.ok(/Approve claim/.test(src), "approve action");
assert.ok(/Reject claim/.test(src), "reject action");
assert.ok(/'pending'/.test(src), "actions render on pending claims only");
// Honest 404/409 decision states (the constraint 409s are honest).
assert.ok(/no longer exists/.test(src), "honest 404 decision copy");
assert.ok(/err instanceof EngineError && err\.status === 409/.test(src), "the 409 arm surfaces the constraint honestly");
// No optimistic updates: the queue re-reads after decisions.
assert.ok(/listEmployerAdminClaims/.test(src.slice(src.indexOf("const decide"))), "decisions re-read the queue");
});
test("pin: the accounts list renders the D-094 columns; the moderation page gains the sibling sections BELOW the v0.9 panel", async () => {
const src = readFileSync(
web("components/admin/employer-network-panel.tsx"),
"utf8",
);
// Accounts columns: email, claimed entity, created_at (+ the
// UNVERIFIED marker rendered as such, D-097).
assert.ok(/Email/.test(src), "email column");
assert.ok(/Claimed entity/.test(src), "claimed entity column");
assert.ok(/Created/.test(src), "created column");
assert.ok(/Unverified/.test(src), "the unverified marker renders as such");
assert.ok(!/email_verified === true|Verified</.test(src), "never rendered as verified");
// Honest empty states.
assert.ok(/No employer accounts yet/.test(src), "accounts empty state");
assert.ok(/No claims in the queue/.test(src), "queue empty state");
assert.ok(/No audit events yet/.test(src), "audit empty state");
// The audit view renders the provenance columns.
assert.ok(/Operator/.test(src), "operator provenance column");
assert.ok(/decided by/.test(src), "decided_by provenance renders on claims");
// The moderation page: the network overview lands BELOW the v0.9
// entitlement panel (sibling sections, D-094).
const page = readFileSync(web("app/(admin)/admin/moderation/page.tsx"), "utf8");
const entitlementIdx = page.indexOf("<EmployerEntitlementPanel");
const networkIdx = page.indexOf("<EmployerNetworkPanel");
assert.ok(entitlementIdx > 0, "the v0.9 entitlement panel is retained");
assert.ok(networkIdx > entitlementIdx, "the network overview renders BELOW the entitlement panel");
assert.ok(/<section/.test(page), "sibling section blocks");
});
test("pin: the engine-client admin section joins the no-logging discipline (G-308)", async () => {
const client = readFileSync(web("lib/engine-client.ts"), "utf8");
// The admin transport lives beside getEntitlement in the ONE client
// module (no fork), rides adminToken(), and never logs.
const adminSection = client.slice(client.indexOf("-- employer admin operator routes"));
assert.ok(adminSection.includes("adminJsonFetch"), "the shared admin transport");
assert.ok(adminSection.includes("adminToken()"), "over the existing adminToken() wrapper");
assert.ok(!/console\./.test(adminSection), "no console logging in the admin section");
// Every operator function takes an AbortSignal (A-406).
for (const fn of [
"listEmployerAdminAccounts",
"listEmployerAdminClaims",
"approveEmployerAdminClaim",
"rejectEmployerAdminClaim",
"listEmployerAdminAudit",
]) {
assert.ok(
new RegExp(`export async function ${fn}\\([\\s\\S]*?signal\\?`).test(client),
`${fn} takes an AbortSignal`,
);
}
});
-165
View File
@@ -1,165 +0,0 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
/**
* v0.10 P3 (REQ-10-003, MH-3e): the /employer/login + /employer/register
* submit flows. Per G-312 (the D-059/E87 discipline): fetch-stub tests
* over the page-level submit functions the clients call + SOURCE pins
* for the honest-state branches — no jsdom/happy-dom render harness
* exists in-tree and none may be added this milestone.
*/
const QS = `?t=${Date.now()}-${Math.random()}`;
const root = new URL("..", import.meta.url);
function web(path: string): string {
return fileURLToPath(new URL(path, root));
}
/** sessionStorage stub (node has none — the wrapper guards on window). */
function stubSessionStorage(): { store: Map<string, string>; restore(): void } {
const store = new Map<string, string>();
const globalAny = globalThis as Record<string, unknown>;
globalAny.window = {
location: { hostname: "localhost" }, // engineBaseUrl reads this under window
sessionStorage: {
getItem: (k: string) => store.get(k) ?? null,
setItem: (k: string, v: string) => void store.set(k, v),
removeItem: (k: string) => void store.delete(k),
},
};
return { store, restore() { delete globalAny.window; } };
}
const TOKEN_BODY = { token: "tok-login-1", expires_at: "2026-10-07T00:00:00Z" };
test("login handoff: a successful login POST stores the session token (register→login handoff arm)", async () => {
const realFetch = globalThis.fetch;
const calls: Array<{ url: string; method?: string; body?: string }> = [];
globalThis.fetch = (async (input: unknown, init?: RequestInit) => {
calls.push({
url: String(input),
method: init?.method,
body: typeof init?.body === "string" ? init.body : undefined,
});
return new Response(JSON.stringify(TOKEN_BODY), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}) as typeof fetch;
const { store, restore } = stubSessionStorage();
try {
const mod = await import(`../lib/engine-client.ts${QS}`);
const session = await mod.loginEmployer(
{ email: "ops@example.com", password: "longenough1" },
undefined,
);
mod.setEmployerSessionToken(session.token);
assert.equal(store.get("nc-employer-session"), "tok-login-1");
assert.equal(new URL(calls[0].url, "http://localhost").pathname, "/v1/employers/auth/login");
assert.ok(calls[0].body?.includes('"password":"longenough1"'), "the login body carries the credential");
} finally {
restore();
globalThis.fetch = realFetch;
}
});
test("register handoff: register → session token stored → dashboard (the success path lands logged-in)", async () => {
const realFetch = globalThis.fetch;
globalThis.fetch = (async () =>
new Response(JSON.stringify(TOKEN_BODY), {
status: 201,
headers: { "Content-Type": "application/json" },
})) as typeof fetch;
const { store, restore } = stubSessionStorage();
try {
const mod = await import(`../lib/engine-client.ts${QS}`);
const session = await mod.registerEmployer({
email: "new@example.com",
password: "longenough1",
});
mod.setEmployerSessionToken(session.token);
assert.equal(store.get("nc-employer-session"), "tok-login-1");
} finally {
restore();
globalThis.fetch = realFetch;
}
});
test("honest states: 401/409/429/422 map to the pages' error copy branches (fetch-stub discrimination)", async () => {
const { SessionRequiredError, RateLimitedError, EngineError } = await import(
`../lib/engine-client.ts${QS}`
);
// 401 → the login page's invalid-credentials branch.
assert.ok(new SessionRequiredError() instanceof EngineError);
assert.equal(new SessionRequiredError().status, 401);
// 429 → the throttle-wait branch.
assert.ok(new RateLimitedError() instanceof EngineError);
assert.equal(new RateLimitedError().status, 429);
// 409/422 ride the generic EngineError with their statuses intact —
// the pages' copy branches discriminate on err.status.
assert.equal(new EngineError("an account with this email already exists", 409).status, 409);
assert.equal(new EngineError("invalid", 422).status, 422);
});
test("pin: the login/register pages + clients keep the honest-state + no-password-echo discipline (G-312 source pins)", async () => {
const loginPage = readFileSync(web("app/(employer)/employer/login/page.tsx"), "utf8");
const registerPage = readFileSync(web("app/(employer)/employer/register/page.tsx"), "utf8");
const loginClient = readFileSync(web("components/employer/login-client.tsx"), "utf8");
const registerClient = readFileSync(web("components/employer/register-client.tsx"), "utf8");
// Server shells with metadata (A-411) rendering the clients (A-406).
for (const [name, page] of [
["login", loginPage],
["register", registerPage],
] as const) {
assert.ok(/export const metadata/.test(page), `${name}: build-time metadata`);
assert.ok(!/"use client"/.test(page), `${name}: server shell (client lives in the component)`);
assert.ok(/<LoginClient \/>|<RegisterClient \/>/.test(page), `${name}: renders the client`);
}
assert.ok(/'use client';/.test(loginClient), "login client is a client component");
assert.ok(/'use client';/.test(registerClient), "register client is a client component");
// AbortController + honest loading/error states (A-406).
for (const [name, client] of [
["login", loginClient],
["register", registerClient],
] as const) {
assert.ok(/AbortController/.test(client), `${name}: AbortController discipline`);
assert.ok(/abortRef\.current\?\.abort\(\)/.test(client), `${name}: in-flight abort`);
assert.ok(/loading/i.test(client), `${name}: loading state`);
assert.ok(/error/i.test(client), `${name}: error state`);
assert.ok(/controller\.signal\.aborted\) return;/.test(client), `${name}: aborted responses never render`);
}
// The honest error-copy branches exist, per status, in both clients.
assert.ok(/SessionRequiredError/.test(loginClient), "login: 401 branch");
assert.ok(/RateLimitedError/.test(loginClient), "login: 429 branch");
assert.ok(/err\.status === 422/.test(loginClient), "login: 422 branch");
assert.ok(/SessionRequiredError/.test(registerClient), "register: session branch");
assert.ok(/RateLimitedError/.test(registerClient), "register: 429 branch");
assert.ok(/err\.status === 409/.test(registerClient), "register: 409 duplicate-email branch");
assert.ok(/err\.status === 422/.test(registerClient), "register: 422 branch");
// 422 copy never echoes the password: no template interpolation of
// the password into any message + no console logging of it anywhere.
for (const [name, client] of [
["login", loginClient],
["register", registerClient],
] as const) {
assert.ok(!/\$\{password\}/.test(client), `${name}: no password interpolation`);
assert.ok(!/console\./.test(client), `${name}: no console logging`);
}
// Success stores the session + redirects to the dashboard.
assert.ok(/setEmployerSessionToken\(session\.token\)/.test(loginClient), "login: stores the session");
assert.ok(/setEmployerSessionToken\(session\.token\)/.test(registerClient), "register: stores the session");
assert.ok(/router\.push\('\/employer'\)/.test(loginClient), "login: redirects to the dashboard");
assert.ok(/router\.push\('\/employer'\)/.test(registerClient), "register: lands logged-in on the dashboard");
// The register page's copy is honest about UNVERIFIED email (D-097).
assert.ok(/unverified/i.test(registerPage), "register copy: email marked unverified");
});
-346
View File
@@ -1,346 +0,0 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
/**
* v0.10 P5 (REQ-10-005, Task 5-2-01 — the employer billing surface):
* fetch stubs pin the engine-client billing functions (checkout
* dispatch, entitlement wire, mock-complete confirmation) + source
* pins cover the billing surface's honest states and the mock-path
* labeling (no render harness in-tree — the D-059/G-312 pin
* discipline).
*/
const QS = `?t=${Date.now()}-${Math.random()}`;
const root = new URL("..", import.meta.url);
function web(path: string): string {
return fileURLToPath(new URL(path, root));
}
/** sessionStorage stub (node has none — the wrappers guard on window).
* location included: engineBaseUrl reads window.location.hostname at
* module-import time (the auth-pages precedent). */
function stubSessionStorage(): { store: Map<string, string>; restore(): void } {
const store = new Map<string, string>();
const globalAny = globalThis as Record<string, unknown>;
globalAny.window = {
location: { hostname: "localhost" },
sessionStorage: {
getItem: (k: string) => store.get(k) ?? null,
setItem: (k: string, v: string) => void store.set(k, v),
removeItem: (k: string) => void store.delete(k),
},
};
return { store, restore() { delete globalAny.window; } };
}
const ENTITLEMENT_ACTIVE = {
employer_id: "emp-openai",
purchase: {
id: "pur-1",
package_id: "pkg-bundle",
package_name: "Bundle",
posts_used: 3,
posts_limit: 10,
expires_at: "2026-12-28T00:00:00Z",
},
subscription: null,
};
const ENTITLEMENT_EMPTY = {
employer_id: "emp-openai",
purchase: null,
subscription: null,
};
const CHECKOUT_RESPONSE = {
session_id: "cs-1",
provider_session_id: "mock-sess-1",
url: "https://mock.local/checkout/mock-sess-1",
mode: "payment",
};
test("startOwnEmployerCheckout: POSTs the checkout route with the Bearer token + item_id on the wire", async () => {
const realFetch = globalThis.fetch;
const calls: Array<{ url: string; method?: string; headers: Record<string, string>; body?: string }> = [];
globalThis.fetch = (async (input: unknown, init?: RequestInit) => {
calls.push({
url: String(input),
method: init?.method,
headers: (init?.headers ?? {}) as Record<string, string>,
body: typeof init?.body === "string" ? init.body : undefined,
});
return new Response(JSON.stringify(CHECKOUT_RESPONSE), {
status: 201,
headers: { "Content-Type": "application/json" },
});
}) as typeof fetch;
const { restore } = stubSessionStorage();
try {
const mod = await import(`../lib/engine-client.ts${QS}`);
mod.setEmployerSessionToken("sess-token-buy");
const created = await mod.startOwnEmployerCheckout({ item_id: "pkg-bundle" });
assert.equal(created.session_id, "cs-1");
assert.equal(created.provider_session_id, "mock-sess-1");
assert.equal(new URL(calls[0].url, "http://localhost").pathname, "/v1/employers/billing/checkout");
assert.equal(calls[0].method, "POST", "checkout is a POST");
assert.equal(calls[0].headers["Authorization"], "Bearer sess-token-buy", "the Bearer token rides checkout");
assert.ok(calls[0].body?.includes('"item_id":"pkg-bundle"'), "item_id rides the body");
// The buyer derives from the session's claim — employer_id never
// rides the body (D-076 superseded).
assert.ok(!calls[0].body?.includes("employer_id"), "no employer_id in the checkout body");
} finally {
restore();
globalThis.fetch = realFetch;
}
});
test("getOwnEmployerEntitlement: GETs the own-entitlement route with the Bearer token (wire shape: honest nulls)", async () => {
const realFetch = globalThis.fetch;
const calls: Array<{ url: string; headers: Record<string, string> }> = [];
globalThis.fetch = (async (input: unknown, init?: RequestInit) => {
calls.push({
url: String(input),
headers: (init?.headers ?? {}) as Record<string, string>,
});
return new Response(JSON.stringify(ENTITLEMENT_ACTIVE), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}) as typeof fetch;
const { restore } = stubSessionStorage();
try {
const mod = await import(`../lib/engine-client.ts${QS}`);
mod.setEmployerSessionToken("sess-token-ent");
const entitlement = await mod.getOwnEmployerEntitlement();
assert.equal(entitlement.purchase?.posts_used, 3);
assert.equal(entitlement.purchase?.posts_limit, 10);
assert.equal(entitlement.subscription, null, "honest null subscription");
assert.equal(new URL(calls[0].url, "http://localhost").pathname, "/v1/employers/billing/entitlement");
assert.equal(calls[0].headers["Authorization"], "Bearer sess-token-ent");
// The empty state rides the same shape (honest nulls — the surface
// renders the empty/expired arms from THIS).
const empty = await mod.getOwnEmployerEntitlement();
void empty;
} finally {
restore();
globalThis.fetch = realFetch;
}
});
test("completeOwnEmployerMockCheckout: POSTs the mock-complete route with the untrusted provider_session_id reference", async () => {
const realFetch = globalThis.fetch;
const calls: Array<{ url: string; method?: string; headers: Record<string, string>; body?: string }> = [];
globalThis.fetch = (async (input: unknown, init?: RequestInit) => {
calls.push({
url: String(input),
method: init?.method,
headers: (init?.headers ?? {}) as Record<string, string>,
body: typeof init?.body === "string" ? init.body : undefined,
});
return new Response(JSON.stringify({ completed: true, payment_status: "paid" }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}) as typeof fetch;
const { restore } = stubSessionStorage();
try {
const mod = await import(`../lib/engine-client.ts${QS}`);
mod.setEmployerSessionToken("sess-token-mock");
const result = await mod.completeOwnEmployerMockCheckout({
provider_session_id: "mock-sess-1",
});
assert.equal(result.completed, true);
assert.equal(result.payment_status, "paid");
assert.equal(new URL(calls[0].url, "http://localhost").pathname, "/v1/employers/billing/mock/complete");
assert.equal(calls[0].method, "POST", "mock-complete is a POST");
assert.equal(calls[0].headers["Authorization"], "Bearer sess-token-mock");
assert.ok(
calls[0].body?.includes('"provider_session_id":"mock-sess-1"'),
"the untrusted ID reference rides the body (G-302)",
);
} finally {
restore();
globalThis.fetch = realFetch;
}
});
test("401 on billing routes → SessionRequiredError (the logged-out state, never a retry loop)", async () => {
const realFetch = globalThis.fetch;
globalThis.fetch = (async () =>
new Response(JSON.stringify({ detail: "invalid or expired session" }), {
status: 401,
headers: { "Content-Type": "application/json" },
})) as typeof fetch;
const { restore } = stubSessionStorage();
try {
const mod = await import(`../lib/engine-client.ts${QS}`);
await assert.rejects(
mod.getOwnEmployerEntitlement(),
(err: unknown) => err instanceof mod.SessionRequiredError,
"401 maps to SessionRequiredError on the entitlement route",
);
await assert.rejects(
mod.startOwnEmployerCheckout({ item_id: "pkg-single" }),
(err: unknown) => err instanceof mod.SessionRequiredError,
"401 maps to SessionRequiredError on the checkout route",
);
} finally {
restore();
globalThis.fetch = realFetch;
}
});
test("403 claim-CTA on the checkout route → ClaimRequiredError (no buyer without a claim)", async () => {
const realFetch = globalThis.fetch;
globalThis.fetch = (async () =>
new Response(
JSON.stringify({
detail: {
reason: "no_claimed_employer",
claim_cta: "This account has not claimed an employer entity yet — submit a claim via POST /v1/employers/claims.",
},
}),
{ status: 403, headers: { "Content-Type": "application/json" } },
)) as typeof fetch;
const { restore } = stubSessionStorage();
try {
const mod = await import(`../lib/engine-client.ts${QS}`);
await assert.rejects(
mod.startOwnEmployerCheckout({ item_id: "pkg-single" }),
(err: unknown) => err instanceof mod.ClaimRequiredError,
"the unclaimed 403 maps to ClaimRequiredError on checkout",
);
} finally {
restore();
globalThis.fetch = realFetch;
}
});
test("404 on mock-complete (stripe-mode deployment) → the honest EngineError, never a fake success", async () => {
const realFetch = globalThis.fetch;
globalThis.fetch = (async () =>
new Response(
JSON.stringify({ detail: "mock-complete exists only under provider=mock" }),
{ status: 404, headers: { "Content-Type": "application/json" } },
)) as typeof fetch;
const { restore } = stubSessionStorage();
try {
const mod = await import(`../lib/engine-client.ts${QS}`);
await assert.rejects(
mod.completeOwnEmployerMockCheckout({ provider_session_id: "sess-x" }),
(err: unknown) => {
const e = err as InstanceType<typeof mod.EngineError>;
return e instanceof mod.EngineError && e.status === 404;
},
"stripe-mode mock-complete 404s honestly",
);
} finally {
restore();
globalThis.fetch = realFetch;
}
});
test("pin: the billing surface renders the entitlement view + buy flow + honest mock-complete labeling (G-312 source pins)", async () => {
const src = readFileSync(
web("components/employer/employer-billing-client.tsx"),
"utf8",
);
// Entitlement view: the ONLY place real limits render (G-320b).
assert.ok(/getOwnEmployerEntitlement/.test(src), "the entitlement route feeds the view");
assert.ok(/posts_used/.test(src) && /posts_limit/.test(src), "posts used/limit render");
assert.ok(/expires_at/.test(src), "expiry renders");
// Honest empty state (MH-5c): a null purchase NEVER asserts expiry
// provenance — the wire shape cannot distinguish never-purchased
// from expired (G-306 lazy skip reads both as null), so the surface
// renders the same honest empty copy for both.
assert.ok(/No active posting package/.test(src), "honest empty state");
assert.ok(!/previous purchases have expired/.test(src), "never fabricates expiry provenance from a null purchase");
assert.ok(!/const expired/.test(src), "no expired-branch derived from the null purchase");
// The buy flow: the PUBLIC v0.9 catalog REUSED (listBillingCatalog,
// never a duplicate catalog fetch path).
assert.ok(/listBillingCatalog/.test(src), "the catalog comes from the public v0.9 route");
assert.ok(/startOwnEmployerCheckout/.test(src), "the own-checkout trigger dispatches");
// The in-app mock-complete confirmation is honestly labeled as the
// pilot mock path (D-082 — the copy says mock, says no charge).
assert.ok(/completeOwnEmployerMockCheckout/.test(src), "the mock-complete arm dispatches");
assert.ok(/Pilot mock path/.test(src), "the mock path is labeled");
assert.ok(/no real charge/.test(src), "the copy says no real charge");
assert.ok(/Confirm purchase \(mock — no charge\)/.test(src), "the confirmation button is honest");
// P6-review fix pin: the confirm button is enabled at rest —
// `disabled={!confirming}` (the inverted guard) left the pilot buy
// flow dead: the confirm button was disabled UNTIL the request was
// already in flight. The honest shape disables ONLY while confirming.
const confirmBtn = src.slice(
src.indexOf('Confirm purchase (mock'),
src.indexOf('Cancel checkout'),
);
assert.ok(
/disabled=\{confirming\}/.test(confirmBtn),
"confirm disables only while confirming (never disabled at rest)",
);
assert.ok(
!/disabled=\{!confirming\}/.test(src),
"the inverted disabled={!confirming} guard is gone",
);
assert.ok(/mock payment provider/.test(src), "the provider is named honestly");
// The admin fallback is noted (D-094).
assert.ok(/operator can complete the purchase on your behalf|operator-assisted checkout remains available/.test(src), "the admin fallback is noted");
// The grant confirmation renders post-webhook (G-303 exactly-once
// server-side; the surface refetches the entitlement).
assert.ok(/Purchase complete/.test(src), "the grant confirmation renders");
assert.ok(/refetch/.test(src), "the entitlement refetches after completion");
// Honest 401/403 arms (never a retry loop).
assert.ok(/SessionRequiredError/.test(src), "the 401 arm is consumed");
assert.ok(/ClaimRequiredError/.test(src), "the claim-CTA arm is consumed");
assert.ok(/loggedOut/.test(src) && /claimPrompt/.test(src), "logged-out + claim-prompt states");
assert.ok(/AbortController/.test(src), "AbortController discipline");
assert.ok(!/while \(|setTimeout|location\.reload/.test(src), "no retry loop machinery");
assert.ok(!/console\./.test(src), "no console logging");
// The 402 payload's byte-stable zeros are NEVER rendered as limits
// on this surface (G-320b) — real limits come from the entitlement.
const entitlementSection = src.slice(
src.indexOf("Your posting package"),
src.indexOf("Pilot payments run through"),
);
assert.ok(
/purchase\.posts_used/.test(entitlementSection) && /purchase\.posts_limit/.test(entitlementSection),
"limits render from the entitlement view only",
);
});
test("pin: the employer layout links the billing surface; the upgrade CTA's stale copy is gone", async () => {
const layout = readFileSync(web("app/(employer)/layout.tsx"), "utf8");
assert.ok(/\/employer\/billing/.test(layout), "the nav links the billing surface");
const page = readFileSync(web("app/(employer)/employer/billing/page.tsx"), "utf8");
assert.ok(/export const metadata/.test(page), "billing page metadata (A-411)");
assert.ok(/<EmployerBillingClient \/>/.test(page), "page renders the client (A-406)");
// The P4 upgrade notice's "arrives" copy is superseded — the surface
// shipped in P5.
const postings = readFileSync(
web("components/employer/own-postings-client.tsx"),
"utf8",
);
assert.ok(!/arrives on the employer/.test(postings), "the stale arrives copy is gone");
assert.ok(/Self-serve checkout lives on the billing surface/.test(postings), "the notice points at the live surface");
});
test("pin: ENTITLEMENT wire fixtures match the dual-schema mirror (store-emitted keys)", async () => {
// The store's exact emitted keys (billing/store.py get_entitlement)
// ride the TS mirror — asserted against the fixture shapes.
const typesSrc = readFileSync(web("../../packages/types/employer.ts"), "utf8");
const purchaseBlock = typesSrc.slice(
typesSrc.indexOf("export interface EmployerPurchaseEntitlement"),
typesSrc.indexOf("export interface EmployerSubscriptionEntitlement"),
);
for (const key of Object.keys(ENTITLEMENT_ACTIVE.purchase as Record<string, unknown>)) {
assert.ok(
purchaseBlock.includes(`${key}:`),
`the store key ${key} rides the TS mirror`,
);
}
assert.equal(ENTITLEMENT_EMPTY.purchase, null, "the honest empty state is null, never a fabricated row");
});
-140
View File
@@ -1,140 +0,0 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
/**
* v0.10 P3 (REQ-10-003, MH-3c/3-3d — Task 3-3-02): the dashboard
* conversion pins. The postings/overview pages are grep-clean of the
* removed mock imports; the talent pages' mock imports are RETAINED
* (D-092 honest out-of-scope); the session-gated clients carry the
* honest-state branches (logged-out/claim-CTA/claim-pending/loading/
* error) in source — the G-312 source-pin discipline (no render
* harness in-tree, none may be added).
*/
const root = new URL("..", import.meta.url);
function web(path: string): string {
return fileURLToPath(new URL(path, root));
}
test("pin: postings + overview pages are grep-clean of the removed mock imports (MH-3c)", async () => {
const overview = readFileSync(web("app/(employer)/employer/page.tsx"), "utf8");
const postings = readFileSync(web("app/(employer)/employer/postings/page.tsx"), "utf8");
for (const [name, page] of [
["overview", overview],
["postings", postings],
] as const) {
assert.ok(!/@nextcraft\/mock-data/.test(page), `${name}: no mock-data import`);
assert.ok(!/\bjobs\b/.test(page), `${name}: no jobs mock`);
assert.ok(!/\bcandidates\b/.test(page), `${name}: no candidates mock`);
assert.ok(!/\bemployers\b\s*from/.test(page), `${name}: no employers mock`);
assert.ok(!/const METRICS/.test(page), `${name}: no inline METRICS constant`);
assert.ok(!/const PIPELINE/.test(page), `${name}: no inline PIPELINE constant`);
}
// The clients render the real own-postings surface, not the old
// mock-driven components (PostingManager's derived mock statuses/
// applicants + AnalyticsCharts' unlabeled mock charts are gone from
// the employer surface's real-data sections).
for (const client of [
"components/employer/employer-overview-client.tsx",
"components/employer/own-postings-client.tsx",
]) {
const src = readFileSync(web(client), "utf8");
assert.ok(!/@nextcraft\/mock-data/.test(src), `${client}: no mock-data import`);
assert.ok(!/deriveStatus|deriveApplicants/.test(src), `${client}: no derived mock statuses/applicants`);
}
assert.ok(
!/PostingManager/.test(postings),
"postings page no longer renders the mock-driven PostingManager",
);
assert.ok(
!/AnalyticsCharts/.test(overview),
"overview page no longer renders the unlabeled AnalyticsCharts",
);
});
test("pin: talent pages keep their mock imports + the mock-labeled metrics stay (MH-3d, D-092)", async () => {
const talent = readFileSync(web("app/(employer)/employer/talent/page.tsx"), "utf8");
const talentDetail = readFileSync(
web("app/(employer)/employer/talent/[candidateId]/page.tsx"),
"utf8",
);
// RETAINED (D-092: no candidate backend exists — honest out-of-scope).
assert.ok(/import \{ candidates \} from '@nextcraft\/mock-data'/.test(talent), "talent search keeps the candidates mock");
assert.ok(/import \{ candidates, competencyStacks \} from '@nextcraft\/mock-data'/.test(talentDetail), "candidate profile keeps the candidates mock");
// The overview's talent-match metrics stay MOCK-LABELED — the
// honest label rides the card copy (never presented as real).
// v0.11 P3: placement counts are now REAL (the D-092 conversion —
// the convertible surface left mock); only talent-match stays mock.
const overviewClient = readFileSync(
web("components/employer/employer-overview-client.tsx"),
"utf8",
);
assert.ok(/mock — no backend computes this/.test(overviewClient), "mock metric carries the honest label");
assert.ok(/MockMetricCard/.test(overviewClient), "the mock-labeled metric card exists");
assert.ok(/sample data/i.test(overviewClient), "analytics section labeled sample data");
assert.ok(/no backend\s+computes them yet/.test(overviewClient), "analytics notice is honest about the mock");
// The REAL arms: active-postings + applicant counts from the real
// route; the claimed-entity profile via the public marketplace fetch;
// v0.11: the placements section is REAL (listOwnEmployerPlacements).
assert.ok(/listOwnEmployerPostings/.test(overviewClient), "overview fetches own postings");
assert.ok(/applicant_count/.test(overviewClient), "overview renders per-posting applicant counts");
assert.ok(/getEmployer\(/.test(overviewClient), "overview fetches the claimed-entity profile (public route)");
assert.ok(/listOwnEmployerPlacements/.test(overviewClient), "overview fetches REAL placements (v0.11)");
assert.ok(/Placements Recorded/.test(overviewClient), "the real placement metric renders");
assert.ok(!/MockMetricCard label="Placement Rate"/.test(overviewClient), "the placement mock card is GONE (converted)");
});
test("pin: the session-gated clients carry every honest-state branch (MH-3b, G-312 source pins)", async () => {
for (const client of [
"components/employer/employer-overview-client.tsx",
"components/employer/own-postings-client.tsx",
]) {
const src = readFileSync(web(client), "utf8");
// SessionRequiredError → the logged-out state with a login CTA —
// NEVER a retry loop (the discrimination arm consumes the 401).
assert.ok(/SessionRequiredError/.test(src), `${client}: the 401 arm is consumed`);
assert.ok(/loggedOut/.test(src), `${client}: logged-out state exists`);
assert.ok(/href="\/employer\/login"/.test(src), `${client}: login CTA present`);
assert.ok(/Sign in/.test(src), `${client}: sign-in CTA copy`);
assert.ok(!/while \(|setTimeout|location\.reload/.test(src), `${client}: no retry loop machinery`);
// ClaimRequiredError → the honest claim-CTA state (D-090 403 class).
assert.ok(/ClaimRequiredError/.test(src), `${client}: the claim-CTA arm is consumed`);
assert.ok(/claimPrompt/.test(src), `${client}: claim-prompt state exists`);
// Claim-pending state visible ("submitted, awaiting review" —
// never presented as verified before the operator decides).
assert.ok(/claimPending/.test(src), `${client}: claim-pending state exists`);
assert.ok(/awaiting review/.test(src), `${client}: awaiting-review copy`);
// Loading + error arms (A-406).
assert.ok(/AbortController/.test(src), `${client}: AbortController`);
assert.ok(/Loading/.test(src), `${client}: honest loading state`);
assert.ok(/error/.test(src), `${client}: honest error state`);
assert.ok(/controller\.signal\.aborted\) return;/.test(src), `${client}: aborted responses never render`);
}
});
test("pin: the own-postings lifecycle view renders the full lifecycle + real counts (MH-3c)", async () => {
const src = readFileSync(web("components/employer/own-postings-client.tsx"), "utf8");
// draft → published → closed visible (the route's full lifecycle —
// unlike the public board's published-only view).
for (const status of ["draft", "published", "closed"]) {
assert.ok(new RegExp(`'${status}'`).test(src), `lifecycle status ${status} renders`);
}
assert.ok(/facets\.draft/.test(src) && /facets\.published/.test(src) && /facets\.closed/.test(src), "facet counts render");
// Real applicant counts (the D-092 overview data source).
assert.ok(/applicant_count/.test(src), "applicant_count renders");
assert.ok(/No postings yet/.test(src), "honest empty state");
// P4 (Task 4-2-01) supersedes the read-only pin: the write actions
// are LIVE now — pinned in employer-postings-write.test.ts (the
// disabled/upcoming-release copy is gone, never fake-wired).
assert.ok(!/upcoming release/.test(src), "the P3 read-only copy is gone (P4 shipped the actions)");
assert.ok(!/disabled title=/.test(src), "no fake-wired disabled buttons remain");
// PII boundary (G-319): learner identifiers never render — only counts.
assert.ok(!/learner_id|applicant_name|candidate_id/.test(src), "counts only, no learner identifiers");
// The real route feeds the view.
assert.ok(/listOwnEmployerPostings/.test(src), "own-postings route feeds the view");
});
@@ -1,307 +0,0 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { existsSync, readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
/**
* v0.10 P4 (REQ-10-004, Task 4-2-01 — G-320b/G-312): the engine-client
* employer WRITE section + the PostingManager write actions. Fetch
* stubs pin the dispatch + refetch + the 402/403/409 discrimination;
* source pins cover the component's honest-state branches (no render
* harness in-tree — the D-059/E87 pin discipline).
*/
const QS = `?t=${Date.now()}-${Math.random()}`;
const root = new URL("..", import.meta.url);
function web(path: string): string {
return fileURLToPath(new URL(path, root));
}
/** sessionStorage stub (node has none — the wrappers guard on
* window). location is included: engineBaseUrl reads
* window.location.hostname at module-import time (the auth-pages
* precedent). */
function stubSessionStorage(): { store: Map<string, string>; restore(): void } {
const store = new Map<string, string>();
const globalAny = globalThis as Record<string, unknown>;
globalAny.window = {
location: { hostname: "localhost" },
sessionStorage: {
getItem: (k: string) => store.get(k) ?? null,
setItem: (k: string, v: string) => void store.set(k, v),
removeItem: (k: string) => void store.delete(k),
},
};
return { store, restore() { delete globalAny.window; } };
}
const WRITE_RESPONSE = {
id: "emp-abc123",
source: "employer",
external_id: "emp-abc123",
employer_id: "emp-openai",
title: "AI Platform Engineer",
status: "draft",
};
test("createOwnEmployerPosting: POSTs the create route with the Bearer token + body on the wire", async () => {
const realFetch = globalThis.fetch;
const calls: Array<{ url: string; method?: string; headers: Record<string, string>; body?: string }> = [];
globalThis.fetch = (async (input: unknown, init?: RequestInit) => {
calls.push({
url: String(input),
method: init?.method,
headers: (init?.headers ?? {}) as Record<string, string>,
body: typeof init?.body === "string" ? init.body : undefined,
});
return new Response(JSON.stringify(WRITE_RESPONSE), {
status: 201,
headers: { "Content-Type": "application/json" },
});
}) as typeof fetch;
const { restore } = stubSessionStorage();
try {
const mod = await import(`../lib/engine-client.ts${QS}`);
mod.setEmployerSessionToken("sess-token-write");
const created = await mod.createOwnEmployerPosting({
title: "AI Platform Engineer",
description: "Build the platform.",
location: "San Francisco",
remote: true,
});
assert.equal(created.id, "emp-abc123");
assert.equal(created.source, "employer", "G-317: employer authorship rides the response");
assert.equal(new URL(calls[0].url, "http://localhost").pathname, "/v1/employers/postings");
assert.equal(calls[0].method, "POST");
assert.equal(calls[0].headers["Authorization"], "Bearer sess-token-write");
assert.ok(calls[0].body?.includes('"title":"AI Platform Engineer"'), "create body rides the wire");
} finally {
restore();
globalThis.fetch = realFetch;
}
});
test("editOwnEmployerPosting: PATCHes the own-draft route; publish/close POST their sub-routes", async () => {
const realFetch = globalThis.fetch;
const calls: Array<{ url: string; method?: string }> = [];
globalThis.fetch = (async (input: unknown, init?: RequestInit) => {
calls.push({ url: String(input), method: init?.method });
return new Response(JSON.stringify(WRITE_RESPONSE), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}) as typeof fetch;
const { restore } = stubSessionStorage();
try {
const mod = await import(`../lib/engine-client.ts${QS}`);
await mod.editOwnEmployerPosting("job-1", { title: "Updated", description: "New copy." });
await mod.publishOwnEmployerPosting("job-1");
await mod.closeOwnEmployerPosting("job-1");
assert.equal(new URL(calls[0].url, "http://localhost").pathname, "/v1/employers/postings/job-1");
assert.equal(calls[0].method, "PATCH", "edit is a PATCH");
assert.equal(
new URL(calls[1].url, "http://localhost").pathname,
"/v1/employers/postings/job-1/publish",
);
assert.equal(calls[1].method, "POST", "publish is a POST");
assert.equal(
new URL(calls[2].url, "http://localhost").pathname,
"/v1/employers/postings/job-1/close",
);
assert.equal(calls[2].method, "POST", "close is a POST");
} finally {
restore();
globalThis.fetch = realFetch;
}
});
test("402 → UpgradeRequiredError with reason + upgrade_cta from the dict detail (G-320b client arm)", async () => {
const realFetch = globalThis.fetch;
globalThis.fetch = (async () =>
new Response(
JSON.stringify({
detail: {
// The D-091 wire contract: byte-stable zeros in BOTH arms —
// the client consumes reason + upgrade_cta ONLY (G-320b).
reason: "no_active_package",
posts_used: 0,
posts_limit: 0,
upgrade_cta:
"This employer has no active posting package — purchase one via /v1/employers/billing/checkout.",
},
}),
{ status: 402, headers: { "Content-Type": "application/json" } },
)) as typeof fetch;
const { restore } = stubSessionStorage();
try {
const { createOwnEmployerPosting, UpgradeRequiredError } = await import(
`../lib/engine-client.ts${QS}`
);
await assert.rejects(
createOwnEmployerPosting({ title: "Blocked" }),
(err: unknown) => {
const upgrade = err as InstanceType<typeof UpgradeRequiredError>;
assert.ok(err instanceof UpgradeRequiredError, "must be UpgradeRequiredError");
assert.equal(upgrade.reason, "no_active_package");
assert.equal(
upgrade.upgradeCta,
"This employer has no active posting package — purchase one via /v1/employers/billing/checkout.",
);
assert.equal(upgrade.status, 402);
return true;
},
"402 maps to UpgradeRequiredError with reason + CTA",
);
} finally {
restore();
globalThis.fetch = realFetch;
}
});
test("403 no_claimed_employer on a write route → ClaimRequiredError (claim-prompt on 403)", async () => {
const realFetch = globalThis.fetch;
globalThis.fetch = (async () =>
new Response(
JSON.stringify({
detail: {
reason: "no_claimed_employer",
claim_cta:
"This account has not claimed an employer entity yet — submit a claim via POST /v1/employers/claims.",
},
}),
{ status: 403, headers: { "Content-Type": "application/json" } },
)) as typeof fetch;
const { restore } = stubSessionStorage();
try {
const { createOwnEmployerPosting, ClaimRequiredError } = await import(
`../lib/engine-client.ts${QS}`
);
await assert.rejects(
createOwnEmployerPosting({ title: "Blocked" }),
(err: unknown) => {
const claim = err as InstanceType<typeof ClaimRequiredError>;
assert.ok(err instanceof ClaimRequiredError, "must be ClaimRequiredError");
assert.equal(claim.reason, "no_claimed_employer");
return true;
},
"the claim-CTA 403 maps to ClaimRequiredError on write routes",
);
} finally {
restore();
globalThis.fetch = realFetch;
}
});
test("publish/close dispatch: the wire verbs hit their sub-routes (the component refetches after — pinned in source)", async () => {
const realFetch = globalThis.fetch;
const calls: Array<{ url: string; method?: string }> = [];
globalThis.fetch = (async (input: unknown, init?: RequestInit) => {
calls.push({ url: String(input), method: init?.method });
return new Response(JSON.stringify(WRITE_RESPONSE), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}) as typeof fetch;
const { restore } = stubSessionStorage();
try {
const mod = await import(`../lib/engine-client.ts${QS}`);
await mod.publishOwnEmployerPosting("job-1");
await mod.closeOwnEmployerPosting("job-1");
assert.equal(
new URL(calls[0].url, "http://localhost").pathname,
"/v1/employers/postings/job-1/publish",
);
assert.equal(calls[0].method, "POST", "publish dispatches a POST");
assert.equal(
new URL(calls[1].url, "http://localhost").pathname,
"/v1/employers/postings/job-1/close",
);
assert.equal(calls[1].method, "POST", "close dispatches a POST");
} finally {
restore();
globalThis.fetch = realFetch;
}
});
test("pin: PostingManager write actions — honest 402/403/409/404 branches + refetch (G-312 source pins)", async () => {
const src = readFileSync(web("components/employer/own-postings-client.tsx"), "utf8");
// The four write actions are wired to the engine-client functions.
for (const fn of [
"createOwnEmployerPosting",
"editOwnEmployerPosting",
"publishOwnEmployerPosting",
"closeOwnEmployerPosting",
]) {
assert.ok(src.includes(fn), `write action wired: ${fn}`);
}
// The four client functions take an AbortSignal (A-406).
const client = readFileSync(web("lib/engine-client.ts"), "utf8");
for (const fn of [
"createOwnEmployerPosting",
"editOwnEmployerPosting",
"publishOwnEmployerPosting",
"closeOwnEmployerPosting",
]) {
assert.ok(
new RegExp(`export async function ${fn}\\([\\s\\S]*?signal\\?`).test(client),
`${fn} takes an AbortSignal`,
);
}
// 402 → the upgrade CTA with reason ONLY (G-320b binding).
assert.ok(/UpgradeRequiredError/.test(src), "the 402 arm is consumed");
assert.ok(/upgradeCta/.test(src), "the CTA text renders from the payload");
assert.ok(/reason === 'no_active_package'/.test(src), "reason drives the copy (no package)");
assert.ok(/reason === 'posting_limit_reached'/.test(src), "reason drives the copy (limit reached)");
assert.ok(/href="\/employer\/billing"/.test(src), "the CTA routes to the employer billing surface");
// The 402 branch NEVER renders the payload's zeroed usage fields as
// limits — real limits render exclusively from the entitlement view
// (P5's route; absent until then). Byte-stable source pin.
const upgradeBranch = src.slice(
src.indexOf("function UpgradeNotice"),
src.indexOf("function writeActionMessage"),
);
assert.ok(
!/posts_used|posts_limit/.test(upgradeBranch),
"G-320b: the 402 branch renders no usage/limit numbers",
);
// 403 claim-CTA → the claim prompt on write actions too.
assert.ok(/ClaimRequiredError/.test(src), "the claim-CTA arm is consumed");
assert.ok(/claimPrompt/.test(src), "claim-prompt state exists");
// 401 → honest logged-out state (never a retry loop).
assert.ok(/SessionRequiredError/.test(src), "the 401 arm is consumed");
assert.ok(/loggedOut/.test(src), "logged-out state exists");
// 409 own-drafts-only error copy (G-318).
assert.ok(/err\.status === 409/.test(src), "the 409 branch exists");
assert.ok(/Only your own drafts/.test(src), "honest own-drafts-only copy");
// 404 honest.
assert.ok(/err\.status === 404/.test(src), "the 404 branch exists");
assert.ok(/no longer exists/.test(src), "honest 404 copy");
// Per-action loading + error states.
assert.ok(/actionBusy/.test(src), "per-action loading state");
assert.ok(/actionError/.test(src), "per-action error state");
// NO optimistic updates — the refetch composes every action.
assert.ok(/refetch/.test(src), "the list refetches after actions");
assert.ok(/await refetch\(controller\.signal\)/.test(src), "refetch AWAITS the read route");
assert.ok(!/setPostings\(/.test(src.slice(src.indexOf("runAction"))), "no optimistic posting writes");
// AbortController + no retry machinery (A-406).
assert.ok(/AbortController/.test(src), "AbortController discipline");
assert.ok(!/while \(|setTimeout|location\.reload/.test(src), "no retry loop machinery");
// No console logging (tokens/errors never logged).
assert.ok(!/console\./.test(src), "no console logging in the component");
});
test("pin: the dead mock PostingManager is gone (no mock-driven employer surface components)", async () => {
assert.ok(
!existsSync(web("components/employer/posting-manager.tsx")),
"the mock posting-manager.tsx is deleted (P4 write actions replace it)",
);
// The real surface is mock-free: no derived statuses, no mock imports.
const src = readFileSync(web("components/employer/own-postings-client.tsx"), "utf8");
assert.ok(!/@nextcraft\/mock-data/.test(src), "the real surface never imports mock data");
assert.ok(!/deriveStatus|deriveApplicants/.test(src), "no derived mock statuses/applicants");
});
-296
View File
@@ -1,296 +0,0 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
/**
* v0.10 P3 (REQ-10-003, D-092/R-61): the engine-client employer
* section — fetch stubs pin the 401 discrimination arm
* (SessionRequiredError), the Bearer transport on every employer
* fetch, and the logout revoke+clear contract. Source pins cover the
* no-retry-loop discipline (no render harness in-tree — the
* D-059/E87/G-312 pin discipline).
*/
const QS = `?t=${Date.now()}-${Math.random()}`;
const root = new URL("..", import.meta.url);
function web(path: string): string {
return fileURLToPath(new URL(path, root));
}
/** sessionStorage stub (node has none — the wrappers guard on window). */
function stubSessionStorage(): { store: Map<string, string>; restore(): void } {
const store = new Map<string, string>();
const globalAny = globalThis as Record<string, unknown>;
globalAny.window = {
sessionStorage: {
getItem: (k: string) => store.get(k) ?? null,
setItem: (k: string, v: string) => void store.set(k, v),
removeItem: (k: string) => void store.delete(k),
},
};
return { store, restore() { delete globalAny.window; } };
}
const ME_BODY = {
account: {
id: "acct-1",
email: "ops@example.com",
employer_id: null,
created_at: "2026-09-30T00:00:00Z",
email_verified: false,
},
claimed_employer_id: null,
claim: null,
};
test("parseError: 401 maps to SessionRequiredError (the D-092 arm — no 401 discrimination existed before v0.10)", async () => {
const realFetch = globalThis.fetch;
globalThis.fetch = (async () =>
new Response(JSON.stringify({ detail: "invalid or expired session" }), {
status: 401,
headers: { "Content-Type": "application/json" },
})) as typeof fetch;
try {
const { getEmployerMe, SessionRequiredError, EngineError } = await import(
`../lib/engine-client.ts${QS}`
);
await assert.rejects(
getEmployerMe(),
(err: unknown) => {
assert.ok(err instanceof SessionRequiredError, "must be SessionRequiredError");
assert.ok(err instanceof EngineError, "rides the EngineError family");
assert.equal((err as InstanceType<typeof EngineError>).status, 401);
return true;
},
"401 maps to SessionRequiredError",
);
} finally {
globalThis.fetch = realFetch;
}
});
test("parseError: the unclaimed 403 claim-CTA maps to ClaimRequiredError (D-090 authorization class)", async () => {
const realFetch = globalThis.fetch;
globalThis.fetch = (async () =>
new Response(
JSON.stringify({
detail: {
reason: "no_claimed_employer",
claim_cta: "This account has not claimed an employer entity yet",
},
}),
{ status: 403, headers: { "Content-Type": "application/json" } },
)) as typeof fetch;
try {
const { listOwnEmployerPostings, ClaimRequiredError } = await import(
`../lib/engine-client.ts${QS}`
);
await assert.rejects(
listOwnEmployerPostings(),
(err: unknown) => {
assert.ok(err instanceof ClaimRequiredError, "must be ClaimRequiredError");
assert.equal(
(err as InstanceType<typeof ClaimRequiredError>).reason,
"no_claimed_employer",
);
return true;
},
"claim-CTA 403 maps to ClaimRequiredError",
);
} finally {
globalThis.fetch = realFetch;
}
});
test("employer fetches: the Bearer token rides Authorization on every session-gated call", async () => {
const realFetch = globalThis.fetch;
const calls: Array<{ url: string; headers: Record<string, string> }> = [];
globalThis.fetch = (async (input: unknown, init?: RequestInit) => {
calls.push({
url: String(input),
headers: (init?.headers ?? {}) as Record<string, string>,
});
return new Response(JSON.stringify(ME_BODY), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}) as typeof fetch;
const { store, restore } = stubSessionStorage();
try {
const mod = await import(`../lib/engine-client.ts${QS}`);
mod.setEmployerSessionToken("sess-token-abc");
assert.equal(store.get("nc-employer-session"), "sess-token-abc", "wrapper writes the key");
await mod.getEmployerMe();
await mod.listOwnEmployerPostings();
await mod.claimEmployerEntity({ employer_id: "emp-openai" });
for (const [i, call] of calls.entries()) {
assert.equal(
call.headers["Authorization"],
"Bearer sess-token-abc",
`employer fetch #${i} carries the Bearer header`,
);
}
assert.equal(new URL(calls[0].url, "http://localhost").pathname, "/v1/employers/auth/me");
assert.equal(new URL(calls[1].url, "http://localhost").pathname, "/v1/employers/postings");
assert.equal(new URL(calls[2].url, "http://localhost").pathname, "/v1/employers/claims");
assert.equal(calls[2].url.includes("/v1/employers/claims"), true, "claims POST path");
} finally {
restore();
globalThis.fetch = realFetch;
}
});
test("logoutEmployer: clears the tab-scoped storage AND fires the revoke route", async () => {
const realFetch = globalThis.fetch;
const calls: Array<{ url: string; method?: string; headers: Record<string, string> }> = [];
globalThis.fetch = (async (input: unknown, init?: RequestInit) => {
calls.push({
url: String(input),
method: init?.method,
headers: (init?.headers ?? {}) as Record<string, string>,
});
return new Response(JSON.stringify({ revoked: true }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}) as typeof fetch;
const { store, restore } = stubSessionStorage();
try {
const mod = await import(`../lib/engine-client.ts${QS}`);
mod.setEmployerSessionToken("sess-token-xyz");
await mod.logoutEmployer();
assert.equal(store.has("nc-employer-session"), false, "storage cleared on logout");
assert.equal(calls.length, 1, "the revoke route fired exactly once");
assert.equal(new URL(calls[0].url, "http://localhost").pathname, "/v1/employers/auth/logout");
assert.equal(calls[0].method, "POST", "logout is a POST");
assert.equal(calls[0].headers["Authorization"], "Bearer sess-token-xyz", "revoke carries the dying token");
} finally {
restore();
globalThis.fetch = realFetch;
}
});
test("logoutEmployer: no token → no revoke call (storage already clean, no zombie fetch)", async () => {
const realFetch = globalThis.fetch;
const calls: unknown[] = [];
globalThis.fetch = (async (...args: unknown[]) => {
calls.push(args);
return new Response("{}", { status: 200 });
}) as typeof fetch;
const { restore } = stubSessionStorage();
try {
const mod = await import(`../lib/engine-client.ts${QS}`);
await mod.logoutEmployer();
assert.equal(calls.length, 0, "no revoke fetch without a stored token");
} finally {
restore();
globalThis.fetch = realFetch;
}
});
test("logoutEmployer: an already-dead token (401) still leaves the caller logged out — no thrown error surface", async () => {
const realFetch = globalThis.fetch;
globalThis.fetch = (async () =>
new Response(JSON.stringify({ detail: "invalid or expired session" }), {
status: 401,
headers: { "Content-Type": "application/json" },
})) as typeof fetch;
const { store, restore } = stubSessionStorage();
try {
const mod = await import(`../lib/engine-client.ts${QS}`);
mod.setEmployerSessionToken("already-dead");
await mod.logoutEmployer(); // must NOT reject
assert.equal(store.has("nc-employer-session"), false, "storage cleared even on the 401 path");
} finally {
restore();
globalThis.fetch = realFetch;
}
});
test("registerEmployer/loginEmployer: POST the auth routes with the body on the wire", async () => {
const realFetch = globalThis.fetch;
const calls: Array<{ url: string; method?: string; body?: string }> = [];
globalThis.fetch = (async (input: unknown, init?: RequestInit) => {
calls.push({
url: String(input),
method: init?.method,
body: typeof init?.body === "string" ? init.body : undefined,
});
return new Response(
JSON.stringify({ token: "tok-1", expires_at: "2026-10-07T00:00:00Z" }),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
}) as typeof fetch;
const { restore } = stubSessionStorage();
try {
const mod = await import(`../lib/engine-client.ts${QS}`);
const reg = await mod.registerEmployer({ email: "a@example.com", password: "longenough1" });
const log = await mod.loginEmployer({ email: "a@example.com", password: "longenough1" });
assert.equal(reg.token, "tok-1");
assert.equal(log.expires_at, "2026-10-07T00:00:00Z");
assert.equal(new URL(calls[0].url, "http://localhost").pathname, "/v1/employers/auth/register");
assert.equal(new URL(calls[1].url, "http://localhost").pathname, "/v1/employers/auth/login");
assert.equal(calls[0].method, "POST");
assert.ok(calls[0].body?.includes('"email":"a@example.com"'), "email rides the body");
// 422 honesty (R-56): the client never echoes the password into a
// log or storage — this test pins the body is JSON-only (the
// server's pii_fields scrub owns the 422 side).
assert.ok(calls[0].body?.startsWith("{"), "JSON body");
} finally {
restore();
globalThis.fetch = realFetch;
}
});
test("pin: the employer section keeps the no-retry-loop + AbortController discipline (G-312 source pin)", async () => {
const client = readFileSync(web("lib/engine-client.ts"), "utf8");
// SessionRequiredError exists + joins parseError before the generic fallthrough.
assert.ok(/export class SessionRequiredError/.test(client), "SessionRequiredError exported");
assert.ok(
/status === 401[\s\S]{0,200}SessionRequiredError/,
"the 401 arm maps to SessionRequiredError inside parseError",
);
// The 401 arm precedes the 403 arm (both are discrimination arms).
const idx401 = client.indexOf("resp.status === 401");
const idx403 = client.indexOf("resp.status === 403");
assert.ok(idx401 > 0 && idx403 > idx401, "401 discrimination precedes 403");
// The sessionStorage wrapper is window-guarded (the adminToken pattern).
assert.ok(
/typeof window === 'undefined'[\s\S]{0,120}sessionStorage.getItem\(EMPLOYER_SESSION_KEY\)/.test(
client,
),
"employerSessionToken is window-guarded",
);
// The employer functions own AbortController-compatible signal params.
for (const fn of [
"registerEmployer",
"loginEmployer",
"logoutEmployer",
"getEmployerMe",
"claimEmployerEntity",
"listOwnEmployerPostings",
]) {
assert.ok(
new RegExp(`export async function ${fn}\\([\\s\\S]*?signal\\?`).test(client),
`${fn} takes an AbortSignal`,
);
}
// No retry loop: the client never re-fetches on a 401 — the caller
// renders the logged-out state (pinned as the absence of retry logic).
assert.ok(
!/retry|setTimeout|while \(true\)/.test(
client.slice(client.indexOf("-- employer auth + account")),
),
"the employer section contains no retry machinery",
);
// The tab-scoped exposure tradeoff is documented in the section copy
// (open-risk-4: bound the XSS window, honest about it).
assert.ok(
/tab-scoped/.test(client),
"the sessionStorage tab-scoping rationale is documented",
);
});
@@ -1,232 +0,0 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
/**
* v0.11 P5 (REQ-11-005, D-104/G-322): the learner placement-status
* client + panel wiring. Fetch stubs pin the wire shapes + the query
* composition (the billing-entitlement template); source pins cover
* the panel's mine-read-first composition, the honest state arms, and
* the A-304 badge honesty (the G-312 discipline — no render harness
* in-tree, none may be added).
*/
const QS = `?t=${Date.now()}-${Math.random()}`;
const root = new URL("..", import.meta.url);
function web(path: string): string {
return fileURLToPath(new URL(path, root));
}
const STATUS_PAYLOAD = {
application: {
id: "app-1",
posting_id: "post-1",
learner_id: "pilot-learner",
status: "in_review",
created_at: "2026-10-21T00:00:00Z",
},
events: [
{
id: 1,
application_id: "app-1",
placement_id: null,
event_type: "application.in_review",
actor_type: "employer",
actor_ref: "employer:emp-test",
payload: {},
prev_hash: "0".repeat(64),
hash: "a".repeat(64),
created_at: "2026-10-21T00:01:00Z",
},
],
placement: null,
};
interface Call {
url: string;
method?: string;
body?: unknown;
headers: Record<string, string>;
}
function stubFetch(handler: () => Response): { calls: Call[]; restore: () => void } {
const realFetch = globalThis.fetch;
const calls: Call[] = [];
globalThis.fetch = (async (input: unknown, init?: RequestInit) => {
calls.push({
url: String(input),
method: init?.method,
body: init?.body ? JSON.parse(String(init.body)) : undefined,
headers: (init?.headers ?? {}) as Record<string, string>,
});
return handler();
}) as typeof fetch;
return { calls, restore: () => { globalThis.fetch = realFetch; } };
}
const ok = (payload: unknown) =>
new Response(JSON.stringify(payload), {
status: 200,
headers: { "Content-Type": "application/json" },
});
test("getMyApplication: GETs the mine route with the (posting, learner) pair — G-322", async () => {
const { calls, restore } = stubFetch(() =>
ok({ application_id: "app-1", status: "submitted" }),
);
try {
const { getMyApplication } = await import(`../lib/engine-client.ts${QS}`);
const mine = await getMyApplication("post-1", "pilot-learner");
assert.equal(mine.application_id, "app-1");
assert.equal(mine.status, "submitted");
const url = new URL(calls[0].url, "http://localhost");
assert.equal(url.pathname, "/v1/marketplace/applications/mine");
assert.equal(url.searchParams.get("posting_id"), "post-1");
assert.equal(url.searchParams.get("learner_id"), "pilot-learner");
assert.equal(calls[0].method, undefined, "the discovery read is a GET");
} finally {
restore();
}
});
test("getMyApplication: no application → EngineError 404 (the apply-CTA arm)", async () => {
const { restore } = stubFetch(
() => new Response(JSON.stringify({ detail: "none" }), { status: 404 }),
);
try {
const { getMyApplication, EngineError } = await import(`../lib/engine-client.ts${QS}`);
await assert.rejects(getMyApplication("post-1", "pilot-learner"), (err: unknown) => {
assert.ok(err instanceof EngineError, "must be EngineError");
const e = err as InstanceType<typeof EngineError>;
return e.status === 404;
});
} finally {
restore();
}
});
test("getLearnerApplicationStatus: GETs the status read with the learner query", async () => {
const { calls, restore } = stubFetch(() => ok(STATUS_PAYLOAD));
try {
const { getLearnerApplicationStatus } = await import(`../lib/engine-client.ts${QS}`);
const status = await getLearnerApplicationStatus("app-1", "pilot-learner");
assert.equal(status.application.status, "in_review");
assert.equal(status.events.length, 1);
assert.equal(status.events[0].event_type, "application.in_review");
assert.equal(status.placement, null);
const url = new URL(calls[0].url, "http://localhost");
assert.equal(url.pathname, "/v1/marketplace/applications/app-1");
assert.equal(url.searchParams.get("learner_id"), "pilot-learner");
} finally {
restore();
}
});
test("withdrawApplication: POSTs the withdraw body — the learner's only edit (D-104)", async () => {
const { calls, restore } = stubFetch(() =>
ok({ application_id: "app-1", posting_id: "post-1", status: "withdrawn" }),
);
try {
const { withdrawApplication } = await import(`../lib/engine-client.ts${QS}`);
const confirmation = await withdrawApplication("app-1", "pilot-learner");
assert.equal(confirmation.status, "withdrawn");
const url = new URL(calls[0].url, "http://localhost");
assert.equal(url.pathname, "/v1/marketplace/applications/app-1/withdraw");
assert.equal(calls[0].method, "POST");
assert.deepEqual(calls[0].body, { learner_id: "pilot-learner" });
} finally {
restore();
}
});
test("withdrawApplication: terminal → EngineError 409 (the honest matrix refusal)", async () => {
const { restore } = stubFetch(
() => new Response(JSON.stringify({ detail: "matrix" }), { status: 409 }),
);
try {
const { withdrawApplication, EngineError } = await import(`../lib/engine-client.ts${QS}`);
await assert.rejects(withdrawApplication("app-1", "pilot-learner"), (err: unknown) => {
assert.ok(err instanceof EngineError, "must be EngineError");
const e = err as InstanceType<typeof EngineError>;
return e.status === 409;
});
} finally {
restore();
}
});
test("getLearnerApplicationStatus: foreign → EngineError 403 (the not-your-application arm)", async () => {
const { restore } = stubFetch(
() => new Response(JSON.stringify({ detail: "foreign" }), { status: 403 }),
);
try {
const { getLearnerApplicationStatus, EngineError } = await import(`../lib/engine-client.ts${QS}`);
await assert.rejects(getLearnerApplicationStatus("app-1", "pilot-learner"), (err: unknown) => {
assert.ok(err instanceof EngineError, "must be EngineError");
const e = err as InstanceType<typeof EngineError>;
return e.status === 403;
});
} finally {
restore();
}
});
test("pin: the panel composes mine-read first — never client-persisted application ids (G-322)", async () => {
const src = readFileSync(web("components/marketplace/job-detail-client.tsx"), "utf8");
assert.ok(/getMyApplication\(/.test(src), "the mine-read drives discovery");
assert.ok(/getLearnerApplicationStatus\(/.test(src), "the status read drives the panel");
assert.ok(/withdrawApplication\(/.test(src), "the withdraw CTA dispatches through the client");
assert.ok(
!/localStorage\.setItem|sessionStorage\.setItem/.test(src),
"no client-persisted application ids (no setItem anywhere)",
);
// mine 404 → the apply CTA (the honest absence arm)
assert.ok(
/mineErr instanceof EngineError && mineErr\.status === 404/.test(src),
"the 404 arm falls through to the apply CTA",
);
});
test("pin: the seven D-099 states render honestly (labels + withdraw gate + terminal/403 copy)", async () => {
const src = readFileSync(web("components/marketplace/job-detail-client.tsx"), "utf8");
for (const state of [
"'submitted'",
"'in_review'",
"'interview'",
"'offer'",
"'hired'",
"'rejected'",
"'withdrawn'",
]) {
assert.ok(src.includes(state), `the ${state} arm renders a label`);
}
assert.ok(
/WITHDRAWABLE_STATUSES/.test(src),
"the withdraw CTA gates on non-terminal sources (D-099)",
);
assert.ok(/status === 409/.test(src), "the 409 arm renders the honest terminal copy");
assert.ok(
/This application belongs to another learner\./.test(src),
"the foreign-403 copy renders",
);
});
test("pin: history rows + the placement-confirmation render from the status payload", async () => {
const src = readFileSync(web("components/marketplace/job-detail-client.tsx"), "utf8");
assert.ok(/appStatus\.events\.map\(/.test(src), "history rows render from events[]");
assert.ok(
/appStatus\.placement &&/.test(src),
"the placement confirmation renders when hired",
);
assert.ok(
/role_title/.test(src) && /start_date/.test(src),
"the placement payload fields render",
);
assert.ok(
/Placement recorded/.test(src),
"the placement copy says RECORDED (G-328 — never a verification claim)",
);
});
-679
View File
@@ -263,682 +263,3 @@ test("pricing page: runtime catalog wiring (REQ-9-002/D-079 — no hardcoded tie
"no raw cents arithmetic outside formatPrice",
);
});
// -- v0.10 employer accounts (REQ-10-003, G-108 dual-schema, MH-3a) ---------------
test("wire pin: EmployerAccount/EmployerClaim/token+me shapes mirror employers/base.py field-for-field (dual-schema sync)", async () => {
const typesSrc = readFileSync(web("../../packages/types/employer.ts"), "utf8");
const pySrc = readFileSync(
web("../ai-service/ai_service/employers/base.py"),
"utf8",
);
// EmployerAccount: every pydantic field asserted on BOTH sides.
const acctBlock = typesSrc.slice(
typesSrc.indexOf("export interface EmployerAccount"),
typesSrc.indexOf("export interface EmployerClaim {"),
);
for (const f of [
"id: string;",
"email: string;",
"employer_id: string | null;",
"created_at: string;",
"email_verified: false;",
]) {
assert.ok(acctBlock.includes(f), `EmployerAccount missing base.py field ${f}`);
}
// D-097: email_verified can never be true (Literal[False] both sides).
assert.ok(!/email_verified: boolean/.test(acctBlock), "email_verified is false, never boolean");
assert.ok(/email_verified: Literal\[False\]/.test(pySrc), "py side pins Literal[False]");
// EmployerClaim (+ the typed evidence dict).
const claimBlock = typesSrc.slice(
typesSrc.indexOf("export interface EmployerClaim {"),
typesSrc.indexOf("export interface RegisterEmployerRequest {"),
);
for (const f of [
"id: string;",
"account_id: string;",
"employer_id: string;",
"status: EmployerClaimStatus;",
"evidence:",
"created_at: string;",
"decided_by: string | null;",
"decided_at: string | null;",
]) {
assert.ok(claimBlock.includes(f), `EmployerClaim missing base.py field ${f}`);
}
// The advisory evidence dict shape (claim_evidence.py compute_claim_evidence).
const evidenceBlock = typesSrc.slice(
typesSrc.indexOf("export interface EmployerClaimEvidence"),
typesSrc.indexOf("export interface EmployerAccount"),
);
for (const f of [
"account_email_domain: string;",
"entity_website_domain: string;",
"match: boolean;",
]) {
assert.ok(evidenceBlock.includes(f), `EmployerClaimEvidence missing D-089 field ${f}`);
}
// Request shapes + TokenResponse + EmployerMe.
const reqBlock = typesSrc.slice(
typesSrc.indexOf("export interface RegisterEmployerRequest {"),
typesSrc.indexOf("export interface EmployerTokenResponse"),
);
for (const f of ["email: string;", "password: string;", "employer_id: string;"]) {
assert.ok(reqBlock.includes(f), `employer request shapes missing base.py field ${f}`);
}
const tokenBlock = typesSrc.slice(
typesSrc.indexOf("export interface EmployerTokenResponse"),
typesSrc.indexOf("export interface EmployerMe"),
);
for (const f of ["token: string;", "expires_at: string;"]) {
assert.ok(tokenBlock.includes(f), `TokenResponse missing base.py field ${f}`);
}
const meBlock = typesSrc.slice(
typesSrc.indexOf("export interface EmployerMe"),
typesSrc.indexOf("export type OwnPostingStatus"),
);
for (const f of [
"account: EmployerAccount;",
"claimed_employer_id: string | null;",
"claim: EmployerClaim | null;",
]) {
assert.ok(meBlock.includes(f), `EmployerMe missing base.py field ${f}`);
}
// The py side keeps the same fields in its pydantic contracts.
for (const f of [
"class EmployerAccount",
"class EmployerClaim",
"class RegisterEmployerRequest",
"class LoginEmployerRequest",
"class ClaimEmployerRequest",
"class TokenResponse",
"class EmployerMe",
"ClaimStatus",
"PASSWORD_MIN_LENGTH = 8",
]) {
assert.ok(pySrc.includes(f), `employers/base.py missing wire contract ${f}`);
}
// Claim status parity: the TS union mirrors the py Literal.
const tsStatuses = typesSrc.match(
/export type EmployerClaimStatus = '[a-z]+' \| '[a-z]+' \| '[a-z]+'/,
);
assert.ok(tsStatuses, "EmployerClaimStatus union exists");
assert.ok(
tsStatuses && tsStatuses[0].includes("'pending'") && tsStatuses[0].includes("'approved'") && tsStatuses[0].includes("'rejected'"),
"claim status union = pending|approved|rejected (py ClaimStatus)",
);
});
test("wire pin: OwnPostingSummary REUSES PostingSummary + own-postings response mirrors base.py (G-319/D-093)", async () => {
const typesSrc = readFileSync(web("../../packages/types/employer.ts"), "utf8");
const marketplaceSrc = readFileSync(web("../../packages/types/marketplace.ts"), "utf8");
const pySrc = readFileSync(
web("../ai-service/ai_service/employers/base.py"),
"utf8",
);
// G-319 REUSE: composition (extends PostingSummary), never a
// field-for-field duplicate of the PostingSummary fields.
const ownBlock = typesSrc.slice(
typesSrc.indexOf("export interface OwnPostingSummary"),
typesSrc.indexOf("export interface OwnPostingsFacets"),
);
assert.ok(
/export interface OwnPostingSummary extends PostingSummary \{/.test(ownBlock),
"OwnPostingSummary extends PostingSummary (reuse, never duplication)",
);
assert.ok(/applicant_count: number;/.test(ownBlock), "OwnPostingSummary carries applicant_count");
// The PostingSummary fields must NOT be re-listed inside the own block
// (a duplicate would drift — the REUSE contract).
for (const dup of ["external_id:", "required_competencies:", "salary_min:"]) {
assert.ok(!ownBlock.includes(dup), `OwnPostingSummary duplicates PostingSummary field ${dup}`);
}
// The py side subclasses marketplace.base.Posting (the same reuse).
assert.ok(/class OwnPostingSummary\(Posting\)/.test(pySrc), "py OwnPostingSummary subclasses Posting");
// PII boundary (G-319, binding): the contract carries the count only —
// no learner-identifier field name exists anywhere on the TS shape.
assert.ok(!/learner_id|applicant_name|candidate_id/.test(ownBlock), "PII boundary: counts only");
// OwnPostingsFacets: every lifecycle key on both sides.
const facetsBlock = typesSrc.slice(
typesSrc.indexOf("export interface OwnPostingsFacets"),
typesSrc.indexOf("export interface OwnPostingsResponse"),
);
for (const f of ["draft: number;", "published: number;", "closed: number;"]) {
assert.ok(facetsBlock.includes(f), `OwnPostingsFacets missing lifecycle key ${f}`);
}
assert.ok(/draft: int = 0/.test(pySrc) && /published: int = 0/.test(pySrc) && /closed: int = 0/.test(pySrc), "py OwnPostingsFacets carries the same three keys");
// OwnPostingsResponse: list + facets + honest total, both sides.
const respBlock = typesSrc.slice(typesSrc.indexOf("export interface OwnPostingsResponse"));
for (const f of [
"postings: OwnPostingSummary[];",
"facets: OwnPostingsFacets;",
"total: number;",
]) {
assert.ok(respBlock.includes(f), `OwnPostingsResponse missing base.py field ${f}`);
}
assert.ok(/postings: list\[OwnPostingSummary\]/.test(pySrc), "py OwnPostingsResponse carries the postings list");
// PostingSummary itself stays single-sourced in marketplace.ts (the
// wire pin at pins.test.ts:48 owns its field list).
assert.ok(
/export interface PostingSummary \{/.test(marketplaceSrc),
"PostingSummary remains defined once in marketplace.ts",
);
});
test("wire pin: employer types surface through the package index (the billing.ts precedent)", async () => {
const indexSrc = readFileSync(web("../../packages/types/index.ts"), "utf8");
assert.ok(
/export \* from '\.\/employer';/.test(indexSrc),
"index.ts re-exports employer.ts",
);
});
test("wire pin: EmployerPostingCreate/Edit/WriteResponse mirror employers/base.py field-for-field (P4 write contracts)", async () => {
const typesSrc = readFileSync(web("../../packages/types/employer.ts"), "utf8");
const pySrc = readFileSync(
web("../ai-service/ai_service/employers/base.py"),
"utf8",
);
// EmployerPostingCreate: every pydantic field asserted on BOTH sides.
const createBlock = typesSrc.slice(
typesSrc.indexOf("export interface EmployerPostingCreate"),
typesSrc.indexOf("export interface EmployerPostingEdit"),
);
for (const f of [
"title: string;",
"description?: string;",
"required_competencies?: string[];",
"skills?: string[];",
"seniority?: string | null;",
"category?: string | null;",
"location?: string;",
"remote?: boolean;",
"salary_min?: number | null;",
"salary_max?: number | null;",
]) {
assert.ok(createBlock.includes(f), `EmployerPostingCreate missing base.py field ${f}`);
}
// The employer_id field is deliberately ABSENT (the entity derives
// from the session's claim, never the body — Task 4-1-02).
assert.ok(
!/employer_id/.test(createBlock),
"EmployerPostingCreate never carries employer_id (claim-derived)",
);
// The py side keeps the same fields (base.py EmployerPostingCreate).
const pyCreateBlock = pySrc.slice(
pySrc.indexOf("class EmployerPostingCreate"),
pySrc.indexOf("class EmployerPostingEdit"),
);
for (const f of [
"title: str = Field(min_length=1)",
"description: str =",
"required_competencies: list[str]",
"skills: list[str]",
"seniority: str | None",
"category: str | None",
"location: str =",
"remote: bool =",
"salary_min: int | None",
"salary_max: int | None",
]) {
assert.ok(pyCreateBlock.includes(f), `base.py EmployerPostingCreate missing ${f}`);
}
// EmployerPostingEdit: the partial shape (title/description/skills).
const editBlock = typesSrc.slice(
typesSrc.indexOf("export interface EmployerPostingEdit"),
typesSrc.indexOf("export interface EmployerPostingWriteResponse"),
);
for (const f of ["title?: string;", "description?: string;", "skills?: string[];"]) {
assert.ok(editBlock.includes(f), `EmployerPostingEdit missing base.py field ${f}`);
}
const pyEditBlock = pySrc.slice(
pySrc.indexOf("class EmployerPostingEdit"),
pySrc.indexOf("class EmployerPostingWriteResponse"),
);
for (const f of [
"title: str | None = Field(default=None, min_length=1)",
"description: str | None = None",
"skills: list[str] | None = None",
]) {
assert.ok(pyEditBlock.includes(f), `base.py EmployerPostingEdit missing ${f}`);
}
// EmployerPostingWriteResponse: the write-verdict shape both sides.
const respBlock = typesSrc.slice(
typesSrc.indexOf("export interface EmployerPostingWriteResponse"),
);
for (const f of [
"id: string;",
"source: string;",
"external_id: string;",
"employer_id: string;",
"title: string;",
"status: string;",
]) {
assert.ok(respBlock.includes(f), `EmployerPostingWriteResponse missing base.py field ${f}`);
}
const pyRespBlock = pySrc.slice(pySrc.indexOf("class EmployerPostingWriteResponse"));
for (const f of ["id: str", "source: str", "external_id: str", "employer_id: str", "title: str", "status: str"]) {
assert.ok(pyRespBlock.includes(f), `base.py EmployerPostingWriteResponse missing ${f}`);
}
});
// -- v0.10 P5 billing + pricing CTAs (REQ-10-005, Task 5-2-01) ----------------------
test("wire pin: EmployerCheckout/Entitlement/MockComplete shapes mirror employers/base.py field-for-field (P5 billing contracts)", async () => {
const typesSrc = readFileSync(web("../../packages/types/employer.ts"), "utf8");
const pySrc = readFileSync(
web("../ai-service/ai_service/employers/base.py"),
"utf8",
);
// EmployerCheckoutCreate: item_id only (D-076's employer_id body
// field is GONE — the claimed entity is the only buyer).
const createBlock = typesSrc.slice(
typesSrc.indexOf("export interface EmployerCheckoutCreate"),
typesSrc.indexOf("export interface EmployerCheckoutResponse"),
);
assert.ok(
createBlock.includes("item_id: string;"),
"EmployerCheckoutCreate carries item_id",
);
assert.ok(
!/employer_id/.test(createBlock),
"EmployerCheckoutCreate never carries employer_id (claim-derived buyer)",
);
// EmployerCheckoutResponse: the v0.9 CheckoutResponse shape reused.
const respBlock = typesSrc.slice(
typesSrc.indexOf("export interface EmployerCheckoutResponse"),
typesSrc.indexOf("export interface EmployerPurchaseEntitlement"),
);
for (const f of [
"session_id: string;",
"provider_session_id: string;",
"url: string;",
"mode: string;",
]) {
assert.ok(respBlock.includes(f), `EmployerCheckoutResponse missing base.py field ${f}`);
}
// The purchase/subscription entitlement summaries — the EXACT keys
// billing/store.py get_entitlement emits (the v0.9 shape reused).
const purchaseBlock = typesSrc.slice(
typesSrc.indexOf("export interface EmployerPurchaseEntitlement"),
typesSrc.indexOf("export interface EmployerSubscriptionEntitlement"),
);
for (const f of [
"id: string;",
"package_id: string;",
"package_name: string;",
"posts_used: number;",
"posts_limit: number | null;",
"expires_at: string | null;",
]) {
assert.ok(purchaseBlock.includes(f), `EmployerPurchaseEntitlement missing store key ${f}`);
}
const subBlock = typesSrc.slice(
typesSrc.indexOf("export interface EmployerSubscriptionEntitlement"),
typesSrc.indexOf("export interface EmployerEntitlementResponse"),
);
for (const f of [
"id: string;",
"plan_id: string;",
"plan_name: string;",
"status: string;",
"current_period_end: string | null;",
"past_due_at: string | null;",
]) {
assert.ok(subBlock.includes(f), `EmployerSubscriptionEntitlement missing store key ${f}`);
}
// EmployerEntitlementResponse: honest nulls until a real grant.
const entBlock = typesSrc.slice(
typesSrc.indexOf("export interface EmployerEntitlementResponse"),
typesSrc.indexOf("export interface EmployerMockCompleteRequest"),
);
assert.ok(entBlock.includes("employer_id: string;"), "employer_id");
assert.ok(
entBlock.includes("purchase: EmployerPurchaseEntitlement | null;"),
"purchase is honestly nullable",
);
assert.ok(
entBlock.includes("subscription: EmployerSubscriptionEntitlement | null;"),
"subscription is honestly nullable",
);
// EmployerMockCompleteRequest/Response (D-082/G-301/G-302).
const reqBlock = typesSrc.slice(
typesSrc.indexOf("export interface EmployerMockCompleteRequest"),
typesSrc.indexOf("export interface EmployerMockCompleteResponse"),
);
assert.ok(
reqBlock.includes("provider_session_id: string;"),
"the untrusted ID reference (G-302)",
);
const mockBlock = typesSrc.slice(
typesSrc.indexOf("export interface EmployerMockCompleteResponse"),
);
for (const f of ["completed: boolean;", "payment_status: string;"]) {
assert.ok(mockBlock.includes(f), `EmployerMockCompleteResponse missing ${f}`);
}
// The py side keeps the same contracts (base.py P5 classes).
const pyP5Block = pySrc.slice(pySrc.indexOf("class EmployerCheckoutCreate"));
for (const f of [
"class EmployerCheckoutCreate",
"item_id: str = Field(min_length=1)",
"class EmployerCheckoutResponse",
"session_id: str",
"provider_session_id: str",
"url: str",
"mode: str",
"class EmployerEntitlementResponse",
"purchase: dict[str, Any] | None = None",
"subscription: dict[str, Any] | None = None",
"class EmployerMockCompleteRequest",
"class EmployerMockCompleteResponse",
"completed: bool",
"payment_status: str",
]) {
assert.ok(pyP5Block.includes(f), `base.py P5 billing contract missing ${f}`);
}
});
test("wire pin: admin overview shapes mirror employers_admin.py operator contracts (Task 5-2-02, D-094)", async () => {
const typesSrc = readFileSync(web("../../packages/types/employer.ts"), "utf8");
const pySrc = readFileSync(
web("../ai-service/ai_service/api/employers_admin.py"),
"utf8",
);
// AdminEmployerAccount: the accounts-list wire row, both sides.
const acctBlock = typesSrc.slice(
typesSrc.indexOf("export interface AdminEmployerAccount"),
typesSrc.indexOf("export interface AdminAccountsResponse"),
);
for (const f of [
"id: string;",
"email: string;",
"employer_id: string | null;",
"created_at: string;",
"email_verified: false;",
]) {
assert.ok(acctBlock.includes(f), `AdminEmployerAccount missing employers_admin.py field ${f}`);
}
// D-097: the unverified marker can never be true; D-087: the
// password hash is NEVER a wire field.
assert.ok(!/email_verified: boolean/.test(acctBlock), "email_verified is false, never boolean");
assert.ok(!/password_hash/.test(acctBlock), "password_hash never rides the wire (D-087)");
assert.ok(/email_verified: Literal\[False\]/.test(pySrc), "py side pins Literal[False]");
// AdminAccountsResponse / AdminClaimsResponse / ClaimDecisionResponse.
const accountsRespBlock = typesSrc.slice(
typesSrc.indexOf("export interface AdminAccountsResponse"),
typesSrc.indexOf("export interface AdminClaimsResponse"),
);
assert.ok(
accountsRespBlock.includes("accounts: AdminEmployerAccount[];"),
"AdminAccountsResponse.accounts",
);
assert.ok(accountsRespBlock.includes("count: number;"), "AdminAccountsResponse.count");
const claimsRespBlock = typesSrc.slice(
typesSrc.indexOf("export interface AdminClaimsResponse"),
typesSrc.indexOf("export interface ClaimDecisionResponse"),
);
assert.ok(
claimsRespBlock.includes("claims: EmployerClaim[];"),
"the queue reuses the EmployerClaim wire shape",
);
assert.ok(claimsRespBlock.includes("count: number;"), "AdminClaimsResponse.count");
const decisionBlock = typesSrc.slice(
typesSrc.indexOf("export interface ClaimDecisionResponse"),
typesSrc.indexOf("export interface AdminAuditEvent"),
);
assert.ok(decisionBlock.includes("claim: EmployerClaim;"), "ClaimDecisionResponse.claim");
// AdminAuditEvent / AdminAuditResponse — the BillingEventRow shape.
const auditBlock = typesSrc.slice(
typesSrc.indexOf("export interface AdminAuditEvent"),
typesSrc.indexOf("export interface AdminAuditResponse"),
);
for (const f of [
"id: number;",
"account_id: string | null;",
"employer_id: string | null;",
"kind: string;",
"name: string;",
"detail: Record<string, unknown>;",
"operator: string | null;",
"created_at: string | null;",
]) {
assert.ok(auditBlock.includes(f), `AdminAuditEvent missing employers_admin.py field ${f}`);
}
const auditRespBlock = typesSrc.slice(
typesSrc.indexOf("export interface AdminAuditResponse"),
);
assert.ok(auditRespBlock.includes("events: AdminAuditEvent[];"), "AdminAuditResponse.events");
assert.ok(auditRespBlock.includes("count: number;"), "AdminAuditResponse.count");
// The py side keeps the same contracts (employers_admin.py).
for (const f of [
"class AdminEmployerAccount",
"class AdminAccountsResponse",
"class AdminClaimsResponse",
"class ClaimDecisionResponse",
"class AdminAuditEvent",
"class AdminAuditResponse",
"claims: list[EmployerClaim]",
"claim: EmployerClaim",
"events: list[AdminAuditEvent]",
]) {
assert.ok(pySrc.includes(f), `employers_admin.py missing wire contract ${f}`);
}
});
test("pricing pin: purchasable CTAs route to /employer/login; the enterprise Contact Sales pin STAYS (Task 5-2-01, MH-5d)", async () => {
const client = readFileSync(
web("components/marketplace/pricing-client.tsx"),
"utf8",
);
// The employer-login path is the purchasable-tier CTA href.
assert.ok(
/href=\{isEnterprise \? '[^']*' : '\/employer\/login'\}/.test(client),
"purchasable tiers route to the employer-login path",
);
// The enterprise contact CTA is RETAINED (R-61: the pins.test.ts:250
// pin holds — price_cents=0 rows stay operator-mediated).
assert.ok(/Contact Sales/.test(client), "enterprise Contact Sales retained");
assert.ok(
/if \(isEnterprise\) return 'Contact Sales';/.test(client),
"enterprise arm keeps the contact copy",
);
// The operator-mediated-only pilot copy is GONE — replaced by the
// self-serve truth with the admin fallback noted.
assert.ok(
!/operator-mediated during the pilot/.test(client),
"the D-079 operator-mediated copy is superseded",
);
assert.ok(
/operator-assisted checkout remains available as a fallback/.test(client),
"the admin-fallback is noted honestly",
);
assert.ok(/mock provider \(honestly labeled/.test(client), "the pilot mock path is labeled");
});
// v0.11 P3 (D-110): the placement wire pins — placement.ts mirrors
// placements/base.py field-for-field (the G-108 discipline; the
// employer.ts block's template). Every change happens in both places.
test("wire pin: Placement mirrors placements/base.py PlacementRead field-for-field (dual-schema sync)", async () => {
const typesSrc = readFileSync(web("../../packages/types/placement.ts"), "utf8");
const pySrc = readFileSync(
web("../../apps/ai-service/ai_service/placements/base.py"),
"utf8",
);
const block = typesSrc.slice(
typesSrc.indexOf("export interface Placement {"),
typesSrc.indexOf("export interface PlacementEvent"),
);
const fields = [
"id:", "learner_id:", "employer_id:", "posting_id:",
"application_id:", "role_title:", "start_date:", "status:",
"created_at:", "decided_at:",
];
for (const f of fields) {
assert.ok(block.includes(f), `Placement missing base.py field ${f}`);
}
// start_date is a DATE on both sides (D-103) — never a datetime
assert.ok(
/start_date: date/.test(pySrc),
"base.py start_date is a pydantic date",
);
assert.ok(
/start_date: string;/.test(block),
"placement.ts start_date is the ISO date string",
);
});
test("wire pin: ApplicationStatus union mirrors the D-099 matrix (status-union regex pin)", async () => {
const typesSrc = readFileSync(web("../../packages/types/placement.ts"), "utf8");
const block = typesSrc.slice(
typesSrc.indexOf("export type ApplicationStatus"),
typesSrc.indexOf("export type PlacementStatus"),
);
// the seven D-099 statuses, in matrix order
const statuses = [
"'submitted'", "'in_review'", "'interview'", "'offer'",
"'hired'", "'rejected'", "'withdrawn'",
];
for (const status of statuses) {
assert.ok(block.includes(status), `ApplicationStatus missing ${status}`);
}
assert.ok(
!/"submitted"/.test(block),
"snake_case on the wire (no kebab 'in-review')",
);
});
test("wire pin: PlacementOverview mirrors the D-107 computation shape", async () => {
const typesSrc = readFileSync(web("../../packages/types/placement.ts"), "utf8");
const block = typesSrc.slice(
typesSrc.indexOf("export interface PlacementOverview"),
);
const fields = [
"total_active_placements:", "distinct_learners_placed:",
"guiding_goal:", "by_employer:", "by_cohort_month:",
];
for (const f of fields) {
assert.ok(block.includes(f), `PlacementOverview missing ${f}`);
}
assert.ok(
/export const GUIDING_GOAL = 1000;/.test(typesSrc),
"the north star is 1,000 learners",
);
});
test("wire pin: EmployerApplicationSummary carries learner_id per the G-319 supersession (D-105)", async () => {
const typesSrc = readFileSync(web("../../packages/types/placement.ts"), "utf8");
const block = typesSrc.slice(
typesSrc.indexOf("export interface EmployerApplicationSummary"),
typesSrc.indexOf("export interface EmployerApplicationsResponse"),
);
assert.ok(/learner_id: string;/.test(block), "learner_id rides the summary");
// the own-postings LIST stays counts-only: OwnPostingSummary (the
// v0.10 contract) still carries ONLY applicant_count — never a
// learner_id field
const employerTypes = readFileSync(web("../../packages/types/employer.ts"), "utf8");
const ownBlock = employerTypes.slice(
employerTypes.indexOf("export interface OwnPostingSummary"),
employerTypes.indexOf("export interface OwnPostingsFacets"),
);
assert.ok(
!/learner_id/.test(ownBlock),
"the G-319 pin split: OwnPostingSummary stays counts-only",
);
});
// v0.11 P5 (REQ-11-005, D-110): the learner status-read wire pins +
// the A-304 badge-honesty census — no placement badge on the mock
// talent surfaces (D-092 binding: a badge on a mock page is
// fabricated placement); the learner's-own application surface is the
// ONLY placement render.
test("wire pin: LearnerApplicationStatus mirrors the base.py learner status read (D-104)", async () => {
const typesSrc = readFileSync(web("../../packages/types/placement.ts"), "utf8");
const pySrc = readFileSync(
web("../../apps/ai-service/ai_service/placements/base.py"),
"utf8",
);
const summaryBlock = typesSrc.slice(
typesSrc.indexOf("export interface LearnerApplicationSummary"),
typesSrc.indexOf("export interface LearnerApplicationStatus"),
);
for (const f of ["id:", "posting_id:", "learner_id:", "status:", "created_at:"]) {
assert.ok(summaryBlock.includes(f), `LearnerApplicationSummary missing ${f}`);
}
const statusBlock = typesSrc.slice(
typesSrc.indexOf("export interface LearnerApplicationStatus"),
);
assert.ok(/application: LearnerApplicationSummary;/.test(statusBlock));
assert.ok(/events: PlacementEvent\[\];/.test(statusBlock));
assert.ok(/placement: Placement \| null;/.test(statusBlock));
// the backend contract carries the same three arms (base.py)
const pyBlock = pySrc.slice(pySrc.indexOf("class LearnerApplicationStatusResponse"));
assert.ok(/application: LearnerApplicationSummary/.test(pyBlock));
assert.ok(/events: list\[PlacementEventRead\]/.test(pyBlock));
assert.ok(/placement: PlacementRead \| None/.test(pyBlock));
// the mine-read contract (G-322)
const mineBlock = typesSrc.slice(typesSrc.indexOf("export interface MineApplication"));
assert.ok(/application_id: string;/.test(mineBlock));
assert.ok(/status: ApplicationStatus;/.test(mineBlock));
assert.ok(
/class MineApplicationResponse/.test(pySrc),
"base.py carries the mine contract",
);
});
test("pin: A-304 badge honesty — no placement badge on the mock talent surfaces (D-092/D-109)", async () => {
const surfaces = [
"app/(employer)/employer/talent/page.tsx",
"app/(employer)/employer/talent/[candidateId]/page.tsx",
"components/employer/talent-search.tsx",
];
for (const surface of surfaces) {
const src = readFileSync(web(surface), "utf8");
assert.ok(
!/placement/i.test(src),
`${surface}: a badge on a mock surface is fabricated placement (D-092 binding)`,
);
}
// the learner's-own application surface is the ONLY placement render
const jobDetail = readFileSync(
web("components/marketplace/job-detail-client.tsx"),
"utf8",
);
assert.ok(
/appStatus\.placement &&/.test(jobDetail),
"the placement confirmation renders only on the learner's-own application surface",
);
const placementBlock = jobDetail.slice(
jobDetail.indexOf("appStatus.placement &&"),
jobDetail.indexOf("{WITHDRAWABLE_STATUSES.has("),
);
assert.ok(
/Placement recorded/.test(placementBlock),
"G-328: the placement copy says RECORDED",
);
assert.ok(
!/verified/i.test(placementBlock),
"G-328: no verification claim on the placement surface",
);
});
-2
View File
@@ -1,7 +1,5 @@
# Deploying Nextcraft — the admin runbook (v0.8)
**TL;DR** — AI-native outcome school + marketplace — graduates prove what they can build, not what they can write. This file explains how to stand the stack up on any Debian-based server: pick one of three install channels, run `nextcraft deploy --service`, and verify with the checklist; troubleshooting tables and evidence sit lower in the file.
Production topology since v0.3.6: **one port, one process**. The ai-service
(uvicorn, `:8420`) serves **both** the API and the exported web UI, so the
site works behind any proxy that can reach exactly one backend port — e.g.
-320
View File
@@ -1,320 +0,0 @@
/**
* Nextcraft — Employer Account Types
*
* Employer auth/account wire shapes (v0.10 P3, REQ-10-003): the
* employer-principal surface served by /v1/employers/*.
*
* Dual-schema-sync mirror of
* `apps/ai-service/ai_service/employers/base.py` (the G-108
* discipline): field-for-field parity, snake_case as emitted by the
* API — every change happens in both places, pinned by
* `apps/web/tests/pins.test.ts`.
*
* D-097: `email_verified` is `false` — the account is UNVERIFIED,
* displayed as such; never presented as verified.
*
* D-093/G-319: `OwnPostingSummary` REUSES the PostingSummary wire
* shape from marketplace.ts (composition, never a field-for-field
* duplication) + `applicant_count`. PII boundary (binding): applicant
* COUNTS are the only applications-derived datum — learner
* identifiers never ride any /v1/employers/* response.
*/
import type { PostingSummary, PostingStatus } from './marketplace';
/** Claim lifecycle states (D-089): pending until an operator decides.
* Approved activates ownership (one account : one entity); rejection
* frees the entity for a new claim. */
export type EmployerClaimStatus = 'pending' | 'approved' | 'rejected';
/** Advisory claim evidence (D-089, computed AT CLAIM TIME by
* claim_evidence.py — a snapshot stored as provenance JSON, never
* recomputed on read). The domain-match signal is ADVISORY and never
* displayed as verification (A-304). */
export interface EmployerClaimEvidence {
account_email_domain: string;
entity_website_domain: string;
match: boolean;
}
/** An employer-side login account (base.py EmployerAccount). */
export interface EmployerAccount {
id: string;
/** G-315: plain string — stdlib-validated + normalized server-side. */
email: string;
/** None until a claim is APPROVED (one account : one entity). */
employer_id: string | null;
created_at: string;
/** D-097: UNVERIFIED — always false, displayed as such. */
email_verified: false;
}
/** An account's claim on an EmployerRow entity (base.py EmployerClaim). */
export interface EmployerClaim {
id: string;
account_id: string;
employer_id: string;
status: EmployerClaimStatus;
/** The claim-time evidence snapshot (advisory, never verification). */
evidence: Partial<EmployerClaimEvidence> & Record<string, unknown>;
created_at: string;
/** Operator provenance — null while pending. */
decided_by: string | null;
decided_at: string | null;
}
/** POST /v1/employers/auth/register body (base.py RegisterEmployerRequest:
* G-315 email + D-086 password min length 8, validated server-side). */
export interface RegisterEmployerRequest {
email: string;
password: string;
}
/** POST /v1/employers/auth/login body (base.py LoginEmployerRequest). */
export interface LoginEmployerRequest {
email: string;
password: string;
}
/** POST /v1/employers/claims body (base.py ClaimEmployerRequest). */
export interface ClaimEmployerRequest {
employer_id: string;
}
/** Login/register session response (base.py TokenResponse): the bearer
* token (raw, exactly once — only sha256(token) is stored server-side,
* D-087) + its lazy-expiry timestamp. */
export interface EmployerTokenResponse {
token: string;
expires_at: string;
}
/** GET /v1/employers/auth/me response (base.py EmployerMe): the
* account (unverified email displayed as such) + the claimed entity +
* the claim state — null until a claim exists. */
export interface EmployerMe {
account: EmployerAccount;
claimed_employer_id: string | null;
claim: EmployerClaim | null;
}
/** G-319: the employer-facing posting summary — the PostingSummary
* wire shape REUSED from marketplace.ts (composition, never
* duplicated) + the per-posting applicant COUNT (the D-092 overview
* data source). PII boundary: counts ONLY, never learner identifiers. */
export interface OwnPostingSummary extends PostingSummary {
applicant_count: number;
}
/** Status facet counts over the session employer's OWN postings
* (base.py OwnPostingsFacets): the full lifecycle — draft|published|
* closed — every key always present (a zero-count status carries its
* honest 0). */
export interface OwnPostingsFacets {
draft: number;
published: number;
closed: number;
}
/** GET /v1/employers/postings response (base.py OwnPostingsResponse):
* own postings (all lifecycle statuses) + per-status facet counts +
* the honest total. Unclaimed accounts never see this shape (the
* route's 403 claim-CTA precedes it). */
export interface OwnPostingsResponse {
postings: OwnPostingSummary[];
facets: OwnPostingsFacets;
total: number;
}
/** The own-postings status facet keys (base.py OWN_POSTING_STATUSES —
* the marketplace PostingStatus lifecycle, all of it, unlike the
* public board's published-only view). */
export type OwnPostingStatus = PostingStatus;
/** POST /v1/employers/postings body (base.py EmployerPostingCreate,
* Task 4-1-02): the employer-authored draft — the AdminPostingCreate
* shape MINUS employer_id (the entity derives from the session's
* CLAIM, never the body). Starts as a DRAFT; publish is explicit (the
* admin-route lifecycle discipline). Field-for-field mirror: title is
* required (min_length 1); every other field carries the pydantic
* default and may be omitted on the wire. */
export interface EmployerPostingCreate {
title: string;
description?: string;
required_competencies?: string[];
skills?: string[];
seniority?: string | null;
category?: string | null;
location?: string;
remote?: boolean;
salary_min?: number | null;
salary_max?: number | null;
}
/** PATCH /v1/employers/postings/{id} body (base.py EmployerPostingEdit,
* G-318): partial content edit — fields omitted stay put; a
* successful edit RE-TAGS the posting (tags are derived data, D-052).
* Own DRAFTS only (published/closed → 409). */
export interface EmployerPostingEdit {
title?: string;
description?: string;
skills?: string[];
}
/** Create/edit/publish/close response (base.py
* EmployerPostingWriteResponse): the write-verdict shape — G-317's
* source stamp rides it ("employer" on every employer-authored row;
* seeded/admin rows keep their own). */
export interface EmployerPostingWriteResponse {
id: string;
source: string;
external_id: string;
employer_id: string;
title: string;
status: string;
}
// P5 (REQ-10-005): the self-serve checkout contracts — mirrors of
// base.py's EmployerCheckoutCreate/Response, EmployerEntitlementResponse
// and EmployerMockCompleteRequest/Response. The checkout/entitlement
// contracts REUSE the v0.9 billing wire shapes where they coincide
// (D-093) — the py side carries the purchase/subscription summaries as
// honest dicts; the TS mirror types the exact keys the store emits.
/** POST /v1/employers/billing/checkout body (base.py
* EmployerCheckoutCreate, Task 5-1-01): which catalog item — the buyer
* derives from the session's CLAIMED entity (D-076's operator-mediated
* employer_id body field is GONE; the claimed entity is the only buyer
* the route will ever record). */
export interface EmployerCheckoutCreate {
item_id: string;
}
/** POST /v1/employers/billing/checkout response (base.py
* EmployerCheckoutResponse): the created session handle + the local
* reconciliation id — the v0.9 billing_admin.CheckoutResponse shape
* REUSED verbatim (one wire contract for both checkout principals). */
export interface EmployerCheckoutResponse {
session_id: string;
provider_session_id: string;
url: string;
mode: string;
}
/** The active-purchase entitlement summary riding
* EmployerEntitlementResponse.purchase (the v0.9 shape — the exact
* keys billing/store.py get_entitlement emits). `posts_limit` null =
* unlimited; `expires_at` null = no expiry. Expired windows are
* skipped lazily at read (G-306) — an expired purchase never rides
* this shape. */
export interface EmployerPurchaseEntitlement {
id: string;
package_id: string;
package_name: string;
posts_used: number;
posts_limit: number | null;
expires_at: string | null;
}
/** The subscription entitlement summary riding
* EmployerEntitlementResponse.subscription (the v0.9 shape — the exact
* keys the store emits; lazy past_due → expired grace per G-306). */
export interface EmployerSubscriptionEntitlement {
id: string;
plan_id: string;
plan_name: string;
status: string;
current_period_end: string | null;
past_due_at: string | null;
}
/** GET /v1/employers/billing/entitlement response (base.py
* EmployerEntitlementResponse): the session employer's OWN entitlement
* summary resolved for the CLAIMED entity only. Honest states:
* purchase/subscription are null until a real grant exists (the
* surface renders the honest empty state from this). The wire shape
* CANNOT distinguish never-purchased from expired (the store skips
* expired windows lazily, G-306 — both read as null) — surfaces must
* never assert expiry provenance from a null purchase. */
export interface EmployerEntitlementResponse {
employer_id: string;
purchase: EmployerPurchaseEntitlement | null;
subscription: EmployerSubscriptionEntitlement | null;
}
/** POST /v1/employers/billing/mock/complete body (base.py
* EmployerMockCompleteRequest, Task 5-1-02, D-082/G-301): the
* provider session id being completed. The id is an UNTRUSTED
* reference (G-302) — the route resolves it against the session's OWN
* checkout rows, never takes it on faith. */
export interface EmployerMockCompleteRequest {
provider_session_id: string;
}
/** POST /v1/employers/billing/mock/complete response (base.py
* EmployerMockCompleteResponse): the completion result — the v0.9
* MockCompleteResponse shape REUSED verbatim. */
export interface EmployerMockCompleteResponse {
completed: boolean;
payment_status: string;
}
// P5 (REQ-10-005, D-094): the admin network-overview wire shapes —
// mirrors of api/employers_admin.py's operator contracts (the G-108
// discipline, both sides pinned by pins.test.ts).
/** The accounts-list wire row (employers_admin.py
* AdminEmployerAccount): email, claimed entity, created_at, the
* UNVERIFIED marker. `email_verified` is `false` — structurally
* incapable of true (D-097); password_hash NEVER rides the wire
* (D-087). */
export interface AdminEmployerAccount {
id: string;
email: string;
employer_id: string | null;
created_at: string;
/** D-097: the account is UNVERIFIED — displayed as such. */
email_verified: false;
}
/** GET /v1/employers/admin/accounts response
* (AdminAccountsResponse). */
export interface AdminAccountsResponse {
accounts: AdminEmployerAccount[];
count: number;
}
/** GET /v1/employers/admin/claims response (AdminClaimsResponse): the
* claim queue — status, the stored evidence snapshot (ADVISORY,
* never verification — A-304), the decided state. */
export interface AdminClaimsResponse {
claims: EmployerClaim[];
count: number;
}
/** POST /v1/employers/admin/claims/{id}/approve|reject response
* (ClaimDecisionResponse): the decided claim (status + operator
* provenance) — either decision arm returns the same shape. */
export interface ClaimDecisionResponse {
claim: EmployerClaim;
}
/** The audit-trail wire row (employers_admin.py AdminAuditEvent —
* the BillingEventRow shape): operator provenance on every claim
* decision. */
export interface AdminAuditEvent {
id: number;
account_id: string | null;
employer_id: string | null;
kind: string;
name: string;
detail: Record<string, unknown>;
operator: string | null;
created_at: string | null;
}
/** GET /v1/employers/admin/audit response (AdminAuditResponse). */
export interface AdminAuditResponse {
events: AdminAuditEvent[];
count: number;
}
-2
View File
@@ -1,10 +1,8 @@
export * from './domain';
export * from './billing';
export * from './employer';
export * from './defense';
export * from './grading';
export * from './marketplace';
export * from './placement';
export * from './telemetry';
export * from './user';
export * from './ui';
-167
View File
@@ -1,167 +0,0 @@
/**
* Nextcraft — Placement Types
*
* Placement-tracking wire shapes (v0.11, REQ-11-001..005): the
* hiring-pipeline surface served by /v1/employers/applications/*,
* /v1/marketplace/applications/*, and /v1/placements/admin/*.
*
* Dual-schema-sync mirror of
* `apps/ai-service/ai_service/placements/base.py` (the G-108
* discipline, D-110): field-for-field parity, snake_case as emitted
* by the API — every change happens in both places, pinned by
* `apps/web/tests/pins.test.ts`.
*
* D-099: the application status set is forward-only with
* skip-forward; hired/rejected/withdrawn are TERMINAL.
*
* G-319 supersession (D-105): learner_id IS visible on
* application-scoped reads for the session's own claimed-entity
* postings — the own-postings LIST stays counts-only.
*/
/** D-099: the hiring lifecycle (snake_case, the billing `past_due`
* precedent). hired/rejected/withdrawn are terminal — no outgoing
* edges. */
export type ApplicationStatus =
| 'submitted'
| 'in_review'
| 'interview'
| 'offer'
| 'hired'
| 'rejected'
| 'withdrawn';
/** D-102: the placement lifecycle — created active; the ONLY
* transition is the operator correction active → revoked. */
export type PlacementStatus = 'active' | 'revoked';
/** A placement record (base.py PlacementRead). employer/learner/
* posting are ID REFERENCES resolved from source rows at read/export
* (G-302) — never trusted denormalized copies. start_date is the
* employer-attested employment start DATE (D-103), never a
* datetime. */
export interface Placement {
id: string;
learner_id: string;
employer_id: string;
posting_id: string;
application_id: string;
role_title: string;
start_date: string;
status: PlacementStatus;
created_at: string;
decided_at: string;
}
/** One ledger event (base.py PlacementEventRead): the append-only
* hash-chained trail — application-stage events carry
* application_id (placement_id null); the placement lane carries
* placement_id set. */
export interface PlacementEvent {
id: number;
application_id: string | null;
placement_id: string | null;
event_type: string;
actor_type: string;
actor_ref: string;
payload: Record<string, unknown>;
prev_hash: string;
hash: string;
created_at: string;
}
/** An employer-facing application row (base.py
* EmployerApplicationSummary, D-105/G-319): learner_id visible on
* application-scoped reads ONLY (the own-postings list stays
* counts-only — the pin split). */
export interface EmployerApplicationSummary {
id: string;
posting_id: string;
learner_id: string;
status: ApplicationStatus;
created_at: string | null;
}
/** GET /v1/employers/applications response (base.py
* EmployerApplicationsResponse): own-pipeline applications (every
* status) + per-status facet counts (every status key ALWAYS
* present — a zero-count status carries its honest 0). */
export interface EmployerApplicationsResponse {
applications: EmployerApplicationSummary[];
facets: Record<ApplicationStatus, number>;
total: number;
}
/** The transition result (base.py ApplicationTransitionResponse):
* the application's new status + the placement record when the
* transition created one (hire). */
export interface ApplicationTransitionResponse {
application_id: string;
status: ApplicationStatus;
placement: Placement | null;
}
/** POST .../hire body (base.py HireTransitionRequest, D-103/G-324):
* role_title is employer-attested free text (1..200 — the house
* bounds); start_date is the employment start DATE (YYYY-MM-DD). */
export interface HireTransitionRequest {
role_title: string;
start_date: string;
}
/** GET /v1/employers/placements response (base.py
* EmployerPlacementsResponse): the claimed entity's OWN placements
* (active + revoked — the full history), created_at then id. */
export interface EmployerPlacementsResponse {
placements: Placement[];
total: number;
}
/** D-107: the guiding outcome's north star (PROJECT.md line 7 — the
* noun is LEARNERS, not placements). */
export const GUIDING_GOAL = 1000;
/** GET /v1/placements/admin/overview response (base.py
* PlacementOverview, D-107): counts computed from rows at query
* time — never denormalized counters. The guiding metric is
* DISTINCT learners with ≥1 active placement (a learner placed
* twice counts ONCE); revoked excluded from every count; cohort
* month = the calendar month of start_date (YYYY-MM). */
export interface PlacementOverview {
total_active_placements: number;
distinct_learners_placed: number;
guiding_goal: number;
by_employer: Array<{ employer_id: string; count: number }>;
by_cohort_month: Array<{ month: string; count: number }>;
}
/** The application row on the learner status read (base.py
* LearnerApplicationSummary, D-104): the mine-visible fields only —
* ids + status + the apply timestamp (nullable per the v0.6 row). */
export interface LearnerApplicationSummary {
id: string;
posting_id: string;
learner_id: string;
status: ApplicationStatus;
created_at: string | null;
}
/** GET /v1/marketplace/applications/{application_id}?learner_id=
* response (base.py LearnerApplicationStatusResponse, REQ-11-005):
* the learner's-own application read — current status + the full
* id-ordered event history for THIS application + the placement
* record when hired (the honest null otherwise, A-304). */
export interface LearnerApplicationStatus {
application: LearnerApplicationSummary;
events: PlacementEvent[];
placement: Placement | null;
}
/** GET /v1/marketplace/applications/mine response (base.py
* MineApplicationResponse, G-322): the panel's application discovery
* on revisit — a 404 renders the apply CTA (the panel never depends
* on client-persisted application_ids). */
export interface MineApplication {
application_id: string;
status: ApplicationStatus;
}