# Report Writer and Report Assembly Contract

## Phase 6 ownership

Phase 6 gives the report writer all settled analysis, convergence, task, and planning context required for synthesis. Read access supplies context; it does not grant write authority over another owner's file.

The report writer writes only the report narrative Markdown, its pointer record, and its audit sidecar. It must not write or patch `final-report-*.data.json`, the approval decision ledger, activity ledger, team state, convergence state, or design-preparation input.

The narrative owns judgments, plan body, summaries, user explanation, recommended steps, and follow-up descriptions. It never owns `designPreparation`, `designSurfaceCoverage`, `executionStatus`, `executionRoles`, `tokenUsage`, `crossVerification`, `approvalContext`, `clarificationItems`, `agentActivity`, or `planBodyVerification`. It must not pre-fill a future round, future gate result, activity identifier, usage value, or resolution.

Report assembly reads the role-owned inputs, validates them, derives links and statuses, and publishes the final record once. An error identifies `owner`, `artifactPath`, `fieldPath`, and `reason`; return it only to the named owner.

## Role-owned inputs

| Input | Owner | Write boundary |
|---|---|---|
| report narrative Markdown | report-writer | `reportNarrativePath` |
| approval decision ledger | lead | `approvalDecisionsPath` through `okstra approval-decision` |
| activity ledger | activity recorder | `leadEventsPath` through `okstra agent-activity append` |
| team state and usage | runtime/accounting adapter | `teamStatePath` |
| convergence state and plan-body result | convergence engine | `convergenceStatePath` |
| design-preparation snapshot | design-surface detector | `designPreparationPath` |
| final report record | report assembly | `expectedReportRecordPath` |

An active clarification exists only in `activeClarifications[]`. A decision carried from a previous run exists only in `carriedDecisions[]`; do not recreate it as an active question.

Prepare seeds `carriedDecisions[]` when it creates the ledger: every clarification the run's carry-in record answered or resolved, plus every row that record's user-responses sidecars answered, arrives carried (`scripts/okstra_ctl/approval_decisions.py` `seed_carried_decisions`). The carry-in record is the `--clarification-response` file; for a new plan it is the option-selection record `--selected-direction` names, and for an implementation run the approved plan `--approved-plan` names (`render._carry_in_source`) — the same pointer assembly writes to `clarificationCarryIn.sourceFile`, so the page links those ids to the prior run's page. A carried plan row's `requirementCoverage[].decisionRefs` may still name a `C-NNN` the carry-in record does not answer — a decision from an older run. Carry it before assembly: `okstra approval-decision carry --ledger <approvalDecisionsPath> --from-responses <instruction-set/clarification-response.md> --clarification-id C-NNN` — repeat `--clarification-id` to take several in one call. That bundle is the source of truth for an earlier run's answer: it is task-level and cumulative, so no prior run seq has to be located, and each response section names the report that posed the question, which is where the row's `statement`, `expectedForm`, and options come from. A carried row lands as `answered`, not `resolved` — it was resolved in another run, and `resolution.checkRefs` names *this* run's activity rows. Carrying an answer also obliges a `supersessionLedger` entry for it.

Each decision option has `role`, `answer`, `rationale`, `disposition`, `reach`, optional `scopeEffects`, `addedWork`, and `directionChange`. `reach` is exactly one of `in-repo` or `cross-repo`. `scopeEffects` may contain `new-schema` and `deferrable`. A `correctness-critical` option cannot use `select` or `accept-risk`; a `noncritical-dissent` option cannot use `select`.

Resolution `checkRefs` name existing `A-NNN` activity rows. Those activity rows carry `clarificationRefs[]`; their `planItemIds[]` let report assembly derive the reverse plan-item links. Do not store copied plan or activity identifiers in `approvalContext`.

## Field content rules

Two rules the assembled record is checked against, both learned from the 2026-09-05 audit of shipped pages:

- **An empty value is empty, never a null literal.** A prose field with nothing to say is omitted or left `""`. `"None"`, `"null"`, `"undefined"` and `"NaN"` are serialisation artefacts the page prints as text (`tradeoffMatrix` cells in nlpvibe planning-009…014). The lowercase `none` an option's `addedWork`/`directionChange` uses to mean "nothing" is a contract token and stays. **Enforced:** `validators/validate-run.py` `_validate_no_null_literals_in_prose` fails a prose field holding one of those literals.
- **Citations name rows the reader can reach.** `evidenceRefs`, `supportingEvidence` and the other citation lists carry the record's own ids (`E-`, `CV-`, `D-`, `EA-`, `C-`, `A-`) or a `path:line`. A worker's own finding number (`F-NNN`) reaches the reader only through a promoted `evidence.primary[]` row whose `sourceItems` name it as `<worker>:F-NNN` — the page links a bare `F-NNN` to that row when exactly one row carries it, and leaves it as dead text otherwise. **Enforced:** `_unbridged_worker_finding_refs` in `validators/validate-run.py` warns on every bare `F-NNN` no `sourceItems` entry carries; the narrative rule `_validate_no_opaque_id_references` already fails one in a reader-facing sentence.

## Report-writer dispatch

For report contract 3.0, prompt materialization first freezes one report synthesis
packet. The packet contains the task brief, analysis packet, report template,
report schema, convergence state, every successful settled worker result recorded
by the run manifest, accumulated `user-responses/` sidecars, and the current
session/token/cost accounting snapshot. Each file-backed source carries its owner,
project-relative path and value. The report writer receives the
packet's Markdown reading projection as the only task input instead of an
independently assembled list of raw paths.

The packet's authoring contract names the result path, narrative format, writing
instructions, runtime-owned content, and validation rules. Missing configured
paths and missing files are collected across the full source set before dispatch
and reported together with their owners. Report assembly checks source availability
and readability without rejecting changed content or comparing hashes.
This is enforced by
`initial_prompt_materialization._materialize_report_writer_packet()`,
`report_synthesis_packet.build_report_synthesis_packet()`, and
`report_assembly.assemble_report()`.

Materialize the duty prompt with `okstra agent-prompt materialize --audience report-writer`. `--assignment-ref` is `initial/report-writer` — the run manifest declares the report writer under the `initial` phase, so `report-writer/report-writer` is refused. `--result` is the narrative path the run manifest's `reportNarrativePath` names (report contract 3.0; the `expectedReportRecordPath` data.json under 2.0) and `--audit-source` is the roster's worker result (`team-state` `workers[].resultPath` for `report-writer`). The materializer refuses any other value: report assembly reads the narrative from the manifest path and nowhere else, and `okstra team await` records the roster row completed only when the roster's file exists. A corrective dispatch (fix, self-fix, verdict, citations, supersession) reuses both paths and changes only the prompt path and the invocation ID. **Enforced:** `_validate_report_writer_paths` in `scripts/okstra_ctl/agent/prompt_cli/materialize.py`. The materializer also writes the report synthesis packet next to the narrative and puts `- Report synthesis packet: <path>` as the first line of the prompt's `## Inputs` section — opening the section when the instruction has none, or inserting under the instruction's own `## Inputs` heading when it has one. The instruction therefore does not enumerate raw source paths; a missing or unreadable packet source is refused before dispatch with its owner (`report synthesis packet contract defects`). **Enforced:** `_report_writer_input_lines` in `scripts/okstra_ctl/agent/prompt_cli/materialize.py`. The prompt starts with these anchors in order:

1. `**Project Root:**`
2. `**Prompt History Path:**`
3. `**Result Path:**`
4. `**Worker Result Path:**`
5. `**Worker Preamble Path:**`
6. `**Worker Error Contract Path:**`
7. `**Errors log path:**`
8. `**Errors sidecar path:**`

The errors sidecar anchor reserves the runtime-owned write-artifact path used by dispatch validation. No model-authored error JSON file is part of report-writer dispatch; failures use the typed error-log command from the worker error contract.

After materialization, generate a v2 jobs file with `okstra agent-prompt jobs --project-root <root> --run-manifest <manifest> --dispatch-kind report-writer --metadata <returned-meta.json> --out <run-state>/report-writer-jobs.json`. Pass the generated file through the selected deterministic dispatcher's `--jobs-file`. The generator reads the narrative and worker-result headers and validates their existing manifest contract. For host-native dispatch, use `okstra agent-prompt record-dispatch` and `link-result`; deterministic dispatchers record their own invocation lifecycle.

When `terminalBackend` is `cmux-pane`, use `okstra team dispatch --project-root <root> --run-manifest <manifest> --jobs-file <jobs-file>`. For a CLI wrapper, use `okstra worker-dispatch --project-root <root> --run-manifest <manifest> --jobs-file <jobs-file>`. Both paths use `okstra team await` to collect completion.

