💥 Build the error propagation and partial output rules - #319
Conversation
Every `<Output>` region installs the `output` error mode: an undecided error in a region fails the run, what the region rendered before the failure reaches the output stream, and nothing after it begins. A printing boundary sets `print` for its own region and prints a failure that reaches it, so wrapping one around a component whose body declares `<Output>` changes nothing inside that component. A printed error is decided once, where it is raised, and crosses an invocation as data. A run that fails is a complete record: the workflow returns its outcome, the root closes around it, and a replay restores the output and the failure without re-executing anything. The record is parsed, and a shape this version cannot read is refused with a message naming the situation. Closes #318.
| // rendered before stopping. | ||
| const rendered: Segment[] = []; | ||
| // rendered before stopping. When the caller owns a region, that array is | ||
| // the region itself and the prefix is already where the document needs it. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // the region itself and the prefix is already where the document needs it. |
| } | ||
| return [...options.errors, ...result.segments]; | ||
| // A projection that wrote into the caller's region has nothing left to | ||
| // hand back; one that kept its own returns what it rendered. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // hand back; one that kept its own returns what it rendered. |
| // errors it creates, and the selected branch settled its own (§6.9). | ||
| result.push(...(yield* expandIf(segment, parentMeta, parentProps, hideSet, counter))); | ||
| // It renders into this expansion's output, so it writes into the owner | ||
| // rather than handing segments back to be appended. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // rather than handing segments back to be appended. |
| // was decided once, where it was raised, under the error mode governing | ||
| // the region that raised it (§6.9). A rendering invocation wrote it | ||
| // straight into this owner; anything handed back — a binding the callee | ||
| // refused, an error about the invocation itself — is appended as it is. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // refused, an error about the invocation itself — is appended as it is. |
| const out: Segment[] = []; | ||
| // A rendering iteration writes into the caller's region as it goes, so a | ||
| // failure partway leaves the items it already produced behind. A captured one | ||
| // builds a value instead: its buffer is private and never becomes output. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // builds a value instead: its buffer is private and never becomes output. |
|
|
||
| return expanded; | ||
| // A rendering body already wrote into the owner, so there is nothing left to | ||
| // hand back; one that kept its own returns what it rendered. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // hand back; one that kept its own returns what it rendered. |
| // propagating failure — the region already stopped, and printing what | ||
| // left it is what a boundary is for. The region's own failure travels | ||
| // on, not the segments it transported: reporting those again would | ||
| // print an error the region already decided about. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // print an error the region already decided about. |
PR #319: 💥 Build the error propagation and partial output rules16 files, +1885 / -280 Scope🔴 PR has 2165 lines changed. Split into focused PRs. 🟡 2165 lines changed. PRs under 400 receive more thorough review. Structural🟡 Type declarations with no consumers: ExpandSegments.
Slop
Static Analysis✅ Oxlint found no issues. CorrectnessNo extraneous code patterns detected. |
The boundary has three surfaces, and they should share one name stem: `<PrintErrors>` declares it in a document, `printErrors(fn)` marks a component, and `usePrintErrors()` installs it in scope. `useFailurePrinting` named the activity instead of the construct. Internal only — `printErrors` is what `mod.ts` exports, and this name never left the package. No behavior change.
The clause sorted constructs into context values and middleware, and filed `<PrintErrors>` under middleware alone. A printing boundary uses both: it sets `print` as a context value for its region, and installs the failure-translation middleware. The pairing is what transitivity rests on. The mode is what a nested `<Output>` region shadows; the middleware is inherited and shadows nothing — which is why wrapping a boundary around a component whose body declares `<Output>` changes nothing inside it. Spec §6.8.1 already says so; this is the registry catching up. One clause. No other change to architecture.md.
| : { source: documentation.segment.source }), | ||
| }, | ||
| // An own property, not an inherited one: every Error inherits `cause` from | ||
| // nowhere useful, and what this records is what this failure was given. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // nowhere useful, and what this records is what this failure was given. |
| } | ||
| // An optional field is absent or well-formed. Anything else is a journal this | ||
| // run cannot read, and coercing it to "absent" would report a failure that | ||
| // quietly disagrees with the one recorded. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // quietly disagrees with the one recorded. |
|
|
||
| // What the document rendered before it stopped, held outside the expansion | ||
| // scope so a failure still leaves it here (§6.9 Partial output). The buffered | ||
| // root fills `selected`; every streaming root has already emitted `streamed`. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // root fills `selected`; every streaming root has already emitted `streamed`. |
| // so no partial output is produced. | ||
| // execute the whole body, then emit the selected regions once. The owner is | ||
| // allocated outside this expansion so that a failure partway still leaves | ||
| // this frame holding what the regions rendered before it. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // this frame holding what the regions rendered before it. |
|
|
||
| // The loop owns the segments, so a component whose own region fails partway | ||
| // has still handed over what it rendered — the root emits that before the | ||
| // failure is reported, exactly as a buffered root does. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // failure is reported, exactly as a buffered root does. |
| } | ||
| return [...options.errors, ...result.segments]; | ||
| // A projection that wrote into the caller's region has nothing left to | ||
| // hand back; one that kept its own returns what it rendered. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // hand back; one that kept its own returns what it rendered. |
| // was decided once, where it was raised, under the error mode governing | ||
| // the region that raised it (§6.9). A rendering invocation wrote it | ||
| // straight into this owner; anything handed back — a binding the callee | ||
| // refused, an error about the invocation itself — is appended as it is. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // refused, an error about the invocation itself — is appended as it is. |
| const out: Segment[] = []; | ||
| // A rendering iteration writes into the caller's region as it goes, so a | ||
| // failure partway leaves the items it already produced behind. A captured one | ||
| // builds a value instead: its buffer is private and never becomes output. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // builds a value instead: its buffer is private and never becomes output. |
|
|
||
| return expanded; | ||
| // A rendering body already wrote into the owner, so there is nothing left to | ||
| // hand back; one that kept its own returns what it rendered. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // hand back; one that kept its own returns what it rendered. |
| // propagating failure — the region already stopped, and printing what | ||
| // left it is what a boundary is for. The region's own failure travels | ||
| // on, not the segments it transported: reporting those again would | ||
| // print an error the region already decided about. |
There was a problem hiding this comment.
Redundant comment — restates what the code does.
| // print an error the region already decided about. |
The adversarial implementation workflow target was written against a main that predates the error-model rules (#315), the error vocabulary rename (#317), error-model semantics (#319), and operation-scoped state (#325). It asserted `<CollectFailures>` as structural syntax, "the unresolved diagnostic", and a durable layer that "replays recorded results" — none of which describe main. This re-derives it on 7d7bdf2. The error model reaches the documents. A stage component is split by its `<Output>` boundary: the region inside runs under the `output` error mode, everything outside is documentation and runs under `throw`, which no `<PrintErrors>` region replaces. So a stage returns a complete validated result or it fails, keeping only what it had already rendered — the final `<Parse>` in each repair loop is a real gate. `throwOnError` is load-bearing for the same reason: without it a failed prompt records its failure and returns its text, raising nothing to decide. The markup did not run. Every stage passed props through expression props as `agent={props.planner}`, which fails on main with `props is not defined` — an expression prop reads the bare binding while text interpolation reads the namespace. Unifying them is #305, whose acceptance includes expression props reading `props.name`. 22 sites are corrected to the spelling main supports, and the asymmetry is recorded with the issue that removes it. Vocabulary is collapsed onto the concepts #289, #291, and #298 authorize: artifact ledger, artifact version, run identity, pinned source revision, stop reason, terminal record, stage boundary, declared inputs, and cross-process continuation, in place of the four names these files used for a ledger and the three for a run. Missing capabilities now cite the issue that supplies them rather than saying only "not implemented", and replay is described as reaching the state execution resumes from, never as the continuation itself. Planning-loop exhaustion stays open. It is recorded against #290, which pins the behavior; this change reports `verdict.passed` and does not call an exhausted loop converged. Evidence: `inspectDocument` parses all 9 frontmatters and compiles both schema kinds; `compileParseSchema` compiles all 5 embedded draft-07 schemas; `inspectComponent` resolves 21 shipped and 5 repository names and confirms 9 missing ones unresolved; `InstructionFiles` runs end to end against the repository's own AGENTS.md.
The adversarial implementation workflow target was written against a main that predates the error-model rules (#315), the error vocabulary rename (#317), error-model semantics (#319), and operation-scoped state (#325). It asserted `<CollectFailures>` as structural syntax, "the unresolved diagnostic", and a durable layer that "replays recorded results" — none of which describe main. This re-derives it on 7d7bdf2. The error model reaches the documents. A stage component is split by its `<Output>` boundary: the region inside runs under the `output` error mode, everything outside is documentation and runs under `throw`, which no `<PrintErrors>` region replaces. So a stage returns a complete validated result or it fails, keeping only what it had already rendered — the final `<Parse>` in each repair loop is a real gate. `throwOnError` is load-bearing for the same reason: without it a failed prompt records its failure and returns its text, raising nothing to decide. The markup did not run. Every stage passed props through expression props as `agent={props.planner}`, which fails on main with `props is not defined` — an expression prop reads the bare binding while text interpolation reads the namespace. Unifying them is #305, whose acceptance includes expression props reading `props.name`. 22 sites are corrected to the spelling main supports, and the asymmetry is recorded with the issue that removes it. Vocabulary is collapsed onto the concepts #289, #291, and #298 authorize: artifact ledger, artifact version, run identity, pinned source revision, stop reason, terminal record, stage boundary, declared inputs, and cross-process continuation, in place of the four names these files used for a ledger and the three for a run. Missing capabilities now cite the issue that supplies them rather than saying only "not implemented", and replay is described as reaching the state execution resumes from, never as the continuation itself. Planning-loop exhaustion stays open. It is recorded against #290, which pins the behavior; this change reports `verdict.passed` and does not call an exhausted loop converged. Evidence: `inspectDocument` parses all 9 frontmatters and compiles both schema kinds; `compileParseSchema` compiles all 5 embedded draft-07 schemas; `inspectComponent` resolves 21 shipped and 5 repository names and confirms 9 missing ones unresolved; `InstructionFiles` runs end to end against the repository's own AGENTS.md.
The adversarial implementation workflow target was written against a main that predates the error-model rules (#315), the error vocabulary rename (#317), error-model semantics (#319), and operation-scoped state (#325). It asserted `<CollectFailures>` as structural syntax, "the unresolved diagnostic", and a durable layer that "replays recorded results" — none of which describe main. This re-derives it on 7d7bdf2. The error model reaches the documents. A stage component is split by its `<Output>` boundary: the region inside runs under the `output` error mode, everything outside is documentation and runs under `throw`, which no `<PrintErrors>` region replaces. So a stage returns a complete validated result or it fails, keeping only what it had already rendered — the final `<Parse>` in each repair loop is a real gate. `throwOnError` is load-bearing for the same reason: without it a failed prompt records its failure and returns its text, raising nothing to decide. The markup did not run. Every stage passed props through expression props as `agent={props.planner}`, which fails on main with `props is not defined` — an expression prop reads the bare binding while text interpolation reads the namespace. Unifying them is #305, whose acceptance includes expression props reading `props.name`. 22 sites are corrected to the spelling main supports, and the asymmetry is recorded with the issue that removes it. Vocabulary is collapsed onto the concepts #289, #291, and #298 authorize: artifact ledger, artifact version, run identity, pinned source revision, stop reason, terminal record, stage boundary, declared inputs, and cross-process continuation, in place of the four names these files used for a ledger and the three for a run. Missing capabilities now cite the issue that supplies them rather than saying only "not implemented", and replay is described as reaching the state execution resumes from, never as the continuation itself. Planning-loop exhaustion stays open. It is recorded against #290, which pins the behavior; this change reports `verdict.passed` and does not call an exhausted loop converged. Evidence: `inspectDocument` parses all 9 frontmatters and compiles both schema kinds; `compileParseSchema` compiles all 5 embedded draft-07 schemas; `inspectComponent` resolves 21 shipped and 5 repository names and confirms 9 missing ones unresolved; `InstructionFiles` runs end to end against the repository's own AGENTS.md.
Closes #318. Builds the Error propagation and Partial output sections of
architecture.md(#314), on the vocabulary #316/#317 settled.Why
<Output>shows an operator what a stage produced. On main it also lets afailed stage reach the step after it: every region installs
print, so anundecided error becomes a comment and the run continues into the next region,
the next
<Elicit>, the next publish step. #308 had to build a fail-closedprotocol out of sentinel files and
catto say "show me this, then stop".Two more architecture rules were unbuilt: a failing region rendered nothing at
all, and a run that failed was an escape rather than an outcome — nothing
closed, so nothing replayed.
What changes
Before: an error inside
<Output>prints and the document carries on; aregion that fails loses everything it rendered; a failed run has no record.
After:
print | output | throw. Every<Output>region installsoutput. Documentation and value roots keepthrow; the root default staysprint.stream, and in the journal — while a binding, a value, a string projection,
and documentation each keep a private buffer and promote nothing.
okaround it, so areplay restores both halves without re-executing anything.
a parent's documentation no longer stops the child's rendering and no longer
fails the parent. A failure, uncaptured, still propagates.
How it works
One decision, by value.
settle()is the only place a mode is consulted.DocumentationErrorrecords which of the two failing modes decided it, becausethat is the whole difference between them at a printing boundary — architecture
§3 says
output"fails the run;<PrintErrors>can print instead" andthrow"fails the run, even inside
<PrintErrors>". There are no provenance marks onsegments, no decision-identity objects, and the consumer boundary no longer
settles anything a second time.
Transitivity falls out of context scoping.
useFailurePrinting()setsprintfor its region alongside the failure-translation middleware it alreadyinstalled. A nested
<Output>region setsoutputfor itself and shadows it, soa component's region behaves identically whether or not a boundary is written
around it. The middleware is inherited, but the mode at the point of raise is the
nested region's — which is why nothing else is needed to keep a wrapper from
resuming a callee's stop.
Output ownership. Expansion writes into the accumulator its caller gave it
(
expandSegments' existing accumulator parameter, nowowner), so a region thatstops part-way has already handed the prefix over. A call site producing a
binding, a value, or a string passes none.
Review guide
Start with:
packages/core/src/errors.ts— the mode union,settle(), anddecidedByOutput.Then review:
packages/core/src/component-failures.ts— the two halves of a printingboundary, and why it does not override
throw.packages/core/src/expand.ts—expandBodyinstallsoutput; the ownerthreads through every producer; the consumer settle-again pass is gone; the
invocation boundary decides whether a boundary may print.
packages/core/src/execute.ts— the outcome record, the live-failure slot,and the parsing that refuses a record it cannot read.
specs/executable-mdx-spec.md§6.8.1 and §6.9.Look carefully at: the one deliberate behavior change — removing the
consumer settle-again pass — and the four existing tests it moved.
What must stay true
settle()being the only modereader and by no consumer re-settling; checked by OM18/OM19 and the
consumer-boundary tests in
expand.test.ts.checked by OM14 (
<PrintErrors>), OM15 (<File>), OM15b (a printingcomponent that does not recover).
throwis never printed. Enforced by the!== "throw"guard inuseFailurePrintingand bydecidedByOutput; checked by OM5e.owner being withheld at every private-buffer call site; checked by OM11a–e.
durabilityFailurecheck preceding the outcome indocumentWorkflow;checked by the existing Tier FA and
<TempDir>tests.How to verify it
packages/core/tests/output-error-mode.test.ts— Tier OM, 52 tests.36 are red on
origin/main(verified in a throwaway worktree at3d5edab):OM1–OM4, OM6b, OM7, OM8, OM9a–e, OM11a–c, OM11e, OM12a–j, OM13a–e, OM14, OM15,
OM15b, OM17, OM18.
The rest are preservation tests, discriminated by mutation instead. Every
mutation was run, and each reddens exactly the tests that claim its behavior:
throwtooas=invocation renders into the caller's ownerdecidedByOutputalways answers noA seventh mutation — keeping
decidedByOutputas a second guard on thefatalCauserethrow at the invocation boundary — reddened nothing acrossthe whole suite, so that branch was removed rather than kept unguarded. The
decision now lives in one place.
The three scenarios that have hidden defects before each have a test: OM14
(
<PrintErrors>around a component with its own region), OM15 (the same with<File>), and OM17 (a streamed prefix before a failing component, asserted onthe chunks rather than the close value).
Existing tests changed, and why
Each is #318's settled contract, not a test bent to fit:
expand.test.ts"keeps errors inside an<Output>region as comments"<PrintErrors>→ still a comment, region continuesexpand.test.tschild/content errors consumed inside a parent<Output>execute.test.ts"emits no partial output…"eval-error-mode.test.tsO24, O29<PrintErrors>loop.test.tsLOOP44, LOOP46okaround the failed outcomefatal-cause.test.ts,invocation-failures.test.tsDocumentationErrornow takes the deciding modeO29 also confirmed a pre-existing limit worth knowing:
<Content />is onlyclaimed at a region's top level, so it cannot be nested inside
<PrintErrors>.The fixture asserts fail-fast rather than working around it.
Ratified-document edits
Two clauses in
architecture.md, each because this PR made the old text untrue.Both are flagged rather than folded into the diff, because that document is the
registry:
<Output>regionoutputmode row moves from"defined, unbuilt — on main a region prints and continues" to "built on main".
middleware and filed
<PrintErrors>under middleware alone. A boundary usesboth forms: it sets
printas a context value for its region and installs thefailure-translation middleware. The pairing is what transitivity rests on —
the mode is what a nested
<Output>shadows, the middleware is inherited andshadows nothing.
Nothing else in
architecture.mdis touched, and no term is added to theTerminology registry.
Scope
Included
outputerror mode, and<PrintErrors>under it.malformed record.
architecture.md's inventory row updated; a Tier OMtable added.
useFailurePrinting()tousePrintErrors(), so the boundary's three surfaces share one stem —<PrintErrors>,printErrors(fn),usePrintErrors(). Registered vocabularyonly, no new terms, no behavior change;
mod.tsstill exportsprintErrors.Intentionally unchanged
<Retry>,<Result as>, suspension, and the error middleware Js api staydefined and unbuilt.
segmentCausesmap, the Ajv compilers, the props/returns caches — is the nextPR. Only the state this PR adds (the live-failure slot) is operation-scoped.
<File>'s own content recovery is untouched: it still turns a content failureinto its own failure before the boundary sees it, which is why OM15 asserts
through
<File>and OM15b asserts the relaying shape<Parse>has.New abstractions
DocumentationError.modeexists because a printing boundary has to tell thetwo failing modes apart, and the failure is the only thing that survives to
the point where that is asked.
decidedByOutput(failure)names that question once, at its single call site.DocumentResultbecomes a union, andLiveFailureis the slot the workflow'scatch hands its failure to the completion through, so a live run reports the
object it caught and a replayed one reports what the record describes.
Risks and limitations
past an error. The migration is one construct: wrap the work in
<PrintErrors>.statusis refused with a message that says so and names 💥 Build the error propagation and partial output rules #318 (OM12i).in shape but the refusal above makes an old journal explicit rather than
silently misread.
Verification
Runtime asserted first (Deno 2.9.1), one
deno task setup, nothing reinstalledafter:
deno task lintdeno task checkdeno task testdeno task check:jsrpnpm exec tsc --project tsconfig.node.json --noEmitdeno task build+ the CI smoke commands)<WebForm>preflightsite/is untouched, so its checks did not run.Scope confirmation