Skip to content

TDD — Phoenix Firm-Order Memo as a DocGen configuration

status: implemented
date: 2026-06-21
solution: phoenix
consumes: docgen framework
prd: ./prd.md
sources:
  - docs/reference/README.md   # full source-document catalogue
  - docs/explanation/phoenix-uc2-analysis.md
  - docs/reference/Phoenix_Firm_Order_Memo_Spec.docx  # §6 = the five-paragraph contract
  - docs/design/specs/2026-06-19-odyssey-4case-eval-and-2mcp-design.md  # 2-MCP + 4-case as-designed
relates_to: epic [E-release-3] Odyssey UAT ; epic [E-release-2] framework features

Purpose: the genericity proof. This TDD configures the Phoenix firm-order memo entirely out of DocGen primitives — declarative document templates, five Sections with render modes, Fields, a computed-field function, one iterated Jinja block, two isolated (LLM) prompts, two MCP data sources, and a pre-render readiness gate. No Phoenix-specific engine code. Where the use-case needs a capability the framework lacks, it is flagged as a gap (G1/G2/G3/G4) and the gap is a generic framework feature (R2), not Phoenix code. The honest accounting — what's R1, what's R2, what's a gap, and one misfit that needs a 4th primitive — is the Framework-coverage table ([PH-T-9]) and Misfits section ([PH-T-10]).


✅ Implementation status (shipped, runs through the real engine)

Phoenix is implemented and seeded as two declarative DocGen template specs, verified end-to-end through the actual gather → readiness-gate → synth → render pipeline (tests/integration/test_phoenix_seeded.py, test_phoenix_real_cases.py, test_phoenix_local_servers.py, test_phoenix_readiness.py, test_phoenix_engine.py; unit coverage in tests/unit/test_phoenix_compute*.py, test_phoenix_mcp.py, test_efs_mcp.py, test_mcp_data_split.py, test_extract_phoenix_comps.py).

What shipped (clean connector / compute / template separation): - Two real MCP data sourcesmcp_servers/phoenix.py (fetch_deal, the deal/pricing NUMBERS) and mcp_servers/efs.py (fetch_referral, the approval/narrative WORDS) are real JSON-RPC 2.0 endpoints. local_servers.py boots both as REAL loopback HTTP servers; the demo talks to them with a pure MCPTool (httpx.post to a URL) — no MockTransport in the running system (that exists only as a unit-test seam). Production swaps in MCP_PHOENIX_URL / MCP_EFS_URL and the engine is unchanged. Each server returns the raw block — no logic (no approval resolution, metrics, formatting, or readiness). - Split sample store — the servers read data/fake/ (14 synthetic stand-ins, committed) + data/client/ (4 real client deals + their cat-model source_extracts/, gitignored — real reinsurance figures for named carriers are never committed). Loaders merge both (client wins on overlap) and degrade to fake-only on a clean checkout. - Two templatesauthoring/specs/odyssey-phoenix-firm-order.json (enriched: digest + worksheet ROLs + per-layer $, verify_cited_sources:true) and odyssey-phoenix-firm-order-spec.json (spec-faithful to §6, no digest, verify_cited_sources:false). Both registered on the Odyssey org via fixtures/orgs.py, compiled by the schema compiler — no Python seed. P1–P3 render_mode='templated' over computed.deal (branching CAT XOL vs Per Risk; P3 iterates computed.deal.layers = G1); P4/P5 render_mode='isolated' (LLM). The fixtures/phoenix.py placeholder module is gone. - Computed fields (G2)fixtures/phoenix_compute.py, registered via engine.computed (register_demo_tools): merges the two MCP blocks, resolves the operative approval (reading the approval_date Field for the G4 re-arm), computes MLR/ROL/…, formats values, branches on memoMode (recommendation vs firm_order), assembles the source-figure digest, and emits the _readiness verdict → computed.deal.* + top-level _readiness. - Readiness gate (G3/G4)engine/readiness.py; the compute layer emits _readiness, the pipeline gates on it (readiness_from_gathered). 621557's OCR-failed date is satisfied by a typed approval_date Field (G4 re-arm via satisfiable_by_field). - Eval + extractionscripts/demo_phoenix_eval.py (byte-exact gate for the synthetic stand-ins; gold-precision recall + figures-grounded + source-audit for the 4 real deals), scripts/extract_phoenix_comps.py (worksheet decode, --strict), scripts/build_source_extracts.py (cat-model .xlsm + Program-Compare .pdfsource_extracts/). - Synthesize is a near no-op (no from_synth sections), as the design predicted.

