prayers[] + approximate valueAtlas 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.
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.)
| Piece | What | WHY |
|---|---|---|
models.py | DELETED 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 gate | The 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_proposal | Wire 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 — kept | AtlasReflect 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.py | Output 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/tests | evaluators._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. |
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.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.| Check | Result |
|---|---|
(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.py | brain-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. |