Story-1359 · pramaan-docs ADR-110 / ADR-117 · Bug-1357 class

Flatten the Atlas generation contract — flat DTO + prayers[] + approximate value

Branch issue-1359-atlas-flatten · Story: pramaan-io/pramaan-spec#1359 · Provenance: Bug-1357 (DeepSeek drops deep required fields under PromptedOutput); pramaan-docs ADR-117 (hand-owned Pydantic AI is the soul), ADR-110 truth boundary. pramaan-agents only — the pramaan-app mirror (atlas-types, EditableMatterFrame, mapper, render, i18n) is a dependent follow-up Story.

🛰️ 1 · Satellite — why this exists

Atlas emits its turn via pydantic-ai PromptedOutput: the JSON schema is injected into the prompt and the model's free text is parsed — zero schema enforcement ("not strictly enforced to match the schema… depends on the model's intelligence"). The default pydantic schema for the old AtlasTurn was $ref/$defs nested, and DeepSeek disregards deep required fields — the dropped ones sat 2–4 levels down (proposal.refined.value_at_stake.basis). That is the Bug-1357 failure class.

The naïve fix — go back to ToolOutput/NativeOutput so the schema is enforced — is exactly what Bug-1357 already rejected: DeepSeek's enforced modes are unstable (drops verdict, or satisfies the schema with an empty proposal). We cannot lean on enforcement. So the move is the opposite: shrink and FLATTEN what the model must emit, so there is no deep field left to drop.

✈️ 2 · Airplane — the shape of the change

BEFORE  (model emits the nested AtlasTurn directly)        AFTER  (model emits a FLAT DTO; code assembles the turn)
AtlasTurn                                                  AtlasFrameOutput  (the model's ONLY schema — all flat)
  proposal: AtlasProposalCore                                verdict, next_message_md, is_terminal
    description                                               proposal_description | None
    refined: AtlasRefined                                     core_dispute        | None
      core_dispute                                            proceeding_type     | None
      relief_sought: AtlasRelief                              prayers: list[str]          ← was the relief struct
        principal/interest/costs/ancillary                    value_amount_minor  | None  ← approximate, no basis
      value_at_stake: AtlasRefinedValue                       value_currency      | None
        currency/amount_minor/basis                           open_questions: [...]       (genuine collection, kept)
      proceeding_type
                                                           output_type = PromptedOutput(_assemble_intake_turn)
                                                              fn(AtlasFrameOutput) -> AtlasTurn(proposal=AtlasProposal(...))
                                                           @output_validator  _require_complete_all_clear
                                                              ModelRetry iff verdict=="all_clear" & a legal field
                                                              is blank / prayers empty   ← the realness gate

The output-FUNCTION pattern is the key. output_type accepts a function: the model fills a shallow argument schema (AtlasFrameOutput, every field top-level), pydantic-ai validates it, and our function returns the rich AtlasTurn. DeepSeek only ever sees the flat schema, so deep-field drop is structurally impossible. (Same reason pydantic-ai splits unions per-tool: simpler schema → better accuracy.)

🔬 3 · Microscope — what changed, and WHY each piece

PieceWhatWHY
models.pyDELETED AtlasProposalCore / AtlasRefined / AtlasRelief / AtlasRefinedValue. ADDED AtlasFrameOutput (flat generation DTO) + AtlasProposal (flat internal/wire proposal). AtlasTurn.proposal & AtlasReflection.corrected → AtlasProposal.One flat shape end-to-end (Fork B). relief→prayers: list[str] (counsel adds 1..n plain strings); value→approximate amount_minor+currency, basis dropped (intake is telling, not filing — exact value comes later).
agent.py_assemble_intake_turn(AtlasFrameOutput) -> AtlasTurn as the intake output_type; _require_complete_all_clear registered as @output_validator.The model sees only the flat DTO; the proposal grouping is assembled in Python.
Keystone — the legal fields are | None + the gateThe DTO's proposal_description / core_dispute / proceeding_type are NOT hard-required. The validator raises ModelRetry only when verdict=="all_clear" and one is blank, or prayers is empty.Anti-hallucination: a thin matter must be able to emit needs_clarification BEFORE the dispute is known — hard-requiring the fields would force a weak model to FABRICATE (a blank-that-validates is a lie to counsel). The realness guarantee MIGRATED from pydantic required-fields → this code gate.
runtime.py _wrap_proposalWire proposal is now flat: description, core_dispute, proceeding_type, prayers, value_amount_minor, value_currency + truth-boundary flags + frozen source_snapshot. No refined/relief_sought nesting.Fork B: pre-revenue, both repos ours → flatten the wire too; no permanent flat→nested mapper kept just to preserve a shape nobody external consumes.
Reflection — keptAtlasReflect retained; corrected re-mapped onto AtlasProposal; reflect prompt renders prayers + approximate value.Atlas is the golden template; the self-audit pass is part of it (product owner). NOT deleted.
prompts/intake.pyOutput instructions rewritten to the flat object: "do NOT nest a proposal", "LIST EACH PRAYER SEPARATELY", "approximate value only — no basis breakdown", "all_clear must carry substance or downgrade to needs_clarification".The prompt and the schema change together (matched-pair rule); the model must be told the flat shape it now emits.
Evals/seeds/testsevaluators._turn_text, golden fixture, _streaming_probe, dev harness, and every test that constructed the nested types rewritten to the flat shape. NEW tests prove the completeness gate fires.Clean rewrite (founder steer): no back-compat, no legacy branches; obsolete shape-encoding tests rewritten fresh.
How the gate composes with streaming In run_stream() a final-output ModelRetry can't be retried, so it surfaces as UnexpectedModelBehavior — which is exactly the existing Bug-1338 path: the orchestrator falls back to one non-stream run() where output_retries DOES apply, and on exhaustion fails closed with the safe terminal error turn. The gate slots into the resilience loop with no new machinery.
Dependent follow-ups (not this PR) (1) pramaan-app mirror — atlas-types.ts, EditableMatterFrame, toEditableMatterFrame, editable-matter-frame.tsx, i18n (relief→prayers list, value→approximate) — a separate Story (ADR-031). (2) pramaan-functions — the broker's MatterFrameProposal contract + its copy of the agent_event_stream golden fixture (parsed by the functions-side parity test) must adopt the flat proposal too. The local fixture here was flattened; the cross-repo copy will desync until that follow-up lands.

✅ 4 · Verification

CheckResult
(cd agents/atlas && pytest tests -q)124 passed — incl. the new gate tests (test_gate_fires_*, test_gate_passes_*) and flat assemble tests.
ruff check agents/ tests/ scripts/All checks passed.
python scripts/check_brain_egress.pybrain-egress check: clean (ADR-110 §6 / ADR-121 D5).
pytest tests/ -m unit (governance)35 passed.
Integration (FunctionModel, no API key)Happy path: flat DTO → assembled AtlasTurn with prayers=[…] + approximate value. Incomplete all_clear → gate → ModelRetry → non-stream retry → fail-closed error turn.
Scope honoured Reflection kept and re-mapped (not deleted); the anti-hallucination gate kept (migrated to code, proven by a test). pramaan-agents only — no pramaan-app edits. No legacy/back-compat branches, no nested-shape tolerance, no flat→nested mapper (founder steer: pre-revenue clean rewrite).