{
  "product": "Verdikt",
  "generated_at": "2026-10-19T04:20:00Z",
  "commit": "203f4be",
  "sources": [
    "docs/roadmap.md",
    "docs/HACKATHON.md",
    "docs/adr/0001-adopt-agpl-for-pymupdf.md",
    "docs/adr/0002-pipeline-inbox-view.md",
    "docs/adr/0003-doc-packages.md",
    "docs/adr/0004-mdm-lookup-history-retrieval.md"
  ],
  "summary": {
    "categories": 10,
    "features_total": 103,
    "shipped": 101,
    "in_progress": 0,
    "backlog": 2,
    "parked": 1
  },
  "categories": [
    {
      "id": "pipeline",
      "name": "PAP Pipeline (Verdikt)",
      "description": "Five-stage decision pipeline (ingest, triage, decide, deliver, remember) from raw enrollment packet to structured JSON verdict with rule citations.",
      "features": [
        {
          "name": "Intake extraction (MiniCPM-V, multimodal)",
          "status": "shipped",
          "description": "Vision-language extraction over mixed PDFs, images, and OCR'd faxes. Served by Nebius Token Factory.",
          "evidence": "src/pap_pipeline/stages/extract.py"
        },
        {
          "name": "Program-configurable extraction schema",
          "status": "shipped",
          "description": "Each Program row carries a JSONB extraction_schema; adding a program is a data change, not a build.",
          "evidence": "alembic 0023 / 0024, /ui/programs"
        },
        {
          "name": "Rule retrieval (Qwen3-Embedding)",
          "status": "shipped",
          "description": "pgvector store seeded from rules/*.yaml. Qwen/Qwen3-Embedding-8B dense retrieval; rerank off.",
          "evidence": "src/pap_pipeline/stages/retrieve.py"
        },
        {
          "name": "Structured reason stage (Nemotron)",
          "status": "shipped",
          "description": "Non-reasoning Nemotron on Nebius Token Factory emits a strict pydantic Decision. Non-reasoning mode is deliberate.",
          "evidence": "src/pap_pipeline/stages/reason.py"
        },
        {
          "name": "Decisions with rule + history citations",
          "status": "shipped",
          "description": "Every fired rule attached with rerank score. Citation type discriminates rule vs history.",
          "evidence": "Decision.citations"
        },
        {
          "name": "Missing-info handling",
          "status": "shipped",
          "description": "Missing_fields list surfaces required slots not present in the extracted intake.",
          "evidence": "Decision.missing_fields"
        },
        {
          "name": "Orchestrator (async gather)",
          "status": "shipped",
          "description": "retrieve() + source.lookup() fire under a single asyncio.gather; extras merge without polluting Intake.",
          "evidence": "src/pap_pipeline/orchestrator.py"
        },
        {
          "name": "Routing-table instrumentation",
          "status": "shipped",
          "description": "Every model call stamped with model name, tokens in/out, latency, per-call cost. Rendered at /ui/admin/routing.",
          "evidence": "src/pap_pipeline/routing.py"
        },
        {
          "name": "Second-program cameo (OncoBridge)",
          "status": "shipped",
          "description": "Different rules pack, opposite verdict, same code. Proof the pipeline generalises.",
          "evidence": "rules/oncobridge.yaml"
        },
        {
          "name": "Deterministic render boundary",
          "status": "shipped",
          "description": "docgen.core never imports docgen.providers. AI-in, deterministic-out. Enforced in tests, not comments.",
          "evidence": "tests/test_boundaries.py"
        },
        {
          "name": "MDM lookup: parallel retrieval seam",
          "status": "shipped",
          "description": "PR1 landed 2026-09-13. NullMdmSource + protocol + history schema wired into orchestrator.",
          "evidence": "ADR-0004, PR1"
        },
        {
          "name": "MDM slot compiler (references_slots)",
          "status": "shipped",
          "description": "Pure fn (events, slot_defs) -> [HistorySlot]. Kinds: bool, latest_outcome, days_since_latest, count. Orchestrator appends history citations for fired slots.",
          "evidence": "src/pap_pipeline/stages/slot_compile.py, tests/test_pap_slot_compile.py"
        },
        {
          "name": "Per-tenant model selection",
          "status": "shipped",
          "evidence": "src/pap_pipeline/orchestrator.py + alembic/versions/0035_program_verifier_extract_model.py",
          "notes": "Each tenant/program can pin its own extract, decision, and verifier models. Adds program.verifier_model + program.extract_model overrides on top of the tenant defaults.",
          "commit": "d0dbb79"
        },
        {
          "name": "PDF labeler: VLM vision mode (chunked + cropped)",
          "status": "shipped",
          "evidence": "src/docgen/designtime/pdf_labeler_backend.py",
          "notes": "MiniCPM-V labels form fields via chunked page batches with per-widget crops when the whole-page pass fails. Handles dense flat forms that the widget-index approach mislabeled.",
          "commit": "34d7bd4"
        },
        {
          "name": "Triage stage \u2014 LLM classifier + case-type router",
          "status": "shipped",
          "evidence": "src/pap_pipeline/stages/triage.py + tests/test_pap_triage_llm.py",
          "notes": "Nemotron Nano on Nebius classifies each case into (new_intake, refill, appeal, missing_info_reply, not_decidable) and routes accordingly (refill \u2192 Pharmacy OMS, missing-info \u2192 CRM outreach, new intake \u2192 Decide, etc.). Rule-based deterministic classifier is the safety net when the LLM call fails or returns low confidence. Model and routing table configurable per tenant and per program."
        },
        {
          "name": "3-level model fallback chain (program \u2192 tenant \u2192 settings)",
          "status": "shipped",
          "evidence": "src/pap_pipeline/config_resolver.py + alembic/versions/0036_configurable_models_and_routing.py",
          "notes": "Every pipeline stage (extract, triage, retrieve, reason, verifier) resolves its model by walking program \u2192 tenant \u2192 settings. Tenants gain default columns for all 5 stages; programs override individually. Same pattern for triage_routing (case_type \u2192 route map). Non-null values at higher precedence override lower ones; NULL falls through."
        },
        {
          "name": "Tenant admin UI \u2014 pipeline model defaults + triage routing",
          "status": "shipped",
          "evidence": "src/docgen/ui/templates/admin_tenant_models.html + src/docgen/ui/routes.py (admin_tenant_models)",
          "notes": "Super-admin page at /ui/admin/tenants/{id}/models. Editable model fields for all 5 stages with platform-default placeholders. Editable triage routing table (5 case_types \u00d7 5 routes). Saves only rows that differ from the platform default so the JSONB stays minimal."
        },
        {
          "name": "Program admin UI \u2014 Triage model + routing overrides + effective-chain panel",
          "status": "shipped",
          "evidence": "src/docgen/ui/templates/admin_program_detail.html + src/docgen/ui/routes.py (program_models_save)",
          "notes": "Program detail page now shows a Triage model input alongside Extract/Reason, plus a Triage routing table that inherits from the tenant. \"What the pipeline uses today\" panel shows the resolved chain per stage with a source label (program / tenant / settings)."
        },
        {
          "name": "Operator-configurable triage routing (dynamic rows + skip-Decide flag)",
          "status": "shipped",
          "evidence": "admin_program_detail.html (Triage tab) + admin_tenant_models.html + src/pap_pipeline/stages/triage.py",
          "notes": "Program-detail page now has a \"Triage routing\" tab with a dynamic table: operators add/remove rows, name their own case types, pick a downstream route, and toggle a \"Decision needed?\" flag per row. When the flag is off, the orchestrator skips the Decide stage entirely and emits a stub Decision carrying the route (refill straight to Pharmacy OMS, not-decidable straight to reject, etc.). LLM prompt is built dynamically from the configured case_types so program-specific types are classifiable."
        },
        {
          "name": "Program page \u2014 \"Inherit from tenant\" toggle per model field",
          "status": "shipped",
          "evidence": "src/docgen/ui/templates/admin_program_detail.html (Models tab) + src/docgen/ui/routes.py (program_models_save)",
          "notes": "Each of the four model dropdowns (Extract, Reason, Triage, Verifier) is paired with a checkbox that, when ticked, disables the dropdown and stores NULL on the program row so the resolver falls back to the tenant/platform default. The row displays the currently-resolved value with its source (program / tenant / settings) so operators can see what they'd inherit before unchecking. Inherit state is per-field, not all-or-nothing.",
          "commit": "3b45c07"
        },
        {
          "name": "Program page \u2014 \"Inherit routing from tenant\" toggle for Triage",
          "status": "shipped",
          "evidence": "src/docgen/ui/templates/admin_program_detail.html (Triage tab) + src/docgen/ui/routes.py (program_triage_routing_save)",
          "notes": "Whole-table inherit toggle on the program's Triage tab. Checked (default when no override exists) disables every row and hides the Add-row button; unchecking enables the dynamic table. On submit with the toggle checked, the JS sends an empty payload and the handler clears program.triage_routing to NULL so the resolver walks up to tenant/platform routing.",
          "commit": "3b45c07"
        },
        {
          "name": "Tenant admin split \u2014 dedicated Triage routing page",
          "status": "shipped",
          "evidence": "src/docgen/ui/templates/admin_tenant_triage_routing.html + src/docgen/ui/templates/admin_tenant_models.html + src/docgen/ui/routes.py (admin_tenant_triage_routing, admin_save_tenant_triage_routing)",
          "notes": "Tenant Models page is now models-only; the routing table lives at /ui/admin/tenants/{id}/triage-routing with its own dynamic-rows editor. Tenant list page swaps text-link actions for a row of icon buttons (Inbox / MDM / Models / Triage / Quotas / Keys) with CSS-only tooltips so operator context stays compact and CSP-safe.",
          "commit": "3b45c07"
        },
        {
          "name": "E2E regression: 3-level config chain reaches the wire",
          "status": "shipped",
          "evidence": "tests/test_pap_e2e_config_chain.py (6 tests)",
          "notes": "Wire-capture MockTransport intercepts every Extract/Triage/Reason HTTP call and asserts the model name in the request body matches the resolved value. Covers program overrides winning end-to-end, tenant fallback when program is NULL, all-null falling through to settings, verifier opt-in firing a second vision call, per-program triage routing skipping Decide entirely, and precedence between program and tenant. Complements the resolver unit tests by proving inbox_hook actually threads the resolved values into each stage's config."
        },
        {
          "name": "Dual-VLM intake verifier (Nemotron-3.5-Super-VL)",
          "status": "shipped",
          "description": "A second vision model reads the same pages; fields the two readers disagree on become verifier findings for the reviewer. Units and name order are not counted as disagreements. On for Alderwood in prod since 2026-10-18.",
          "evidence": "src/pap_pipeline/verify.py, tests/test_pap_verify.py, docs/benchmarks/vlm-extract-nemotron-vl-2026-10-18.md"
        },
        {
          "name": "Slot range guard (min/max)",
          "status": "shipped",
          "description": "Integer and number slots can declare a valid range. A read outside it is treated as not read, so rules return missing_info instead of deciding on a misread, and an extract_out_of_range finding shows the reviewer what was read. The demo's household_size accepts 1 to 10, the range its income table covers.",
          "evidence": "src/pap_pipeline/verify.py::drop_out_of_range, tests/test_pap_verify.py, tests/test_demo_seed_income_rule.py"
        }
      ]
    },
    {
      "id": "templates",
      "name": "Templates & Rendering",
      "description": "Bytes-in-bytes-out engine. DOCX + PDF templates share preflight, freeze, publish, and rollback.",
      "features": [
        {
          "name": "DOCX template upload + preflight",
          "status": "shipped",
          "description": "Magic-byte check, macro strip, docxtpl tag audit. First-class kind.",
          "evidence": "src/docgen/designtime/preflight.py"
        },
        {
          "name": "PDF templates: AcroForm flavor",
          "status": "shipped",
          "description": "Widget rects harvested to placements; template renderable at upload.",
          "evidence": "roadmap PR1, src/docgen/designtime/preflight_pdf.py"
        },
        {
          "name": "PDF templates: flat flavor + placement editor",
          "status": "shipped",
          "description": "Click-to-place, drag, keyboard nudge, per-page canvas. reportlab overlay + pypdf merge.",
          "evidence": "roadmap PR2, ui/templates/placement_editor.html"
        },
        {
          "name": "Freeze + publish lifecycle",
          "status": "shipped",
          "description": "Frozen version hash immutable; publish gates on outcome bindings.",
          "evidence": "src/docgen/designtime/freeze.py, publish.py"
        },
        {
          "name": "Locale variants",
          "status": "shipped",
          "description": "One template, N locale variants. Variant selection via caller-profile locale key.",
          "evidence": "tests/test_locale_variant.py"
        },
        {
          "name": "AI re-alignment on PDF re-upload",
          "status": "shipped",
          "description": "Heuristic \u2192 diff review UI \u2192 AI fallback via Nemotron label match.",
          "evidence": "roadmap PR3 A/B/C"
        },
        {
          "name": "Print-to-PDF reject at router boundary",
          "status": "shipped",
          "description": "Text-less flat PDFs rejected before persist; producer-aware hint.",
          "evidence": "roadmap PR4"
        },
        {
          "name": "Non-text widget types (checkbox, radio)",
          "status": "shipped",
          "description": "Explicit type field on placements; render dispatches by type. Signature fields deferred.",
          "evidence": "roadmap deferred \u2192 shipped"
        },
        {
          "name": "Caller profile mapping engine",
          "status": "shipped",
          "description": "Closed-parser (no eval) for source paths. Version-pinned per profile.",
          "evidence": "src/docgen/core/mapping.py"
        },
        {
          "name": "Golden fixture render diffs",
          "status": "shipped",
          "description": "Snapshot-test rendered DOCX vs known-good bytes per sample.",
          "evidence": "tests/test_golden.py"
        },
        {
          "name": "Pagination on templates + renders lists",
          "status": "shipped",
          "description": "Opaque keyset cursors; constant DB cost at any page depth.",
          "evidence": "src/docgen/pagination.py"
        },
        {
          "name": "Empty-state onboarding (sample template)",
          "status": "shipped",
          "description": "'Or try a sample \u2192' button pushes bundled DOCX through the real preflight path.",
          "evidence": "/ui/upload/sample"
        },
        {
          "name": "Signature field render",
          "status": "shipped",
          "description": "PDF placement type 'signature'; payload is base64 PNG data URL. Empty payload draws a signature line placeholder.",
          "evidence": "src/docgen/core/render_pipeline_pdf.py::_draw_signature"
        },
        {
          "name": "Command palette (\u2318K)",
          "status": "shipped",
          "description": "Jump to any template, tenant, caller profile, program, or nav route. Lazy /_palette.json load on first open; fuzzy score + arrow-key selection.",
          "evidence": "src/docgen/ui/templates/base.html cmdk-* + routes.py /_palette.json"
        },
        {
          "name": "Responsive tables \u2192 mobile card view",
          "status": "shipped",
          "description": "Under 720px, table rows collapse into stacked cards labelled by data-label. Opt-in via class='responsive'. Templates + renders lists covered.",
          "evidence": "base.html @media (max-width: 720px) block; index.html + renders.html data-label attrs"
        },
        {
          "name": "Submission video: design-time labeler insert (Beat 5)",
          "status": "shipped",
          "evidence": "assets/video/captures/labeler-clip.mp4 + scripts/assemble_video.sh",
          "notes": "Real Verdikt UI captures (template detail \u2192 placement editor with acroform bboxes \u2192 expanded 21-field list) overlaid on Beat 5 during the design-time narration."
        },
        {
          "name": "Submission video: 5-stage timeline overlay",
          "status": "shipped",
          "evidence": "assets/video/designs/stage-timeline.html + scripts/assemble_video.sh",
          "notes": "Persistent top-right Ingest\u2192Triage\u2192Decide\u2192Deliver\u2192Remember pill. Active stage walks left-to-right through the video so judges can track pipeline position."
        }
      ]
    },
    {
      "id": "packages",
      "name": "Static Docs & Doc Packages",
      "description": "Outbound artifact is an ordered PDF bundle, not a single letter. Reply differs by outcome. (ADR-0003)",
      "features": [
        {
          "name": "static_documents schema + admin CRUD",
          "status": "shipped",
          "description": "Tenant-scoped versioned PDF library. Bytes-in, bytes-out. Never rendered.",
          "evidence": "PR-A, alembic 0029"
        },
        {
          "name": "doc_packages composer",
          "status": "shipped",
          "description": "Ordered list of {kind, ref_id} items. Drag-to-reorder in the admin composer.",
          "evidence": "PR-B, /ui/admin/packages"
        },
        {
          "name": "Program outcome bindings",
          "status": "shipped",
          "description": "programs.outcomes[] + package_bindings{outcome \u2192 package_id}. Publish gate refuses unbound outcomes.",
          "evidence": "PR-C"
        },
        {
          "name": "Assembler pipeline stage",
          "status": "shipped",
          "description": "Post-verdict assembler renders each template item, concatenates static PDFs in order.",
          "evidence": "PR-D, pap_pipeline.assembler"
        },
        {
          "name": "doc_package_assemblies audit log",
          "status": "shipped",
          "description": "Append-only assembly log stamps resolved template + static versions per case.",
          "evidence": "alembic 0029"
        },
        {
          "name": "Preview merged package (dry-run)",
          "status": "shipped",
          "description": "Admin composer 'Preview' runs assembler against a sample intake, returns merged PDF inline.",
          "evidence": "PR-E"
        }
      ]
    },
    {
      "id": "inbox",
      "name": "Inbox Agent",
      "description": "IMAP-backed intake bus. Email is a normaliser: fax\u2192email, scan\u2192email, web\u2192JSON all fan into this one loop.",
      "features": [
        {
          "name": "IMAP polling + allowlist",
          "status": "shipped",
          "description": "Single system mailbox (intake@example.com). Per-tenant routing via tenants.inbound_config.",
          "evidence": "src/docgen/inbox/agent.py"
        },
        {
          "name": "Auto-approve scoring",
          "status": "shipped",
          "description": "Sender/subject rules gate whether a message becomes a reviewable event vs auto-approved artifact.",
          "evidence": "test_inbox_score.py"
        },
        {
          "name": "Sender/domain resolver",
          "status": "shipped",
          "description": "Resolves inbound sender \u2192 tenant + program via inbound_config.",
          "evidence": "src/docgen/inbox/resolver.py"
        },
        {
          "name": "Reviewer UI + audit trail",
          "status": "shipped",
          "description": "Pending queue, approve/reject actions, per-event history.",
          "evidence": "/ui/inbox"
        },
        {
          "name": "Dedicated pipeline detail view",
          "status": "shipped",
          "description": "PAP events get /ui/inbox/{id}/pipeline (extract slots, citations, missing info, assembled reply).",
          "evidence": "ADR-0002"
        },
        {
          "name": "Reply mailer + attachment persistence",
          "status": "shipped",
          "description": "Rendered PDF uploaded to blob store keyed by event_id, then handed to mailer byte-identical.",
          "evidence": "alembic 0019"
        },
        {
          "name": "use_pipeline_backend extension flag",
          "status": "shipped",
          "description": "Single boundary Verdikt uses to plug into the generic inbox agent without touching substrate.",
          "evidence": "docgen.inbox.agent"
        },
        {
          "name": "Webhook callbacks on state change",
          "status": "shipped",
          "description": "HMAC-signed webhooks fire on approve/reject/auto-approved.",
          "evidence": "test_webhooks.py"
        },
        {
          "name": "Single prod poller for the demo mailbox",
          "status": "shipped",
          "description": "Only prod polls the demo mailbox (every 60 s, UNSEEN only); dev has polling off so two pollers never race. Verified end to end on prod with the email test guide.",
          "evidence": "src/docgen/inbox/job.py, deploy/docker-compose.prod.yml, docs/demo-scripts/email-inbox-test.md"
        },
        {
          "name": "Judge email test kit (three tests)",
          "status": "shipped",
          "description": "submission/test-demo/ holds the email test guide and three tests: the clean packet (approve), the packet without its income worksheet (missing info), and a noisy 7-page scanned fax with no household size (missing info). All three verified on the live demo.",
          "evidence": "docs/verdikt-email-test-guide.html, docs/demo-scripts/email-inbox-test.md, submission/test-demo/"
        }
      ]
    },
    {
      "id": "programs",
      "name": "Programs & Rules Library",
      "description": "Rules live in tenant-scoped library, linked many-to-many to programs. YAML is the seed; DB is authoritative.",
      "features": [
        {
          "name": "programs table (tenant \u00d7 PAP program)",
          "status": "shipped",
          "description": "One row per tenant \u00d7 program with extraction_schema, outcomes, package_bindings.",
          "evidence": "alembic 0023"
        },
        {
          "name": "rules + program_rule_links tables",
          "status": "shipped",
          "description": "Tenant-scoped master library; per-program position + is_active links.",
          "evidence": "alembic 0025"
        },
        {
          "name": "Rules pack YAML seeding (idempotent)",
          "status": "shipped",
          "description": "Every tenant gets a private copy of every YAML rule as their starting library.",
          "evidence": "0025 upgrade()"
        },
        {
          "name": "History-kind rule seeding (0031)",
          "status": "shipped",
          "description": "'history' kind widens the CHECK constraint in a later migration, then seeds CV-HIS-01/02.",
          "evidence": "alembic 0031"
        },
        {
          "name": "Slot compiler + references_slots",
          "status": "shipped",
          "description": "programs.history_slots + rules.references_slots (migration 0033). Publish gate refuses activation on undeclared slot refs.",
          "evidence": "alembic 0033, src/docgen/ui/routes.py::_publish_gate_slot_refs"
        },
        {
          "name": "Rule formula engine",
          "status": "shipped",
          "description": "Rules can carry a deterministic formula (comparisons, AND/OR, similar_to for misspelled drug names, three-valued logic). When every rule has a formula, the Reason model is skipped. An undetermined approval gate yields missing_info.",
          "evidence": "src/pap_pipeline/rule_formula.py, alembic 0040, tests/test_rule_formula.py"
        },
        {
          "name": "Income eligibility rule (CV-INC-01)",
          "status": "shipped",
          "description": "Cardiovita program checks annual household income against the cap for household sizes 1-10. Live on prod; the email test guide's two packets approve and return missing info.",
          "evidence": "deploy/demo-seed/seed.json, tests/test_demo_seed_income_rule.py"
        }
      ]
    },
    {
      "id": "auth",
      "name": "Auth, Tenancy & Session",
      "description": "DB-backed admins, session cookies, CSRF, per-tenant scope. RBAC via role model.",
      "features": [
        {
          "name": "DB-backed admin login",
          "status": "shipped",
          "description": "Password hashing (argon2). Argon2id verifier. Superadmin fallback for bootstrap.",
          "evidence": "src/docgen/auth.py"
        },
        {
          "name": "Session cookies (HttpOnly, SameSite=Lax, Secure)",
          "status": "shipped",
          "description": "Secure flag computed from request scheme + XFF hop. Env override for local dev.",
          "evidence": "secure_cookie_flag()"
        },
        {
          "name": "CSRF middleware",
          "status": "shipped",
          "description": "Per-session token; POST paths refuse mismatched tokens.",
          "evidence": "test_csrf.py"
        },
        {
          "name": "Login rate limit (sliding window in Redis)",
          "status": "shipped",
          "description": "10 attempts / 15 min per (ip, username). Fail-open on Redis outage.",
          "evidence": "src/docgen/ratelimit.py"
        },
        {
          "name": "RBAC + role model",
          "status": "shipped",
          "description": "Roles constrain admin/tenant surfaces; enforced in middleware + template.",
          "evidence": "test_rbac.py"
        },
        {
          "name": "Multi-tenant scope audit (CI)",
          "status": "shipped",
          "description": "scripts/tenant_scope_audit.py flags any tenant-scoped query without a tenant_id filter within 40 lines.",
          "evidence": "CI step + script"
        },
        {
          "name": "Public landing at /",
          "status": "shipped",
          "description": "Root URL renders a marketing landing; detects session cookie and swaps 'Sign in' for 'Open dashboard'.",
          "evidence": "src/docgen/ui/templates/landing.html + main.py"
        },
        {
          "name": "Per-tenant quotas",
          "status": "shipped",
          "description": "Rolling 24h renders/day and active-templates caps per tenant (NULL = unlimited). API returns 429 naming the tripped quota. Idempotent replays bypass the check. Storage-bytes column reserved.",
          "evidence": "src/docgen/quotas.py"
        },
        {
          "name": "Judge demo sign-in",
          "status": "shipped",
          "description": "Behind DOCGEN_DEMO_LOGIN_ENABLED, the sign-in page shows a tenant-admin account for the Alderwood demo tenant so judges can review and approve cases. On in prod.",
          "evidence": "src/docgen/ui/routes.py::_demo_login, src/docgen/config.py"
        }
      ]
    },
    {
      "id": "mcp",
      "name": "MCP Surface",
      "description": "Model Context Protocol server: 12 tools exposing template CRUD, render, and the reviewer queue.",
      "features": [
        {
          "name": "MCP auth (per-tenant API keys, SHA-256)",
          "status": "shipped",
          "description": "MCP surface reuses the same auth path as REST; tokens hashed at rest, return-once on creation.",
          "evidence": "test_mcp_auth.py"
        },
        {
          "name": "Idempotent tool calls",
          "status": "shipped",
          "description": "Idempotency-Key replay-safe for the write tools; 409 on fingerprint mismatch.",
          "evidence": "test_mcp_idempotent.py"
        },
        {
          "name": "submit_pap_intake",
          "status": "shipped",
          "description": "Fire an intake directly through the PAP pipeline. Bypasses IMAP for programmatic callers.",
          "evidence": "mcp/tools.py"
        },
        {
          "name": "list_pending_reviews",
          "status": "shipped",
          "description": "Reviewer queue for external agents.",
          "evidence": "commit 0b3819c"
        },
        {
          "name": "fetch_case_reply (signed URL)",
          "status": "shipped",
          "description": "Preview the drafted reply artifact via short-lived signed URL.",
          "evidence": "commit 1bbbceb + 0b3819c"
        },
        {
          "name": "approve_case / reject_case",
          "status": "shipped",
          "description": "External-agent path for approve/reject with audit trail.",
          "evidence": "mcp/tools.py"
        }
      ]
    },
    {
      "id": "observability",
      "name": "Admin Dashboard & Observability",
      "description": "Operator surfaces for state, failures, provenance. First-run path from empty inbox \u2192 rendered letter.",
      "features": [
        {
          "name": "/ui/dashboard rollup",
          "status": "shipped",
          "description": "Recent renders, recent publishes, failing jobs, top templates. Single-query aggregations.",
          "evidence": "routes.dashboard"
        },
        {
          "name": "Failing-jobs triage (last 7d)",
          "status": "shipped",
          "description": "Superseded old DLQ page. Dashboard widget + /ui/renders?state=failed.",
          "evidence": "roadmap Reliability"
        },
        {
          "name": "Watchdog reaper",
          "status": "shipped",
          "description": "Orphaned state='running' rows reaped into 'failed' on worker boot.",
          "evidence": "src/docgen/jobs/watchdog.py"
        },
        {
          "name": "Provenance / audit log per render",
          "status": "shipped",
          "description": "Every render stamps template version, caller profile, mapping engine version.",
          "evidence": "provenance model"
        },
        {
          "name": "Skip-to-content + focus-visible ring",
          "status": "shipped",
          "description": "A11y hygiene. Tab-first users get a focus ring; visible skip link on the first tab.",
          "evidence": "base.html"
        },
        {
          "name": "OpenTelemetry end-to-end tracing",
          "status": "backlog",
          "description": "FastAPI \u2192 SQLAlchemy \u2192 boto3 \u2192 render span. Not wired yet.",
          "evidence": "roadmap Observability"
        },
        {
          "name": "Per-tenant metrics + queue-depth alerts",
          "status": "shipped",
          "description": "Prometheus exposition at /metrics. Counters for renders (tenant \u00d7 format \u00d7 state) and preflight rejections (tenant \u00d7 reason). Histogram for render latency. Queue-depth gauge sampled on scrape.",
          "evidence": "src/docgen/metrics.py, /metrics endpoint in src/docgen/main.py"
        },
        {
          "name": "Engineering dashboard CSP hardening",
          "status": "shipped",
          "evidence": "src/docgen/security_headers.py (_ENGINEERING_CSP)",
          "notes": "Pre-generated /engineering/* HTML gets a no-nonce CSP so browsers honor `unsafe-inline` for inline styles/scripts, plus fonts.gstatic.com in font-src."
        },
        {
          "name": "Landing page: demo video",
          "status": "shipped",
          "evidence": "src/docgen/ui/templates/landing.html (data-youtube-id)",
          "description": "The submission video (https://youtu.be/EleEatbaEWM) plays from YouTube on click. Replaced the earlier local MP4, which was never deployed."
        }
      ]
    },
    {
      "id": "reliability",
      "name": "Reliability & Security Boundaries",
      "description": "Idempotency, sanitisation, boundaries enforced in tests. AI-in, deterministic-out is a mechanical claim.",
      "features": [
        {
          "name": "Idempotency-Key on /render + /render/mapped",
          "status": "shipped",
          "description": "Replay-safe for 24h. Fingerprint mismatch \u2192 409.",
          "evidence": "src/docgen/core/idempotency.py"
        },
        {
          "name": "DOCX magic-byte + macro sanitiser",
          "status": "shipped",
          "description": "Rejects non-ZIP payloads; strips word/vbaProject.bin before persist.",
          "evidence": "src/docgen/core/docx_sanitize.py"
        },
        {
          "name": "API keys hashed at rest (SHA-256)",
          "status": "shipped",
          "description": "Raw key returned once at creation via copy-once banner. Look-up by digest.",
          "evidence": "src/docgen/auth.py"
        },
        {
          "name": "Security headers (CSP, HSTS, X-Frame-Options)",
          "status": "shipped",
          "description": "SecurityHeadersMiddleware stamps CSP + HSTS + Referrer-Policy + Permissions-Policy on every response.",
          "evidence": "test_security_headers.py"
        },
        {
          "name": "boundary tests (docgen \u21ae pap_pipeline)",
          "status": "shipped",
          "description": "tests/test_boundaries.py fails the build on one-way violation or AI in the render path.",
          "evidence": "test_boundaries.py"
        },
        {
          "name": "SSRF + IDOR net-safety",
          "status": "shipped",
          "description": "Outbound HTTP goes through a URL allowlist; IDOR-shaped tenant-hop returns 404.",
          "evidence": "test_ssrf_and_idor.py"
        },
        {
          "name": "License scan (\u00a712)",
          "status": "shipped",
          "description": "MIT-compat allowlist + explicit LGPL/dual-license exceptions with justification.",
          "evidence": "scripts/license_check.py"
        },
        {
          "name": "CVE scan (pip-audit) in CI",
          "status": "shipped",
          "description": "pip-audit --strict --skip-editable runs on every PR. Zero known vulns at HEAD. CVE-safe floors codified in pyproject.toml.",
          "evidence": ".github/workflows/ci.yml"
        }
      ]
    },
    {
      "id": "deploy",
      "name": "Deployment & Ops",
      "description": "Compose stack for dev; self-hosted for prod. Model inference on Nebius Token Factory.",
      "features": [
        {
          "name": "Docker compose stack (Postgres, Redis, MinIO, web, worker)",
          "status": "shipped",
          "description": "One-shot local dev. Migrations run via `docker compose run --rm web alembic upgrade head`.",
          "evidence": "docker-compose.yml"
        },
        {
          "name": "GitHub Actions CI (lint \u2192 tests \u2192 license \u2192 CVE \u2192 tenant-audit)",
          "status": "shipped",
          "description": "7 gates. quay.io mirror for MinIO to sidestep Docker Hub rate limits.",
          "evidence": ".github/workflows/ci.yml"
        },
        {
          "name": "Alembic migrations (0001 \u2192 0040)",
          "status": "shipped",
          "description": "Fresh DB reaches head cleanly; prod is at 0040 (rules.formula column).",
          "evidence": "alembic/versions/"
        },
        {
          "name": "Tailwind precompile (17 KB minified)",
          "status": "shipped",
          "description": "Standalone binary compiles CSS at build time; no CDN dependency.",
          "evidence": "scripts/build-css.sh"
        },
        {
          "name": "Self-hosted deploy (live at verdikt.aismith.ai)",
          "status": "shipped",
          "description": "Live since 2026-10-18: the production compose stack on a self-hosted server, behind a Cloudflare tunnel with a Let's Encrypt certificate. Every model call goes to Nebius Token Factory. Each merge to main is deployed.",
          "evidence": "docs/deploy-nebius.md \u00a78c, reports/production.html"
        },
        {
          "name": "Public docker image release cadence",
          "status": "backlog",
          "description": "Not started. Prod is deployed from a git archive of main and built on the host; no image is published to a registry yet, and there is no upgrade guide.",
          "evidence": "roadmap Later"
        }
      ]
    }
  ],
  "backlog": [
    {
      "category": "Pipeline",
      "item": "MDM PR3 \u00b7 state-mdm-v1 adapter",
      "priority": "high",
      "notes": "Real REST+JWT connector against fake MDM in local/mdm-fake/."
    },
    {
      "category": "Pipeline",
      "item": "MDM PR4 \u00b7 Ledger columns backfill",
      "priority": "medium",
      "notes": "mdm_snapshot_hash + provider on the case row."
    },
    {
      "category": "Pipeline",
      "item": "MDM PR5 \u00b7 History citation UI",
      "priority": "medium",
      "notes": "Reply-letter conditional block with prior-determination reference."
    },
    {
      "category": "Pipeline",
      "item": "MDM PR6 \u00b7 CI boundary test",
      "priority": "medium",
      "notes": "Sweep every rules pack; enforce slot mediation."
    },
    {
      "category": "Observability",
      "item": "OpenTelemetry end-to-end tracing",
      "priority": "medium",
      "notes": "FastAPI \u2192 SQLAlchemy \u2192 boto3 \u2192 render span."
    },
    {
      "category": "Docs",
      "item": "Scalar/Redoc API reference",
      "priority": "low",
      "notes": "Richer worked examples per endpoint. OG image + social-share tags already shipped across all report dashboards and app pages."
    },
    {
      "category": "DX",
      "item": "mypy --strict ratchet",
      "priority": "low",
      "notes": "New modules first, expand from there."
    },
    {
      "category": "Deploy",
      "item": "Published docker image + upgrade guide",
      "priority": "low",
      "notes": "Prod builds from a git archive of main on the host today."
    }
  ],
  "parked": [
    {
      "item": "DOCX \u2192 PDF conversion path",
      "reason": "Reverted 2026-08-31; PDF is now a first-class kind. No conversion layer.",
      "date": "2026-08-31"
    }
  ]
}
