Compare commits

...

4 Commits

Author SHA1 Message Date
CIAgent 9f727427a7 feat(P04): complete audit-export phase (REQ-11-004)
CI / node (push) Successful in 41s
Release / release (push) Successful in 2m49s
CI / python (push) Successful in 3m36s
Phase 4 complete: the self-contained CIRR-style audit export (JSON +
markdown summary from the same document, methodology with the three
pilot gaps + the G-321 chain-honesty statements, G-325 revoked
evidence bundles, R-73 determinism), the admin placements block +
export links, the full-chain E2E audit proof, the security review
fixes. Verify four-layers PASS. Ships as v0.10.4.

---ci---
phase: 4
milestone: v0.11
status: complete
requirements:
  covered: [REQ-11-004]
  partial: []
---/ci---
2026-10-01 14:14:38 +00:00
CIAgent 151a2ffff8 chore(P03): phase 3 complete — v0.10.3 shipped, release 958
CI / node (push) Failing after 46s
CI / python (push) Successful in 3m19s
---ci---
phase: 3
milestone: v0.11
status: complete
requirements:
  covered: [REQ-11-003]
  partial: []
---/ci---
2026-10-01 14:00:24 +00:00
CIAgent cb3af9d06a feat(P03): complete placement-reporting phase (REQ-11-003)
CI / node (push) Successful in 39s
Release / release (push) Successful in 2m52s
CI / python (push) Successful in 3m34s
Phase 3 complete: the D-107 overview (distinct-learner counting
toward 1,000), the per-employer placement view, placement.ts wire
pins, the engine-client placements section, the /employer/applications
page + the overview mock-block conversion. Verify four-layers PASS.
Ships as v0.10.3.

---ci---
phase: 3
milestone: v0.11
status: complete
requirements:
  covered: [REQ-11-003]
  partial: []
---/ci---
2026-10-01 13:59:56 +00:00
CIAgent 89bff0c36a chore(P02): phase 2 complete — v0.10.2 shipped, release 957
CI / node (push) Successful in 41s
CI / python (push) Successful in 3m17s
---ci---
phase: 2
milestone: v0.11
status: complete
requirements:
  covered: [REQ-11-002]
  partial: []