The sections below are the original design; the as-built notes inline are the shipped record where they diverge.


[PH-T-1] Document template definition — two specs

Phoenix ships as two declarative template specs against the same two-MCP data path, both compiled by the schema compiler and registered on the Odyssey org (fixtures/orgs.pyOrgSpec('odyssey', …, spec_files=('odyssey-phoenix-firm-order.json', 'odyssey-phoenix-firm-order-spec.json'))). Each is one template Document (is_template=true), instantiated per PID via templates.instantiate_template (R1 [T-api-7]); the instance starts empty and is generated against that deal's gathered values.

Spec Five sections P3 / P4 / P5 emphasis verify_cited_sources
Enriched (odyssey-phoenix-firm-order.json) P1 Reconfirmation · P2 Outcome · P3 Structure & Metrics · P4 Rationale · P5 Underlying Book P3 renders worksheet ROLs (quoted/broker-flat/as-if) + per-layer our-share $ + FHCF/first-event lines; P4/P5 fed a source-figure digest (losses, zone PMLs, expiring towers) + the full cat-model extract true
Spec-faithful (odyssey-phoenix-firm-order-spec.json) P1 Reconfirmation · P2 Layer Summary · P3 Authorized Amounts · P4 Rationale · P5 Underlying Book & Exposure §6 literal — P2 = structure, P3 = authorized amounts; P4 grounds only on referralCommentary (generic fallback if absent); P5 only on underlyingBook false

Both have five Sections in position order, in the same deterministic/LLM split:

render_mode LLM? Drives
P1 templated no Jinja over computed.deal.p1_opener (approval- + memoMode-driven)
P2 templated no Jinja over computed totals (G2), branching CAT XOL vs Per Risk
P3 templated no Jinja {% for l in computed.deal.layers %} (G1) over per-layer + computed metrics (G2)
P4 isolated yes (temp 0) prompt over digest + extract (enriched) / referralCommentary (spec-faithful)
P5 isolated yes (temp 0) prompt over digest + extract (enriched) / underlyingBook (spec-faithful)

Mapping to the R1.7 render-mode contract ([T-refactor-r17]): templated = "pure Jinja2 over texts / computed / gathered / document, no LLM"; isolated = "section prompt Jinja-resolved then sent to the model with no shared synth context." P1–P3 use templated; P4–P5 use isolated. No section uses from_synth — Phoenix never needs the mega-synth to author body prose. (The earlier fixtures/phoenix.py placeholder module that stubbed all five as from_synth has been removed; the declarative specs are the solution.)

Synthesize phase is a near no-op for Phoenix. With zero from_synth sections the dynamic mega-synth schema is empty; synthesize contributes nothing to P1–P5. Gather still runs (it pulls both MCP blocks). P4/P5 make their own isolated calls. This is a legitimate framework configuration — the pipeline supports a document whose body is templated + isolated only.

[PH-T-2] Two MCP data sources + the pid Field

The data path is the shipped centrepiece. Rather than scalar Fields populated by a bespoke connector, each template declares two tool data_needs against two generic MCPTool sources and a single pid Field:

data_need tool_ref MCP call output_key Yields
deal phoenix fetch_deal {pid} gathered.deal raw deal/pricing block (+ sourceDocuments for real deals)
efs efs fetch_referral {pid} gathered.efs raw approval/narrative block

