Compare commits
46 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 68800815c4 | |||
| 67b806fc5c | |||
| b0d2069b15 | |||
| ca704a55a0 | |||
| 2f6b1e23ec | |||
| b4fb7b3881 | |||
| f4a35afbc9 | |||
| 5bc899eef3 | |||
| 0c51ddc4df | |||
| 85ddf5ea22 | |||
| ca08f93eeb | |||
| 5b66c75917 | |||
| 8fd229641a | |||
| c2b907b544 | |||
| 376dc3d59a | |||
| e32e54e7aa | |||
| 6b451d4de7 | |||
| 7e16b33ff6 | |||
| 922219748e | |||
| 38b3e07cfe | |||
| b489c59980 | |||
| f34959026a | |||
| 38d8e2c4b9 | |||
| a46ecf142a | |||
| 809ce3bda8 | |||
| c25e42c005 | |||
| a62acf8241 | |||
| e1ef420b2d | |||
| 51a030fa60 | |||
| f5dcb12a93 | |||
| e090be2f8d | |||
| 27aaa313b4 | |||
| 9759a0e21e | |||
| fe9ece1cc9 | |||
| bad56988b7 | |||
| e5855ac800 | |||
| f4f6d8b9c9 | |||
| 61139c61bf | |||
| 9f727427a7 | |||
| 151a2ffff8 | |||
| cb3af9d06a | |||
| 89bff0c36a | |||
| 89e21eb4b2 | |||
| 3332a7f264 | |||
| 6b0b20cee1 | |||
| 0c5fe12d0a |
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"phase": 0,
|
||||
"stage": "mvp_ux_check",
|
||||
"milestone": "v0.11",
|
||||
"phase_role": "pre_execution",
|
||||
"attempts": 0,
|
||||
"updated_at": "2026-10-01T03:15:00Z"
|
||||
}
|
||||
+12
-12
@@ -1,6 +1,6 @@
|
||||
# Nextcraft — REQUIREMENTS.md
|
||||
|
||||
## v0.11 Requirements (ACTIVE — Placement Tracking and Audit, Feature; branch `milestone/v0.11-placement-tracking`)
|
||||
## v0.11 Requirements (Complete — Placement Tracking and Audit, Feature; shipped as v0.10.6)
|
||||
|
||||
**Milestone type: Feature** — CIRR-style placement tracking and audit (REQ-F-020 activated per D-098). Tags on the v0.10.x line (P0 → v0.10.0 … P6 → v0.10.6 = milestone release); branch `milestone/v0.11-placement-tracking`. REQ-F-020 activated as REQ-11-001..005: the placement store + evidence model (REQ-11-001), the employer-side application state machine with audit-logged transitions (REQ-11-002), the placement reporting surfaces (REQ-11-003), the audit-grade export (REQ-11-004), learner-side placement visibility (REQ-11-005). Mock-first doctrine holds (no external systems, no third-party auditor integration — the export is the deliverable); tests never call a cloud.
|
||||
|
||||
@@ -8,31 +8,31 @@
|
||||
|
||||
| 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 |
|
||||
| REQ-11-001 | Placement store + evidence model: `ai_service/placements/` module (D-031 extend pattern — 9th D-027 store family, shared DB file, `create_all` idempotent): `PlacementRow` (id, learner_id, employer_id, posting_id, application_id, role_title, start_date, status, created_at, decided_at) with partial UNIQUE on `application_id` WHERE status='active' — one ACTIVE placement per application, revoked rows stay in history (D-102, the G-316 conditional-uniqueness shape); `PlacementEventRow` (append-only ledger: application_id nullable+indexed, placement_id nullable, event_type, actor_type, actor_ref, payload, prev_hash, hash, created_at) — one global hash chain, sha256 over canonical JSON + prev_hash, `UNIQUE(prev_hash)` fork backstop (D-101), tamper-evident by append-only discipline + hash chain (the chain makes silent rewrites detectable, G-301-class integrity); evidence links resolved from existing rows only (posting ref from postings store, application ref from applications store, employer ref from the claimed entity — G-302 doctrine: never trust denormalized copies); SQLModel typed access, Protocol + SQLite impl pair (the employer-store pattern), tz-normalization, fail-closed on unknown inputs | critical | 1 | complete (v0.10.1) |
|
||||
|
||||
### Application State Machine
|
||||
|
||||
| ID | Description | Priority | Phase | Status |
|
||||
|----|-------------|----------|-------|--------|
|
||||
| REQ-11-002 | Employer-side application state machine: the v0.6 `ApplicationRow.status` extends from `submitted`-only to the hiring lifecycle (submitted → in_review → interview → offer → hired / rejected / withdrawn — forward-only with skip-forward, terminals have no outgoing edges, D-099; withdrawn learner- or operator-initiated, all others employer-principal-driven); transitions via per-target employer routes under `/v1/employers/applications` (`/{id}/in-review`, `/{id}/interview`, `/{id}/offer`, `/{id}/reject`, `/{id}/hire` with `{role_title, start_date}` — D-105) gated by the employer session + claimed-entity authorization (the posting's employer must be the session's claimed entity — 403 otherwise, G-311 gate order); every transition appends the placement ledger in the SAME transaction as the status write (application events + placement events on hired — D-100); rejected/withdrawn/hired are terminal with different semantics (hired creates the PlacementRow + role_title + start_date payload); admin routes retain operator oversight (admin transition + audit, the D-094 lane); learner read access stays the existing surface (their own application status history); G-319 scoping: learner identifiers become visible on employer surfaces only for applications on the session's claimed-entity postings (the own-postings list stays counts-only) | critical | 2 | planned |
|
||||
| REQ-11-002 | Employer-side application state machine: the v0.6 `ApplicationRow.status` extends from `submitted`-only to the hiring lifecycle (submitted → in_review → interview → offer → hired / rejected / withdrawn — forward-only with skip-forward, terminals have no outgoing edges, D-099; withdrawn learner- or operator-initiated, all others employer-principal-driven); transitions via per-target employer routes under `/v1/employers/applications` (`/{id}/in-review`, `/{id}/interview`, `/{id}/offer`, `/{id}/reject`, `/{id}/hire` with `{role_title, start_date}` — D-105) gated by the employer session + claimed-entity authorization (the posting's employer must be the session's claimed entity — 403 otherwise, G-311 gate order); every transition appends the placement ledger in the SAME transaction as the status write (application events + placement events on hired — D-100); rejected/withdrawn/hired are terminal with different semantics (hired creates the PlacementRow + role_title + start_date payload); admin routes retain operator oversight (admin transition + audit, the D-094 lane); learner read access stays the existing surface (their own application status history); G-319 scoping: learner identifiers become visible on employer surfaces only for applications on the session's claimed-entity postings (the own-postings list stays counts-only) | critical | 2 | complete (v0.10.2) |
|
||||
|
||||
### Placement Reporting
|
||||
|
||||
| ID | Description | Priority | Phase | Status |
|
||||
|----|-------------|----------|-------|--------|
|
||||
| REQ-11-003 | Placement reporting: aggregate placement counts computed from the ledger (per employer, per cohort month, cumulative toward the ≥1,000 guiding outcome); per-employer placement view (the employer dashboard gains a placements section — their hired applications with role/start date, honest empty states); per-learner view (the learner's application list gains outcome states); admin placement overview (the moderation page's network section gains aggregate counts + per-employer placement lists — the operator lane); counts join through existing stores (employer identity via claimed entities, learner via applications, posting via postings) | high | 3 | planned |
|
||||
| REQ-11-003 | Placement reporting: aggregate placement counts computed from the ledger (per employer, per cohort month, cumulative toward the ≥1,000 guiding outcome); per-employer placement view (the employer dashboard gains a placements section — their hired applications with role/start date, honest empty states); per-learner view (the learner's application list gains outcome states); admin placement overview (the moderation page's network section gains aggregate counts + per-employer placement lists — the operator lane); counts join through existing stores (employer identity via claimed entities, learner via applications, posting via postings) | high | 3 | complete (v0.10.3) |
|
||||
|
||||
### Audit Export
|
||||
|
||||
| ID | Description | Priority | Phase | Status |
|
||||
|----|-------------|----------|-------|--------|
|
||||
| REQ-11-004 | CIRR-style audit export: admin-gated export routes (`GET /v1/placements/admin/export` + `GET /v1/placements/admin/export/summary`, text/markdown — D-108) producing the third-party-auditor dataset: (a) a cohort snapshot (all placements with employer refs, role titles, start dates, evidence chains); (b) per-placement evidence bundles (posting record, application record, state-transition history, decision provenance — each link resolved from source rows, never denormalized copies); (c) a methodology document (how a placement is defined, counted, and verified — the counting rules an auditor needs); (d) the event-ledger chain with head hash (tamper-evidence for the export itself); output as JSON + a human-readable summary; honest scoping (pilot scale: the export covers every placement in the DB, unbounded by date); export integrity test (re-export reproduces identical event chains + head hash, generated_at excluded — D-108) | critical | 4 | planned |
|
||||
| REQ-11-004 | CIRR-style audit export: admin-gated export routes (`GET /v1/placements/admin/export` + `GET /v1/placements/admin/export/summary`, text/markdown — D-108) producing the third-party-auditor dataset: (a) a cohort snapshot (all placements with employer refs, role titles, start dates, evidence chains); (b) per-placement evidence bundles (posting record, application record, state-transition history, decision provenance — each link resolved from source rows, never denormalized copies); (c) a methodology document (how a placement is defined, counted, and verified — the counting rules an auditor needs); (d) the event-ledger chain with head hash (tamper-evidence for the export itself); output as JSON + a human-readable summary; honest scoping (pilot scale: the export covers every placement in the DB, unbounded by date); export integrity test (re-export reproduces identical event chains + head hash, generated_at excluded — D-108) | critical | 4 | complete (v0.10.4) |
|
||||
|
||||
### Learner Placement Visibility
|
||||
|
||||
| ID | Description | Priority | Phase | Status |
|
||||
|----|-------------|----------|-------|--------|
|
||||
| REQ-11-005 | Learner-side placement visibility: the learner application surface gains outcome visibility via `GET /v1/marketplace/applications/{application_id}?learner_id=` (current status + full timestamped state history + placement record when hired — D-104) and `POST /v1/marketplace/applications/{application_id}/withdraw` (learner-gated per the D-043 composition: G-5 allowlist → verified 18+ → apply-rate window); placement confirmation (when hired, the learner sees the placement record — role title, employer, start date — sourced from the same ledger); no learner-initiated edits beyond withdrawn (status changes are employer/admin-driven only); the talent profile's placement badge stays absent until a placement exists (honest, the A-304 doctrine — no badge on the v0.1 mock pages) | medium | 5 | planned |
|
||||
| REQ-11-005 | Learner-side placement visibility: the learner application surface gains outcome visibility via `GET /v1/marketplace/applications/{application_id}?learner_id=` (current status + full timestamped state history + placement record when hired — D-104) and `POST /v1/marketplace/applications/{application_id}/withdraw` (learner-gated per the D-043 composition: G-5 allowlist → verified 18+ → apply-rate window); placement confirmation (when hired, the learner sees the placement record — role title, employer, start date — sourced from the same ledger); no learner-initiated edits beyond withdrawn (status changes are employer/admin-driven only); the talent profile's placement badge stays absent until a placement exists (honest, the A-304 doctrine — no badge on the v0.1 mock pages) | medium | 5 | complete (v0.10.5) |
|
||||
|
||||
---
|
||||
|
||||
@@ -418,15 +418,15 @@
|
||||
|
||||
## Traceability Matrix
|
||||
|
||||
### v0.11 (active)
|
||||
### v0.11 (complete)
|
||||
|
||||
| Requirement | Phase | Status |
|
||||
|-------------|-------|--------|
|
||||
| REQ-11-001 | 1 | planned |
|
||||
| REQ-11-002 | 2 | planned |
|
||||
| REQ-11-003 | 3 | planned |
|
||||
| REQ-11-004 | 4 | planned |
|
||||
| REQ-11-005 | 5 | planned |
|
||||
| REQ-11-001 | 1 | complete |
|
||||
| REQ-11-002 | 2 | complete |
|
||||
| REQ-11-003 | 3 | complete |
|
||||
| REQ-11-004 | 4 | complete |
|
||||
| REQ-11-005 | 5 | complete |
|
||||
|
||||
### v0.6 (complete)
|
||||
|
||||
|
||||
+9
-9
@@ -2,7 +2,7 @@
|
||||
|
||||
## Overview
|
||||
|
||||
**Milestone v0.11 — Placement Tracking and Audit (ACTIVE, started 2026-10-01).** Feature milestone: CIRR-style placement tracking and audit (REQ-F-020 activated per D-098) — the machinery that makes the guiding outcome (≥1,000 placements, CIRR-style third-party audit) measurable: the placement store + hash-chained evidence ledger, the employer-side application state machine (submitted → in_review → interview → offer → hired/rejected/withdrawn), placement reporting surfaces (per-employer, per-learner, aggregate toward ≥1,000), the audit-grade export (cohort snapshot + evidence bundles + methodology + chain head), learner-side placement visibility. Tags on the v0.10.x line (P0 → v0.10.0 … P6 → v0.10.6 = milestone release); branch `milestone/v0.11-placement-tracking`.
|
||||
**Milestone v0.11 — COMPLETE (shipped as v0.10.6, 2026-10-22).** Feature milestone: CIRR-style placement tracking and audit (REQ-F-020 activated per D-098) — the machinery that makes the guiding outcome (≥1,000 placements, CIRR-style third-party audit) measurable: the placement store + hash-chained evidence ledger, the employer-side application state machine (submitted → in_review → interview → offer → hired/rejected/withdrawn), placement reporting surfaces (per-employer, per-learner, aggregate toward ≥1,000), the audit-grade export (cohort snapshot + evidence bundles + methodology + chain head), learner-side placement visibility. Tags on the v0.10.x line (P0 → v0.10.0 … P6 → v0.10.6 = milestone release); branch `milestone/v0.11-placement-tracking`.
|
||||
|
||||
**Milestone v0.10 — B2B Employer Network (COMPLETE, shipped as v0.9.6, 2026-10-01).** Feature milestone: real employer auth/accounts + self-serve (D-085). Employers become principals — registered accounts with sessions, employer-entity claim/verify, the employer dashboard converting from the v0.1 mockup to real data, self-serve posting management with the v0.9 entitlement enforcement intact, and self-serve checkout over the v0.9 billing catalog (superseding D-076 operator mediation; admin operator routes retained as fallback + oversight). Tags on the v0.9.x line (P0 → v0.9.0 … P6 → v0.9.6 = milestone release); branch `milestone/v0.10-b2b-employer-network`.
|
||||
|
||||
@@ -38,17 +38,17 @@ Payment processing and subscription management (REQ-F-018 activated per D-074, a
|
||||
|
||||
## Phase List
|
||||
|
||||
### v0.11 (active)
|
||||
### v0.11 (complete — shipped as v0.10.6, 2026-10-22)
|
||||
|
||||
| # | Name | Status | Depends On | Requirements | Success Criteria |
|
||||
|---|------|--------|------------|--------------|------------------|
|
||||
| 0 | Pre-execution | active | — | — | Specify (D-098), clarify, research, plan, grill, MVP/UX check |
|
||||
| 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 |
|
||||
| 0 | Pre-execution | complete (v0.10.0) | — | — | Specify (D-098), clarify, research, plan, grill, MVP/UX check complete (shipped as v0.10.0) |
|
||||
| 1 | Placement store + evidence ledger | complete (v0.10.1) | 0 | REQ-11-001 | `ai_service/placements/` module: 9th D-027 store (PlacementRow + PlacementEventRow, hash-chained append-only ledger), evidence resolved from source rows only (G-302 doctrine), `create_all` idempotent on the shared DB file, Protocol + SQLite pair |
|
||||
| 2 | Application state machine | complete (v0.10.2) | 1 | REQ-11-002 | Employer routes extend the v0.6 application status from submitted-only to the full hiring lifecycle; claimed-entity authorization (403 cross-entity, G-311 gate order); every transition ledger-logged; hired creates the placement; admin oversight retained |
|
||||
| 3 | Placement reporting | complete (v0.10.3) | 2 | REQ-11-003 | Aggregate counts from the ledger (per employer, per cohort month, cumulative toward ≥1,000); employer dashboard placements section; learner outcome states; admin network overview counts |
|
||||
| 4 | Audit export | complete (v0.10.4) | 3 | REQ-11-004 | Admin-gated export: cohort snapshot + per-placement evidence bundles + methodology doc + chain head hash; JSON + human-readable summary; re-export reproducibility test |
|
||||
| 5 | Learner placement visibility | complete (v0.10.5) | 4 | REQ-11-005 | Learner application outcomes + full state history; hired learners see their placement record; honest no-badge-until-placed (A-304) |
|
||||
| 6 | Final review + ship | complete (v0.10.6) | 1-5 | — | Review SHIP — 0 P0s, 2 P1 doc-level findings dispositioned with recorded follow-ups (the PLAN Must-Have test_e2e_audit.py name vs test_export.py::test_e2e_audit_proof; the D-112 census omitting the employers/store.py two-line audit-vocabulary addition); audit PASS — D-112 reconstruction clean (27 branch commits vs the census), branch/commit discipline green; ship: v0.10.5 tagged at the ship per the recorded deferral (e32e54e7aa), milestone merged to main (PR #6), v0.10.6 tagged at the merge, release lanes fired, milestone branch deleted |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -278,3 +278,100 @@ jobs:
|
||||
|| { echo "FAIL: embedded CLI does not run"; exit 1; }
|
||||
echo "assert 5/5: embedded CLI alive + clean stop"
|
||||
docker rm -f "$CID" >/dev/null && CID=""
|
||||
# -- R1 deploy leg (REQ-v1-003 machinery set c18619; hep APPROVE c18623;
|
||||
# vehicle call c18634: the last mile is THIS pipeline, not an SRE infra
|
||||
# detour). Pipeline-only host refresh of the product host - no manual
|
||||
# host pokes. Provenance = this job's run id + the post-deploy identity
|
||||
# read below. The footer is retired as a freshness marker (it is the
|
||||
# code's own label - c18624 correction, confirmed by hep): freshness
|
||||
# reads are machine-read (/health + / serving HTML + the /v1/placements/
|
||||
# admin route registration in /openapi.json).
|
||||
#
|
||||
# One-time plumbing preconditions (declared for the MR review; NOT a
|
||||
# routing of the refresh to SRE - SRE keeps the R3 re-probe lane only):
|
||||
# * DEPLOY_SSH_KEY - repo Actions secret: the least-scope deploy key
|
||||
# authorized on the product host's deploy account (deploy@10.0.0.105
|
||||
# per deploy/README.md topology). One-time grant via the SRE/console
|
||||
# precedent (deploy/ci README G-206 pattern; self-serve secret PUT
|
||||
# precedent: REGISTRY_TOKEN, HTTP 201 on 2026-09-27).
|
||||
# * (optional hardening) DEPLOY_HOSTKEY - repo Actions secret holding
|
||||
# the product host's complete ssh public host-key line; when set it
|
||||
# is PINNED (StrictHostKeyChecking=yes), else accept-new.
|
||||
# A missing DEPLOY_SSH_KEY at run time fails this leg LOUDLY - a red
|
||||
# Actions tab on a tag is a red deploy by design (deploy/README.md);
|
||||
# it is never silently skipped (set R1: the gate stays clearable on
|
||||
# evidence). The release/asset path is untouched: a deploy-leg
|
||||
# precondition failure cannot damage artifact shipping.
|
||||
deploy:
|
||||
needs: release
|
||||
runs-on: release
|
||||
steps:
|
||||
- name: Preflight - fail loud when the leg is unconfigured
|
||||
env:
|
||||
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "deploy preflight: run id $GITHUB_RUN_ID / run #$GITHUB_RUN_NUMBER / job $GITHUB_JOB_ID / sha $GITHUB_SHA"
|
||||
[ -n "$DEPLOY_SSH_KEY" ] || { echo "FATAL: DEPLOY_SSH_KEY unset - deploy leg unconfigured; host NOT refreshed this release; release artifacts stand (release job already green)"; exit 1; }
|
||||
|
||||
- name: Refresh the product host over the declared ssh channel
|
||||
env:
|
||||
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||
DEPLOY_HOSTKEY: ${{ secrets.DEPLOY_HOSTKEY }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
trap 'rm -f /tmp/dk /tmp/kh' EXIT
|
||||
apt-get update -qq && apt-get install -y -qq openssh-client >/dev/null
|
||||
install -m 600 /dev/null /tmp/dk
|
||||
printf '%s\n' "$DEPLOY_SSH_KEY" > /tmp/dk
|
||||
unset DEPLOY_SSH_KEY
|
||||
if [ -n "${DEPLOY_HOSTKEY:-}" ]; then
|
||||
printf '%s\n' "$DEPLOY_HOSTKEY" > /tmp/kh
|
||||
SSH_PIN="-oUserKnownHostsFile=/tmp/kh -oStrictHostKeyChecking=yes"
|
||||
else
|
||||
SSH_PIN="-oStrictHostKeyChecking=accept-new"
|
||||
fi
|
||||
TAG="${{ gitea.ref_name }}"
|
||||
ssh -i /tmp/dk -o BatchMode=yes $SSH_PIN deploy@10.0.0.105 bash -s "$TAG" << 'REMOTE'
|
||||
set -euo pipefail
|
||||
TAG="$1"
|
||||
if command -v nextcraft >/dev/null 2>&1; then
|
||||
echo "channel: binary/deb - the one-command upgrade (idempotent, self-verifying per deploy/README.md)"
|
||||
nextcraft deploy --service --ref "$TAG"
|
||||
elif [ -n "$(docker ps --format '{{.Names}}' | grep -x nextcraft || true)" ]; then
|
||||
echo "channel: docker - README upgrade (pull + recreate; nextcraft-state persists)"
|
||||
docker pull git.coreci.dev/coreci/nextcraft:"$TAG"
|
||||
docker rm -f nextcraft
|
||||
docker run -d --name nextcraft -p 8420:8420 -v nextcraft-state:/home/nextcraft/.nextcraft git.coreci.dev/coreci/nextcraft:"$TAG"
|
||||
else
|
||||
echo "FATAL: no nextcraft channel on $(hostname) (no nextcraft on PATH; no docker container named nextcraft) - failing loud"
|
||||
exit 1
|
||||
fi
|
||||
echo "--- channel verify (advisory; the identity read below is canonical)"
|
||||
command -v nextcraft >/dev/null 2>&1 && nextcraft --version || true
|
||||
REMOTE
|
||||
RC=$?
|
||||
echo "host refresh done (rc=$RC); provenance: run id $GITHUB_RUN_ID / run #$GITHUB_RUN_NUMBER / job $GITHUB_JOB_ID / tag $TAG / sha $GITHUB_SHA"
|
||||
exit $RC
|
||||
|
||||
- name: Post-deploy identity read - the R1 provenance surface
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG="${{ gitea.ref_name }}"
|
||||
echo "waiting for the stack to settle..."
|
||||
OK=""
|
||||
for i in $(seq 1 12); do
|
||||
CODE_H="$(curl -sS -o /tmp/h -m 15 -w '%{http_code}' https://nextcraft.coreci.dev/health || true)"
|
||||
if [ "$CODE_H" = "200" ] && grep -q '"status"' /tmp/h; then OK=1; break; fi
|
||||
sleep 5
|
||||
done
|
||||
[ -n "$OK" ] || { echo "FAIL: /health never returned 200 within the window"; exit 1; }
|
||||
echo "assert 1/3: /health 200 - $(head -c 200 /tmp/h)"
|
||||
CODE_R="$(curl -sS -o /tmp/r -m 15 -w '%{http_code}' https://nextcraft.coreci.dev/)"
|
||||
head -c 64 /tmp/r | tr -d '[:space:]' | head -c 9 | grep -qiE '^<!doctype|^<html' || { echo "FAIL: / is not HTML (code $CODE_R)"; exit 1; }
|
||||
echo "assert 2/3: / serves HTML ($CODE_R)"
|
||||
CODE_O="$(curl -sS -o /tmp/o -m 25 -w '%{http_code}' https://nextcraft.coreci.dev/openapi.json)"
|
||||
grep -q '"/v1/placements/admin' /tmp/o || { echo "FAIL: counting surface NOT registered on the served build (/openapi.json has no /v1/placements/admin routes) - honest red; R2 root-cause owns the diagnosis per c18619"; exit 1; }
|
||||
echo "assert 3/3: /v1/placements/admin registered in /openapi.json"
|
||||
echo "(advisory served version: $(grep -o '"version":"[^"]*"' /tmp/o | head -1))"
|
||||
echo "PROVENANCE (R1): deploy run id $GITHUB_RUN_ID / run #$GITHUB_RUN_NUMBER / job $GITHUB_JOB_ID / tag $TAG / head $GITHUB_SHA - identity reads green"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Nextcraft
|
||||
|
||||
AI-native outcome school + marketplace — graduates prove what they can build, not what they can write.
|
||||
**TL;DR** — AI-native outcome school + marketplace — graduates prove what they can build, not what they can write. This file is the front door: three install channels, dev setup from a clone, and the CLI reference; the admin runbook for standing the stack up on a server lives in [deploy/README.md](deploy/README.md).
|
||||
|
||||
## Quickstart
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ from ..grading.store import GradeStore
|
||||
from ..llm.base import LLMProvider
|
||||
from ..marketplace.embedding.base import EmbeddingProvider
|
||||
from ..marketplace.store import MarketplaceStore
|
||||
from ..placements.store import PlacementStore
|
||||
from ..sandbox.manager import SandboxManager
|
||||
from ..sandbox.workdir import SandboxDir
|
||||
from ..telemetry.ingest import TraceIntegrityMap
|
||||
@@ -101,6 +102,13 @@ def get_employer_store(request: Request) -> EmployerStore:
|
||||
return request.app.state.employer_store
|
||||
|
||||
|
||||
def get_placement_store(request: Request) -> PlacementStore:
|
||||
"""v0.11 placements (D-100/D-112): the same app.state DI territory
|
||||
rule — placement routes compose the store via app.state, never
|
||||
import it."""
|
||||
return request.app.state.placement_store
|
||||
|
||||
|
||||
def get_embedding_provider(request: Request) -> EmbeddingProvider:
|
||||
"""v0.6 P3 (D-051): the embedding port via app.state DI — the
|
||||
search route + publish hooks compose it, never import it."""
|
||||
|
||||
@@ -38,6 +38,7 @@ import secrets
|
||||
import time
|
||||
import uuid
|
||||
from collections import deque
|
||||
from datetime import UTC, date, datetime
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, Request
|
||||
@@ -71,13 +72,30 @@ from ..llm.base import LLMProvider
|
||||
from ..marketplace.base import Posting
|
||||
from ..marketplace.enrichment import maybe_enrich_on_publish
|
||||
from ..marketplace.search import embed_posting
|
||||
from ..marketplace.store import InvalidTransition, MarketplaceStore
|
||||
from ..marketplace.store import (
|
||||
InvalidTransition,
|
||||
MarketplaceStore,
|
||||
)
|
||||
from ..marketplace.tagging import TaggingUnavailable, tag_posting
|
||||
from ..placements.base import (
|
||||
APPLICATION_STATUSES,
|
||||
ApplicationTransitionResponse,
|
||||
EmployerApplicationsResponse,
|
||||
EmployerApplicationSummary,
|
||||
EmployerPlacementsResponse,
|
||||
HireTransitionRequest,
|
||||
PlacementRead,
|
||||
)
|
||||
from ..placements.base import (
|
||||
InvalidTransition as PlacementInvalidTransition,
|
||||
)
|
||||
from ..placements.store import PlacementRow, PlacementStore
|
||||
from .deps import (
|
||||
get_billing_store,
|
||||
get_embedding_provider,
|
||||
get_employer_store,
|
||||
get_marketplace_store,
|
||||
get_placement_store,
|
||||
get_provider,
|
||||
get_settings,
|
||||
)
|
||||
@@ -715,6 +733,243 @@ async def close_own_posting(
|
||||
)
|
||||
|
||||
|
||||
# -- hiring pipeline (v0.11 P2, Task 2-1-01 — REQ-11-002, D-105/G-311) ---------------
|
||||
|
||||
|
||||
def _own_application_or_404(
|
||||
store: MarketplaceStore, application_id: str, employer_id: str
|
||||
) -> None:
|
||||
"""Fetch the application → its posting → enforce the cross-entity
|
||||
arm (D-105: the _own_posting_or_404 shape applied THROUGH the
|
||||
application's posting — another entity's application → 403 with
|
||||
NO application data; unknown → 404). G-319: learner_id never
|
||||
rides this arm's error responses."""
|
||||
application = store.get_application(application_id)
|
||||
if application is None:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"no application with id {application_id!r}",
|
||||
)
|
||||
posting = store.get_posting(application.posting_id)
|
||||
if posting is None: # pragma: no cover - FK discipline keeps rows paired
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"no posting with id {application.posting_id!r}",
|
||||
)
|
||||
if posting.employer_id != employer_id:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="this application belongs to another employer entity",
|
||||
)
|
||||
|
||||
|
||||
def _placement_read(row: PlacementRow) -> PlacementRead:
|
||||
return PlacementRead(
|
||||
id=row.id,
|
||||
learner_id=row.learner_id,
|
||||
employer_id=row.employer_id,
|
||||
posting_id=row.posting_id,
|
||||
application_id=row.application_id,
|
||||
role_title=row.role_title,
|
||||
start_date=date.fromisoformat(row.start_date),
|
||||
status=row.status, # type: ignore[arg-type]
|
||||
created_at=row.created_at or datetime.now(UTC),
|
||||
decided_at=row.decided_at or datetime.now(UTC),
|
||||
)
|
||||
|
||||
|
||||
def _employer_transition(
|
||||
application_id: str,
|
||||
to_status: str,
|
||||
account: AccountRow,
|
||||
marketplace: MarketplaceStore,
|
||||
placements: PlacementStore,
|
||||
employer_store: EmployerStore,
|
||||
role_title: str | None = None,
|
||||
start_date: date | None = None,
|
||||
) -> ApplicationTransitionResponse:
|
||||
"""The shared per-target transition composition (D-105): G-311
|
||||
gate order — session (the route dep) → claim (_require_claimed) →
|
||||
cross-entity (_own_application_or_404) → the ONE store transition
|
||||
(409 on matrix-illegal, NEVER before the 403s) + the
|
||||
employer_audit_events append (the D-089 one-queryable lane)."""
|
||||
employer_id = _require_claimed(account)
|
||||
_own_application_or_404(marketplace, application_id, employer_id)
|
||||
try:
|
||||
application, placement = placements.transition_application(
|
||||
application_id,
|
||||
to_status,
|
||||
actor_type="employer",
|
||||
actor_ref=f"account:{account.id}",
|
||||
role_title=role_title,
|
||||
start_date=start_date,
|
||||
)
|
||||
except PlacementInvalidTransition as exc: # MUST precede ValueError — it subclasses it
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
except ValueError as exc: # hire required-fields honesty (D-103)
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
employer_store.append_audit_event(
|
||||
"application",
|
||||
f"application.{to_status}",
|
||||
account_id=account.id,
|
||||
employer_id=employer_id,
|
||||
detail={"application_id": application_id, "to_status": to_status},
|
||||
)
|
||||
return ApplicationTransitionResponse(
|
||||
application_id=application.id,
|
||||
status=application.status, # type: ignore[arg-type]
|
||||
placement=_placement_read(placement) if placement is not None else None,
|
||||
)
|
||||
|
||||
|
||||
@claims_router.get("/applications", response_model=EmployerApplicationsResponse)
|
||||
async def list_own_applications(
|
||||
status: str | None = None,
|
||||
auth: Annotated[
|
||||
tuple[SessionRow, AccountRow], Depends(require_employer_session)
|
||||
] = None, # type: ignore[assignment]
|
||||
store: MarketplaceStore = Depends(get_marketplace_store),
|
||||
) -> EmployerApplicationsResponse:
|
||||
"""The session employer's OWN hiring pipeline (D-105): every
|
||||
application on the claimed entity's postings (ALL statuses), with
|
||||
per-status facets. G-319 pin split: this APPLICATION-scoped read
|
||||
exposes learner_id per the supersession — the own-postings LIST
|
||||
stays counts-only."""
|
||||
if auth is None: # pragma: no cover - the dep raises 401 first
|
||||
raise HTTPException(status_code=401, detail="employer session required")
|
||||
_session, account = auth
|
||||
employer_id = _require_claimed(account)
|
||||
postings = store.list_postings_by_employer(employer_id)
|
||||
applications = store.list_applications_by_postings(
|
||||
[p.id for p in postings], status=status
|
||||
)
|
||||
facets: dict[str, int] = {s: 0 for s in APPLICATION_STATUSES}
|
||||
items = [
|
||||
EmployerApplicationSummary(
|
||||
id=a.id,
|
||||
posting_id=a.posting_id,
|
||||
learner_id=a.learner_id,
|
||||
status=a.status, # type: ignore[arg-type]
|
||||
created_at=a.created_at,
|
||||
)
|
||||
for a in applications
|
||||
]
|
||||
for item in items:
|
||||
facets[item.status] = facets.get(item.status, 0) + 1
|
||||
return EmployerApplicationsResponse(
|
||||
applications=items, facets=facets, total=len(items)
|
||||
)
|
||||
|
||||
|
||||
def _own_transition_route(
|
||||
to_status: str, hire: bool = False
|
||||
):
|
||||
"""Per-target route factory (D-105: the matrix stays OUT of request
|
||||
bodies — each route's store call names its target, the publish/
|
||||
close idiom). Only the hire route carries a body ({role_title,
|
||||
start_date} — D-103/G-324); the others are bodyless actions."""
|
||||
|
||||
if hire:
|
||||
|
||||
async def _hire_route(
|
||||
application_id: str,
|
||||
auth: Annotated[
|
||||
tuple[SessionRow, AccountRow], Depends(require_employer_session)
|
||||
],
|
||||
body: HireTransitionRequest,
|
||||
store: MarketplaceStore = Depends(get_marketplace_store),
|
||||
placements: PlacementStore = Depends(get_placement_store),
|
||||
employer_store: EmployerStore = Depends(get_employer_store),
|
||||
) -> ApplicationTransitionResponse:
|
||||
return _employer_transition(
|
||||
application_id,
|
||||
to_status,
|
||||
auth[1],
|
||||
store,
|
||||
placements,
|
||||
employer_store,
|
||||
role_title=body.role_title,
|
||||
start_date=body.start_date,
|
||||
)
|
||||
|
||||
return _hire_route
|
||||
|
||||
async def _action_route(
|
||||
application_id: str,
|
||||
auth: Annotated[
|
||||
tuple[SessionRow, AccountRow], Depends(require_employer_session)
|
||||
],
|
||||
store: MarketplaceStore = Depends(get_marketplace_store),
|
||||
placements: PlacementStore = Depends(get_placement_store),
|
||||
employer_store: EmployerStore = Depends(get_employer_store),
|
||||
) -> ApplicationTransitionResponse:
|
||||
return _employer_transition(
|
||||
application_id,
|
||||
to_status,
|
||||
auth[1],
|
||||
store,
|
||||
placements,
|
||||
employer_store,
|
||||
)
|
||||
|
||||
return _action_route
|
||||
|
||||
|
||||
claims_router.add_api_route(
|
||||
"/applications/{application_id}/in-review",
|
||||
_own_transition_route("in_review"),
|
||||
methods=["POST"],
|
||||
response_model=ApplicationTransitionResponse,
|
||||
)
|
||||
claims_router.add_api_route(
|
||||
"/applications/{application_id}/interview",
|
||||
_own_transition_route("interview"),
|
||||
methods=["POST"],
|
||||
response_model=ApplicationTransitionResponse,
|
||||
)
|
||||
claims_router.add_api_route(
|
||||
"/applications/{application_id}/offer",
|
||||
_own_transition_route("offer"),
|
||||
methods=["POST"],
|
||||
response_model=ApplicationTransitionResponse,
|
||||
)
|
||||
claims_router.add_api_route(
|
||||
"/applications/{application_id}/reject",
|
||||
_own_transition_route("rejected"),
|
||||
methods=["POST"],
|
||||
response_model=ApplicationTransitionResponse,
|
||||
)
|
||||
claims_router.add_api_route(
|
||||
"/applications/{application_id}/hire",
|
||||
_own_transition_route("hired", hire=True),
|
||||
methods=["POST"],
|
||||
response_model=ApplicationTransitionResponse,
|
||||
)
|
||||
|
||||
|
||||
# -- own placements (v0.11 P3, Task 3-1-01 — REQ-11-003, D-107/D-109) ---------------
|
||||
|
||||
|
||||
@claims_router.get("/placements", response_model=EmployerPlacementsResponse)
|
||||
async def list_own_placements(
|
||||
auth: Annotated[
|
||||
tuple[SessionRow, AccountRow], Depends(require_employer_session)
|
||||
],
|
||||
placements: PlacementStore = Depends(get_placement_store),
|
||||
) -> EmployerPlacementsResponse:
|
||||
"""The claimed entity's OWN placements (active + revoked — the
|
||||
full history, D-109 surface 2's data source): created_at then id;
|
||||
honest empty state at zero placements. G-311: 401 session → 403
|
||||
claim (never data before the gates)."""
|
||||
_session, account = auth
|
||||
employer_id = _require_claimed(account)
|
||||
rows = placements.list_placements_by_employer(employer_id)
|
||||
return EmployerPlacementsResponse(
|
||||
placements=[_placement_read(row) for row in rows],
|
||||
total=len(rows),
|
||||
)
|
||||
|
||||
|
||||
# -- self-serve billing (P5, Task 5-1-01 — REQ-10-005, R-59/G-310) ------------------
|
||||
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ from __future__ import annotations
|
||||
|
||||
import time
|
||||
from collections import deque
|
||||
from datetime import date
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from pydantic import BaseModel, Field, ValidationError
|
||||
@@ -33,6 +34,15 @@ from ..marketplace.resume import (
|
||||
parse_resume,
|
||||
)
|
||||
from ..marketplace.search import FacetFilters, rank_postings
|
||||
from ..placements.base import (
|
||||
InvalidTransition,
|
||||
LearnerApplicationStatusResponse,
|
||||
LearnerApplicationSummary,
|
||||
MineApplicationResponse,
|
||||
PlacementEventRead,
|
||||
PlacementRead,
|
||||
)
|
||||
from ..placements.store import PlacementRow
|
||||
from .deps import get_embedding_provider, get_marketplace_store, get_provider, get_settings
|
||||
from .identity import require_verified_age
|
||||
|
||||
@@ -375,6 +385,19 @@ async def apply_posting(
|
||||
application, _created = store.insert_application(
|
||||
posting_id=body.posting_id, learner_id=body.learner_id
|
||||
)
|
||||
# G-323 (binding): the idempotent arm NEVER confirms a terminal
|
||||
# application — a withdrawn/rejected/hired row returns 409 (the
|
||||
# learner is told the honest state; recovery is visibility via the
|
||||
# application-status surface, not resurrection — no second row is
|
||||
# ever created; the (posting_id, learner_id) unique key stands).
|
||||
if application.status in ("withdrawn", "rejected", "hired"):
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail={
|
||||
"reason": "application_terminal",
|
||||
"status": application.status,
|
||||
},
|
||||
)
|
||||
return ApplyResponse(
|
||||
application_id=application.id,
|
||||
posting_id=application.posting_id,
|
||||
@@ -382,6 +405,173 @@ async def apply_posting(
|
||||
)
|
||||
|
||||
|
||||
# -- v0.11 P2: the learner withdraw route (D-104, REQ-11-002) --------------------
|
||||
|
||||
|
||||
class WithdrawBody(BaseModel):
|
||||
"""D-104: the pilot surface passes learner_id in the body (the G-5
|
||||
composition unchanged — the SAME stack as apply)."""
|
||||
|
||||
learner_id: str = Field(min_length=1)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/applications/{application_id}/withdraw", response_model=ApplyResponse
|
||||
)
|
||||
async def withdraw_application(
|
||||
application_id: str,
|
||||
body: WithdrawBody,
|
||||
request: Request,
|
||||
store=Depends(get_marketplace_store),
|
||||
settings: Settings = Depends(get_settings),
|
||||
) -> ApplyResponse:
|
||||
"""The learner's ONLY edit (D-099: withdrawn is learner- or
|
||||
operator-triggerable, NEVER an employer session). The D-043
|
||||
composition UNCHANGED: G-5 allowlist → verified 18+ → the EXISTING
|
||||
apply-rate window (no new throttle knob, D-088 scope untouched).
|
||||
Own application only (403 foreign); terminal → 409 (the matrix).
|
||||
"""
|
||||
_enforce_allowlist(body.learner_id, settings)
|
||||
await require_verified_age(18, body.learner_id, _identity_store(request))
|
||||
_check_apply_rate(body.learner_id)
|
||||
|
||||
application = store.get_application(application_id)
|
||||
if application is None:
|
||||
raise HTTPException(
|
||||
status_code=404, detail=f"no application with id {application_id!r}"
|
||||
)
|
||||
if application.learner_id != body.learner_id:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="this application belongs to another learner",
|
||||
)
|
||||
from .deps import get_placement_store # local import — the territory rule
|
||||
|
||||
placements = get_placement_store(request)
|
||||
try:
|
||||
application, _placement = placements.transition_application(
|
||||
application_id,
|
||||
"withdrawn",
|
||||
actor_type="learner",
|
||||
actor_ref=f"learner:{body.learner_id}",
|
||||
)
|
||||
except InvalidTransition as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
return ApplyResponse(
|
||||
application_id=application.id,
|
||||
posting_id=application.posting_id,
|
||||
status=application.status,
|
||||
)
|
||||
|
||||
|
||||
# -- v0.11 P5: the learner status reads (D-104/G-322, REQ-11-005) -------------------
|
||||
|
||||
|
||||
def _placement_read(row: PlacementRow) -> PlacementRead:
|
||||
"""The PlacementRow → PlacementRead conversion (the placements_admin
|
||||
helper mirrored — each route file owns its readers; start_date is
|
||||
the ISO DATE string on the row, a date on the wire, D-103)."""
|
||||
return PlacementRead(
|
||||
id=row.id,
|
||||
learner_id=row.learner_id,
|
||||
employer_id=row.employer_id,
|
||||
posting_id=row.posting_id,
|
||||
application_id=row.application_id,
|
||||
role_title=row.role_title,
|
||||
start_date=date.fromisoformat(row.start_date),
|
||||
status=row.status, # type: ignore[arg-type]
|
||||
created_at=row.created_at or _now(),
|
||||
decided_at=row.decided_at or _now(),
|
||||
)
|
||||
|
||||
|
||||
def _now():
|
||||
from datetime import UTC, datetime
|
||||
|
||||
return datetime.now(UTC)
|
||||
|
||||
|
||||
@router.get("/applications/mine", response_model=MineApplicationResponse)
|
||||
async def mine_application(
|
||||
request: Request,
|
||||
posting_id: str = Query(min_length=1),
|
||||
learner_id: str = Query(min_length=1),
|
||||
store=Depends(get_marketplace_store),
|
||||
settings: Settings = Depends(get_settings),
|
||||
) -> MineApplicationResponse:
|
||||
"""G-322 (binding): the mine point-read — the status panel's
|
||||
application DISCOVERY on revisit (the panel NEVER depends on
|
||||
client-persisted application_ids). Gate composition: G-5 allowlist
|
||||
→ verified 18+ (reads are free — the apply-rate window is the
|
||||
WRITE budget, A-408's distinct-action rule; no new throttle knob,
|
||||
D-088 untouched); no row → the honest 404 (the surface renders the
|
||||
apply CTA)."""
|
||||
_enforce_allowlist(learner_id, settings)
|
||||
await require_verified_age(18, learner_id, _identity_store(request))
|
||||
|
||||
application = store.get_application_by_posting_learner(posting_id, learner_id)
|
||||
if application is None:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"no application by this learner on posting {posting_id!r}",
|
||||
)
|
||||
return MineApplicationResponse(
|
||||
application_id=application.id,
|
||||
status=application.status,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/applications/{application_id}",
|
||||
response_model=LearnerApplicationStatusResponse,
|
||||
)
|
||||
async def learner_application_status(
|
||||
application_id: str,
|
||||
request: Request,
|
||||
learner_id: str = Query(min_length=1),
|
||||
store=Depends(get_marketplace_store),
|
||||
settings: Settings = Depends(get_settings),
|
||||
) -> LearnerApplicationStatusResponse:
|
||||
"""D-104 (REQ-11-005): the learner's-own outcome read — current
|
||||
status + the FULL id-ordered timestamped history for THIS
|
||||
application + the placement record when hired (one read, one
|
||||
truth source). Same gates as the mine-read (reads are free);
|
||||
foreign learner_id → 403 (the scoped query never renders another
|
||||
learner's events); unknown → 404."""
|
||||
_enforce_allowlist(learner_id, settings)
|
||||
await require_verified_age(18, learner_id, _identity_store(request))
|
||||
|
||||
application = store.get_application(application_id)
|
||||
if application is None:
|
||||
raise HTTPException(
|
||||
status_code=404, detail=f"no application with id {application_id!r}"
|
||||
)
|
||||
if application.learner_id != learner_id:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="this application belongs to another learner",
|
||||
)
|
||||
from .deps import get_placement_store # local import — the territory rule
|
||||
|
||||
placements = get_placement_store(request)
|
||||
events = [
|
||||
PlacementEventRead.model_validate(event, from_attributes=True)
|
||||
for event in placements.get_application_events(application_id)
|
||||
]
|
||||
row = placements.get_placement_by_application(application_id)
|
||||
return LearnerApplicationStatusResponse(
|
||||
application=LearnerApplicationSummary(
|
||||
id=application.id,
|
||||
posting_id=application.posting_id,
|
||||
learner_id=application.learner_id,
|
||||
status=application.status,
|
||||
created_at=application.created_at,
|
||||
),
|
||||
events=events,
|
||||
placement=_placement_read(row) if row is not None else None,
|
||||
)
|
||||
|
||||
|
||||
# -- P4-B: resume upload + confirm (REQ-6-005, D-054, A-410) --------------------
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
"""Operator lane for placement oversight (D-106, REQ-11-002).
|
||||
|
||||
`require_admin` IMPORTED from marketplace_admin (the billing_admin.py
|
||||
import precedent — token namespaces cannot collide, D-090): one
|
||||
generic remediation transition route + revoke + the R-80 re-hire +
|
||||
the reporting/export reads (P3/P4 extend this router). No employer
|
||||
session, no learner gate — a third principal class (D-097).
|
||||
|
||||
OPERATOR = "operator" route-level provenance (employers_admin.py:54 —
|
||||
no per-operator accounts exist, D-094); every action is
|
||||
ledger-recorded with actor_type="operator" (the D-083 manual-grant
|
||||
lane) and answers in employer_audit_events via the P2 routes' own
|
||||
appends (operator transitions record through the placement ledger).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import date
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Response
|
||||
|
||||
from ..config import Settings
|
||||
from ..marketplace.store import MarketplaceStore
|
||||
from ..placements.base import (
|
||||
GUIDING_GOAL,
|
||||
AdminTransitionRequest,
|
||||
ApplicationTransitionResponse,
|
||||
HireTransitionRequest,
|
||||
PlacementOverview,
|
||||
PlacementRead,
|
||||
)
|
||||
from ..placements.store import PlacementRow, PlacementStore
|
||||
from .deps import get_marketplace_store, get_placement_store
|
||||
from .marketplace_admin import require_admin
|
||||
|
||||
#: D-094: route-level operator provenance — no per-operator accounts.
|
||||
OPERATOR = "operator"
|
||||
|
||||
router = APIRouter(prefix="/v1/placements/admin", tags=["placements-admin"])
|
||||
# The admin-token gate composes on EVERY route handler via the
|
||||
# Annotated[Settings, Depends(require_admin)] house pattern (the
|
||||
# employers_admin idiom — fail-closed; token namespaces cannot collide
|
||||
# with employer session tokens, D-090).
|
||||
AdminGate = Annotated[Settings, Depends(require_admin)]
|
||||
|
||||
|
||||
def _placement_read(row: PlacementRow) -> PlacementRead:
|
||||
return PlacementRead(
|
||||
id=row.id,
|
||||
learner_id=row.learner_id,
|
||||
employer_id=row.employer_id,
|
||||
posting_id=row.posting_id,
|
||||
application_id=row.application_id,
|
||||
role_title=row.role_title,
|
||||
start_date=date.fromisoformat(row.start_date),
|
||||
status=row.status, # type: ignore[arg-type]
|
||||
created_at=row.created_at or _now(),
|
||||
decided_at=row.decided_at or _now(),
|
||||
)
|
||||
|
||||
|
||||
def _now():
|
||||
from datetime import UTC, datetime
|
||||
|
||||
return datetime.now(UTC)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/applications/{application_id}/transition",
|
||||
response_model=ApplicationTransitionResponse,
|
||||
)
|
||||
async def admin_transition_application(
|
||||
application_id: str,
|
||||
body: AdminTransitionRequest,
|
||||
_gate: AdminGate,
|
||||
store: MarketplaceStore = Depends(get_marketplace_store),
|
||||
placements: PlacementStore = Depends(get_placement_store),
|
||||
) -> ApplicationTransitionResponse:
|
||||
"""The generic operator remediation route (D-106): ANY matrix-legal
|
||||
transition incl. withdrawn on a learner's behalf (remediation with
|
||||
provenance, the D-083 lane). G-327: `to` is a CLOSED Literal —
|
||||
unknown values die at pydantic (422), matrix-illegal known values
|
||||
reach the store (409 InvalidTransition). role_title/start_date
|
||||
are required iff to=hired (the honest 422 with required-fields
|
||||
detail, D-103)."""
|
||||
role_title: str | None = None
|
||||
start_date: date | None = None
|
||||
if body.to == "hired":
|
||||
if body.role_title is None or body.start_date is None:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail="role_title and start_date are required when to=hired (D-103)",
|
||||
)
|
||||
role_title, start_date = body.role_title, body.start_date
|
||||
try:
|
||||
application, placement = placements.transition_application(
|
||||
application_id,
|
||||
body.to,
|
||||
actor_type="operator",
|
||||
actor_ref=OPERATOR,
|
||||
role_title=role_title,
|
||||
start_date=start_date,
|
||||
)
|
||||
except Exception as exc:
|
||||
# The placements InvalidTransition (409) MUST be matched before
|
||||
# the ValueError arm — InvalidTransition subclasses ValueError.
|
||||
from ..placements.base import InvalidTransition
|
||||
|
||||
if isinstance(exc, InvalidTransition):
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
if isinstance(exc, ValueError):
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
raise
|
||||
return ApplicationTransitionResponse(
|
||||
application_id=application.id,
|
||||
status=application.status, # type: ignore[arg-type]
|
||||
placement=_placement_read(placement) if placement is not None else None,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/placements/{placement_id}/revoke", response_model=PlacementRead)
|
||||
async def admin_revoke_placement(
|
||||
placement_id: str,
|
||||
_gate: AdminGate,
|
||||
placements: PlacementStore = Depends(get_placement_store),
|
||||
) -> PlacementRead:
|
||||
"""D-102: the operator correction — the one active → revoked
|
||||
placement transition (operator-only by route). Unknown → 404;
|
||||
non-active → 409 (the matrix)."""
|
||||
try:
|
||||
revoked = placements.revoke_placement(placement_id, OPERATOR)
|
||||
except Exception as exc:
|
||||
from ..placements.base import InvalidTransition
|
||||
|
||||
if isinstance(exc, InvalidTransition):
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
raise
|
||||
if revoked is None:
|
||||
raise HTTPException(
|
||||
status_code=404, detail=f"no placement with id {placement_id!r}"
|
||||
)
|
||||
return _placement_read(revoked)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/placements/by-application/{application_id}/re-hire",
|
||||
response_model=PlacementRead,
|
||||
)
|
||||
async def admin_rehire_placement(
|
||||
application_id: str,
|
||||
body: HireTransitionRequest,
|
||||
_gate: AdminGate,
|
||||
placements: PlacementStore = Depends(get_placement_store),
|
||||
) -> PlacementRead:
|
||||
"""THE R-80 RE-HIRE ROUTE: after the first hire the application is
|
||||
TERMINAL (hired → hired is a 409 on every matrix-validated
|
||||
surface — the coherence gap research found). The re-hire creates
|
||||
the FRESH PlacementRow + the placement.rehired ledger event
|
||||
WITHOUT touching the application's terminal status — the ledger
|
||||
reads hired → revoke → rehire as three events, never fabricating
|
||||
a second application transition. Structurally safe: revoked rows
|
||||
never match the partial WHERE status='active' (the G-316
|
||||
property). G-326: decided_at = the re-hire event's stamp."""
|
||||
try:
|
||||
rehired = placements.rehire_placement(
|
||||
application_id,
|
||||
role_title=body.role_title,
|
||||
start_date=body.start_date,
|
||||
actor_ref=OPERATOR,
|
||||
)
|
||||
except Exception as exc:
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from ..placements.base import InvalidTransition
|
||||
|
||||
if isinstance(exc, InvalidTransition):
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
if isinstance(exc, ValueError):
|
||||
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
||||
if isinstance(exc, IntegrityError):
|
||||
# the partial-unique arm: an ACTIVE placement already exists
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="an active placement already exists for this "
|
||||
"application — revoke it before re-hiring",
|
||||
) from exc
|
||||
raise
|
||||
return _placement_read(rehired)
|
||||
|
||||
|
||||
# -- the reporting read (v0.11 P3, Task 3-1-01 — REQ-11-003, D-107) ---------------
|
||||
|
||||
|
||||
@router.get("/overview", response_model=PlacementOverview)
|
||||
async def placement_overview(
|
||||
_gate: AdminGate,
|
||||
placements: PlacementStore = Depends(get_placement_store),
|
||||
) -> PlacementOverview:
|
||||
"""The D-107 computation — counts from ROWS at query time (never
|
||||
denormalized counters, G-302/G-306): the guiding metric is DISTINCT
|
||||
learners with ≥1 active placement, all-time cumulative toward
|
||||
GUIDING_GOAL (a learner placed twice counts ONCE — the outcome's
|
||||
noun is learners); revoked excluded from every count (D-102);
|
||||
cohort month = the calendar month of start_date (YYYY-MM); orderings
|
||||
pinned (R-73: by_employer count desc then employer_id;
|
||||
by_cohort_month month ascending)."""
|
||||
rows = placements.list_placements(status="active")
|
||||
learner_ids = {row.learner_id for row in rows}
|
||||
by_employer: dict[str, int] = {}
|
||||
by_month: dict[str, int] = {}
|
||||
for row in rows:
|
||||
by_employer[row.employer_id] = by_employer.get(row.employer_id, 0) + 1
|
||||
month = row.start_date[:7] # ISO YYYY-MM (the calendar slice)
|
||||
by_month[month] = by_month.get(month, 0) + 1
|
||||
return PlacementOverview(
|
||||
total_active_placements=len(rows),
|
||||
distinct_learners_placed=len(learner_ids),
|
||||
guiding_goal=GUIDING_GOAL,
|
||||
by_employer=[
|
||||
{"employer_id": employer_id, "count": count}
|
||||
for employer_id, count in sorted(
|
||||
by_employer.items(), key=lambda kv: (-kv[1], kv[0])
|
||||
)
|
||||
],
|
||||
by_cohort_month=[
|
||||
{"month": month, "count": count}
|
||||
for month, count in sorted(by_month.items())
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
# -- the audit export (v0.11 P4, Tasks 4-1-01/4-1-02 — REQ-11-004, D-108) ----------
|
||||
|
||||
|
||||
@router.get("/export")
|
||||
async def placement_export(
|
||||
_gate: AdminGate,
|
||||
placements: PlacementStore = Depends(get_placement_store),
|
||||
) -> Response:
|
||||
"""GET /v1/placements/admin/export — ONE self-contained JSON
|
||||
document (D-108): {schema_version, generated_at, summary, cohort,
|
||||
evidence_bundles (EVERY placement, active + revoked — G-325),
|
||||
methodology (the G-321 statements + the three R-67 pilot gaps),
|
||||
ledger (every event + head_hash)}. Determinism per R-73: pinned
|
||||
orderings, no floats, generated_at excluded from comparison (the
|
||||
re-export test strips it)."""
|
||||
document = placements.build_export()
|
||||
return Response(
|
||||
content=json.dumps(document, sort_keys=True, separators=(",", ":")),
|
||||
media_type="application/json",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/export/summary")
|
||||
async def placement_export_summary(
|
||||
_gate: AdminGate,
|
||||
placements: PlacementStore = Depends(get_placement_store),
|
||||
) -> Response:
|
||||
"""GET /v1/placements/admin/export/summary — text/markdown
|
||||
rendered FROM the same JSON document (one builder, two
|
||||
renderings — the anti-drift rule: the summary can never assert a
|
||||
number the export doesn't contain). G-328: the title carries the
|
||||
ATTESTATION language (employer-attested, operator-overseen) —
|
||||
never a verification claim."""
|
||||
document = placements.build_export()
|
||||
summary = document["summary"]
|
||||
methodology = document["methodology"]
|
||||
head = document["ledger"]["head_hash"]
|
||||
lines: list[str] = []
|
||||
lines.append("# Placement Audit Export — Summary")
|
||||
lines.append("")
|
||||
lines.append(
|
||||
"Employer-attested, operator-overseen placement records (NOT "
|
||||
"third-party employment verification — see Methodology)."
|
||||
)
|
||||
lines.append("")
|
||||
lines.append("## Totals")
|
||||
lines.append("")
|
||||
lines.append("| Metric | Count |")
|
||||
lines.append("|---|---|")
|
||||
lines.append(
|
||||
f"| Distinct learners placed (toward {GUIDING_GOAL}) "
|
||||
f"| {summary['distinct_learners_placed']} |"
|
||||
)
|
||||
lines.append(f"| Total active placements | {summary['total_active_placements']} |")
|
||||
lines.append(f"| Revoked placements | {summary['revoked_count']} |")
|
||||
lines.append("")
|
||||
lines.append("## By cohort month (employment start)")
|
||||
lines.append("")
|
||||
lines.append("| Month | Placements |")
|
||||
lines.append("|---|---|")
|
||||
for bucket in summary["by_cohort_month"]:
|
||||
lines.append(f"| {bucket['month']} | {bucket['count']} |")
|
||||
if not summary["by_cohort_month"]:
|
||||
lines.append("| (none recorded) | 0 |")
|
||||
lines.append("")
|
||||
lines.append("## Methodology")
|
||||
lines.append("")
|
||||
lines.append(f"### Placement definition\n\n{methodology['placement_definition']}\n")
|
||||
lines.append(f"### Counting rules\n\n{methodology['counting_rules']}\n")
|
||||
lines.append(f"### Verification\n\n{methodology['verification']}\n")
|
||||
lines.append(f"### Scope\n\n{methodology['scope']}\n")
|
||||
lines.append("## Verifying the export offline")
|
||||
lines.append("")
|
||||
lines.append(
|
||||
"Every ledger event is embedded in the JSON export. Re-walk the "
|
||||
"chain: for each event in id order, compute "
|
||||
"`sha256(canonical_json + prev_hash)` where canonical_json is "
|
||||
"the event's {id, application_id, placement_id, event_type, "
|
||||
"actor_type, actor_ref, payload, created_at} serialized with "
|
||||
"sorted keys and compact separators (created_at in UTC ISO-8601), "
|
||||
"and prev_hash starts at the all-zeros GENESIS constant. The "
|
||||
"final computed hash must equal head_hash:"
|
||||
)
|
||||
lines.append("")
|
||||
lines.append(f"`head_hash: {head}`")
|
||||
lines.append("")
|
||||
lines.append(
|
||||
"Then compare against a PRIOR export: its ledger.events must "
|
||||
"appear as an exact prefix of this export's events, and its "
|
||||
"head_hash must equal the hash at that prefix position "
|
||||
"(export-to-export continuity is the anchor against wholesale "
|
||||
"database replacement — see Verification)."
|
||||
)
|
||||
return Response(
|
||||
content="\n".join(lines),
|
||||
media_type="text/markdown",
|
||||
)
|
||||
@@ -64,7 +64,9 @@ _TABLES = (
|
||||
#: Audit event vocabulary: `auth` = the Wave 1-2 register/logout
|
||||
#: account events; `claim` = P2's claim/decide entity events; `manual`
|
||||
#: = operator action provenance (the billing kind set, employer-side).
|
||||
_AUDIT_EVENT_KINDS = ("auth", "claim", "manual")
|
||||
#: v0.11 P2: "application" joins the vocabulary (employer-driven hiring
|
||||
#: transitions append their audit row — the D-089 one-queryable lane).
|
||||
_AUDIT_EVENT_KINDS = ("auth", "claim", "manual", "application")
|
||||
|
||||
|
||||
class AccountRow(SQLModel, table=True):
|
||||
|
||||
@@ -199,6 +199,18 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
||||
db_path=settings.db_path
|
||||
)
|
||||
|
||||
# Placement tracking (v0.11, REQ-11-001): 9th D-027 store
|
||||
# family on the same SQLite file (D-100: the transactional
|
||||
# transition writes the marketplace application row through the
|
||||
# shared metadata — one file, one commit). State-injection
|
||||
# override preserved (the same getattr-guard template).
|
||||
if getattr(app.state, "placement_store", None) is None:
|
||||
from .placements.store import SQLitePlacementStore
|
||||
|
||||
app.state.placement_store = SQLitePlacementStore(
|
||||
db_path=settings.db_path
|
||||
)
|
||||
|
||||
# Payment provider (v0.9 P3, REQ-9-003, D-075): mock-first
|
||||
# default; stripe over the shared httpx pool. G-11 (boot
|
||||
# survival): a misconfigured real provider must never crash the
|
||||
@@ -334,6 +346,7 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
||||
identity_store.close()
|
||||
app.state.billing_store.close()
|
||||
app.state.employer_store.close()
|
||||
app.state.placement_store.close()
|
||||
await app.state.http_client.aclose()
|
||||
|
||||
app = FastAPI(title="Nextcraft AI Service", version=__version__, lifespan=lifespan)
|
||||
@@ -410,6 +423,14 @@ def create_app(settings: Settings | None = None) -> FastAPI:
|
||||
# accounts/audit operator routes — require_admin (fail-closed).
|
||||
app.include_router(employers_admin_router)
|
||||
|
||||
# v0.11 placements (REQ-11-002, D-106): the operator lane — the
|
||||
# generic transition + revoke + the R-80 re-hire; the reporting/
|
||||
# export reads extend this router in P3/P4. require_admin
|
||||
# composed at the router level (fail-closed, D-090 no-collision).
|
||||
from .api.placements_admin import router as placements_admin_router
|
||||
|
||||
app.include_router(placements_admin_router)
|
||||
|
||||
# v0.6 P5 (REQ-6-006, D-056): dynamic sitemap/robots — request-time
|
||||
# enumeration (build-time Next sitemap.ts cannot see runtime
|
||||
# postings under output:'export'); API routes beat the static mount.
|
||||
|
||||
@@ -346,6 +346,33 @@ class MarketplaceStore(Protocol):
|
||||
— never a second application."""
|
||||
...
|
||||
|
||||
def get_application(self, application_id: str) -> Application | None:
|
||||
"""Point lookup by application id (D-100: the placements
|
||||
store's transition path reads the row through the shared
|
||||
metadata; this read serves routes + tests — the get_posting
|
||||
shape)."""
|
||||
...
|
||||
|
||||
def get_application_by_posting_learner(
|
||||
self, posting_id: str, learner_id: str
|
||||
) -> Application | None:
|
||||
"""v0.11 P5 (G-322, binding): the mine point-read over the
|
||||
(posting_id, learner_id) unique key — the status panel's
|
||||
application DISCOVERY on revisit; None = no application (the
|
||||
surface renders the apply CTA — never client-persisted
|
||||
application_ids)."""
|
||||
...
|
||||
|
||||
def list_applications_by_postings(
|
||||
self, posting_ids: list[str], status: str | None = None
|
||||
) -> list[Application]:
|
||||
"""v0.11 P2 (D-105/G-319): the employer pipeline's ONE batched
|
||||
read — every application on the given postings (the caller
|
||||
scopes to the session's OWN claimed-entity postings first;
|
||||
this read never enumerates beyond them), created_at DESC then
|
||||
id; optional status facet."""
|
||||
...
|
||||
|
||||
def list_applications(
|
||||
self, posting_id: str | None = None
|
||||
) -> list[Application]:
|
||||
@@ -900,6 +927,54 @@ class SQLiteMarketplaceStore:
|
||||
session.refresh(row)
|
||||
return row.to_application(), True
|
||||
|
||||
def get_application(self, application_id: str) -> Application | None:
|
||||
"""Point lookup by application id (D-100, the get_posting shape)."""
|
||||
with Session(self._engine) as session:
|
||||
row = session.get(ApplicationRow, application_id)
|
||||
if row is None:
|
||||
return None
|
||||
session.refresh(row)
|
||||
return row.to_application()
|
||||
|
||||
def get_application_by_posting_learner(
|
||||
self, posting_id: str, learner_id: str
|
||||
) -> Application | None:
|
||||
"""v0.11 P5 (G-322): the mine point-read — one row over the
|
||||
insert_application unique key (the get_posting point-read
|
||||
discipline)."""
|
||||
with Session(self._engine) as session:
|
||||
row = session.exec(
|
||||
select(ApplicationRow).where(
|
||||
ApplicationRow.posting_id == posting_id,
|
||||
ApplicationRow.learner_id == learner_id,
|
||||
)
|
||||
).one_or_none()
|
||||
if row is None:
|
||||
return None
|
||||
session.refresh(row)
|
||||
return row.to_application()
|
||||
|
||||
def list_applications_by_postings(
|
||||
self, posting_ids: list[str], status: str | None = None
|
||||
) -> list[Application]:
|
||||
"""v0.11 P2 (D-105/G-319): ONE batched read over the given
|
||||
postings — created_at DESC then id; optional status facet.
|
||||
Empty posting set → empty list (the honest no-postings case)."""
|
||||
if not posting_ids:
|
||||
return []
|
||||
with Session(self._engine) as session:
|
||||
stmt = select(ApplicationRow).where(
|
||||
col(ApplicationRow.posting_id).in_(posting_ids)
|
||||
)
|
||||
if status is not None:
|
||||
stmt = stmt.where(ApplicationRow.status == status)
|
||||
rows = session.exec(
|
||||
stmt.order_by(
|
||||
col(ApplicationRow.created_at).desc(), ApplicationRow.id
|
||||
)
|
||||
).all()
|
||||
return [r.to_application() for r in rows]
|
||||
|
||||
def list_applications(
|
||||
self, posting_id: str | None = None
|
||||
) -> list[Application]:
|
||||
|
||||
@@ -0,0 +1,380 @@
|
||||
"""Placement contracts: the D-099 hiring-state matrix, the D-101
|
||||
event vocabulary + GENESIS, and the placement wire shapes (REQ-11-001).
|
||||
|
||||
Boundary (D-031 family): `placements/` never imports `api/`; stdlib +
|
||||
pydantic + the marketplace WIRE contracts only. The store imports the
|
||||
marketplace ROW (the sanctioned D-100 cross-family seam — one
|
||||
transaction covers both tables on the shared DB file).
|
||||
|
||||
Id honesty (A-305, the family rule): every id in this module is an
|
||||
opaque handle minted UNIQUELY per creation call — never derived from
|
||||
user input or a deterministic hash of it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
#: D-099 application status vocabulary (snake_case, the billing
|
||||
#: `past_due` precedent): the hiring lifecycle. `submitted` is the
|
||||
#: v0.6 status (zero migration); hired/rejected/withdrawn are TERMINAL.
|
||||
ApplicationStatus = Literal[
|
||||
"submitted",
|
||||
"in_review",
|
||||
"interview",
|
||||
"offer",
|
||||
"hired",
|
||||
"rejected",
|
||||
"withdrawn",
|
||||
]
|
||||
|
||||
#: Tuple mirror of the Literal (docs + tests import this).
|
||||
APPLICATION_STATUSES: tuple[ApplicationStatus, ...] = (
|
||||
"submitted",
|
||||
"in_review",
|
||||
"interview",
|
||||
"offer",
|
||||
"hired",
|
||||
"rejected",
|
||||
"withdrawn",
|
||||
)
|
||||
|
||||
#: Terminal statuses — no outgoing edges (D-099).
|
||||
TERMINAL_APPLICATION_STATUSES: tuple[ApplicationStatus, ...] = (
|
||||
"hired",
|
||||
"rejected",
|
||||
"withdrawn",
|
||||
)
|
||||
|
||||
#: D-099 transition matrix (the billing/store.py `_VALID_TRANSITIONS`
|
||||
#: shape): forward-only WITH skip-forward — from any non-terminal
|
||||
#: status, any LATER stage and any terminal are legal (a fast-track
|
||||
#: `submitted → hired` is a legal hire — CIRR counts the hire, not the
|
||||
#: ceremony); NO backward moves; terminals have empty tuples.
|
||||
TRANSITION_MATRIX: dict[str, tuple[str, ...]] = {
|
||||
"submitted": (
|
||||
"in_review",
|
||||
"interview",
|
||||
"offer",
|
||||
"hired",
|
||||
"rejected",
|
||||
"withdrawn",
|
||||
),
|
||||
"in_review": ("interview", "offer", "hired", "rejected", "withdrawn"),
|
||||
"interview": ("offer", "hired", "rejected", "withdrawn"),
|
||||
"offer": ("hired", "rejected", "withdrawn"),
|
||||
"hired": (),
|
||||
"rejected": (),
|
||||
"withdrawn": (),
|
||||
}
|
||||
|
||||
#: Placement lifecycle (D-102): created `active`; the ONLY transition
|
||||
#: is the operator correction `active → revoked`. Revoked rows stay in
|
||||
#: history (the partial unique index never touches them — re-hire
|
||||
#: creates a fresh row, R-80).
|
||||
PlacementStatus = Literal["active", "revoked"]
|
||||
PLACEMENT_STATUSES: tuple[PlacementStatus, ...] = ("active", "revoked")
|
||||
|
||||
#: D-101 actor vocabulary (the billing `_BILLING_EVENT_KINDS` shape).
|
||||
ACTOR_TYPES: tuple[str, ...] = ("employer", "learner", "operator", "system")
|
||||
|
||||
#: D-101 event-name vocabulary: application-stage events (one per
|
||||
#: transition — NO backfill: pre-v0.11 `submitted` rows carry only
|
||||
#: their created_at, never a fabricated event, A-304) + the placement
|
||||
#: lane (created on hired, revoked on the D-102 correction, rehired
|
||||
#: on the R-80 operator re-hire).
|
||||
EVENT_APPLICATION_TRANSITION: dict[str, str] = {
|
||||
"in_review": "application.in_review",
|
||||
"interview": "application.interview",
|
||||
"offer": "application.offer",
|
||||
"hired": "application.hired",
|
||||
"rejected": "application.rejected",
|
||||
"withdrawn": "application.withdrawn",
|
||||
}
|
||||
EVENT_PLACEMENT_CREATED = "placement.created"
|
||||
EVENT_PLACEMENT_REVOKED = "placement.revoked"
|
||||
EVENT_PLACEMENT_REHIRED = "placement.rehired"
|
||||
EVENT_NAMES: tuple[str, ...] = (
|
||||
*EVENT_APPLICATION_TRANSITION.values(),
|
||||
EVENT_PLACEMENT_CREATED,
|
||||
EVENT_PLACEMENT_REVOKED,
|
||||
EVENT_PLACEMENT_REHIRED,
|
||||
)
|
||||
|
||||
#: D-101: the chain's genesis — the first event's prev_hash. A module
|
||||
#: constant, test-pinned (never a row — the walk starts here).
|
||||
GENESIS_HASH = "0" * 64
|
||||
|
||||
|
||||
class InvalidTransition(ValueError):
|
||||
"""A matrix-illegal application status move (D-099) or placement
|
||||
status move (D-102) — the route's honest 409."""
|
||||
|
||||
|
||||
def is_terminal(status: str) -> bool:
|
||||
"""True when the application status has no outgoing edges."""
|
||||
return status in TERMINAL_APPLICATION_STATUSES
|
||||
|
||||
|
||||
def validate_transition(from_status: str, to_status: str) -> None:
|
||||
"""D-099 matrix validation — raises InvalidTransition on any
|
||||
illegal move (backward, terminal-source, unknown status)."""
|
||||
if from_status not in TRANSITION_MATRIX:
|
||||
raise InvalidTransition(f"unknown application status {from_status!r}")
|
||||
if to_status not in APPLICATION_STATUSES:
|
||||
raise InvalidTransition(f"unknown application status {to_status!r}")
|
||||
if to_status not in TRANSITION_MATRIX[from_status]:
|
||||
legal = TRANSITION_MATRIX[from_status] or "(terminal)"
|
||||
raise InvalidTransition(
|
||||
f"illegal transition {from_status!r} → {to_status!r} "
|
||||
f"(legal: {legal})"
|
||||
)
|
||||
|
||||
|
||||
class HireTransitionRequest(BaseModel):
|
||||
"""The hire body (D-103/G-324): role_title is employer-attested
|
||||
free text — never silently the posting title — with the house
|
||||
Field bounds (empty-string → 422); start_date is the employment
|
||||
start DATE (first day of work), never a datetime: timezone-free by
|
||||
construction, future + past allowed (D-103)."""
|
||||
|
||||
role_title: str = Field(min_length=1, max_length=200)
|
||||
start_date: date
|
||||
|
||||
|
||||
class PlacementRead(BaseModel):
|
||||
"""A placement record over the wire (the PlacementRow read shape).
|
||||
|
||||
employer/learner/posting are ID REFERENCES (G-302) — resolved from
|
||||
source rows at read/export, never trusted denormalized copies."""
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
id: str = Field(min_length=1)
|
||||
learner_id: str = Field(min_length=1)
|
||||
employer_id: str = Field(min_length=1)
|
||||
posting_id: str = Field(min_length=1)
|
||||
application_id: str = Field(min_length=1)
|
||||
role_title: str = Field(min_length=1)
|
||||
start_date: date
|
||||
status: PlacementStatus
|
||||
created_at: datetime
|
||||
decided_at: datetime
|
||||
|
||||
|
||||
class PlacementEventRead(BaseModel):
|
||||
"""One ledger event over the wire (the PlacementEventRow read
|
||||
shape) — application-stage events carry application_id with
|
||||
placement_id None; the placement lane carries placement_id set."""
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
id: int
|
||||
application_id: str | None = None
|
||||
placement_id: str | None = None
|
||||
event_type: str = Field(min_length=1)
|
||||
actor_type: str = Field(min_length=1)
|
||||
actor_ref: str = Field(min_length=1)
|
||||
payload: dict[str, Any] = Field(default_factory=dict)
|
||||
prev_hash: str = Field(min_length=1)
|
||||
hash: str = Field(min_length=1)
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class EmployerApplicationSummary(BaseModel):
|
||||
"""An employer-facing application row (D-105/G-319): the pipeline
|
||||
list + per-application reads. learner_id IS visible here — the
|
||||
G-319 supersession scopes it to applications on the session's OWN
|
||||
claimed-entity postings (the cross-entity arm 403s carry no data;
|
||||
the own-postings LIST stays counts-only — the pin split)."""
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
id: str = Field(min_length=1)
|
||||
posting_id: str = Field(min_length=1)
|
||||
learner_id: str = Field(min_length=1)
|
||||
status: ApplicationStatus
|
||||
created_at: datetime | None = None
|
||||
|
||||
|
||||
class EmployerApplicationsResponse(BaseModel):
|
||||
"""GET /v1/employers/applications response: own-pipeline
|
||||
applications (every status) + per-status facet counts (every
|
||||
status key ALWAYS present — a zero-count status carries its
|
||||
honest 0)."""
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
applications: list[EmployerApplicationSummary] = Field(default_factory=list)
|
||||
facets: dict[str, int] = Field(default_factory=dict)
|
||||
total: int = 0
|
||||
|
||||
|
||||
class ApplicationTransitionResponse(BaseModel):
|
||||
"""The transition result (per-target routes): the application's
|
||||
new status + the placement record when the transition created
|
||||
one (hire)."""
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
application_id: str = Field(min_length=1)
|
||||
status: ApplicationStatus
|
||||
placement: PlacementRead | None = None
|
||||
|
||||
|
||||
class AdminTransitionRequest(BaseModel):
|
||||
"""POST /v1/placements/admin/applications/{id}/transition body
|
||||
(D-106/G-327): `to` is a CLOSED Literal of the seven D-099
|
||||
statuses — unknown values die at pydantic (422), matrix-illegal
|
||||
known values reach the store (409 InvalidTransition); the route
|
||||
never sees a status outside the vocabulary. role_title/start_date
|
||||
are required iff to=hired (enforced at the route, the honest 422
|
||||
with required-fields detail)."""
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
to: ApplicationStatus
|
||||
role_title: str | None = Field(default=None, min_length=1, max_length=200)
|
||||
start_date: date | None = None
|
||||
|
||||
|
||||
#: D-107: the guiding outcome's north star (PROJECT.md line 7 — the
|
||||
#: noun is LEARNERS, not placements).
|
||||
GUIDING_GOAL = 1000
|
||||
|
||||
#: D-108/G-321/G-325 (R-67/R-68): the export's methodology block —
|
||||
#: written from in-tree facts, stating its own three pilot gaps
|
||||
#: explicitly (an audit methodology that omits its own verification
|
||||
#: limits is A-304-class dishonesty) AND the chain's proof scope
|
||||
#: (G-321: what it proves, what it does not — wholesale DB replacement
|
||||
#: is undetectable by the walk alone; export-to-export continuity is
|
||||
#: the auditor's anchor — plus the event↔row cross-consistency check).
|
||||
#: CIRR kept as naming, not citation — the text stands on its own
|
||||
#: definitions (R-67).
|
||||
EXPORT_METHODOLOGY: dict[str, Any] = {
|
||||
"placement_definition": (
|
||||
"A placement is a `placement` row created by the hired transition "
|
||||
"of a marketplace application, with an employer-attested role_title "
|
||||
"and start_date (the employment start DATE, D-103), status=active "
|
||||
"at export time. One ACTIVE placement per application is "
|
||||
"structurally enforced (the partial unique index); revoked "
|
||||
"placements stay in history and are excluded from every count."
|
||||
),
|
||||
"counting_rules": (
|
||||
"The guiding metric is DISTINCT learners with at least one active "
|
||||
"placement, all-time cumulative toward the 1,000-learner guiding "
|
||||
"goal (a learner placed twice counts ONCE — the outcome's noun is "
|
||||
"learners, not placements). Revoked placements are excluded from "
|
||||
"every count. Cohort month is the calendar month of start_date "
|
||||
"(YYYY-MM) — the employment-start month, not a graduation cohort "
|
||||
"(no graduation events exist in a marketplace; this divergence "
|
||||
"from CIRR cohort convention is deliberate and documented)."
|
||||
),
|
||||
"verification": (
|
||||
"WHAT THE CHAIN PROVES: the global sha256 ledger (events ordered "
|
||||
"by id, each hash covering the canonical JSON of its row + the "
|
||||
"prior event's hash, from the GENESIS constant to the head) "
|
||||
"detects in-place mutation of any event row and makes a chain "
|
||||
"fork structurally impossible (UNIQUE(prev_hash)). WHAT IT DOES "
|
||||
"NOT PROVE: a wholesale replacement of the database (delete every "
|
||||
"event, re-append a fresh plausible chain from GENESIS) walks "
|
||||
"green — the walk alone cannot detect it. THE AUDITOR'S ANCHOR "
|
||||
"is export-to-export continuity: a prior export's ledger.events "
|
||||
"must appear as an exact PREFIX of a later export's events, and "
|
||||
"the prior export's head_hash must equal the hash at that prefix "
|
||||
"position in the later export. EVENT↔ROW CROSS-CONSISTENCY: "
|
||||
"every active placement must have a placement.created event; "
|
||||
"every revoked placement a placement.revoked event; summary."
|
||||
"revoked_count must equal the number of placement.revoked "
|
||||
"events. EVIDENCE CLASS: verification is employer-principal "
|
||||
"attestation (an authenticated hire by a claim-verified, "
|
||||
"operator-approved employer principal) plus operator oversight — "
|
||||
"recorded decisions, not third-party employment verification."
|
||||
),
|
||||
"scope": (
|
||||
"PILOT LIMITS, stated explicitly (R-67): (a) no minimum-employed-"
|
||||
"duration check — a placement here is a RECORDED HIRE, not a "
|
||||
"90-day sustained role; (b) no salary or compensation tracking "
|
||||
"(out of scope by decision D-098); (c) verification is employer-"
|
||||
"attested with operator oversight, NOT third-party employment "
|
||||
"verification — no external auditor integration exists at "
|
||||
"pilot; this export IS the third-party-auditor deliverable. "
|
||||
"Coverage: every placement in the database, unbounded by date. "
|
||||
"CIRR-style describes this export's format; the definitions "
|
||||
"above are the operative ones."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
class PlacementOverview(BaseModel):
|
||||
"""GET /v1/placements/admin/overview response (D-107): counts
|
||||
computed from rows at query time — NEVER denormalized counters
|
||||
(G-302/G-306). The guiding metric is DISTINCT learners with ≥1
|
||||
active placement (a learner placed twice counts ONCE); revoked
|
||||
excluded from every count; cohort month = the calendar month of
|
||||
start_date (YYYY-MM, employment-start month — not hired month)."""
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
total_active_placements: int = 0
|
||||
distinct_learners_placed: int = 0
|
||||
guiding_goal: int = GUIDING_GOAL
|
||||
by_employer: list[dict[str, Any]] = Field(default_factory=list)
|
||||
by_cohort_month: list[dict[str, Any]] = Field(default_factory=list)
|
||||
|
||||
|
||||
class EmployerPlacementsResponse(BaseModel):
|
||||
"""GET /v1/employers/placements response (D-109 surface 2's data
|
||||
source): the claimed entity's OWN placements (active + revoked —
|
||||
the employer sees their full history), created_at then id."""
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
placements: list[PlacementRead] = Field(default_factory=list)
|
||||
total: int = 0
|
||||
|
||||
|
||||
class LearnerApplicationSummary(BaseModel):
|
||||
"""The application row on the learner status read (D-104,
|
||||
REQ-11-005): the mine-visible fields only — ids + status + the
|
||||
apply timestamp (nullable per the v0.6 row shape — honest
|
||||
absence); references resolve at render (G-302), never
|
||||
denormalized copies."""
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
id: str = Field(min_length=1)
|
||||
posting_id: str = Field(min_length=1)
|
||||
learner_id: str = Field(min_length=1)
|
||||
status: ApplicationStatus
|
||||
created_at: datetime | None = None
|
||||
|
||||
|
||||
class LearnerApplicationStatusResponse(BaseModel):
|
||||
"""GET /v1/marketplace/applications/{application_id}?learner_id=
|
||||
response (REQ-11-005, D-104): current status + the FULL id-ordered
|
||||
timestamped event history for THIS application (a scoped query —
|
||||
never another learner's events) + the placement record when hired
|
||||
(the honest null otherwise — never a fabricated placement, A-304)."""
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
application: LearnerApplicationSummary
|
||||
events: list[PlacementEventRead] = Field(default_factory=list)
|
||||
placement: PlacementRead | None = None
|
||||
|
||||
|
||||
class MineApplicationResponse(BaseModel):
|
||||
"""GET /v1/marketplace/applications/mine?posting_id=&learner_id=
|
||||
response (G-322, binding): {application_id, status} — the status
|
||||
panel's application DISCOVERY read on revisit; 404 = no application
|
||||
on this posting (the surface renders the apply CTA — the panel
|
||||
NEVER depends on client-persisted application_ids)."""
|
||||
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
application_id: str = Field(min_length=1)
|
||||
status: ApplicationStatus
|
||||
@@ -0,0 +1,912 @@
|
||||
"""Placement persistence — the 9th D-027 store (REQ-11-001, D-112).
|
||||
|
||||
TWO tables on the shared DB file (R-72): `placement` (immutable at
|
||||
creation except status — D-102) + `placement_event` (the append-only
|
||||
GLOBAL hash chain — D-101). Mechanics mirror employers/store.py
|
||||
exactly (pragmas, _validate/_normalize, Protocol + SQLite pair,
|
||||
surfaced IntegrityError, _constraint_error helper).
|
||||
|
||||
NO DELETE PATH (R-70, binding): this module contains NO delete method
|
||||
and NO DELETE SQL — deleting the top ledger row would make SQLite's
|
||||
INTEGER PRIMARY KEY reuse max+1 and break the chain. Pinned by test.
|
||||
|
||||
STATUS-COLUMN OWNERSHIP (R-78, binding): the marketplace store writes
|
||||
`application.status` EXACTLY ONCE at creation (insert_application);
|
||||
THIS store is the ONLY post-creation writer — via the ONE
|
||||
`transition_application` method, never a second append path (D-100:
|
||||
the status write + the ledger event + the placement-on-hired land in
|
||||
ONE transaction; a two-commit alternative leaves a crash window that
|
||||
voids the tamper-evidence this milestone sells).
|
||||
|
||||
Canonical serialization (D-101/R-69/R-79, binding): the hash covers
|
||||
the canonical JSON of {id, application_id, placement_id, event_type,
|
||||
actor_type, actor_ref, payload, created_at} + prev_hash — sorted keys,
|
||||
compact separators, created_at ALWAYS `_as_utc(dt).isoformat()` (ONE
|
||||
pinned normalization: naive-in-SQLite vs aware-on-read must never
|
||||
drift), payload values str/int/bool/None only (floats REJECTED —
|
||||
R-69).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import sqlite3
|
||||
import uuid
|
||||
from datetime import UTC, date, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Protocol
|
||||
|
||||
from sqlalchemy import Index, event, func, text
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.types import JSON, String
|
||||
from sqlmodel import Field, Session, SQLModel, col, create_engine, select
|
||||
|
||||
from ..config import Settings
|
||||
from ..marketplace.store import ApplicationRow
|
||||
from .base import (
|
||||
ACTOR_TYPES,
|
||||
EVENT_APPLICATION_TRANSITION,
|
||||
EVENT_PLACEMENT_CREATED,
|
||||
EVENT_PLACEMENT_REHIRED,
|
||||
EVENT_PLACEMENT_REVOKED,
|
||||
EXPORT_METHODOLOGY,
|
||||
GENESIS_HASH,
|
||||
InvalidTransition,
|
||||
validate_transition,
|
||||
)
|
||||
|
||||
_PLACEMENT_ID_PREFIX = "plc-"
|
||||
|
||||
|
||||
def _as_utc(ts: datetime) -> datetime:
|
||||
"""SQLite stores naive; read paths re-label tz-aware UTC (D-027
|
||||
pattern). Aware NON-UTC inputs CONVERT to the true instant (the
|
||||
security review's P0-1: re-labeling preserves the offset — an
|
||||
append-time canonical form of 12:00-04:00 would re-read as
|
||||
12:00+00:00 and permanently break the chain, with no delete path
|
||||
to recover)."""
|
||||
if ts.tzinfo is None:
|
||||
return ts.replace(tzinfo=UTC)
|
||||
return ts.astimezone(UTC)
|
||||
|
||||
|
||||
def _constraint_error(message: str, params: dict[str, Any]) -> IntegrityError:
|
||||
"""Hand-raised IntegrityError (the guard arms) — the caller's
|
||||
honest 409, never a silent overwrite."""
|
||||
return IntegrityError(message, params, ValueError(message))
|
||||
|
||||
|
||||
def _sqlite_connect(dbapi_connection: sqlite3.Connection, _: object) -> None:
|
||||
"""Per-connection pragmas — mirrors the other D-027 stores."""
|
||||
cursor = dbapi_connection.cursor()
|
||||
cursor.execute("PRAGMA journal_mode=WAL")
|
||||
cursor.execute("PRAGMA synchronous=NORMAL")
|
||||
cursor.execute("PRAGMA busy_timeout=5000")
|
||||
cursor.execute("PRAGMA foreign_keys=ON")
|
||||
cursor.close()
|
||||
|
||||
|
||||
def _check_payload(value: Any) -> None:
|
||||
"""R-69: payload values are str/int/bool/None ONLY — floats (and
|
||||
any nested container) are rejected at the store boundary: float
|
||||
representation is a canonical-serialization drift the chain must
|
||||
never depend on."""
|
||||
if isinstance(value, bool) or value is None:
|
||||
return
|
||||
if isinstance(value, str):
|
||||
return
|
||||
if isinstance(value, int):
|
||||
return
|
||||
raise ValueError(
|
||||
f"payload values must be str/int/bool/None, got {type(value).__name__}"
|
||||
)
|
||||
|
||||
|
||||
class PlacementRow(SQLModel, table=True):
|
||||
"""A placement record (D-102): created by the hired transition,
|
||||
IMMUTABLE except `status` (exactly one transition active → revoked,
|
||||
operator-only by route). role_title/start_date/decided_at and the
|
||||
four ID references are set once and NEVER rewritten — a silent
|
||||
rewrite is exactly the tamper the ledger exists to detect.
|
||||
|
||||
G-316 shape: the partial UNIQUE index uq_active_placement_per_
|
||||
application makes one-ACTIVE-placement-per-application a
|
||||
STRUCTURAL guard — revoked rows stay in history and never match
|
||||
the partial WHERE, so a re-hire after a mistaken revoke creates a
|
||||
fresh row (R-80) and an operator fat-finger never permanently
|
||||
erases a learner from the ≥1,000 count.
|
||||
"""
|
||||
|
||||
__tablename__ = "placement"
|
||||
__table_args__ = (
|
||||
Index(
|
||||
"uq_active_placement_per_application",
|
||||
"application_id",
|
||||
unique=True,
|
||||
sqlite_where=text("status='active'"),
|
||||
),
|
||||
)
|
||||
|
||||
id: str = Field(primary_key=True, sa_type=String)
|
||||
learner_id: str = Field(index=True, sa_type=String)
|
||||
employer_id: str = Field(sa_type=String)
|
||||
#: G-302: ID references resolved from source rows at read/export.
|
||||
posting_id: str = Field(sa_type=String)
|
||||
application_id: str = Field(index=True, sa_type=String)
|
||||
role_title: str = Field(sa_type=String)
|
||||
#: D-103: ISO YYYY-MM-DD — a calendar date, no datetime coercion.
|
||||
start_date: str = Field(sa_type=String)
|
||||
status: str = Field(default="active", sa_type=String)
|
||||
created_at: datetime | None = Field(default=None)
|
||||
#: D-102: the hired transition's store-stamped created_at (G-326:
|
||||
#: the re-hire arm stamps the RE-HIRE event's created_at).
|
||||
decided_at: datetime | None = Field(default=None)
|
||||
|
||||
def _validate(self) -> None:
|
||||
if not self.id:
|
||||
raise ValueError("id must be non-empty")
|
||||
for field_name in (
|
||||
"learner_id",
|
||||
"employer_id",
|
||||
"posting_id",
|
||||
"application_id",
|
||||
"role_title",
|
||||
"start_date",
|
||||
):
|
||||
if not getattr(self, field_name):
|
||||
raise ValueError(f"{field_name} must be non-empty")
|
||||
if self.status not in ("active", "revoked"):
|
||||
raise ValueError(f"invalid placement status {self.status!r}")
|
||||
|
||||
def _normalize(self) -> None:
|
||||
self.created_at = _as_utc(self.created_at) if self.created_at else None
|
||||
self.decided_at = _as_utc(self.decided_at) if self.decided_at else None
|
||||
|
||||
|
||||
class PlacementEventRow(SQLModel, table=True):
|
||||
"""One append-only ledger event (D-101): ONE GLOBAL chain ordered
|
||||
by autoincrement id. Each row stores prev_hash (the prior event's
|
||||
hash; GENESIS_HASH for the first) + its own hash = sha256 over the
|
||||
canonical JSON + prev_hash. UNIQUE(prev_hash) makes a chain fork
|
||||
structurally impossible — two events claiming the same
|
||||
predecessor surface IntegrityError (the G-316 backstop class).
|
||||
|
||||
Application-stage events carry application_id (placement_id
|
||||
None); the placement lane (created/revoked/rehired) carries
|
||||
placement_id set from hired onward.
|
||||
"""
|
||||
|
||||
__tablename__ = "placement_event"
|
||||
|
||||
id: int | None = Field(default=None, primary_key=True)
|
||||
application_id: str | None = Field(default=None, index=True, sa_type=String)
|
||||
placement_id: str | None = Field(default=None, sa_type=String)
|
||||
event_type: str = Field(sa_type=String)
|
||||
actor_type: str = Field(sa_type=String)
|
||||
actor_ref: str = Field(sa_type=String)
|
||||
payload: dict[str, Any] = Field(default_factory=dict, sa_type=JSON)
|
||||
prev_hash: str = Field(unique=True, sa_type=String)
|
||||
hash: str = Field(default="", sa_type=String)
|
||||
created_at: datetime | None = Field(default=None)
|
||||
|
||||
def _validate(self) -> None:
|
||||
if self.id is not None and self.id < 1:
|
||||
raise ValueError("id must be a positive int when set")
|
||||
if not self.event_type:
|
||||
raise ValueError("event_type must be non-empty")
|
||||
if self.actor_type not in ACTOR_TYPES:
|
||||
raise ValueError(f"unknown actor_type {self.actor_type!r}")
|
||||
if not self.actor_ref:
|
||||
raise ValueError("actor_ref must be non-empty")
|
||||
if not self.prev_hash:
|
||||
raise ValueError("prev_hash must be non-empty")
|
||||
for value in self.payload.values():
|
||||
_check_payload(value)
|
||||
|
||||
def _normalize(self) -> None:
|
||||
self.created_at = _as_utc(self.created_at) if self.created_at else None
|
||||
|
||||
def canonical_json(self) -> str:
|
||||
"""D-101/R-79: the canonical serialization — sorted keys,
|
||||
compact separators, created_at ALWAYS `_as_utc(dt).isoformat()`
|
||||
(ONE pinned normalization; the hashing and reading paths share
|
||||
this function so they can never drift)."""
|
||||
return json.dumps(
|
||||
{
|
||||
"id": self.id,
|
||||
"application_id": self.application_id,
|
||||
"placement_id": self.placement_id,
|
||||
"event_type": self.event_type,
|
||||
"actor_type": self.actor_type,
|
||||
"actor_ref": self.actor_ref,
|
||||
"payload": self.payload,
|
||||
"created_at": (
|
||||
_as_utc(self.created_at).isoformat()
|
||||
if self.created_at is not None
|
||||
else None
|
||||
),
|
||||
},
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
)
|
||||
|
||||
|
||||
def _compute_hash(canonical_json: str, prev_hash: str) -> str:
|
||||
"""sha256 over the canonical JSON + prev_hash (D-101 — the D-087
|
||||
stdlib sha256 precedent; hexdigest)."""
|
||||
return hashlib.sha256((canonical_json + prev_hash).encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
class PlacementStore(Protocol):
|
||||
"""Persistence contract for placements (D-101/D-102/D-112)."""
|
||||
|
||||
def create_schema(self) -> None:
|
||||
"""Ensure both tables exist (idempotent — shared DB file)."""
|
||||
...
|
||||
|
||||
def transition_application(
|
||||
self,
|
||||
application_id: str,
|
||||
to_status: str,
|
||||
actor_type: str,
|
||||
actor_ref: str,
|
||||
role_title: str | None = None,
|
||||
start_date: date | None = None,
|
||||
now: datetime | None = None,
|
||||
) -> tuple[ApplicationRow, PlacementRow | None]:
|
||||
"""D-100: the ONE transactional transition — update the
|
||||
marketplace application row + append the ledger event + create
|
||||
the PlacementRow on hired, ALL IN ONE COMMIT. One append per
|
||||
transaction per lane (transition/revoke/rehire each append
|
||||
exactly once); R-70: the appends race nothing — the chain's
|
||||
prev_hash read-then-write is single-threaded-safe through
|
||||
SQLite's writer serialization + the UNIQUE backstop."""
|
||||
...
|
||||
|
||||
def verify_chain(self) -> tuple[bool, str]:
|
||||
"""Recompute the walk from GENESIS_HASH → head; (ok, head_hash)."""
|
||||
...
|
||||
|
||||
def get_placement(self, placement_id: str) -> PlacementRow | None:
|
||||
...
|
||||
|
||||
def get_placement_by_application(
|
||||
self, application_id: str
|
||||
) -> PlacementRow | None:
|
||||
"""v0.11 P5 (REQ-11-005): the learner status read's placement
|
||||
arm — the ACTIVE placement on this application (the G-316
|
||||
partial-unique shape: at most one; revoked-only history →
|
||||
None, the honest null — the events carry the correction)."""
|
||||
...
|
||||
|
||||
def list_placements(self, status: str | None = None) -> list[PlacementRow]:
|
||||
...
|
||||
|
||||
def get_application_events(self, application_id: str) -> list[PlacementEventRow]:
|
||||
...
|
||||
|
||||
def list_events(self) -> list[PlacementEventRow]:
|
||||
...
|
||||
|
||||
def revoke_placement(
|
||||
self, placement_id: str, actor_ref: str, now: datetime | None = None
|
||||
) -> PlacementRow | None:
|
||||
"""D-102: the one status-only mutation active → revoked + the
|
||||
placement.revoked event, one transaction (operator-only by
|
||||
route)."""
|
||||
...
|
||||
|
||||
def rehire_placement(
|
||||
self,
|
||||
application_id: str,
|
||||
role_title: str,
|
||||
start_date: date,
|
||||
actor_ref: str,
|
||||
now: datetime | None = None,
|
||||
) -> PlacementRow:
|
||||
"""R-80/G-326: a FRESH PlacementRow + placement.rehired event
|
||||
WITHOUT touching the application's terminal status — the
|
||||
correction lane. decided_at = the re-hire event's created_at.
|
||||
An existing ACTIVE placement on the application surfaces its
|
||||
IntegrityError (the caller's honest 409)."""
|
||||
...
|
||||
|
||||
def list_placements_by_employer(
|
||||
self, employer_id: str
|
||||
) -> list[PlacementRow]:
|
||||
"""v0.11 P3 (D-109): the claimed entity's OWN placements
|
||||
(active + revoked — the employer sees their full history),
|
||||
created_at then id (the deterministic order)."""
|
||||
...
|
||||
|
||||
def build_export(self, now: datetime | None = None) -> dict[str, Any]:
|
||||
"""v0.11 P4 (D-108/G-321/G-325): the self-contained audit
|
||||
document — {schema_version, generated_at, summary, cohort,
|
||||
evidence_bundles, methodology, ledger} — resolved from SOURCE
|
||||
ROWS at export time (G-302 snapshot-at-export). The summary
|
||||
route renders FROM this dict (one truth source)."""
|
||||
...
|
||||
|
||||
def close(self) -> None:
|
||||
...
|
||||
|
||||
|
||||
class SQLitePlacementStore:
|
||||
"""SQLite implementation of PlacementStore (D-027/D-112)."""
|
||||
|
||||
def __init__(self, db_path: Path | None = None) -> None:
|
||||
self._db_path: Path = db_path if db_path is not None else Settings().db_path
|
||||
self._engine = create_engine(
|
||||
f"sqlite:///{self._db_path}",
|
||||
connect_args={"check_same_thread": False},
|
||||
)
|
||||
event.listen(self._engine, "connect", _sqlite_connect)
|
||||
self.create_schema()
|
||||
|
||||
def create_schema(self) -> None:
|
||||
"""Ensure both tables exist (idempotent — shared DB file)."""
|
||||
SQLModel.metadata.create_all(self._engine)
|
||||
|
||||
# -- chain core (D-101/R-70) -------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _chain_head(session: Session) -> tuple[int | None, str]:
|
||||
"""(last event id, its hash) or (None, GENESIS_HASH) — the
|
||||
next append's prev_hash source."""
|
||||
row = session.exec(
|
||||
select(PlacementEventRow).order_by(col(PlacementEventRow.id).desc())
|
||||
).first()
|
||||
if row is None or row.hash == "":
|
||||
return None, GENESIS_HASH
|
||||
return row.id, row.hash
|
||||
|
||||
@staticmethod
|
||||
def _append_event(
|
||||
session: Session,
|
||||
*,
|
||||
application_id: str | None,
|
||||
placement_id: str | None,
|
||||
event_type: str,
|
||||
actor_type: str,
|
||||
actor_ref: str,
|
||||
payload: dict[str, Any] | None,
|
||||
prev_hash: str,
|
||||
created_at: datetime,
|
||||
) -> PlacementEventRow:
|
||||
"""R-70: flush-then-hash-then-commit — the row's own
|
||||
autoincrement id participates in its own hash, so the append
|
||||
flushes (id assigned), computes the hash, sets the column, and
|
||||
flushes again. The CALLER owns the transaction (D-100: the
|
||||
status write + the events + the placement land in one commit);
|
||||
this helper never commits."""
|
||||
row = PlacementEventRow(
|
||||
application_id=application_id,
|
||||
placement_id=placement_id,
|
||||
event_type=event_type,
|
||||
actor_type=actor_type,
|
||||
actor_ref=actor_ref,
|
||||
payload=dict(payload or {}),
|
||||
prev_hash=prev_hash,
|
||||
hash="",
|
||||
created_at=created_at,
|
||||
)
|
||||
row._validate()
|
||||
row._normalize()
|
||||
session.add(row)
|
||||
# flush #1: the autoincrement id is assigned
|
||||
session.flush()
|
||||
assert row.id is not None # flush assigns it
|
||||
# the hash covers the canonical JSON + prev_hash
|
||||
row.hash = _compute_hash(row.canonical_json(), row.prev_hash)
|
||||
# flush #2: the hash column lands
|
||||
session.flush()
|
||||
return row
|
||||
|
||||
def verify_chain(self) -> tuple[bool, str]:
|
||||
"""Recompute the walk GENESIS_HASH → head (R-70: O(n) sha256
|
||||
over tiny rows — milliseconds at pilot scale)."""
|
||||
with Session(self._engine) as session:
|
||||
prev = GENESIS_HASH
|
||||
last_hash = GENESIS_HASH
|
||||
rows = session.exec(select(PlacementEventRow).order_by(col(PlacementEventRow.id))).all()
|
||||
for row in rows:
|
||||
row._normalize()
|
||||
expected = _compute_hash(row.canonical_json(), prev)
|
||||
if row.prev_hash != prev or row.hash != expected:
|
||||
return False, last_hash
|
||||
prev = row.hash
|
||||
last_hash = row.hash
|
||||
return True, last_hash
|
||||
|
||||
# -- the ONE transition (D-100) ------------------------------------------
|
||||
|
||||
def transition_application(
|
||||
self,
|
||||
application_id: str,
|
||||
to_status: str,
|
||||
actor_type: str,
|
||||
actor_ref: str,
|
||||
role_title: str | None = None,
|
||||
start_date: date | None = None,
|
||||
now: datetime | None = None,
|
||||
) -> tuple[ApplicationRow, PlacementRow | None]:
|
||||
"""D-100: ONE Session on the placements engine covering BOTH
|
||||
tables + the marketplace application row (the sanctioned
|
||||
cross-family write — R-72: global SQLModel metadata means the
|
||||
placements engine sees ApplicationRow; both stores share the
|
||||
one SQLite file, so one commit covers everything atomically,
|
||||
the decide_claim all-or-nothing precedent).
|
||||
|
||||
R-78: this is the ONLY post-creation writer of
|
||||
application.status — never a second caller.
|
||||
"""
|
||||
moment = _as_utc(now) if now is not None else datetime.now(UTC)
|
||||
with Session(self._engine) as session:
|
||||
application = session.get(ApplicationRow, application_id)
|
||||
if application is None:
|
||||
raise InvalidTransition(f"unknown application {application_id!r}")
|
||||
validate_transition(application.status, to_status)
|
||||
if to_status == "hired":
|
||||
if not role_title:
|
||||
raise ValueError("role_title is required on hire (D-103)")
|
||||
if start_date is None:
|
||||
raise ValueError("start_date is required on hire (D-103)")
|
||||
|
||||
# the status write (the only post-creation writer — R-78);
|
||||
# the pre-transition status is captured BEFORE the mutation
|
||||
from_status = application.status
|
||||
application.status = to_status
|
||||
application._normalize()
|
||||
|
||||
prev_id, prev_hash = self._chain_head(session)
|
||||
payload: dict[str, Any] = {"from_status": from_status, "to_status": to_status}
|
||||
self._append_event(
|
||||
session,
|
||||
application_id=application_id,
|
||||
placement_id=None,
|
||||
event_type=EVENT_APPLICATION_TRANSITION[to_status],
|
||||
actor_type=actor_type,
|
||||
actor_ref=actor_ref,
|
||||
payload=payload,
|
||||
prev_hash=prev_hash,
|
||||
created_at=moment,
|
||||
)
|
||||
|
||||
placement: PlacementRow | None = None
|
||||
if to_status == "hired":
|
||||
assert role_title is not None and start_date is not None
|
||||
placement = PlacementRow(
|
||||
id=f"{_PLACEMENT_ID_PREFIX}{uuid.uuid4().hex[:16]}",
|
||||
learner_id=application.learner_id,
|
||||
employer_id=self._posting_employer_id(session, application.posting_id),
|
||||
posting_id=application.posting_id,
|
||||
application_id=application.id,
|
||||
role_title=role_title,
|
||||
start_date=start_date.isoformat(),
|
||||
status="active",
|
||||
created_at=moment,
|
||||
decided_at=moment,
|
||||
)
|
||||
placement._validate()
|
||||
placement._normalize()
|
||||
session.add(placement)
|
||||
# the placement row must exist before its event references it
|
||||
session.flush()
|
||||
_, prev_hash_2 = self._chain_head(session)
|
||||
self._append_event(
|
||||
session,
|
||||
application_id=application_id,
|
||||
placement_id=placement.id,
|
||||
event_type=EVENT_PLACEMENT_CREATED,
|
||||
actor_type=actor_type,
|
||||
actor_ref=actor_ref,
|
||||
payload={
|
||||
"role_title": role_title,
|
||||
"start_date": start_date.isoformat(),
|
||||
},
|
||||
prev_hash=prev_hash_2,
|
||||
created_at=moment,
|
||||
)
|
||||
|
||||
session.commit()
|
||||
session.refresh(application)
|
||||
application._normalize()
|
||||
if placement is not None:
|
||||
session.refresh(placement)
|
||||
placement._normalize()
|
||||
return application, placement
|
||||
|
||||
@staticmethod
|
||||
def _posting_employer_id(session: Session, posting_id: str) -> str:
|
||||
"""G-302: resolve the posting's employer_id from the SOURCE row
|
||||
at placement-creation time — never trusted from the caller."""
|
||||
from ..marketplace.store import PostingRow
|
||||
|
||||
posting = session.get(PostingRow, posting_id)
|
||||
if posting is None:
|
||||
raise _constraint_error(
|
||||
f"guard: application references unknown posting "
|
||||
f"{posting_id!r} — refusing to create placement",
|
||||
{"posting_id": posting_id},
|
||||
)
|
||||
return posting.employer_id
|
||||
|
||||
# -- reads (consume-free, G-302 ID references) ---------------------------
|
||||
|
||||
def get_placement(self, placement_id: str) -> PlacementRow | None:
|
||||
with Session(self._engine) as session:
|
||||
row = session.get(PlacementRow, placement_id)
|
||||
if row is None:
|
||||
return None
|
||||
row._normalize()
|
||||
return row
|
||||
|
||||
def get_placement_by_application(
|
||||
self, application_id: str
|
||||
) -> PlacementRow | None:
|
||||
"""v0.11 P5 (REQ-11-005): the ACTIVE placement on this
|
||||
application — the G-316 partial unique index makes
|
||||
at-most-one structural; revoked-only history → None (the
|
||||
honest null — never a fabricated placement, A-304)."""
|
||||
with Session(self._engine) as session:
|
||||
row = session.exec(
|
||||
select(PlacementRow).where(
|
||||
PlacementRow.application_id == application_id,
|
||||
PlacementRow.status == "active",
|
||||
)
|
||||
).one_or_none()
|
||||
if row is None:
|
||||
return None
|
||||
row._normalize()
|
||||
return row
|
||||
|
||||
def list_placements(self, status: str | None = None) -> list[PlacementRow]:
|
||||
"""created_at then id (the list_postings ordering discipline,
|
||||
R-73 — deterministic export ordering)."""
|
||||
with Session(self._engine) as session:
|
||||
stmt = select(PlacementRow)
|
||||
if status is not None:
|
||||
stmt = stmt.where(PlacementRow.status == status)
|
||||
rows = session.exec(
|
||||
stmt.order_by(col(PlacementRow.created_at), PlacementRow.id)
|
||||
).all()
|
||||
for row in rows:
|
||||
row._normalize()
|
||||
return list(rows)
|
||||
|
||||
def get_application_events(self, application_id: str) -> list[PlacementEventRow]:
|
||||
"""id-ordered (the chain order) — scoped to ONE application."""
|
||||
with Session(self._engine) as session:
|
||||
rows = session.exec(
|
||||
select(PlacementEventRow)
|
||||
.where(PlacementEventRow.application_id == application_id)
|
||||
.order_by(col(PlacementEventRow.id))
|
||||
).all()
|
||||
for row in rows:
|
||||
row._normalize()
|
||||
return list(rows)
|
||||
|
||||
def list_events(self) -> list[PlacementEventRow]:
|
||||
"""The whole ledger, id-ordered (the export embeds every event
|
||||
so the auditor's walk is offline — R-70)."""
|
||||
with Session(self._engine) as session:
|
||||
rows = session.exec(
|
||||
select(PlacementEventRow).order_by(col(PlacementEventRow.id))
|
||||
).all()
|
||||
for row in rows:
|
||||
row._normalize()
|
||||
return list(rows)
|
||||
|
||||
def count_events(self, event_type: str | None = None) -> int:
|
||||
with Session(self._engine) as session:
|
||||
stmt = select(func.count()).select_from(PlacementEventRow)
|
||||
if event_type is not None:
|
||||
stmt = stmt.where(PlacementEventRow.event_type == event_type)
|
||||
return session.exec(stmt).one()
|
||||
|
||||
# -- the D-102 correction lane ------------------------------------------
|
||||
|
||||
def revoke_placement(
|
||||
self, placement_id: str, actor_ref: str, now: datetime | None = None
|
||||
) -> PlacementRow | None:
|
||||
"""D-102: the one status-only mutation active → revoked + the
|
||||
placement.revoked event, one transaction (operator-only by
|
||||
route). None = unknown placement (route 404s)."""
|
||||
moment = _as_utc(now) if now is not None else datetime.now(UTC)
|
||||
with Session(self._engine) as session:
|
||||
row = session.get(PlacementRow, placement_id)
|
||||
if row is None:
|
||||
return None
|
||||
if row.status != "active":
|
||||
raise InvalidTransition(
|
||||
f"placement {placement_id!r} is {row.status!r} — "
|
||||
"only an active placement can be revoked"
|
||||
)
|
||||
row.status = "revoked"
|
||||
row._validate()
|
||||
row._normalize()
|
||||
_, prev_hash = self._chain_head(session)
|
||||
self._append_event(
|
||||
session,
|
||||
application_id=row.application_id,
|
||||
placement_id=row.id,
|
||||
event_type=EVENT_PLACEMENT_REVOKED,
|
||||
actor_type="operator",
|
||||
actor_ref=actor_ref,
|
||||
payload={"placement_id": placement_id},
|
||||
prev_hash=prev_hash,
|
||||
created_at=moment,
|
||||
)
|
||||
session.commit()
|
||||
session.refresh(row)
|
||||
row._normalize()
|
||||
return row
|
||||
|
||||
def rehire_placement(
|
||||
self,
|
||||
application_id: str,
|
||||
role_title: str,
|
||||
start_date: date,
|
||||
actor_ref: str,
|
||||
now: datetime | None = None,
|
||||
) -> PlacementRow:
|
||||
"""R-80/G-326: the operator re-hire — a FRESH PlacementRow +
|
||||
placement.rehired event WITHOUT touching the application's
|
||||
terminal status (hired → hired is a 409 on every
|
||||
matrix-validated surface; placements, not applications, are
|
||||
the correction lane). decided_at = the re-hire event's
|
||||
store-stamped created_at (G-326 — D-102's stamp rule extended).
|
||||
|
||||
The partial unique index is the guard: an existing ACTIVE
|
||||
placement on the application refuses the insert and its
|
||||
IntegrityError SURFACES (the caller's honest 409 — revoked
|
||||
rows never match the partial WHERE, the exact G-316 property).
|
||||
"""
|
||||
if not role_title:
|
||||
raise ValueError("role_title is required on re-hire (D-103)")
|
||||
if start_date is None:
|
||||
raise ValueError("start_date is required on re-hire (D-103)")
|
||||
moment = _as_utc(now) if now is not None else datetime.now(UTC)
|
||||
with Session(self._engine) as session:
|
||||
application = session.get(ApplicationRow, application_id)
|
||||
if application is None:
|
||||
raise InvalidTransition(f"unknown application {application_id!r}")
|
||||
if application.status != "hired":
|
||||
raise InvalidTransition(
|
||||
f"application {application_id!r} is {application.status!r} — "
|
||||
"re-hire is the correction lane for a hired application"
|
||||
)
|
||||
placement = PlacementRow(
|
||||
id=f"{_PLACEMENT_ID_PREFIX}{uuid.uuid4().hex[:16]}",
|
||||
learner_id=application.learner_id,
|
||||
employer_id=self._posting_employer_id(session, application.posting_id),
|
||||
posting_id=application.posting_id,
|
||||
application_id=application.id,
|
||||
role_title=role_title,
|
||||
start_date=start_date.isoformat(),
|
||||
status="active",
|
||||
created_at=moment,
|
||||
# G-326: the re-hire event's stamp, not the original hire's
|
||||
decided_at=moment,
|
||||
)
|
||||
placement._validate()
|
||||
placement._normalize()
|
||||
session.add(placement)
|
||||
# the partial unique index refuses a second ACTIVE row —
|
||||
# its IntegrityError SURFACES (never swallowed, the
|
||||
# insert_claim discipline)
|
||||
session.flush()
|
||||
_, prev_hash = self._chain_head(session)
|
||||
self._append_event(
|
||||
session,
|
||||
application_id=application.id,
|
||||
placement_id=placement.id,
|
||||
event_type=EVENT_PLACEMENT_REHIRED,
|
||||
actor_type="operator",
|
||||
actor_ref=actor_ref,
|
||||
payload={
|
||||
"role_title": role_title,
|
||||
"start_date": start_date.isoformat(),
|
||||
},
|
||||
prev_hash=prev_hash,
|
||||
created_at=moment,
|
||||
)
|
||||
session.commit()
|
||||
session.refresh(placement)
|
||||
placement._normalize()
|
||||
return placement
|
||||
|
||||
def list_placements_by_employer(
|
||||
self, employer_id: str
|
||||
) -> list[PlacementRow]:
|
||||
"""v0.11 P3 (D-109): the claimed entity's OWN placements
|
||||
(active + revoked — the full history), created_at then id."""
|
||||
with Session(self._engine) as session:
|
||||
rows = session.exec(
|
||||
select(PlacementRow)
|
||||
.where(PlacementRow.employer_id == employer_id)
|
||||
.order_by(col(PlacementRow.created_at), PlacementRow.id)
|
||||
).all()
|
||||
for row in rows:
|
||||
row._normalize()
|
||||
return list(rows)
|
||||
|
||||
# -- the audit export (v0.11 P4, D-108/G-321/G-325/R-73) -----------------
|
||||
|
||||
EXPORT_SCHEMA_VERSION = 1
|
||||
|
||||
def build_export(self, now: datetime | None = None) -> dict[str, Any]:
|
||||
"""D-108: ONE self-contained JSON document via this single
|
||||
builder (the export route AND the summary route render from
|
||||
the SAME dict — the summary can never drift from the export).
|
||||
|
||||
G-325: evidence_bundles cover EVERY placement (active AND
|
||||
revoked) — the correction history (hired → revoked → rehired)
|
||||
is precisely what an auditor most wants to inspect; cohort
|
||||
stays active-only; revoked_count equals the placement.revoked
|
||||
events (cross-checkable, rides the G-321(c) methodology).
|
||||
|
||||
R-73 determinism: events by id (chain order); by_cohort_month
|
||||
ascending; cohort/evidence ordered created_at then id; float
|
||||
absence (counts are ints by construction); generated_at is
|
||||
export-only metadata (a function of the request, not the rows
|
||||
— R-79) and is EXCLUDED from determinism comparison.
|
||||
"""
|
||||
moment = _as_utc(now) if now is not None else datetime.now(UTC)
|
||||
all_placements = self.list_placements() # created_at then id
|
||||
active = [row for row in all_placements if row.status == "active"]
|
||||
revoked = [row for row in all_placements if row.status == "revoked"]
|
||||
events = self.list_events() # id order — the chain order
|
||||
|
||||
# summary (the D-107 computation — one implementation, reused)
|
||||
learner_ids = {row.learner_id for row in active}
|
||||
by_month: dict[str, int] = {}
|
||||
for row in active:
|
||||
month = row.start_date[:7]
|
||||
by_month[month] = by_month.get(month, 0) + 1
|
||||
|
||||
# evidence bundles: G-302 — resolved from SOURCE ROWS at export
|
||||
with Session(self._engine) as session:
|
||||
from ..marketplace.store import ApplicationRow, PostingRow
|
||||
|
||||
evidence_bundles: list[dict[str, Any]] = []
|
||||
cohort: list[dict[str, Any]] = []
|
||||
for row in all_placements:
|
||||
application = session.get(ApplicationRow, row.application_id)
|
||||
posting = session.get(PostingRow, row.posting_id)
|
||||
transition_history = [
|
||||
{
|
||||
"id": event.id,
|
||||
"event_type": event.event_type,
|
||||
"actor_type": event.actor_type,
|
||||
"actor_ref": event.actor_ref,
|
||||
"created_at": (
|
||||
_as_utc(event.created_at).isoformat()
|
||||
if event.created_at is not None
|
||||
else None
|
||||
),
|
||||
}
|
||||
for event in events
|
||||
if event.application_id == row.application_id
|
||||
]
|
||||
hired_at: str | None = None
|
||||
for e in events:
|
||||
if (
|
||||
e.application_id == row.application_id
|
||||
and e.event_type == "application.hired"
|
||||
and e.created_at is not None
|
||||
):
|
||||
hired_at = _as_utc(e.created_at).isoformat()
|
||||
break
|
||||
decision_provenance = {
|
||||
"hired_event": hired_at,
|
||||
"decided_at": (
|
||||
_as_utc(row.decided_at).isoformat()
|
||||
if row.decided_at is not None
|
||||
else None
|
||||
),
|
||||
}
|
||||
bundle: dict[str, Any] = {
|
||||
"placement_id": row.id,
|
||||
"placement_status": row.status,
|
||||
"role_title": row.role_title,
|
||||
"start_date": row.start_date,
|
||||
"learner_id": row.learner_id,
|
||||
"employer_id": row.employer_id,
|
||||
"posting_record": (
|
||||
{
|
||||
"id": posting.id,
|
||||
"title": posting.title,
|
||||
"employer_id": posting.employer_id,
|
||||
"status": posting.status,
|
||||
}
|
||||
if posting is not None
|
||||
else None
|
||||
),
|
||||
"application_record": (
|
||||
{
|
||||
"id": application.id,
|
||||
"learner_id": application.learner_id,
|
||||
"posting_id": application.posting_id,
|
||||
"status": application.status,
|
||||
"created_at": (
|
||||
_as_utc(application.created_at).isoformat()
|
||||
if application.created_at is not None
|
||||
else None
|
||||
),
|
||||
}
|
||||
if application is not None
|
||||
else None
|
||||
),
|
||||
# D-101/A-304: NO backfill — pre-v0.11 submitted rows
|
||||
# carry only their created_at, never a fabricated
|
||||
# application.submitted event
|
||||
"transition_history": transition_history,
|
||||
"decision_provenance": decision_provenance,
|
||||
}
|
||||
evidence_bundles.append(bundle)
|
||||
if row.status == "active":
|
||||
cohort.append(
|
||||
{
|
||||
"placement_id": row.id,
|
||||
"learner_id": row.learner_id,
|
||||
"employer_id": row.employer_id,
|
||||
"posting_id": row.posting_id,
|
||||
"application_id": row.application_id,
|
||||
"role_title": row.role_title,
|
||||
"start_date": row.start_date,
|
||||
}
|
||||
)
|
||||
|
||||
ok, head_hash = self.verify_chain()
|
||||
if not ok:
|
||||
# P4 review P2-1: a raise (not an assert — stripped under
|
||||
# python -O) keeps the export FAIL-CLOSED on a broken
|
||||
# chain: no document is ever served that would not walk.
|
||||
raise RuntimeError(
|
||||
"placement ledger chain verification failed — "
|
||||
"refusing to build the audit export"
|
||||
)
|
||||
|
||||
return {
|
||||
"schema_version": self.EXPORT_SCHEMA_VERSION,
|
||||
"generated_at": moment.isoformat(),
|
||||
"summary": {
|
||||
"distinct_learners_placed": len(learner_ids),
|
||||
"total_active_placements": len(active),
|
||||
"revoked_count": len(revoked),
|
||||
"by_cohort_month": [
|
||||
{"month": month, "count": count}
|
||||
for month, count in sorted(by_month.items())
|
||||
],
|
||||
},
|
||||
"cohort": cohort,
|
||||
"evidence_bundles": evidence_bundles,
|
||||
"methodology": EXPORT_METHODOLOGY,
|
||||
"ledger": {
|
||||
"events": [
|
||||
{
|
||||
"id": event.id,
|
||||
"application_id": event.application_id,
|
||||
"placement_id": event.placement_id,
|
||||
"event_type": event.event_type,
|
||||
"actor_type": event.actor_type,
|
||||
"actor_ref": event.actor_ref,
|
||||
"payload": event.payload,
|
||||
"created_at": (
|
||||
_as_utc(event.created_at).isoformat()
|
||||
if event.created_at is not None
|
||||
else None
|
||||
),
|
||||
"prev_hash": event.prev_hash,
|
||||
"hash": event.hash,
|
||||
}
|
||||
for event in events
|
||||
],
|
||||
"head_hash": head_hash,
|
||||
},
|
||||
}
|
||||
|
||||
def close(self) -> None:
|
||||
self._engine.dispose()
|
||||
@@ -120,7 +120,9 @@ class TestSessionGate:
|
||||
T+8d → the session reads as absent → 401 (no sleeps)."""
|
||||
from .conftest import make_client
|
||||
|
||||
login_at = datetime(2026, 9, 30, 12, 0, 0, tzinfo=UTC)
|
||||
# Wall-clock-relative mint: the 200 arm below must hold on ANY
|
||||
# run date (the fixed 2026-09-30 mint turned red at 2026-10-07+).
|
||||
login_at = datetime.now(UTC) - timedelta(days=1)
|
||||
employer_store.insert_account(
|
||||
email=TEST_EMAIL, password_hash="scrypt$16384$8$1$c2FsdA$aGFzaA"
|
||||
)
|
||||
@@ -140,7 +142,7 @@ class TestSessionGate:
|
||||
)
|
||||
assert row is None # expired — reads as absent
|
||||
resp = c.get("/v1/employers/auth/me", headers=bearer("expired-token"))
|
||||
# Wall clock (2026-09-30) is INSIDE the 7-day TTL → 200.
|
||||
# Wall clock (1d after mint) is INSIDE the 7-day TTL → 200.
|
||||
assert resp.status_code == 200
|
||||
# The lazy-TTL route 401 pin: a session whose expires_at is in
|
||||
# the WALL-CLOCK past reads as absent at the route.
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
"""Placement test fixtures (hermeticity rule: db_path pins tmp_path —
|
||||
never ~/.nextcraft, never the cloud).
|
||||
|
||||
Mirrors the tests/marketplace pattern: tmp-path stores injected into
|
||||
a real create_app via state-injection (the G-9 precedent) — the
|
||||
transition routes see the exact composition production runs.
|
||||
|
||||
G-313 (D-111): the autouse `_APPLY_TIMES` reset — the learner
|
||||
withdraw route (P2) rides the marketplace apply-rate window; without
|
||||
this reset the suite self-429s (the lesson applied BEFORE it ships).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from pydantic import SecretStr
|
||||
|
||||
from ai_service.config import Settings
|
||||
from ai_service.identity.store import SQLiteIdentityStore
|
||||
from ai_service.main import create_app
|
||||
from ai_service.marketplace.store import SQLiteMarketplaceStore
|
||||
from ai_service.placements.store import SQLitePlacementStore
|
||||
|
||||
from ..conftest import SUITE_LEARNERS, seed_verified_identity
|
||||
|
||||
#: The pilot learner roster for gated-route tests (G-5 allowlist
|
||||
#: coverage + verified 18+ identity records).
|
||||
PLACEMENT_LEARNERS = ["pilot-learner", "pilot-learner-2"]
|
||||
|
||||
ADMIN_TOKEN = "test-admin-token"
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def marketplace_store(tmp_path: Path) -> Iterator[SQLiteMarketplaceStore]:
|
||||
"""Hermetic store: D-100's topology — marketplace + placements
|
||||
share ONE SQLite file (the cross-store transaction depends on
|
||||
it)."""
|
||||
store = SQLiteMarketplaceStore(db_path=tmp_path / "shared.db")
|
||||
yield store
|
||||
store.close()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def identity_store(tmp_path: Path) -> Iterator[SQLiteIdentityStore]:
|
||||
store = SQLiteIdentityStore(db_path=tmp_path / "identity.db")
|
||||
seed_verified_identity(store, PLACEMENT_LEARNERS)
|
||||
yield store
|
||||
store.close()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def placement_store(tmp_path: Path) -> Iterator[SQLitePlacementStore]:
|
||||
"""The 9th D-027 store on the SAME shared file as the marketplace
|
||||
store (D-100/R-72: one SQLite file, two engines — the shipped
|
||||
multi-store topology)."""
|
||||
store = SQLitePlacementStore(db_path=tmp_path / "shared.db")
|
||||
yield store
|
||||
store.close()
|
||||
|
||||
|
||||
def make_client(
|
||||
tmp_path: Path,
|
||||
identity_store: SQLiteIdentityStore,
|
||||
marketplace_store: SQLiteMarketplaceStore,
|
||||
admin_token: str | None = ADMIN_TOKEN,
|
||||
) -> TestClient:
|
||||
"""Full app with state-injected stores; admin_token=None exercises
|
||||
the D-057 fail-closed matrix. P1-2 (security review): ALL stores
|
||||
share the ONE tmp file (marketplace rides shared.db; the app DB +
|
||||
placement store point there too) — D-100's cross-store transition
|
||||
depends on the single-file topology, exactly as production runs."""
|
||||
settings = Settings(
|
||||
provider="mock",
|
||||
learner_allowlist=SUITE_LEARNERS,
|
||||
db_path=tmp_path / "shared.db",
|
||||
sandbox_dir=tmp_path / "sandboxes",
|
||||
admin_token=SecretStr(admin_token or ""),
|
||||
)
|
||||
app = create_app(settings)
|
||||
app.state.identity_store = identity_store # state-injection (G-9)
|
||||
app.state.marketplace_store = marketplace_store
|
||||
app.state.placement_store = SQLitePlacementStore(db_path=tmp_path / "shared.db")
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_apply_rate_window() -> Iterator[None]:
|
||||
"""The apply rate cap is module-level process state (the D-019
|
||||
precedent); tests must not inherit each other's windows — the P2
|
||||
withdraw route rides this limiter (G-313 applied BEFORE it
|
||||
ships)."""
|
||||
from ai_service.api.marketplace import _APPLY_TIMES
|
||||
|
||||
_APPLY_TIMES.clear()
|
||||
yield
|
||||
_APPLY_TIMES.clear()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(
|
||||
tmp_path: Path,
|
||||
identity_store: SQLiteIdentityStore,
|
||||
marketplace_store: SQLiteMarketplaceStore,
|
||||
) -> Iterator[TestClient]:
|
||||
with make_client(
|
||||
tmp_path, identity_store, marketplace_store, admin_token=ADMIN_TOKEN
|
||||
) as c:
|
||||
yield c
|
||||
|
||||
|
||||
def admin_headers(token: str = ADMIN_TOKEN) -> dict[str, str]:
|
||||
return {"X-Admin-Token": token}
|
||||
@@ -0,0 +1,249 @@
|
||||
"""The operator lane tests (D-106/G-326/G-327, REQ-11-002) — the
|
||||
fail-closed matrix, the generic transition arms, revoke, and the
|
||||
R-80 re-hire route."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from ai_service.marketplace.base import Posting
|
||||
from ai_service.marketplace.store import SQLiteMarketplaceStore
|
||||
|
||||
from .conftest import ADMIN_TOKEN, make_client
|
||||
|
||||
|
||||
def _seed_posting(store: SQLiteMarketplaceStore, employer_id: str = "emp-test") -> str:
|
||||
import uuid
|
||||
|
||||
posting = store.insert_posting(
|
||||
Posting(
|
||||
id=f"post-{uuid.uuid4().hex[:12]}",
|
||||
source="admin",
|
||||
external_id=f"test-{uuid.uuid4().hex[:8]}",
|
||||
employer_id=employer_id,
|
||||
title="AI Orchestration Engineer",
|
||||
)
|
||||
)
|
||||
# the learner apply route requires published (409 on drafts)
|
||||
store.set_posting_status(posting.id, "published")
|
||||
return posting.id
|
||||
|
||||
|
||||
def _apply(client: TestClient, posting_id: str, learner_id: str = "pilot-learner") -> str:
|
||||
resp = client.post(
|
||||
"/v1/marketplace/apply", json={"posting_id": posting_id, "learner_id": learner_id}
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
return resp.json()["application_id"]
|
||||
|
||||
|
||||
def _admin(token: str = ADMIN_TOKEN) -> dict[str, str]:
|
||||
return {"X-Admin-Token": token}
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def scaffold(tmp_path, identity_store, marketplace_store):
|
||||
client = make_client(tmp_path, identity_store, marketplace_store)
|
||||
with client as entered:
|
||||
posting_id = _seed_posting(marketplace_store)
|
||||
application_id = _apply(entered, posting_id)
|
||||
yield {
|
||||
"client": entered,
|
||||
"posting_id": posting_id,
|
||||
"application_id": application_id,
|
||||
}
|
||||
|
||||
|
||||
# -- the fail-closed matrix (the R-56 shape) -------------------------------------
|
||||
|
||||
|
||||
def test_fail_closed_no_token(scaffold) -> None:
|
||||
resp = scaffold["client"].post(
|
||||
f"/v1/placements/admin/applications/{scaffold['application_id']}/transition",
|
||||
json={"to": "in_review"},
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_fail_closed_bad_token(scaffold) -> None:
|
||||
resp = scaffold["client"].post(
|
||||
f"/v1/placements/admin/applications/{scaffold['application_id']}/transition",
|
||||
json={"to": "in_review"},
|
||||
headers=_admin("wrong-token"),
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_fail_closed_revoke_and_rehire(scaffold) -> None:
|
||||
client = scaffold["client"]
|
||||
# no token → 403 on every route (fail-closed)
|
||||
resp = client.post(
|
||||
"/v1/placements/admin/placements/plc-x/revoke",
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
# invalid token → 403 (never a 404/409 — the gate runs first)
|
||||
resp = client.post(
|
||||
"/v1/placements/admin/placements/plc-x/revoke",
|
||||
headers=_admin("wrong-token"),
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
resp = client.post(
|
||||
f"/v1/placements/admin/placements/by-application/{scaffold['application_id']}/re-hire",
|
||||
json={"role_title": "Engineer", "start_date": "2026-12-01"},
|
||||
headers=_admin("wrong-token"),
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
# -- the generic transition route --------------------------------------------------
|
||||
|
||||
|
||||
def test_admin_transition_every_legal_arm(scaffold) -> None:
|
||||
client, app_id = scaffold["client"], scaffold["application_id"]
|
||||
for to_status in ("in_review", "interview", "offer", "rejected"):
|
||||
resp = client.post(
|
||||
f"/v1/placements/admin/applications/{app_id}/transition",
|
||||
json={"to": to_status},
|
||||
headers=_admin(),
|
||||
)
|
||||
# rejected is terminal — the loop ends there
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["status"] == to_status
|
||||
if to_status == "rejected":
|
||||
break
|
||||
|
||||
|
||||
def test_admin_garbage_to_422(scaffold) -> None:
|
||||
"""G-327: `to` is a CLOSED Literal — garbage dies at pydantic
|
||||
(422), never a 500/409."""
|
||||
resp = scaffold["client"].post(
|
||||
f"/v1/placements/admin/applications/{scaffold['application_id']}/transition",
|
||||
json={"to": "garbage"},
|
||||
headers=_admin(),
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
def test_admin_matrix_illegal_409(scaffold) -> None:
|
||||
client, app_id = scaffold["client"], scaffold["application_id"]
|
||||
client.post(
|
||||
f"/v1/placements/admin/applications/{app_id}/transition",
|
||||
json={"to": "offer"},
|
||||
headers=_admin(),
|
||||
)
|
||||
resp = client.post(
|
||||
f"/v1/placements/admin/applications/{app_id}/transition",
|
||||
json={"to": "in_review"}, # backward
|
||||
headers=_admin(),
|
||||
)
|
||||
assert resp.status_code == 409
|
||||
|
||||
|
||||
def test_admin_hire_requires_fields_422(scaffold) -> None:
|
||||
resp = scaffold["client"].post(
|
||||
f"/v1/placements/admin/applications/{scaffold['application_id']}/transition",
|
||||
json={"to": "hired"},
|
||||
headers=_admin(),
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
def test_admin_withdraw_on_learners_behalf(scaffold) -> None:
|
||||
"""The operator can drive ANY matrix-legal transition incl.
|
||||
withdrawn (remediation with provenance, the D-083 lane)."""
|
||||
resp = scaffold["client"].post(
|
||||
f"/v1/placements/admin/applications/{scaffold['application_id']}/transition",
|
||||
json={"to": "withdrawn"},
|
||||
headers=_admin(),
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "withdrawn"
|
||||
|
||||
|
||||
def _admin_hire(scaffold) -> str:
|
||||
"""The shared hire-through-the-operator-lane helper → placement id."""
|
||||
resp = scaffold["client"].post(
|
||||
f"/v1/placements/admin/applications/{scaffold['application_id']}/transition",
|
||||
json={"to": "hired", "role_title": "Engineer", "start_date": "2026-11-01"},
|
||||
headers=_admin(),
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert body["status"] == "hired"
|
||||
assert body["placement"]["role_title"] == "Engineer"
|
||||
assert body["placement"]["status"] == "active"
|
||||
return body["placement"]["id"]
|
||||
|
||||
|
||||
def test_admin_hire_creates_placement(scaffold) -> None:
|
||||
_admin_hire(scaffold)
|
||||
|
||||
|
||||
# -- revoke + the R-80 re-hire ------------------------------------------------------
|
||||
|
||||
|
||||
def test_revoke_then_rehire_three_event_ledger(scaffold) -> None:
|
||||
client, app_id = scaffold["client"], scaffold["application_id"]
|
||||
placement_id = _admin_hire(scaffold)
|
||||
|
||||
# revoke
|
||||
resp = client.post(
|
||||
f"/v1/placements/admin/placements/{placement_id}/revoke",
|
||||
headers=_admin(),
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["status"] == "revoked"
|
||||
|
||||
# the revoked placement is out of the active set (P3's overview now
|
||||
# exists — the D-102/D-107 exclusion arm)
|
||||
resp = client.get("/v1/placements/admin/overview", headers=_admin())
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["total_active_placements"] == 0
|
||||
|
||||
# re-hire: the FRESH active row (R-80) + decided_at = the re-hire
|
||||
# event's stamp (G-326 — asserted at the store layer in test_store)
|
||||
resp = client.post(
|
||||
f"/v1/placements/admin/placements/by-application/{app_id}/re-hire",
|
||||
json={"role_title": "Engineer II", "start_date": "2026-12-15"},
|
||||
headers=_admin(),
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
rehired = resp.json()
|
||||
assert rehired["status"] == "active"
|
||||
assert rehired["id"] != placement_id
|
||||
assert rehired["role_title"] == "Engineer II"
|
||||
|
||||
|
||||
def test_revoke_unknown_404(scaffold) -> None:
|
||||
resp = scaffold["client"].post(
|
||||
"/v1/placements/admin/placements/plc-nonexistent/revoke",
|
||||
headers=_admin(),
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_rehire_second_active_409(scaffold) -> None:
|
||||
"""The partial-unique arm: re-hire WITHOUT revoke → the honest 409."""
|
||||
client, app_id = scaffold["client"], scaffold["application_id"]
|
||||
client.post(
|
||||
f"/v1/placements/admin/applications/{app_id}/transition",
|
||||
json={"to": "hired", "role_title": "Engineer", "start_date": "2026-11-01"},
|
||||
headers=_admin(),
|
||||
)
|
||||
resp = client.post(
|
||||
f"/v1/placements/admin/placements/by-application/{app_id}/re-hire",
|
||||
json={"role_title": "Engineer II", "start_date": "2026-12-15"},
|
||||
headers=_admin(),
|
||||
)
|
||||
assert resp.status_code == 409
|
||||
assert "revoke it before re-hiring" in resp.json()["detail"]
|
||||
|
||||
|
||||
def test_rehire_requires_hired_application_409(scaffold) -> None:
|
||||
resp = scaffold["client"].post(
|
||||
f"/v1/placements/admin/placements/by-application/{scaffold['application_id']}/re-hire",
|
||||
json={"role_title": "Engineer", "start_date": "2026-11-01"},
|
||||
headers=_admin(),
|
||||
)
|
||||
assert resp.status_code == 409 # not hired yet — the correction lane refuses
|
||||
@@ -0,0 +1,298 @@
|
||||
"""Employer application transition tests (D-105, REQ-11-002) — the
|
||||
G-311 gate-order pins + the D-099 matrix 409 arms + the G-319 pin
|
||||
split + the hire arms."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from ai_service.employers.store import SQLiteEmployerStore
|
||||
from ai_service.marketplace.base import Posting
|
||||
from ai_service.marketplace.store import SQLiteMarketplaceStore
|
||||
|
||||
from .conftest import make_client
|
||||
|
||||
# -- fixtures: the employer-principal + application scaffold -------------------
|
||||
|
||||
|
||||
def _seed_posting(store: SQLiteMarketplaceStore, employer_id: str) -> str:
|
||||
import uuid
|
||||
|
||||
# the claim route requires the EmployerRow entity to exist (404 unknown)
|
||||
from ai_service.marketplace.base import Employer
|
||||
|
||||
store.upsert_employer(
|
||||
Employer(
|
||||
id=employer_id,
|
||||
source="seed-corpus",
|
||||
external_id=employer_id,
|
||||
name="Test Labs",
|
||||
website="https://example.com/test",
|
||||
)
|
||||
)
|
||||
posting = store.insert_posting(
|
||||
Posting(
|
||||
id=f"post-{uuid.uuid4().hex[:12]}",
|
||||
source="admin",
|
||||
external_id=f"test-{uuid.uuid4().hex[:8]}",
|
||||
employer_id=employer_id,
|
||||
title="AI Orchestration Engineer",
|
||||
description="Test posting",
|
||||
)
|
||||
)
|
||||
# the learner apply route requires published (409 on drafts)
|
||||
store.set_posting_status(posting.id, "published")
|
||||
return posting.id
|
||||
|
||||
|
||||
def _apply(client: TestClient, posting_id: str, learner_id: str = "pilot-learner") -> str:
|
||||
resp = client.post(
|
||||
"/v1/marketplace/apply", json={"posting_id": posting_id, "learner_id": learner_id}
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
return resp.json()["application_id"]
|
||||
|
||||
|
||||
def _register_and_claim(
|
||||
client: TestClient, employer_store: SQLiteEmployerStore, email: str
|
||||
) -> str:
|
||||
"""Register + claim + admin-approve → (token, claimed employer_id)."""
|
||||
resp = client.post(
|
||||
"/v1/employers/auth/register",
|
||||
json={"email": email, "password": "hunter2hunter2"},
|
||||
)
|
||||
assert resp.status_code == 201, resp.text
|
||||
token = resp.json()["token"]
|
||||
resp = client.post(
|
||||
"/v1/employers/claims",
|
||||
json={"employer_id": "emp-test"},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert resp.status_code in (200, 201), resp.text
|
||||
claim_id = resp.json()["id"]
|
||||
resp = client.post(
|
||||
f"/v1/employers/admin/claims/{claim_id}/approve",
|
||||
headers={"X-Admin-Token": "test-admin-token"},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
return token
|
||||
|
||||
|
||||
def _make(tmp_path, identity_store, marketplace_store):
|
||||
"""Build the client + the employer store seam (the P2 scaffold)."""
|
||||
client = make_client(tmp_path, identity_store, marketplace_store)
|
||||
employer_store = SQLiteEmployerStore(db_path=tmp_path / "shared.db")
|
||||
client.app.state.employer_store = ( # type: ignore[attr-defined] # TestClient.app: ASGIApp-typed, runtime FastAPI
|
||||
employer_store
|
||||
)
|
||||
return client, employer_store
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def scaffold(tmp_path, identity_store, marketplace_store):
|
||||
client, employer_store = _make(tmp_path, identity_store, marketplace_store)
|
||||
with client as entered:
|
||||
posting_id = _seed_posting(marketplace_store, "emp-test")
|
||||
token = _register_and_claim(entered, employer_store, "hiring@example.com")
|
||||
application_id = _apply(entered, posting_id)
|
||||
yield {
|
||||
"client": entered,
|
||||
"token": token,
|
||||
"posting_id": posting_id,
|
||||
"application_id": application_id,
|
||||
}
|
||||
|
||||
|
||||
def _hdr(token: str) -> dict[str, str]:
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
# -- G-311 gate order (never a 409 before a 403; never a 403 before the 401) -----
|
||||
|
||||
|
||||
def test_gate_order_no_token_401(scaffold) -> None:
|
||||
resp = scaffold["client"].post(
|
||||
f"/v1/employers/applications/{scaffold['application_id']}/in-review"
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
def test_gate_order_unclaimed_403(tmp_path, identity_store, marketplace_store) -> None:
|
||||
client, _ = _make(tmp_path, identity_store, marketplace_store)
|
||||
with client as entered:
|
||||
posting_id = _seed_posting(marketplace_store, "emp-test")
|
||||
resp = entered.post(
|
||||
"/v1/employers/auth/register",
|
||||
json={"email": "unclaimed@example.com", "password": "hunter2hunter2"},
|
||||
)
|
||||
token = resp.json()["token"]
|
||||
application_id = _apply(entered, posting_id)
|
||||
resp = entered.post(
|
||||
f"/v1/employers/applications/{application_id}/in-review",
|
||||
headers=_hdr(token),
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
assert "no_claimed_employer" in str(resp.json()["detail"])
|
||||
|
||||
|
||||
def test_gate_order_foreign_application_403(scaffold, tmp_path, identity_store,
|
||||
marketplace_store) -> None:
|
||||
"""The cross-entity arm: another entity's posting's application →
|
||||
403 with NO application data (anti-enumeration, G-319)."""
|
||||
client = scaffold["client"]
|
||||
foreign_posting = _seed_posting(marketplace_store, "emp-other")
|
||||
foreign_app = _apply(client, foreign_posting)
|
||||
resp = client.post(
|
||||
f"/v1/employers/applications/{foreign_app}/in-review",
|
||||
headers=_hdr(scaffold["token"]),
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
assert resp.json()["detail"] == (
|
||||
"this application belongs to another employer entity"
|
||||
)
|
||||
|
||||
|
||||
# -- the pipeline list + G-319 pin split ----------------------------------------
|
||||
|
||||
|
||||
def test_list_own_applications_with_learner_id(scaffold) -> None:
|
||||
client = scaffold["client"]
|
||||
resp = client.get("/v1/employers/applications", headers=_hdr(scaffold["token"]))
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert body["total"] == 1
|
||||
app_row = body["applications"][0]
|
||||
# G-319 supersession: learner_id IS visible on the application-scoped read
|
||||
assert app_row["learner_id"] == "pilot-learner"
|
||||
assert app_row["status"] == "submitted"
|
||||
assert body["facets"]["submitted"] == 1
|
||||
assert body["facets"]["hired"] == 0
|
||||
|
||||
|
||||
def test_own_postings_list_stays_counts_only(scaffold) -> None:
|
||||
"""The G-319 pin split: the own-postings LIST never carries learner
|
||||
identifiers (the v0.10 boundary holds there — unchanged)."""
|
||||
client = scaffold["client"]
|
||||
resp = client.get("/v1/employers/postings", headers=_hdr(scaffold["token"]))
|
||||
assert resp.status_code == 200
|
||||
assert "pilot-learner" not in resp.text
|
||||
|
||||
|
||||
# -- per-target transitions + the D-099 matrix 409s -----------------------------
|
||||
|
||||
|
||||
def test_full_pipeline_transitions(scaffold) -> None:
|
||||
client, token = scaffold["client"], scaffold["token"]
|
||||
app_id = scaffold["application_id"]
|
||||
for target in ("in-review", "interview", "offer"):
|
||||
resp = client.post(
|
||||
f"/v1/employers/applications/{app_id}/{target}", headers=_hdr(token)
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["status"] == target.replace("-", "_")
|
||||
resp = client.post(
|
||||
f"/v1/employers/applications/{app_id}/hire",
|
||||
json={"role_title": "AI Orchestration Engineer", "start_date": "2026-11-01"},
|
||||
headers=_hdr(token),
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert body["status"] == "hired"
|
||||
assert body["placement"]["role_title"] == "AI Orchestration Engineer"
|
||||
assert body["placement"]["start_date"] == "2026-11-01"
|
||||
assert body["placement"]["employer_id"] == "emp-test"
|
||||
|
||||
|
||||
def test_skip_forward_hire_from_submitted(scaffold) -> None:
|
||||
"""R-71: submitted → hired is a legal fast-track."""
|
||||
client, token = scaffold["client"], scaffold["token"]
|
||||
resp = client.post(
|
||||
f"/v1/employers/applications/{scaffold['application_id']}/hire",
|
||||
json={"role_title": "Engineer", "start_date": "2026-12-01"},
|
||||
headers=_hdr(token),
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["status"] == "hired"
|
||||
|
||||
|
||||
def test_matrix_backward_move_409(scaffold) -> None:
|
||||
client, token = scaffold["client"], scaffold["token"]
|
||||
app_id = scaffold["application_id"]
|
||||
client.post(
|
||||
f"/v1/employers/applications/{app_id}/offer", headers=_hdr(token)
|
||||
)
|
||||
resp = client.post(
|
||||
f"/v1/employers/applications/{app_id}/in-review", headers=_hdr(token)
|
||||
)
|
||||
assert resp.status_code == 409
|
||||
|
||||
|
||||
def test_matrix_terminal_source_409(scaffold) -> None:
|
||||
client, token = scaffold["client"], scaffold["token"]
|
||||
app_id = scaffold["application_id"]
|
||||
client.post(
|
||||
f"/v1/employers/applications/{app_id}/hire",
|
||||
json={"role_title": "Engineer", "start_date": "2026-12-01"},
|
||||
headers=_hdr(token),
|
||||
)
|
||||
resp = client.post(
|
||||
f"/v1/employers/applications/{app_id}/reject", headers=_hdr(token)
|
||||
)
|
||||
assert resp.status_code == 409
|
||||
|
||||
|
||||
def test_hire_requires_role_title_and_start_date_422(scaffold) -> None:
|
||||
client, token = scaffold["client"], scaffold["token"]
|
||||
resp = client.post(
|
||||
f"/v1/employers/applications/{scaffold['application_id']}/hire",
|
||||
json={"role_title": "", "start_date": "2026-12-01"},
|
||||
headers=_hdr(token),
|
||||
)
|
||||
# G-324: min_length=1 → the empty string dies at pydantic (422)
|
||||
assert resp.status_code == 422
|
||||
resp = client.post(
|
||||
f"/v1/employers/applications/{scaffold['application_id']}/hire",
|
||||
json={"role_title": "Engineer"},
|
||||
headers=_hdr(token),
|
||||
)
|
||||
assert resp.status_code == 422
|
||||
|
||||
|
||||
def test_unknown_application_404(scaffold) -> None:
|
||||
resp = scaffold["client"].post(
|
||||
"/v1/employers/applications/app-nonexistent/in-review",
|
||||
headers=_hdr(scaffold["token"]),
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_withdraw_never_offered_to_employers(scaffold) -> None:
|
||||
"""D-099: employers reject — there is NO employer withdraw route."""
|
||||
client = scaffold["client"]
|
||||
resp = client.post(
|
||||
f"/v1/employers/applications/{scaffold['application_id']}/withdraw",
|
||||
headers=_hdr(scaffold["token"]),
|
||||
)
|
||||
assert resp.status_code in (404, 405)
|
||||
|
||||
|
||||
# -- the P2-review pin (P2-2 disposition): the D-089 audit lane -------------------
|
||||
|
||||
|
||||
def test_transition_appends_employer_audit_row(scaffold) -> None:
|
||||
"""P2-2 pin: every employer transition appends its
|
||||
employer_audit_events row (kind='application' — the vocabulary
|
||||
extended in this phase; the one-queryable lane)."""
|
||||
|
||||
scaffold["client"].post(
|
||||
f"/v1/employers/applications/{scaffold['application_id']}/in-review",
|
||||
headers=_hdr(scaffold["token"]),
|
||||
)
|
||||
store = scaffold["client"].app.state.employer_store
|
||||
events = store.list_audit_events(kind="application")
|
||||
assert len(events) == 1
|
||||
assert events[0].name == "application.in_review"
|
||||
assert events[0].application_id if False else True # noqa: the id rides detail
|
||||
assert events[0].detail["application_id"] == scaffold["application_id"]
|
||||
assert events[0].detail["to_status"] == "in_review"
|
||||
@@ -0,0 +1,283 @@
|
||||
"""The audit-export tests (D-108/G-321/G-325, REQ-11-004) — re-export
|
||||
determinism, chain-head consistency, methodology completeness, the
|
||||
G-325 evidence bundles, fail-closed gates."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from ai_service.marketplace.base import Employer, Posting
|
||||
from ai_service.marketplace.store import SQLiteMarketplaceStore
|
||||
|
||||
from .conftest import ADMIN_TOKEN, make_client
|
||||
|
||||
|
||||
def _seed_posting(store: SQLiteMarketplaceStore, employer_id: str) -> str:
|
||||
store.upsert_employer(
|
||||
Employer(
|
||||
id=employer_id,
|
||||
source="seed-corpus",
|
||||
external_id=employer_id,
|
||||
name=f"Labs {employer_id}",
|
||||
)
|
||||
)
|
||||
posting = store.insert_posting(
|
||||
Posting(
|
||||
id=f"post-{uuid.uuid4().hex[:10]}",
|
||||
source="admin",
|
||||
external_id=f"t-{uuid.uuid4().hex[:10]}",
|
||||
employer_id=employer_id,
|
||||
title="AI Orchestration Engineer",
|
||||
)
|
||||
)
|
||||
store.set_posting_status(posting.id, "published")
|
||||
return posting.id
|
||||
|
||||
|
||||
def _hire_direct(
|
||||
client: TestClient,
|
||||
marketplace: SQLiteMarketplaceStore,
|
||||
posting_id: str,
|
||||
learner_id: str,
|
||||
start_date: str = "2026-11-01",
|
||||
) -> str:
|
||||
from datetime import date
|
||||
|
||||
placements = client.app.state.placement_store # type: ignore[attr-defined] # TestClient.app: ASGIApp-typed, runtime FastAPI
|
||||
application, _ = marketplace.insert_application(posting_id, learner_id)
|
||||
_, placement = placements.transition_application(
|
||||
application.id,
|
||||
"hired",
|
||||
actor_type="operator",
|
||||
actor_ref="operator",
|
||||
role_title="Engineer",
|
||||
start_date=date.fromisoformat(start_date),
|
||||
)
|
||||
assert placement is not None
|
||||
return placement.id
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def scaffold(tmp_path, identity_store, marketplace_store):
|
||||
client = make_client(tmp_path, identity_store, marketplace_store)
|
||||
with client as entered:
|
||||
yield {"client": entered, "marketplace": marketplace_store}
|
||||
|
||||
|
||||
def _export(client: TestClient, token: str = ADMIN_TOKEN) -> dict:
|
||||
resp = client.get(
|
||||
"/v1/placements/admin/export", headers={"X-Admin-Token": token}
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.headers["content-type"].startswith("application/json")
|
||||
return json.loads(resp.text)
|
||||
|
||||
|
||||
# -- the G-321/G-325/R-73 contract -----------------------------------------------
|
||||
|
||||
|
||||
def test_reexport_determinism(scaffold) -> None:
|
||||
"""R-73: two exports on an unchanged DB → identical ledger chains
|
||||
+ identical head_hash, generated_at EXCLUDED from comparison."""
|
||||
s = scaffold
|
||||
posting_id = _seed_posting(s["marketplace"], "emp-a")
|
||||
_hire_direct(s["client"], s["marketplace"], posting_id, "pilot-learner")
|
||||
|
||||
first = _export(s["client"])
|
||||
second = _export(s["client"])
|
||||
|
||||
# generated_at differs (a function of the request) — everything
|
||||
# else is byte-identical (the TOTAL-exclusion rule: strip the key)
|
||||
first.pop("generated_at")
|
||||
second.pop("generated_at")
|
||||
assert first == second
|
||||
assert first["ledger"]["events"] == second["ledger"]["events"]
|
||||
assert first["ledger"]["head_hash"] == second["ledger"]["head_hash"]
|
||||
|
||||
|
||||
def test_chain_head_consistency(scaffold) -> None:
|
||||
"""head_hash = the last event's hash = verify_chain's output."""
|
||||
s = scaffold
|
||||
posting_id = _seed_posting(s["marketplace"], "emp-a")
|
||||
_hire_direct(s["client"], s["marketplace"], posting_id, "pilot-learner")
|
||||
body = _export(s["client"])
|
||||
events = body["ledger"]["events"]
|
||||
assert body["ledger"]["head_hash"] == events[-1]["hash"]
|
||||
placements = s["client"].app.state.placement_store
|
||||
ok, head = placements.verify_chain()
|
||||
assert ok is True
|
||||
assert head == body["ledger"]["head_hash"]
|
||||
|
||||
|
||||
def test_methodology_completeness(scaffold) -> None:
|
||||
"""G-321 + R-67/R-68: all four methodology keys present; the three
|
||||
pilot gaps stated explicitly; the chain proof-scope + continuity
|
||||
anchor + cross-check stated."""
|
||||
body = _export(scaffold["client"])
|
||||
methodology = body["methodology"]
|
||||
for key in ("placement_definition", "counting_rules", "verification", "scope"):
|
||||
assert key in methodology, f"methodology missing {key}"
|
||||
verification = methodology["verification"]
|
||||
scope = methodology["scope"]
|
||||
# the chain's proof scope + limit + anchor (G-321)
|
||||
assert "WHAT THE CHAIN PROVES" in verification
|
||||
assert "WHAT IT DOES NOT PROVE" in verification
|
||||
assert "export-to-export continuity" in verification or "PREFIX" in verification
|
||||
assert "revoked_count" in verification # the (c) cross-check
|
||||
# the three R-67 pilot gaps (a) (b) (c) stated in scope
|
||||
assert "minimum-employed-duration" in scope or "duration" in scope
|
||||
assert "salary" in scope
|
||||
assert "NOT third-party employment verification" in scope
|
||||
# employer-attested evidence class named
|
||||
assert "attestation" in verification
|
||||
|
||||
|
||||
def test_evidence_bundles_cover_revoked_too(scaffold) -> None:
|
||||
"""G-325: bundles cover EVERY placement (active AND revoked) with
|
||||
the full event history; cohort stays active-only."""
|
||||
s = scaffold
|
||||
posting_id = _seed_posting(s["marketplace"], "emp-a")
|
||||
placement_id = _hire_direct(s["client"], s["marketplace"], posting_id,
|
||||
"pilot-learner")
|
||||
placements = s["client"].app.state.placement_store
|
||||
placements.revoke_placement(placement_id, "operator")
|
||||
|
||||
body = _export(s["client"])
|
||||
assert body["summary"]["revoked_count"] == 1
|
||||
assert body["cohort"] == [] # active-only
|
||||
assert len(body["evidence_bundles"]) == 1 # the revoked row IS bundled
|
||||
bundle = body["evidence_bundles"][0]
|
||||
assert bundle["placement_status"] == "revoked"
|
||||
assert bundle["posting_record"] is not None
|
||||
assert bundle["application_record"] is not None
|
||||
event_types = [e["event_type"] for e in bundle["transition_history"]]
|
||||
assert "application.hired" in event_types
|
||||
assert "placement.created" in event_types
|
||||
assert "placement.revoked" in event_types
|
||||
# the (c) cross-check: revoked_count == the placement.revoked events
|
||||
revoked_events = [
|
||||
e for e in body["ledger"]["events"] if e["event_type"] == "placement.revoked"
|
||||
]
|
||||
assert body["summary"]["revoked_count"] == len(revoked_events)
|
||||
|
||||
|
||||
def test_export_fail_closed(scaffold) -> None:
|
||||
resp = scaffold["client"].get("/v1/placements/admin/export")
|
||||
assert resp.status_code == 403
|
||||
resp = scaffold["client"].get("/v1/placements/admin/export/summary")
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_summary_renders_from_the_same_document(scaffold) -> None:
|
||||
"""D-108's anti-drift rule: the summary's totals equal the export's
|
||||
summary block on the same DB state; the content type is markdown;
|
||||
the G-328 attestation title + the head hash present."""
|
||||
s = scaffold
|
||||
posting_id = _seed_posting(s["marketplace"], "emp-a")
|
||||
_hire_direct(s["client"], s["marketplace"], posting_id, "pilot-learner")
|
||||
export = _export(s["client"])
|
||||
resp = s["client"].get(
|
||||
"/v1/placements/admin/export/summary", headers={"X-Admin-Token": ADMIN_TOKEN}
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.headers["content-type"].startswith("text/markdown")
|
||||
text = resp.text
|
||||
assert "Employer-attested, operator-overseen" in text # G-328 title
|
||||
assert f"| {export['summary']['distinct_learners_placed']} |" in text
|
||||
assert export["ledger"]["head_hash"] in text # the walk instructions
|
||||
assert "PREFIX" in text # the export-to-export continuity anchor
|
||||
|
||||
|
||||
# -- the full-chain E2E proof (the milestone's reason to exist) -------------------
|
||||
|
||||
|
||||
def test_e2e_audit_proof(scaffold) -> None:
|
||||
"""The machine-checked E2E (D-111): register → claim → admin
|
||||
approve → posting → verified learner applies → transitions →
|
||||
hire → placement created → chain green → export reproduces →
|
||||
counts the learner ONCE."""
|
||||
from ai_service.employers.store import SQLiteEmployerStore
|
||||
|
||||
s = scaffold
|
||||
client = s["client"]
|
||||
# fresh employer store on the shared file (the scaffold's client
|
||||
# wires one only through the lifespan default — build it here)
|
||||
import pathlib
|
||||
|
||||
db_path = pathlib.Path(
|
||||
str(client.app.state.marketplace_store._db_path) # the shared tmp file (test seam)
|
||||
)
|
||||
employer_store = SQLiteEmployerStore(db_path=db_path)
|
||||
client.app.state.employer_store = employer_store
|
||||
|
||||
posting_id = _seed_posting(s["marketplace"], "emp-a")
|
||||
|
||||
# employer registers → claims → operator approves
|
||||
resp = client.post(
|
||||
"/v1/employers/auth/register",
|
||||
json={"email": "e2e@example.com", "password": "hunter2hunter2"},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
token = resp.json()["token"]
|
||||
resp = client.post(
|
||||
"/v1/employers/claims",
|
||||
json={"employer_id": "emp-a"},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert resp.status_code in (200, 201)
|
||||
claim_id = resp.json()["id"]
|
||||
resp = client.post(
|
||||
f"/v1/employers/admin/claims/{claim_id}/approve",
|
||||
headers={"X-Admin-Token": ADMIN_TOKEN},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
# verified learner applies
|
||||
resp = client.post(
|
||||
"/v1/marketplace/apply",
|
||||
json={"posting_id": posting_id, "learner_id": "pilot-learner"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
application_id = resp.json()["application_id"]
|
||||
|
||||
# employer drives in_review → interview → offer → hire
|
||||
for target in ("in-review", "interview", "offer"):
|
||||
resp = client.post(
|
||||
f"/v1/employers/applications/{application_id}/{target}",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
resp = client.post(
|
||||
f"/v1/employers/applications/{application_id}/hire",
|
||||
json={"role_title": "AI Orchestration Engineer", "start_date": "2026-11-01"},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["placement"]["status"] == "active"
|
||||
|
||||
# the chain walks green
|
||||
placements = client.app.state.placement_store
|
||||
ok, _ = placements.verify_chain()
|
||||
assert ok is True
|
||||
|
||||
# the export reproduces (chain + head identical, generated_at excluded)
|
||||
first = _export(client)
|
||||
first.pop("generated_at")
|
||||
second = _export(client)
|
||||
second.pop("generated_at")
|
||||
assert first == second
|
||||
|
||||
# the overview counts the learner ONCE toward 1,000
|
||||
resp = client.get(
|
||||
"/v1/placements/admin/overview", headers={"X-Admin-Token": ADMIN_TOKEN}
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
overview = resp.json()
|
||||
assert overview["distinct_learners_placed"] == 1
|
||||
assert overview["total_active_placements"] == 1
|
||||
assert overview["guiding_goal"] == 1000
|
||||
assert overview["by_cohort_month"] == [{"month": "2026-11", "count": 1}]
|
||||
@@ -0,0 +1,260 @@
|
||||
"""Learner application-status tests (D-104/G-322, REQ-11-005) — the
|
||||
mine-read discovery arms, the status read's scoped-history + hired
|
||||
placement arms, the revoked honest-null arm, and the reads-stay-free
|
||||
pin (no self-429, G-313)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from ai_service.marketplace.base import Posting
|
||||
from ai_service.marketplace.store import SQLiteMarketplaceStore
|
||||
|
||||
from .conftest import ADMIN_TOKEN, PLACEMENT_LEARNERS, make_client
|
||||
|
||||
|
||||
def _seed_posting(store: SQLiteMarketplaceStore, employer_id: str = "emp-test") -> str:
|
||||
import uuid
|
||||
|
||||
posting = store.insert_posting(
|
||||
Posting(
|
||||
id=f"post-{uuid.uuid4().hex[:12]}",
|
||||
source="admin",
|
||||
external_id=f"test-{uuid.uuid4().hex[:8]}",
|
||||
employer_id=employer_id,
|
||||
title="AI Orchestration Engineer",
|
||||
)
|
||||
)
|
||||
# the learner apply route requires published (409 on drafts)
|
||||
store.set_posting_status(posting.id, "published")
|
||||
return posting.id
|
||||
|
||||
|
||||
def _apply(client: TestClient, posting_id: str, learner_id: str = "pilot-learner") -> str:
|
||||
resp = client.post(
|
||||
"/v1/marketplace/apply", json={"posting_id": posting_id, "learner_id": learner_id}
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
return resp.json()["application_id"]
|
||||
|
||||
|
||||
def _admin(token: str = ADMIN_TOKEN) -> dict[str, str]:
|
||||
return {"X-Admin-Token": token}
|
||||
|
||||
|
||||
def _transition(client: TestClient, application_id: str, body: dict) -> None:
|
||||
resp = client.post(
|
||||
f"/v1/placements/admin/applications/{application_id}/transition",
|
||||
json=body,
|
||||
headers=_admin(),
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
|
||||
def _status(client: TestClient, application_id: str, learner_id: str):
|
||||
return client.get(
|
||||
f"/v1/marketplace/applications/{application_id}",
|
||||
params={"learner_id": learner_id},
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def scaffold(tmp_path, identity_store, marketplace_store):
|
||||
client = make_client(tmp_path, identity_store, marketplace_store)
|
||||
with client as entered:
|
||||
posting_id = _seed_posting(marketplace_store)
|
||||
application_id = _apply(entered, posting_id)
|
||||
yield {
|
||||
"client": entered,
|
||||
"posting_id": posting_id,
|
||||
"application_id": application_id,
|
||||
}
|
||||
|
||||
|
||||
# -- the mine-read (G-322): discovery on revisit -------------------------------
|
||||
|
||||
|
||||
def test_mine_own_200(scaffold) -> None:
|
||||
resp = scaffold["client"].get(
|
||||
"/v1/marketplace/applications/mine",
|
||||
params={"posting_id": scaffold["posting_id"], "learner_id": "pilot-learner"},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert body["application_id"] == scaffold["application_id"]
|
||||
assert body["status"] == "submitted"
|
||||
|
||||
|
||||
def test_mine_none_404(scaffold) -> None:
|
||||
resp = scaffold["client"].get(
|
||||
"/v1/marketplace/applications/mine",
|
||||
params={"posting_id": scaffold["posting_id"], "learner_id": "pilot-learner-2"},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_mine_unallowlisted_403(scaffold) -> None:
|
||||
resp = scaffold["client"].get(
|
||||
"/v1/marketplace/applications/mine",
|
||||
params={"posting_id": scaffold["posting_id"], "learner_id": "intruder"},
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_mine_unverified_403(tmp_path, identity_store, marketplace_store) -> None:
|
||||
"""A learner on the roster but WITHOUT a verified identity record →
|
||||
the verified-18+ arm refuses (the D-043 composition)."""
|
||||
client = make_client(tmp_path, identity_store, marketplace_store)
|
||||
with client as entered:
|
||||
posting_id = _seed_posting(marketplace_store)
|
||||
from ..conftest import SUITE_LEARNERS
|
||||
|
||||
unverified = next(name for name in SUITE_LEARNERS if name not in PLACEMENT_LEARNERS)
|
||||
resp = entered.get(
|
||||
"/v1/marketplace/applications/mine",
|
||||
params={"posting_id": posting_id, "learner_id": unverified},
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
# -- the status read (D-104): status + scoped history + placement ---------------
|
||||
|
||||
|
||||
def test_status_own_200_with_events(scaffold) -> None:
|
||||
client = scaffold["client"]
|
||||
_transition(client, scaffold["application_id"], {"to": "in_review"})
|
||||
resp = _status(client, scaffold["application_id"], "pilot-learner")
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert body["application"]["id"] == scaffold["application_id"]
|
||||
assert body["application"]["posting_id"] == scaffold["posting_id"]
|
||||
assert body["application"]["learner_id"] == "pilot-learner"
|
||||
assert body["application"]["status"] == "in_review"
|
||||
assert [e["event_type"] for e in body["events"]] == ["application.in_review"]
|
||||
assert body["placement"] is None
|
||||
|
||||
|
||||
def test_status_events_scoped_and_id_ordered(scaffold) -> None:
|
||||
"""The events arm is a SCOPED query: another learner's events never
|
||||
ride this read, and the history renders id-ordered (the chain
|
||||
order)."""
|
||||
client = scaffold["client"]
|
||||
import uuid
|
||||
|
||||
store = client.app.state.marketplace_store
|
||||
posting2 = store.insert_posting(
|
||||
Posting(
|
||||
id=f"post-b-{uuid.uuid4().hex[:6]}",
|
||||
source="admin",
|
||||
external_id=f"b-{uuid.uuid4().hex[:8]}",
|
||||
employer_id="emp-test",
|
||||
title="Second posting",
|
||||
)
|
||||
)
|
||||
store.set_posting_status(posting2.id, "published")
|
||||
other = _apply(client, posting2.id, learner_id="pilot-learner-2")
|
||||
_transition(client, other, {"to": "interview"})
|
||||
_transition(client, scaffold["application_id"], {"to": "in_review"})
|
||||
_transition(client, scaffold["application_id"], {"to": "interview"})
|
||||
resp = _status(client, scaffold["application_id"], "pilot-learner")
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert [e["event_type"] for e in body["events"]] == [
|
||||
"application.in_review",
|
||||
"application.interview",
|
||||
]
|
||||
assert all(
|
||||
e["application_id"] == scaffold["application_id"] for e in body["events"]
|
||||
)
|
||||
|
||||
|
||||
def test_status_hired_carries_placement(scaffold) -> None:
|
||||
client = scaffold["client"]
|
||||
_transition(
|
||||
client,
|
||||
scaffold["application_id"],
|
||||
{"to": "hired", "role_title": "Placement Engineer", "start_date": "2026-11-02"},
|
||||
)
|
||||
resp = _status(client, scaffold["application_id"], "pilot-learner")
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert body["application"]["status"] == "hired"
|
||||
placement = body["placement"]
|
||||
assert placement is not None
|
||||
assert placement["role_title"] == "Placement Engineer"
|
||||
assert placement["start_date"] == "2026-11-02"
|
||||
assert placement["application_id"] == scaffold["application_id"]
|
||||
assert placement["learner_id"] == "pilot-learner"
|
||||
assert placement["employer_id"] == "emp-test"
|
||||
assert placement["posting_id"] == scaffold["posting_id"]
|
||||
# hired → the two-event chain: application.hired + placement.created
|
||||
assert [e["event_type"] for e in body["events"]] == [
|
||||
"application.hired",
|
||||
"placement.created",
|
||||
]
|
||||
|
||||
|
||||
def test_status_revoked_placement_is_honest_null(scaffold) -> None:
|
||||
"""A-304: no active placement record renders after the operator
|
||||
revoke — the honest null (the events carry the correction trail,
|
||||
never a fabricated badge)."""
|
||||
client = scaffold["client"]
|
||||
app_id = scaffold["application_id"]
|
||||
_transition(
|
||||
client,
|
||||
app_id,
|
||||
{"to": "hired", "role_title": "Placement Engineer", "start_date": "2026-11-02"},
|
||||
)
|
||||
placement_id = _status(client, app_id, "pilot-learner").json()["placement"]["id"]
|
||||
resp = client.post(
|
||||
f"/v1/placements/admin/placements/{placement_id}/revoke",
|
||||
headers=_admin(),
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
resp = _status(client, app_id, "pilot-learner")
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert body["placement"] is None
|
||||
assert body["events"][-1]["event_type"] == "placement.revoked"
|
||||
|
||||
|
||||
def test_status_foreign_learner_403(scaffold) -> None:
|
||||
resp = _status(scaffold["client"], scaffold["application_id"], "pilot-learner-2")
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_status_unallowlisted_403(scaffold) -> None:
|
||||
resp = _status(scaffold["client"], scaffold["application_id"], "intruder")
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_status_unverified_403(tmp_path, identity_store, marketplace_store) -> None:
|
||||
client = make_client(tmp_path, identity_store, marketplace_store)
|
||||
with client as entered:
|
||||
posting_id = _seed_posting(marketplace_store)
|
||||
from ..conftest import SUITE_LEARNERS
|
||||
|
||||
unverified = next(name for name in SUITE_LEARNERS if name not in PLACEMENT_LEARNERS)
|
||||
marketplace_store.insert_application(posting_id, unverified)
|
||||
application = marketplace_store.list_applications(posting_id)[0]
|
||||
resp = _status(entered, application.id, unverified)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_status_unknown_404(scaffold) -> None:
|
||||
resp = _status(scaffold["client"], "app-nonexistent", "pilot-learner")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# -- G-313: reads never consume the apply-rate budget ----------------------------
|
||||
|
||||
|
||||
def test_status_reads_stay_free_no_self_429(scaffold) -> None:
|
||||
"""A-408's distinct-WRITE rule: status reads stay free — a revisit
|
||||
storm of reads never 429s (the conftest reset proves the next
|
||||
test starts clean either way)."""
|
||||
client = scaffold["client"]
|
||||
for _ in range(10):
|
||||
resp = _status(client, scaffold["application_id"], "pilot-learner")
|
||||
assert resp.status_code == 200, resp.text
|
||||
@@ -0,0 +1,226 @@
|
||||
"""Learner withdraw tests (D-104/G-323, REQ-11-002) — the D-043 gate
|
||||
stack + the terminal arms + the apply-route terminal guard."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from ai_service.marketplace.base import Posting
|
||||
from ai_service.marketplace.store import SQLiteMarketplaceStore
|
||||
|
||||
from .conftest import PLACEMENT_LEARNERS, make_client
|
||||
|
||||
|
||||
def _seed_posting(store: SQLiteMarketplaceStore, employer_id: str = "emp-test") -> str:
|
||||
import uuid
|
||||
|
||||
posting = store.insert_posting(
|
||||
Posting(
|
||||
id=f"post-{uuid.uuid4().hex[:12]}",
|
||||
source="admin",
|
||||
external_id=f"test-{uuid.uuid4().hex[:8]}",
|
||||
employer_id=employer_id,
|
||||
title="AI Orchestration Engineer",
|
||||
)
|
||||
)
|
||||
# the learner apply route requires published (409 on drafts)
|
||||
store.set_posting_status(posting.id, "published")
|
||||
return posting.id
|
||||
|
||||
|
||||
def _apply(client: TestClient, posting_id: str, learner_id: str = "pilot-learner") -> str:
|
||||
resp = client.post(
|
||||
"/v1/marketplace/apply", json={"posting_id": posting_id, "learner_id": learner_id}
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
return resp.json()["application_id"]
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def scaffold(tmp_path, identity_store, marketplace_store):
|
||||
client = make_client(tmp_path, identity_store, marketplace_store)
|
||||
with client as entered:
|
||||
posting_id = _seed_posting(marketplace_store)
|
||||
application_id = _apply(entered, posting_id)
|
||||
yield {
|
||||
"client": entered,
|
||||
"posting_id": posting_id,
|
||||
"application_id": application_id,
|
||||
}
|
||||
|
||||
|
||||
# -- the D-043 gate stack (G-5 → verified 18+ → the apply-rate window) ------------
|
||||
|
||||
|
||||
def test_withdraw_own_submitted(scaffold) -> None:
|
||||
resp = scaffold["client"].post(
|
||||
f"/v1/marketplace/applications/{scaffold['application_id']}/withdraw",
|
||||
json={"learner_id": "pilot-learner"},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["status"] == "withdrawn"
|
||||
|
||||
|
||||
def test_withdraw_unallowlisted_403(scaffold) -> None:
|
||||
resp = scaffold["client"].post(
|
||||
f"/v1/marketplace/applications/{scaffold['application_id']}/withdraw",
|
||||
json={"learner_id": "intruder"},
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_withdraw_unverified_403(tmp_path, identity_store, marketplace_store) -> None:
|
||||
"""A learner on the roster but WITHOUT a verified identity record →
|
||||
the verified-18+ arm refuses (the D-043 composition)."""
|
||||
client = make_client(tmp_path, identity_store, marketplace_store)
|
||||
with client as entered:
|
||||
posting_id = _seed_posting(marketplace_store)
|
||||
# an allowlisted-but-unverified learner: the suite roster carries
|
||||
# learners beyond PLACEMENT_LEARNERS (the tests/conftest SUITE
|
||||
# list); the identity store only seeds PLACEMENT_LEARNERS.
|
||||
from ..conftest import SUITE_LEARNERS
|
||||
|
||||
unverified = next(name for name in SUITE_LEARNERS if name not in PLACEMENT_LEARNERS)
|
||||
# even the APPLY is refused for an unverified learner (the same
|
||||
# D-043 stack) — the withdraw arm pins identically: build the
|
||||
# application row directly through the store, then attempt the
|
||||
# withdraw through the route.
|
||||
marketplace_store.insert_application(posting_id, unverified)
|
||||
application = marketplace_store.list_applications(posting_id)[0]
|
||||
resp = entered.post(
|
||||
f"/v1/marketplace/applications/{application.id}/withdraw",
|
||||
json={"learner_id": unverified},
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_withdraw_foreign_learner_403(scaffold) -> None:
|
||||
resp = scaffold["client"].post(
|
||||
f"/v1/marketplace/applications/{scaffold['application_id']}/withdraw",
|
||||
json={"learner_id": "pilot-learner-2"},
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_withdraw_terminal_409(scaffold) -> None:
|
||||
client = scaffold["client"]
|
||||
client.post(
|
||||
f"/v1/marketplace/applications/{scaffold['application_id']}/withdraw",
|
||||
json={"learner_id": "pilot-learner"},
|
||||
)
|
||||
resp = client.post(
|
||||
f"/v1/marketplace/applications/{scaffold['application_id']}/withdraw",
|
||||
json={"learner_id": "pilot-learner"},
|
||||
)
|
||||
assert resp.status_code == 409
|
||||
|
||||
|
||||
def test_withdraw_unknown_404(scaffold) -> None:
|
||||
resp = scaffold["client"].post(
|
||||
"/v1/marketplace/applications/app-nonexistent/withdraw",
|
||||
json={"learner_id": "pilot-learner"},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# -- G-323: apply on a terminal application → 409, never a 200 lie --------------
|
||||
|
||||
|
||||
def test_reapply_after_withdraw_409(scaffold) -> None:
|
||||
client = scaffold["client"]
|
||||
client.post(
|
||||
f"/v1/marketplace/applications/{scaffold['application_id']}/withdraw",
|
||||
json={"learner_id": "pilot-learner"},
|
||||
)
|
||||
resp = client.post(
|
||||
"/v1/marketplace/apply",
|
||||
json={"posting_id": scaffold["posting_id"], "learner_id": "pilot-learner"},
|
||||
)
|
||||
assert resp.status_code == 409
|
||||
assert resp.json()["detail"]["reason"] == "application_terminal"
|
||||
assert resp.json()["detail"]["status"] == "withdrawn"
|
||||
|
||||
|
||||
def test_apply_idempotent_still_200_on_submitted(scaffold) -> None:
|
||||
"""A-408 unchanged: a duplicate apply on a LIVE application still
|
||||
returns the 200 with the existing row (G-323 guards only terminals)."""
|
||||
resp = scaffold["client"].post(
|
||||
"/v1/marketplace/apply",
|
||||
json={"posting_id": scaffold["posting_id"], "learner_id": "pilot-learner"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["application_id"] == scaffold["application_id"]
|
||||
|
||||
|
||||
# -- the P2-review pins (P2-1/P2-2 disposition): provenance + budget -------------
|
||||
|
||||
|
||||
def test_withdraw_ledger_actor_is_learner(scaffold) -> None:
|
||||
"""D-104 pin: the withdraw transition's ledger event records
|
||||
actor_type='learner' with the learner's actor_ref."""
|
||||
|
||||
scaffold["client"].post(
|
||||
f"/v1/marketplace/applications/{scaffold['application_id']}/withdraw",
|
||||
json={"learner_id": "pilot-learner"},
|
||||
)
|
||||
placements = scaffold["client"].app.state.placement_store
|
||||
events = placements.get_application_events(scaffold["application_id"])
|
||||
assert len(events) == 1
|
||||
assert events[0].event_type == "application.withdrawn"
|
||||
assert events[0].actor_type == "learner"
|
||||
assert events[0].actor_ref == "learner:pilot-learner"
|
||||
|
||||
|
||||
def test_withdraw_rides_the_apply_rate_window(scaffold) -> None:
|
||||
"""D-104/G-313 pin: withdraw shares the apply-rate window. Budget:
|
||||
the scaffold's apply consumed 1; two withdraws (2,3) + one apply
|
||||
(4) exhaust the 5/60s window; the NEXT gated action 429s honestly
|
||||
(the conftest reset proves the next test starts clean)."""
|
||||
client = scaffold["client"]
|
||||
import uuid
|
||||
|
||||
from ai_service.marketplace.base import Posting
|
||||
|
||||
store = client.app.state.marketplace_store
|
||||
# withdraw the scaffold application (action 2)
|
||||
resp = client.post(
|
||||
f"/v1/marketplace/applications/{scaffold['application_id']}/withdraw",
|
||||
json={"learner_id": "pilot-learner"},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
# a second application: apply (action 3) + withdraw (action 4)
|
||||
posting = store.insert_posting(
|
||||
Posting(
|
||||
id=f"post-w-{uuid.uuid4().hex[:6]}",
|
||||
source="admin",
|
||||
external_id=f"w-{uuid.uuid4().hex[:8]}",
|
||||
employer_id="emp-test",
|
||||
title="Posting",
|
||||
)
|
||||
)
|
||||
store.set_posting_status(posting.id, "published")
|
||||
application_id = _apply(client, posting.id)
|
||||
resp = client.post(
|
||||
f"/v1/marketplace/applications/{application_id}/withdraw",
|
||||
json={"learner_id": "pilot-learner"},
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
# a third application: the apply is the 5th gated action — green;
|
||||
# its withdraw would be the 6th → 429
|
||||
posting2 = store.insert_posting(
|
||||
Posting(
|
||||
id=f"post-w2-{uuid.uuid4().hex[:6]}",
|
||||
source="admin",
|
||||
external_id=f"w2-{uuid.uuid4().hex[:8]}",
|
||||
employer_id="emp-test",
|
||||
title="Posting",
|
||||
)
|
||||
)
|
||||
store.set_posting_status(posting2.id, "published")
|
||||
application2 = _apply(client, posting2.id)
|
||||
resp = client.post(
|
||||
f"/v1/marketplace/applications/{application2}/withdraw",
|
||||
json={"learner_id": "pilot-learner"},
|
||||
)
|
||||
assert resp.status_code == 429
|
||||
@@ -0,0 +1,220 @@
|
||||
"""Reporting tests (D-107, REQ-11-003) — the distinct-learner rule,
|
||||
cohort bucketing, revoke exclusion, the per-employer view's gates."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from ai_service.marketplace.base import Employer, Posting
|
||||
from ai_service.marketplace.store import SQLiteMarketplaceStore
|
||||
from ai_service.placements.store import SQLitePlacementStore
|
||||
|
||||
from .conftest import ADMIN_TOKEN, make_client
|
||||
|
||||
|
||||
def _seed_posting(store: SQLiteMarketplaceStore, employer_id: str) -> str:
|
||||
store.upsert_employer(
|
||||
Employer(
|
||||
id=employer_id,
|
||||
source="seed-corpus",
|
||||
external_id=employer_id,
|
||||
name=f"Labs {employer_id}",
|
||||
)
|
||||
)
|
||||
posting = store.insert_posting(
|
||||
Posting(
|
||||
id=f"post-{uuid.uuid4().hex[:10]}",
|
||||
source="admin",
|
||||
external_id=f"t-{uuid.uuid4().hex[:10]}",
|
||||
employer_id=employer_id,
|
||||
title="AI Orchestration Engineer",
|
||||
)
|
||||
)
|
||||
store.set_posting_status(posting.id, "published")
|
||||
return posting.id
|
||||
|
||||
|
||||
def _hire_direct(
|
||||
placements: SQLitePlacementStore,
|
||||
marketplace: SQLiteMarketplaceStore,
|
||||
posting_id: str,
|
||||
learner_id: str,
|
||||
role_title: str = "Engineer",
|
||||
start_date: str = "2026-11-01",
|
||||
) -> str:
|
||||
"""Direct store-level hire (the route arms are P2-tested; here we
|
||||
exercise the REPORTING computation over placement rows)."""
|
||||
application, _ = marketplace.insert_application(posting_id, learner_id)
|
||||
_, placement = placements.transition_application(
|
||||
application.id,
|
||||
"hired",
|
||||
actor_type="operator",
|
||||
actor_ref="operator",
|
||||
role_title=role_title,
|
||||
start_date=__import__("datetime").date.fromisoformat(start_date),
|
||||
)
|
||||
assert placement is not None
|
||||
return placement.id
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def scaffold(tmp_path, identity_store, marketplace_store):
|
||||
client = make_client(tmp_path, identity_store, marketplace_store)
|
||||
with client as entered:
|
||||
placements = entered.app.state.placement_store # type: ignore[attr-defined] # TestClient.app: ASGIApp-typed, runtime FastAPI
|
||||
yield {
|
||||
"client": entered,
|
||||
"placements": placements,
|
||||
"marketplace": marketplace_store,
|
||||
}
|
||||
|
||||
|
||||
def _overview(client: TestClient) -> dict:
|
||||
resp = client.get(
|
||||
"/v1/placements/admin/overview", headers={"X-Admin-Token": ADMIN_TOKEN}
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
return resp.json()
|
||||
|
||||
|
||||
def test_empty_overview_honest_zeroes(scaffold) -> None:
|
||||
body = _overview(scaffold["client"])
|
||||
assert body["total_active_placements"] == 0
|
||||
assert body["distinct_learners_placed"] == 0
|
||||
assert body["guiding_goal"] == 1000
|
||||
assert body["by_employer"] == []
|
||||
assert body["by_cohort_month"] == []
|
||||
|
||||
|
||||
def test_distinct_learner_rule(scaffold) -> None:
|
||||
"""D-107: a learner placed TWICE counts ONCE toward the guiding
|
||||
goal (the outcome's noun is learners); both placements count in
|
||||
total_active_placements."""
|
||||
s = scaffold
|
||||
posting_a = _seed_posting(s["marketplace"], "emp-a")
|
||||
posting_b = _seed_posting(s["marketplace"], "emp-b")
|
||||
_hire_direct(s["placements"], s["marketplace"], posting_a, "pilot-learner")
|
||||
_hire_direct(s["placements"], s["marketplace"], posting_b, "pilot-learner")
|
||||
body = _overview(s["client"])
|
||||
assert body["total_active_placements"] == 2
|
||||
assert body["distinct_learners_placed"] == 1 # placed twice, counted once
|
||||
assert body["by_employer"] == [
|
||||
{"employer_id": "emp-a", "count": 1},
|
||||
{"employer_id": "emp-b", "count": 1},
|
||||
]
|
||||
|
||||
|
||||
def test_cohort_month_bucketing(scaffold) -> None:
|
||||
"""D-107/D-103: cohort month = the calendar month of start_date —
|
||||
two starts in one month share a bucket; a future-dated start
|
||||
lands in its own month."""
|
||||
s = scaffold
|
||||
posting_a = _seed_posting(s["marketplace"], "emp-a")
|
||||
posting_b = _seed_posting(s["marketplace"], "emp-a")
|
||||
posting_c = _seed_posting(s["marketplace"], "emp-a")
|
||||
_hire_direct(s["placements"], s["marketplace"], posting_a, "pilot-learner",
|
||||
start_date="2026-11-01")
|
||||
_hire_direct(s["placements"], s["marketplace"], posting_b, "pilot-learner-2",
|
||||
start_date="2026-11-20")
|
||||
_hire_direct(s["placements"], s["marketplace"], posting_c, "api-learner",
|
||||
start_date="2027-02-05")
|
||||
body = _overview(s["client"])
|
||||
assert body["by_cohort_month"] == [
|
||||
{"month": "2026-11", "count": 2},
|
||||
{"month": "2027-02", "count": 1},
|
||||
]
|
||||
|
||||
|
||||
def test_revoke_excluded_from_counts(scaffold) -> None:
|
||||
"""D-102/D-107: revoked placements are gone from every count; a
|
||||
learner whose ONLY placement was revoked is back to 0."""
|
||||
s = scaffold
|
||||
posting = _seed_posting(s["marketplace"], "emp-a")
|
||||
placement_id = _hire_direct(s["placements"], s["marketplace"], posting,
|
||||
"pilot-learner")
|
||||
before = _overview(s["client"])
|
||||
assert before["distinct_learners_placed"] == 1
|
||||
|
||||
s["placements"].revoke_placement(placement_id, "operator")
|
||||
|
||||
after = _overview(s["client"])
|
||||
assert after["total_active_placements"] == 0
|
||||
assert after["distinct_learners_placed"] == 0 # back to 0
|
||||
assert after["by_employer"] == []
|
||||
assert after["by_cohort_month"] == []
|
||||
|
||||
|
||||
def test_overview_fail_closed(scaffold) -> None:
|
||||
resp = scaffold["client"].get("/v1/placements/admin/overview")
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_per_employer_view_own_rows(scaffold) -> None:
|
||||
"""The employer placement view: session-gated, own claimed entity
|
||||
only; honest empty state (403 + claim-CTA unclaimed; 401 no token)."""
|
||||
s = scaffold
|
||||
posting = _seed_posting(s["marketplace"], "emp-a")
|
||||
_hire_direct(s["placements"], s["marketplace"], posting, "pilot-learner")
|
||||
|
||||
# 401 without a session
|
||||
resp = s["client"].get("/v1/employers/placements")
|
||||
assert resp.status_code == 401
|
||||
|
||||
# 403 + claim-CTA when unclaimed
|
||||
resp = s["client"].post(
|
||||
"/v1/employers/auth/register",
|
||||
json={"email": "viewer@example.com", "password": "hunter2hunter2"},
|
||||
)
|
||||
token = resp.json()["token"]
|
||||
resp = s["client"].get(
|
||||
"/v1/employers/placements", headers={"Authorization": f"Bearer {token}"}
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
assert "no_claimed_employer" in str(resp.json()["detail"])
|
||||
|
||||
# the honest empty state for a claimed entity with zero placements
|
||||
store = s["client"].app.state.marketplace_store
|
||||
store.upsert_employer(
|
||||
Employer(
|
||||
id="emp-b",
|
||||
source="seed-corpus",
|
||||
external_id="emp-b",
|
||||
name="Labs emp-b",
|
||||
)
|
||||
)
|
||||
resp = s["client"].post(
|
||||
"/v1/employers/claims",
|
||||
json={"employer_id": "emp-b"},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert resp.status_code in (200, 201), resp.text
|
||||
claim_id = resp.json()["id"]
|
||||
s["client"].post(
|
||||
f"/v1/employers/admin/claims/{claim_id}/approve",
|
||||
headers={"X-Admin-Token": ADMIN_TOKEN},
|
||||
)
|
||||
resp = s["client"].get(
|
||||
"/v1/employers/placements", headers={"Authorization": f"Bearer {token}"}
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["total"] == 0
|
||||
assert resp.json()["placements"] == []
|
||||
|
||||
|
||||
def test_by_employer_ordering(scaffold) -> None:
|
||||
"""R-73: by_employer is count desc then employer_id ascending."""
|
||||
s = scaffold
|
||||
posting_a1 = _seed_posting(s["marketplace"], "emp-a")
|
||||
posting_b1 = _seed_posting(s["marketplace"], "emp-b")
|
||||
posting_b2 = _seed_posting(s["marketplace"], "emp-b")
|
||||
_hire_direct(s["placements"], s["marketplace"], posting_a1, "pilot-learner")
|
||||
_hire_direct(s["placements"], s["marketplace"], posting_b1, "pilot-learner-2")
|
||||
_hire_direct(s["placements"], s["marketplace"], posting_b2, "api-learner")
|
||||
body = _overview(s["client"])
|
||||
assert body["by_employer"] == [
|
||||
{"employer_id": "emp-b", "count": 2},
|
||||
{"employer_id": "emp-a", "count": 1},
|
||||
]
|
||||
@@ -0,0 +1,585 @@
|
||||
"""Placement store tests (D-111, REQ-11-001) — the ledger's adversarial
|
||||
suite: chain verification, tamper, fork, concurrency, no-delete-path,
|
||||
the D-099 matrix, the D-102 revoke/re-hire lane.
|
||||
|
||||
Real stores on tmp_path DBs, zero network by construction (no
|
||||
MockTransport needed — no external calls exist), real scrypt-free
|
||||
paths, honest assertions (behavior over bytes).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
import uuid
|
||||
from datetime import UTC, date, datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlmodel import Session
|
||||
|
||||
from ai_service.marketplace.base import Posting
|
||||
from ai_service.marketplace.store import (
|
||||
ApplicationRow,
|
||||
SQLiteMarketplaceStore,
|
||||
)
|
||||
from ai_service.placements.base import (
|
||||
APPLICATION_STATUSES,
|
||||
GENESIS_HASH,
|
||||
TRANSITION_MATRIX,
|
||||
InvalidTransition,
|
||||
validate_transition,
|
||||
)
|
||||
from ai_service.placements.store import PlacementEventRow, SQLitePlacementStore
|
||||
|
||||
# -- helpers -----------------------------------------------------------------
|
||||
|
||||
|
||||
def _seed_posting(store: SQLiteMarketplaceStore, employer_id: str = "emp-test") -> str:
|
||||
"""Insert a draft posting via the store's own path → its id."""
|
||||
posting = store.insert_posting(
|
||||
Posting(
|
||||
id=f"post-{uuid.uuid4().hex[:12]}",
|
||||
source="admin",
|
||||
external_id=f"test-{uuid.uuid4().hex[:8]}",
|
||||
employer_id=employer_id,
|
||||
title="AI Orchestration Engineer",
|
||||
description="Test posting",
|
||||
required_competencies=["orchestration"],
|
||||
skills=["python"],
|
||||
)
|
||||
)
|
||||
return posting.id
|
||||
|
||||
|
||||
def _apply(store: SQLiteMarketplaceStore, posting_id: str, learner_id: str) -> ApplicationRow:
|
||||
application, _created = store.insert_application(posting_id, learner_id)
|
||||
with Session(store._engine) as session:
|
||||
row = session.get(ApplicationRow, application.id)
|
||||
assert row is not None
|
||||
return row
|
||||
|
||||
|
||||
def _hire(
|
||||
store: SQLitePlacementStore,
|
||||
application_id: str,
|
||||
actor: str = "operator",
|
||||
now: datetime | None = None,
|
||||
) -> object:
|
||||
return store.transition_application(
|
||||
application_id,
|
||||
"hired",
|
||||
actor_type="operator",
|
||||
actor_ref=actor,
|
||||
role_title="AI Orchestration Engineer",
|
||||
start_date=date(2026, 11, 1),
|
||||
now=now,
|
||||
)
|
||||
|
||||
|
||||
# -- D-099 matrix (base contracts) ---------------------------------------------
|
||||
|
||||
|
||||
def test_genesis_hash_pinned() -> None:
|
||||
assert GENESIS_HASH == "0" * 64
|
||||
|
||||
|
||||
def test_transition_matrix_forward_only_with_skip_forward() -> None:
|
||||
# forward legal
|
||||
assert "in_review" in TRANSITION_MATRIX["submitted"]
|
||||
# skip-forward legal (the fast-track hire — R-71)
|
||||
assert "hired" in TRANSITION_MATRIX["submitted"]
|
||||
assert "interview" in TRANSITION_MATRIX["submitted"]
|
||||
# terminals empty
|
||||
assert TRANSITION_MATRIX["hired"] == ()
|
||||
assert TRANSITION_MATRIX["rejected"] == ()
|
||||
assert TRANSITION_MATRIX["withdrawn"] == ()
|
||||
# every status is a matrix key
|
||||
for status in APPLICATION_STATUSES:
|
||||
assert status in TRANSITION_MATRIX
|
||||
|
||||
|
||||
def test_validate_transition_arms() -> None:
|
||||
validate_transition("submitted", "hired") # skip-forward
|
||||
validate_transition("in_review", "rejected")
|
||||
with pytest.raises(InvalidTransition):
|
||||
validate_transition("offer", "in_review") # backward — illegal
|
||||
with pytest.raises(InvalidTransition):
|
||||
validate_transition("hired", "rejected") # terminal-source
|
||||
with pytest.raises(InvalidTransition):
|
||||
validate_transition("submitted", "garbage") # unknown target
|
||||
with pytest.raises(InvalidTransition):
|
||||
validate_transition("garbage", "hired") # unknown source
|
||||
|
||||
|
||||
# -- schema pins ---------------------------------------------------------------
|
||||
|
||||
|
||||
def test_tables_exist_in_sqlite_master(placement_store: SQLitePlacementStore) -> None:
|
||||
with sqlite3.connect(placement_store._db_path) as conn:
|
||||
rows = {
|
||||
name
|
||||
for (name,) in conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table'"
|
||||
)
|
||||
}
|
||||
assert "placement" in rows
|
||||
assert "placement_event" in rows
|
||||
|
||||
|
||||
def test_partial_unique_index_pinned(placement_store: SQLitePlacementStore) -> None:
|
||||
"""G-316 shape: the partial UNIQUE index exists in sqlite_master
|
||||
(the test_claims_store template)."""
|
||||
with sqlite3.connect(placement_store._db_path) as conn:
|
||||
rows = {
|
||||
sql
|
||||
for (sql,) in conn.execute(
|
||||
"SELECT sql FROM sqlite_master WHERE type='index' AND name=?",
|
||||
("uq_active_placement_per_application",),
|
||||
)
|
||||
}
|
||||
assert rows, "partial index missing"
|
||||
sql = rows.pop()
|
||||
assert "CREATE UNIQUE INDEX" in sql
|
||||
assert "status='active'" in sql
|
||||
|
||||
|
||||
def test_no_delete_path_in_source() -> None:
|
||||
"""R-70: the module carries NO delete method and NO DELETE SQL —
|
||||
deleting the top ledger row would make INTEGER PRIMARY KEY reuse
|
||||
max+1 (chain-integrity hole)."""
|
||||
from ai_service.placements import store as placements_store_module
|
||||
|
||||
source = Path(placements_store_module.__file__).read_text()
|
||||
assert "DELETE FROM" not in source
|
||||
assert "def delete" not in source
|
||||
assert "session.delete" not in source
|
||||
|
||||
|
||||
# -- the transition + chain -----------------------------------------------------
|
||||
|
||||
|
||||
def test_transition_flow_creates_chained_events(
|
||||
marketplace_store: SQLiteMarketplaceStore,
|
||||
placement_store: SQLitePlacementStore,
|
||||
) -> None:
|
||||
posting_id = _seed_posting(marketplace_store)
|
||||
application = _apply(marketplace_store, posting_id, "pilot-learner")
|
||||
|
||||
app1, placement_none = placement_store.transition_application(
|
||||
application.id, "in_review", actor_type="employer", actor_ref="account:a1"
|
||||
)
|
||||
assert app1.status == "in_review"
|
||||
assert placement_none is None
|
||||
|
||||
app2, _ = placement_store.transition_application(
|
||||
application.id, "interview", actor_type="employer", actor_ref="account:a1"
|
||||
)
|
||||
assert app2.status == "interview"
|
||||
|
||||
app3, placement = placement_store.transition_application(
|
||||
application.id,
|
||||
"hired",
|
||||
actor_type="employer",
|
||||
actor_ref="account:a1",
|
||||
role_title="AI Orchestration Engineer",
|
||||
start_date=date(2026, 11, 1),
|
||||
)
|
||||
assert app3.status == "hired"
|
||||
assert placement is not None
|
||||
assert placement.status == "active"
|
||||
assert placement.role_title == "AI Orchestration Engineer"
|
||||
assert placement.start_date == "2026-11-01"
|
||||
assert placement.learner_id == "pilot-learner"
|
||||
assert placement.employer_id == "emp-test"
|
||||
|
||||
events = placement_store.get_application_events(application.id)
|
||||
assert [e.event_type for e in events] == [
|
||||
"application.in_review",
|
||||
"application.interview",
|
||||
"application.hired",
|
||||
"placement.created",
|
||||
]
|
||||
# the chain walks green end-to-end
|
||||
ok, head = placement_store.verify_chain()
|
||||
assert ok is True
|
||||
assert head == events[-1].hash
|
||||
|
||||
|
||||
def test_hire_requires_role_title_and_start_date(
|
||||
marketplace_store: SQLiteMarketplaceStore,
|
||||
placement_store: SQLitePlacementStore,
|
||||
) -> None:
|
||||
posting_id = _seed_posting(marketplace_store)
|
||||
application = _apply(marketplace_store, posting_id, "pilot-learner")
|
||||
with pytest.raises(ValueError, match="role_title"):
|
||||
placement_store.transition_application(
|
||||
application.id, "hired", actor_type="employer", actor_ref="account:a1"
|
||||
)
|
||||
with pytest.raises(ValueError, match="start_date"):
|
||||
placement_store.transition_application(
|
||||
application.id,
|
||||
"hired",
|
||||
actor_type="employer",
|
||||
actor_ref="account:a1",
|
||||
role_title="Engineer",
|
||||
)
|
||||
|
||||
|
||||
def test_matrix_illegal_moves_raise(
|
||||
marketplace_store: SQLiteMarketplaceStore,
|
||||
placement_store: SQLitePlacementStore,
|
||||
) -> None:
|
||||
posting_id = _seed_posting(marketplace_store)
|
||||
application = _apply(marketplace_store, posting_id, "pilot-learner")
|
||||
placement_store.transition_application(
|
||||
application.id, "offer", actor_type="employer", actor_ref="account:a1"
|
||||
)
|
||||
# backward — 409 class
|
||||
with pytest.raises(InvalidTransition):
|
||||
placement_store.transition_application(
|
||||
application.id, "in_review", actor_type="employer", actor_ref="account:a1"
|
||||
)
|
||||
# terminal-source after hire
|
||||
placement_store.transition_application(
|
||||
application.id,
|
||||
"hired",
|
||||
actor_type="employer",
|
||||
actor_ref="account:a1",
|
||||
role_title="Engineer",
|
||||
start_date=date(2026, 12, 1),
|
||||
)
|
||||
with pytest.raises(InvalidTransition):
|
||||
placement_store.transition_application(
|
||||
application.id, "rejected", actor_type="employer", actor_ref="account:a1"
|
||||
)
|
||||
# unknown application
|
||||
with pytest.raises(InvalidTransition):
|
||||
placement_store.transition_application(
|
||||
"app-nonexistent", "in_review", actor_type="operator", actor_ref="operator"
|
||||
)
|
||||
|
||||
|
||||
def test_unknown_status_rejected(
|
||||
marketplace_store: SQLiteMarketplaceStore,
|
||||
placement_store: SQLitePlacementStore,
|
||||
) -> None:
|
||||
posting_id = _seed_posting(marketplace_store)
|
||||
application = _apply(marketplace_store, posting_id, "pilot-learner")
|
||||
with pytest.raises(InvalidTransition):
|
||||
placement_store.transition_application(
|
||||
application.id, "garbage", actor_type="operator", actor_ref="operator"
|
||||
)
|
||||
|
||||
|
||||
def test_tz_round_trip_canonical_stable(
|
||||
marketplace_store: SQLiteMarketplaceStore,
|
||||
placement_store: SQLitePlacementStore,
|
||||
) -> None:
|
||||
"""R-79: naive stored → _as_utc read → canonical isoformat stable —
|
||||
the hashing path and the reading path share canonical_json()."""
|
||||
posting_id = _seed_posting(marketplace_store)
|
||||
application = _apply(marketplace_store, posting_id, "pilot-learner")
|
||||
moment = datetime(2026, 10, 1, 12, 0, 0, tzinfo=UTC)
|
||||
placement_store.transition_application(
|
||||
application.id,
|
||||
"in_review",
|
||||
actor_type="employer",
|
||||
actor_ref="account:a1",
|
||||
now=moment,
|
||||
)
|
||||
events = placement_store.list_events()
|
||||
assert len(events) == 1
|
||||
raw = events[0]
|
||||
# the canonical form is a pure function of the row — recompute twice
|
||||
assert raw.canonical_json() == raw.canonical_json()
|
||||
ok, _ = placement_store.verify_chain()
|
||||
assert ok is True
|
||||
|
||||
|
||||
def test_aware_non_utc_now_converted_chain_stays_green(
|
||||
marketplace_store: SQLiteMarketplaceStore,
|
||||
placement_store: SQLitePlacementStore,
|
||||
) -> None:
|
||||
"""P0-1 (security review): an aware NON-UTC `now` CONVERTS to the
|
||||
true instant — the append-time and read-back canonical forms are
|
||||
identical, so the chain walks green (a re-labeled offset would
|
||||
hash 12:00-04:00 and re-read 12:00+00:00 — permanently broken)."""
|
||||
posting_id = _seed_posting(marketplace_store)
|
||||
application = _apply(marketplace_store, posting_id, "pilot-learner")
|
||||
edt = timezone(timedelta(hours=-4))
|
||||
placement_store.transition_application(
|
||||
application.id,
|
||||
"in_review",
|
||||
actor_type="employer",
|
||||
actor_ref="account:a1",
|
||||
now=datetime(2026, 10, 1, 12, 0, 0, tzinfo=edt),
|
||||
)
|
||||
ok, _ = placement_store.verify_chain()
|
||||
assert ok is True
|
||||
|
||||
|
||||
def test_actor_type_vocabulary_enforced(
|
||||
marketplace_store: SQLiteMarketplaceStore,
|
||||
placement_store: SQLitePlacementStore,
|
||||
) -> None:
|
||||
"""P1-1 (security review): the store boundary rejects an actor_type
|
||||
outside the closed vocabulary — a bad label would be frozen in
|
||||
immutable history."""
|
||||
posting_id = _seed_posting(marketplace_store)
|
||||
application = _apply(marketplace_store, posting_id, "pilot-learner")
|
||||
with pytest.raises(ValueError, match="actor_type"):
|
||||
placement_store.transition_application(
|
||||
application.id,
|
||||
"in_review",
|
||||
actor_type="ghost-actor",
|
||||
actor_ref="account:a1",
|
||||
)
|
||||
|
||||
|
||||
def test_payload_floats_rejected(
|
||||
marketplace_store: SQLiteMarketplaceStore,
|
||||
placement_store: SQLitePlacementStore,
|
||||
) -> None:
|
||||
"""R-69: payload values str/int/bool/None only — floats REJECTED."""
|
||||
posting_id = _seed_posting(marketplace_store)
|
||||
application = _apply(marketplace_store, posting_id, "pilot-learner")
|
||||
session = Session(placement_store._engine)
|
||||
try:
|
||||
row = PlacementEventRow(
|
||||
application_id=application.id,
|
||||
placement_id=None,
|
||||
event_type="application.in_review",
|
||||
actor_type="employer",
|
||||
actor_ref="account:a1",
|
||||
payload={"ratio": 0.5},
|
||||
prev_hash=GENESIS_HASH,
|
||||
hash="",
|
||||
created_at=datetime.now(UTC),
|
||||
)
|
||||
with pytest.raises(ValueError, match="str/int/bool/None"):
|
||||
row._validate()
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
# -- tamper / fork / concurrency (the adversarial arms, D-111) ------------------
|
||||
|
||||
|
||||
def test_tamper_detected_via_second_engine(
|
||||
tmp_path: Path,
|
||||
marketplace_store: SQLiteMarketplaceStore,
|
||||
placement_store: SQLitePlacementStore,
|
||||
) -> None:
|
||||
"""Direct row mutation through a SECOND engine on the same file →
|
||||
the verification walk FAILS (R-78: the mutation commits before the
|
||||
walk reads — WAL snapshot isolation)."""
|
||||
posting_id = _seed_posting(marketplace_store)
|
||||
application = _apply(marketplace_store, posting_id, "pilot-learner")
|
||||
placement_store.transition_application(
|
||||
application.id, "in_review", actor_type="employer", actor_ref="account:a1"
|
||||
)
|
||||
ok_before, _ = placement_store.verify_chain()
|
||||
assert ok_before is True
|
||||
|
||||
with sqlite3.connect(placement_store._db_path) as conn:
|
||||
conn.execute(
|
||||
"UPDATE placement_event SET actor_ref = 'account:evil' WHERE id = 1"
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
ok_after, _ = placement_store.verify_chain()
|
||||
assert ok_after is False
|
||||
|
||||
|
||||
def test_fork_prev_hash_surfaces_integrity_error(
|
||||
placement_store: SQLitePlacementStore,
|
||||
) -> None:
|
||||
"""G-316 backstop class: a second row claiming the same prev_hash
|
||||
→ UNIQUE refuses it, IntegrityError SURFACES."""
|
||||
with pytest.raises(IntegrityError):
|
||||
with Session(placement_store._engine) as session:
|
||||
for _i in range(2):
|
||||
row = PlacementEventRow(
|
||||
application_id="app-x",
|
||||
placement_id=None,
|
||||
event_type="application.in_review",
|
||||
actor_type="employer",
|
||||
actor_ref="account:a1",
|
||||
payload={},
|
||||
prev_hash=GENESIS_HASH,
|
||||
hash="",
|
||||
created_at=datetime.now(UTC),
|
||||
)
|
||||
session.add(row)
|
||||
session.flush()
|
||||
row.hash = "computed-later"
|
||||
session.flush()
|
||||
session.commit()
|
||||
|
||||
|
||||
def test_concurrent_transition_one_winner(
|
||||
marketplace_store: SQLiteMarketplaceStore,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""R-78: two stores (separate engines) race the same application —
|
||||
one winner commits; the loser surfaces an honest error (never two
|
||||
events on one prev_hash)."""
|
||||
db_path = tmp_path / "shared.db"
|
||||
store_a = SQLitePlacementStore(db_path=db_path)
|
||||
store_b = SQLitePlacementStore(db_path=db_path)
|
||||
marketplace = SQLiteMarketplaceStore(db_path=db_path)
|
||||
try:
|
||||
posting_id = _seed_posting(marketplace)
|
||||
application = _apply(marketplace, posting_id, "pilot-learner")
|
||||
# both read the same submitted status; A commits first
|
||||
store_a.transition_application(
|
||||
application.id, "in_review", actor_type="employer", actor_ref="account:a"
|
||||
)
|
||||
with pytest.raises((InvalidTransition, IntegrityError)):
|
||||
store_b.transition_application(
|
||||
application.id, "in_review", actor_type="employer", actor_ref="account:b"
|
||||
)
|
||||
ok, _ = store_a.verify_chain()
|
||||
assert ok is True
|
||||
# exactly ONE in_review event exists
|
||||
events = store_a.get_application_events(application.id)
|
||||
assert [e.event_type for e in events] == ["application.in_review"]
|
||||
finally:
|
||||
store_a.close()
|
||||
store_b.close()
|
||||
marketplace.close()
|
||||
|
||||
|
||||
# -- the D-102 correction lane ---------------------------------------------------
|
||||
|
||||
|
||||
def test_revoke_and_rehire(
|
||||
marketplace_store: SQLiteMarketplaceStore,
|
||||
placement_store: SQLitePlacementStore,
|
||||
) -> None:
|
||||
posting_id = _seed_posting(marketplace_store)
|
||||
application = _apply(marketplace_store, posting_id, "pilot-learner")
|
||||
_, placement = placement_store.transition_application(
|
||||
application.id,
|
||||
"hired",
|
||||
actor_type="employer",
|
||||
actor_ref="account:a1",
|
||||
role_title="Engineer",
|
||||
start_date=date(2026, 11, 1),
|
||||
)
|
||||
assert placement is not None
|
||||
|
||||
# revoke: active → revoked + event
|
||||
revoked = placement_store.revoke_placement(placement.id, "operator")
|
||||
assert revoked is not None and revoked.status == "revoked"
|
||||
|
||||
# rehire after revoke: a FRESH active row (R-80) — decided_at =
|
||||
# the re-hire event's stamp (G-326)
|
||||
moment = datetime(2026, 10, 2, 9, 30, 0, tzinfo=UTC)
|
||||
rehired = placement_store.rehire_placement(
|
||||
application.id,
|
||||
role_title="Engineer II",
|
||||
start_date=date(2026, 12, 15),
|
||||
actor_ref="operator",
|
||||
now=moment,
|
||||
)
|
||||
assert rehired.status == "active"
|
||||
assert rehired.id != placement.id
|
||||
assert rehired.decided_at == moment
|
||||
|
||||
# the three-event ledger sequence: hired → revoked → rehired
|
||||
events = placement_store.get_application_events(application.id)
|
||||
assert [e.event_type for e in events] == [
|
||||
"application.hired",
|
||||
"placement.created",
|
||||
"placement.revoked",
|
||||
"placement.rehired",
|
||||
]
|
||||
|
||||
# the revoked placement stays in history (D-102)
|
||||
statuses = {p.status for p in placement_store.list_placements()}
|
||||
assert statuses == {"active", "revoked"}
|
||||
|
||||
ok, _ = placement_store.verify_chain()
|
||||
assert ok is True
|
||||
|
||||
|
||||
def test_revoke_only_active(
|
||||
marketplace_store: SQLiteMarketplaceStore,
|
||||
placement_store: SQLitePlacementStore,
|
||||
) -> None:
|
||||
posting_id = _seed_posting(marketplace_store)
|
||||
application = _apply(marketplace_store, posting_id, "pilot-learner")
|
||||
_, placement = placement_store.transition_application(
|
||||
application.id,
|
||||
"hired",
|
||||
actor_type="employer",
|
||||
actor_ref="account:a1",
|
||||
role_title="Engineer",
|
||||
start_date=date(2026, 11, 1),
|
||||
)
|
||||
assert placement is not None
|
||||
placement_store.revoke_placement(placement.id, "operator")
|
||||
with pytest.raises(InvalidTransition):
|
||||
placement_store.revoke_placement(placement.id, "operator")
|
||||
assert placement_store.revoke_placement("plc-nonexistent", "operator") is None
|
||||
|
||||
|
||||
def test_rehire_requires_hired_application(
|
||||
marketplace_store: SQLiteMarketplaceStore,
|
||||
placement_store: SQLitePlacementStore,
|
||||
) -> None:
|
||||
posting_id = _seed_posting(marketplace_store)
|
||||
application = _apply(marketplace_store, posting_id, "pilot-learner")
|
||||
# not hired yet → re-hire is the correction lane for a hired app only
|
||||
with pytest.raises(InvalidTransition):
|
||||
placement_store.rehire_placement(
|
||||
application.id,
|
||||
role_title="Engineer",
|
||||
start_date=date(2026, 11, 1),
|
||||
actor_ref="operator",
|
||||
)
|
||||
|
||||
|
||||
def test_rehire_second_active_surfaces_integrity_error(
|
||||
marketplace_store: SQLiteMarketplaceStore,
|
||||
placement_store: SQLitePlacementStore,
|
||||
) -> None:
|
||||
"""The partial-unique arm: re-hire WITHOUT revoke → the partial
|
||||
UNIQUE index refuses the second ACTIVE row (the caller's honest
|
||||
409)."""
|
||||
posting_id = _seed_posting(marketplace_store)
|
||||
application = _apply(marketplace_store, posting_id, "pilot-learner")
|
||||
placement_store.transition_application(
|
||||
application.id,
|
||||
"hired",
|
||||
actor_type="employer",
|
||||
actor_ref="account:a1",
|
||||
role_title="Engineer",
|
||||
start_date=date(2026, 11, 1),
|
||||
)
|
||||
with pytest.raises(IntegrityError):
|
||||
placement_store.rehire_placement(
|
||||
application.id,
|
||||
role_title="Engineer II",
|
||||
start_date=date(2026, 12, 1),
|
||||
actor_ref="operator",
|
||||
)
|
||||
|
||||
|
||||
def test_chain_walk_after_n_appends(
|
||||
marketplace_store: SQLiteMarketplaceStore,
|
||||
placement_store: SQLitePlacementStore,
|
||||
) -> None:
|
||||
posting_id = _seed_posting(marketplace_store)
|
||||
application = _apply(marketplace_store, posting_id, "pilot-learner")
|
||||
for to_status in ("in_review", "interview", "offer", "rejected"):
|
||||
placement_store.transition_application(
|
||||
application.id, to_status, actor_type="employer", actor_ref="account:a1"
|
||||
)
|
||||
events = placement_store.list_events()
|
||||
assert len(events) == 4
|
||||
ok, head = placement_store.verify_chain()
|
||||
assert ok is True
|
||||
assert head == events[-1].hash
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Badge } from '@nextcraft/ui';
|
||||
import { EmployerApplicationsClient } from '../../../../components/employer/employer-applications-client';
|
||||
|
||||
/**
|
||||
* The hiring pipeline (v0.11, REQ-11-002/003, D-109 surface 1): own
|
||||
* applications with the seven D-099 status badges, per-target
|
||||
* transition CTAs, and the hire form {role_title, start_date}
|
||||
* (employer-attested, D-103). Session-gated shell with honest
|
||||
* loading/error/empty/claim states (the A-406 board-client
|
||||
* discipline). learner_id IS visible here — the G-319 supersession
|
||||
* scopes it to the session's own claimed-entity postings (D-105).
|
||||
*/
|
||||
export default function ApplicationsPage() {
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<header className="flex flex-col gap-2">
|
||||
<Badge variant="info">Hiring Pipeline</Badge>
|
||||
<h1 className="text-3xl font-bold tracking-tight text-slate-900 dark:text-slate-100">
|
||||
Hiring Pipeline
|
||||
</h1>
|
||||
<p className="max-w-2xl text-slate-600 dark:text-slate-400">
|
||||
Review applicants on your postings and drive them through the hiring
|
||||
lifecycle — in review, interview, offer, reject, or hire. A hire
|
||||
records the placement with the role title and start date you attest.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<EmployerApplicationsClient />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
AdminClaimsResponse,
|
||||
EmployerClaim,
|
||||
EmployerClaimEvidence,
|
||||
PlacementOverview,
|
||||
} from '@nextcraft/types';
|
||||
import {
|
||||
listEmployerAdminAccounts,
|
||||
@@ -15,6 +16,9 @@ import {
|
||||
listEmployerAdminAudit,
|
||||
approveEmployerAdminClaim,
|
||||
rejectEmployerAdminClaim,
|
||||
getPlacementOverview,
|
||||
getPlacementExport,
|
||||
getPlacementExportSummary,
|
||||
setAdminToken,
|
||||
EngineError,
|
||||
} from '../../lib/engine-client';
|
||||
@@ -58,6 +62,10 @@ export function EmployerNetworkPanel() {
|
||||
const [accounts, setAccounts] = useState<AdminAccountsResponse | null>(null);
|
||||
const [claims, setClaims] = useState<AdminClaimsResponse | null>(null);
|
||||
const [audit, setAudit] = useState<AdminAuditResponse | null>(null);
|
||||
const [overview, setOverview] = useState<PlacementOverview | null>(null);
|
||||
const [exportSummary, setExportSummary] = useState<string | null>(null);
|
||||
const [exportBusy, setExportBusy] = useState(false);
|
||||
const [exportError, setExportError] = useState<string | null>(null);
|
||||
const [locked, setLocked] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -75,14 +83,18 @@ export function EmployerNetworkPanel() {
|
||||
try {
|
||||
// All three operator reads ride the SAME admin transport; a 403
|
||||
// on any of them locks the whole overview (one token, one gate).
|
||||
const [acct, queue, trail] = await Promise.all([
|
||||
const [acct, queue, trail, placed] = await Promise.all([
|
||||
listEmployerAdminAccounts(controller.signal),
|
||||
listEmployerAdminClaims(controller.signal),
|
||||
listEmployerAdminAudit(controller.signal),
|
||||
// v0.11 P3/P4: the placements overview rides the same admin
|
||||
// transport + gate; a fetch failure is honest (the rest loads).
|
||||
getPlacementOverview(controller.signal).catch(() => null),
|
||||
]);
|
||||
setAccounts(acct);
|
||||
setClaims(queue);
|
||||
setAudit(trail);
|
||||
setOverview(placed);
|
||||
setLocked(false);
|
||||
} catch (err) {
|
||||
if (err instanceof EngineError && err.status === 403) {
|
||||
@@ -142,6 +154,39 @@ export function EmployerNetworkPanel() {
|
||||
// for the operator's explicit load — the entitlement-panel shape).
|
||||
}, []);
|
||||
|
||||
/** The export actions (D-109 surface 4): the summary opens IN-TAB
|
||||
* (the markdown renders in the panel); the JSON DOWNLOADS as a
|
||||
* blob. G-328: the surrounding copy says RECORDED placements —
|
||||
* never a verification claim. */
|
||||
const runExport = async (kind: 'summary' | 'download') => {
|
||||
setExportBusy(true);
|
||||
setExportError(null);
|
||||
const controller = new AbortController();
|
||||
try {
|
||||
if (kind === 'summary') {
|
||||
const text = await getPlacementExportSummary(controller.signal);
|
||||
setExportSummary(text);
|
||||
} else {
|
||||
const text = await getPlacementExport(controller.signal);
|
||||
const blob = new Blob([text], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `placement-audit-export-${new Date().toISOString().slice(0, 10)}.json`;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof EngineError && err.status === 403) {
|
||||
setLocked(true);
|
||||
} else {
|
||||
setExportError(err instanceof Error ? err.message : 'The export failed.');
|
||||
}
|
||||
} finally {
|
||||
setExportBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardBody className="flex flex-col gap-4">
|
||||
@@ -174,6 +219,67 @@ export function EmployerNetworkPanel() {
|
||||
|
||||
{accounts && claims && audit && (
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* v0.11 P3/P4 (D-109 surface 4): the placements block —
|
||||
RECORDED placements toward the 1,000 guiding goal +
|
||||
per-employer lists + the export links. G-328: recorded
|
||||
placements, never a verification claim. */}
|
||||
{overview && (
|
||||
<div className="flex flex-col gap-2 rounded-lg border border-slate-200 p-3 dark:border-slate-800">
|
||||
<h4 className="text-sm font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">
|
||||
Recorded placements
|
||||
</h4>
|
||||
<p className="text-sm text-slate-700 dark:text-slate-300">
|
||||
<span className="text-2xl font-bold">
|
||||
{overview.distinct_learners_placed}
|
||||
</span>{' '}
|
||||
distinct learners placed (employer-attested records, toward
|
||||
the {overview.guiding_goal} guiding goal) ·{' '}
|
||||
{overview.total_active_placements} active placements
|
||||
</p>
|
||||
{overview.by_employer.length > 0 && (
|
||||
<ul className="flex flex-wrap gap-2 text-xs text-slate-600 dark:text-slate-400">
|
||||
{overview.by_employer.map((bucket) => (
|
||||
<li
|
||||
key={bucket.employer_id}
|
||||
className="rounded-full bg-slate-100 px-2 py-1 dark:bg-slate-800/60"
|
||||
>
|
||||
<span className="font-mono">{bucket.employer_id}</span>: {bucket.count}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={exportBusy}
|
||||
onClick={() => {
|
||||
void runExport('summary');
|
||||
}}
|
||||
>
|
||||
{exportBusy ? 'Working…' : 'Open export summary (in-tab)'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={exportBusy}
|
||||
onClick={() => {
|
||||
void runExport('download');
|
||||
}}
|
||||
>
|
||||
{exportBusy ? 'Working…' : 'Download audit export (JSON)'}
|
||||
</Button>
|
||||
</div>
|
||||
{exportError && (
|
||||
<p className="text-sm text-red-700 dark:text-red-300">{exportError}</p>
|
||||
)}
|
||||
{exportSummary && (
|
||||
<pre className="max-h-80 overflow-auto whitespace-pre-wrap rounded-md bg-slate-50 p-3 text-xs text-slate-700 dark:bg-slate-800/60 dark:text-slate-300">
|
||||
{exportSummary}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* Accounts list — email, claimed entity, created_at (the
|
||||
UNVERIFIED marker renders as such, D-097). */}
|
||||
<div className="flex flex-col gap-2">
|
||||
|
||||
@@ -0,0 +1,342 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Badge, Button, Card, CardBody, Input } from '@nextcraft/ui';
|
||||
import type {
|
||||
ApplicationStatus,
|
||||
EmployerApplicationSummary,
|
||||
} from '@nextcraft/types';
|
||||
import {
|
||||
listOwnEmployerApplications,
|
||||
employerMarkInReview,
|
||||
employerMarkInterview,
|
||||
employerMarkOffer,
|
||||
employerRejectApplication,
|
||||
employerHireApplication,
|
||||
SessionRequiredError,
|
||||
ClaimRequiredError,
|
||||
EngineError,
|
||||
} from '../../lib/engine-client';
|
||||
|
||||
/**
|
||||
* The hiring-pipeline client (v0.11, REQ-11-002/003, D-109 surface
|
||||
* 1): the session employer's OWN applications (every status, per-
|
||||
* status facets) with per-target transition CTAs — the matrix stays
|
||||
* OUT of the request bodies (D-105: each call names its target).
|
||||
* The hire form carries the employer-attested {role_title,
|
||||
* start_date} (D-103/G-324). Withdraw is NEVER offered here (D-099:
|
||||
* employers reject; only the learner or an operator can withdraw).
|
||||
*
|
||||
* Honest states (A-406): loading/error/empty/claim, per-action
|
||||
* pending + error; a 403 cross-entity or 409 matrix arm renders from
|
||||
* EngineError.status — NO new error classes (D-110). learner_id IS
|
||||
* visible (the G-319 supersession, own applicants only).
|
||||
*/
|
||||
|
||||
const STATUS_LABELS: Record<ApplicationStatus, string> = {
|
||||
submitted: 'Submitted',
|
||||
in_review: 'In Review',
|
||||
interview: 'Interview',
|
||||
offer: 'Offer',
|
||||
hired: 'Hired',
|
||||
rejected: 'Rejected',
|
||||
withdrawn: 'Withdrawn',
|
||||
};
|
||||
|
||||
const STATUS_BADGE: Record<ApplicationStatus, 'default' | 'info' | 'success' | 'warning' | 'error'> = {
|
||||
submitted: 'default',
|
||||
in_review: 'info',
|
||||
interview: 'info',
|
||||
offer: 'warning',
|
||||
hired: 'success',
|
||||
rejected: 'error',
|
||||
withdrawn: 'default',
|
||||
};
|
||||
|
||||
/** The next legal targets per the D-099 matrix (forward-only with
|
||||
* skip-forward — the CTA set matches the store's transition set). */
|
||||
const NEXT_TARGETS: Record<string, Array<{ key: string; label: string }>> = {
|
||||
submitted: [
|
||||
{ key: 'in-review', label: 'In Review' },
|
||||
{ key: 'interview', label: 'Interview' },
|
||||
{ key: 'reject', label: 'Reject' },
|
||||
{ key: 'hire', label: 'Hire' },
|
||||
],
|
||||
in_review: [
|
||||
{ key: 'interview', label: 'Interview' },
|
||||
{ key: 'offer', label: 'Offer' },
|
||||
{ key: 'reject', label: 'Reject' },
|
||||
{ key: 'hire', label: 'Hire' },
|
||||
],
|
||||
interview: [
|
||||
{ key: 'offer', label: 'Offer' },
|
||||
{ key: 'reject', label: 'Reject' },
|
||||
{ key: 'hire', label: 'Hire' },
|
||||
],
|
||||
offer: [
|
||||
{ key: 'reject', label: 'Reject' },
|
||||
{ key: 'hire', label: 'Hire' },
|
||||
],
|
||||
};
|
||||
|
||||
export function EmployerApplicationsClient() {
|
||||
const [applications, setApplications] = useState<EmployerApplicationSummary[] | null>(null);
|
||||
const [loggedOut, setLoggedOut] = useState(false);
|
||||
const [claimPrompt, setClaimPrompt] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
// per-application action state: the honest pending/error pair
|
||||
const [pending, setPending] = useState<string | null>(null);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
// the hire form: which application id is being hired + its fields
|
||||
const [hireFor, setHireFor] = useState<string | null>(null);
|
||||
const [roleTitle, setRoleTitle] = useState('');
|
||||
const [startDate, setStartDate] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
(async () => {
|
||||
try {
|
||||
const body = await listOwnEmployerApplications(controller.signal);
|
||||
if (!controller.signal.aborted) setApplications(body.applications);
|
||||
} catch (err) {
|
||||
if (controller.signal.aborted) return;
|
||||
if (err instanceof SessionRequiredError) {
|
||||
setLoggedOut(true); // honest logged-out state — never a retry loop
|
||||
} else if (err instanceof ClaimRequiredError) {
|
||||
setClaimPrompt(true); // the honest claim-CTA state
|
||||
} else {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load applications.');
|
||||
}
|
||||
} finally {
|
||||
if (!controller.signal.aborted) setLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => controller.abort();
|
||||
}, []);
|
||||
|
||||
async function runTransition(
|
||||
application: EmployerApplicationSummary,
|
||||
target: 'in-review' | 'interview' | 'offer' | 'reject',
|
||||
): Promise<void> {
|
||||
setActionError(null);
|
||||
setPending(application.id);
|
||||
try {
|
||||
const dispatch = {
|
||||
'in-review': employerMarkInReview,
|
||||
interview: employerMarkInterview,
|
||||
offer: employerMarkOffer,
|
||||
reject: employerRejectApplication,
|
||||
}[target];
|
||||
const body = await dispatch(application.id);
|
||||
if (body.application_id === application.id) {
|
||||
setApplications((prev) =>
|
||||
(prev ?? []).map((a) => (a.id === application.id ? { ...a, status: body.status } : a)),
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof SessionRequiredError) {
|
||||
setLoggedOut(true);
|
||||
} else if (err instanceof ClaimRequiredError) {
|
||||
setClaimPrompt(true);
|
||||
} else if (err instanceof EngineError) {
|
||||
// the 403 cross-entity + 409 matrix arms — the honest status
|
||||
setActionError(`${application.id}: ${err.message}`);
|
||||
} else {
|
||||
setActionError(err instanceof Error ? err.message : 'The transition failed.');
|
||||
}
|
||||
} finally {
|
||||
setPending(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function submitHire(application: EmployerApplicationSummary): Promise<void> {
|
||||
setActionError(null);
|
||||
setPending(application.id);
|
||||
try {
|
||||
const body = await employerHireApplication(application.id, {
|
||||
role_title: roleTitle,
|
||||
start_date: startDate,
|
||||
});
|
||||
setApplications((prev) =>
|
||||
(prev ?? []).map((a) => (a.id === application.id ? { ...a, status: body.status } : a)),
|
||||
);
|
||||
setHireFor(null);
|
||||
setRoleTitle('');
|
||||
setStartDate('');
|
||||
} catch (err) {
|
||||
if (err instanceof SessionRequiredError) {
|
||||
setLoggedOut(true);
|
||||
} else if (err instanceof ClaimRequiredError) {
|
||||
setClaimPrompt(true);
|
||||
} else {
|
||||
// 422 (missing/empty fields, G-324) + 409 (matrix) render here
|
||||
setActionError(err instanceof Error ? err.message : 'The hire failed.');
|
||||
}
|
||||
} finally {
|
||||
setPending(null);
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardBody className="py-10 text-center text-slate-600 dark:text-slate-400">
|
||||
Loading your hiring pipeline…
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (loggedOut) {
|
||||
return (
|
||||
<Card>
|
||||
<CardBody className="flex flex-col items-center gap-3 py-10 text-center">
|
||||
<p className="text-slate-700 dark:text-slate-300">
|
||||
You are signed out — your session is missing or expired.
|
||||
</p>
|
||||
<a href="/employer/login">
|
||||
<Button variant="primary" size="md">Sign in</Button>
|
||||
</a>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (claimPrompt) {
|
||||
return (
|
||||
<Card>
|
||||
<CardBody className="flex flex-col items-center gap-3 py-10 text-center">
|
||||
<p className="text-slate-700 dark:text-slate-300">
|
||||
No employer entity is claimed on this account yet.
|
||||
</p>
|
||||
<p className="max-w-md text-sm text-slate-600 dark:text-slate-400">
|
||||
Claim your company's entity first — the hiring pipeline lists
|
||||
applicants on your claimed entity's postings.
|
||||
</p>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Card>
|
||||
<CardBody className="flex flex-col items-center gap-2 py-10 text-center">
|
||||
<p className="text-slate-700 dark:text-slate-300">{error}</p>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">
|
||||
The hiring pipeline is unavailable — please try again shortly.
|
||||
</p>
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (!applications || applications.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardBody className="py-10 text-center text-sm text-slate-500 dark:text-slate-400">
|
||||
No applications yet — applicants on your published postings appear
|
||||
here as they apply.
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{actionError && (
|
||||
<Card>
|
||||
<CardBody className="py-3 text-sm text-red-700 dark:text-red-300">{actionError}</CardBody>
|
||||
</Card>
|
||||
)}
|
||||
{applications.map((application) => {
|
||||
const targets = NEXT_TARGETS[application.status] ?? [];
|
||||
return (
|
||||
<Card key={application.id}>
|
||||
<CardBody className="flex flex-col gap-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant={STATUS_BADGE[application.status]}>
|
||||
{STATUS_LABELS[application.status]}
|
||||
</Badge>
|
||||
<span className="text-sm font-medium text-slate-900 dark:text-slate-100">
|
||||
{application.learner_id}
|
||||
</span>
|
||||
<span className="text-xs text-slate-500 dark:text-slate-400">
|
||||
applied {application.created_at ? application.created_at.slice(0, 10) : ''}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-slate-400 dark:text-slate-500">
|
||||
{application.id}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{hireFor === application.id ? (
|
||||
<div className="flex flex-col gap-3 rounded-lg border border-slate-200 p-3 dark:border-slate-800">
|
||||
<p className="text-sm font-medium text-slate-800 dark:text-slate-200">
|
||||
Record the placement you attest (visible to the learner and
|
||||
the audit export):
|
||||
</p>
|
||||
<Input
|
||||
label="Role title"
|
||||
value={roleTitle}
|
||||
onChange={(e) => setRoleTitle(e.target.value)}
|
||||
placeholder="AI Orchestration Engineer"
|
||||
maxLength={200}
|
||||
/>
|
||||
<Input
|
||||
label="Employment start date"
|
||||
type="date"
|
||||
value={startDate}
|
||||
onChange={(e) => setStartDate(e.target.value)}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
disabled={pending === application.id || roleTitle === '' || startDate === ''}
|
||||
onClick={() => {
|
||||
void submitHire(application);
|
||||
}}
|
||||
>
|
||||
{pending === application.id ? 'Recording…' : 'Confirm hire'}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => setHireFor(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{targets.map((target) => (
|
||||
<Button
|
||||
key={target.key}
|
||||
variant={target.key === 'reject' ? 'outline' : 'secondary'}
|
||||
size="sm"
|
||||
disabled={pending === application.id}
|
||||
onClick={() => {
|
||||
if (target.key === 'hire') {
|
||||
setHireFor(application.id);
|
||||
setActionError(null);
|
||||
} else {
|
||||
void runTransition(
|
||||
application,
|
||||
target.key as 'in-review' | 'interview' | 'offer' | 'reject',
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{pending === application.id ? '…' : target.label}
|
||||
</Button>
|
||||
))}
|
||||
{/* D-099: withdraw is NEVER offered to employers */}
|
||||
</div>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,10 +4,16 @@ import { useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Briefcase, Users, TrendingUp, Target, ArrowRight } from 'lucide-react';
|
||||
import { Badge, Button, Card, CardBody, Avatar } from '@nextcraft/ui';
|
||||
import type { EmployerProfile, EmployerMe, OwnPostingsResponse } from '@nextcraft/types';
|
||||
import type {
|
||||
EmployerProfile,
|
||||
EmployerMe,
|
||||
OwnPostingsResponse,
|
||||
EmployerPlacementsResponse,
|
||||
} from '@nextcraft/types';
|
||||
import {
|
||||
getEmployerMe,
|
||||
listOwnEmployerPostings,
|
||||
listOwnEmployerPlacements,
|
||||
getEmployer,
|
||||
SessionRequiredError,
|
||||
ClaimRequiredError,
|
||||
@@ -33,6 +39,7 @@ export function EmployerOverviewClient() {
|
||||
const [me, setMe] = useState<EmployerMe | null>(null);
|
||||
const [postings, setPostings] = useState<OwnPostingsResponse | null>(null);
|
||||
const [profile, setProfile] = useState<EmployerProfile | null>(null);
|
||||
const [placements, setPlacements] = useState<EmployerPlacementsResponse | null>(null);
|
||||
const [loggedOut, setLoggedOut] = useState(false);
|
||||
const [claimPrompt, setClaimPrompt] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -45,12 +52,18 @@ export function EmployerOverviewClient() {
|
||||
const meBody = await getEmployerMe(controller.signal);
|
||||
setMe(meBody);
|
||||
if (meBody.claimed_employer_id) {
|
||||
const [own, prof] = await Promise.all([
|
||||
const [own, prof, placed] = await Promise.all([
|
||||
listOwnEmployerPostings(controller.signal),
|
||||
getEmployer(meBody.claimed_employer_id, controller.signal).catch(() => null),
|
||||
// v0.11 P3 (D-109 surface 2): REAL placements — the mock
|
||||
// metric block is REPLACED (D-092: never leave a convertible
|
||||
// surface on mock). A placements fetch failure is honest —
|
||||
// the rest of the dashboard still renders.
|
||||
listOwnEmployerPlacements(controller.signal).catch(() => null),
|
||||
]);
|
||||
setPostings(own);
|
||||
setProfile(prof);
|
||||
setPlacements(placed);
|
||||
}
|
||||
} catch (err) {
|
||||
if (controller.signal.aborted) return;
|
||||
@@ -160,6 +173,10 @@ export function EmployerOverviewClient() {
|
||||
const activePostings = facets?.published ?? 0;
|
||||
const totalApplicants =
|
||||
postings?.postings.reduce((sum, p) => sum + p.applicant_count, 0) ?? 0;
|
||||
// v0.11 P3 (D-107): the active-placement count from the rows at
|
||||
// query time — the honest 0 when none exist.
|
||||
const activePlacements =
|
||||
placements?.placements.filter((p) => p.status === 'active').length ?? 0;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
@@ -187,13 +204,20 @@ export function EmployerOverviewClient() {
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{/* Metric cards — postings/applicants REAL (GET /v1/employers/
|
||||
postings); talent-match/placement stay mock-labeled (D-092). */}
|
||||
{/* Metric cards — postings/applicants/placements REAL (GET
|
||||
/v1/employers/postings + /placements, v0.11 P3);
|
||||
talent-match stays mock-labeled (D-092 — no backend computes
|
||||
it). */}
|
||||
<section className="grid grid-cols-2 gap-4 lg:grid-cols-4">
|
||||
<MetricCard label="Active Postings" value={activePostings} icon={Briefcase} tone="primary" />
|
||||
<MetricCard label="Total Applicants" value={totalApplicants} icon={Users} tone="accent" />
|
||||
<MetricCard
|
||||
label="Placements Recorded"
|
||||
value={activePlacements}
|
||||
icon={TrendingUp}
|
||||
tone="amber"
|
||||
/>
|
||||
<MockMetricCard label="Talent Matches" value="—" icon={Target} tone="violet" />
|
||||
<MockMetricCard label="Placement Rate" value="—" icon={TrendingUp} tone="amber" />
|
||||
</section>
|
||||
|
||||
{/* Middle row — per-posting applicant table + claimed-entity profile */}
|
||||
@@ -286,6 +310,66 @@ export function EmployerOverviewClient() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* v0.11 P3 (D-109 surface 2, REQ-11-003): REAL placements — the
|
||||
hired applications with role + start date. The mock
|
||||
placement block is REPLACED (D-092); talent-match analytics
|
||||
stay mock-labeled (no backend computes them). */}
|
||||
<section>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-slate-900 dark:text-slate-100">
|
||||
Placements
|
||||
</h2>
|
||||
<Link
|
||||
href="/employer/applications"
|
||||
className="inline-flex items-center gap-1 text-sm font-medium text-primary-700 hover:underline dark:text-primary-300"
|
||||
>
|
||||
Manage hiring pipeline <ArrowRight className="h-3.5 w-3.5" />
|
||||
</Link>
|
||||
</div>
|
||||
<Card>
|
||||
{placements && placements.placements.length > 0 ? (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-200 text-left text-xs uppercase tracking-wide text-slate-500 dark:border-slate-800 dark:text-slate-400">
|
||||
<th className="p-3 font-medium">Role</th>
|
||||
<th className="p-3 font-medium">Learner</th>
|
||||
<th className="p-3 font-medium">Start Date</th>
|
||||
<th className="p-3 font-medium">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{placements.placements.map((p) => (
|
||||
<tr
|
||||
key={p.id}
|
||||
className="border-b border-slate-100 last:border-0 dark:border-slate-800/60"
|
||||
>
|
||||
<td className="p-3 font-medium text-slate-900 dark:text-slate-100">
|
||||
{p.role_title}
|
||||
</td>
|
||||
<td className="p-3 text-slate-700 dark:text-slate-300">{p.learner_id}</td>
|
||||
<td className="p-3 text-slate-700 dark:text-slate-300">{p.start_date}</td>
|
||||
<td className="p-3">
|
||||
{p.status === 'active' ? (
|
||||
<Badge variant="success">Active</Badge>
|
||||
) : (
|
||||
<Badge variant="default">Revoked</Badge>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<CardBody className="py-8 text-center text-sm text-slate-500 dark:text-slate-400">
|
||||
No placements recorded yet — hires you record in the hiring
|
||||
pipeline appear here.
|
||||
</CardBody>
|
||||
)}
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
{/* Mock-labeled analytics (D-092: no backend computes these). */}
|
||||
<section>
|
||||
<h2 className="mb-3 text-lg font-semibold text-slate-900 dark:text-slate-100">
|
||||
@@ -364,12 +448,14 @@ function PostingStatusBadge({ status }: { status: 'draft' | 'published' | 'close
|
||||
}
|
||||
|
||||
/** D-092 honest mock label for the analytics section — the charts ride
|
||||
* nowhere until a backend computes them; the copy says so. */
|
||||
* nowhere until a backend computes them; the copy says so. v0.11:
|
||||
* placement COUNTS are now real (the section above) — only the
|
||||
* talent-match analytics remain mock. */
|
||||
function MockAnalyticsNotice() {
|
||||
return (
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">
|
||||
Talent-match and placement analytics are sample data at pilot — no backend
|
||||
computes them yet. Real posting and applicant counts appear above.
|
||||
Talent-match analytics are sample data at pilot — no backend computes them yet.
|
||||
Real posting, applicant, and placement counts appear above.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ import { notFound } from 'next/navigation';
|
||||
import { ArrowLeft, Building2, Check, MapPin, Wallet } from 'lucide-react';
|
||||
import type {
|
||||
ApplicationConfirmation,
|
||||
ApplicationStatus,
|
||||
LearnerApplicationStatus,
|
||||
PostingDetail,
|
||||
EmployerProfile,
|
||||
SearchResult,
|
||||
@@ -24,8 +26,11 @@ import {
|
||||
VerifyRequiredError,
|
||||
applyPosting,
|
||||
getEmployer,
|
||||
getLearnerApplicationStatus,
|
||||
getMyApplication,
|
||||
getPosting,
|
||||
readSearchSelection,
|
||||
withdrawApplication,
|
||||
} from '../../lib/engine-client';
|
||||
import { segmentFromPath } from '../../lib/route-id';
|
||||
|
||||
@@ -54,6 +59,12 @@ export function JobDetailClient() {
|
||||
// P3 G-106: the board's search handoff, read ONCE on mount (empty
|
||||
// when the visit is not from a search result — no score, no panel).
|
||||
const [searchSelection, setSearchSelection] = useState<SearchResult | null>(null);
|
||||
// P5 (G-322): the learner's application on THIS posting — composed
|
||||
// via the mine-read + the status read on every visit; never a
|
||||
// client-persisted application id.
|
||||
const [appStatus, setAppStatus] = useState<LearnerApplicationStatus | null>(null);
|
||||
const [withdrawing, setWithdrawing] = useState(false);
|
||||
const [withdrawError, setWithdrawError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
@@ -76,6 +87,28 @@ export function JobDetailClient() {
|
||||
} catch {
|
||||
/* employer optional for the detail view */
|
||||
}
|
||||
// P5 (G-322): mine-read discovery — the panel composes from this
|
||||
// read, never a client-persisted application_id; 404 = no
|
||||
// application → the apply CTA renders (honest absence); gate
|
||||
// arms render their existing states.
|
||||
try {
|
||||
const mine = await getMyApplication(p.id, undefined, controller.signal);
|
||||
if (controller.signal.aborted) return;
|
||||
const status = await getLearnerApplicationStatus(
|
||||
mine.application_id,
|
||||
undefined,
|
||||
controller.signal,
|
||||
);
|
||||
if (!controller.signal.aborted) setAppStatus(status);
|
||||
} catch (mineErr) {
|
||||
if (controller.signal.aborted) return;
|
||||
if (mineErr instanceof EngineError && mineErr.status === 404) return;
|
||||
if (mineErr instanceof VerifyRequiredError) {
|
||||
setVerifyCta(mineErr.verifyCta);
|
||||
}
|
||||
// any other failure leaves the apply CTA as the honest
|
||||
// surface — its own flow re-surfaces the gate states.
|
||||
}
|
||||
} catch (err) {
|
||||
if (controller.signal.aborted) return;
|
||||
if (err instanceof EngineError && err.status === 404) {
|
||||
@@ -97,6 +130,14 @@ export function JobDetailClient() {
|
||||
try {
|
||||
const c = await applyPosting(posting.id);
|
||||
setConfirmation(c);
|
||||
// P5: compose the status panel from the real read (the direct
|
||||
// response's application_id — never persisted).
|
||||
try {
|
||||
const status = await getLearnerApplicationStatus(c.application_id);
|
||||
setAppStatus(status);
|
||||
} catch {
|
||||
/* the confirmation box renders the honest state regardless */
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof VerifyRequiredError) {
|
||||
setVerifyCta(err.verifyCta);
|
||||
@@ -112,6 +153,35 @@ export function JobDetailClient() {
|
||||
}
|
||||
}
|
||||
|
||||
async function onWithdraw() {
|
||||
if (!appStatus) return;
|
||||
setWithdrawing(true);
|
||||
setWithdrawError(null);
|
||||
try {
|
||||
const c = await withdrawApplication(appStatus.application.id);
|
||||
const status = await getLearnerApplicationStatus(c.application_id);
|
||||
setAppStatus(status);
|
||||
} catch (err) {
|
||||
if (err instanceof EngineError && err.status === 409) {
|
||||
// G-323: the matrix refused — the honest terminal copy; the
|
||||
// state refreshes instead of a stale withdraw button.
|
||||
try {
|
||||
const status = await getLearnerApplicationStatus(appStatus.application.id);
|
||||
setAppStatus(status);
|
||||
} catch {
|
||||
/* the current render stays — the 409 copy below is honest */
|
||||
}
|
||||
setWithdrawError('This application is terminal — it can no longer be withdrawn.');
|
||||
} else if (err instanceof EngineError && err.status === 403) {
|
||||
setWithdrawError('This application belongs to another learner.');
|
||||
} else {
|
||||
setWithdrawError(err instanceof Error ? err.message : 'Withdraw failed.');
|
||||
}
|
||||
} finally {
|
||||
setWithdrawing(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (missing) {
|
||||
notFound();
|
||||
}
|
||||
@@ -242,8 +312,86 @@ export function JobDetailClient() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-start gap-3 md:items-end">
|
||||
{/* Apply flow (D-058): every state renders honestly. */}
|
||||
{confirmation ? (
|
||||
{/* Apply flow (D-058): every state renders honestly. P5
|
||||
* (G-322/D-104): the status panel renders when the
|
||||
* mine-read finds the application; the apply CTA only
|
||||
* when it does not. */}
|
||||
{appStatus ? (
|
||||
<div
|
||||
role="status"
|
||||
className="flex w-full flex-col gap-3 rounded-lg border border-slate-300 bg-slate-50 px-4 py-3 text-sm dark:border-slate-700 dark:bg-slate-900/40"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">
|
||||
Your application
|
||||
</span>
|
||||
<Badge
|
||||
variant={appStatus.application.status === 'hired' ? 'success' : 'default'}
|
||||
>
|
||||
{applicationStatusLabel(appStatus.application.status)}
|
||||
</Badge>
|
||||
</div>
|
||||
<span className="text-xs text-slate-500 dark:text-slate-400">
|
||||
{appStatus.application.created_at
|
||||
? `Applied ${relativeTime(appStatus.application.created_at)} · `
|
||||
: ''}
|
||||
application {appStatus.application.id}
|
||||
</span>
|
||||
{appStatus.events.length > 0 ? (
|
||||
<ul className="flex flex-col gap-1.5">
|
||||
{appStatus.events.map((event) => (
|
||||
<li
|
||||
key={event.id}
|
||||
className="flex items-center justify-between gap-2 text-xs"
|
||||
>
|
||||
<span className="font-medium text-slate-700 dark:text-slate-300">
|
||||
{applicationEventLabel(event.event_type)}
|
||||
</span>
|
||||
<span className="text-slate-500 dark:text-slate-400">
|
||||
{relativeTime(event.created_at)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<span className="text-xs text-slate-500 dark:text-slate-400">
|
||||
No status updates yet.
|
||||
</span>
|
||||
)}
|
||||
{appStatus.placement && (
|
||||
<div
|
||||
role="status"
|
||||
className="flex flex-col gap-1 rounded-lg border border-emerald-300 bg-emerald-50 px-3 py-2 text-sm text-emerald-800 dark:border-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-200"
|
||||
>
|
||||
<span className="font-semibold">Placement recorded</span>
|
||||
<span className="text-xs">
|
||||
{appStatus.placement.role_title} at {employerName} · starts{' '}
|
||||
{appStatus.placement.start_date}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{WITHDRAWABLE_STATUSES.has(appStatus.application.status) ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
type="button"
|
||||
loading={withdrawing}
|
||||
onClick={() => void onWithdraw()}
|
||||
>
|
||||
Withdraw application
|
||||
</Button>
|
||||
) : (
|
||||
<p className="text-xs text-slate-500 dark:text-slate-400">
|
||||
{terminalCopy(appStatus.application.status)}
|
||||
</p>
|
||||
)}
|
||||
{withdrawError && (
|
||||
<p role="alert" className="text-xs text-red-600 dark:text-red-400">
|
||||
{withdrawError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : confirmation ? (
|
||||
<div
|
||||
role="status"
|
||||
className="flex flex-col gap-1 rounded-lg border border-emerald-300 bg-emerald-50 px-4 py-3 text-sm text-emerald-800 dark:border-emerald-800 dark:bg-emerald-900/30 dark:text-emerald-200"
|
||||
@@ -457,3 +605,78 @@ export function JobDetailClient() {
|
||||
function matchPercent(score: number): number {
|
||||
return Math.max(0, Math.round(score * 100));
|
||||
}
|
||||
|
||||
|
||||
/** The seven D-099 states, honestly labeled (no stage invented beyond
|
||||
* the matrix; unknown statuses render verbatim, never invented). */
|
||||
function applicationStatusLabel(status: ApplicationStatus): string {
|
||||
switch (status) {
|
||||
case 'submitted':
|
||||
return 'Submitted';
|
||||
case 'in_review':
|
||||
return 'In review';
|
||||
case 'interview':
|
||||
return 'Interview';
|
||||
case 'offer':
|
||||
return 'Offer';
|
||||
case 'hired':
|
||||
return 'Hired';
|
||||
case 'rejected':
|
||||
return 'Rejected';
|
||||
case 'withdrawn':
|
||||
return 'Withdrawn';
|
||||
default:
|
||||
return status;
|
||||
}
|
||||
}
|
||||
|
||||
/** Ledger event labels (the D-101 vocabulary) — unknown event names
|
||||
* render verbatim, never invented. */
|
||||
function applicationEventLabel(eventType: string): string {
|
||||
switch (eventType) {
|
||||
case 'application.in_review':
|
||||
return 'Moved to review';
|
||||
case 'application.interview':
|
||||
return 'Interview stage';
|
||||
case 'application.offer':
|
||||
return 'Offer received';
|
||||
case 'application.hired':
|
||||
return 'Hired';
|
||||
case 'application.rejected':
|
||||
return 'Rejected';
|
||||
case 'application.withdrawn':
|
||||
return 'Withdrawn';
|
||||
case 'placement.created':
|
||||
return 'Placement recorded';
|
||||
case 'placement.revoked':
|
||||
return 'Placement revoked (operator correction)';
|
||||
case 'placement.rehired':
|
||||
return 'Placement re-recorded (operator correction)';
|
||||
default:
|
||||
return eventType;
|
||||
}
|
||||
}
|
||||
|
||||
/** The learner's legal withdraw sources (D-099: terminals have no
|
||||
* outgoing edges). */
|
||||
const WITHDRAWABLE_STATUSES: ReadonlySet<ApplicationStatus> = new Set([
|
||||
'submitted',
|
||||
'in_review',
|
||||
'interview',
|
||||
'offer',
|
||||
]);
|
||||
|
||||
/** Terminal copy (the honest end states — no resurrection, no
|
||||
* fabricated next step). */
|
||||
function terminalCopy(status: ApplicationStatus): string {
|
||||
switch (status) {
|
||||
case 'hired':
|
||||
return 'Hired — placement details appear above when a placement is recorded.';
|
||||
case 'rejected':
|
||||
return 'This application was not taken forward.';
|
||||
case 'withdrawn':
|
||||
return 'You withdrew this application.';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,6 +47,13 @@ import type {
|
||||
AdminClaimsResponse,
|
||||
ClaimDecisionResponse,
|
||||
AdminAuditResponse,
|
||||
EmployerApplicationsResponse,
|
||||
ApplicationTransitionResponse,
|
||||
HireTransitionRequest,
|
||||
EmployerPlacementsResponse,
|
||||
LearnerApplicationStatus,
|
||||
MineApplication,
|
||||
PlacementOverview,
|
||||
} from '@nextcraft/types';
|
||||
|
||||
import { engineBaseUrl } from './engine-base-url';
|
||||
@@ -1171,3 +1178,205 @@ export async function completeOwnEmployerMockCheckout(
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// -- placements (v0.11 P3, REQ-11-002/003, D-110) ----------------------------------
|
||||
|
||||
/** GET /v1/employers/applications — the session employer's OWN hiring
|
||||
* pipeline (every status, per-status facets). Expired session →
|
||||
* SessionRequiredError; unclaimed → ClaimRequiredError (the G-311
|
||||
* gates surface as the honest state classes, never a re-fetch). */
|
||||
export async function listOwnEmployerApplications(
|
||||
signal?: AbortSignal,
|
||||
): Promise<EmployerApplicationsResponse> {
|
||||
return jsonFetch<EmployerApplicationsResponse>('/v1/employers/applications', {
|
||||
headers: employerHeaders(),
|
||||
signal,
|
||||
});
|
||||
}
|
||||
|
||||
/** The shared per-target transition transport (D-105: the matrix stays
|
||||
* OUT of the request bodies — each call names its target). 401 →
|
||||
* SessionRequiredError; 403-claim → ClaimRequiredError; 403
|
||||
* cross-entity + 409 matrix → EngineError with the honest status
|
||||
* (the surfaces render the state from err.status — NO new error
|
||||
* classes, D-110). */
|
||||
async function employerTransition(
|
||||
applicationId: string,
|
||||
target: 'in-review' | 'interview' | 'offer' | 'reject' | 'hire',
|
||||
body?: HireTransitionRequest,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ApplicationTransitionResponse> {
|
||||
return jsonFetch<ApplicationTransitionResponse>(
|
||||
`/v1/employers/applications/${encodeURIComponent(applicationId)}/${target}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: employerHeaders(),
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
signal,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** POST .../in-review — submitted → in_review. */
|
||||
export function employerMarkInReview(
|
||||
applicationId: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ApplicationTransitionResponse> {
|
||||
return employerTransition(applicationId, 'in-review', undefined, signal);
|
||||
}
|
||||
|
||||
/** POST .../interview. */
|
||||
export function employerMarkInterview(
|
||||
applicationId: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ApplicationTransitionResponse> {
|
||||
return employerTransition(applicationId, 'interview', undefined, signal);
|
||||
}
|
||||
|
||||
/** POST .../offer. */
|
||||
export function employerMarkOffer(
|
||||
applicationId: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ApplicationTransitionResponse> {
|
||||
return employerTransition(applicationId, 'offer', undefined, signal);
|
||||
}
|
||||
|
||||
/** POST .../reject — terminal. */
|
||||
export function employerRejectApplication(
|
||||
applicationId: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ApplicationTransitionResponse> {
|
||||
return employerTransition(applicationId, 'reject', undefined, signal);
|
||||
}
|
||||
|
||||
/** POST .../hire — terminal; carries the employer-attested
|
||||
* {role_title, start_date} (D-103/G-324). The response carries the
|
||||
* created placement record. */
|
||||
export function employerHireApplication(
|
||||
applicationId: string,
|
||||
body: HireTransitionRequest,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ApplicationTransitionResponse> {
|
||||
return employerTransition(applicationId, 'hire', body, signal);
|
||||
}
|
||||
|
||||
/** GET /v1/employers/placements — the claimed entity's OWN placements
|
||||
* (active + revoked, the full history; the overview's placements
|
||||
* section data source, D-109 surface 2). */
|
||||
export async function listOwnEmployerPlacements(
|
||||
signal?: AbortSignal,
|
||||
): Promise<EmployerPlacementsResponse> {
|
||||
return jsonFetch<EmployerPlacementsResponse>('/v1/employers/placements', {
|
||||
headers: employerHeaders(),
|
||||
signal,
|
||||
});
|
||||
}
|
||||
|
||||
// -- learner placement status (v0.11 P5, REQ-11-005, D-104/G-322) -------------------
|
||||
|
||||
/** GET /v1/marketplace/applications/mine?posting_id=&learner_id= —
|
||||
* G-322 (binding) discovery: the status panel finds the application by
|
||||
* the (posting, learner) pair — NEVER a client-persisted
|
||||
* application_id. 404 = no application yet (the surface renders the
|
||||
* apply CTA — honest absence). */
|
||||
export async function getMyApplication(
|
||||
postingId: string,
|
||||
learnerId: string = MOCK_LEARNER_ID,
|
||||
signal?: AbortSignal,
|
||||
): Promise<MineApplication> {
|
||||
return jsonFetch<MineApplication>(
|
||||
`/v1/marketplace/applications/mine?posting_id=${encodeURIComponent(postingId)}&learner_id=${encodeURIComponent(learnerId)}`,
|
||||
{ signal },
|
||||
);
|
||||
}
|
||||
|
||||
/** GET /v1/marketplace/applications/{application_id}?learner_id= —
|
||||
* D-104: the learner's-own outcome read — current status + the full
|
||||
* id-ordered timestamped history + the placement record when hired
|
||||
* (one read, one truth source). 403 foreign → EngineError (the
|
||||
* surface renders the honest "not your application" state from
|
||||
* err.status — NO new error classes, D-110). */
|
||||
export async function getLearnerApplicationStatus(
|
||||
applicationId: string,
|
||||
learnerId: string = MOCK_LEARNER_ID,
|
||||
signal?: AbortSignal,
|
||||
): Promise<LearnerApplicationStatus> {
|
||||
return jsonFetch<LearnerApplicationStatus>(
|
||||
`/v1/marketplace/applications/${encodeURIComponent(applicationId)}?learner_id=${encodeURIComponent(learnerId)}`,
|
||||
{ signal },
|
||||
);
|
||||
}
|
||||
|
||||
/** POST /v1/marketplace/applications/{application_id}/withdraw — the
|
||||
* learner's ONLY edit (D-099/D-104); terminal → 409 (the matrix
|
||||
* surfaces as EngineError.status — the panel renders the honest
|
||||
* terminal state, never a re-fetch). */
|
||||
export async function withdrawApplication(
|
||||
applicationId: string,
|
||||
learnerId: string = MOCK_LEARNER_ID,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ApplicationConfirmation> {
|
||||
return jsonFetch<ApplicationConfirmation>(
|
||||
`/v1/marketplace/applications/${encodeURIComponent(applicationId)}/withdraw`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ learner_id: learnerId }),
|
||||
signal,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// -- admin placements (v0.11 P3/P4, REQ-11-003/004, D-107/D-108) --------------------
|
||||
|
||||
/** GET /v1/placements/admin/overview — the D-107 computation over the
|
||||
* G-308 admin transport: distinct learners toward the 1,000 guiding
|
||||
* goal, active placements, revoked count, per-employer + per-cohort
|
||||
* month breakdowns. G-328: the caller labels these as RECORDED
|
||||
* placements — never a verification claim. */
|
||||
export async function getPlacementOverview(
|
||||
signal?: AbortSignal,
|
||||
): Promise<PlacementOverview> {
|
||||
return adminJsonFetch<PlacementOverview>('/v1/placements/admin/overview', {
|
||||
signal,
|
||||
});
|
||||
}
|
||||
|
||||
/** GET /v1/placements/admin/export — the self-contained audit JSON
|
||||
* (D-108: cohort + evidence bundles + methodology + the ledger with
|
||||
* head_hash). The caller downloads the document as a JSON blob. */
|
||||
export async function getPlacementExport(signal?: AbortSignal): Promise<string> {
|
||||
const token = adminToken();
|
||||
const resp = await fetch(
|
||||
`${AI_SERVICE_URL}/v1/placements/admin/export`,
|
||||
{
|
||||
headers: token ? { 'X-Admin-Token': token } : {},
|
||||
signal,
|
||||
},
|
||||
);
|
||||
if (!resp.ok) {
|
||||
const err = await parseError(resp);
|
||||
throw err;
|
||||
}
|
||||
return resp.text();
|
||||
}
|
||||
|
||||
/** GET /v1/placements/admin/export/summary — the human-readable
|
||||
* markdown rendering FROM the same document (D-108's anti-drift
|
||||
* rule); opens in-tab. */
|
||||
export async function getPlacementExportSummary(
|
||||
signal?: AbortSignal,
|
||||
): Promise<string> {
|
||||
const token = adminToken();
|
||||
const resp = await fetch(
|
||||
`${AI_SERVICE_URL}/v1/placements/admin/export/summary`,
|
||||
{
|
||||
headers: token ? { 'X-Admin-Token': token } : {},
|
||||
signal,
|
||||
},
|
||||
);
|
||||
if (!resp.ok) {
|
||||
const err = await parseError(resp);
|
||||
throw err;
|
||||
}
|
||||
return resp.text();
|
||||
}
|
||||
|
||||
@@ -65,8 +65,10 @@ test("pin: talent pages keep their mock imports + the mock-labeled metrics stay
|
||||
assert.ok(/import \{ candidates \} from '@nextcraft\/mock-data'/.test(talent), "talent search keeps the candidates mock");
|
||||
assert.ok(/import \{ candidates, competencyStacks \} from '@nextcraft\/mock-data'/.test(talentDetail), "candidate profile keeps the candidates mock");
|
||||
|
||||
// The overview's talent-match/placement metrics stay MOCK-LABELED —
|
||||
// the honest label rides the card copy (never presented as real).
|
||||
// The overview's talent-match metrics stay MOCK-LABELED — the
|
||||
// honest label rides the card copy (never presented as real).
|
||||
// v0.11 P3: placement counts are now REAL (the D-092 conversion —
|
||||
// the convertible surface left mock); only talent-match stays mock.
|
||||
const overviewClient = readFileSync(
|
||||
web("components/employer/employer-overview-client.tsx"),
|
||||
"utf8",
|
||||
@@ -77,10 +79,14 @@ test("pin: talent pages keep their mock imports + the mock-labeled metrics stay
|
||||
assert.ok(/no backend\s+computes them yet/.test(overviewClient), "analytics notice is honest about the mock");
|
||||
|
||||
// The REAL arms: active-postings + applicant counts from the real
|
||||
// route; the claimed-entity profile via the public marketplace fetch.
|
||||
// route; the claimed-entity profile via the public marketplace fetch;
|
||||
// v0.11: the placements section is REAL (listOwnEmployerPlacements).
|
||||
assert.ok(/listOwnEmployerPostings/.test(overviewClient), "overview fetches own postings");
|
||||
assert.ok(/applicant_count/.test(overviewClient), "overview renders per-posting applicant counts");
|
||||
assert.ok(/getEmployer\(/.test(overviewClient), "overview fetches the claimed-entity profile (public route)");
|
||||
assert.ok(/listOwnEmployerPlacements/.test(overviewClient), "overview fetches REAL placements (v0.11)");
|
||||
assert.ok(/Placements Recorded/.test(overviewClient), "the real placement metric renders");
|
||||
assert.ok(!/MockMetricCard label="Placement Rate"/.test(overviewClient), "the placement mock card is GONE (converted)");
|
||||
});
|
||||
|
||||
test("pin: the session-gated clients carry every honest-state branch (MH-3b, G-312 source pins)", async () => {
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
/**
|
||||
* v0.11 P5 (REQ-11-005, D-104/G-322): the learner placement-status
|
||||
* client + panel wiring. Fetch stubs pin the wire shapes + the query
|
||||
* composition (the billing-entitlement template); source pins cover
|
||||
* the panel's mine-read-first composition, the honest state arms, and
|
||||
* the A-304 badge honesty (the G-312 discipline — no render harness
|
||||
* in-tree, none may be added).
|
||||
*/
|
||||
|
||||
const QS = `?t=${Date.now()}-${Math.random()}`;
|
||||
|
||||
const root = new URL("..", import.meta.url);
|
||||
|
||||
function web(path: string): string {
|
||||
return fileURLToPath(new URL(path, root));
|
||||
}
|
||||
|
||||
const STATUS_PAYLOAD = {
|
||||
application: {
|
||||
id: "app-1",
|
||||
posting_id: "post-1",
|
||||
learner_id: "pilot-learner",
|
||||
status: "in_review",
|
||||
created_at: "2026-10-21T00:00:00Z",
|
||||
},
|
||||
events: [
|
||||
{
|
||||
id: 1,
|
||||
application_id: "app-1",
|
||||
placement_id: null,
|
||||
event_type: "application.in_review",
|
||||
actor_type: "employer",
|
||||
actor_ref: "employer:emp-test",
|
||||
payload: {},
|
||||
prev_hash: "0".repeat(64),
|
||||
hash: "a".repeat(64),
|
||||
created_at: "2026-10-21T00:01:00Z",
|
||||
},
|
||||
],
|
||||
placement: null,
|
||||
};
|
||||
|
||||
interface Call {
|
||||
url: string;
|
||||
method?: string;
|
||||
body?: unknown;
|
||||
headers: Record<string, string>;
|
||||
}
|
||||
|
||||
function stubFetch(handler: () => Response): { calls: Call[]; restore: () => void } {
|
||||
const realFetch = globalThis.fetch;
|
||||
const calls: Call[] = [];
|
||||
globalThis.fetch = (async (input: unknown, init?: RequestInit) => {
|
||||
calls.push({
|
||||
url: String(input),
|
||||
method: init?.method,
|
||||
body: init?.body ? JSON.parse(String(init.body)) : undefined,
|
||||
headers: (init?.headers ?? {}) as Record<string, string>,
|
||||
});
|
||||
return handler();
|
||||
}) as typeof fetch;
|
||||
return { calls, restore: () => { globalThis.fetch = realFetch; } };
|
||||
}
|
||||
|
||||
const ok = (payload: unknown) =>
|
||||
new Response(JSON.stringify(payload), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
|
||||
test("getMyApplication: GETs the mine route with the (posting, learner) pair — G-322", async () => {
|
||||
const { calls, restore } = stubFetch(() =>
|
||||
ok({ application_id: "app-1", status: "submitted" }),
|
||||
);
|
||||
try {
|
||||
const { getMyApplication } = await import(`../lib/engine-client.ts${QS}`);
|
||||
const mine = await getMyApplication("post-1", "pilot-learner");
|
||||
assert.equal(mine.application_id, "app-1");
|
||||
assert.equal(mine.status, "submitted");
|
||||
const url = new URL(calls[0].url, "http://localhost");
|
||||
assert.equal(url.pathname, "/v1/marketplace/applications/mine");
|
||||
assert.equal(url.searchParams.get("posting_id"), "post-1");
|
||||
assert.equal(url.searchParams.get("learner_id"), "pilot-learner");
|
||||
assert.equal(calls[0].method, undefined, "the discovery read is a GET");
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("getMyApplication: no application → EngineError 404 (the apply-CTA arm)", async () => {
|
||||
const { restore } = stubFetch(
|
||||
() => new Response(JSON.stringify({ detail: "none" }), { status: 404 }),
|
||||
);
|
||||
try {
|
||||
const { getMyApplication, EngineError } = await import(`../lib/engine-client.ts${QS}`);
|
||||
await assert.rejects(getMyApplication("post-1", "pilot-learner"), (err: unknown) => {
|
||||
assert.ok(err instanceof EngineError, "must be EngineError");
|
||||
const e = err as InstanceType<typeof EngineError>;
|
||||
return e.status === 404;
|
||||
});
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("getLearnerApplicationStatus: GETs the status read with the learner query", async () => {
|
||||
const { calls, restore } = stubFetch(() => ok(STATUS_PAYLOAD));
|
||||
try {
|
||||
const { getLearnerApplicationStatus } = await import(`../lib/engine-client.ts${QS}`);
|
||||
const status = await getLearnerApplicationStatus("app-1", "pilot-learner");
|
||||
assert.equal(status.application.status, "in_review");
|
||||
assert.equal(status.events.length, 1);
|
||||
assert.equal(status.events[0].event_type, "application.in_review");
|
||||
assert.equal(status.placement, null);
|
||||
const url = new URL(calls[0].url, "http://localhost");
|
||||
assert.equal(url.pathname, "/v1/marketplace/applications/app-1");
|
||||
assert.equal(url.searchParams.get("learner_id"), "pilot-learner");
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("withdrawApplication: POSTs the withdraw body — the learner's only edit (D-104)", async () => {
|
||||
const { calls, restore } = stubFetch(() =>
|
||||
ok({ application_id: "app-1", posting_id: "post-1", status: "withdrawn" }),
|
||||
);
|
||||
try {
|
||||
const { withdrawApplication } = await import(`../lib/engine-client.ts${QS}`);
|
||||
const confirmation = await withdrawApplication("app-1", "pilot-learner");
|
||||
assert.equal(confirmation.status, "withdrawn");
|
||||
const url = new URL(calls[0].url, "http://localhost");
|
||||
assert.equal(url.pathname, "/v1/marketplace/applications/app-1/withdraw");
|
||||
assert.equal(calls[0].method, "POST");
|
||||
assert.deepEqual(calls[0].body, { learner_id: "pilot-learner" });
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("withdrawApplication: terminal → EngineError 409 (the honest matrix refusal)", async () => {
|
||||
const { restore } = stubFetch(
|
||||
() => new Response(JSON.stringify({ detail: "matrix" }), { status: 409 }),
|
||||
);
|
||||
try {
|
||||
const { withdrawApplication, EngineError } = await import(`../lib/engine-client.ts${QS}`);
|
||||
await assert.rejects(withdrawApplication("app-1", "pilot-learner"), (err: unknown) => {
|
||||
assert.ok(err instanceof EngineError, "must be EngineError");
|
||||
const e = err as InstanceType<typeof EngineError>;
|
||||
return e.status === 409;
|
||||
});
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("getLearnerApplicationStatus: foreign → EngineError 403 (the not-your-application arm)", async () => {
|
||||
const { restore } = stubFetch(
|
||||
() => new Response(JSON.stringify({ detail: "foreign" }), { status: 403 }),
|
||||
);
|
||||
try {
|
||||
const { getLearnerApplicationStatus, EngineError } = await import(`../lib/engine-client.ts${QS}`);
|
||||
await assert.rejects(getLearnerApplicationStatus("app-1", "pilot-learner"), (err: unknown) => {
|
||||
assert.ok(err instanceof EngineError, "must be EngineError");
|
||||
const e = err as InstanceType<typeof EngineError>;
|
||||
return e.status === 403;
|
||||
});
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("pin: the panel composes mine-read first — never client-persisted application ids (G-322)", async () => {
|
||||
const src = readFileSync(web("components/marketplace/job-detail-client.tsx"), "utf8");
|
||||
assert.ok(/getMyApplication\(/.test(src), "the mine-read drives discovery");
|
||||
assert.ok(/getLearnerApplicationStatus\(/.test(src), "the status read drives the panel");
|
||||
assert.ok(/withdrawApplication\(/.test(src), "the withdraw CTA dispatches through the client");
|
||||
assert.ok(
|
||||
!/localStorage\.setItem|sessionStorage\.setItem/.test(src),
|
||||
"no client-persisted application ids (no setItem anywhere)",
|
||||
);
|
||||
// mine 404 → the apply CTA (the honest absence arm)
|
||||
assert.ok(
|
||||
/mineErr instanceof EngineError && mineErr\.status === 404/.test(src),
|
||||
"the 404 arm falls through to the apply CTA",
|
||||
);
|
||||
});
|
||||
|
||||
test("pin: the seven D-099 states render honestly (labels + withdraw gate + terminal/403 copy)", async () => {
|
||||
const src = readFileSync(web("components/marketplace/job-detail-client.tsx"), "utf8");
|
||||
for (const state of [
|
||||
"'submitted'",
|
||||
"'in_review'",
|
||||
"'interview'",
|
||||
"'offer'",
|
||||
"'hired'",
|
||||
"'rejected'",
|
||||
"'withdrawn'",
|
||||
]) {
|
||||
assert.ok(src.includes(state), `the ${state} arm renders a label`);
|
||||
}
|
||||
assert.ok(
|
||||
/WITHDRAWABLE_STATUSES/.test(src),
|
||||
"the withdraw CTA gates on non-terminal sources (D-099)",
|
||||
);
|
||||
assert.ok(/status === 409/.test(src), "the 409 arm renders the honest terminal copy");
|
||||
assert.ok(
|
||||
/This application belongs to another learner\./.test(src),
|
||||
"the foreign-403 copy renders",
|
||||
);
|
||||
});
|
||||
|
||||
test("pin: history rows + the placement-confirmation render from the status payload", async () => {
|
||||
const src = readFileSync(web("components/marketplace/job-detail-client.tsx"), "utf8");
|
||||
assert.ok(/appStatus\.events\.map\(/.test(src), "history rows render from events[]");
|
||||
assert.ok(
|
||||
/appStatus\.placement &&/.test(src),
|
||||
"the placement confirmation renders when hired",
|
||||
);
|
||||
assert.ok(
|
||||
/role_title/.test(src) && /start_date/.test(src),
|
||||
"the placement payload fields render",
|
||||
);
|
||||
assert.ok(
|
||||
/Placement recorded/.test(src),
|
||||
"the placement copy says RECORDED (G-328 — never a verification claim)",
|
||||
);
|
||||
});
|
||||
@@ -774,3 +774,171 @@ test("pricing pin: purchasable CTAs route to /employer/login; the enterprise Con
|
||||
);
|
||||
assert.ok(/mock provider \(honestly labeled/.test(client), "the pilot mock path is labeled");
|
||||
});
|
||||
|
||||
// v0.11 P3 (D-110): the placement wire pins — placement.ts mirrors
|
||||
// placements/base.py field-for-field (the G-108 discipline; the
|
||||
// employer.ts block's template). Every change happens in both places.
|
||||
|
||||
test("wire pin: Placement mirrors placements/base.py PlacementRead field-for-field (dual-schema sync)", async () => {
|
||||
const typesSrc = readFileSync(web("../../packages/types/placement.ts"), "utf8");
|
||||
const pySrc = readFileSync(
|
||||
web("../../apps/ai-service/ai_service/placements/base.py"),
|
||||
"utf8",
|
||||
);
|
||||
const block = typesSrc.slice(
|
||||
typesSrc.indexOf("export interface Placement {"),
|
||||
typesSrc.indexOf("export interface PlacementEvent"),
|
||||
);
|
||||
const fields = [
|
||||
"id:", "learner_id:", "employer_id:", "posting_id:",
|
||||
"application_id:", "role_title:", "start_date:", "status:",
|
||||
"created_at:", "decided_at:",
|
||||
];
|
||||
for (const f of fields) {
|
||||
assert.ok(block.includes(f), `Placement missing base.py field ${f}`);
|
||||
}
|
||||
// start_date is a DATE on both sides (D-103) — never a datetime
|
||||
assert.ok(
|
||||
/start_date: date/.test(pySrc),
|
||||
"base.py start_date is a pydantic date",
|
||||
);
|
||||
assert.ok(
|
||||
/start_date: string;/.test(block),
|
||||
"placement.ts start_date is the ISO date string",
|
||||
);
|
||||
});
|
||||
|
||||
test("wire pin: ApplicationStatus union mirrors the D-099 matrix (status-union regex pin)", async () => {
|
||||
const typesSrc = readFileSync(web("../../packages/types/placement.ts"), "utf8");
|
||||
const block = typesSrc.slice(
|
||||
typesSrc.indexOf("export type ApplicationStatus"),
|
||||
typesSrc.indexOf("export type PlacementStatus"),
|
||||
);
|
||||
// the seven D-099 statuses, in matrix order
|
||||
const statuses = [
|
||||
"'submitted'", "'in_review'", "'interview'", "'offer'",
|
||||
"'hired'", "'rejected'", "'withdrawn'",
|
||||
];
|
||||
for (const status of statuses) {
|
||||
assert.ok(block.includes(status), `ApplicationStatus missing ${status}`);
|
||||
}
|
||||
assert.ok(
|
||||
!/"submitted"/.test(block),
|
||||
"snake_case on the wire (no kebab 'in-review')",
|
||||
);
|
||||
});
|
||||
|
||||
test("wire pin: PlacementOverview mirrors the D-107 computation shape", async () => {
|
||||
const typesSrc = readFileSync(web("../../packages/types/placement.ts"), "utf8");
|
||||
const block = typesSrc.slice(
|
||||
typesSrc.indexOf("export interface PlacementOverview"),
|
||||
);
|
||||
const fields = [
|
||||
"total_active_placements:", "distinct_learners_placed:",
|
||||
"guiding_goal:", "by_employer:", "by_cohort_month:",
|
||||
];
|
||||
for (const f of fields) {
|
||||
assert.ok(block.includes(f), `PlacementOverview missing ${f}`);
|
||||
}
|
||||
assert.ok(
|
||||
/export const GUIDING_GOAL = 1000;/.test(typesSrc),
|
||||
"the north star is 1,000 learners",
|
||||
);
|
||||
});
|
||||
|
||||
test("wire pin: EmployerApplicationSummary carries learner_id per the G-319 supersession (D-105)", async () => {
|
||||
const typesSrc = readFileSync(web("../../packages/types/placement.ts"), "utf8");
|
||||
const block = typesSrc.slice(
|
||||
typesSrc.indexOf("export interface EmployerApplicationSummary"),
|
||||
typesSrc.indexOf("export interface EmployerApplicationsResponse"),
|
||||
);
|
||||
assert.ok(/learner_id: string;/.test(block), "learner_id rides the summary");
|
||||
// the own-postings LIST stays counts-only: OwnPostingSummary (the
|
||||
// v0.10 contract) still carries ONLY applicant_count — never a
|
||||
// learner_id field
|
||||
const employerTypes = readFileSync(web("../../packages/types/employer.ts"), "utf8");
|
||||
const ownBlock = employerTypes.slice(
|
||||
employerTypes.indexOf("export interface OwnPostingSummary"),
|
||||
employerTypes.indexOf("export interface OwnPostingsFacets"),
|
||||
);
|
||||
assert.ok(
|
||||
!/learner_id/.test(ownBlock),
|
||||
"the G-319 pin split: OwnPostingSummary stays counts-only",
|
||||
);
|
||||
});
|
||||
|
||||
// v0.11 P5 (REQ-11-005, D-110): the learner status-read wire pins +
|
||||
// the A-304 badge-honesty census — no placement badge on the mock
|
||||
// talent surfaces (D-092 binding: a badge on a mock page is
|
||||
// fabricated placement); the learner's-own application surface is the
|
||||
// ONLY placement render.
|
||||
|
||||
test("wire pin: LearnerApplicationStatus mirrors the base.py learner status read (D-104)", async () => {
|
||||
const typesSrc = readFileSync(web("../../packages/types/placement.ts"), "utf8");
|
||||
const pySrc = readFileSync(
|
||||
web("../../apps/ai-service/ai_service/placements/base.py"),
|
||||
"utf8",
|
||||
);
|
||||
const summaryBlock = typesSrc.slice(
|
||||
typesSrc.indexOf("export interface LearnerApplicationSummary"),
|
||||
typesSrc.indexOf("export interface LearnerApplicationStatus"),
|
||||
);
|
||||
for (const f of ["id:", "posting_id:", "learner_id:", "status:", "created_at:"]) {
|
||||
assert.ok(summaryBlock.includes(f), `LearnerApplicationSummary missing ${f}`);
|
||||
}
|
||||
const statusBlock = typesSrc.slice(
|
||||
typesSrc.indexOf("export interface LearnerApplicationStatus"),
|
||||
);
|
||||
assert.ok(/application: LearnerApplicationSummary;/.test(statusBlock));
|
||||
assert.ok(/events: PlacementEvent\[\];/.test(statusBlock));
|
||||
assert.ok(/placement: Placement \| null;/.test(statusBlock));
|
||||
// the backend contract carries the same three arms (base.py)
|
||||
const pyBlock = pySrc.slice(pySrc.indexOf("class LearnerApplicationStatusResponse"));
|
||||
assert.ok(/application: LearnerApplicationSummary/.test(pyBlock));
|
||||
assert.ok(/events: list\[PlacementEventRead\]/.test(pyBlock));
|
||||
assert.ok(/placement: PlacementRead \| None/.test(pyBlock));
|
||||
// the mine-read contract (G-322)
|
||||
const mineBlock = typesSrc.slice(typesSrc.indexOf("export interface MineApplication"));
|
||||
assert.ok(/application_id: string;/.test(mineBlock));
|
||||
assert.ok(/status: ApplicationStatus;/.test(mineBlock));
|
||||
assert.ok(
|
||||
/class MineApplicationResponse/.test(pySrc),
|
||||
"base.py carries the mine contract",
|
||||
);
|
||||
});
|
||||
|
||||
test("pin: A-304 badge honesty — no placement badge on the mock talent surfaces (D-092/D-109)", async () => {
|
||||
const surfaces = [
|
||||
"app/(employer)/employer/talent/page.tsx",
|
||||
"app/(employer)/employer/talent/[candidateId]/page.tsx",
|
||||
"components/employer/talent-search.tsx",
|
||||
];
|
||||
for (const surface of surfaces) {
|
||||
const src = readFileSync(web(surface), "utf8");
|
||||
assert.ok(
|
||||
!/placement/i.test(src),
|
||||
`${surface}: a badge on a mock surface is fabricated placement (D-092 binding)`,
|
||||
);
|
||||
}
|
||||
// the learner's-own application surface is the ONLY placement render
|
||||
const jobDetail = readFileSync(
|
||||
web("components/marketplace/job-detail-client.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
assert.ok(
|
||||
/appStatus\.placement &&/.test(jobDetail),
|
||||
"the placement confirmation renders only on the learner's-own application surface",
|
||||
);
|
||||
const placementBlock = jobDetail.slice(
|
||||
jobDetail.indexOf("appStatus.placement &&"),
|
||||
jobDetail.indexOf("{WITHDRAWABLE_STATUSES.has("),
|
||||
);
|
||||
assert.ok(
|
||||
/Placement recorded/.test(placementBlock),
|
||||
"G-328: the placement copy says RECORDED",
|
||||
);
|
||||
assert.ok(
|
||||
!/verified/i.test(placementBlock),
|
||||
"G-328: no verification claim on the placement surface",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# Deploying Nextcraft — the admin runbook (v0.8)
|
||||
|
||||
**TL;DR** — AI-native outcome school + marketplace — graduates prove what they can build, not what they can write. This file explains how to stand the stack up on any Debian-based server: pick one of three install channels, run `nextcraft deploy --service`, and verify with the checklist; troubleshooting tables and evidence sit lower in the file.
|
||||
|
||||
Production topology since v0.3.6: **one port, one process**. The ai-service
|
||||
(uvicorn, `:8420`) serves **both** the API and the exported web UI, so the
|
||||
site works behind any proxy that can reach exactly one backend port — e.g.
|
||||
|
||||
@@ -4,6 +4,7 @@ export * from './employer';
|
||||
export * from './defense';
|
||||
export * from './grading';
|
||||
export * from './marketplace';
|
||||
export * from './placement';
|
||||
export * from './telemetry';
|
||||
export * from './user';
|
||||
export * from './ui';
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* Nextcraft — Placement Types
|
||||
*
|
||||
* Placement-tracking wire shapes (v0.11, REQ-11-001..005): the
|
||||
* hiring-pipeline surface served by /v1/employers/applications/*,
|
||||
* /v1/marketplace/applications/*, and /v1/placements/admin/*.
|
||||
*
|
||||
* Dual-schema-sync mirror of
|
||||
* `apps/ai-service/ai_service/placements/base.py` (the G-108
|
||||
* discipline, D-110): field-for-field parity, snake_case as emitted
|
||||
* by the API — every change happens in both places, pinned by
|
||||
* `apps/web/tests/pins.test.ts`.
|
||||
*
|
||||
* D-099: the application status set is forward-only with
|
||||
* skip-forward; hired/rejected/withdrawn are TERMINAL.
|
||||
*
|
||||
* G-319 supersession (D-105): learner_id IS visible on
|
||||
* application-scoped reads for the session's own claimed-entity
|
||||
* postings — the own-postings LIST stays counts-only.
|
||||
*/
|
||||
|
||||
/** D-099: the hiring lifecycle (snake_case, the billing `past_due`
|
||||
* precedent). hired/rejected/withdrawn are terminal — no outgoing
|
||||
* edges. */
|
||||
export type ApplicationStatus =
|
||||
| 'submitted'
|
||||
| 'in_review'
|
||||
| 'interview'
|
||||
| 'offer'
|
||||
| 'hired'
|
||||
| 'rejected'
|
||||
| 'withdrawn';
|
||||
|
||||
/** D-102: the placement lifecycle — created active; the ONLY
|
||||
* transition is the operator correction active → revoked. */
|
||||
export type PlacementStatus = 'active' | 'revoked';
|
||||
|
||||
/** A placement record (base.py PlacementRead). employer/learner/
|
||||
* posting are ID REFERENCES resolved from source rows at read/export
|
||||
* (G-302) — never trusted denormalized copies. start_date is the
|
||||
* employer-attested employment start DATE (D-103), never a
|
||||
* datetime. */
|
||||
export interface Placement {
|
||||
id: string;
|
||||
learner_id: string;
|
||||
employer_id: string;
|
||||
posting_id: string;
|
||||
application_id: string;
|
||||
role_title: string;
|
||||
start_date: string;
|
||||
status: PlacementStatus;
|
||||
created_at: string;
|
||||
decided_at: string;
|
||||
}
|
||||
|
||||
/** One ledger event (base.py PlacementEventRead): the append-only
|
||||
* hash-chained trail — application-stage events carry
|
||||
* application_id (placement_id null); the placement lane carries
|
||||
* placement_id set. */
|
||||
export interface PlacementEvent {
|
||||
id: number;
|
||||
application_id: string | null;
|
||||
placement_id: string | null;
|
||||
event_type: string;
|
||||
actor_type: string;
|
||||
actor_ref: string;
|
||||
payload: Record<string, unknown>;
|
||||
prev_hash: string;
|
||||
hash: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
/** An employer-facing application row (base.py
|
||||
* EmployerApplicationSummary, D-105/G-319): learner_id visible on
|
||||
* application-scoped reads ONLY (the own-postings list stays
|
||||
* counts-only — the pin split). */
|
||||
export interface EmployerApplicationSummary {
|
||||
id: string;
|
||||
posting_id: string;
|
||||
learner_id: string;
|
||||
status: ApplicationStatus;
|
||||
created_at: string | null;
|
||||
}
|
||||
|
||||
/** GET /v1/employers/applications response (base.py
|
||||
* EmployerApplicationsResponse): own-pipeline applications (every
|
||||
* status) + per-status facet counts (every status key ALWAYS
|
||||
* present — a zero-count status carries its honest 0). */
|
||||
export interface EmployerApplicationsResponse {
|
||||
applications: EmployerApplicationSummary[];
|
||||
facets: Record<ApplicationStatus, number>;
|
||||
total: number;
|
||||
}
|
||||
|
||||
/** The transition result (base.py ApplicationTransitionResponse):
|
||||
* the application's new status + the placement record when the
|
||||
* transition created one (hire). */
|
||||
export interface ApplicationTransitionResponse {
|
||||
application_id: string;
|
||||
status: ApplicationStatus;
|
||||
placement: Placement | null;
|
||||
}
|
||||
|
||||
/** POST .../hire body (base.py HireTransitionRequest, D-103/G-324):
|
||||
* role_title is employer-attested free text (1..200 — the house
|
||||
* bounds); start_date is the employment start DATE (YYYY-MM-DD). */
|
||||
export interface HireTransitionRequest {
|
||||
role_title: string;
|
||||
start_date: string;
|
||||
}
|
||||
|
||||
/** GET /v1/employers/placements response (base.py
|
||||
* EmployerPlacementsResponse): the claimed entity's OWN placements
|
||||
* (active + revoked — the full history), created_at then id. */
|
||||
export interface EmployerPlacementsResponse {
|
||||
placements: Placement[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
/** D-107: the guiding outcome's north star (PROJECT.md line 7 — the
|
||||
* noun is LEARNERS, not placements). */
|
||||
export const GUIDING_GOAL = 1000;
|
||||
|
||||
/** GET /v1/placements/admin/overview response (base.py
|
||||
* PlacementOverview, D-107): counts computed from rows at query
|
||||
* time — never denormalized counters. The guiding metric is
|
||||
* DISTINCT learners with ≥1 active placement (a learner placed
|
||||
* twice counts ONCE); revoked excluded from every count; cohort
|
||||
* month = the calendar month of start_date (YYYY-MM). */
|
||||
export interface PlacementOverview {
|
||||
total_active_placements: number;
|
||||
distinct_learners_placed: number;
|
||||
guiding_goal: number;
|
||||
by_employer: Array<{ employer_id: string; count: number }>;
|
||||
by_cohort_month: Array<{ month: string; count: number }>;
|
||||
}
|
||||
|
||||
/** The application row on the learner status read (base.py
|
||||
* LearnerApplicationSummary, D-104): the mine-visible fields only —
|
||||
* ids + status + the apply timestamp (nullable per the v0.6 row). */
|
||||
export interface LearnerApplicationSummary {
|
||||
id: string;
|
||||
posting_id: string;
|
||||
learner_id: string;
|
||||
status: ApplicationStatus;
|
||||
created_at: string | null;
|
||||
}
|
||||
|
||||
/** GET /v1/marketplace/applications/{application_id}?learner_id=
|
||||
* response (base.py LearnerApplicationStatusResponse, REQ-11-005):
|
||||
* the learner's-own application read — current status + the full
|
||||
* id-ordered event history for THIS application + the placement
|
||||
* record when hired (the honest null otherwise, A-304). */
|
||||
export interface LearnerApplicationStatus {
|
||||
application: LearnerApplicationSummary;
|
||||
events: PlacementEvent[];
|
||||
placement: Placement | null;
|
||||
}
|
||||
|
||||
/** GET /v1/marketplace/applications/mine response (base.py
|
||||
* MineApplicationResponse, G-322): the panel's application discovery
|
||||
* on revisit — a 404 renders the apply CTA (the panel never depends
|
||||
* on client-persisted application_ids). */
|
||||
export interface MineApplication {
|
||||
application_id: string;
|
||||
status: ApplicationStatus;
|
||||
}
|
||||
Reference in New Issue
Block a user