The pointer record contains the narrative and audit paths. Completion never depends on the final record because assembly runs after writer completion.

**okstra renders the `## Output` section of every run-branch report-writer prompt** — the narrative at `**Result Path:**`, the pointer record at `**Worker Result Path:**`, and the reading audit at `**Audit sidecar path:**` — for the first dispatch and for every follow-up alike, so the instruction body must not contain a `## Output` or `## Corrections` heading; the materializer refuses a body that does. The three are enumerated by code rather than by hand because `dispatch_state.dispatch_completion_paths` treats the narrative and the pointer as two separate completion artifacts: a hand-written `## Output` that named only the narrative ended the dispatch with `required worker artifact was not produced` even though the writer did everything the body asked. **Enforced:** `_with_report_writer_sections` in `scripts/okstra_ctl/agent/prompt_cli/materialize.py`.

### Corrective report-writer dispatch (ledger required)

After each readable writer result, preserve the narrative and run `check-corrections` with a ledger whose `corrections` is initially empty. This checks the complete writer-owned schema and, for planning, the existing Stage Map checks before another writer call. It derives `stageMap[].stepCount` from the unique matching stage's execution rows; the returned corrections show the calculated values. An empty ledger with no derived corrections needs no apply call. Missing declarations, ambiguous stage identities, invalid exemptions, and other semantic defects remain errors. The dispatcher still treats a readable narrative as a produced artifact; this check does not trigger a full regeneration. Enforcement: `run_corrections_check`, `report_corrections.check_corrections`, and `collect_data_validation_errors` in `validators/validate-implementation-plan-stages.py`.

Use `add` with an exact `replacement` for a missing field or an array append, and `move` with `fromPath` for an existing object field whose destination is absent. The checker refuses overwritten destinations, machine-owned fields, and array moves. Include `baseNarrativeSha256` from the check output to bind a later application to that preserved version. Add field-specific `evidenceRefs` and the necessary `context` to the ledger; correction materialization consumes these instead of the initial instruction body.

For `rewrite`, the materialized correction-only prompt supplies target values, constraints, evidence references, and the base hash. The writer returns JSON replacement values for exactly those ids and runs the generated `apply-corrections --rewrite-results` command. The runtime merges the values, preserves unrelated fields, and checks the complete result before writing the narrative. It records the supplied replacement file in the correction activity evidence. The writer then produces the normal pointer and reading audit. Do not ask a correction-only writer to read the complete synthesis packet or reproduce the complete narrative.

This section adds report-specific checks to [okstra-lead-contract](./okstra-lead-contract.md) "Worker instruction quality gate"; it does not limit that common gate to report-writer calls. A follow-up dispatch that revises a narrative which already exists and whose structure parses is a corrective dispatch, and its instruction is a corrections ledger, not prose:

1. Preserve the attempt: copy the current narrative to `worker-results/report-writer-narrative-a<N>-<task-type>-<seq>.md`. The ledger's `baseNarrativePath` names that copy, never the live `reportNarrativePath` — the correction overwrites the live file, and the copy is what the writer (or `apply-corrections`) reads.
2. The ledger lives at `runs/<task-type>/state/report-writer-corrections-<task-type>-<seq>-a<N>.json` (schema `schemas/report-writer-corrections-v1.0.schema.json`) and holds one entry per defect: `replace` with the exact replacement value (add `current` when you want it checked), `remove` for an item or optional field, `rewrite` with a `rule` when the writer has to re-author prose. Paths use the validator's grammar (`implementationOptionSelection.rankedOptions[1].coverageSummary.coveragePercent`), so a report-assembly refusal can be copied into the ledger verbatim. Never write an indirect instruction such as `use the schema value`, `use the valid status`, or `fix the enum`: a `replacement` is the literal, and a `rule` names the required outcome. You do not copy allowed enum literals by hand — okstra attaches each `rewrite`'s schema constraint from the frozen schema.
3. Run `okstra agent-prompt check-corrections --project-root <root> --run-manifest <path> --corrections <ledger>` until it reports no defect. It applies the ledger to a scratch copy of the base narrative and validates the complete proposed narrative against the writer-owned value schema and the task's semantic validator, listing every defect at once. Validating only the edited field is insufficient because one replacement can select a different schema branch, which is why the check covers the whole narrative.
4. When the check reports `mechanical: true` and has corrections, run `okstra agent-prompt apply-corrections` with the same arguments: okstra writes the corrected narrative to `reportNarrativePath` and records a `lead-correction-applied` activity row naming the ledger and its correction ids. This includes validated `replace`, `remove`, `add`, `move`, and derived step counts. No writer dispatch, `record-dispatch`, or `link-result` follows; the roster row's result already exists.
5. Otherwise materialize the writer prompt with the same `--corrections <ledger>` under a new invocation id and prompt path (retire the first attempt's link with `reject-result` as [plan-body-verification](./plan-body-verification.md) describes). okstra renders the correction-only field values, evidence, schema constraints, replacement-file contract, application command, and output paths. Put context in the ledger; the initial instruction body is not sent to the correction writer.

A report-writer materialization without `--corrections` whose narrative already exists and parses is refused before any prompt is written — free-form corrections cannot be checked before the writer runs, and four of six re-runs in the 2026-09-03 measurement were lead instructions that contradicted the authoring contract. Only a narrative whose structure does not parse (line grammar, an unknown top-level field) is re-authored, not corrected: that dispatch needs no ledger, and its body quotes the parser's message. Because re-authoring overwrites the live file in place, okstra copies the existing narrative to `worker-results/<narrative-name>.pre-<invocation-id>.md` at materialization and renders a `## Previous Attempt` section naming that copy (**Enforced:** `_preserve_reauthored_narrative` in `scripts/okstra_ctl/agent/prompt_cli/materialize.py`); the 2026-09-09 dev-10642 run lost a 579-line attempt to a failed in-place re-indent command with no copy to fall back on. A narrative that breaks the line grammar is not a produced artifact: the dispatcher settles that attempt as `required worker artifact is unusable: narrative does not parse: …` and retries it inside the same batch, so you see the parser's message at collection, not at Phase 7 assembly (**Enforced:** `okstra_ctl.dispatch_state.unusable_result_defect`, read by `missing_completion_paths` and the `team await` record path). The synthesis packet's Authoring Contract carries the line grammar itself (`report_narrative.NARRATIVE_GRAMMAR_INSTRUCTIONS`), so a writer that reads only the packet still sees it. Value defects — an id outside its pattern, a value outside its enum, a missing required field — leave the structure readable and are exactly what the ledger fixes; the a3 attempt of the 2026-09-03 run carried twenty `SC-` ids that assembly refused and was still a corrective base.

**Enforced:** `_with_report_writer_sections` / `_refuse_free_form_correction` in `scripts/okstra_ctl/agent/prompt_cli/materialize.py`, `report_corrections.check_corrections`, `agent/prompt_cli/corrections.run_corrections_apply`; `tests/run/test_agent_prompt_corrections.py` and `tests/contract/test_report_writer_v3_contract.py` keep this procedure in the lead contract.

## Implementation-planning sequence

1. Dispatch the report writer and wait for the narrative and pointer.
2. Parse the narrative and extract the deterministic plan-item queue without publishing `data.json`. `okstra plan-items prepare` flips `convergence.planBodyVerification.gating` to `false` when the detector reports `no-design-inputs` and the Stage Map has one row.
3. Run initial plan-body verification as round 1.
4. Apply at most one automatic planner self-fix to the narrative. Skip this step when `gating` is `false`.
5. Run targeted re-verification as round 2 when needed. Skip this step when `gating` is `false`.
6. Persist the completed `planBodyVerification` value in convergence state. Run `okstra plan-items next-dispatch`: after the single automatic self-fix, settle eligible judgements with `resolve-dissent`; ask the user immediately for decisions outside lead authority. Preserve dissent and do not restart the automatic loop. The exact procedure and enforced authority checks are in `prompts/lead/plan-body-verification.md` step 8.
7. Complete the design-surface detector snapshot: `okstra design-snapshot --narrative <reportNarrativePath> --output <designPreparationPath>`, taking both paths from the run manifest. Nothing else writes that snapshot, and step 8 fails without it — `report_inputs._PLANNING_INPUT_FIELDS` lists `designPreparationPath` as a required planning input.
8. Run Phase 7 report assembly.

The writer never predicts steps 3–7 in its draft. The plan-body verifier reads the plan body, and report assembly adds its completed result afterward.

**Implementation-planning direction branch.** A selected-direction narrative carries `selectedDirectionRef` and `directionRealization`; the `P-Dir-1` verifier checks that realization against the selected core mechanism, architecture boundaries, planning invariants, and any hidden direction change. A legacy candidate-comparison narrative retains `P-Opt-*` option comparison semantics. These are plan-body judgments, not frontmatter fields owned by the writer. Four `directionRealization` fields — `coreMechanism`, `architectureBoundaries`, `planningInvariants`, `userConstraints` — are verbatim copies of the selected-direction snapshot and report assembly overwrites them from that snapshot at publication: do not paraphrase them; spend the writing on the fields the writer actually owns (`fileStructure` and the other realization fields).

**Implementation-option-selection comparison.** Candidate details remain direction-level and must not claim planning precision:

```json
{"candidateDetailBoundary":{"expectedChangeAreas":"direction-level-only","expectedVerification":"direction-level-signals-only","forbidden":["exact-file-lists","stage-lists","test-commands"]}}
```

For historical schema-v1 Markdown only, the following heading table remains a read-side compatibility reference. It is not an authoring instruction for report contract 3.0.

| # | Required substring | Recommended heading form |
|---:|---|---|

## Phase 6 → Phase 7 execution sequence (BLOCKING order)

**Enforced:** `okstra_ctl.report_finalize.V3_STEP_ORDER` is the order — `report-finalize` runs the steps from that tuple, so the sequence cannot be reordered by a caller. Running the steps by hand is what this rule forbids, and that path is not reachable through the CLI.

Do not run the nine steps below manually. Invoke `okstra report-finalize`; contract 3.0 runs them in this order:

1. **`token-usage`** — collect usage into team state without touching the final record.
2. **`project-activity`** — report assembly validates every owner input and publishes the final record once.
3. **`preflight`** — run the same schema and conformance checks used by final validation before translation, without writing run status. Structural failure skips translation; rendering and final validation still record the failure. `--only translate` also includes this check.
4. **`translate`** — for a non-English `reportLanguage`, materialize and dispatch the translator worker and require its `*.i18n.<lang>.json` sidecar; a no-op for English or when the sidecar already exists.
5. **`render-views`** — render the Markdown reading copy and human HTML, with the translation sidecar overlaid.
6. **`spawn-followups`** — materialize registered follow-up tasks.
7. **`validate-run`** — validate the record, views, run manifest, and team state.
8. **`record-group-memory`** — write this run's conclusion (headline, decisions, watch-outs, open follow-ups, record path, next phase) and the group's start order into the task-group's `group-context.md` okstra region, creating the file when the group has none; skipped, like teardown, when an earlier step failed. Sibling tasks read it as `## Task-Group Memory`.
9. **`teardown-stages`** — remove eligible stage worktrees after successful validation.

After `report-finalize` returns with `ok: true`, the lead closes the run with the launch prompt's User closeout. A generated HTML file alone does not establish successful validation.

When the result carries `recovery.mode: same-run`, continue the authorized work in this run. Read every `recovery.issues` entry and failed step output. Correct writer-owned fields through the correction ledger procedure above; correct orchestrator-owned inputs through their owning commands. If plan content changes, prepare and verify the affected items before finalizing. Then execute `recovery.resumeCommand`, which retains this run manifest and resumes from the earliest failure, including reassembly of contract 3.0 inputs. After a narrative correction, check an existing translation with `okstra report-translate check-data --run-manifest <manifest>` and regenerate it if its source no longer matches. Keep previous approvals, user decisions, model selections and completed evidence. Do not ask for another `/okstra-run` to repair references, coverage summaries, narrative syntax or command spelling. Ask only when an unresolved user decision or an actual external prerequisite prevents the correction. Do not edit an installed runtime or another repository while recovering a consuming project's task. **Enforced:** `report_finalize._finalize_recovery` returns the same-run command and complete owner issues, `closeout_command` suppresses the new-run closeout for that result, and `tests/report/test_report_finalize.py` checks recovery after a failed step. Execution of the correction remains the lead's responsibility.

### The translation sidecar: the `translate` step

`report-finalize` dispatches the translator itself. Its `translate` step runs directly before `render-views` (which overlays the sidecar), and it is a no-op when `reportLanguage` is `en` or the `*.i18n.<lang>.json` sidecar already exists. When the sidecar is missing, the step reuses this run's undispatched translator reservation if the lead already materialized one, otherwise it materializes the prompt itself — instruction file `state/translator-instructions-<task-type>-<seq>.md` (kept when the lead wrote one), prompt `prompts/translator-worker-prompt-<task-type>-<seq>.md`, `--result worker-results/translator-translations-<task-type>-<seq>.md`, `--audit-source worker-results/translator-worker-<task-type>-<seq>.md`, invocation id `<task-type>-<seq>-translator` (`-r2`, `-r3` after a failed attempt) — then runs the CLI-wrapper dispatch, which records the dispatch and links the result. Do not run `agent-prompt materialize --audience translator` or `worker-dispatch --workers translator` by hand before `report-finalize`; the sequence used to be a manual lead step and was skipped in practice (2026-09-09, fontsninja-v3-site dev-10628-3: a `ko` run finalized in one call, zero translator reservations, English HTML). **Enforced:** `okstra_ctl.report_finalize.V3_STEP_ORDER` places the step; `okstra_ctl.report_translation_dispatch.translate_report` owns it; `_translator_job_from_reservation` in `scripts/okstra_ctl/dispatch_core.py` still refuses two undispatched reservations for one run, which only a hand-made second reservation produces.

The step succeeds only when the sidecar exists afterwards; a translator that exits 0 without publishing it fails the step. A failed `translate` does not stop the sequence: `render-views` still writes the view from the English body, `validate-run` records the missing sidecar as an advisory, and the result's resume hint starts at `--only translate --only render-views …` — run that once the cause (a host approval gate, an unavailable provider) is cleared rather than closing the run on an English view.

## Routing pointer

`workflow.nextRecommendedPhase` has `phase`, `status`, and `rationale`. The status vocabulary is `ready`, `pending`, `blocked`, and `terminal`.

**Nobody writes this pointer by hand, and there is no field you can patch to change it.** Phase 7 projects it from this run's report — `scripts/okstra_ctl/next_phase.py::project` reads the one routing field your task type owns, and nothing else reaches the pointer. What you author is that routing field:

| Task type | Field you author | What the projection takes |
|---|---|---|
| `requirements-discovery`, `error-analysis` | `<block>.routing` | `nextTaskType` → the pointer's phase, `rationale` → the pointer's reason |
| `implementation`, `final-verification` | `<block>.routingRecommendation` | `target` → the pointer's phase, `rationale` → the pointer's reason |
| `implementation-option-selection` | `implementationOptionSelection.routing` | the enum value alone; this field has no reason slot |
| `implementation-planning` | the plan's outcome and approval rows | the projection derives both from the record; see the rules below |

Write the routing `rationale` for someone who reads the pointer with no report in front of them: why this phase comes next, in one or two sentences. It is the only sentence that survives into the next run, and the lead's closeout quotes it as the reason for the command it proposes. A `rationale` that restates the phase name tells that reader nothing.

`phase` is non-empty only under `ready`. `prepare` may lower `ready` to `pending` while retaining `phase` in `scripts/okstra_ctl/render.py::_derive_next_recommended_phase`; readers must use `status` for launchability. The projection maps a `release-handoff(stage-group)` target onto `phase` `release-handoff` — the parenthesised form is a scope qualifier, not a task type, and the wizard asks the stage scope separately. A `done` target ends the lifecycle: `terminal`, no `phase`, carrying your `rationale` as the reason the task is finished.

For `implementation-planning` the projection reads the record rather than a routing field. When `implementationPlanning.outcome` is `plan-ready`, or the plan is a candidate-comparison record with no `outcome` field, and a `Blocks=approval` row still blocks progress (`open`, or an unincorporated `request-revision` / `reject`), or the plan-body gate is `aborted-non-result`, the pointer comes out `blocked` with an empty `phase` and a reason naming the blocking ids. The same plan with no open approval blocker is approvable — `ready` at `implementation`. `blocked-by-disagreement` with every approval row user-proceeded (`accept-risk` / `select` / `answer`) or incorporated in `supersessionLedger` is approvable — `ready` at `implementation`. The user still has to approve it separately. A missing `outcome` is not `pending` and is not a reason to send the user to `/okstra-inspect`.

## Compatibility

Contract 2.0 reports remain readable. They are never migrated or rewritten by report assembly. New writes use contract 3.0 only.