The only scalar Field is pid (string, default 614563); the data_need args interpolate it ({"pid": "{{ pid }}"}). Everything else — approver, source kind, artifact date, per-layer values, totals — is derived in the compute layer from the two raw blocks ([PH-T-3]), not carried as Fields. approval_date is a writable Field that exists only to satisfy the G4 manual-completion loop (621557).

Faked only at the MCP boundary. mcp_servers/phoenix.py / efs.py are real JSON-RPC 2.0 servers; local_servers.ensure_fake_mcp_servers() runs both PHOENIX_MCP_APP / EFS_MCP_APP as threaded-uvicorn loopback HTTP servers (ephemeral 127.0.0.1 ports, process-level singleton). register_demo_tools registers a pure MCPTool (httpx.post) pointed at each loopback URL — byte-identical to production, which sets MCP_PHOENIX_URL / MCP_EFS_URL to live endpoints and skips the loopback entirely. No MockTransport runs in the demo; it survives only as build_phoenix_mcp_transport() for socket-free unit tests.

Split sample store (client-data handling). Each server reads two files per block — data/fake/{phoenix_deals,efs_records}.json (14 synthetic stand-ins, committed) and data/client/{phoenix_deals,efs_records}.json (4 real deals, gitignored) — and load_all_deals() / load_all_efs() merge them (client wins on pid overlap), degrading to fake-only when data/client/ is absent. The real deals' cat-model extracts live under the gitignored data/client/source_extracts/<pid>.{md,sources.json} and are attached as sourceDocuments only when present (the 14 stand-ins are untouched). Real reinsurance figures for named carriers never enter the repo.

Per-layer values are not scalar Fields — they are a list of layer objects on the deal, which is exactly what G1 below addresses. Firm boilerplate (signature/footer) stays in org-scoped Texts referenced from the templated sections.

[PH-T-3] Computed fields via G2 — the derivation

DocGen stores no derived values; the formulas in [PH-feat-4] plus all approval resolution and presentation are the G2 computed field. register_demo_tools registers one pure function — phoenix_compute.compute_phoenix_deal (name phoenix_deal) — via engine.computed; the framework calls it after gather and merges its output into the Jinja scope under computed.deal.* (plus a top-level _readiness). It self-gates: returns {} unless a raw Phoenix deal is present, so registering it globally is harmless.

The function does the work the MCP servers must not:

  1. Merge the two MCP blocks. gathered.deal and gathered.efs arrive as MCPTool envelopes ({'result': …}); the hook unwraps each and attaches the EFS block onto the deal.
  2. Resolve the operative approval + readiness verdict (resolve_for_generation, see [PH-T-6]), honouring a typed approval_date Field (G4).
  3. Derive the per-layer metricsMLR = occEV / devPremium × 100 is computed (never a Phoenix field); ROL/RARC/MSD are read or computed per layer.
  4. Branch on memoMode (recommendation | firm_order, default firm_order): the P1 opener, the auth-verb ("Recommending" vs "Authorized"), and the auth summary all switch on it. The four real Odyssey deals are recommendation; the synthetic stand-ins default to firm_order. This is read from the deal's own data, not a per-deal carve-out.
  5. Pre-format display values ($50M, April 20, 2026) and assemble the source-figure digest (enriched template only) — losses_digest, zone_pmls, fhcf_fmt, first_event, expiring_total_fmt / current_tower / prior_tower, and the concatenated source_extract blob from the deal's sourceDocuments.

Every digest/extras field is guarded: a deal lacking a key emits no field, so the 14 synthetic stand-ins render byte-identical while the 4 real deals (which carry the cat-model extract) surface the richer lines. computed.deal.layers is the list P3 iterates; computed.deal.type is the CAT XOL / Per Risk switch.

[PH-T-4] G1 iteration — the per-layer P3 block

P3 must emit one block per layer over a list-valued render variable — not expressible with R1's flat scalar Fields. G1 exposes structured (list/dict) data in the sandboxed Jinja scope and confirms the env permits {% for %} + attribute access. P3 is a templated-mode section whose output_template iterates computed.deal.layers. The shipped enriched P3 (abbreviated):

