feat(P04): complete audit-export phase (REQ-11-004)
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---
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"phase": 3,
|
||||
"stage": "complete",
|
||||
"phase": 4,
|
||||
"stage": "verify",
|
||||
"milestone": "v0.11",
|
||||
"phase_role": "execution",
|
||||
"attempts": 0,
|
||||
"updated_at": "2026-10-01T09:25:00Z"
|
||||
"updated_at": "2026-10-01T11:00:00Z"
|
||||
}
|
||||
|
||||
@@ -15,10 +15,11 @@ 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
|
||||
@@ -228,3 +229,102 @@ async def placement_overview(
|
||||
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",
|
||||
)
|
||||
|
||||
@@ -245,6 +245,69 @@ class AdminTransitionRequest(BaseModel):
|
||||
#: 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
|
||||
|
||||
@@ -50,6 +50,7 @@ from .base import (
|
||||
EVENT_PLACEMENT_CREATED,
|
||||
EVENT_PLACEMENT_REHIRED,
|
||||
EVENT_PLACEMENT_REVOKED,
|
||||
EXPORT_METHODOLOGY,
|
||||
GENESIS_HASH,
|
||||
InvalidTransition,
|
||||
validate_transition,
|
||||
@@ -310,6 +311,14 @@ class PlacementStore(Protocol):
|
||||
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:
|
||||
...
|
||||
|
||||
@@ -695,5 +704,181 @@ class SQLitePlacementStore:
|
||||
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()
|
||||
|
||||
@@ -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}]
|
||||
@@ -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">
|
||||
|
||||
@@ -51,6 +51,7 @@ import type {
|
||||
ApplicationTransitionResponse,
|
||||
HireTransitionRequest,
|
||||
EmployerPlacementsResponse,
|
||||
PlacementOverview,
|
||||
} from '@nextcraft/types';
|
||||
|
||||
import { engineBaseUrl } from './engine-base-url';
|
||||
@@ -1268,3 +1269,58 @@ export async function listOwnEmployerPlacements(
|
||||
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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user