---/ci---
2026-10-01 13:12:42 +00:00
19 changed files with 1950 additions and 21 deletions
+2 -2
View File
@@ -1,8 +1,8 @@
{
"phase": 2,
"phase": 4,
"stage": "verify",
"milestone": "v0.11",
"phase_role": "execution",
"attempts": 0,
"updated_at": "2026-10-01T07:10:00Z"
"updated_at": "2026-10-01T11:00:00Z"
}
+2 -2
View File
@@ -14,13 +14,13 @@
| 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
+2 -2
View File
@@ -44,8 +44,8 @@ Payment processing and subscription management (REQ-F-018 activated per D-074, a
|---|------|--------|------------|--------------|------------------|
| 0 | Pre-execution | active | — | — | Specify (D-098), clarify, research, plan, grill, MVP/UX check |
| 1 | Placement store + evidence ledger | complete (v0.10.1) | 0 | REQ-11-001 | `ai_service/placements/` module: 9th D-027 store (PlacementRow + PlacementEventRow, hash-chained append-only ledger), evidence resolved from source rows only (G-302 doctrine), `create_all` idempotent on the shared DB file, Protocol + SQLite pair |
| 2 | Application state machine | 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 |
| 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 | 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 |
@@ -82,6 +82,7 @@ from ..placements.base import (
ApplicationTransitionResponse,
EmployerApplicationsResponse,
EmployerApplicationSummary,
EmployerPlacementsResponse,
HireTransitionRequest,
PlacementRead,
)
@@ -946,6 +947,29 @@ claims_router.add_api_route(
)
# -- 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) ------------------
@@ -15,17 +15,20 @@ 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
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
@@ -185,3 +188,143 @@ async def admin_rehire_placement(
) 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",
)
@@ -239,3 +239,99 @@ class AdminTransitionRequest(BaseModel):
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
@@ -50,6 +50,7 @@ from .base import (
EVENT_PLACEMENT_CREATED,
EVENT_PLACEMENT_REHIRED,
EVENT_PLACEMENT_REVOKED,
EXPORT_METHODOLOGY,
GENESIS_HASH,
InvalidTransition,
validate_transition,
@@ -302,6 +303,22 @@ class PlacementStore(Protocol):
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:
...
@@ -672,5 +689,196 @@ class SQLitePlacementStore:
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()
@@ -195,9 +195,11 @@ def test_revoke_then_rehire_three_event_ledger(scaffold) -> None:
assert resp.status_code == 200, resp.text
assert resp.json()["status"] == "revoked"
# the revoked placement is out of the active set but in history
# 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 == 404 # P3 ships the overview — 404 until then
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)
@@ -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,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,31 @@
import { Badge } from '@nextcraft/ui';
import { EmployerApplicationsClient } from '../../../../components/employer/employer-applications-client';
/**
* The hiring pipeline (v0.11, REQ-11-002/003, D-109 surface 1): own
* applications with the seven D-099 status badges, per-target
* transition CTAs, and the hire form {role_title, start_date}
* (employer-attested, D-103). Session-gated shell with honest
* loading/error/empty/claim states (the A-406 board-client
* discipline). learner_id IS visible here — the G-319 supersession
* scopes it to the session's own claimed-entity postings (D-105).
*/
export default function ApplicationsPage() {
return (
<div className="flex flex-col gap-6">
<header className="flex flex-col gap-2">
<Badge variant="info">Hiring Pipeline</Badge>
<h1 className="text-3xl font-bold tracking-tight text-slate-900 dark:text-slate-100">
Hiring Pipeline
</h1>
<p className="max-w-2xl text-slate-600 dark:text-slate-400">
Review applicants on your postings and drive them through the hiring
lifecycle — in review, interview, offer, reject, or hire. A hire
records the placement with the role title and start date you attest.
</p>
</header>
<EmployerApplicationsClient />
</div>
);
}
@@ -8,6 +8,7 @@ import type {
AdminClaimsResponse,
EmployerClaim,
EmployerClaimEvidence,
PlacementOverview,
} from '@nextcraft/types';
import {
listEmployerAdminAccounts,
@@ -15,6 +16,9 @@ import {
listEmployerAdminAudit,
approveEmployerAdminClaim,
rejectEmployerAdminClaim,
getPlacementOverview,
getPlacementExport,
getPlacementExportSummary,
setAdminToken,
EngineError,
} from '../../lib/engine-client';
@@ -58,6 +62,10 @@ export function EmployerNetworkPanel() {
const [accounts, setAccounts] = useState<AdminAccountsResponse | null>(null);
const [claims, setClaims] = useState<AdminClaimsResponse | null>(null);
const [audit, setAudit] = useState<AdminAuditResponse | null>(null);
const [overview, setOverview] = useState<PlacementOverview | null>(null);
const [exportSummary, setExportSummary] = useState<string | null>(null);
const [exportBusy, setExportBusy] = useState(false);
const [exportError, setExportError] = useState<string | null>(null);
const [locked, setLocked] = useState(false);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
@@ -75,14 +83,18 @@ export function EmployerNetworkPanel() {
try {
// All three operator reads ride the SAME admin transport; a 403
// on any of them locks the whole overview (one token, one gate).
const [acct, queue, trail] = await Promise.all([
const [acct, queue, trail, placed] = await Promise.all([
listEmployerAdminAccounts(controller.signal),
listEmployerAdminClaims(controller.signal),
listEmployerAdminAudit(controller.signal),
// v0.11 P3/P4: the placements overview rides the same admin
// transport + gate; a fetch failure is honest (the rest loads).
getPlacementOverview(controller.signal).catch(() => null),
]);
setAccounts(acct);
setClaims(queue);
setAudit(trail);
setOverview(placed);
setLocked(false);
} catch (err) {
if (err instanceof EngineError && err.status === 403) {
@@ -142,6 +154,39 @@ export function EmployerNetworkPanel() {
// for the operator's explicit load — the entitlement-panel shape).
}, []);
/** The export actions (D-109 surface 4): the summary opens IN-TAB
* (the markdown renders in the panel); the JSON DOWNLOADS as a
* blob. G-328: the surrounding copy says RECORDED placements —
* never a verification claim. */
const runExport = async (kind: 'summary' | 'download') => {
setExportBusy(true);
setExportError(null);
const controller = new AbortController();
try {
if (kind === 'summary') {
const text = await getPlacementExportSummary(controller.signal);
setExportSummary(text);
} else {
const text = await getPlacementExport(controller.signal);
const blob = new Blob([text], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `placement-audit-export-${new Date().toISOString().slice(0, 10)}.json`;
link.click();
URL.revokeObjectURL(url);
}
} catch (err) {
if (err instanceof EngineError && err.status === 403) {
setLocked(true);
} else {
setExportError(err instanceof Error ? err.message : 'The export failed.');
}
} finally {
setExportBusy(false);
}
};
return (
<Card>
<CardBody className="flex flex-col gap-4">
@@ -174,6 +219,67 @@ export function EmployerNetworkPanel() {
{accounts && claims && audit && (
<div className="flex flex-col gap-6">
{/* v0.11 P3/P4 (D-109 surface 4): the placements block —
RECORDED placements toward the 1,000 guiding goal +
per-employer lists + the export links. G-328: recorded
placements, never a verification claim. */}
{overview && (
<div className="flex flex-col gap-2 rounded-lg border border-slate-200 p-3 dark:border-slate-800">
<h4 className="text-sm font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400">
Recorded placements
</h4>
<p className="text-sm text-slate-700 dark:text-slate-300">
<span className="text-2xl font-bold">
{overview.distinct_learners_placed}
</span>{' '}
distinct learners placed (employer-attested records, toward
the {overview.guiding_goal} guiding goal) ·{' '}
{overview.total_active_placements} active placements
</p>
{overview.by_employer.length > 0 && (
<ul className="flex flex-wrap gap-2 text-xs text-slate-600 dark:text-slate-400">
{overview.by_employer.map((bucket) => (
<li
key={bucket.employer_id}
className="rounded-full bg-slate-100 px-2 py-1 dark:bg-slate-800/60"
>
<span className="font-mono">{bucket.employer_id}</span>: {bucket.count}
</li>
))}
</ul>
)}
<div className="flex flex-wrap gap-2">
<Button
variant="secondary"
size="sm"
disabled={exportBusy}
onClick={() => {
void runExport('summary');
}}
>
{exportBusy ? 'Working…' : 'Open export summary (in-tab)'}
</Button>
<Button
variant="outline"
size="sm"
disabled={exportBusy}
onClick={() => {
void runExport('download');
}}
>
{exportBusy ? 'Working…' : 'Download audit export (JSON)'}
</Button>
</div>
{exportError && (
<p className="text-sm text-red-700 dark:text-red-300">{exportError}</p>
)}
{exportSummary && (
<pre className="max-h-80 overflow-auto whitespace-pre-wrap rounded-md bg-slate-50 p-3 text-xs text-slate-700 dark:bg-slate-800/60 dark:text-slate-300">
{exportSummary}
</pre>
)}
</div>
)}
{/* Accounts list — email, claimed entity, created_at (the
UNVERIFIED marker renders as such, D-097). */}
<div className="flex flex-col gap-2">
@@ -0,0 +1,342 @@
'use client';
import { useEffect, useState } from 'react';
import { Badge, Button, Card, CardBody, Input } from '@nextcraft/ui';
import type {
ApplicationStatus,
EmployerApplicationSummary,
} from '@nextcraft/types';
import {
listOwnEmployerApplications,
employerMarkInReview,
employerMarkInterview,
employerMarkOffer,
employerRejectApplication,
employerHireApplication,
SessionRequiredError,
ClaimRequiredError,
EngineError,
} from '../../lib/engine-client';
/**
* The hiring-pipeline client (v0.11, REQ-11-002/003, D-109 surface
* 1): the session employer's OWN applications (every status, per-
* status facets) with per-target transition CTAs — the matrix stays
* OUT of the request bodies (D-105: each call names its target).
* The hire form carries the employer-attested {role_title,
* start_date} (D-103/G-324). Withdraw is NEVER offered here (D-099:
* employers reject; only the learner or an operator can withdraw).
*
* Honest states (A-406): loading/error/empty/claim, per-action
* pending + error; a 403 cross-entity or 409 matrix arm renders from
* EngineError.status — NO new error classes (D-110). learner_id IS
* visible (the G-319 supersession, own applicants only).
*/
const STATUS_LABELS: Record<ApplicationStatus, string> = {
submitted: 'Submitted',
in_review: 'In Review',
interview: 'Interview',
offer: 'Offer',
hired: 'Hired',
rejected: 'Rejected',
withdrawn: 'Withdrawn',
};
const STATUS_BADGE: Record<ApplicationStatus, 'default' | 'info' | 'success' | 'warning' | 'error'> = {
submitted: 'default',
in_review: 'info',
interview: 'info',
offer: 'warning',
hired: 'success',
rejected: 'error',
withdrawn: 'default',
};
/** The next legal targets per the D-099 matrix (forward-only with
* skip-forward — the CTA set matches the store's transition set). */
const NEXT_TARGETS: Record<string, Array<{ key: string; label: string }>> = {
submitted: [
{ key: 'in-review', label: 'In Review' },
{ key: 'interview', label: 'Interview' },
{ key: 'reject', label: 'Reject' },
{ key: 'hire', label: 'Hire' },
],
in_review: [
{ key: 'interview', label: 'Interview' },
{ key: 'offer', label: 'Offer' },
{ key: 'reject', label: 'Reject' },
{ key: 'hire', label: 'Hire' },
],
interview: [
{ key: 'offer', label: 'Offer' },
{ key: 'reject', label: 'Reject' },
{ key: 'hire', label: 'Hire' },
],
offer: [
{ key: 'reject', label: 'Reject' },
{ key: 'hire', label: 'Hire' },
],
};
export function EmployerApplicationsClient() {
const [applications, setApplications] = useState<EmployerApplicationSummary[] | null>(null);
const [loggedOut, setLoggedOut] = useState(false);
const [claimPrompt, setClaimPrompt] = useState(false);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
// per-application action state: the honest pending/error pair
const [pending, setPending] = useState<string | null>(null);
const [actionError, setActionError] = useState<string | null>(null);
// the hire form: which application id is being hired + its fields
const [hireFor, setHireFor] = useState<string | null>(null);
const [roleTitle, setRoleTitle] = useState('');
const [startDate, setStartDate] = useState('');
useEffect(() => {
const controller = new AbortController();
(async () => {
try {
const body = await listOwnEmployerApplications(controller.signal);
if (!controller.signal.aborted) setApplications(body.applications);
} catch (err) {
if (controller.signal.aborted) return;
if (err instanceof SessionRequiredError) {
setLoggedOut(true); // honest logged-out state — never a retry loop
} else if (err instanceof ClaimRequiredError) {
setClaimPrompt(true); // the honest claim-CTA state
} else {
setError(err instanceof Error ? err.message : 'Failed to load applications.');
}
} finally {
if (!controller.signal.aborted) setLoading(false);
}
})();
return () => controller.abort();
}, []);
async function runTransition(
application: EmployerApplicationSummary,
target: 'in-review' | 'interview' | 'offer' | 'reject',
): Promise<void> {
setActionError(null);
setPending(application.id);
try {
const dispatch = {
'in-review': employerMarkInReview,
interview: employerMarkInterview,
offer: employerMarkOffer,
reject: employerRejectApplication,
}[target];
const body = await dispatch(application.id);
if (body.application_id === application.id) {
setApplications((prev) =>
(prev ?? []).map((a) => (a.id === application.id ? { ...a, status: body.status } : a)),
);
}
} catch (err) {
if (err instanceof SessionRequiredError) {
setLoggedOut(true);
} else if (err instanceof ClaimRequiredError) {
setClaimPrompt(true);
} else if (err instanceof EngineError) {
// the 403 cross-entity + 409 matrix arms — the honest status
setActionError(`${application.id}: ${err.message}`);
} else {
setActionError(err instanceof Error ? err.message : 'The transition failed.');
}
} finally {
setPending(null);
}
}
async function submitHire(application: EmployerApplicationSummary): Promise<void> {
setActionError(null);
setPending(application.id);
try {
const body = await employerHireApplication(application.id, {
role_title: roleTitle,
start_date: startDate,
});
setApplications((prev) =>
(prev ?? []).map((a) => (a.id === application.id ? { ...a, status: body.status } : a)),
);
setHireFor(null);
setRoleTitle('');
setStartDate('');
} catch (err) {
if (err instanceof SessionRequiredError) {
setLoggedOut(true);
} else if (err instanceof ClaimRequiredError) {
setClaimPrompt(true);
} else {
// 422 (missing/empty fields, G-324) + 409 (matrix) render here
setActionError(err instanceof Error ? err.message : 'The hire failed.');
}
} finally {
setPending(null);
}
}
if (loading) {
return (
<Card>
<CardBody className="py-10 text-center text-slate-600 dark:text-slate-400">
Loading your hiring pipeline…
</CardBody>
</Card>
);
}
if (loggedOut) {
return (
<Card>
<CardBody className="flex flex-col items-center gap-3 py-10 text-center">
<p className="text-slate-700 dark:text-slate-300">
You are signed out — your session is missing or expired.
</p>
<a href="/employer/login">
<Button variant="primary" size="md">Sign in</Button>
</a>
</CardBody>
</Card>
);
}
if (claimPrompt) {
return (
<Card>
<CardBody className="flex flex-col items-center gap-3 py-10 text-center">
<p className="text-slate-700 dark:text-slate-300">
No employer entity is claimed on this account yet.
</p>
<p className="max-w-md text-sm text-slate-600 dark:text-slate-400">
Claim your company&apos;s entity first — the hiring pipeline lists
applicants on your claimed entity&apos;s postings.
</p>
</CardBody>
</Card>
);
}
if (error) {
return (
<Card>
<CardBody className="flex flex-col items-center gap-2 py-10 text-center">
<p className="text-slate-700 dark:text-slate-300">{error}</p>
<p className="text-sm text-slate-500 dark:text-slate-400">
The hiring pipeline is unavailable — please try again shortly.
</p>
</CardBody>
</Card>
);
}
if (!applications || applications.length === 0) {
return (
<Card>
<CardBody className="py-10 text-center text-sm text-slate-500 dark:text-slate-400">
No applications yet — applicants on your published postings appear
here as they apply.
</CardBody>
</Card>
);
}
return (
<div className="flex flex-col gap-4">
{actionError && (
<Card>
<CardBody className="py-3 text-sm text-red-700 dark:text-red-300">{actionError}</CardBody>
</Card>
)}
{applications.map((application) => {
const targets = NEXT_TARGETS[application.status] ?? [];
return (
<Card key={application.id}>
<CardBody className="flex flex-col gap-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2">
<Badge variant={STATUS_BADGE[application.status]}>
{STATUS_LABELS[application.status]}
</Badge>
<span className="text-sm font-medium text-slate-900 dark:text-slate-100">
{application.learner_id}
</span>
<span className="text-xs text-slate-500 dark:text-slate-400">
applied {application.created_at ? application.created_at.slice(0, 10) : ''}
</span>
</div>
<span className="text-xs text-slate-400 dark:text-slate-500">
{application.id}
</span>
</div>
{hireFor === application.id ? (
<div className="flex flex-col gap-3 rounded-lg border border-slate-200 p-3 dark:border-slate-800">
<p className="text-sm font-medium text-slate-800 dark:text-slate-200">
Record the placement you attest (visible to the learner and
the audit export):
</p>
<Input
label="Role title"
value={roleTitle}
onChange={(e) => setRoleTitle(e.target.value)}
placeholder="AI Orchestration Engineer"
maxLength={200}
/>
<Input
label="Employment start date"
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
/>
<div className="flex gap-2">
<Button
variant="primary"
size="sm"
disabled={pending === application.id || roleTitle === '' || startDate === ''}
onClick={() => {
void submitHire(application);
}}
>
{pending === application.id ? 'Recording…' : 'Confirm hire'}
</Button>
<Button variant="outline" size="sm" onClick={() => setHireFor(null)}>
Cancel
</Button>
</div>
</div>
) : (
<div className="flex flex-wrap gap-2">
{targets.map((target) => (
<Button
key={target.key}
variant={target.key === 'reject' ? 'outline' : 'secondary'}
size="sm"
disabled={pending === application.id}
onClick={() => {
if (target.key === 'hire') {
setHireFor(application.id);
setActionError(null);
} else {
void runTransition(
application,
target.key as 'in-review' | 'interview' | 'offer' | 'reject',
);
}
}}
>
{pending === application.id ? '…' : target.label}
</Button>
))}
{/* D-099: withdraw is NEVER offered to employers */}
</div>
)}
</CardBody>
</Card>
);
})}
</div>
);
}
@@ -4,10 +4,16 @@ import { useEffect, useState } from 'react';
import Link from 'next/link';
import { Briefcase, Users, TrendingUp, Target, ArrowRight } from 'lucide-react';
import { Badge, Button, Card, CardBody, Avatar } from '@nextcraft/ui';
import type { EmployerProfile, EmployerMe, OwnPostingsResponse } from '@nextcraft/types';
import type {
EmployerProfile,
EmployerMe,
OwnPostingsResponse,
EmployerPlacementsResponse,
} from '@nextcraft/types';
import {
getEmployerMe,
listOwnEmployerPostings,
listOwnEmployerPlacements,
getEmployer,
SessionRequiredError,
ClaimRequiredError,
@@ -33,6 +39,7 @@ export function EmployerOverviewClient() {
const [me, setMe] = useState<EmployerMe | null>(null);
const [postings, setPostings] = useState<OwnPostingsResponse | null>(null);
const [profile, setProfile] = useState<EmployerProfile | null>(null);
const [placements, setPlacements] = useState<EmployerPlacementsResponse | null>(null);
const [loggedOut, setLoggedOut] = useState(false);
const [claimPrompt, setClaimPrompt] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -45,12 +52,18 @@ export function EmployerOverviewClient() {
const meBody = await getEmployerMe(controller.signal);
setMe(meBody);
if (meBody.claimed_employer_id) {
const [own, prof] = await Promise.all([
const [own, prof, placed] = await Promise.all([
listOwnEmployerPostings(controller.signal),
getEmployer(meBody.claimed_employer_id, controller.signal).catch(() => null),
// v0.11 P3 (D-109 surface 2): REAL placements — the mock
// metric block is REPLACED (D-092: never leave a convertible
// surface on mock). A placements fetch failure is honest —
// the rest of the dashboard still renders.
listOwnEmployerPlacements(controller.signal).catch(() => null),
]);
setPostings(own);
setProfile(prof);
setPlacements(placed);
}
} catch (err) {
if (controller.signal.aborted) return;
@@ -160,6 +173,10 @@ export function EmployerOverviewClient() {
const activePostings = facets?.published ?? 0;
const totalApplicants =
postings?.postings.reduce((sum, p) => sum + p.applicant_count, 0) ?? 0;
// v0.11 P3 (D-107): the active-placement count from the rows at
// query time — the honest 0 when none exist.
const activePlacements =
placements?.placements.filter((p) => p.status === 'active').length ?? 0;
return (
<div className="flex flex-col gap-8">
@@ -187,13 +204,20 @@ export function EmployerOverviewClient() {
</p>
</header>
{/* Metric cards — postings/applicants REAL (GET /v1/employers/
postings); talent-match/placement stay mock-labeled (D-092). */}
{/* Metric cards — postings/applicants/placements REAL (GET
/v1/employers/postings + /placements, v0.11 P3);
talent-match stays mock-labeled (D-092 — no backend computes
it). */}
<section className="grid grid-cols-2 gap-4 lg:grid-cols-4">
<MetricCard label="Active Postings" value={activePostings} icon={Briefcase} tone="primary" />
<MetricCard label="Total Applicants" value={totalApplicants} icon={Users} tone="accent" />
<MetricCard
label="Placements Recorded"
value={activePlacements}
icon={TrendingUp}
tone="amber"
/>
<MockMetricCard label="Talent Matches" value="—" icon={Target} tone="violet" />
<MockMetricCard label="Placement Rate" value="—" icon={TrendingUp} tone="amber" />
</section>
{/* Middle row — per-posting applicant table + claimed-entity profile */}
@@ -286,6 +310,66 @@ export function EmployerOverviewClient() {
</div>
</section>
{/* v0.11 P3 (D-109 surface 2, REQ-11-003): REAL placements — the
hired applications with role + start date. The mock
placement block is REPLACED (D-092); talent-match analytics
stay mock-labeled (no backend computes them). */}
<section>
<div className="mb-3 flex items-center justify-between">
<h2 className="text-lg font-semibold text-slate-900 dark:text-slate-100">
Placements
</h2>
<Link
href="/employer/applications"
className="inline-flex items-center gap-1 text-sm font-medium text-primary-700 hover:underline dark:text-primary-300"
>
Manage hiring pipeline <ArrowRight className="h-3.5 w-3.5" />
</Link>
</div>
<Card>
{placements && placements.placements.length > 0 ? (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-slate-200 text-left text-xs uppercase tracking-wide text-slate-500 dark:border-slate-800 dark:text-slate-400">
<th className="p-3 font-medium">Role</th>
<th className="p-3 font-medium">Learner</th>
<th className="p-3 font-medium">Start Date</th>
<th className="p-3 font-medium">Status</th>
</tr>
</thead>
<tbody>
{placements.placements.map((p) => (
<tr
key={p.id}
className="border-b border-slate-100 last:border-0 dark:border-slate-800/60"
>
<td className="p-3 font-medium text-slate-900 dark:text-slate-100">
{p.role_title}
</td>
<td className="p-3 text-slate-700 dark:text-slate-300">{p.learner_id}</td>
<td className="p-3 text-slate-700 dark:text-slate-300">{p.start_date}</td>
<td className="p-3">
{p.status === 'active' ? (
<Badge variant="success">Active</Badge>
) : (
<Badge variant="default">Revoked</Badge>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<CardBody className="py-8 text-center text-sm text-slate-500 dark:text-slate-400">
No placements recorded yet — hires you record in the hiring
pipeline appear here.
</CardBody>
)}
</Card>
</section>
{/* Mock-labeled analytics (D-092: no backend computes these). */}
<section>
<h2 className="mb-3 text-lg font-semibold text-slate-900 dark:text-slate-100">
@@ -364,12 +448,14 @@ function PostingStatusBadge({ status }: { status: 'draft' | 'published' | 'close
}
/** D-092 honest mock label for the analytics section — the charts ride
* nowhere until a backend computes them; the copy says so. */
* nowhere until a backend computes them; the copy says so. v0.11:
* placement COUNTS are now real (the section above) — only the
* talent-match analytics remain mock. */
function MockAnalyticsNotice() {
return (
<p className="text-sm text-slate-500 dark:text-slate-400">
Talent-match and placement analytics are sample data at pilot — no backend
computes them yet. Real posting and applicant counts appear above.
Talent-match analytics are sample data at pilot — no backend computes them yet.
Real posting, applicant, and placement counts appear above.
</p>
);
}
+153
View File
@@ -47,6 +47,11 @@ import type {
AdminClaimsResponse,
ClaimDecisionResponse,
AdminAuditResponse,
EmployerApplicationsResponse,
ApplicationTransitionResponse,
HireTransitionRequest,
EmployerPlacementsResponse,
PlacementOverview,
} from '@nextcraft/types';
import { engineBaseUrl } from './engine-base-url';
@@ -1171,3 +1176,151 @@ 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,
});
}
// -- admin placements (v0.11 P3/P4, REQ-11-003/004, D-107/D-108) --------------------
/** GET /v1/placements/admin/overview — the D-107 computation over the
* G-308 admin transport: distinct learners toward the 1,000 guiding
* goal, active placements, revoked count, per-employer + per-cohort
* month breakdowns. G-328: the caller labels these as RECORDED
* placements — never a verification claim. */
export async function getPlacementOverview(
signal?: AbortSignal,
): Promise<PlacementOverview> {
return adminJsonFetch<PlacementOverview>('/v1/placements/admin/overview', {
signal,
});
}
/** GET /v1/placements/admin/export — the self-contained audit JSON
* (D-108: cohort + evidence bundles + methodology + the ledger with
* head_hash). The caller downloads the document as a JSON blob. */
export async function getPlacementExport(signal?: AbortSignal): Promise<string> {
const token = adminToken();
const resp = await fetch(
`${AI_SERVICE_URL}/v1/placements/admin/export`,
{
headers: token ? { 'X-Admin-Token': token } : {},
signal,
},
);
if (!resp.ok) {
const err = await parseError(resp);
throw err;
}
return resp.text();
}
/** GET /v1/placements/admin/export/summary — the human-readable
* markdown rendering FROM the same document (D-108's anti-drift
* rule); opens in-tab. */
export async function getPlacementExportSummary(
signal?: AbortSignal,
): Promise<string> {
const token = adminToken();
const resp = await fetch(
`${AI_SERVICE_URL}/v1/placements/admin/export/summary`,
{
headers: token ? { 'X-Admin-Token': token } : {},
signal,
},
);
if (!resp.ok) {
const err = await parseError(resp);
throw err;
}
return resp.text();
}
+9 -3
View File
@@ -65,8 +65,10 @@ test("pin: talent pages keep their mock imports + the mock-labeled metrics stay
assert.ok(/import \{ candidates \} from '@nextcraft\/mock-data'/.test(talent), "talent search keeps the candidates mock");
assert.ok(/import \{ candidates, competencyStacks \} from '@nextcraft\/mock-data'/.test(talentDetail), "candidate profile keeps the candidates mock");
// The overview's talent-match/placement metrics stay MOCK-LABELED —
// the honest label rides the card copy (never presented as real).
// The overview's talent-match metrics stay MOCK-LABELED — the
// honest label rides the card copy (never presented as real).
// v0.11 P3: placement counts are now REAL (the D-092 conversion —
// the convertible surface left mock); only talent-match stays mock.
const overviewClient = readFileSync(
web("components/employer/employer-overview-client.tsx"),
"utf8",
@@ -77,10 +79,14 @@ test("pin: talent pages keep their mock imports + the mock-labeled metrics stay
assert.ok(/no backend\s+computes them yet/.test(overviewClient), "analytics notice is honest about the mock");
// The REAL arms: active-postings + applicant counts from the real
// route; the claimed-entity profile via the public marketplace fetch.
// route; the claimed-entity profile via the public marketplace fetch;
// v0.11: the placements section is REAL (listOwnEmployerPlacements).
assert.ok(/listOwnEmployerPostings/.test(overviewClient), "overview fetches own postings");
assert.ok(/applicant_count/.test(overviewClient), "overview renders per-posting applicant counts");
assert.ok(/getEmployer\(/.test(overviewClient), "overview fetches the claimed-entity profile (public route)");
assert.ok(/listOwnEmployerPlacements/.test(overviewClient), "overview fetches REAL placements (v0.11)");
assert.ok(/Placements Recorded/.test(overviewClient), "the real placement metric renders");
assert.ok(!/MockMetricCard label="Placement Rate"/.test(overviewClient), "the placement mock card is GONE (converted)");
});
test("pin: the session-gated clients carry every honest-state branch (MH-3b, G-312 source pins)", async () => {
+92
View File
@@ -774,3 +774,95 @@ 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",
);
});
+1
View File
@@ -4,6 +4,7 @@ export * from './employer';
export * from './defense';
export * from './grading';
export * from './marketplace';
export * from './placement';
export * from './telemetry';
export * from './user';
export * from './ui';
+136
View File
@@ -0,0 +1,136 @@
/**
* 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 }>;
}