{# P3 output_template — templated render_mode, no LLM #}
{% if computed.deal.type == 'CAT XOL' %}Below is the FOT structure & metrics:
{% for l in computed.deal.layers %}Layer {{ l.lyr }}{{ l.limit_fmt }} xs {{ l.retention_fmt }} ({{ l.reinstatement }}); ROL of {{ l.rol_fmt }}%{% if l.quoted_rol_fmt is defined %} (quoted {{ l.quoted_rol_fmt }}%){% endif %} for a MLR of {{ l.mlr_fmt }}%, MSD of {{ l.msd_fmt }}% & RARC of {{ l.rarc_fmt }}% – {{ l.auth_text }}{% if l.ody_limit_fmt is defined %} ({{ l.ody_limit_fmt }} for {{ l.ody_premium_fmt }}){% endif %}
{% endfor %}{% if computed.deal.fhcf_fmt is defined %}FHCF inures to the program: {{ computed.deal.fhcf_fmt }}.
{% endif %}Total {% if computed.deal.memoMode == 'recommendation' %}recommended authorization{% else %}authorization{% endif %} of {{ computed.deal.total_limit_fmt }} in limit for {{ computed.deal.total_premium_fmt }} in premium.{% else %}…Per Risk branch…{% endif %}

The {% if computed.deal.type %} switch is how one template serves both CAT XOL and Per Risk — no third template, exactly the JBRK "context-independent, variants via {% if %}" rule (R2 [T2-entity-texts]). The optional-field is defined guards (quoted/broker-flat/as-if ROLs, per-layer $, FHCF, first-event, expiring share) are how the richer real deals render extra lines while the stand-ins stay byte-exact. The spec-faithful P3 is the §6-literal "Authorized amounts" list over the same layers, with no metric columns.

[PH-T-5] isolated prompts for P4 / P5

P4 and P5 are isolated-mode sections (R1.7 contract: section prompt Jinja-resolved, sent to the model with no shared synth context). The framework renders at temperature 0 ([T-api-4]); the template's system_prompt carries the hard no-fabrication rule, and each prompt body carries a conservative fallback line. The two templates differ in what they feed the prompt:

  • Enriched P4 (Rationale): one bullet per layer over computed.deal.layers, plus the source-figure digest (losses, FHCF, first-event, expiring tower) and the full source_extract; "summarize and reformat the source — do NOT invent … any number not present." It explicitly permits deriving standard figures (loss ratios, RARC, %-changes) by arithmetic from source figures.
  • Enriched P5 (Underlying Book): a short bulleted list over zone_pmls / losses_digest + the extract; selective, material figures only.
  • Spec-faithful P4: grounds only on referralCommentary; if empty, emits exactly the §6 generic line "This firm order follows our standard review of the program structure and pricing; no additional referral commentary was recorded."
  • Spec-faithful P5: grounds only on underlyingBook; if empty, emits exactly "The underlying book is per the program submission; no additional exposure commentary was provided."

The fallback is prompt-rule-driven (the framework's isolated mode has no separate fallback mechanism — and doesn't need one; this matches phoenix-uc2-analysis.md §2 "fallback = prompt rules"). For the four real deals the EFS narrative is intentionally empty, so the enriched P4/P5 ground on the committed cat-model extract — never the gold memo — which is what keeps the eval non-circular.

[PH-T-6] G3 readiness preconditions — the approval gate

Phoenix needs a gate that runs before render and short-circuits with a structured gap when approval can't be established. The framework verify phase is post-hoc (it fact-checks citations after synthesis, [T-decision-19]) — it cannot block generation. G3 is the shipped generic primitive in engine/readiness.py: a ReadinessResult (generation_should_proceed, ui_state, gaps) the pipeline gates on. G4 is the manual-completion extension: a ReadinessGap may carry a satisfiable_by_field (key + type + prompt) the UI renders as an input; re-evaluation IS the re-arm.

How Phoenix wires it (no bespoke ReadinessSpec — the compute layer emits the marker). phoenix_compute.resolve_for_generation(deal, approval_date=…) returns (approval, verdict), and compute_phoenix_deal turns the verdict into a ReadinessResult.as_dict() stored at computed._readiness. The pipeline aggregates it via readiness_from_gathered and, if generation_should_proceed is false, blocks render and surfaces the gaps as attention. The verdict shape on the OCR-failed-date case:

{ "generation_should_proceed": false,
  "ui_state": "attention",
  "gaps": [{ "precondition_key": "phoenix_approval",
             "reason": "Approval date unresolved.",
             "satisfiable_by_field": { "key": "approval_date", "type": "date",
                                       "prompt": "Enter the approval date from the signed PDF." } }] }

The approval logic is solution code in phoenix_compute (select_approval / _matches_approval): a strict "{initials} approval" pattern match against the EFS commentField (two tokens, 2–3-letter initials + a fuzzy-matched "approval" word, tolerant of hand-typed typos), email-vs-PDF classification, and most-recent selection by artifactDate over approvalHistory. The framework owns only the declarative plumbing (Precondition / ReadinessResult + run-before-render + short-circuit + the G4 FieldTarget). The four edge cases:

Deal Resolver outcome Gate result
619055 no comment matches block → attention
620998 comments exist, none match pattern block → attention (no best-guess)
621557 PDF found, dateExtractionStatus == 'FAILED' block with satisfiable_by_field: approval_date; underwriter types it, the typed Field re-arms (G4) → passes
618015 multiple matches select_approval returns most recent by artifactDate → gate passes

G3 is distinct from verify and stays so: verify remains the optional post-synthesis citation check (the enriched template sets verify_cited_sources: true, fact-checking P4/P5 claims against the sourceDocuments evidence rows); G3 is a pre-render gate. They do not overlap.

[PH-T-7] Composition model — does Phoenix need it?

No — Phoenix is lighter than JBRK and does not need the Snippet composition model ([P2-composition]). Honest analysis:

Composition primitive JBRK needs it because… Phoenix?
Risk Blocks + approval gate ([T2-entity-risk_blocks]) lawyer-authored Section-III analysis units, status draft→approved, "every word approved." No — Phoenix has no reusable, approved risk-blocks. P4/P5 are deal-specific LLM prose, not a catalog of pre-approved analytical paragraphs.
Snippets compose (snippet_items, document_snippets, phase_order) one reorg = several recognized situation patterns (freeze + pipeline) assembled in phase order; transaction steps numbered across molecules. No — a Phoenix memo is one document with a fixed five-paragraph order. There is no "several patterns compose into one document" problem; position ordering of five Sections is sufficient.
Grouped-by-type assembly ([T2-render]) Section II = all atoms across molecules; Section III = all risk blocks across molecules. No — no cross-snippet collation; the per-layer repetition is within one section (G1 loop), not across compositions.

What Phoenix actually needs is lighter: Sections + render modes (R1) + G1 (iteration) + G2 (computed) + G3/G4 (readiness gate + manual completion). The per-layer repetition that looks like it might want composition is a single {% for %} loop inside one templated section — G1, not Snippets. Firm boilerplate reuse (signature/footer) is covered by R1 Texts-as-flat-templates. Phoenix exercises composition's underlying primitives (Texts, Jinja, Fields) but not its compositional machinery. This is a useful genericity result: the framework scales down to a deterministic five-paragraph memo without dragging in JBRK's full composition stack.

[PH-T-8] Sample data, eval, and extraction

Sample stores (served by the two MCP servers; [PH-T-2]):

Store Path Tracked Use
Synthetic stand-ins (14) mcp_servers/data/fake/{phoenix_deals,efs_records}.json committed drives gather for the regression suite; the input contract
Real client deals (4) mcp_servers/data/client/{phoenix_deals,efs_records}.json gitignored the real Odyssey deals (real carrier figures)
Cat-model extracts mcp_servers/data/client/source_extracts/<pid>.{md,sources.json} gitignored the complete .xlsm + .pdf content; attached as sourceDocuments

Answer keys:

Target Path Tracked Use
expected-memos.json (14) docs/reference/phoenix-data/expected-memos.json committed byte-exact P1–P3 + block-verdict gold for the synthetic stand-ins
gold-real-memos.json (4) eval_reports/gold-real-memos.json gitignored, never committed the underwriters' actual referral memos — diff target only, never an input

The eval (scripts/demo_phoenix_eval.py). Seeds the org, runs the full gather → readiness-gate → synth → render pipeline on every doc, scored by what's checkable:

  • Synthetic stand-insP1–P3 byte-exact vs expected-memos.json (the PASS gate); blocked deals must block with the expected verdict. G2 / G3 are exercised through the real pipeline here.
  • Real deals — no byte-exact gate (the gold is the underwriter's free-form prose). Scored by a gold-precision recall matcher (a gold number counts as reproduced only when an output value matches at the gold's displayed precision and is within ~1% of it — so finer output meets a coarse gold, but a coincidental coarse collision does not) and figures-grounded (every figure DocGen states traces to the gold or our own source — the no-fabrication proof). Surfacing more real data than the underwriter never lowers recall.
  • A source-figure digest lifts enriched-P4/P5 recall (feed the loss/PML/expiring-tower figures, don't haystack-mine the 200k-char extract). A "held in source but not surfaced" report and a source audit split the un-surfaced gold figures into in our source (a prose gap) vs in no source (the honest floor — needs a referral feed).

Extraction (scripts/, run offline; outputs are the artifact):

  • build_source_extracts.py — dumps every meaningful cat-model .xlsm tab (excluding raw Monte-Carlo grids, listed in the header — no silent curation) + the full Program-Compare .pdf into source_extracts/<pid>.md, and the chunked {url, snippet, page_text} evidence rows into <pid>.sources.json for the cited/verify path.
  • extract_phoenix_comps.py — promotes per-layer worksheet ROLs (quoted / broker-flat / as-if) into the client phoenix_deals.json so the deterministic P3 can render them: header-labeled grids self-identify; unlabeled grids use an audited per-deal column map (_EXPLICIT_MAP); the loss/zone-PML block is block-scoped to avoid colliding with county-aggregate TIVs. _validate_extraction flags out-of-band values and --strict exits non-zero on any warning. Values come only from the deal's own extract, never the gold.

The four real deals' P1–P3 are verified to reproduce the gold memos' numbers; the recall figures for P4/P5 are reported, not gated (valid rephrasing + selective prose are expected).

[PH-T-9] Framework coverage

Phoenix need Framework primitive Status
Per-PID fetch of the phoenix (deal) + efs (referral) blocks gather phase + two generic MCPTool data sources have (R1) — real JSON-RPC servers (phoenix.py/efs.py) over loopback HTTP; live endpoints swap in via MCP_PHOENIX_URL / MCP_EFS_URL, no engine change.
Five ordered sections sections + position ordering have (R1)
Per-PID instantiation templates.instantiate_template ([T-api-7]) have (R1)
Single pid parameter fields table + scope precedence ([T-decision-18]) have (R1.8) — only pid (+ the G4 approval_date); the rest is computed.
Deterministic P1–P3 from data templated render_mode (Jinja, no LLM) have (R1.7)
Firm boilerplate reuse (signature/footer) org-scoped Texts have (R1.7)
LLM P4/P5, temp 0, faithful, fallback isolated sections; temp 0 ([T-api-4]); fallback = prompt rules have (R1)
Mechanical / semantic citation check verify_cited_sources + verify phase over sourceDocuments have (R1.8) — on for the enriched template; off for the spec-faithful one.
recommendation vs firm-order framing a memoMode field on the deal, branched in compute + templates have — generic data-driven branch, no engine change.
Per-layer iteration in P3 (loop over N layers) structured (list) value iterable in Jinja {% for %} GAP G1 (R2) — built
Computed fields (MLR, ROL, formatting, digest, readiness verdict) engine.computed hook → computed.* render namespace GAP G2 (R2) — built
Pre-generation readiness gate (block + structured gap) engine.readiness (ReadinessResult, readiness_from_gathered) run before render GAP G3 (R2) — built
Gap-driven manual field completion (621557 date re-arm) ReadinessGap.satisfiable_by_field + FieldTarget; re-eval re-arms GAP G4 (R2) — built (was misfit M1)
ready / attention status + action-required banner Document.state + G3's structured gap surfaced as state have — "attention" is G3's ui_state.
Approval extraction (pattern, fuzzy match, date, most-recent) compute-layer resolver solution code (R3) — framework provides the G3/G4 plumbing; the "{initials} approval" rules are Phoenix-specific.

Net: everything maps to a framework primitive, and the four known gaps (G1/G2/G3/G4) are generic R2 framework features, all now built (JBRK needs G1/G2 too; the readiness gate G3+G4 generalizes any "don't generate until inputs are ready, and let a human supply the missing one" document). The single misfit M1 from the original design ([PH-T-10]) is the gap that became G4 and is now shipped.

[PH-T-10] Misfits / gaps

Four known gaps (G1/G2/G3/G4) are restated in [PH-T-9] and are framework features, not misfits — they map to R2 primitives and are all now built.

Misfit M1 — manual-field-completion round-trip — RESOLVED as G4. Edge case 621557 (PDF approval, handwritten date, OCR fails) was only half served by G3 as originally scoped: G3 correctly blocked, but the spec's required behaviour is that the underwriter then types the date in and re-runs — "leave a placeholder for underwriter completion rather than fabricate." The original design flagged this as a distinct primitive M1: a gap must name a writable target Field and re-arm once that Field is supplied. It shipped as G4 (engine/readiness.py): a ReadinessGap carries satisfiable_by_field (a FieldTarget of key + type + prompt); phoenix_compute.resolve_for_generation emits it on the date-failed case targeting the approval_date Field; the UI renders that as an input, and re-running the same evaluation IS the re-arm (no separate re-arm call). The Phoenix use-case now maps entirely onto framework features.

Voice. Both templates set voice: "odyssey-underwriter" — the senior-underwriter style profile (dense inline figures, per-layer bullets, "Due to…" closing) carried on the spec and applied to the LLM sections (P4/P5). The Phoenix solution only references the voice; the framework Voices primitive itself is being documented separately in r2-tdd, so it is not redefined here.

Minor: synthesize is a no-op for Phoenix. With zero from_synth sections the mega-synth schema is empty ([PH-T-1]). Not a gap — the pipeline tolerates it — but worth flagging that Phoenix exercises gather → (empty synth) → render, a less-travelled path than a from_synth-heavy seed. Covered by the integration suite asserting an all-templated/isolated document generates cleanly with an empty synthesized context.

Cross-references

  • PRD: ./prd.md
  • Use-case analysis + gap map (G1/G2/G3): ../../explanation/phoenix-uc2-analysis.md
  • Odyssey spec (§6 = the five-paragraph contract): ../../reference/Phoenix_Firm_Order_Memo_Spec.docx
  • As-designed 2-MCP + 4-case eval reference: ../../design/specs/2026-06-19-odyssey-4case-eval-and-2mcp-design.md
  • Framework R1 TDD (render modes, fields, pipeline): ../../releases/r1-framework-v0-core/r1-tdd.md
  • Framework R2 TDD (composition, readiness, Voices): ../../releases/r2-framework-production-features/r2-tdd.md
  • Shipped specs: docgen/src/docgen/authoring/specs/odyssey-phoenix-firm-order{,-spec}.json
  • Compute + MCP servers: docgen/src/docgen/fixtures/phoenix_compute.py, docgen/src/docgen/mcp_servers/{phoenix,efs,local_servers}.py