docs(P00): complete pre-execution phase — v0.11 placement tracking
CI / node (push) Successful in 40s
Release / release (push) Successful in 2m50s
CI / python (push) Successful in 3m12s

Phase 0 complete: SPECIFY (D-098 scope lock — REQ-F-020 activated
as REQ-11-001..005), CLARIFY (D-099..D-112), RESEARCH (R-67..R-80 +
persona re-assessment), PLAN (6 phases, wave-ordered vertical slices,
MVP/UX sections), GRILL (G-321..G-328 applied, verdict PROCEED at
0.82), MVP/UX check (all 3 sections verified). Tags on the v0.10.x
line; phase 0 ships as v0.10.0.

---ci---
phase: 0
milestone: v0.11
status: complete
---/ci---
This commit is contained in:
CIAgent
2026-10-01 11:47:33 +00:00
parent 7f7d39b748
commit 8684d9d60c
9 changed files with 616 additions and 157 deletions
+8
View File
@@ -0,0 +1,8 @@
{
"phase": 0,
"stage": "mvp_ux_check",
"milestone": "v0.11",
"phase_role": "pre_execution",
"attempts": 0,
"updated_at": "2026-10-01T03:15:00Z"
}
+62
View File
@@ -331,3 +331,65 @@ None. Every phase maps to a REQ-10-001..005 row; the one candidate (employer dra
6. G-317/G-318/G-311 → PLAN Task 4-1-02 amendment note (single combined note)
7. G-319 → PLAN Task 3-1-01 amendment note
8. G-320b → PLAN Task 4-2-01 amendment note + UX criterion 3 clause
---
## v0.11 Grill (Phase 0 GRILL, 2026-10-01)
### Run: 2026-10-01 03:00 (mode: mechanical, focus: all — v0.11 placement tracking)
### Verdict: Proceed (confidence: 0.82)
**Verdict summary.** The v0.11 plan (6 phases, 15 waves, ~30 tasks) is feasible, correctly sequenced, and — rare for a plan this audit-flavored — unusually honest about its own limits (R-67's three methodology gaps are pinned into the export). Every load-bearing tree citation re-verified held: the billing transition-matrix shape, the `decide_claim` same-transaction precedent, the `submitted`-only application status (zero migration), the G-313 conftest reset pattern, the G-319 counts-only pin, all four main.py wiring sites, the engine-client error-class rails, and the overview mock block D-109 replaces. The two genuinely novel mechanisms (the hash chain, the export) each carry dedicated adversarial tests (tamper/fork/determinism), and the one coherence gap research found (R-80 re-hire) is pinned to a route. But red-teaming found **8 binding defects**: the export's verification story overclaims by omission (the chain detects in-place mutation of an intact sequence — it cannot detect a wholesale DB rewrite, and no instruction tells the auditor how to use prior exports as the anchor); **the learner status panel cannot find the learner's application on revisit** (the read route is keyed by `application_id`, no route exists to discover it for a posting, and the plan never says how the panel obtains it — the happy path only works in the same browser session); the apply route's idempotent arm returns a 200 "applied" confirmation for a withdrawn (terminal) application — a lie D-104's withdraw creates; `role_title` accepts the empty string (the 422 arm pins absence, not emptiness); revoked placements are counted but their evidence bundles are unspecified (an unverifiable `revoked_count`); the re-hired PlacementRow's `decided_at` is unspecified; the operator route's `to` has no 422-vs-409 discrimination; and nothing pins the live UI wording against "CIRR-verified" theater. All eight amend existing tasks — nothing re-architects, no scope cuts required.
### Per-Axis Findings
| # | Axis | Verdict | Reason |
|---|------|---------|--------|
| 1 | Feasibility | PASS | Outcome is unmeasurable without this milestone (no hire state exists in-tree); every prerequisite shipped; 6-phase shape matches the two shipped precedents |
| 2 | Scope | PASS | Explicit out-of-scope list; R-67's three pilot gaps pre-disclosed in the methodology (the hidden-requirement class handled better than any prior milestone); the one boundary change (G-319 supersession) is decisioned with a pin split |
| 3 | Architecture | CONCERN → fixed | Chain NOT over-engineered (it IS the deliverable; the cheap version) but UNDER-scoped (G-321: the walk cannot detect a wholesale DB rewrite — export-to-export prefix continuity is the anchor); the mine-read gap strands the learner status panel on revisit (G-322); D-100 cross-store transaction verified sound (one Session, one commit — the two-commit alternative voids the tamper-evidence) |
| 4 | People | PASS | Roster matches the work; the security-auditor's four review points cover exactly the novel-risk surfaces |
| 5 | Timeline | PASS | Consistent with v0.9/v0.10 identical-shape precedents; the flush-then-hash-then-commit append is the one new mechanism, mitigated by R-70's in-tree template + tamper/fork arms |
| 6 | Budget | PASS | Zero new deps, zero knobs, zero workflow changes; chain verification is O(n) sha256 over tiny rows |
| 7 | Risks | CONCERN → fixed | Pre-mortem done (R-80 found + pinned by research itself; all 10 open risks map 1:1 to tasks); NEW cross-decision defect found: apply → withdraw → re-apply returns a 200 "applied" lie (G-323 — D-043 × D-099 × D-104 interaction nobody mapped) |
| 8 | Governance | CONCERN → fixed | Decisions traceable + discipline enforced (the R-80 escalation flow is the pattern working); MISSING: live-surface wording discipline — "CIRR-verified" theater drift unpinned (G-328); the operator route's 422-vs-409 split unspecified (G-327) |
| 9 | Adoption | CONCERN → fixed | Export is honestly-minimal (dataset + disclosure, not theater) PROVIDED G-321/G-328 hold; counting gameable only via operator-gated claim approval — honestly disclosed (a-321); empty-string role_title accepted (G-324) |
| M | Meta | CONCERN → fixed | Revoked placements' evidence bundles unspecified — an auditor handed revoked_count with no evidence has an unverifiable number, and correction history is what an auditor most wants to inspect (G-325); the re-hired row's decided_at unpinned (G-326); simplest-80% version = this plan (no cut found) |
### Binding Decisions (G-321..G-328 — applied to PLAN before execute)
**G-321 — the export methodology states the chain's proof scope, its limit, and the auditor's real anchor.** The walk proves in-place mutation/fork detection of an intact sequence; it does NOT detect a wholesale DB rewrite (delete all events, re-append a fresh plausible chain from GENESIS — it walks green; GENESIS is a constant with no external anchor). PlacementRow itself is not hash-chained — a raw-SQL status flip leaves a chain-consistent ledger with a lying row. Decision: the methodology's `verification` block + the summary's verification instructions state (a) what the chain proves, (b) what it does not (wholesale replacement — the auditor's defense is export-to-export continuity: a prior export's `ledger.events` must appear as an exact prefix of a later export's, and `head_hash` at the prior export must equal the hash at that prefix position), and (c) an event↔row cross-consistency check (every active placement has `placement.created`; every revoked has `placement.revoked`; revoked_count matches the `placement.revoked` events). Amends Tasks 4-1-01, 4-1-02; asserted present by 4-1-03. Confidence 0.80.
**G-322 — the mine point-read: the learner status panel must never depend on client-persisted application_ids.** The read route is keyed by application_id, but no route discovers a learner's application for a posting (the idempotent apply return yields the id only in-session; there is no by-(posting, learner) read anywhere in-tree) — REQ-11-005's outcome visibility breaks on revisit, the common case for a job board. Decision: store `get_application_by_posting_learner(posting_id, learner_id)` (the insert_application unique key — the get_posting point-read shape) + route `GET /v1/marketplace/applications/mine?posting_id=&learner_id=` on the unchanged D-043 gate stack, returning `{application_id, status}` or 404. The job-detail panel composes: mine-read → found? render the status panel via D-104's read : not found? render the apply CTA. Amends D-104 (additive route), Tasks 5-1-01 + 5-2-01; tests in test_learner_status.py (own/foreign/unverified/unknown arms + 404-when-none). Confidence 0.85.
**G-323 — apply on an existing terminal application → 409, never a 200 "applied" confirmation.** insert_application's idempotent arm (marketplace/store.py:341-344) returns the EXISTING application with 200 — after D-104's withdraw makes it terminal, re-apply tells the learner they applied to a job they can never re-enter the pipeline for. Decision: the apply route's idempotent arm gains a terminal-state guard: returned application in `withdrawn`/`rejected`/`hired` → 409 `{"reason": "application_terminal", "status": …}` (the posting_not_published 409 shape, marketplace.py:366-373), never a 200; no new application row (the unique key stands); recovery is visibility (G-322's mine-read + status panel), not resurrection. Amends Task 2-2-01's test list (apply → withdraw → re-apply → 409). Confidence 0.75.
**G-324 — `role_title = Field(min_length=1, max_length=200)`; start_date keeps no semantic range.** Pydantic "required" rejects absence, not emptiness — the house Field shape (marketplace/base.py:68-71) is not specified for the hire body and `role_title: ""` would enter the hashed ledger. Decision: min_length=1 (pinned beside the missing-fields 422 arm), max_length=200 (bounds absurd payloads out of the hashed rows). start_date stays a bare pydantic date — retroactive + future legal per D-103; garbage dates surface as anomalous by_cohort_month buckets in the operator overview and are corrected via the revoke/re-hire lane; the methodology's attestation note covers date integrity. Amends Tasks 1-1-01, 2-1-01. Confidence 0.80.
**G-325 — evidence_bundles cover EVERY placement (active + revoked), each with the full event history.** An auditor handed `revoked_count: 3` with no evidence for the three revocations has an unverifiable number — and the correction history (hired → revoked → rehired) is precisely what an auditor most wants to inspect. Decision: bundles cover active AND revoked placements with the full event history incl. placement.revoked/placement.rehired; `cohort` stays active-only; revoked_count equals the placement.revoked events (cross-checkable, rides G-321(c)). Amends Tasks 4-1-01, 4-1-03 (completeness test: every placement active-or-revoked has a bundle). Confidence 0.80.
**G-326 — the re-hired PlacementRow's decided_at = the re-hire event's store-stamped created_at.** D-102 stamps decided_at from "the hired transition's created_at" — a re-hire has no hired transition; an executor will guess. Decision: the same `datetime.now(UTC)` stamp rule extends to the re-hire arm; stated in the module docstring + asserted in the revoke→re-hire test arm. Amends Task 2-2-02. Confidence 0.85.
**G-327 — the operator transition route's `to` is a pydantic Literal of the seven D-099 statuses.** Unknown value → 422 (body validation), matrix-illegal known value → 409 InvalidTransition — the route never sees a status outside the vocabulary (no 500s, no silent acceptance). Test arm in test_admin_lane.py (garbage `to` → 422, never 500/409). Amends Task 2-2-02. Confidence 0.85.
**G-328 — live surfaces never present attestation as verification.** Nothing pins the admin placements block / overview copy against the theater drift — "CIRR-verified placements" or "verified: 1,000" on a live surface presents attestation as third-party verification (A-304), the exact self-certification trap the milestone could fall into. Decision: live surfaces label counts as recorded placements ("distinct learners placed", "placements recorded"); "CIRR" NEVER appears as a verification claim on any live surface — "CIRR-style" describes the export format in release notes/methodology only; the summary's title renders the attestation language ("employer-attested, operator-overseen"). Amends Task 4-2-01 (copy) + UX criterion 5's test arm (source pin: no "verified" placement wording on any new surface). Confidence 0.75.
### Advisories (recorded, no action)
- a-321: No technical self-hire guard exists (learner handles and employer accounts are disjoint namespaces); the anti-gaming control is operator claim-approval diligence + per-employer overview detection + the revoke lane — already disclosed as R-67 gap (c); do not add a theater cross-check.
- a-322: Withdraw shares the apply-rate window (5/60s per learner, D-104) — a learner withdrawing 5 applications quickly locks themselves out of applying for the window's remainder. Honest, bounded, no new knob.
- a-323: The matrix arm (read status → validate) is check-then-act, but the terminal guard makes a concurrent loser's write a 409-conflict on re-read; the D-111 concurrent-transition test covers the shape; no pessimistic locking at pilot volumes.
- a-324: The employer pipeline list necessarily carries learner_id per row (own postings only) — the G-319 pin split's application-scoped arm working as decisioned; the P2 security review confirms the list route's cross-entity arms return 403 with no data, same as the transitions.
### Escalations
None. All eight findings resolve with evidence-backed defaults (each names its amends-task and its test); nothing re-architects, no axis falls below 0.60.
### Plan Edits Recorded (applied by the orchestrator)
1. G-321/G-325/G-328 → PLAN Tasks 4-1-01, 4-1-02, 4-1-03, 4-2-01 amendment notes + UX criterion 5 arm
2. G-322 → PLAN Tasks 5-1-01, 5-2-01 amendment notes (additive route per D-104)
3. G-323 → PLAN Task 2-2-01 amendment note (apply-route terminal guard + test arm)
4. G-324 → PLAN Tasks 1-1-01, 2-1-01 amendment notes
5. G-326/G-327 → PLAN Task 2-2-02 amendment notes
+35 -37
View File
@@ -2,13 +2,13 @@
## Persona Roster
> **v0.10 update (RESEARCH, lead-developer assessment):** milestone = B2B Employer Network (Feature — ai_service/employers/ module: 8th D-027 store + scrypt auth + session/claim flows, /v1/employers routes, employer web-surface conversion + admin network overview). **Activated:** lead-developer (coordination), backend-engineer (owns ai_service/employers/* + api/employers*.py + api/employers posting/billing routes + the D-091 helper + config.py knobs + main.py wiring — REQ-10-001..005 core), data-engineer (owns the 4-table employer store schema: account/session/claim/audit rows, unique + check-first constraints, tz normalization — REQ-10-001/002), frontend-engineer (owns the employer surface conversion + engine-client employer section + SessionRequiredError + login/register pages + packages/types/employer.ts + admin overview sections + pricing CTA conversion — REQ-10-003/005), security-auditor (phase-specific: scrypt params + compare_digest, session hashing-at-rest, throttle windows, CORS Authorization, 422 password-echo scrub, claim-evidence honesty, sessionStorage exposure, PII — REQ-10-001/002), identity-engineer stays DEACTIVATED (see its note — employer auth is NOT the v0.5 learner identity module). **Deactivated:** cli-engineer, devops-engineer, sandbox-engineer, ai-engineer, voice-engineer, design-system-engineer (all unchanged surfaces this milestone).
> **v0.11 update (RESEARCH, lead-developer assessment):** milestone = Placement Tracking and Audit (Feature — ai_service/placements/ module: 9th D-027 store with the hash-chained placement_event ledger, the application state machine via one transactional transition_application, /v1/employers/applications per-target routes, learner withdraw/status routes in marketplace.py, the placements_admin operator lane + export, four web surfaces). **Activated:** lead-developer (coordination + the R-80 re-hire coherence review + the G-319 pin-split review), backend-engineer (owns ai_service/placements/* + api/placements_admin.py + the employer application routes + learner routes in marketplace.py + deps.py get_placement_store + main.py wiring — REQ-11-001..005 core), data-engineer (owns the 2-table placement store schema: PlacementRow partial-unique + PlacementEventRow global hash chain flush-then-hash-then-commit, canonical serialization, the D-102 revoke semantics — REQ-11-001/002), frontend-engineer (owns the four D-109 surfaces: /employer/applications page, overview placements section, job-detail status panel, admin placements block + engine-client placements section + packages/types/placement.ts — REQ-11-003/005), security-auditor (phase-specific: the hash-chain tamper evidence, the G-319 supersession PII review, gate-order pins, export integrity — REQ-11-001/002/004). **Deactivated:** cli-engineer, devops-engineer, sandbox-engineer, ai-engineer, identity-engineer (learner gates compose unchanged — D-104), voice-engineer, design-system-engineer (all unchanged surfaces this milestone).
### lead-developer
```yaml
active: true
phase_specific: false
reason: Coordinates the v0.10 B2B Employer Network milestone (auth foundation → claim → surface conversion → posting management → checkout waves); owns wave order + the cross-boundary wire review (employer.ts ↔ employers/base.py dual-schema pins, the 402/401/403 discrimination contract) + the D-091 extraction's no-drift review (both principal gates compose the same helper)
reason: Coordinates the v0.11 Placement Tracking milestone (store → state machine → reporting → export → visibility waves); owns wave order + the R-80 re-hire coherence decision's implementation review (the placement.rehired route/event shape) + the G-319 pin-split review (counts-only stays on own-postings lists; learner_id on application-scoped routes only) + the placement.ts ↔ placements/base.py dual-schema pin review + the D-100 cross-store transaction discipline review (one transition_application, no second status writer)
domain: coordination
frameworks:
- turborepo
@@ -27,7 +27,7 @@ territory:
```yaml
active: false
phase_specific: false
reason: v0.10 DEACTIVATED — no infra changes; the employer module rides the existing release lane (image + assets build automatically on tag per D-064/D-065). Roster + v0.8 territory retained.
reason: v0.11 DEACTIVATED — no infra changes; the placements module rides the existing release lane (image + assets build automatically on tag per D-064/D-065). Roster + v0.8 territory retained.
domain: devops
frameworks:
- docker
@@ -53,7 +53,7 @@ territory:
```yaml
active: false
phase_specific: false
reason: v0.10 DEACTIVATED — no CLI changes; the employer surface is server+web only. Roster + v0.8 territory retained.
reason: v0.11 DEACTIVATED — no CLI changes; the placement surface is server+web only. Roster + v0.8 territory retained.
domain: cli
frameworks:
- node
@@ -82,7 +82,7 @@ territory:
```yaml
active: false
phase_specific: false
reason: v0.10 DEACTIVATED — no sandbox fabric changes; the D-068 degrade surface ships unchanged. Roster + v0.8 territory retained.
reason: v0.11 DEACTIVATED — no sandbox fabric changes; the D-068 degrade surface ships unchanged. Roster + v0.8 territory retained.
domain: infra
frameworks:
- python
@@ -106,7 +106,7 @@ territory:
```yaml
active: true
phase_specific: true
reason: v0.10 re-activated (phase-specific) — P1 auth review: scrypt params/verify discipline (N=2^14/r=8/p=1 module constants pinned by test, hmac.compare_digest, self-describing rows so params evolve without invalidating stored hashes — D-086), session tokens hashed-at-rest (sha256(token) only, DB-leak yields nothing — D-087), throttle windows (per-email failed-login + global cap; email-keyed never IP — D-088), 401 vs 403 semantics (credential vs authorization — D-090); P2 claim review: evidence is advisory, never displayed as verification (A-304 — example.com corpus makes match=false corpus-wide), one-account-one-entity UNIQUE enforcement; P3+ the 422 password-echo fix (main.py pii_fields gains "password"+"email" — pydantic 422s echo err.input today, R-56/open-risk-3), sessionStorage exposure copy, employer mock-complete own-session check (G-301 hole class); P6 final review (no secrets/logs/releases leak credentials)
reason: v0.11 re-activated (phase-specific) — P1 ledger review: the hash-chain construction (canonical JSON rules, GENESIS pinned, UNIQUE(prev_hash) fork backstop, flush-then-hash-then-commit single append path, no delete path — R-70), partial-unique active-placement guard (G-316 shape); P2 gate review: G-311 gate order on application transitions (401 → 403-claim → 403-cross-entity → 409), the G-319 supersession PII review (learner_id = opaque handle, application-scoped only, anti-enumeration preserved — R-79); P4 export integrity (determinism rules, generated_at exclusion, methodology honesty gaps stated — R-67/R-68/R-73); P6 final review (no PII/credentials in logs or releases)
domain: security
frameworks:
- pytest
@@ -116,19 +116,19 @@ constraints:
- secrets-via-env-only (D-014)
- secrets-never-logged
- fail-closed-on-unconfigured
- tokens-hashed-at-rest (D-087)
- never-display-forgeable-as-verified (D-089/A-304)
- never-display-forgeable-as-verified (A-304 — the export methodology states its own limits)
- tamper-evidence-tested (an audit ledger nobody has tried to break is theater, D-111)
territory:
- "apps/ai-service/ai_service/employers/**"
- "apps/ai-service/ai_service/api/employers*.py"
- "apps/ai-service/tests/employers/**"
- "apps/ai-service/ai_service/placements/**"
- "apps/ai-service/ai_service/api/placements_admin.py"
- "apps/ai-service/tests/placements/**"
```
### backend-engineer
```yaml
active: true
phase_specific: false
reason: v0.10 ACTIVATED — owns the employers module core: ai_service/employers/base.py (pydantic contracts), passwords.py (stdlib scrypt KDF, D-086), store.py wiring via deps.py get_employer_store, claim evidence normalization (stdlib suffix match, D-089); api/employers.py (auth routes register/login/logout/me + claim + own-postings CRUD/publish/close + own-checkout + own-entitlement; require_employer_session dep per D-090) + api/employers_admin.py (accounts/claims/audit operator routes, require_admin imported from marketplace_admin — D-094); the D-091 shared consume_posting_entitlement helper + its adoption in marketplace_admin.create_posting (wire contract byte-identical); config.py throttle knobs + .env.example; main.py wiring (employer_store lifespan + close, router mounts, CORS allow_headers gains "Authorization" — R-60)
reason: v0.11 ACTIVATED — owns the placements module core: ai_service/placements/base.py (pydantic contracts + the D-099 transition matrix + event-name vocabulary), store.py wiring via deps.py get_placement_store; the ONE transactional transition_application method (status write + ledger append + placement-on-hired, one commit — D-100) + the R-70 flush-then-hash-then-commit append shape; api routes: employer per-target transitions under /v1/employers/applications (G-311 gate order — D-105), learner withdraw/status in marketplace.py (D-043 stack unchanged — D-104), placements_admin.py operator lane (generic transition + revoke + R-80 re-hire route + overview/export reads — D-106/D-108); main.py wiring (placement_store lifespan + close, placements_admin mount); no new config knobs (no new throttles)
domain: backend
frameworks:
- fastapi
@@ -141,16 +141,14 @@ constraints:
- secrets-via-env-only (D-014)
- idempotent-scripts
- fail-closed-admin
- never-call-cloud-in-tests (zero network by construction, D-096)
- gate-before-insert (the 402 consumes nothing, R-58)
- real-kdf-in-tests (no fake-hash path, D-096)
- never-call-cloud-in-tests (zero network by construction, D-111)
- one-status-writer (placements store is the ONLY post-creation writer of application.status — R-78)
- no-cross-family-foreign-keys (G-302 ID references at read)
territory:
- "apps/ai-service/ai_service/employers/**"
- "apps/ai-service/ai_service/config.py"
- "apps/ai-service/ai_service/placements/**"
- "apps/ai-service/ai_service/main.py"
- "apps/ai-service/ai_service/api/**"
- "apps/ai-service/tests/employers/**"
- "apps/ai-service/.env.example"
- "apps/ai-service/tests/placements/**"
- "apps/ai-service/pyproject.toml"
```
@@ -158,7 +156,7 @@ territory:
```yaml
active: true
phase_specific: false
reason: v0.10 ACTIVATED — owns the employer surface conversion (D-092): engine-client employer section (register/login/logout/me, claim, own-postings, own-checkout, own-entitlement + SessionRequiredError 401 discrimination + nc-employer-session sessionStorage wrapper — the adminToken pattern), /employer/login + /employer/register pages (greenfield), postings page own-lifecycle conversion, overview page real active-postings + applicant counts (talent metrics stay mock-labeled), employer profile via the existing public GET, packages/types/employer.ts (dual-schema pinned, D-093); admin overview sections on the moderation page (accounts list, claim queue with evidence signal + approve/reject, audit — the entitlement panel's locked-state pattern, D-094); pricing CTA conversion from contact-path to employer-login path (supersedes D-079); talent pages stay mock (D-092)
reason: v0.11 ACTIVATED — owns the four D-109 surfaces: new /employer/applications page (own pipeline with status badges + per-target transition CTAs + hire form {role_title, start_date}), employer overview placements section (replaces the mock-labeled placement metrics block — D-092 never-fake-convert), job-detail-client application-status panel (learner history + placement confirmation via D-104 read, EngineError.status discrimination, no new error classes), admin moderation placements block (overview counts + per-employer lists + export links, adminToken transport); engine-client placements section (employer/learner/admin functions, AbortController everywhere, SessionRequiredError reuse — D-110); packages/types/placement.ts dual-schema pinned (D-110); talent badge stays absent (A-304)
domain: frontend
frameworks:
- react
@@ -174,14 +172,14 @@ constraints:
territory:
- "apps/web/**"
- "packages/ui/**"
- "packages/types/employer.ts"
- "packages/types/placement.ts"
```
### ai-engineer
```yaml
active: false
phase_specific: false
reason: v0.10 DEACTIVATED — no LLM-facing work; employer flows need no tagging/enrichment/embedding changes (publish re-tag + enrich-on-first-publish compose unchanged behind the employer routes).
reason: v0.11 DEACTIVATED — no LLM-facing work; placement flows need no tagging/enrichment/embedding changes (hire transitions record decisions; no AI surface is touched).
domain: ai
frameworks:
- pydantic
@@ -202,7 +200,7 @@ territory:
```yaml
active: true
phase_specific: false
reason: v0.10 ACTIVATED — owns the 8th D-027 store schema: employers/store.py with the 4-table family (employer_accounts: email UNIQUE + scrypt hash + employer_id UNIQUE-on-approval; employer_sessions: token_hash UNIQUE, expires_at = login + 7-day constant, lazy TTL; employer_claims: pending→approved|rejected + evidence JSON + decided_by/decided_at; employer_audit_events: the BillingEventRow audit shape), the employer_ prefix (bare `employer` is marketplace's table), check-first one-pending-per-entity/one-approved-owner constraints (the insert_application precedent), _validate/_normalize + _as_utc tz discipline, SQLModel.metadata.create_all idempotent compat (D-095), consume-free read paths for accounts/claims/audit lists
reason: v0.11 ACTIVATED — owns the 2-table 9th store schema (D-112): placements/store.py with PlacementRow (id, learner/employer/posting/application refs, role_title, start_date DATE, status active|revoked, created_at, decided_at; partial UNIQUE application_id WHERE status='active' — the G-316 shape, D-102) + PlacementEventRow (append-only global ledger: nullable indexed application_id + placement_id, event_type, actor_type employer|learner|operator|system, actor_ref, payload, prev_hash UNIQUE + hash, created_at — D-101); the canonical serialization rules (sort_keys + separators, _as_utc(dt).isoformat() pinned, no floats — R-79/R-69); flush-then-hash-then-commit append (R-70); the R-80 placement.rehired event vocabulary; cross-store transaction (one Session writes ApplicationRow + both placement tables — D-100); tz discipline + create_all idempotent compat (D-095 shape); _validate/_normalize + surfaced IntegrityError
domain: data
frameworks:
- sqlmodel
@@ -212,12 +210,12 @@ constraints:
- schema-first
- type-safe
- dual-schema-sync
- idempotency-by-constraint (UniqueConstraint + check-first)
- tokens-never-stored-raw (sha256 only, D-087)
- audit-append-only (provenance rows never mutated)
- idempotency-by-constraint (partial UNIQUE + check-first)
- audit-append-only (the ledger has NO delete path — R-70)
- canonical-serialization-pinned (one normalization, test-pinned)
territory:
- "apps/ai-service/ai_service/employers/store.py"
- "apps/ai-service/tests/employers/test_store.py"
- "apps/ai-service/ai_service/placements/store.py"
- "apps/ai-service/tests/placements/test_store.py"
- "packages/types/**"
```
@@ -225,7 +223,7 @@ territory:
```yaml
active: false
phase_specific: false
reason: v0.10 stays DEACTIVATED — the employer auth in this milestone is NOT the v0.5 learner identity module: identity/ is the KYC/age-gate store (IdentityRecord, derived age bands, require_verified_age gates for LEARNERS per D-043); v0.10's employer principals are a THIRD principal class (D-097 — no learner gates on employer routes, the D-043 composition on learner routes untouched). The identity module, its store, and its gates ship unchanged; backend-engineer owns the new employer auth surface. Reactivation is a founder call if/when a real KYC vendor lands or employer SSO/OIDC (explicitly out of scope at pilot) is scheduled.
reason: v0.11 stays DEACTIVATED — the milestone composes the EXISTING learner gates unchanged (D-104: the withdraw/status routes ride the D-043 stack — G-5 allowlist → verified 18+ → apply-rate window); no identity-module changes. Reactivation is a founder call if/when a real KYC vendor lands.
domain: identity
frameworks:
- fastapi
@@ -244,7 +242,7 @@ territory:
```yaml
active: false
phase_specific: false
reason: v0.5 territory — voice shipped complete; no v0.10 voice work.
reason: v0.5 territory — voice shipped complete; no v0.11 voice work.
domain: ai-media
frameworks:
- httpx
@@ -265,7 +263,7 @@ territory:
```yaml
active: false
phase_specific: false
reason: No design-token or primitive work in v0.10 (login/register pages reuse existing Input/Button/Card primitives); roster retained.
reason: No design-token or primitive work in v0.11 (the applications page reuses existing Badge/Button/Card primitives); roster retained.
domain: frontend
frameworks:
- tailwindcss
@@ -281,7 +279,7 @@ territory:
| Persona | Phases | Removed After |
|---------|--------|---------------|
| security-auditor | 1 (auth surface: scrypt + session hashing + throttles + CORS + 422-scrub fix), 2 (claim evidence honesty + UNIQUE enforcement), 5 (employer mock-complete own-session + checkout review), 6 (final review: no credentials in logs/releases) | milestone complete |
| security-auditor | 1 (store/ledger review: hash chain, canonical serialization, partial unique, no-delete path), 2 (gate order + G-319 supersession PII review on application routes), 4 (export integrity: determinism rules, methodology honesty), 6 (final review: no PII in logs/releases) | milestone complete |
All other personas span the milestone. Deactivated personas receive no tasks.
@@ -289,9 +287,9 @@ All other personas span the milestone. Deactivated personas receive no tasks.
| Conflict | Resolution |
|----------|------------|
| backend-engineer vs frontend-engineer (engine-client.ts) | backend-engineer owns the API contract shapes the client consumes; frontend-engineer owns engine-client.ts itself (the employer section, SessionRequiredError, the session wrapper) — the shared `EntitlementResponse`/`PurchaseEntitlement` interfaces in engine-client.ts (lines 730-752) are a frozen v0.9 contract neither side may re-shape; the new employer wire types land in packages/types/employer.ts (data-engineer dual-schema-syncs, lead-developer reviews the pins) |
| backend-engineer vs security-auditor (api/employers.py) | backend-engineer implements the dep/routes/throttles; security-auditor reviews and may patch security DEFECTS directly in api/employers*.py + employers/passwords.py (its territory) — e.g. the 422 password-echo scrub fix in main.py's pii_fields; implementation vs review stays with backend |
| backend-engineer vs data-engineer (employers/store.py) | data-engineer owns the schema + store methods; backend-engineer owns routes/deps/wiring that compose it; passwords.py (KDF core) is backend territory, the password_hash COLUMN contract is data territory |
| backend-engineer vs frontend-engineer (main.py CORS block) | backend-engineer owns the allow_headers edit at main.py:341; security-auditor owns the tests/api/test_cors.py preflight PIN (the E87 regression lesson is a security concern — the pin lives in the security review's changeset) |
| frontend-engineer vs security-auditor (sessionStorage) | frontend-engineer implements the nc-employer-session wrapper; security-auditor reviews the exposure copy + logout-revoke discipline and pins the no-retry-loop locked state (the G-308 pattern); neither patches the other's files — findings flow through lead-developer |
| backend-engineer vs frontend-engineer (engine-client.ts) | backend-engineer owns the API contract shapes the client consumes; frontend-engineer owns engine-client.ts itself (the placements section) — the existing identity/marketplace/billing/employer sections are frozen contracts neither side may re-shape; the new wire types land in packages/types/placement.ts (data-engineer dual-schema-syncs, lead-developer reviews the pins) |
| backend-engineer vs security-auditor (api routes) | backend-engineer implements the routes/deps/gates; security-auditor reviews and may patch security DEFECTS directly in api/placements_admin.py + placements/store.py (its territory) — implementation vs review stays with backend |
| backend-engineer vs data-engineer (placements/store.py) | data-engineer owns the schema + store methods (rows, chain, canonical serialization); backend-engineer owns routes/deps/wiring that compose it; the transition_application METHOD contract is data territory (the transaction + hash append), the routes calling it are backend territory |
| backend-engineer vs data-engineer (application.status column) | the placements store is the ONLY post-creation status writer (R-78) — marketplace/store.py's insert_application writes it once at creation; no route or future store adds a second writer outside the one-transaction discipline |
| frontend-engineer vs security-auditor (PII surfaces) | frontend-engineer implements the application surfaces (learner_id visible on own applicants); security-auditor reviews the G-319 pin split (counts-only stays on own-postings lists) and pins the cross-entity 403 negative arms; neither patches the other's files — findings flow through lead-developer |
| lead-developer vs any | lead-developer coordinates only, does not directly modify code files |
+114 -114
View File
@@ -1,198 +1,198 @@
# Nextcraft — PLAN.md (v0.10 B2B Employer Network)
# Nextcraft — PLAN.md (v0.11 CIRR-Style Placement Tracking and Audit)
## Overview
**Milestone:** v0.10 — B2B Employer Network (Feature, D-085): real employer auth/accounts + employer self-serve — employers become principals: registered accounts with server-side sessions (scrypt-hashed credentials, hashed-at-rest session tokens), claim/verify of their existing v0.6 `EmployerRow` entity (admin-approved at pilot, evidence advisory), the employer dashboard converting from the v0.1 mockup to real data (own-postings lifecycle, real counts, honest mock-labels only where no backend exists), self-serve posting management composing the v0.9 entitlement gate unchanged (D-091 shared helper, 402 wire contract byte-stable), and self-serve checkout over the v0.9 billing catalog (buyer = session employer, superseding D-076; admin operator routes retained as fallback + oversight, D-094 network overview). Mock-first doctrine holds: no external auth provider, tests never call a cloud, zero new dependencies (R-66).
**Tag line:** phase tags advance the v0.9.x line (P0 → v0.9.0 … P6 → v0.9.6 = milestone release), continuing from the v0.8.6 milestone release
**Branch:** milestone/v0.10-b2b-employer-network
**Milestone:** v0.11 — CIRR-Style Placement Tracking and Audit (Feature, D-098): the machinery that makes the guiding outcome measurable and auditable — the placement record model (the 9th D-027 store: `PlacementRow` with the partial-unique one-active-placement-per-application guard + the append-only `PlacementEventRow` ledger, one global sha256 hash chain with a `UNIQUE(prev_hash)` fork backstop), the employer-side application state machine extending the v0.6 `submitted`-only status (the forward-only D-099 matrix, per-target employer routes under `/v1/employers/applications`, learner withdraw, the operator lane incl. revoke + the R-80 re-hire), placement reporting (distinct-learner counts cumulative toward ≥1,000, per employer, per start-date cohort month), and the audit-grade export (self-contained JSON + human summary generated from the same document, methodology stating its own three pilot gaps, re-export determinism). Mock-first doctrine holds: no third-party auditor integration (the export IS the deliverable), tests never call a cloud, zero new dependencies (D-112).
**Tag line:** phase tags advance the v0.10.x line (P0 → v0.10.0 … P6 → v0.10.6 = milestone release), continuing from the v0.10 milestone release (v0.9.6)
**Branch:** milestone/v0.11-placement-tracking (phase work on phase/NN-* branches; Phase 0 on phase/00-pre-execution)
**Phase list (requirement mapping):**
| Phase | Requirement | Focus |
|-------|-------------|-------|
| P1 | REQ-10-001 | Employer auth foundation: employers/ module (8th D-027 store + stdlib scrypt KDF + hashed session rows + both throttle windows), register/login/logout/me routes, main.py wiring (store + CORS Authorization + 422 scrub) — zero web changes |
| P2 | REQ-10-002 | Employer entity claim: claim route + advisory evidence (stdlib domain match), one account : one entity (UNIQUE + check-first), admin claim review/approve/reject operator routes + audit |
| P3 | REQ-10-003 | Employer account surface: own-postings read route, packages/types/employer.ts + dual-schema pins, engine-client employer section (SessionRequiredError + nc-employer-session), login/register pages, session-gated shell, postings/overview conversion (talent stays mock) |
| P4 | REQ-10-004 | Self-serve posting management: D-091 shared consume_posting_entitlement helper + admin adoption (no drift), employer create/edit/publish/close routes, PostingManager write actions |
| P5 | REQ-10-005 | Self-serve checkout + admin overview: employer checkout + entitlement view + own-session mock-complete through the REAL webhook path, pricing CTAs → employer-login path, admin network overview sections |
| P6 | — | Final review + audit + milestone ship (v0.9.6 = milestone release) |
| P1 | REQ-11-001 | Placement store + evidence ledger: placements/ module (PlacementRow + PlacementEventRow global hash chain, partial UNIQUE active guard), the D-099 matrix + event vocabulary in base.py, the ONE transactional transition_application (flush-then-hash-then-commit), main.py wiring — zero web changes |
| P2 | REQ-11-002 | Application state machine: employer per-target routes (G-311 gate order: 401 → 403-claim → 403-cross-entity → 409), learner withdraw (D-043 stack), placements_admin operator lane (generic transition + revoke + the R-80 re-hire), G-319 pin split |
| P3 | REQ-11-003 | Placement reporting: admin overview (distinct learners, by_employer, by_cohort_month), per-employer placement view, placement.ts wire types + engine-client placements section, /employer/applications page + overview placements section replacing the mock block |
| P4 | REQ-11-004 | Audit export: the self-contained JSON + summary-from-same-JSON, methodology with the three pilot gaps, determinism rules, admin web placements block + export links, the full-chain E2E proof |
| P5 | REQ-11-005 | Learner visibility: GET application-status read, job-detail status panel + placement confirmation, badge honesty |
| P6 | — | Final review + audit + milestone ship (v0.10.6 = milestone release) |
**Wave order (binding): principals before claims before surface before management before monetization.** P1 builds the module everything else composes (store + KDF + session gate — P2's claim route authenticates through it, P3's surface gates on it, P4/P5 parameterize it). P2 precedes P3 because the dashboard's claimed-entity profile and claim-pending states need decided claims to exist. P3 precedes P4 because the postings page conversion consumes the own-postings read route and the P4 mutations wire into the converted page. P4 precedes P5 because checkout's buyer = the session's claimed, managing entity and the upgrade CTA targets the surface P4 completed. Each phase is a vertical slice: store/schema → routes → web wiring → tests green in the same wave → ship.
**Wave order (binding): ledger before machine before reporting before export before visibility.** P1 builds the store + chain every later phase composes (P2's transitions append to it in one transaction; P3's counts read the rows it writes; P4's export embeds the chain it maintains; P5's panel renders the events it records). P2 precedes P3 because reporting counts placements that only the hired transition creates — a report over an un-exercised machine proves nothing. P3 precedes P4 because the export's summary block is the D-107 computation (one truth source — the summary is generated from the same JSON) and the admin web block consumes both the overview and the export. P4 precedes P5 by requirements priority (REQ-11-004 critical ships before REQ-11-005 medium; no dependency runs the other way — the learner panel composes only P1/P2 machinery). Each phase is a vertical slice: store/schema → routes → web wiring → tests green in the same wave → ship.
**Binding contracts:** clarify decisions D-086..D-097 (PROJECT.md) and research conclusions R-55..R-66 (RESEARCH.md) are design contracts; this plan operationalizes them, never re-litigates. Module layout is decided (ARCHITECTURE v0.10): `ai_service/employers/{__init__,base,store,passwords,claim_evidence}.py` — no service.py; `api/employers.py` + `api/employers_admin.py`; `tests/employers/`.
**Binding contracts:** clarify decisions D-099..D-112 (PROJECT.md) and research conclusions R-67..R-80 (RESEARCH.md) are design contracts; this plan operationalizes them, never re-litigates. Module layout is decided: `ai_service/placements/{__init__,base,store}.py` — NO service.py (D-112/R-74 — routes compose the store via `app.state` DI); routes: the applications section of api/employers.py, learner routes in api/marketplace.py, `api/placements_admin.py`; `tests/placements/`; `packages/types/placement.ts`.
**Environment facts (probe-verified, R-55..R-66):** scrypt is stdlib in the shipped venv (3.11.2), ~47 ms/hash at N=2^14/r=8/p=1, 16 MiB working set, default maxmem admits it — never pin maxmem below ~17 MiB (R-55); require_admin extract→verify→return shape at api/marketplace_admin.py:46-85, the 422 scrubber redacts only `date_of_birth` today (main.py:398-424, pii_fields line 401 — open-risk-3) (R-56); the 8th-store recipe fully specified by billing/store.py, bare `employer` table taken by marketplace/store.py:144 (R-57); create_posting gate order + the 402 dict's hardcoded posts_used=0/posts_limit=0 in BOTH arms (marketplace_admin.py:157-251) (R-58); billing_admin.create_checkout source mechanics incl. unique-pending placeholder + G-310 URLs (billing_admin.py:77-146) + the admin mock-complete path (152-227) (R-59); the four main.py wiring sites (lifespan ~182-190, CORS allow_headers line 341, inline router mounts 366-389, Settings-int knob shape) (R-60); web census — engine-client.ts section order (employer appends beside billing at ~717), adminToken() wrapper at 759-767, the pins.test.ts billing.ts block 205-231 + "Contact Sales" pin 250, NO login page in any route group (R-61); moderation page + entitlement-panel locked-state pattern (R-62); both throttle precedents (identity _SubmitRateLimiter 97-116, sandboxes _check_global_create_rate 123-137, reset-fixture pattern tests/marketplace/conftest.py:123-131) (R-63); tests/billing/ layout + state-injection make_client fixture (test_entitlement_enforcement.py:28-59) (R-64); seeded employer websites are example.com placeholders → evidence match=false corpus-wide, stdlib urlsplit suffix-match normalization (R-65); zero new deps, zero workspace changes, two .env.example knobs (R-66).
**Environment facts (probe-verified, R-67..R-80):** the hash covers the row's own autoincrement id → the append must be flush-then-hash-then-commit inside one Session, ALL appends funneling through the one transition_application (R-70; decide_claim is the all-or-nothing template employers/store.py:600-652; the ledger must have NO delete path — INTEGER PRIMARY KEY reuses max+1 if the top row is deleted; verification is O(n) sha256 over tiny rows); the matrix precedent is billing's `_VALID_TRANSITIONS` with empty-tuple terminals (billing/store.py:84-90; the 409-matrix test trio tests/billing/test_store.py:400-418; zero migration — existing rows are all `submitted`, marketplace/store.py:211); the shared-DB multi-engine topology is shipped (8 stores, identical pragmas; global SQLModel metadata means create_all on any engine creates every registered table, employers/store.py:400; cross-module import is family-sanctioned employers/base.py:39; NO cross-family foreign keys — G-302 ID references at read); export determinism needs pinned orderings + total generated_at exclusion + float absence (R-73; the list_postings ordering discipline marketplace/store.py:940-948); main.py wiring sites verified (lifespan getattr-guard 182-190/195-200, close() in the finally 335-336, routers beside the employers block 400-411, CORS already carries Authorization 357, G-11 admin boot warning 251-256, deps.py:98); gates verified (require_employer_session employers.py:173-191, _require_claimed :467-484, _own_posting_or_404 :487-503, per-target route idiom :635/:687, provenance `"account:{id}"` :792; learner stack marketplace.py:46-52 → :358 → :359 with window :66-76, `_APPLY_TIMES` :58, cap :63; operator lane employers_admin.py:47 + OPERATOR :54, guard marketplace_admin.py:62-85); web rails verified (engine-client sections — SessionRequiredError :126-131, ClaimRequiredError :136, parseError :146, placements section appends after employer billing :1114; pins.test.ts employer dual-schema block :267-374 + G-319 block :376+; the four surfaces — employer-overview-client.tsx :162/:190-230 real vs mock block :335-372 labelled :351, job-detail-client.tsx apply :95-115 + EngineError 409/404 :103-105 + confirmation :253, moderation page :3/:35 → employer-network-panel.tsx :56 + adminToken/locked-state :28-40); test templates verified (make_client state-injection tests/marketplace/conftest.py:80-120; conftest resets :123-131 + tests/employers/conftest.py:31-33; partial-index sqlite_master pin test_claims_store.py:75-88; gate pins test_postings_write.py:225-234/:315 + test_marketplace_api.py:188; counts-only pin test_postings_read.py:216); two WAL facts (insert_application :873-901 never touches status post-creation; the tamper test's second engine sees the mutation only after commit); three clocks, three serialization rules (start_date date-only ISO YYYY-MM-DD with cohort month `strftime("%Y-%m")`; created_at naive-stored re-labeled via `_as_utc` marketplace/store.py:263-267 — `isoformat()` emits +00:00 for aware, nothing for naive, so the chain pins ONE normalization; generated_at export-only; learner_id is an opaque G-5 handle the 422 scrubber's pii_fields main.py:439 never sees).
**Verification commands (phase gates — the repo's own scripts, R-66):**
**Verification commands (phase gates — the repo's own scripts):**
- `pnpm lint` (turbo lint — eslint across all TS workspaces) and `pnpm ai:lint` (ruff on ai-service)
- `pnpm typecheck` (turbo typecheck — tsc workspaces) and `pnpm ai:typecheck` (mypy — zero new overrides, the D-086 passlib rejection holds)
- `pnpm ai:test:fast` (FAST=1 scripts/test.sh — `-m "not real_server and not userns"` per D-063; tests/employers/ joins the fast lane)
- `pnpm typecheck` (turbo typecheck — tsc workspaces) and `pnpm ai:typecheck` (mypy — zero new overrides; stdlib-only, D-112 keeps the D-086 regime intact)
- `pnpm ai:test:fast` (FAST=1 — `-m "not real_server and not userns"` per D-063; tests/placements/ joins the fast lane)
- `pnpm web:test` (node:test incl. pins.test.ts) + `pnpm cli:test` (unchanged surface, regression guard)
- `pnpm --filter web build` (static export, D-039 unchanged) on web-touching phases (P3, P4, P5)
**NFR note:** this is a Feature milestone — no purity gate; `feat:` commits are expected and scoped to ai_service/employers/, api/employers*.py + the D-091 helper, config.py/main.py wiring, tests/employers/, packages/types/employer.ts, the employer web surface, and the admin overview/pricing CTA surfaces.
**NFR note:** this is a Feature milestone — no purity gate; `feat:` commits are expected and scoped to ai_service/placements/, api/placements_admin.py + the employers.py/marketplace.py route sections + deps.py/main.py wiring, tests/placements/, packages/types/placement.ts, the employer/job-detail/admin web surfaces.
**Secrets policy (binding):** no new secrets this milestone — AI_ADMIN_TOKEN stays env-only (D-014); employer passwords are user data: stored only as self-describing scrypt hashes (D-086), never logged, never returned, never echoed in 422s (pii_fields fix, R-56); session tokens stored ONLY as sha256(token) (D-087 — a DB-file leak yields nothing usable); tests use throwaway credentials against tmp_path DBs; no credential value appears in any committed file, log line, or release asset.
**Secrets policy (binding):** no new secrets this milestone — AI_ADMIN_TOKEN stays env-only (D-014); the placements admin lane inherits require_admin's fail-closed behavior + the G-11 boot warning free (R-74); the export carries no credentials and no identity PII (learner_id is an opaque allowlisted handle, R-79 — name/email/DOB are never stored, identity/base.py:25-33); tests use tmp_path DBs with no credential values; no secret appears in any committed file, log line, or release asset.
---
## Phase 1: Employer Auth Foundation (REQ-10-001)
## Phase 1: Placement Store + Evidence Ledger (REQ-11-001)
**Goal:** The employers module exists and is proven: 4-table store + stdlib scrypt KDF + hashed server-side sessions + register/login/logout/me routes + both throttle windows + main.py wiring — all tested with the REAL KDF, zero web changes, zero new dependencies.
**Goal:** The placements module exists and is proven: the 2-table 9th D-027 store (immutable-at-creation PlacementRow + the append-only global hash-chain PlacementEventRow), the D-099 matrix + event vocabulary in base.py, the ONE transactional transition_application with flush-then-hash-then-commit, main.py wiring — tamper-tested, fork-tested, zero web changes, zero new dependencies.
### Wave 1-1: Module core — store + KDF + contracts (backend-engineer + data-engineer, sequential)
### Wave 1-1: Module core — contracts + matrix + schema (backend-engineer + data-engineer, sequential)
- **Task 1-1-01** (backend-engineer): `ai_service/employers/__init__.py` + `base.py` — pydantic contracts (D-093's list, dual-schema-pinned later in P3): `EmployerAccount` (id, email, employer_id nullable, created_at, `email_verified: Literal[False]` — UNVERIFIED per D-097, displayed as such), `EmployerClaim` (id, account_id, employer_id, status pending|approved|rejected, evidence dict, created_at, decided_by/decided_at), `RegisterEmployerRequest`/`LoginEmployerRequest` (email + password, honest validation), `TokenResponse` (token, expires_at), `EmployerMe` (account + claimed employer_id + claim state). No service.py (R-57) — routes compose the store via deps DI. (G-315: email fields are plain `str` with stdlib-only validation — non-empty, exactly one "@", non-empty domain, stripped + lowercased on normalize; pydantic `EmailStr` is BANNED — email-validator is not in the venv and adding it would be the first new dependency, breaking R-66/D-086.)
- **Task 1-1-02** (data-engineer): `employers/store.py` — 8th D-027 store (R-57, D-095): 4 tables, all `employer_`-prefixed (bare `employer` is marketplace's EmployerRow, marketplace/store.py:144): `employer_accounts` (id PK `acct-`-style, email UNIQUE indexed, password_hash self-describing string, employer_id UNIQUE nullable-until-claim-approval, created_at), `employer_sessions` (id PK, token_hash UNIQUE — stores ONLY sha256(token), account_id indexed, created_at, expires_at = login + `SESSION_TTL_DAYS = 7` module constant), `employer_claims` (schema per D-089, built in P2 — table lands now per D-095's one-time create_all), `employer_audit_events` (auto id PK, account_id/employer_id nullable indexed, kind/name, detail JSON, operator, created_at — the BillingEventRow audit shape, billing/store.py:337-359). Mechanics mirror billing/store.py exactly: `_sqlite_connect` pragmas (WAL / synchronous=NORMAL / busy_timeout=5000 / foreign_keys=ON), `sa_type=String/JSON` portable columns, `_validate()`/`_normalize()` hooks, `_as_utc` tz re-labeling, Protocol `EmployerStore` + `SQLiteEmployerStore` pair, surfaced IntegrityError; `SESSION_TTL_DAYS` pinned by test (the PAST_DUE_GRACE_DAYS shape, D-087/D-083). Store methods: insert_account (check-first → (row, created)), get_account_by_email, mint_session (token_hash=sha256(token), expires_at=now+TTL, `now` injectable), get_session_by_token_hash (**lazy TTL — expired row reads as absent, G-306, no timer**), revoke_session (DELETE), append_audit_event, close. Tests: `tests/employers/test_store.py` — tmp_path DB, table-existence via sqlite_master (4 employer_ tables on the shared db_path), pragma pins, UNIQUE violations surfaced (duplicate email, duplicate token_hash), **token never stored raw (row holds sha256 only)**, lazy TTL with injected now (login+6d present, +8d absent — no sleeps), revoke → lookup miss.
- **Task 1-1-03** (backend-engineer): `employers/passwords.py` — stdlib scrypt KDF (D-086, R-55): module constants `SCRYPT_N=2**14, SCRYPT_R=8, SCRYPT_P=1, SCRYPT_DKLEN=32` (**pinned by test**); per-password salt `secrets.token_bytes(16)`; stored self-describing `scrypt$16384$8$1$<salt_b64>$<hash_b64>` (params evolve without invalidating rows); verify parses params from the stored string, derives, compares via `hmac.compare_digest`; **no maxmem argument below ~17 MiB** (R-55 probe: default 32 MiB admits the 16 MiB working set; if pinned for explicitness use 33554432); module docstring records the ~50 ms/hash budget + throttle-bounded burst risk (open-risk-1). Tests: `tests/employers/test_passwords.py` — REAL KDF, no fake-hash path (D-096): hash→verify accept, wrong password reject, two hashes of the same password differ (random salt), params-pinned constants test, self-describing round-trip (old-format row with explicit params still verifies), **tests never assert hash bytes — accept/reject behavior only**.
- **Task 1-1-01** (backend-engineer): `ai_service/placements/__init__.py` + `base.py` — pydantic contracts + constants: `TRANSITION_MATRIX` per D-099 (snake_case `submitted → in_review → interview → offer → hired|rejected|withdrawn`, forward-only with skip-forward — any non-terminal may move to any LATER stage or any terminal, terminals have empty-tuple outgoing edges, the billing/store.py:84-90 shape); `APPLICATION_STATUSES`; actor vocabulary `employer|learner|operator|system` (the `_BILLING_EVENT_KINDS` shape billing/store.py:92, D-101); event-name vocabulary per D-101 + **R-80's placement lane**: application-stage events (NO backfill — `application.submitted` is never fabricated for v0.6 rows, D-101/A-304; one event per transition: in_review/interview/offer/rejected/withdrawn/hired) + placement-level events (`placement.created` on hired, `placement.revoked` on the D-102 correction, `placement.rehired` on the R-80 operator re-hire); `PlacementRead`/`PlacementEventRead`/`EmployerApplicationSummary` response contracts (dual-schema-pinned P3 per D-110); `HireTransitionRequest {role_title, start_date}` (D-103: start_date is a pydantic `date` — never a datetime, future + past allowed, timezone-free by construction; **G-324: role_title = Field(min_length=1, max_length=200) — empty-string → 422, the house Field shape marketplace/base.py:68-71**); `GENESIS_HASH = "0" * 64` module constant (D-101, test-pinned). No service.py — routes compose the store via app.state DI (D-112).
- **Task 1-1-02** (data-engineer): `placements/store.py` — the 9th D-027 store (D-112/R-72): tables `placement` (id PK, learner_id, employer_id, posting_id, application_id indexed, role_title, start_date stored ISO `YYYY-MM-DD` — a calendar date, no datetime coercion in its path, R-79; status `active|revoked`, created_at, decided_at) + `placement_event` (auto-increment id PK, application_id nullable+indexed, placement_id nullable, event_type, actor_type, actor_ref, payload JSON, prev_hash **UNIQUE**, hash, created_at). Bare `placement` is FREE (D-095's prefix rule was collision-driven — marketplace owns posting/employer/application, no placement table exists). D-102 semantics: role_title/start_date/decided_at/learner_id/employer_id/posting_id/application_id are set once at the hired transition and NEVER rewritten (a silent rewrite is exactly the tamper the chain detects); `status` is the ONLY mutable column with exactly one transition `active → revoked`; decided_at = the hired transition's store-stamped created_at (`datetime.now(UTC)`); the one-active-per-application guard is a **partial UNIQUE index `uq_active_placement_per_application` ON application_id WHERE status='active'** (the G-316 `uq_pending_claim_per_entity` shape, employers/store.py:161-167 — revoked rows stay in history; a re-hire after a mistaken revoke creates a fresh row). Mechanics mirror employers/store.py exactly (D-112): `_sqlite_connect` pragmas WAL / synchronous=NORMAL / busy_timeout=5000 / foreign_keys=ON, `sa_type` portable columns, `_validate()`/`_normalize()` hooks, `_as_utc` re-labeling, Protocol `PlacementStore` + `SQLitePlacementStore` pair, surfaced IntegrityError, `_constraint_error` helper; global-metadata `create_all` idempotent (checkfirst adds both tables to every existing DB at next boot — the shipped D-095 behavior, employers/store.py:398-400); NO cross-family foreign keys (employer/learner/posting/application are ID references resolved at read/export, G-302); **NO delete method and no DELETE SQL anywhere in the module** (R-70: deleting the top row makes INTEGER PRIMARY KEY reuse max+1 — chain-integrity hole; source-pinned). Canonical serialization per D-101/R-69/R-79: `_canonical_event_json` = `json.dumps({...}, sort_keys=True, separators=(",", ":"))` with created_at normalized via **always `_as_utc(dt).isoformat()` — ONE pinned normalization** (the R-79 pitfall: isoformat emits `+00:00` for aware, nothing for naive — drift between the hashing and reading paths breaks the chain on first re-read) and **no floats in payload ever** (str/int/bool/None only, R-69); `hash = hashlib.sha256` hexdigest over the canonical JSON + prev_hash. Tests `tests/placements/test_store.py`: GENESIS_HASH pinned; table existence via sqlite_master (2 tables) + the partial-index SQL pinned (the test_claims_store.py:75-88 template); pragma pins; duplicate prev_hash → IntegrityError surfaced; no-delete-path source pin; tz round-trip (naive stored, aware read, canonical form stable).
- **Task 1-1-03** (data-engineer): the chain append + `transition_application` — D-100/R-70: `transition_application(application_id, to_status, actor_type, actor_ref, role_title=None, start_date=None)` — ONE Session on the placements engine covering both tables (R-72: the marketplace `application` row is written through it — the sanctioned D-100 deliberate exception; both tables + the status write land in ONE commit, the decide_claim all-or-nothing precedent employers/store.py:634-652 — the two-commit alternative's crash window voids the tamper-evidence the milestone sells): get_application (marketplace/store.py gains ONLY this point read, the get_posting shape :578-584 — insert_application and every other marketplace seam unchanged) → matrix validation (module InvalidTransition → 409 at routes) → update `application.status` → read chain head (`ORDER BY id DESC LIMIT 1`) → construct the event row with prev_hash=head → **`session.flush()` to obtain the autoincrement id → compute hash → set the column → on hired: create the PlacementRow (decided_at = this transition's created_at; role_title/start_date REQUIRED, D-103) + append `placement.created` with placement_id set → `session.commit()`** — flush-then-hash-then-commit, and ALL ledger appends funnel through this one method (a second append path races prev_hash; the UNIQUE backstop catches it as a lost event). Column ownership stated in the module docstring (R-78): marketplace insert_application (:873-901) writes status once at creation; the placements store is the ONLY post-creation writer of application.status — no route or future store adds a second writer outside the one-transaction discipline. Store methods: `verify_chain` (recompute the walk from GENESIS_HASH → head; O(n) sha256 over tiny rows — milliseconds at pilot scale, R-70), `get_placement`, `get_application_events` (id-ordered), `list_active_placements`, `revoke_placement` (the one status-only mutation + `placement.revoked` event, one transaction, operator-only by route), `rehire_placement` (R-80: fresh PlacementRow + `placement.rehired` event WITHOUT touching the application's terminal status — revoked rows don't match the partial WHERE, the exact G-316 property employers/store.py:159-167). Tests: chain-walk verification green after N appends; hired → PlacementRow + both events chained (application_id event + placement_id event); **tamper test** — direct row mutation via a second engine on the same file (mutation commits before the verification walk reads, R-78 WAL snapshot isolation) → verify_chain fails; **fork test** — a second row claiming the same prev_hash → IntegrityError surfaced; **concurrent-transition arm** (the test_concurrent_consumes_one_post_left shape, tests/billing/test_store.py:335): two sessions transition the same application → one winner commits, the loser surfaces IntegrityError/InvalidTransition — never two events on one prev_hash; revoke → status revoked + event; rehire after revoke → fresh active row + three-event ledger sequence (hired, placement.revoked, placement.rehired).
### Wave 1-2: Auth routes + throttles + main.py wiring (backend-engineer)
### Wave 1-2: main.py wiring + deps + conftest (backend-engineer)
- **Task 1-2-01** (backend-engineer): `api/employers.py` — `require_employer_session` dependency (D-090, R-56: the require_admin extract→verify→return shape, single-header `Authorization: Bearer <token>` via partition + scheme check; sha256(token) hash-lookup; **401** missing/expired/unknown — credential failure; **403** reserved for cross-entity authorization; returns session + account + claimed employer_id; token namespaces cannot collide with admin tokens — one class fails the other's gate) + `deps.py` `get_employer_store` (app.state DI). Routes: `POST /v1/employers/auth/register` (email+password → scrypt hash → insert_account; duplicate email → 409 honest; account UNVERIFIED per D-097 — no learner gates ever composed on employer routes), `POST /v1/employers/auth/login` (verify → mint token → `{token, expires_at}`; bad credentials → 401 honest, no user-enumeration difference in response body beyond the 401), `POST /v1/employers/auth/logout` (revoke: DELETE row + audit event; unknown/revoked token → 401), `GET /v1/employers/auth/me` (session + account + claimed employer_id + unverified email displayed as such). Tests: `tests/employers/test_auth.py` — **register→login→me→logout flow green**; duplicate register 409; wrong password 401; logout revokes (subsequent me → 401); expired token → 401 (injected now); no-token → 401; concurrent sessions per account allowed (two logins, both me-green, one logout leaves the other alive). REAL scrypt throughout (≈2 hashes ≈100 ms/test, R-64 budget).
- **Task 1-2-02** (backend-engineer): throttles (D-088, R-63 — both copy the proven in-tree mechanics, keyed by SUBMITTED EMAIL, never client IP): (a) per-EMAIL failed-login window — module dict[email]→timestamps, `time.monotonic()` slice-trim, **default 5 failures / rolling 15 min (900 s, window seconds parameterized — identity hardcodes 60.0) → 429 with the honest "wait" detail**; failures-only append; **successful login CLEARS the email's window**; (b) global per-min cap on login+register attempts — module `deque[float]`, while-popleft older-than-60s, `len >= settings.employer_auth_attempts_per_min` → 429, **append only after ALL checks pass (a rejected attempt never consumes budget)**. Settings knobs as plain ints (the `identity_submits_per_min` shape, R-60d): `employer_login_failures_per_window: int = 5`, `employer_auth_attempts_per_min: int = 30`; `.env.example` entries beside the AI_ADMIN_TOKEN block (commented, default-off, R-66). Tests: `tests/employers/test_throttle.py` — 5 failures → 429 with wait detail; success clears the email window; global cap → 429; rejected attempts don't consume the global budget; **both limiter module states reset via fixtures (the _reset_apply_rate_window pattern, R-63)**. (G-313: an autouse fixture in a NEW tests/employers/conftest.py clears BOTH limiter module states before/after EVERY employers test — the whole suite runs inside one 60s window and self-429s otherwise; the sanctioned deviation from billing's no-conftest layout, R-64. G-314: the global deque appends every well-formed login+register attempt — FAILED PASSWORDS COUNT; only 422-invalid bodies and the cap's own 429s are exempt, or D-088's credential-stuffing bound is void; the "rejected attempts consume nothing" test is reworded to "422-invalid consumes nothing; failed-password attempts DO count; N distinct emails inside the window → the (cap+1)-th 429s — the stuffing test". G-320a: the login route order is body-validation → global cap → per-email window → scrypt verify → failure-append — a 429'd attempt performs ZERO KDF derivations, pinned.)
- **Task 1-2-03** (backend-engineer): main.py wiring (R-60, all four sites): (a) employer_store lifespan block directly after billing_store (~line 191, same getattr-guard) + `close()` in the finally block; (b) employers router mount beside the billing block (inline-import pattern); (c) **CORS `allow_headers` gains `"Authorization"` (main.py:341) + the `tests/api/test_cors.py` preflight pin lands in THIS task** (the E87 lesson applied before it ships — open-risk-2: without it every cross-origin employer fetch dies preflight in the two-port dev + D-038 network-mode topologies); (d) **pii_fields gains `"password"` + `"email"`** (main.py:401, R-56/open-risk-3 — a pydantic 422 on register would echo the raw password into err["input"] today) + the pin: register with an invalid body → 422 whose detail never contains the submitted password (added to tests/employers/test_auth.py). Tests: test_cors.py Authorization-preflight green; 422-echo pin green.
- **Task 1-2-01** (backend-engineer): wiring per D-112/R-74: `deps.py` `get_placement_store` beside get_employer_store (deps.py:98); main.py lifespan getattr-guard `placement_store` directly after the employers block (main.py:195-200 template) + `close()` in the finally block (main.py:336); CORS already carries Authorization (main.py:357 — no change); the G-11 admin boot warning (main.py:251-256) covers the placements admin free. Zero new config knobs, zero .env.example changes (no new throttle — D-104 rides the existing window; the router file + mounts land with its first routes in P2, the main.py:400-411 pattern). `tests/placements/conftest.py` NOW (D-111/G-313/open-risk-4): the make_client state-injection fixture (the tests/marketplace/conftest.py:80-120 shape — tmp_path DB, create_app, `app.state.placement_store` injected, hermetic zero-network by construction — no MockTransport needed, no external calls exist) + the **autouse `_APPLY_TIMES` reset (tests/marketplace/conftest.py:123-131 pattern) before/after EVERY placements test — the withdraw route rides that limiter in P2; without the reset the suite self-429s (the G-313 lesson applied before it ships)**.
### Wave 1-3: P1 security review + phase gate (security-auditor + lead-developer)
- **Task 1-3-01** (security-auditor): auth-core review — scrypt params/compare_digest discipline (constants pinned, self-describing rows), session tokens hashed-at-rest (sha256 only — DB leak yields nothing, D-087), throttle windows (email-keyed never IP, both knobs honored), 401 vs 403 semantics (D-090), CORS Authorization pin present, 422 scrub pinned, revoke-on-logout. Findings recorded; P0s fixed in-phase.
- **Task 1-3-02** (lead-developer): P1 phase gate — `pnpm ai:lint` + `pnpm ai:typecheck` (zero new mypy overrides) + `pnpm ai:test:fast` + `pnpm web:test` + `pnpm cli:test` green (existing suites unaffected — the module is additive, marketplace/billing/identity untouched); D-097 discipline check (no require_verified_age/allowlist composed on any employer route); test counts recorded.
- **Task 1-3-01** (security-auditor): ledger review (P1 scope, D-111's "an audit ledger nobody has tried to break is theater"): canonical serialization pinned to ONE normalization (the hashing/reading drift pitfall caught by tamper tests), GENESIS pinned, UNIQUE(prev_hash) fork backstop real (IntegrityError surfaced never swallowed), flush-then-hash-then-commit with a single append funnel (no second writer), no-delete-path pinned, partial-unique active-placement guard emitted by create_all (G-316 shape), actor vocabulary closed, no floats in payload, no PII in payload fields. Findings recorded; P0s fixed in-phase.
- **Task 1-3-02** (lead-developer): P1 phase gate — `pnpm ai:lint` + `pnpm ai:typecheck` (zero new mypy overrides — stdlib only) + `pnpm ai:test:fast` + `pnpm web:test` + `pnpm cli:test` green (additive module — marketplace/billing/employers suites untouched); the D-100 discipline check (one transition_application, no second status writer, no second append path); test counts recorded.
## Phase 2: Employer Entity Claim (REQ-10-002)
## Phase 2: Application State Machine (REQ-11-002)
**Goal:** An authenticated account claims an existing EmployerRow entity; claims are pending with recorded advisory evidence; admin approve/reject activates ownership (one account : one entity); the operator queue and audit trail are real.
**Goal:** The hiring pipeline is live: employers drive per-target transitions on their own claimed-entity applications (G-311 gate order, audit-logged, ledger-chained in the same transaction), learners withdraw their own, the operator lane transitions/revoke/re-hire with provenance — every matrix arm tested, the G-319 pin split enforced.
### Wave 2-1: Claim core — evidence + store + route (backend-engineer + data-engineer)
### Wave 2-1: Employer application routes + G-319 pin split (backend-engineer)
- **Task 2-1-01** (backend-engineer): `employers/claim_evidence.py` — stdlib registrable-domain normalization (R-65): `urllib.parse.urlsplit(website).hostname` lowercased ("https://example.com/openai" → "example.com"), email domain after the last "@" lowercased, `match` = simple SUFFIX match (equal, or one endswith "." + other) — no PSL library, no tldextract (zero-dep discipline); module docstring records the multi-label-suffix over-match as acceptable for an advisory signal; evidence shape per D-089: `{account_email_domain, entity_website_domain, match}`. Tests: `tests/employers/test_claim_evidence.py` — normalization table (scheme/path stripped, case-folded), match true on same registrable domain, false on the example.com corpus (R-65).
- **Task 2-1-02** (data-engineer): store claim methods (the insert_application check-first precedent, marketplace/store.py:341-347): `insert_claim` (**one PENDING claim per entity → created=False/409; one APPROVED owner per entity; rejection frees the entity for a new claim**), `decide_claim` (approve → set `employer_accounts.employer_id` — the UNIQUE one-account-one-entity constraint — + decided_by/decided_at + audit append; reject → decided_by/decided_at + audit append), `list_claims` (status filter, consume-free read path for the admin queue), `get_claim`. Tests: `tests/employers/test_claims_store.py` — second pending claim on an entity rejected; claim after rejection succeeds; approve sets employer_id + writes an audit row; second approve by another account → IntegrityError surfaced (one approved owner); decided_at/decided_at provenance pinned. (G-316: the pending-arm uniqueness is TOCTOU on check-first alone — `employer_claims.__table_args__` gains a partial UNIQUE index `uq_pending_claim_per_entity` on employer_id `sqlite_where status='pending'` (SQLite partial index, emitted by create_all — the D-095 one-time path); insert_claim keeps the check-first 409 fast-path and SURFACES the IntegrityError as the same honest 409 under race; the approved-owner arm is already structurally sound via employer_accounts.employer_id UNIQUE. Pinned: the partial index exists in sqlite_master; a raced second pending insert → 409, never a duplicate pending row.)
- **Task 2-1-03** (backend-engineer): `POST /v1/employers/claims {employer_id}` (session-gated via require_employer_session): entity must exist in the marketplace store (404 unknown — claims target the v0.6 ingested EmployerRow data), account must not already hold an entity (409 honest — one account : one entity), evidence computed AT CLAIM TIME + stored as provenance JSON on the claim row, response = pending claim with evidence. **Auto-approval is deferred: the operator decision activates (D-089 — without email verification the match is forgeable; A-304: never present unverified as verified).** Tests: `tests/employers/test_claims.py` — claim → pending with the exact evidence dict; unknown entity 404; already-owner 409; second pending claim on the same entity 409; unauthenticated 401.
- **Task 2-1-01** (backend-engineer): api/employers.py gains `GET /v1/employers/applications?status=` (own claimed-entity pipeline — session → claimed → applications resolved through own postings via a new in-pattern marketplace grouped read, the count_applications_by_posting discipline store.py:915-932, never per-row N+1; the `EmployerApplicationSummary` contract with **learner_id visible per the G-319 supersession, D-105: application-scoped reads expose learner_id ONLY for applications on the session's own claimed-entity postings**) + the per-target action routes `POST /v1/employers/applications/{application_id}/in-review | /interview | /offer | /reject | /hire` (the per-target idiom employers.py:635/:687 — the matrix stays OUT of request bodies; hire body `{role_title, start_date}` per D-103, both REQUIRED; **G-324: role_title min_length=1/max_length=200 — the empty-string 422 arm pinned beside the missing-fields arm**). Gate order G-311 (D-105/R-75): **401** (require_employer_session employers.py:173-191) → **403** claim (_require_claimed :467-484) → **403** cross-entity (fetch application → posting → `posting.employer_id != claimed` — the _own_posting_or_404 shape :487-503 applied through the application's posting) → **409** transition (never a 409 before a 403). Every route calls the ONE transition_application with `actor_type="employer"`, `actor_ref=f"account:{account.id}"` (the :792 provenance shape) + appends an `employer_audit_events` row (the D-089 one-queryable lane). Tests `tests/placements/test_employer_transitions.py`: gate-order pins (no token → 401, unclaimed → 403, foreign application → 403 — never a 409 first; the test_postings_write.py:225-234/315 pin shape); matrix 409 arms (every illegal move: backward `offer → in_review`, terminal-source `hired → anything`/`rejected → anything`/`withdrawn → anything`); skip-forward arms legal (`submitted → hired` fast-track, `submitted → interview`, `in_review → reject` — R-71); hire → 201 + PlacementRow + chained events + audit row; hire without role_title/start_date → honest 422 with the D-103 required-fields detail; **G-324 arm: role_title="" → 422**.
- **Task 2-1-02** (backend-engineer): the **G-319 pin split** (R-77/D-105, binding): the own-postings LIST stays counts-only — tests/employers/test_postings_read.py:216 green UNCHANGED (the v0.10 boundary holds there); learner_id appears ONLY on application-scoped responses for own-posting applications; cross-entity → 403 with NO application data (anti-enumeration preserved). Pins in test_employer_transitions.py: the list response carries no learner identifiers; the application-scoped GET carries learner_id on own postings; a foreign application's transition/list never returns any application payload.
### Wave 2-2: Admin claim review routes + security review (backend-engineer + security-auditor)
### Wave 2-2: Learner withdraw + operator lane incl. the R-80 re-hire (backend-engineer)
- **Task 2-2-01** (backend-engineer): `api/employers_admin.py` — `require_admin` imported from marketplace_admin (the billing_admin.py:27 precedent, D-094): `GET /v1/employers/admin/accounts` (email, claimed entity, created_at, unverified marker), `GET /v1/employers/admin/claims` (queue: status, evidence, domain-match signal, decided state), `POST /v1/employers/admin/claims/{id}/approve`, `POST .../claims/{id}/reject` (set decided_by/decided_at + append employer_audit_events per D-089), `GET /v1/employers/admin/audit` (queryable trail). Fail-closed 403 on no/missing/invalid token (the R-56 matrix). Tests: `tests/employers/test_claims_admin.py` — **approve → account.employer_id set + audit row + claim approved**; reject frees the entity (a new claim from another account succeeds); decided twice → 409 honest; 403 fail-closed on all five routes; audit query returns claim decisions with operator provenance.
- **Task 2-2-02** (security-auditor): claim-evidence review — evidence is advisory, never displayed as verification (A-304; the example.com corpus makes match=false corpus-wide, R-65/open-risk-6); one-account-one-entity UNIQUE enforced at the store (approve path surfaces IntegrityError, never silently overwrites); claim decisions audit-complete (decided_by/decided_at + events); the 409 paths don't leak account existence beyond the authenticated principal. Findings recorded; P0s fixed in-phase.
- **Task 2-2-01** (backend-engineer): api/marketplace.py gains `POST /v1/marketplace/applications/{application_id}/withdraw` body `{learner_id}` (D-104): the **D-043 composition unchanged** — `_enforce_allowlist` (marketplace.py:46-52) → `require_verified_age(18)` (:358) → `_check_apply_rate` (:359; window :66-76, `_APPLY_TIMES` :58, cap 5/rolling-60s :63 — the EXISTING window, no new throttle knob); 403 when the application is not the caller's, 409 when terminal (D-099: `withdrawn` is learner- or operator-triggerable, NEVER an employer session — employers reject instead); calls the same transition_application with `actor_type="learner"`; composes the placements store via app.state DI (deps.py). **G-323 (binding): the apply route's idempotent arm gains a terminal-state guard** — a returned application in `withdrawn`/`rejected`/`hired` → 409 `{"reason": "application_terminal", "status": …}` (the posting_not_published 409 shape, marketplace.py:366-373), never a 200 "applied" confirmation; no new application row (the unique key stands); recovery is visibility (G-322's mine-read + status panel), not resurrection. Tests `tests/placements/test_learner_withdraw.py`: unallowlisted → 403, unverified → 403, foreign application → 403, own+submitted → 200 `withdrawn` + ledger event actor learner, terminal → 409; **apply → withdraw → re-apply → 409 application_terminal (never a 200)**; the conftest reset proves no self-429 (5 withdraws inside the window → the 6th 429s honestly, reset between tests).
- **Task 2-2-02** (backend-engineer): `api/placements_admin.py` (D-106): prefix `/v1/placements/admin`, `require_admin` IMPORTED from marketplace_admin (the billing_admin.py:27 import precedent, employers_admin.py:47 — token namespaces cannot collide, D-090); router mounts in main.py beside the employers block (main.py:400-411). Routes: `POST /v1/placements/admin/applications/{application_id}/transition` `{to, role_title?, start_date?}` — the generic operator remediation route validated against the SAME D-099 matrix (role_title/start_date required iff `to=hired`); **G-327 (binding): `to` is a pydantic Literal of the seven D-099 statuses — unknown value → 422 (body validation), matrix-illegal known value → 409 InvalidTransition; the route never sees a status outside the vocabulary (no 500s, no silent acceptance; test arm: garbage `to` → 422)**; `actor_type="operator"`, `OPERATOR = "operator"` route-level provenance (employers_admin.py:54 — no per-operator accounts, D-094); the operator can drive ANY matrix-legal transition including `withdrawn` on a learner's behalf (remediation with provenance, the D-083 lane). `POST /v1/placements/admin/placements/{placement_id}/revoke` (D-102: the one `active → revoked` correction). **THE R-80 RE-HIRE ROUTE (pinned explicitly): `POST /v1/placements/admin/placements/by-application/{application_id}/re-hire` `{role_title, start_date}`** — after the first hire the application is terminal (`hired → hired` is a 409 on every matrix-validated surface — the R-80 coherence gap); the re-hire creates the FRESH PlacementRow + appends `placement.rehired` (actor operator) WITHOUT touching the application's terminal status — the ledger reads hired → revoke → rehire as three events, never fabricating a second application transition (the rejected matrix-amendment and revoke+reapplication shapes stay rejected per R-80); structurally safe because revoked rows don't match the partial WHERE status='active' (the exact G-316 property). **G-326 (binding): the re-hired PlacementRow's `decided_at` = the re-hire event's store-stamped created_at (D-102's `datetime.now(UTC)` stamp rule extended) — stated in the module docstring + asserted in the revoke→re-hire test arm.** Reporting/export reads land P3/P4. Tests `tests/placements/test_admin_lane.py`: fail-closed 403 on every route (no/missing/invalid token — the R-56 matrix); generic transition every legal arm + the 409 arms + **garbage `to` → 422 (G-327)**; revoke → placement revoked (→ excluded from counts, asserted P3); **revoke → re-hire → fresh active placement + the three-event ledger sequence pinned + decided_at = the re-hire event's stamp (G-326) + second active placement on the same application without revoke → the IntegrityError surfaced as an honest 409**.
## Phase 3: Employer Account Surface (REQ-10-003)
### Wave 2-3: P2 security review + phase gate (security-auditor + lead-developer)
**Goal:** The employer surface is real: own-postings read route feeding a converted dashboard (login/register pages, session-gated shell, own-postings lifecycle view, real counts), wire types dual-schema-pinned, 401 → honest logged-out state — talent pages stay mock, honestly labeled.
- **Task 2-3-01** (security-auditor): gate + PII review (P2 scope): G-311 gate order on every transition route (401 → 403-claim → 403-cross-entity → 409, pinned); the G-319 supersession (R-79): learner_id is an opaque G-5 handle, application-scoped only, anti-enumeration preserved (cross-entity 403 carries no data); employer sessions can never withdraw (D-099); operator provenance on every admin action incl. re-hire; the audit trail answers every decision (employer_audit_events + ledger actor refs). Findings recorded; P0s fixed in-phase.
- **Task 2-3-02** (lead-developer): P2 phase gate — `pnpm ai:lint` + `pnpm ai:typecheck` + `pnpm ai:test:fast` + `pnpm web:test` + `pnpm cli:test` green (existing marketplace/employers suites green unmodified); the R-80 coherence review recorded (placements, not applications, are the correction lane — the route shape vs D-099/D-102); test counts recorded.
### Wave 3-1: Own-postings read route (backend-engineer)
## Phase 3: Placement Reporting (REQ-11-003)
- **Task 3-1-01** (backend-engineer): `GET /v1/employers/postings` (session-gated; **unclaimed account → 403 + claim-CTA detail** — the D-090 403 semantics; claimed → own postings only, resolved from the marketplace store by the session's employer_id, status facets draft|published|closed, **per-posting applicant_count from the applications table** — the D-092 overview data source). Reuses marketplace store via deps DI; no new tables. Tests: `tests/employers/test_postings_read.py` — own-entity rows only (a second claimed employer's postings never appear); unclaimed 403 with claim-CTA; status facets pinned (counts per status); applicant_count matches seeded applications; no-token 401. (G-319: the response is a NAMED pydantic contract in employers/base.py — `OwnPostingSummary` (PostingSummary reused + applicant_count) + the facets/list response — so the MH-3a dual-schema pin is schema↔schema, not a prose payload; the counts ride a new in-pattern marketplace store read `count_applications_by_posting(posting_ids) -> dict[str, int]`, never a per-posting N+1; employer-facing responses expose applicant COUNTS ONLY — learner identifiers NEVER appear in any /v1/employers/* response, PII boundary, security-auditor P3 scope.)
**Goal:** The guiding outcome is measurable: distinct-learner counts computed from rows at query time (per employer, per start-date cohort month, cumulative toward 1,000), the per-employer placement view live, wire types dual-schema-pinned, the employer surfaces converted (the applications page + the overview placements section replacing the mock block).
### Wave 3-2: Wire types + engine-client employer section (frontend-engineer)
### Wave 3-1: Reporting reads (backend-engineer)
- **Task 3-2-01** (frontend-engineer): `packages/types/employer.ts` (the billing.ts precedent, R-61/R-66) — EmployerAccount, EmployerClaim (+evidence), token/me shapes, own-postings list response (PostingSummary REUSED from marketplace.ts:99-117, never duplicated; entitlement view reuses the v0.9 shapes per D-093) + `export * from './employer'` in index.ts. Dual-schema pins in `apps/web/tests/pins.test.ts` (G-108 discipline; the billing.ts pin block 205-231 is the template): **the employer.ts pin block asserts every field field-for-field against ai_service/employers/base.py's pydantic contracts — both schemas**.
- **Task 3-2-02** (frontend-engineer): `engine-client.ts` employer section (appended beside billing, R-61 — ONE client module, no fork): `registerEmployer`/`loginEmployer`/`logoutEmployer`/`getEmployerMe`/`claimEmployerEntity`/`listOwnEmployerPostings` + the `nc-employer-session` sessionStorage wrapper (the adminToken() 759-767 pattern: window-guarded get/set/clear; tab-scoped bounds XSS exposure — open-risk-4, documented in surface copy) + **`SessionRequiredError` — the 401 discrimination arm joining the parseError chain (no 401 arm exists today, R-61); 401 → honest logged-out state, NEVER a retry loop** + logout clears storage AND calls the revoke route + AbortController on every fetch (A-406). Tests: web fetch-stub tests (the billing-entitlement.test.ts template) — 401 → SessionRequiredError; token attached as Authorization header on every employer fetch; logout clears storage + fires revoke.
- **Task 3-1-01** (backend-engineer): reporting per D-107 — counts computed from rows at query time, NEVER denormalized counters (G-302/G-306): `GET /v1/placements/admin/overview` → `{total_active_placements, distinct_learners_placed, guiding_goal: 1000, by_employer: [{employer_id, count}], by_cohort_month: [{month, count}]}` — **the guiding-outcome metric is DISTINCT LEARNERS with ≥1 active placement, all-time cumulative toward 1,000 (a learner placed twice counts ONCE — the outcome's noun is learners, PROJECT.md line 7); revoked excluded from every count (D-102); cohort month = `strftime("%Y-%m")` over start_date (D-103/R-79 — employment-start month, not hired month, not application month)**; by_employer ordered count desc then employer_id, by_cohort_month month ascending (R-73); joins through existing stores (employer identity via employer_accounts/EmployerRow, posting via postings, learner via application rows); batched grouped reads (the count_applications_by_posting discipline store.py:915-932 — never per-row N+1). Plus `GET /v1/employers/placements` — the per-employer placement view (session-gated; own claimed-entity hired applications with role_title/start_date, honest empty state; 403 + claim-CTA unclaimed; 401 no token) — D-109 surface 2's data source. Tests `tests/placements/test_reporting.py`: distinct-learner rule (one learner, two employers, two placements → distinct_learners_placed=1, total_active_placements=2); cohort bucketing (two starts in one month → one bucket; a future-dated start lands in its own month); revoke exclusion (revoked → gone from every count; a learner whose only placement was revoked → back to 0); per-employer view own-rows-only + cross-entity 403 + unclaimed 403.
### Wave 3-3: Pages — login/register + dashboard conversion (frontend-engineer)
### Wave 3-2: Wire types + engine-client placements section (frontend-engineer + data-engineer)
- **Task 3-3-01** (frontend-engineer): `/employer/login` + `/employer/register` pages (greenfield client pages, R-61 — no login page exists in any route group; existing Input/Button/Card primitives, no design-token work): honest states throughout — 401 invalid-credentials, 409 duplicate email, 429 throttle wait, 422 validation (never echoing the password — server-side pinned already); register success → logged-in dashboard; login stores the session token. Tests: fetch-stub render tests — loading/error/empty honest, 429 copy, register→login handoff. (G-312: fetch-stub + source-pin discipline — no render harness, per D-059.)
- **Task 3-3-02** (frontend-engineer): employer dashboard conversion: postings page drops the `jobs` mock import + PostingManager's derived mock statuses/applicants (components/employer/posting-manager.tsx:29-37) → own-postings read lifecycle view (draft → published → closed visible, applicant counts real; write actions land P4); overview page drops `candidates, employers` mock imports + inline METRICS/PIPELINE constants (page.tsx:10, 18-74) → real active-postings count + per-posting applicant counts from listOwnEmployerPostings + claimed-entity profile via the existing public GET /v1/marketplace/employers/{id} (enrichment retained); **talent-match/placement metrics STAY mock-labeled (D-092 — no backend computes them); talent search + candidate profile pages keep their mock imports unchanged (no candidate backend exists — honest out-of-scope)**. Session-gated shell: no/expired session → honest logged-out state with login CTA (SessionRequiredError path), never a retry loop; claim-pending state visible (claim submitted, awaiting review). Tests: web render tests — logged-out state, claim-pending state, postings lifecycle render, real counts; **pins: postings/overview pages grep-clean of the removed mock imports; talent pages' mock imports retained**; `pnpm --filter web build` (static export) green. (G-312: "render tests" = the D-059/E87 discipline — fetch-stub tests over engine-client employer functions + SOURCE pins for component honest states (logged-out/claim-pending/loading/error branches present in source, the pins.test.ts discipline); NO jsdom/happy-dom render harness exists in-tree and none may be added this milestone.)
- **Task 3-2-01** (frontend-engineer, data-engineer dual-schema-sync): `packages/types/placement.ts` (D-110 — the fourth types module, the employer.ts precedent): ApplicationStatus union (regex-pinned the D-093 status-union way), Placement, PlacementEvent, EmployerApplicationSummary, transition request/response shapes, HireTransitionRequest, PlacementOverview (+ PlacementExport added with the export shapes in P4) + `export * from './placement'` in index.ts. Dual-schema pins in `apps/web/tests/pins.test.ts` (the employer.ts block :267-374 template): **every field asserted both sides against `ai_service/placements/base.py`** — lead-developer reviews the pins (the G-108 discipline from day one).
- **Task 3-2-02** (frontend-engineer): engine-client.ts **placements section** (D-110 — appended after employer billing :1114; ONE client module, no fork): listEmployerApplications, the employer transition functions (inReview/interview/offer/reject/hire with `{role_title, start_date}`), listEmployerPlacements, getPlacementOverview (learner status/withdraw + export functions land P5/P4 with their routes) — every fetch AbortController-equipped (A-406), 401 → SessionRequiredError REUSE (engine-client.ts:126-131), 403 claim → ClaimRequiredError reuse (:136), **NO new error classes** (403 cross-entity renders from EngineError.status). Tests: web fetch-stub tests (the billing-entitlement.test.ts template) — 401 → SessionRequiredError, token attached on every fetch, transition dispatch with the exact body.
## Phase 4: Self-Serve Posting Management (REQ-10-004)
### Wave 3-3: Employer web surfaces (frontend-engineer)
**Goal:** Employers manage their own postings end-to-end: the v0.9 entitlement gate extracted ONCE into a shared api/ helper, both principal gates composing it with byte-identical wire contracts; employer create/edit/publish/close routes; the converted PostingManager gains write actions with honest 402/403 states.
- **Task 3-3-01** (frontend-engineer): D-109 surface 1 — the NEW `/employer/applications` page (beside postings/login/register/billing/talent): the hiring pipeline — own applications with status badges (the seven D-099 states), per-target transition CTAs, the hire form `{role_title, start_date}` (a date input, future dates allowed — D-103), learner_id shown (own applicants, G-319 supersession); honest pending/empty/403-claim states (the A-406 board-client discipline); session-gated shell (SessionRequiredError → honest logged-out state, never a retry loop). Tests: web fetch-stub + source pins (the G-312 discipline — no render harness): loading/error/empty/claim states present in source, status badge per status, hire form dispatches the transition, withdraw NEVER offered to employers (D-099).
- **Task 3-3-02** (frontend-engineer): D-109 surface 2 — the employer overview page's real placements section **REPLACES the mock-labeled placement metrics block** (employer-overview-client.tsx :335-372, the "(mock — no backend computes this)" label :351 — D-092: never leave a convertible surface on mock): hired applications with role_title/start-date via listEmployerPlacements, honest empty state; the talent-match/applicant-pipeline mock metrics STAY mock-labeled (no backend computes them). Tests: pins — the overview grep-clean of the placement mock block + the retained mock labels intact; fetch-stub placements render + empty state; `pnpm --filter web build` (static export) green.
### Wave 4-1: Shared entitlement helper + employer posting routes (backend-engineer + lead-developer)
### Wave 3-4: P3 phase gate (lead-developer)
- **Task 4-1-01** (backend-engineer): the D-091 extraction: `consume_posting_entitlement(store, employer_id, upgrade_cta)` in api/ beside both callers (no billing/→api/ boundary violation) — extracted from marketplace_admin.create_posting lines 181-220; **canonical 402 dict `{reason, posts_used: 0, posts_limit: 0, upgrade_cta}` — posts_used/posts_limit hardcoded 0 in BOTH arms preserved EXACTLY (R-58 — the wire contract; UpgradeRequiredError consumes reason + upgrade_cta unchanged, D-080/D-084)**; store-missing → 503 fail-closed (the getattr guard); **gate runs BEFORE insert — a 402 consumes nothing, creates nothing**; `consume_posting` itself (G-304 single conditional UPDATE) UNTOUCHED. marketplace_admin.create_posting adopts the helper with the admin-checkout CTA — behavior byte-identical. Tests: **tests/billing/test_entitlement_enforcement.py green UNCHANGED (the no-drift proof)** + a new payload-parity test: admin-gate 402 vs employer-gate 402 assert equal reason/posts_used/posts_limit with only upgrade_cta differing per principal.
- **Task 4-1-02** (backend-engineer): employer posting write routes (session gate → claim check → entitlement → insert, mirroring the "admin gate still first" composition, R-58): `POST /v1/employers/postings` (create draft — `consume_posting_entitlement(billing_store, session.employer_id, employer-CTA)`; 403 + claim-CTA unclaimed), `PATCH /v1/employers/postings/{id}` (**own drafts only — published/closed → 409 honest; another entity's posting → 403, the D-090 authorization arm; unknown → 404**), `POST .../postings/{id}/publish` (own draft → published + posted_at; the tagging/enrichment composition behind publish unchanged), `POST .../postings/{id}/close` (own posting → closed). Admin posting routes remain untouched (operator path unchanged, provenance-recorded). Tests: `tests/employers/test_postings_write.py` — create consumes from the employer's OWN package (posts_used+1 pinned); 402 both arms via direct store grants (the grant_posts precedent, test_entitlement_enforcement.py:70-85 — no package → `no_active_package`, exhausted → `posting_limit_reached`); **402 consumes nothing (gate-before-insert pinned: failed create leaves posts_used unchanged and no row)**; cross-entity 403; edit published → 409; publish/close lifecycle + applicant counts accrue; unlimited/enterprise manual grants bypass. (G-311: the session/claim gates compose BEFORE the entitlement check — unauthenticated → 401, unclaimed → 403, never a 402 (the R-58 admin-gate-first order; REQ-10-004's "AFTER" wording is corrected by this grill). G-317: employer create stamps its OWN provenance — `source="employer"` with `emp-` id/external_id schemes, never the admin route's `source="admin"` (authorship must stay answerable across seeded/admin/employer classes); the admin routes are untouched. G-318: employer edit composes the IDENTICAL update_posting_fields + tag_posting re-tag path as the admin edit (D-052 — tags are derived, overwritten on edit; tag failure honest, edit stands) with the own-drafts guard evaluated BEFORE any mutation — edit published/closed → 409 with no field/tag side effects. Pinned: employer-created posting carries the employer source stamp; employer edit re-tags on a mock provider; edit published → 409 AND nothing mutated.)
- **Task 4-1-03** (lead-developer): D-091 no-drift review — both principal gates compose the SAME helper (source-level: no duplicated gate logic in either route); the 402 payload-parity test green; v0.9 billing/marketplace_admin suites green unmodified; wire contract (UpgradeRequiredError) unchanged.
- **Task 3-4-01** (lead-developer): P3 phase gate — `pnpm lint` + `pnpm typecheck` + `pnpm ai:lint` + `pnpm ai:typecheck` + `pnpm ai:test:fast` + `pnpm web:test` + `pnpm cli:test` + `pnpm --filter web build` (web-touching phase) green; the D-110 pin review verdict recorded (placement.ts ↔ placements/base.py field-for-field); counts-discipline check (no denormalized counters anywhere); test counts recorded.
### Wave 4-2: PostingManager write actions (frontend-engineer)
## Phase 4: Audit Export (REQ-11-004)
- **Task 4-2-01** (frontend-engineer): PostingManager gains the write actions over the engine-client employer section: create draft, edit own draft, publish, close — honest states: **402 → UpgradeRequiredError renders the upgrade CTA with the correct reason (the CTA routes to the employer billing surface, P5)**; 403 claim-CTA renders the claim prompt; 409/404 honest; optimistic-free, loading/error/empty per component state; AbortController everywhere. Tests: web fetch-stub — 402 CTA render with reason, publish/close dispatch, own-draft-only error copy, claim-prompt on 403. (G-320b: the 402 branch renders reason + upgrade CTA ONLY — the payload's posts_used/posts_limit are byte-stable zeros (D-091/MH-4a) and are NEVER rendered as limits; real limits render exclusively from GET /v1/employers/billing/entitlement; pinned by source pin.)
**Goal:** The third-party-auditor deliverable exists: the self-contained JSON export + the human summary generated from the same document, methodology stating its own three pilot gaps, byte-deterministic re-exports, the admin web placements block + export links, the full-chain E2E proof.
## Phase 5: Self-Serve Checkout + Admin Overview (REQ-10-005)
### Wave 4-1: Export + summary routes + methodology + E2E (backend-engineer)
**Goal:** The B2B loop closes: employer-principal checkout over the v0.9 catalog through the REAL webhook path (buyer = session's claimed entity), own entitlement view, pricing CTAs → employer-login path, and the admin moderation page gains the network overview (accounts, claim queue, audit).
- **Task 4-1-01** (backend-engineer): `GET /v1/placements/admin/export` (D-108) — one self-contained JSON document via a single store-level builder function: `{schema_version, generated_at, summary: {distinct_learners_placed, total_active_placements, revoked_count, by_cohort_month}, cohort: [placement records with employer/posting/application refs + role_title + start_date], evidence_bundles: [per-placement {posting_record, application_record, transition_history[], decision_provenance} — **G-325 (binding): bundles cover EVERY placement (active AND revoked), each with the full event history incl. placement.revoked/placement.rehired — the correction history is precisely what an auditor most wants to inspect; `cohort` stays active-only; `revoked_count` equals the placement.revoked events (cross-checkable, rides G-321(c))** — each resolved from SOURCE ROWS at export time (G-302: the export EMBEDS resolved source content — snapshot-at-export, which is what self-contained means — while live rows stay the only write path; **NO backfill**: pre-v0.11 `submitted` applications carry only their `created_at` in the bundle — honest absence, never fabricated history, D-101/A-304)], methodology: {placement_definition, counting_rules, verification, scope}, ledger: {events: [{id, application_id, placement_id, event_type, actor_type, actor_ref, payload, created_at, prev_hash, hash}], head_hash}}`. Methodology per R-68 (written from in-tree facts) with **the three R-67 pilot gaps stated explicitly — (a) no minimum-employed-duration check (a placement = a recorded hire, not a 90-day sustained role), (b) no salary (D-098), (c) verification is employer-principal attestation + operator oversight, not third-party employment verification — an audit methodology that omits its own verification limits is A-304-class dishonesty**: `placement_definition` = a PlacementRow created by the hired transition, employer-attested role_title + start_date, active at export time, one active placement per application (the G-316 partial unique); `counting_rules` = distinct learners with ≥1 active placement, all-time cumulative toward 1,000, revoked excluded, cohort month = start_date calendar month (D-107); **`verification` per G-321 (binding): states (a) what the chain proves (in-place mutation/fork detection of an intact sequence), (b) what it does NOT (wholesale DB replacement undetectable by the walk alone — the auditor's defense is export-to-export continuity: a prior export's ledger.events must appear as an exact prefix of a later export's, with the prior head_hash equal to the hash at that prefix position), and (c) the event↔row cross-consistency check (every active placement has placement.created; every revoked has placement.revoked; revoked_count matches the placement.revoked events)**; `scope` = pilot-bounded, every placement in the DB unbounded by date, evidence class = employer-principal attestation; the cohort divergence documented deliberately (employment-start month, not graduation cohort — no graduation events exist in a marketplace, R-68); **CIRR kept as naming, not citation — the text stands on its own definitions (R-67)**. Determinism rules (R-73, binding): events ordered by id (chain order); `by_cohort_month` month ascending; cohort/evidence lists ordered placement `created_at` then id (the list_postings discipline, marketplace/store.py:940-948) — pydantic declaration-order serialization is deterministic but set-derived lists would not be, so every list is built with an explicit sort; **`generated_at` is export-only metadata (a function of the request, not the rows — R-79) and is excluded from determinism comparison**; **float absence — every export value is str/int/bool, no ratios or percentages (counts are integers by construction, D-107)**; `head_hash` = the last event's hash = `verify_chain()`'s computed head. pydantic + `JSONResponse` only — zero new deps (D-112). Tests land with Task 4-1-03.
- **Task 4-1-02** (backend-engineer): `GET /v1/placements/admin/export/summary` (D-108) — `text/markdown` rendered **FROM the same JSON document (one builder, two renderings — the summary can never drift from the export, D-108's anti-drift rule)**: totals table (distinct learners placed, total active placements, revoked count, the guiding-goal progress line toward 1,000), per-cohort month table, the methodology prose (rendered from the export's methodology block — **G-321: incl. the chain's proof scope, its wholesale-rewrite limit, and the export-to-export prefix-continuity anchor**), **the head hash + chain-walk verification instructions** (recompute `hashlib.sha256` over the canonical JSON + prev_hash from GENESIS_HASH event-by-event — the walk is offline: the export embeds every event, so the auditor needs nothing else, R-70); **G-328 (binding): the summary's title carries the attestation language ("employer-attested, operator-overseen") — never a "CIRR-verified" claim**. Tests: summary totals equal the export's summary block on the same DB state (the same-builder pin); content-type pinned; 403 fail-closed without the admin token.
- **Task 4-1-03** (backend-engineer): `tests/placements/test_export.py` — **re-export determinism: two exports on an unchanged DB → identical `ledger.events` chains + identical `head_hash`, with generated_at excluded (parsed field-by-field comparison or key-strip — R-73's TOTAL-exclusion rule)**; **chain-head consistency: `head_hash` = the last event's `hash` = `verify_chain()`'s output** (the walk re-computes every hash from GENESIS_HASH); **methodology completeness: the three R-67 gaps + all four methodology keys asserted present + the G-321 verification-scope statements (proof scope, wholesale-rewrite limit, export-to-export continuity, event↔row cross-check) asserted present**; **evidence-bundle completeness per G-325: every placement (active OR revoked) has a bundle with the resolved posting_record + application_record + transition_history (revoked bundles carry placement.revoked; re-hired carry placement.rehired)**; revoked placements absent from cohort but present in revoked_count (D-108); 403 fail-closed on both routes. Plus **the full-chain E2E proof `tests/placements/test_e2e_audit.py`** (the milestone's machine-checked reason-to-exist, D-111): employer register → claim → admin approve → create draft (direct package grant, the grant_posts precedent test_entitlement_enforcement.py:70-85) → publish → verified learner applies (seed_verified_identity, SUITE_LEARNERS) → employer drives in_review → interview → offer → hire `{role_title, start_date}` → PlacementRow created → ledger events chained (`verify_chain` green) → export → identical chain + head on re-export → overview counts the learner ONCE toward 1,000.
### Wave 5-1: Employer checkout + entitlement view + mock-complete (backend-engineer)
### Wave 4-2: Admin placements block + export links + security review + phase gate (frontend-engineer + security-auditor + lead-developer)
- **Task 5-1-01** (backend-engineer): `POST /v1/employers/billing/checkout {item_id}` (R-59 verbatim transpose of billing_admin.create_checkout — only principal derivation + metadata provenance change): session gate → **unclaimed → 403 + claim-CTA** → catalog lookup 404 → mode = subscription iff kind==plan → success/cancel URLs from `settings.public_base_url` (G-310) → `insert_checkout_session` FIRST with unique pending placeholder (`pending-{uuid4().hex[:16]}`) → CheckoutSessionSpec with metadata employer_id = claimed entity, operator_ref → the account's provenance (D-076's "operator" becomes account provenance) → provider.create_checkout_session via app.state.payment_provider → bind_provider_session_id → CheckoutResponse. Plus `GET /v1/employers/billing/entitlement` (own purchases/subscriptions: current package, posts used/limit, expiry — honest states; the v0.9 shapes reused). The webhook route + _grant_entitlement serve employer checkouts verbatim (G-303 per-session exactly-once holds). Tests: `tests/employers/test_checkout.py` — unclaimed 403 claim-CTA; 404 unknown item; 201 shape + persisted row; metadata provenance pinned (employer_id = claimed entity, account ref recorded); entitlement view honest states (none/active/expired).
- **Task 5-1-02** (backend-engineer): employer mock-complete arm (D-082 transposed, R-59): `POST /v1/employers/billing/mock/complete` mounted ONLY under provider=mock (factory-conditional), **employer-SESSION-gated, completing one's OWN session — cross-session completion → 403 (the G-301 hole class, open-risk-5); provider_session_id resolved from LOCAL rows by the session's own checkout rows (G-302 — payloads are untrusted ID references)**; mints the signed event through the REAL webhook path (mint_event → process_webhook_event, direct handler call — the D-082 proof shape); 404 under stripe (test-pinned). Tests: `tests/employers/test_checkout_e2e.py` — **E2E: register → login → claim → admin approve → checkout → own mock-complete → webhook → purchase exists with posts available**; cross-session completion → 403; stripe-mode mount → 404; duplicate complete falls through already-complete (no double grant, G-303).
- **Task 4-2-01** (frontend-engineer): D-109 surface 4 — the moderation page (app/(admin)/admin/moderation/page.tsx:3/:35, the EmployerNetworkPanel area employer-network-panel.tsx:56) gains a placements block: aggregate counts (distinct learners placed, total active placements, guiding-goal progress) + per-employer placement lists via `GET /v1/placements/admin/overview`, transported over the existing `adminToken()` G-308 sessionStorage wrapper (engine-client.ts:815; the locked-state :28-40 pattern — **403 renders the honest locked state with the token prompt, never a retry loop**); export links — both fetched via the admin transport (token header attached, AbortController-equipped): **the summary opens in-tab** (the markdown rendered in the panel) and **the JSON export downloads** (blob download from the fetched document); honest empty state at zero placements. **G-328 (binding): counts are labeled as recorded placements ("distinct learners placed", "placements recorded") — "CIRR" NEVER appears as a verification claim on any live surface; "CIRR-style" describes the export format in release notes/methodology only (source pin: no "verified" placement wording on any new surface).** Tests: web fetch-stub — locked state on 403, counts + per-employer lists render, export links dispatch with the admin header, empty state; source pins for the locked-state branches + the G-328 wording pin.
- **Task 4-2-02** (security-auditor): export-integrity review (P4 scope) — determinism rules honored (orderings pinned, generated_at total exclusion, no floats), **no PII beyond opaque handles** (learner_id is a G-5 roster handle, not name/email/DOB — identity PII is never stored, R-79; the export + 422 scrubber surface carry nothing else), methodology honesty (the three gaps stated; attestation never presented as third-party verification — A-304, the never-display-forgeable-as-verified constraint), head-hash truth (the export's head equals verify_chain's), evidence bundles resolved from source rows (G-302), both export gates fail-closed. Findings recorded; P0s fixed in-phase.
- **Task 4-2-03** (lead-developer): P4 phase gate — `pnpm lint` + `pnpm typecheck` + `pnpm ai:lint` + `pnpm ai:typecheck` + `pnpm ai:test:fast` + `pnpm web:test` + `pnpm cli:test` + `pnpm --filter web build` green (web-touching phase); the determinism + methodology-honesty verdicts recorded; test counts recorded.
### Wave 5-2: Employer billing surface + pricing CTAs + admin network overview (frontend-engineer)
## Phase 5: Learner Placement Visibility (REQ-11-005)
- **Task 5-2-01** (frontend-engineer): employer billing surface + pricing CTA conversion: the dashboard gains the entitlement view (current package, posts used/limit bar, expiry — honest empty/expired states) + buy flow (catalog from the public `/v1/billing/catalog`, own-checkout trigger, **in-app mock-complete confirmation honestly labeled as the pilot mock path**); **pricing-client.tsx CTAs convert from contact-path to employer-login path (superseding D-079) — the "Contact Sales" enterprise pin at pins.test.ts:250 STAYS (R-61)**; the operator-mediated copy is replaced by the self-serve truth with the admin-fallback noted honestly. Tests: web fetch-stub — entitlement render, checkout dispatch, mock-complete confirmation copy, CTA → /employer/login path, enterprise Contact Sales retained.
- **Task 5-2-02** (frontend-engineer): admin network overview (D-094, R-62): the moderation page (app/(admin)/admin/moderation/page.tsx) gains sibling `<section>` blocks below the v0.9 EmployerEntitlementPanel: employer accounts list (email, claimed entity, entitlement state columns), claim queue (**evidence + domain-match signal rendered ADVISORY — never as verification, A-304**; approve/reject actions), audit view — fed by the P2 operator routes via engine-client admin transport over the existing `adminToken()` G-308 wrapper; **403 renders an honest locked state with the token prompt, never a retry loop (the entitlement-panel load() pattern, R-62)**. Tests: web fetch-stub — locked state on 403, claim queue renders evidence + advisory signal, approve/reject dispatch, accounts list columns.
**Goal:** The learner sees their outcome on the surface where they applied: current status + full timestamped history + placement confirmation when hired (one read, one truth source), withdraw within reach; the badge stays honest everywhere.
### Wave 5-3: P5 security review (security-auditor)
### Wave 5-1: Learner application-status read route (backend-engineer)
- **Task 5-3-01** (security-auditor): checkout surface review — employer mock-complete own-session check (cross-session 403 pinned; mock-only mount pinned; stripe-mode 404 pinned — the G-301 class closed), unclaimed checkout 403 claim-CTA honest, entitlement view leaks nothing cross-entity, sessionStorage exposure copy present on the dashboard (open-risk-4), pricing copy honest (self-serve real, mock path labeled, enterprise pin retained). Findings recorded; P0s fixed in-phase.
- **Task 5-1-01** (backend-engineer): `GET /v1/marketplace/applications/{application_id}?learner_id=` (D-104, in api/marketplace.py — the existing apply surface): gated by the **D-043 composition** (G-5 allowlist marketplace.py:46-52 → verified 18+ :358 — the P2 withdraw additionally rides the existing apply-rate window; no new throttle knob, D-088 untouched); returns `{application, events[], placement|null}` — current status + the full id-ordered timestamped ledger history for THIS application (scoped query, never another learner's events) + the placement record when hired (REQ-11-005's outcome visibility + placement confirmation in one read, D-104); composes the placements store via `app.state` DI (deps.py); the response is a named pydantic contract in placements/base.py (pinned in placement.ts per D-110). **G-322 (binding): the mine point-read — store `get_application_by_posting_learner(posting_id, learner_id)` (the insert_application unique key — the get_posting point-read shape, marketplace/store.py:578-584) + route `GET /v1/marketplace/applications/mine?posting_id=&learner_id=` on the unchanged D-043 gate stack, returning `{application_id, status}` or 404 — the panel NEVER depends on client-persisted application_ids; without this route the status panel cannot find the application on revisit (the common case for a job board).** Tests `tests/placements/test_learner_status.py`: own application → 200 with status + events[] (id-ordered, this application only); hired → placement payload with role_title/start_date/employer refs; foreign learner_id → 403; unverified → 403; unallowlisted → 403; unknown application → 404; **mine-read arms (G-322): own → 200 {application_id, status}; none → 404; foreign learner_id → 403; unverified → 403**; the G-313 conftest reset holds (no self-429).
## Phase 6: Final Review + Ship (milestone release, v0.9.x line)
### Wave 5-2: Job-detail status panel (frontend-engineer)
**Goal:** Multi-persona review of all v0.10 changes; audit (reconstruction + discipline); milestone ship: merge to main, tag v0.9.6 (final v0.9.x patch = milestone release), full asset matrix + registry image via the existing release lane (no infra changes — the module rides D-064/D-065), milestone completion commits, branch cleanup.
- **Task 5-2-01** (frontend-engineer): D-109 surface 3 — job-detail-client.tsx (the existing apply surface: apply :95-115, EngineError 409/404 discrimination :103-105, confirmation render :253) gains the application-status panel: **G-322 composition: mine-read (`GET /applications/mine?posting_id=&learner_id=`) first → found? render the status panel via D-104's read : not found? render the apply CTA — never client-persisted application_ids**; current status badge (the seven D-099 states), timestamped history rows (events[]), placement confirmation when hired (role title, employer, start date — D-104's read), and the withdraw CTA (the learner's ONLY edit, D-104 — 409 terminal copy honest, and G-323's apply → 409 application_terminal copy renders the honest terminal state); **EngineError.status discrimination through the existing checks — NO new error classes** (403 foreign → the honest "not your application" state; 404 unknown; 401/403 gate arms render their existing states); AbortController everywhere (A-406). Badge honesty (A-304/D-109): **a placement badge renders ONLY in this learner's-own application surface where the data is real; the talent profile pages carry NO badge (v0.1 mock imports — a badge on a mock page is fabricated placement, D-092 binding)**. Tests: web fetch-stub + source pins (the G-312 discipline) — each status state renders, history rows, placement-confirmation payload render, withdraw dispatch + terminal 409 copy, foreign-403 copy, **mine-read composition (mine 404 → apply CTA; mine 200 → status panel)**; pins — talent-profile pages grep-clean of placement-badge additions; pins.test.ts placement blocks green.
### Wave 5-3: P5 phase gate (lead-developer)
- **Task 5-3-01** (lead-developer): P5 phase gate — `pnpm lint` + `pnpm typecheck` + `pnpm ai:lint` + `pnpm ai:typecheck` + `pnpm ai:test:fast` + `pnpm web:test` + `pnpm cli:test` + `pnpm --filter web build` green (web-touching phase); the A-304 badge-honesty census recorded (no badge on mock talent pages, no fabricated placement anywhere, mock labels retained where no backend computes them); test counts recorded.
## Phase 6: Final Review + Ship (milestone release, v0.10.x line)
**Goal:** Multi-persona review of all v0.11 changes; audit (reconstruction + discipline); milestone ship: merge to main, tag v0.10.6 (final v0.10.x patch = milestone release), full asset matrix + registry image via the existing release lane (no infra changes — the module rides D-064/D-065), milestone completion commits, branch cleanup.
### Wave 6-1: Multi-persona final review (lead-developer + security-auditor)
- **Task 6-1-01** (lead-developer → all active personas): full milestone-branch diff review — the D-091 no-drift verdict holds end-to-end (one helper, two gates, byte-identical 402), the cross-boundary wire review (employer.ts ↔ employers/base.py pins, the 401/402/403 discrimination contract in engine-client), D-097 discipline (learner gates untouched on learner routes; zero on employer routes), the D-092 honest-surface census (talent mock labels intact, no fake-converted surface, no convertible surface left on mock). P0s fixed in-phase, never loop back to EXECUTE.
- **Task 6-1-02** (security-auditor): final review — no credentials in logs/releases/assets; scrypt params + self-describing rows pinned; sessions hashed-at-rest; 422 scrub covers password+email; throttle windows active; audit trail complete (claim decisions, grants, revocations all provenance-recorded); no secret ever echoed by an exception path.
- **Task 6-1-01** (lead-developer → all active personas): full milestone-branch diff review — the **D-100 one-transaction discipline holds end-to-end** (one transition_application, no second status writer, no second append path — R-78), the **R-80 re-hire coherence verdict** (placements, not applications, are the correction lane; the ledger reads hired → revoke → rehire as three events, never a fabricated second application transition), the **G-319 pin-split verdict** (counts-only stays on the own-postings list — test_postings_read.py:216 unchanged; learner_id application-scoped only), the **D-110 wire-pin review** (placement.ts ↔ placements/base.py field-for-field + the status-union regex), the D-092 honest-surface census (the overview mock block replaced, talent mocks retained + labeled, no fake-converted surface, no convertible surface left on mock). P0s fixed in-phase; P1+ findings dispositioned with recorded follow-ups — never loop back to EXECUTE.
- **Task 6-1-02** (security-auditor): final review — no credentials or identity PII in logs/releases/assets (learner_id is an opaque handle everywhere, R-79); gate orders pinned on every principal class (employer/learner/operator); the ledger's no-delete path intact; tamper/fork/concurrency arms green; export determinism + methodology honesty re-verified; no secret echoed by any exception path.
### Wave 6-2: Audit + ship (lead-developer)
- **Task 6-2-01** (lead-developer → ship): audit (git log ↔ .ciagent/ reconstruction; file discipline — the R-66 addition census: ai_service/employers/, api/employers*.py, the api/ helper + adoption, config.py knobs, main.py wiring, tests/employers/, .env.example, packages/types/employer.ts, engine-client section, employer pages, admin sections, pricing CTA; branch/commit discipline — feat: scoped to the employer surface) + ship: merge milestone → main; tag v0.9.6; Gitea release with full summary + the complete asset matrix + registry image (the existing tag-triggered lane — zero workflow changes); delete milestone branches (tags preserve history); mark REQ-10-001..005 complete in REQUIREMENTS.md; ROADMAP milestone entry; checkpoint cleared.
- **Task 6-2-01** (lead-developer → ship): audit (git log ↔ .ciagent/ reconstruction; file discipline — the D-112 addition census: ai_service/placements/, api/placements_admin.py + the employers.py/marketplace.py route sections + deps.py get_placement_store, the marketplace store's get_application point read + grouped reads, main.py wiring, tests/placements/, packages/types/placement.ts + index.ts export, engine-client placements section, /employer/applications page, overview placements section, job-detail panel, admin placements block; branch/commit discipline — feat: scoped to the placement surface) + ship: merge milestone → main; tag v0.10.6; Gitea release with full summary + the complete asset matrix + registry image (the existing tag-triggered lane — zero workflow changes); delete milestone branches (tags preserve history); mark REQ-11-001..005 complete in REQUIREMENTS.md; ROADMAP milestone entry; checkpoint cleared.
---
## Must-Haves (per phase — concrete, falsifiable, test-linked)
- **P1 (REQ-10-001):** the employers module is importable + tested standalone; register/login/logout/me live with hashed sessions + both throttles; main.py wired; zero web changes. **MH-1a** register→login→me→logout flow green in tests/employers/test_auth.py; logout revokes (subsequent me → 401); concurrent sessions survive single logout. **MH-1b** REAL scrypt in every auth test (no fake-hash path; ≈2 hashes/test within the R-55 budget); params are module constants pinned by test; hash bytes never asserted. **MH-1c** employer_sessions stores ONLY sha256(token) (store-pinned); lazy 7-day TTL pinned via injected now (+6d present, +8d absent — no sleeps). **MH-1d** both throttle windows green in tests/employers/test_throttle.py — 5 failures/15 min → 429 with wait detail, success clears the email window, global cap → 429, rejected attempts consume nothing; reset fixtures isolate module state. **MH-1e** CORS preflight with `Access-Control-Request-Headers: Authorization` allowed — pinned in tests/api/test_cors.py (landed with the routes, not after). **MH-1f** a 422 on register never echoes the submitted password or email (pii_fields pin); mypy + ruff + full fast pytest green, zero new mypy overrides; no learner gate composed on any employer route (D-097).
- **P2 (REQ-10-002):** claim → pending → admin approve → account.employer_id set, all pinned. **MH-2a** claim→approve→ownership set + audit row green in tests/employers/test_claims_admin.py. **MH-2b** evidence dict `{account_email_domain, entity_website_domain, match}` computed at claim time, stored as provenance, pinned in tests/employers/test_claims.py; displayed nowhere as verification (match=false corpus-wide per R-65). **MH-2c** one PENDING claim per entity + one APPROVED owner per entity + rejection frees the entity — all three pinned in tests/employers/test_claims_store.py. **MH-2d** all five operator routes (accounts/claims/approve/reject/audit) fail-closed 403 without a valid admin token.
- **P3 (REQ-10-003):** the dashboard reads real data through the authenticated surface. **MH-3a** pins.test.ts employer.ts block asserts every field both schemas (ai_service/employers/base.py ↔ packages/types/employer.ts, D-093; PostingSummary reused from marketplace.ts, never duplicated). **MH-3b** 401 → SessionRequiredError → honest logged-out state with login CTA, never a retry loop (web test-pinned). **MH-3c** postings + overview pages grep-clean of removed mock imports (`jobs`, `candidates`, `employers`, inline METRICS/PIPELINE constants); own-postings lifecycle + real active count + per-posting applicant counts render from GET /v1/employers/postings; unclaimed → claim-CTA state visible; claim-pending state visible. **MH-3d** talent search + candidate profile pages keep their mock imports + mock labels (D-092 honest out-of-scope). **MH-3e** /employer/login + /employer/register render with honest 401/409/429/422 states; `pnpm --filter web build` (static export) green.
- **P4 (REQ-10-004):** one shared gate, two principals, no drift. **MH-4a** the 402 payload is byte-identical between admin and employer gates on `{reason, posts_used: 0, posts_limit: 0}` — only upgrade_cta carries the principal's CTA — pinned by the payload-parity test; **tests/billing/test_entitlement_enforcement.py green UNCHANGED**. **MH-4b** gate-before-insert: a 402 consumes nothing and creates nothing (posts_used unchanged, no row) — pinned. **MH-4c** employer create consumes from the employer's OWN package (posts_used+1 pinned); both 402 arms (no_active_package, posting_limit_reached) proven via direct store grants; unlimited bypass holds. **MH-4d** cross-entity access → 403; own-drafts-only edit (published → 409); publish/close lifecycle green in tests/employers/test_postings_write.py; admin posting routes unchanged (existing marketplace_admin tests green unmodified).
- **P5 (REQ-10-005):** the B2B loop closes machine-checked. **MH-5a** employer checkout E2E through the REAL webhook path green in tests/employers/test_checkout_e2e.py: register → claim → approve → checkout → own mock-complete → purchase exists with posts available (G-303 exactly-once holds — duplicate complete never double-grants). **MH-5b** cross-session mock-complete → 403; the route 404s under provider=stripe (mock-only mount) — both pinned (the G-301 class closed). **MH-5c** unclaimed checkout → 403 + claim-CTA; entitlement view renders honest none/active/expired states. **MH-5d** pricing CTAs route to /employer/login (contact-path copy gone from purchasable tiers); the enterprise "Contact Sales" pin retained (pins.test.ts:250). **MH-5e** admin network overview renders the accounts list, claim queue with advisory evidence + approve/reject actions, and the audit view; 403 → honest locked state with token prompt (web tests).
- **P6 (ship):** **MH-6a** multi-persona review clean (P0s fixed in-phase); the D-091 no-drift + wire-pin verdicts recorded. **MH-6b** security final review clean (no credentials in logs/releases; audit trail complete). **MH-6c** reconstruction + discipline checks green; v0.9.6 tagged, merged to main, release carries the full asset matrix + registry image via the existing lane; REQ-10-001..005 marked complete; branches deleted; checkpoint cleared.
## Milestone Must-Haves
- REQ-10-001..005 all complete per their REQUIREMENTS.md rows.
- The full B2B loop is the machine-checked E2E proof: register → login → claim → admin approve → create draft → buy package (own mock checkout → REAL webhook) → publish → posting live — every step pinned in tests/employers/.
- Zero new dependencies (R-66), zero workspace changes, zero mypy overrides; scrypt/secrets/hmac/sha256/urllib stdlib-only end-to-end.
- The v0.9 wire contracts unchanged: UpgradeRequiredError byte-stable, existing billing/marketplace_admin entitlement tests green unmodified.
- REQ-11-001..005 all complete per their REQUIREMENTS.md rows.
- The E2E machine-checked proof (the milestone's reason to exist, tests/placements/test_e2e_audit.py): employer registers → claims → operator approves → posts → learner (verified 18+) applies → employer drives in_review → interview → offer → hire `{role_title, start_date}` → PlacementRow created → the global chain walks green GENESIS_HASH → head → the export reproduces identical chains + head_hash on re-export → the overview counts the learner ONCE toward 1,000 — every step pinned in tests/placements/.
- Zero new dependencies (D-112 — the chain is stdlib hashlib/json; export is pydantic + JSONResponse), zero workspace changes, zero mypy overrides, zero .env.example changes (no new knobs — recorded explicitly).
- The v0.6/v0.9/v0.10 wire contracts unchanged: marketplace/billing/employers suites green unmodified (the only marketplace seam changes are the get_application point read + grouped reads); the own-postings counts-only pin green unchanged (G-319); UpgradeRequiredError and every existing engine-client contract byte-stable.
- Wire pins green: placement.ts dual-schema pinned field-for-field against placements/base.py (D-110), status-union regex pinned.
- Suite counts: pytest fast lane + web + cli all green, zero warnings (the v0.7 regime holds).
- Docs: .env.example throttle knobs; REQUIREMENTS/ROADMAP updated; deploy/README.md unaffected (no admin-box changes — record explicitly); the mock-first doctrine intact (no external auth provider, tests never call a cloud).
- Docs: REQUIREMENTS/ROADMAP updated; deploy/README.md unaffected (no admin-box changes — recorded explicitly); the mock-first doctrine intact (no external systems, no third-party auditor integration — the export IS the deliverable; tests never call a cloud).
## Risks
1. **scrypt CPU on concurrent auth** (R-55, ~50 ms/hash single-threaded; a login burst stacks) — bounded by both throttle windows; budget recorded in the passwords.py docstring; acceptable at pilot.
2. **CORS Authorization omission kills every cross-origin employer fetch** (E87 class, R-60/open-risk-2) — the main.py edit + the test_cors.py pin land in P1 Task 1-2-03, the same wave as the routes, never after.
3. **422 password echo** (R-56/open-risk-3) — pii_fields fix + pin land in P1 with the register route; auditor re-verifies at the P1 review.
4. **sessionStorage token exposure** (D-092 tradeoff, open-risk-4) — tab-scoped by design, exposure documented in surface copy; tokens stored hashed server-side (DB leak yields nothing); logout revokes the row.
5. **Employer mock-complete cross-session completion** (G-301 hole class, open-risk-5) — own-session check (403 cross-session) + mock-only mount + session gate, all pinned in P5.
6. **example.com seed websites make claim evidence match=false corpus-wide** (R-65/open-risk-6) — the queue copy presents the signal as advisory; the operator decision activates; auto-approval stays deferred (A-304).
7. **D-091 extraction drift** (the 402 dict's hardcoded zeros are easy to "improve") — the payload-parity test + unchanged v0.9 entitlement suite are the no-drift proof; lead-developer reviews at P4.
8. **Throttle module-state leaks across tests** (R-63) — both limiters get reset fixtures from day one (the _reset_apply_rate_window pattern).
1. **Re-hire-after-revoke coherence gap (R-80)** — `hired → hired` is a 409 on every matrix-validated surface, so the re-hire is pinned as the placement-level operator route `POST /v1/placements/admin/placements/by-application/{application_id}/re-hire` + the `placement.rehired` ledger event, never an application transition — Task 2-2-02; the three-event sequence + the partial-unique IntegrityError arm tested.
2. **Hash-after-flush write shape (R-70)** — flush-then-hash-then-commit inside one transaction, ALL appends through the one transition_application, the no-delete-path pin — Task 1-1-03 (+ the 1-1-02 source pin); tamper/fork/concurrency tests green.
3. **created_at canonical serialization (R-79/R-69)** — ONE pinned normalization (`_as_utc(dt).isoformat()`) in `_canonical_event_json`; the tamper tests catch hashing/reading drift before the chain breaks on its first re-read — Task 1-1-02.
4. **_APPLY_TIMES limiter reset (R-77)** — tests/placements/conftest.py replicates the autouse reset from day one (the G-313 lesson applied before it ships, not after flaky archaeology) — Task 1-2-01.
5. **Methodology honesty block (R-67/R-68)** — the three pilot gaps (no duration check, no salary, attestation-not-third-party-verification) stated explicitly in the export methodology — Task 4-1-01; asserted present by Task 4-1-03.
6. **Export determinism rules (R-73)** — pinned list orderings (events by id; cohort month ascending; placements created_at then id), total generated_at exclusion, float absence — Task 4-1-01; the re-export test is the proof.
7. **Status-column ownership (R-78)** — the placements store is the ONLY post-creation writer of application.status; stated column-level in the module docstring + enforced by the D-100 discipline check at the P1 gate (Task 1-3-02).
8. **G-319 supersession pin split (R-77)** — counts-only stays on the own-postings list (test_postings_read.py:216 unchanged); learner_id appears on application-scoped routes only, cross-entity 403 carries no data — Task 2-1-02 pins both arms.
9. **Concurrent-transition arm (R-78)** — one-winner-one-IntegrityError on truly concurrent transition_application calls (the billing concurrent-consumption shape, tests/billing/test_store.py:335) — Task 1-1-03.
10. **CIRR specifics from knowledge, not live sources (R-67)** — the methodology text stands on its own definitions; CIRR is naming, not citation; the summary renders the prose an auditor reads without external references — Tasks 4-1-01/4-1-02.
## User-Facing Surface
1. **/employer/login + /employer/register** — greenfield auth pages with honest 401/409/429/422 states; register → logged-in dashboard; sessions tab-scoped via `nc-employer-session`.
2. **Employer dashboard — postings page** — the employer's OWN postings lifecycle (draft → published → closed) with real applicant counts, create/edit/publish/close actions, honest 402 upgrade CTA + 403 claim prompt.
3. **Employer dashboard — overview** — real active-postings count + per-posting applicant counts + claimed-entity profile; talent-match/placement metrics honestly mock-labeled; claim-pending state visible.
4. **Talent search + candidate profile** — remain the v0.1 mock, honestly labeled (no candidate backend exists — D-092).
5. **Pricing page** — CTAs convert to the employer-login path (self-serve checkout is real); enterprise tier keeps Contact Sales.
6. **Admin moderation page — network overview** — employer accounts list, claim queue with advisory evidence + approve/reject, audit view; 403 → honest locked state.
7. **API surfaces** — /v1/employers/* (session-gated: auth, claims, postings, billing) + /v1/employers/admin/* (operator-gated) + the existing /v1/billing/* reused verbatim.
8. **Release notes** — the v0.9.x line ships the employer network through the existing automated lane (no admin-box changes required).
1. **/employer/applications (NEW)** — the hiring pipeline: own applications with status badges, per-target transition CTAs, the hire form `{role_title, start_date}` (a date input, future dates allowed); honest pending/empty/claim states.
2. **Employer dashboard — overview placements section** — real hired placements with role/start-date (the mock block REPLACED); talent-match/applicant-pipeline metrics stay mock-labeled.
3. **Job detail — application-status panel** — the learner's current status + timestamped history + placement confirmation when hired + withdraw CTA; honest 403/404/409 states.
4. **Admin moderation page — placements block** — aggregate counts toward the 1,000 guiding goal + per-employer lists + export links (summary in-tab, JSON download); 403 → honest locked state with token prompt.
5. **Talent search + candidate profile** — remain the v0.1 mock, honestly labeled, with NO placement badge (A-304 — no badge on mock pages).
6. **API surfaces** — `/v1/employers/applications/*` (session-gated transitions + own pipeline list), `/v1/marketplace/applications/{id}` + `/withdraw` (learner-gated, D-043 stack), `/v1/placements/admin/*` (operator-gated: transition, revoke, re-hire, overview, export, export/summary).
7. **Release notes** — the v0.10.x line ships placement tracking through the existing automated lane (no admin-box changes required).
## Happy Path (end-to-end, written BEFORE execute)
1. **Employer registers:** /employer/register → account created (email unverified, labeled as such) → logged in → dashboard shell with the claim prompt.
2. **Employer claims their entity:** picks their EmployerRow from the marketplace corpus → claim pending, evidence recorded → dashboard shows the honest claim-pending state.
3. **Operator approves:** admin moderation page → claim queue shows the evidence + domain-match signal (advisory) → approve → account bound to the entity (one owner) → audit row written.
4. **Employer tries to post without a package:** create draft → 402 with upgrade CTA (reason: no_active_package) → nothing consumed, nothing created.
5. **Employer buys a package (pilot mock):** dashboard billing surface → own checkout over the catalog → in-app mock-complete (honestly labeled) → REAL webhook path → purchase granted → posts available in the entitlement view.
6. **Employer publishes:** create draft (posts_used+1) → publish → posting live on the public job board; applicant counts accrue on the overview; the 11th post on a 10-pack → 402 upgrade CTA again.
7. **Operator oversight throughout:** the network overview shows the account, claimed entity, and entitlement state; the audit trail answers every claim/grant/revocation decision.
1. **Employer onboards (v0.10 rails unchanged):** register → claim their entity → operator approves → the account owns the entity.
2. **Employer posts:** create draft (package granted) → publish → the posting is live on the job board.
3. **Learner applies:** a verified 18+ learner applies from the job-detail page → application `submitted` → the status panel shows the timestamped entry.
4. **Employer drives the pipeline:** /employer/applications → in_review → interview → offer → the learner's panel reflects each step with its event history.
5. **Hire:** the employer submits `{role_title: "AI Orchestration Engineer", start_date: next month}` → hired (terminal) → the PlacementRow is created in the same transaction as the ledger events → the learner's panel shows the placement confirmation (role, employer, start date).
6. **Operator oversight:** the admin overview counts the learner ONCE toward 1,000; a mistaken hire → operator revoke → re-hire creates a fresh placement with a three-event ledger trail — the learner is never erased from the count by a correction.
7. **Auditor exports:** the admin placements block → the JSON export downloads (self-contained: cohort + evidence bundles + methodology + chain with head hash) + the summary opens in-tab → re-export reproduces the identical chain + head → the chain-walk instructions let a third party recompute every hash offline.
## UX Acceptance Criteria
1. **Honest states everywhere:** every new surface renders real loading/error/empty states — no spinners that lie, no silent failures, no fake data.
2. **401 → honest logged-out state:** SessionRequiredError renders the logged-out state with a login CTA — never a retry loop, never a silent redirect.
3. **402 upgrade CTA visible with correct limits:** the payload's reason drives the copy (no package vs limit reached); the CTA routes to the employer billing surface; limits shown are the store's truth. (G-320b: the 402 payload's posts_used/posts_limit are byte-stable zeros, never rendered — real limits render from the entitlement view only.)
4. **Claim-pending state visible to the employer:** the dashboard says "claim submitted, awaiting review" until the operator decides — never presented as verified before approval.
5. **Admin sees the claim queue with evidence:** the domain-match signal renders as advisory (match=false is the corpus norm); approve/reject are explicit operator actions with an audit trail.
6. **Talent pages remain mock-labeled:** the honest-surface rule cuts both ways — never fake-convert, never leave a convertible surface on mock; talent stays mock because no backend computes it.
2. **Gate order never surfaces a 409 before a 403:** unauthenticated → 401, unclaimed → 403 claim prompt, foreign application → 403 with no data, only then matrix 409s — pinned on every transition route (G-311).
3. **401 → honest logged-out state:** SessionRequiredError renders the login CTA on employer surfaces — never a retry loop, never a silent redirect; admin surfaces render the token-prompt locked state on 403.
4. **Counts honesty:** the overview counts DISTINCT learners toward 1,000 (a learner placed twice counts once); revoked placements excluded from every count; counts render from the store's rows at query time — never a denormalized or cached counter.
5. **Export self-verification:** the summary carries the head hash + offline chain-walk instructions; the methodology states its own three pilot gaps AND the chain's proof scope + wholesale-rewrite limit + export-to-export continuity anchor (G-321) — employer attestation is never presented as third-party verification (A-304); **G-328: no "verified" placement wording on any live surface (source-pinned)**.
6. **Badge honesty:** a placement badge renders only on the learner's own application surface where the data is real; no badge until a placement exists; none ever on the mock talent pages.
7. **Mock labels retained where no backend exists:** talent-match/applicant-pipeline metrics stay mock-labeled; talent pages stay the v0.1 mock — never fake-convert, never leave a convertible surface on mock (D-092).
+28 -1
View File
@@ -16,7 +16,34 @@ Nextcraft is an AI-native outcome school where graduates prove what they can bui
---
## Milestone v0.10 — B2B Employer Network (ACTIVE, Feature, started 2026-09-30)
## Milestone v0.11 — CIRR-Style Placement Tracking and Audit (ACTIVE, Feature, started 2026-10-01)
**Scope (D-098, auto-decided at SPECIFY under full autonomy D-007; founder scope confirmed 2026-10-01):** Activates **REQ-F-020 — CIRR-style placement tracking and audit** as REQ-11-001..005. The guiding outcome ("≥1,000 learners placed in AI-orchestration roles within 36 months, **verified by CIRR-style third-party audit**") is the school's single north star — v0.11 builds the machinery that makes it measurable and auditable. Every prerequisite is shipped: the learner identity spine (v0.5), the credential pipeline (v0.3), the marketplace applications flow (v0.6, `ApplicationRow`), the employer principals (v0.10, claimed entities + audit trail). v0.11 adds: the placement record model (learner + employer + role + start date, evidenced, immutable event log), the employer-side application state machine extending the v0.6 `submitted`-only status (hiring-stages transitions with audit), placement reporting surfaces (per-employer, per-learner, aggregate), and the **audit-grade export** — the evidence-backed dataset a third-party auditor can verify (cohort snapshots, per-placement evidence chains, tamper-evident event ledger, methodology documentation). Rationale: D-085's closing sentence names CIRR tracking as what the employer network "unlocks"; the guiding outcome is unmeasurable without it; no other deferred requirement (REQ-F-019 is medium and adds a surface, not the outcome). Confidence 0.80 ≥ threshold 0.60 (D-007).
**Out of scope:** human tutor marketplace (REQ-F-019, deferred), external third-party auditor integration (the export is the deliverable — no CIRR org exists to integrate with), learner-side employment verification (self-report stays out; employer-side state changes are the evidence), compensation/salary tracking (role + start date + employer suffice for placement counting; salary is out per CIRR-adjacent minimalism), public placement-stats marketing pages (internal + export only).
**Success (milestone-level):** every application in the marketplace has an auditable state history (submitted → in_review → interview → offer → hired/rejected/withdrawn, transitions employer-principal-driven and audit-logged); a placement record exists for every hired application with evidence (posting ref, application ref, timestamps, decision provenance); aggregate placement counts are computable from the ledger (per employer, per cohort month, cumulative toward ≥1,000); the audit export produces a self-contained, evidence-linked dataset (JSON + human-readable summary) whose placement claims are each traceable to immutable events; the mock-first doctrine holds (no external systems; tests never call a cloud). a placement record exists for every hired application with evidence (posting ref, application ref, timestamps, decision provenance); aggregate placement counts are computable from the ledger (per employer, per cohort month, cumulative toward ≥1,000); the audit export produces a self-contained, evidence-linked dataset (JSON + human-readable summary) whose placement claims are each traceable to immutable events; the mock-first doctrine holds (no external systems; tests never call a cloud).
## Clarify decisions (D-099+, CLARIFY stage full autonomy, 2026-10-01)
| ID | Decision | Rationale | Confidence |
|----|----------|-----------|------------|
| D-099 | Application status vocabulary = **snake_case set `submitted → in_review → interview → offer → hired / rejected / withdrawn`, forward-only with skip-forward, terminals have no outgoing edges**: the validated matrix (billing's `_VALID_TRANSITIONS` shape, billing/store.py:84-90) allows from any non-terminal status (a) any LATER stage (a fast-track `submitted → hired` is a legal hire — CIRR counts the hire, not the ceremony) and (b) any terminal (`rejected`/`withdrawn`); NO backward moves (no `offer → in_review`; the posting lifecycle draft→published→closed is strictly forward, store.py:289/609-632 — hiring inherits the discipline); `hired`/`rejected`/`withdrawn` are terminal — re-transition → 409 (`InvalidTransition`, the set_posting_status route contract); `withdrawn` is triggerable by the learner (own application, D-104) OR the operator (remediation, actor recorded — never by an employer session, who may `reject` instead); value naming is snake_case `in_review` (REQ-11-002's own text; billing's `past_due` precedent). Existing v0.6 rows are all `submitted` (store.py:211) — a valid matrix start, zero data migration. | The tree's two transition machines (posting store.py:609-632, subscription billing/store.py:84-90) both pin exact matrices with terminal empty-tuples; G-305's lesson (no phantom states, no un-matrixed moves) applies verbatim; skipping stages is honest pipeline reality (REQ-11-002 names the stages but no rule forbids acceleration, and an operator correcting a mis-entered pipeline must be able to jump forward). | 0.85 |
| D-100 | The state machine lives in **`ai_service/placements/` — the placements store owns ONE `transition_application(application_id, to_status, actor_type, actor_ref, role_title=None, start_date=None)` method that updates the marketplace `application` row + appends the ledger event + creates the `PlacementRow` on hired, ALL IN ONE TRANSACTION** (the decide_claim all-or-nothing precedent, employers/store.py:636-651: "the audit row rides the SAME transaction"); the transition matrix constant lives in `placements/base.py` (billing/base.py `CANONICAL_EVENT_NAMES` placement); the marketplace store KEEPS application insert/read ownership (insert_application unchanged; gains only a `get_application(application_id)` point read, the get_posting shape at store.py:578-584) — the placements store's cross-family write to the `application` table is the sanctioned deliberate exception: both stores share the one SQLite file, so one Session on the placements engine covers both tables atomically. The rejected alternative — route composes `marketplace.set_application_status` + `placements.append_event` as two commits — leaves a crash window where the status changed but no ledger row exists, which voids REQ-11-002's "every transition appends the placement ledger" (the exact tamper-evidence the milestone sells). | REQ-11-002's ledger-append requirement is only provable under one transaction; decide_claim is the in-tree proof that decision + audit event must land together; cross-module reuse is family-sanctioned (employers/base.py:22 subclasses marketplace.base.Posting — D-093); D-027 table ownership bends here because the feature boundary (application lifecycle) crosses the v0.6 table boundary, and the bend is one column-write executed by one named store method, never a route-side two-step. | 0.80 |
| D-101 | Hash-chain construction = **ONE GLOBAL chain over `PlacementEventRow` ordered by autoincrement id: each row stores `prev_hash` (the prior event's hash; `GENESIS_HASH = "0" * 64` module constant, test-pinned) + its own `hash` = `hashlib.sha256` (hex digest) over canonical JSON `{id, application_id, placement_id, event_type, actor_type, actor_ref, payload, created_at}` + `prev_hash`, serialized `json.dumps(..., sort_keys=True, separators=(",", ":"))`, created_at as ISO-8601 UTC** (deterministic; stdlib-only — the D-086 zero-dep doctrine, the D-077 HMAC-SHA256 crypto precedent); **`UNIQUE(prev_hash)`** makes a chain fork structurally impossible (two events claiming the same predecessor surface IntegrityError — the G-316 structural-backstop class); event rows carry BOTH a nullable indexed `application_id` (application-stage transitions log before any placement exists) and nullable `placement_id` (set from hired onward); **NO backfill**: pre-v0.11 applications have no `submitted` event — the export's evidence bundle carries the application record's own `created_at` (honest absence, never fabricated history, A-304; the G-302 doctrine cuts both ways). Actor vocabulary: `employer \| learner \| operator \| system` (the billing `_BILLING_EVENT_KINDS` shape, billing/store.py:92). | REQ-11-004 names "the event-ledger chain with head hash" — singular: one global chain with one head is the simplest verifiable shape (a single linear walk re-computes every hash; per-placement chains would need N heads and per-chain genesis bookkeeping for nothing at pilot scale); canonical sorted-key JSON is the standard deterministic-serialization answer and makes the re-export integrity test exact; SQLite's single-writer serialization plus the UNIQUE backstop closes the two-appenders-same-prev race the G-316 lesson demands. | 0.80 |
| D-102 | PlacementRow semantics = **immutable at creation; `status` is the ONLY mutable column with exactly one transition `active → revoked` (operator-only correction, the D-083 manual-remediation lane)**: `role_title`/`start_date`/`decided_at`/`learner_id`/`employer_id`/`posting_id`/`application_id` are set once at the hired transition and NEVER rewritten (a silent field rewrite is exactly the tamper the ledger exists to detect; the D-053 append-only enrichment doctrine); `decided_at` = the hired transition's created_at (store-stamped, `datetime.now(UTC)`, the insert_application precedent); the one-placement-per-application guard is a **partial UNIQUE index on `application_id` WHERE status='active'** (the G-316 `uq_pending_claim_per_entity` shape, employers/store.py:161-167) — a revoked placement stays in history, and a re-hire after a mistaken revoke creates a fresh placement for the same application, so an operator fat-finger never permanently erases a learner from the ≥1,000 count; `employer_id`/`learner_id`/`posting_id` are ID REFERENCES resolved from source rows at read/export (G-302 — never trusted denormalized copies). | REQ-11-001's plain "UniqueConstraint (application_id)" would make a mistaken hire uncorrectable forever (terminal application + unique placement = no re-hire path), which is operationally dishonest for an audit-grade dataset that must survive human error; the partial-index precedent exists in-tree for exactly this conditional-uniqueness shape; revoked placements are excluded from counts (D-107) so the correction is counting-honest too. | 0.75 |
| D-103 | `start_date` = **the employment start DATE (first day of work), a pydantic `date` — not the offer date, not a datetime**: the offer moment is already audit-truth as the hired transition's `decided_at` (D-102), so `start_date` answers "when did/will employment begin" (the CIRR question); timezone-free by construction (a calendar date has no tz — the cohort month D-107 keys on it); **future dates allowed** (hired today, starts next month — the normal case) and past dates allowed (retroactive recording of a hire that began last week); REQUIRED in the hire request body alongside `role_title` (employer-supplied at transition time, REQ-11-002's "hired creates the PlacementRow + role_title + start_date payload"); `role_title` is free text (defaults to nothing — never silently the posting title; the employer names the actual role). | A datetime would fabricate precision no employer supplies (nobody's start is "2026-11-03T14:22:51Z"); date-only matches every CIRR-adjacent reporting convention (monthly cohort tables); making both fields explicit-request-body requirements keeps the placement record employer-attested (the A-304 doctrine: the record carries what the principal actually asserted, never an inferred copy). | 0.85 |
| D-104 | Learner routes live in **`api/marketplace.py` (the existing apply surface) with the D-043 composition unchanged — G-5 allowlist → verified 18+ → `_check_apply_rate`**: `POST /v1/marketplace/applications/{application_id}/withdraw` (body `{learner_id}` — 403 when the application is not the caller's, 409 when terminal, 422-free) and `GET /v1/marketplace/applications/{application_id}?learner_id=` returning `{application, events[], placement|null}` (current status + full timestamped ledger history + the placement record when hired — REQ-11-005's outcome visibility + placement confirmation in one read); the withdraw rides the EXISTING apply-rate window (marketplace.py:66-76 — 5/rolling-60s per learner, no new throttle knob, D-088's auth-only throttle scope untouched); the routes compose the placements store via `app.state` DI (the territory rule, deps.py) for the history/placement sections and call the SAME `transition_application` store method (D-100) with `actor_type="learner"`. Learner-initiated edits go NO further: only `withdrawn` (REQ-11-005's "no learner-initiated edits… the withdrawn transition is the exception"). | D-097's binding left the learner gates untouched and pinned them to learner surfaces — the marketplace module IS the learner application surface (apply at marketplace.py:345-382 with this exact gate stack); the G-5 allowlist stays the pilot guard on every learner-gated route (A-306); riding the existing limiter avoids a new knob and its G-313 conftest-reset trap is already solved in tests/marketplace (conftest.py:123-131, mirrored into tests/placements per D-111). | 0.85 |
| D-105 | Employer transition routes = **per-target action routes under `/v1/employers/applications`** — `GET /v1/employers/applications?status=` (own pipeline), `POST .../{application_id}/in-review`, `.../interview`, `.../offer`, `.../reject`, `.../hire` (hire body `{role_title, start_date}`) — mirroring the posting lifecycle idiom (`/postings/{id}/publish`, `/postings/{id}/close`, employers.py:632-715); gate order (G-311): session 401 (`require_employer_session`, employers.py:173-191) → claim 403 (`_require_claimed`, employers.py:467-484) → cross-entity 403 (fetch application → posting → `posting.employer_id != claimed` → 403, the `_own_posting_or_404` shape at employers.py:487-503 applied through the application's posting) → transition 409 (never a 409 before a 403); every route rides the same `transition_application` method with `actor_type="employer", actor_ref="account:{id}"` (the D-091 checkout-metadata provenance shape) and appends an `employer_audit_events` row (the audit trail stays one queryable lane, D-089). **G-319 scoping (binding):** the v0.10 counts-only PII boundary was scoped to the own-postings LIST surface; v0.11 supersedes it for application-scoped reads/transitions — `learner_id` becomes visible ONLY for applications on the session's own claimed-entity postings (cross-entity → 403, no enumeration); the own-postings list's `applicant_count` stays counts-only. | A hiring pipeline without applicant identity is unworkable (REQ-11-002/003 inherently require it; A-408's "founder contacts employers out-of-band" was the no-employer-principal era's workaround, ended by v0.10); per-target routes keep the matrix OUT of request bodies (each route's store call names its target — one less validation surface, the publish/close precedent); G-319's intent was anti-enumeration/anti-cross-entity, and the 403-on-foreign-application arm preserves exactly that. | 0.80 |
| D-106 | Admin oversight = **`api/placements_admin.py`, prefix `/v1/placements/admin`, `require_admin` IMPORTED from marketplace_admin (the billing_admin.py:27 import precedent, employers_admin.py:47) — one generic remediation route `POST /v1/placements/admin/applications/{application_id}/transition` `{to, role_title?, start_date?}`** (validated against the same D-099 matrix; role_title/start_date required iff `to=hired`; `actor_type="operator"` with `OPERATOR = "operator"` route-level provenance, employers_admin.py:54 — no per-operator accounts exist, D-094), **plus `POST .../placements/{placement_id}/revoke`** (D-102's operator-only correction, one transition) and the reporting/export reads (D-107/D-108): `GET .../overview`, `GET .../export`, `GET .../export/summary`; no employer-session gate, no learner gate (a third principal class — D-097); token namespaces cannot collide (D-090); the operator can drive ANY matrix-legal transition including `withdrawn` on a learner's behalf (remediation with provenance — the D-083 manual-grant lane). | REQ-11-002 names "admin routes retain operator oversight (admin transition + audit, the D-094 lane)" — one generic route (vs the employer surface's per-target idiom) is the operator-tooling shape: fewer routes to audit, and the matrix validation lives once in the store (D-100); the export prefix matches REQ-11-004's own `/v1/placements/admin/export`; fail-closed on unset token is inherited free from require_admin (G-11 boot warning already covers it, main.py:251-256). | 0.85 |
| D-107 | Reporting semantics = **counts computed from rows at query time (never denormalized counters — G-302), with the guiding-outcome metric = DISTINCT LEARNERS with ≥1 `active` placement, all-time cumulative toward 1,000**: a learner placed twice counts ONCE ("≥1,000 learners placed" — the PROJECT.md guiding outcome is learners, not placements); `revoked` placements are excluded from every count (D-102's correction semantics); **cohort month = the calendar month of `start_date` (`YYYY-MM`)** — not application month, not hired month (CIRR cohorts on employment start); `GET /v1/placements/admin/overview` returns `{total_active_placements, distinct_learners_placed, guiding_goal: 1000, by_employer: [{employer_id, count}], by_cohort_month: [{month, count}]}`; per-employer view = the claimed entity's `hired` applications with role/start-date (REQ-11-003's employer-dashboard placements section); joins run through the existing stores (employer identity via `employer_accounts.employer_id`/EmployerRow, posting via postings, learner via application rows — REQ-11-003's own "counts join through existing stores"); batched reads use the `count_applications_by_posting` grouped-read discipline (store.py:915-932) — never per-row N+1. | The guiding outcome's noun is "learners" (PROJECT.md line 7) — counting placements would inflate the north star; date-only cohort keys make month buckets deterministic (D-103); counting from rows keeps revoke honest without counter-maintenance writes (the G-306 lazy-evaluation lesson: derived state computed at read, never a timer or a stored tally). | 0.85 |
| D-108 | Export format = **`GET /v1/placements/admin/export` returns one self-contained JSON document**: `{schema_version, generated_at, summary: {distinct_learners_placed, total_active_placements, revoked_count, by_cohort_month}, cohort: [placement records with employer/posting/application refs + role_title + start_date], evidence_bundles: [per-placement {posting_record, application_record, transition_history[], decision_provenance} — each resolved from SOURCE ROWS at export time (G-302)], methodology: {placement_definition, counting_rules, verification, scope}, ledger: {events: [{id, application_id, placement_id, event_type, actor_type, actor_ref, payload, created_at, prev_hash, hash}], head_hash}}`; **`GET /v1/placements/admin/export/summary`** returns the human-readable rendering (text/markdown: totals table, per-cohort table, methodology prose, head hash, chain-walk verification instructions); scope is honest pilot-bounded (every placement in the DB, unbounded by date — REQ-11-004's own scoping); **re-export determinism: the integrity test asserts identical `ledger.events` chains + identical `head_hash` across two exports, with `generated_at` excluded from comparison** (the chain is a function of stored rows; generated_at is the export's own timestamp, not ledger content); the summary is generated from the same JSON (one truth source — the summary can never drift from the export). | REQ-11-004 names all four dataset components ((a) cohort snapshot, (b) evidence bundles, (c) methodology, (d) chain+head) — this is exactly that shape with names an auditor can consume; "resolved from source rows, never denormalized copies" means snapshot-at-export (the export EMBEDS resolved source content — that's what self-contained means) while the live rows stay the only write path; methodology-as-structured-JSON keeps it versioned and machine-checkable while the summary route renders the prose an auditor reads. | 0.80 |
| D-109 | Web conversion scope = **four surfaces, no new client module**: (1) new `/employer/applications` page (the hiring pipeline: own applications with status badges + per-target transition CTAs — honest pending/empty/403-claim states, the A-406 board-client discipline); (2) the employer overview page gains a real placements section (hired applications with role/start-date, honest empty state) — its existing applicant-pipeline/talent-match mock metrics stay mock-labeled (D-092: never fake-convert, never leave a convertible surface on mock); (3) the job-detail client (the existing apply surface, job-detail-client.tsx) gains an application-status panel: current status + timestamped history + placement confirmation when hired, via D-104's learner read (401/403/409 discrimination through existing `EngineError.status` checks — job-detail-client.tsx:103-105 precedent, NO new error classes); (4) the admin moderation page's EmployerNetworkPanel area gains a placements block (aggregate counts + per-employer lists via D-107 overview, adminToken() transport per G-308) + an export link (summary opens in-tab; JSON export downloads). **The talent profile's placement badge stays absent** — talent profile pages remain v0.1 mock (D-092 binding), and a badge on a mock page is A-304 dishonesty; a REAL badge renders only on the learner's own application surface (D-104's placement section) where the data is real. | REQ-11-003 names the employer dashboard + moderation-page surfaces; REQ-11-005 names the learner application surface (the job detail page IS that surface today — apply + confirmation live there, job-detail-client.tsx:50/253); D-092's conversion doctrine (real where a real backend exists, mock where none does) maps cleanly: placements have a backend, talent profiles do not. | 0.80 |
| D-110 | Wire discipline = **`packages/types/placement.ts` (ApplicationStatus union, Placement, PlacementEvent, EmployerApplicationSummary, transition request/response shapes, PlacementOverview, PlacementExport) dual-schema pinned field-for-field against `ai_service/placements/base.py` pydantic contracts in `apps/web/tests/pins.test.ts`** (the D-093 template — the employer.ts pin block); engine-client.ts gains a **placements section** (no new client module — D-092's binding: one client module keeps parseError discrimination in one place): employer list/transition/hire functions, learner application-status/withdraw, admin overview/export — every fetch AbortController-equipped (A-406), 401 → SessionRequiredError reuse (engine-client.ts:126-131), no new error classes. | Every prior cross-boundary wire (PostingSummary D-084, employer.ts D-093) rides exactly this rail from day one; the placement surface is a new cross-boundary contract and the pins make drift structurally detectable (G-108 discipline); the 403 cross-entity arm needs no client-side class — components render the honest "not your application" state from EngineError.status. | 0.85 |
| D-111 | Testing doctrine = **`tests/placements/` mirroring D-096: real flows against create_app + TestClient, tmp_path DBs, zero network by construction, no MockTransport (no external calls exist), mypy green with no new overrides (stdlib only — D-086)**: state-machine 409 matrix arms (every illegal move incl. backward + terminal-source), gate order pins (employer transition: no-token → 401, unclaimed → 403, foreign application → 403 — never a 409 first; learner withdraw: unallowlisted → 403, unverified → 403, foreign application → 403, terminal → 409), hired E2E (transition → PlacementRow created → ledger events chained → employer dashboard placement visible), revoke + re-hire (partial-unique arm: second active placement on the same application → IntegrityError surfaced), **hash-chain tamper tests** (direct row mutation via a second engine → verification walk fails; fork attempt → UNIQUE(prev_hash) surfaces), **export determinism** (two exports → identical chains + head, generated_at excluded), learner visibility (history + placement confirmation), reporting counts (distinct-learner rule: one learner, two placements → counts once). `tests/placements/conftest.py` carries the G-313-shape autouse reset for the marketplace apply-rate limiter module state (the withdraw route rides it — tests/marketplace/conftest.py:123-131 pattern) — the suite cannot self-429. | The billing/employers suites are the proven template (behavior over the real app, real scrypt, honest assertions); the tamper and determinism tests are THIS milestone's reason to exist — an audit ledger nobody has tried to break is theater; the limiter reset is the G-13 lesson applied before it ships (v0.10 caught it only after flaky archaeology). | 0.85 |
| D-112 | Migration/compat = **9th D-027 store, tables `placement` + `placement_event` on the shared DB file, `SQLModel.metadata.create_all` idempotent — zero migration** (the D-095 shape verbatim): `SQLitePlacementStore` mirrors employers/store.py mechanics exactly (WAL/synchronous=NORMAL/busy_timeout/foreign_keys pragmas at connect, `sa_type` portable columns, `_validate`/`_normalize` hooks, Protocol + SQLite impl pair, surfaced IntegrityError, tz-aware↔naive normalization, `_constraint_error` helper); bare `placement` is FREE (D-095's prefix rule was collision-driven — marketplace owns `posting`/`employer`/`application`, no `placement` table exists); the marketplace `application` table gains NO new columns (status is already a plain str column, store.py:211 — the D-099 vocabulary extends the validated set, existing `submitted` rows stay valid); lifespan wiring = the getattr-guard template (main.py:195-200) + `close()` in the finally block (main.py:336); routers mount in main.py beside the employers block (main.py:400-411). No new dependencies (hash chain is stdlib `hashlib`/`json`; export is pydantic + `JSONResponse`). | Seven prior stores never broke an existing DB via this exact pattern (D-095's 0.90 confidence carries forward); create_all checkfirst adds the two tables to every existing deployment at next boot; the zero-dep claim stays intact (D-086/R-66) — sha256 + canonical JSON need nothing the venv lacks. | 0.90 |
## Milestone v0.10 — B2B Employer Network (COMPLETE, Feature, 2026-09-30, shipped as v0.9.6)
**Scope (D-085, auto-decided at SPECIFY under full autonomy D-007; founder scope confirmed 2026-09-30):** Real employer auth/accounts and employer self-serve — the milestone every prior decision sequences next: A-407 ("Real employer auth lands post-payments with the B2B employer network" — payments shipped v0.9), A-401 (employer self-serve posting management deferred pending employer auth), D-076/D-079 (operator-mediated checkout + contact-path CTAs were honest *because* no employer principals exist). Employers become principals: registered accounts with sessions, claiming their existing employer entity (the v0.6 `EmployerRow` ingested data), self-serve posting management (draft → published → closed on their own entities), self-serve checkout (the v0.9 billing catalog with employer-principal buyers instead of operator mediation), and the employer dashboard surface converting from the v0.1 mockup to real data. Rationale: the guiding outcome (≥1,000 placements) needs a self-serve employer side; the supply side (v0.6), monetization rails (v0.9), and install-anywhere distribution (v0.8) are all in place — the employer network is the missing B2B surface that unlocks CIRR tracking (REQ-F-020 needs employer-side placement data) and closes the last mock-only surface. Confidence 0.80 ≥ threshold 0.60 (D-007).
+47 -1
View File
@@ -1,5 +1,41 @@
# Nextcraft — REQUIREMENTS.md
## v0.11 Requirements (ACTIVE — Placement Tracking and Audit, Feature; branch `milestone/v0.11-placement-tracking`)
**Milestone type: Feature** — CIRR-style placement tracking and audit (REQ-F-020 activated per D-098). Tags on the v0.10.x line (P0 → v0.10.0 … P6 → v0.10.6 = milestone release); branch `milestone/v0.11-placement-tracking`. REQ-F-020 activated as REQ-11-001..005: the placement store + evidence model (REQ-11-001), the employer-side application state machine with audit-logged transitions (REQ-11-002), the placement reporting surfaces (REQ-11-003), the audit-grade export (REQ-11-004), learner-side placement visibility (REQ-11-005). Mock-first doctrine holds (no external systems, no third-party auditor integration — the export is the deliverable); tests never call a cloud.
### Placement Store + Evidence Model
| ID | Description | Priority | Phase | Status |
|----|-------------|----------|-------|--------|
| REQ-11-001 | Placement store + evidence model: `ai_service/placements/` module (D-031 extend pattern — 9th D-027 store family, shared DB file, `create_all` idempotent): `PlacementRow` (id, learner_id, employer_id, posting_id, application_id, role_title, start_date, status, created_at, decided_at) with partial UNIQUE on `application_id` WHERE status='active' — one ACTIVE placement per application, revoked rows stay in history (D-102, the G-316 conditional-uniqueness shape); `PlacementEventRow` (append-only ledger: application_id nullable+indexed, placement_id nullable, event_type, actor_type, actor_ref, payload, prev_hash, hash, created_at) — one global hash chain, sha256 over canonical JSON + prev_hash, `UNIQUE(prev_hash)` fork backstop (D-101), tamper-evident by append-only discipline + hash chain (the chain makes silent rewrites detectable, G-301-class integrity); evidence links resolved from existing rows only (posting ref from postings store, application ref from applications store, employer ref from the claimed entity — G-302 doctrine: never trust denormalized copies); SQLModel typed access, Protocol + SQLite impl pair (the employer-store pattern), tz-normalization, fail-closed on unknown inputs | critical | 1 | planned |
### Application State Machine
| ID | Description | Priority | Phase | Status |
|----|-------------|----------|-------|--------|
| REQ-11-002 | Employer-side application state machine: the v0.6 `ApplicationRow.status` extends from `submitted`-only to the hiring lifecycle (submitted → in_review → interview → offer → hired / rejected / withdrawn — forward-only with skip-forward, terminals have no outgoing edges, D-099; withdrawn learner- or operator-initiated, all others employer-principal-driven); transitions via per-target employer routes under `/v1/employers/applications` (`/{id}/in-review`, `/{id}/interview`, `/{id}/offer`, `/{id}/reject`, `/{id}/hire` with `{role_title, start_date}` — D-105) gated by the employer session + claimed-entity authorization (the posting's employer must be the session's claimed entity — 403 otherwise, G-311 gate order); every transition appends the placement ledger in the SAME transaction as the status write (application events + placement events on hired — D-100); rejected/withdrawn/hired are terminal with different semantics (hired creates the PlacementRow + role_title + start_date payload); admin routes retain operator oversight (admin transition + audit, the D-094 lane); learner read access stays the existing surface (their own application status history); G-319 scoping: learner identifiers become visible on employer surfaces only for applications on the session's claimed-entity postings (the own-postings list stays counts-only) | critical | 2 | planned |
### Placement Reporting
| ID | Description | Priority | Phase | Status |
|----|-------------|----------|-------|--------|
| REQ-11-003 | Placement reporting: aggregate placement counts computed from the ledger (per employer, per cohort month, cumulative toward the ≥1,000 guiding outcome); per-employer placement view (the employer dashboard gains a placements section — their hired applications with role/start date, honest empty states); per-learner view (the learner's application list gains outcome states); admin placement overview (the moderation page's network section gains aggregate counts + per-employer placement lists — the operator lane); counts join through existing stores (employer identity via claimed entities, learner via applications, posting via postings) | high | 3 | planned |
### Audit Export
| ID | Description | Priority | Phase | Status |
|----|-------------|----------|-------|--------|
| REQ-11-004 | CIRR-style audit export: admin-gated export routes (`GET /v1/placements/admin/export` + `GET /v1/placements/admin/export/summary`, text/markdown — D-108) producing the third-party-auditor dataset: (a) a cohort snapshot (all placements with employer refs, role titles, start dates, evidence chains); (b) per-placement evidence bundles (posting record, application record, state-transition history, decision provenance — each link resolved from source rows, never denormalized copies); (c) a methodology document (how a placement is defined, counted, and verified — the counting rules an auditor needs); (d) the event-ledger chain with head hash (tamper-evidence for the export itself); output as JSON + a human-readable summary; honest scoping (pilot scale: the export covers every placement in the DB, unbounded by date); export integrity test (re-export reproduces identical event chains + head hash, generated_at excluded — D-108) | critical | 4 | planned |
### Learner Placement Visibility
| ID | Description | Priority | Phase | Status |
|----|-------------|----------|-------|--------|
| REQ-11-005 | Learner-side placement visibility: the learner application surface gains outcome visibility via `GET /v1/marketplace/applications/{application_id}?learner_id=` (current status + full timestamped state history + placement record when hired — D-104) and `POST /v1/marketplace/applications/{application_id}/withdraw` (learner-gated per the D-043 composition: G-5 allowlist → verified 18+ → apply-rate window); placement confirmation (when hired, the learner sees the placement record — role title, employer, start date — sourced from the same ledger); no learner-initiated edits beyond withdrawn (status changes are employer/admin-driven only); the talent profile's placement badge stays absent until a placement exists (honest, the A-304 doctrine — no badge on the v0.1 mock pages) | medium | 5 | planned |
---
## v0.10 Requirements (Complete — B2B Employer Network, Feature; shipped as v0.9.6)
**Milestone type: Feature** — real employer auth/accounts + employer self-serve (the B2B employer network; activated per D-085). Shipped on the v0.9.x line (P0 → v0.9.0 … P6 → v0.9.6 = milestone release); branch `milestone/v0.10-b2b-employer-network`. All 5 requirements complete: the employer auth foundation with scrypt credentials + hashed sessions + throttles (REQ-10-001), the entity claim/verify flow with advisory evidence + admin approval (REQ-10-002), the dashboard real-data conversion with honest states (REQ-10-003), self-serve postings through the shared D-091 entitlement gate byte-identical with the admin path (REQ-10-004), self-serve checkout over the v0.9 catalog through the real webhook path + the admin network overview (REQ-10-005). Auth is session-based with hashed credentials (mock-first doctrine: no external auth provider, tests never call a cloud); admin operator routes are retained unchanged (D-043 composition untouched).
@@ -362,7 +398,7 @@
| REQ-F-017 | Identity verification and age-gating (16+/18+) — real KYC backend | high | v0.5 | activated → complete (REQ-5-003/004) |
| REQ-F-018 | Payment processing and subscription management | high | v0.9 | activated → REQ-9-001..005 |
| REQ-F-019 | Human tutor marketplace (third-party courses) | medium | v0.4+ | deferred |
| REQ-F-020 | CIRR-style placement tracking and audit | medium | v0.5+ | deferred |
| REQ-F-020 | CIRR-style placement tracking and audit | medium | v0.5+ | activated → REQ-11-001..005 |
---
@@ -382,6 +418,16 @@
## Traceability Matrix
### v0.11 (active)
| Requirement | Phase | Status |
|-------------|-------|--------|
| REQ-11-001 | 1 | planned |
| REQ-11-002 | 2 | planned |
| REQ-11-003 | 3 | planned |
| REQ-11-004 | 4 | planned |
| REQ-11-005 | 5 | planned |
### v0.6 (complete)
| Requirement | Phase | Status |
+302
View File
@@ -1,3 +1,305 @@
# Nextcraft — RESEARCH.md (v0.11 Phase 0 RESEARCH — Placement Tracking and Audit)
## v0.11 research conclusions (Phase 0 RESEARCH, 2026-10-01)
In-tree census of the placement-tracking surface, 2026-10-01, plus
audit-convention findings (CIRR methodology, hash-chained ledgers, ATS
stage models, evidence-bundle formats) grounded in audit practice and
marked [convention] vs [pilot adaptation] — no web access in this
environment; the methodology text is written to stand on its own
definitions with CIRR kept as naming, not citation. Binding clarify
decisions D-099..D-112 are NOT re-litigated; this research
operationalizes them. Probes: marketplace/store.py (ApplicationRow +
insert_application + transition machines), employers/store.py (the
8th store + G-316 partial index + decide_claim transaction), billing/
(store.py matrix + event rows + concurrent-test precedent), api/
employers.py + marketplace.py + employers_admin.py +
marketplace_admin.py (gates + provenance + throttles), main.py
(lifespan + routers + CORS + boot warning), engine-client.ts +
pins.test.ts + packages/types/ (web rails), tests/employers/ +
tests/marketplace/ + tests/billing/ (suite layouts + conftest
resets), employer/admin web surfaces (D-109's four surfaces).
**R-67. CIRR methodology transfers as disclosure discipline; three
pilot gaps must be stated in the export methodology (R-67/R-68).**
[convention] CIRR-style audits rest on four load-bearing parts: a
placement definition (in-field role, FTE, exclusions for
self-employment), cohort construction with stated denominators,
employer-verified evidence sampled by an independent auditor, and
disclosure discipline (methodology published beside the numbers,
self-reported vs employer-verified labeled separately). [pilot
adaptation] v0.11's employer-principal attestation (authenticated
`hire` by a claim-verified employer — employers.py:173-191/:467-484,
claim approval with provenance employers/store.py:583-655) is a
stronger evidence class than learner self-report but is NOT CIRR-grade
employer verification. The methodology JSON must state three gaps
honestly: (a) no minimum-employed-duration check (a placement = a
recorded hire, not a 90-day sustained role), (b) no salary (D-098),
(c) verification is employer-attestation + operator oversight, not
third-party employment verification. D-107's distinct-learner counting
matches CIRR's placed-graduate semantics for a cumulative north star;
CIRR reports cohort RATES over denominators, which v0.11 deliberately
does not claim at pilot. Confidence 0.75.
**R-68. The methodology section writes itself from in-tree facts.**
`placement_definition` = a PlacementRow created by the hired
transition, employer-attested role_title + start_date, active at
export time, one active placement per application (partial unique, the
G-316 shape employers/store.py:159-167); `counting_rules` = distinct
learners with ≥1 active placement, all-time cumulative, revoked
excluded, cohort month = start_date calendar month (D-107);
`verification` = each evidence bundle carries the source posting +
application records + full transition ledger whose global sha256 chain
re-walks from GENESIS_HASH to head_hash (D-101); `scope` =
pilot-bounded, every placement in the DB, evidence is
employer-principal attestation. The methodology text must name the
three R-67 gaps explicitly — an audit methodology that omits its own
verification limits is A-304-class dishonesty. Cohort divergence is
deliberate and documented: employment-start month, not graduation
cohort (no graduation events exist in a marketplace). Confidence 0.85.
**R-69. D-101's global hash chain sits in the well-trodden
blockchain-less tamper-evidence lane (CT, git, rekor).** A global
linear chain with one head is the right pilot shape — one walk
recomputes everything; Merkle trees buy proof-efficiency nobody needs
at thousands of events. The UNIQUE(prev_hash) fork backstop has direct
prior-art analog; G-316 proves SQLite unique/partial indexes emit
correctly through create_all and surface IntegrityError
(employers/store.py:398-400). Canonical-serialization rules: sort_keys
+ separators pinned; datetime formatting must be NORMALIZED (R-79);
avoid floats in payload entirely (str/int/bool/None only); sha256
hexdigest is the D-087 token pattern (employers/store.py:230) —
stdlib-only, zero new deps. No backfill: pre-v0.11 submitted rows
carry only their created_at (marketplace/store.py:873-901) — honest
absence per A-304. Confidence 0.85.
**R-70. The hash covers the row's own autoincrement id → the append
must be flush-then-hash-then-commit inside one transaction, and ALL
appends funnel through the one transition_application method (D-100).**
Within one Session: read head (ORDER BY id DESC LIMIT 1), construct
row with prev_hash=head, session.flush() to obtain the id, compute
hash = sha256(canonical_json + prev_hash), set the column, commit.
Nearest in-tree template: decide_claim (employers/store.py:600-652 —
get → guard → mutate → flush → append audit → commit). Corollaries:
(1) a second append path would race on prev_hash (the UNIQUE backstop
catches it as a lost event — IntegrityError); (2) the ledger must have
NO delete path (INTEGER PRIMARY KEY reuses max+1 if the top row is
deleted — D-111's tests pin zero DELETE methods + no DELETE SQL
anywhere). Verification cost at pilot: O(n) sha256 over tiny rows —
milliseconds for thousands of events; the export embeds every event so
the auditor's walk is offline. Confidence 0.80.
**R-71. ATS prior art confirms forward-only-with-skip-forward; the
exclusions are genuinely excludable (D-099).** Real ATSs allow backward
moves because they manage scheduling and offers — which D-98 excludes.
At pilot, `submitted → hired` fast-track matches how small-scale hiring
actually proceeds. Terminals-with-no-outgoing-edges is the tree's own
discipline (billing/store.py:84-90, marketplace/store.py:609-632,
InvalidTransition → 409 at :289). No interview scheduling (no calendar
surface exists — A-408), no offers machinery (no document generation),
no email (no mail transport — D-097 precedent). The matrix constant
belongs in placements/base.py (CANONICAL_EVENT_NAMES shape,
billing/base.py:71-78); the 409-matrix test arms mirror billing's
test_legal_transitions/test_illegal_transition_raises/test_cancelled_is_terminal
trio (tests/billing/test_store.py:400-418) plus skip-forward arms.
Zero data migration: existing rows are all `submitted`
(marketplace/store.py:211) — a valid matrix start. Confidence 0.85.
**R-72. D-100's cross-store single-transaction write is mechanically
sound on the shared file.** Every D-027 store builds its own engine on
the same db_path with identical pragmas (WAL/synchronous=NORMAL/
busy_timeout=5000/foreign_keys=ON — marketplace/store.py:277-286);
multiple engines on one file is the SHIPPED topology (8 stores).
SQLModel's metadata is global — create_all on any engine creates every
registered table (employers/store.py:400), so the placements store
writes ApplicationRow through one Session on its own engine without
bringing up the marketplace store. Importing across modules is
family-sanctioned (employers/base.py:39 imports marketplace.base).
decide_claim is the exact all-or-nothing precedent
(employers/store.py:634-652). Guardrails: NO cross-family foreign keys
(G-302 ID references at read); the store must tolerate being
constructed against a fresh file (global metadata + idempotent
create_all covers it). The two-commit alternative's crash window (status
changed, no ledger row) is precisely the tamper-evidence void the
milestone sells. Confidence 0.85.
**R-73. Export determinism needs three pinned rules (D-108).**
(1) Ordering — events by id (chain order); cohort/summary lists by
pinned sorts (cohort month ascending; placements by created_at then id
— the list_postings discipline, marketplace/store.py:940-948); pydantic
declaration-order serialization is deterministic but set-derived lists
would not be. (2) generated_at exclusion must be TOTAL — compare
parsed documents field-by-field or strip the key before comparison.
(3) Float absence — every export value str/int/bool; no ratios or
percentages (counts are integers by construction, D-107). The summary
generated from the same JSON is the anti-drift rule. Zero new deps
(JSONResponse + pydantic in-tree; D-112 holds). Confidence 0.80.
**R-74. Service seams verified for D-112.** Store lifespan wiring:
the getattr-guard template at main.py:182-190 (billing)/195-200
(employers) — placements slots in beside employers; close() in the
finally block main.py:335-336; router mounting beside the employers
block main.py:400-411; learner routes mount from the existing
marketplace router (no new mount — D-104), placements_admin mounts
beside employers_admin; CORS already carries Authorization
(main.py:357 — no change); the G-11 admin boot warning main.py:251-256
covers the placements admin free. deps.py gains get_placement_store
beside get_employer_store (deps.py:98). create_all checkfirst is the
probe-verified D-095 shipped behavior (employers/store.py:398-400).
Confidence 0.90.
**R-75. API gates verified for D-104/D-105/D-106.** require_employer_session
(employers.py:173-191, 401 class, returns (session, account));
_require_claimed (employers.py:467-484, 403 + claim-CTA);
_own_posting_or_404 (employers.py:487-503). The application-scoped
cross-entity arm is the same shape through the application's posting:
fetch application → posting → employer_id != claimed → 403. G-311
gate order is test-pinned (tests/employers/test_postings_write.py:225-
234). Per-target route idiom: employers.py:635 (publish)/:687 (close).
Provenance: `"account:{account.id}"` (employers.py:792). Learner stack:
_enforce_allowlist (marketplace.py:46-52) → require_verified_age(18)
(:358) → _check_apply_rate (:359; window :66-76, module state
_APPLY_TIMES :58, cap 5/60s :63) — withdraw composes the identical
stack, actor_type=learner. Operator lane: require_admin imports from
marketplace_admin (employers_admin.py:47; guard marketplace_admin.py:
62-85, fail-closed dual-header); OPERATOR="operator"
(employers_admin.py:54); the applications-review precedent
(marketplace_admin.py:420-443) already exposes learner_id to the
operator — unchanged. Token namespaces cannot collide (D-090 —
hash-lookup vs env-compare, verified in both guards). Confidence 0.90.
**R-76. Web rails verified for D-109/D-110.** engine-client.ts
sections in order: identity :351, marketplace :545 (applyPosting :647-
656), search :659, resume :730, billing :773, entitlements :782, admin
transport :843 (adminToken :815), employer auth :918 —
SessionRequiredError :126-131, ClaimRequiredError :136, parseError
:146 (401/403 discrimination); a placements section appends after
employer billing (:1114). pins.test.ts: marketplace :48-133, billing
:203-230, employer dual-schema :267-374 (field-for-field + status-union
regex pins — the D-110 template), G-319 own-postings block :376+.
packages/types/ carries billing/employer/marketplace — placement.ts is
the fourth. Four surfaces: employer overview (app/(employer)/employer/
page.tsx:25 → employer-overview-client.tsx — real postings/counts
:162/:190-230, the mock-labeled placement metrics block :335-372
labelled "(mock — no backend computes this)" :351 — the real
placements section REPLACES that mock block); /employer/applications
page is NEW beside postings/login/register/billing/talent; job-detail-
client.tsx is the learner apply surface (apply :95-115, EngineError
409/404 discrimination :103-105, confirmation render :253 — the
status panel composes the same discipline, no new classes); moderation
page (app/(admin)/admin/moderation/page.tsx:3/:35 →
employer-network-panel.tsx:56, adminToken/locked-state :28-40) gains
the placements block. Talent-profile badge stays absent — talent pages
are v0.1 mock imports; a badge on a mock page is fabricated placement
(A-304). Confidence 0.90.
**R-77. Test suites verified for D-111.** tests/employers/ (16 files)
is the D-096 layout; tests/marketplace/conftest.py:123-131 is the
G-313-shape autouse reset (_reset_apply_rate_window clears _APPLY_TIMES
before/after every test — the withdraw route rides this limiter, so
tests/placements/conftest.py MUST replicate it or the suite
self-429s); tests/employers/conftest.py:31-33 same pattern for the two
employer throttles. Hermeticity: tmp_path DBs, state-injected stores
via create_app (tests/marketplace/conftest.py:80-120 make_client),
zero network via root conftest. Matrix arms template:
tests/billing/test_store.py:400-418. Partial-index pin template:
tests/employers/test_claims_store.py:75-88 (SELECT from sqlite_master
asserting the index SQL — applies to the placement partial unique,
D-102). Gate-order pins: tests/employers/test_postings_write.py:315
(cross-entity 403), tests/marketplace/test_marketplace_api.py:188
(invalid transitions 409). G-319 pin SPLIT needed (D-105): counts-only
stays on the own-postings list (test_postings_read.py:216); learner_id
appears on application-scoped responses for own-posting applications;
cross-entity → 403 with no application data. Confidence 0.85.
**R-78. SQLite two-store cross-write interplay safe at pilot; two WAL
facts to pin.** (1) The marketplace store's own application writes are
idempotent inserts (insert_application :873-901) that never touch
status after creation — the placements store is the ONLY status writer
post-creation; state the COLUMN-level ownership in the plan (no second
status writer outside the one-transaction discipline). (2) The tamper
test's second engine on the same file sees WAL snapshot isolation —
the mutation must commit before the verification walk reads it
(Session context-managers guarantee). Multi-session contention on one
file is already exercised in-tree (test_concurrent_consumes_one_post_left,
tests/billing/test_store.py:335). busy_timeout=5000 is generous for
hiring-transition volumes (rare events, not hot-path). Confidence 0.80.
**R-79. Three clocks, three serialization rules.** (1) start_date is
a calendar date — timezone-free, ISO YYYY-MM-DD in SQLite, cohort
month = strftime("%Y-%m"); no datetime coercion in its path. (2)
created_at/decided_at are UTC datetimes stored NAIVE in SQLite,
re-labeled tz-aware on read (_as_utc, marketplace/store.py:263-267 —
the family pattern). The hash-serialization pitfall: datetime.isoformat()
emits +00:00 for aware, nothing for naive — the chain's canonical JSON
must pin ONE normalization (always _as_utc(dt).isoformat(), or a fixed
YYYY-MM-DDTHH:MM:SSZ format) and the tamper tests catch drift between
the hashing and reading paths. (3) generated_at is export-only
metadata, excluded from determinism (D-108 — a function of the request,
not the rows). Privacy (G-319 supersession): learner_id is an opaque
allowlisted handle (the G-5 roster), not name/email/DOB — identity PII
never stored (identity/base.py:25-33), the 422 scrubber's pii_fields
(main.py:439) never sees learner_id; exposure bounded to opaque handles
on the employer's own applicants, cross-entity 403 preserves
anti-enumeration. start_date is not PII-class. Confidence 0.85.
**R-80. Coherence gap found: re-hire after revoke is UNREACHABLE
through the D-099 matrix — PLAN must pin the operator re-hire as a
placement-level route.** After the first hire the application is
`hired` — terminal, no outgoing edges; D-106 validates the operator's
generic route against the same matrix, so `hired → hired` is a 409 on
every surface; the revoke itself (active → revoked, D-102) leaves the
application hired — correct — but no matrix-legal path creates the
fresh placement D-102 promises. Candidate shapes: (a) an explicit
operator re-hire route (POST /v1/placements/admin/placements/
by-application/{application_id}/re-hire {role_title, start_date})
creating the fresh PlacementRow + a `placement.rehired` ledger event
(actor operator) WITHOUT touching the application's terminal status —
audit-coherent: the ledger shows hired → revoke → rehire as three
events, never fabricating a second application transition; (b) matrix
amendment hired → hired operator-only — REJECTED (breaks the terminals
rule, and "the application was hired twice" is false); (c) revoke +
full reapplication — REJECTED (a new application may be impossible on
a closed posting, and it orphans the learner from the count — exactly
what D-102 exists to prevent). Shape (a) is coherent: placements, not
applications, are the correction lane (D-102's own framing); the
partial unique index makes it safe (revoked rows don't match WHERE
status='active' — the exact G-316 property that makes
claim-rejection-free-reclaim work, employers/store.py:159-167). The
ledger event vocabulary (D-101) admits this event type; D-111's revoke
+ re-hire test arm is unaffected. Confidence 0.80.
## Open risks (v0.11, tracked into PLAN)
1. **Re-hire-after-revoke coherence gap (R-80)** — pin the operator
re-hire as a placement-level route + `placement.rehired` ledger
event, never an application transition. Changes route list, event
vocabulary, and a test arm.
2. **Hash-after-flush write shape (R-70)** — flush-then-hash-then-commit
inside one transaction; ALL appends through the one
transition_application method; no-delete-path pin.
3. **created_at canonical serialization (R-79/R-69)** — pin ONE
normalization (_as_utc(dt).isoformat() or fixed Z-format) or the
chain breaks on its first re-read.
4. **_APPLY_TIMES limiter reset (R-77)** — tests/placements/conftest.py
must replicate the autouse reset or the suite self-429s (G-313).
5. **Methodology honesty block (R-67/R-68)** — the three pilot gaps
stated explicitly in the export methodology.
6. **Export determinism rules (R-73)** — pinned list orderings, total
generated_at exclusion, float absence.
7. **Status-column ownership (R-78)** — placements store is the only
post-creation writer of application.status; state it column-level.
8. **G-319 supersession pin split (R-77)** — counts-only stays on the
own-postings list; learner_id on application-scoped routes only.
9. **Concurrent-transition arm (R-78)** — one-winner-one-IntegrityError
on truly concurrent transition_application calls (the billing
concurrent-consumption test shape).
10. **CIRR specifics from knowledge, not live sources (R-67)** — the
methodology text stands on its own definitions; CIRR is naming,
not citation.
---
# Nextcraft — RESEARCH.md (v0.10 Phase 0 RESEARCH — B2B Employer Network)
## v0.10 research conclusions (Phase 0 RESEARCH, 2026-09-30)
+17 -1
View File
@@ -2,6 +2,8 @@
## Overview
**Milestone v0.11 — Placement Tracking and Audit (ACTIVE, started 2026-10-01).** Feature milestone: CIRR-style placement tracking and audit (REQ-F-020 activated per D-098) — the machinery that makes the guiding outcome (≥1,000 placements, CIRR-style third-party audit) measurable: the placement store + hash-chained evidence ledger, the employer-side application state machine (submitted → in_review → interview → offer → hired/rejected/withdrawn), placement reporting surfaces (per-employer, per-learner, aggregate toward ≥1,000), the audit-grade export (cohort snapshot + evidence bundles + methodology + chain head), learner-side placement visibility. Tags on the v0.10.x line (P0 → v0.10.0 … P6 → v0.10.6 = milestone release); branch `milestone/v0.11-placement-tracking`.
**Milestone v0.10 — B2B Employer Network (COMPLETE, shipped as v0.9.6, 2026-10-01).** Feature milestone: real employer auth/accounts + self-serve (D-085). Employers become principals — registered accounts with sessions, employer-entity claim/verify, the employer dashboard converting from the v0.1 mockup to real data, self-serve posting management with the v0.9 entitlement enforcement intact, and self-serve checkout over the v0.9 billing catalog (superseding D-076 operator mediation; admin operator routes retained as fallback + oversight). Tags on the v0.9.x line (P0 → v0.9.0 … P6 → v0.9.6 = milestone release); branch `milestone/v0.10-b2b-employer-network`.
**Milestone v0.9 — Payment Engine (COMPLETE, shipped as v0.8.6, 2026-09-30).** Payment processing and subscription management (REQ-F-018 activated per D-074): the billing module (provider protocol mock+stripe, 7th D-027 store, HMAC webhooks), catalog + pricing surface, operator-mediated checkout, entitlement enforcement, full subscription lifecycle. Founder scope decision D-074; clarify D-075..D-084; grill G-301+. Tags on the v0.8.x line (v0.8.0 phase 0 → v0.8.6 final = milestone release); branch `milestone/v0.9-payment-engine`.
@@ -36,7 +38,21 @@ Payment processing and subscription management (REQ-F-018 activated per D-074, a
## Phase List
### v0.10 (complete)
### v0.11 (active)
| # | Name | Status | Depends On | Requirements | Success Criteria |
|---|------|--------|------------|--------------|------------------|
| 0 | Pre-execution | active | — | — | Specify (D-098), clarify, research, plan, grill, MVP/UX check |
| 1 | Placement store + evidence ledger | planned | 0 | REQ-11-001 | `ai_service/placements/` module: 9th D-027 store (PlacementRow + PlacementEventRow, hash-chained append-only ledger), evidence resolved from source rows only (G-302 doctrine), `create_all` idempotent on the shared DB file, Protocol + SQLite pair |
| 2 | Application state machine | planned | 1 | REQ-11-002 | Employer routes extend the v0.6 application status from submitted-only to the full hiring lifecycle; claimed-entity authorization (403 cross-entity, G-311 gate order); every transition ledger-logged; hired creates the placement; admin oversight retained |
| 3 | Placement reporting | planned | 2 | REQ-11-003 | Aggregate counts from the ledger (per employer, per cohort month, cumulative toward ≥1,000); employer dashboard placements section; learner outcome states; admin network overview counts |
| 4 | Audit export | planned | 3 | REQ-11-004 | Admin-gated export: cohort snapshot + per-placement evidence bundles + methodology doc + chain head hash; JSON + human-readable summary; re-export reproducibility test |
| 5 | Learner placement visibility | planned | 4 | REQ-11-005 | Learner application outcomes + full state history; hired learners see their placement record; honest no-badge-until-placed (A-304) |
| 6 | Final review + ship | planned | 1-5 | — | Review clean; audit pass; milestone tagged v0.10.6 (final v0.10.x patch = milestone release); release with full asset matrix + registry image; milestone merged to main |
---
## Phase List (v0.10 — complete)
| # | Name | Status | Depends On | Requirements | Success Criteria |
|---|------|--------|------------|--------------|------------------|
+3 -3
View File
@@ -46,9 +46,9 @@
"projects": [],
"active_project": null,
"milestone": {
"version": "v0.10",
"name": "b2b-employer-network",
"version": "v0.11",
"name": "placement-tracking",
"type": "feature",
"branch": "milestone/v0.10-b2b-employer-network"
"branch": "milestone/v0.11-placement-tracking"
}
}