Skip to content

✨ Stop authored work deliberately with <Fail> - #662

Merged
taras merged 1 commit into
mainfrom
agent/issue-659-fail-component
Aug 30, 2026
Merged

✨ Stop authored work deliberately with <Fail>#662
taras merged 1 commit into
mainfrom
agent/issue-659-fail-component

Conversation

@taras

@taras taras commented Aug 30, 2026

Copy link
Copy Markdown
Owner

Closes #659.

Why

A document that has decided it cannot go on has something to say about why, and until now it had no way to say it. Ending a value body meant reaching its end without a <Return>, and the resulting message — The root document declares \returns` but produced no value.` — reports a structural fact about the body's flow, not what the author concluded. A reader of that run learns that nothing was selected, never why.

What changes

<Fail> is a new ordinary @executablemd/core default that raises the author's own sentence where it is written.

Before:

<!-- the only way to stop a value root: fall off the end -->
<If condition={approved}>
  <Return value={candidate} />
</If>
<!-- → "The root document declares `returns` but produced no <Return> value." -->

After:

<If condition={approved}>
  <Return value={candidate} />
</If>

<Fail message="No candidate was approved after 3 review rounds." />
<!-- → "No candidate was approved after 3 review rounds.", at doc.md:6:1 -->

A repository Fail.md overrides it under the existing resolution order, exactly as it would override <Parse> or <Json>.

How it works

<Fail message="…" />  →  form dispatch (self-closing only)
                      →  prop validation (closed schema, required non-empty message)
                      →  body: hasBinding() refuses `as`
                      →  throw new Error(message)
                      →  the ordinary function-component failure boundary

Nothing here is new machinery. Form dispatch, prop validation, invocation publication, source-position capture and failure propagation are the paths every function component already takes; the component adds a schema, a body and a FormDeclaration, and the engine does the rest.

The one design decision worth naming is an omission: the implementation carries no printErrors() declaration. That omission is the recovery policy. A text root therefore stops at the failure by default, and an author asks for continuation explicitly by writing <PrintErrors> around the region. A value body installs throw — the one mode a printing boundary does not replace — so an enclosing <PrintErrors> there cannot turn a deliberate abort into a successful result.

Review guide

Start with: packages/core/src/components/Fail.ts — the whole implementation is 30 lines under its own explanation.

Then review:

  1. specs/executable-mdx-spec.md §6.8.2 — the public contract, beside §6.8.1 whose failure path it uses
  2. packages/core/src/components/registry.ts — one core("Fail", failForm, …) entry
  3. packages/core/tests/fail-component.test.ts — Tier FAIL, 16 cases
  4. The §5.4 / §6.10 / architecture.md edits that recast missing <Return>

Look carefully at:

  • The absence of printErrors() in Fail.ts. Copying a built-in such as <Json> and keeping its wrapper would make a plain <Fail> silently continue everywhere. FAIL1 and FAIL6 exist to catch exactly that.
  • The order of refusals. as is valid for a text component by default, so the body asks hasBinding() before it raises. Every refusal is a FailInvocationError naming the invocation, never the author's sentence.

What must stay true

  • A repository Fail.md wins. — enforced by registering an ordinary default and staying out of RESERVED_STRUCTURAL / STRUCTURAL_DECLARATIONS; checked by CR16b, CR17 and FAIL10.
  • The authored message is raised unaltered. — enforced by throw new Error(String(props.message)) with no prefix, classification or interpolation; checked by FAIL1 and FAIL8.
  • An invalid invocation never carries the authored message. — enforced by form dispatch and the closed schema running before the body, and by the hasBinding() guard running before the throw; checked by FAIL7aFAIL7g, each using a distinctive sentinel message and asserting it appears nowhere in the failure, the output, the observed segments or the offered failures.
  • Recovery is the region's, never the component's. — enforced by leaving the implementation unmarked; checked by FAIL1 (observed is empty, so nothing printed it) and FAIL5 (an authored <PrintErrors> prints it once and the later sibling runs).
  • A value root cannot be talked out of the failure. — enforced by the existing throw error mode, which usePrintErrors() deliberately does not replace; checked by FAIL2 and FAIL6, which require the authored Error to reach the completion by identity and produced no <Return> value to appear nowhere.
  • The component owns no durable operation. — enforced by touching no durable, filesystem, environment or expansion API; checked by FAIL9.

How to verify it

deno task test packages/core/tests/fail-component.test.ts packages/core/tests/syntax-catalog.test.ts packages/core/tests/component-registration.test.ts
# ok | 23 passed (109 steps) | 0 failed

The evidence is written so that absence of output is never the proof. A sibling that must not run is a registered <Ran> component recording its own mark; a projected child that must not expand is that same component written inside the element.

  • FAIL1 proves a plain root ends with the exact authored message and starts nothing after it, and fails if a printErrors() wrapper were ever added.
  • FAIL2 proves a value root settles on the authored Error by object identity even with a <Return> written later in the body, and fails if the missing-<Return> diagnostic replaced it.
  • FAIL3 proves an unselected <If> reaches nothing — asserted against the journal's import_component names, not against absent output, so a silently-executed-and-swallowed failure could not pass it.
  • FAIL4 separates loop exhaustion from the failure chosen after it: the <Loop> records both of its own iterations, then the sibling <Fail> names exhaustion and the work after it never begins.
  • FAIL5 proves an authored <PrintErrors> reports it exactly once (counted, not merely present) and the later sibling runs.
  • FAIL6 proves <PrintErrors> in a value root does not downgrade it, and fails if a recovered failure let missing-<Return> settlement speak instead.
  • FAIL7aFAIL7g cover paired content, an empty paired spelling, as, and a missing, empty, non-string or unknown prop. Each asserts the child tripwire never fired and the sentinel message appears nowhere, so a refusal that happened after the body decided to raise would fail.
  • FAIL8 pins name, the exact message, and the path, offset, line and column of the opening tag.
  • FAIL9 proves the failed root's journal holds import_component/__root__, import_component/Ran, import_component/Fail and close and nothing else, then hands the same stream to a second run: the same failure comes back, no event is appended, and the <Ran> tripwire written before the failure does not record a second run.
  • FAIL10 proves a repository Fail.md renders its own content and no failure is offered at all.
  • SY24c asserts the complete catalog row with one toEqual, so the absence of as and context prose is pinned alongside origin, reserved: false, forms, schema, captures, return mode and description. Any drift in what an author reads from xmd syntax fails here.

Beyond the focused command, the suites that enumerate core defaults were run because a new one changes what they see: document-validation, generated-xmd and packages/cli/tests/syntax-cli.test.ts (Tier SX) together give ok | 29 passed (169 steps) | 0 failed. deno task lint exits 0.

Rebased onto #658

#658 (<CodeBlock>) merged as 879c4ff6 while this PR was being opened, so the branch is rebased onto it. The two features are independent and neither contract changed; the three conflicts were all additive collisions in the same neighbourhood, resolved by keeping both:

  • component-registration.test.ts CR16b/CR17 — one alphabetical name list now carrying both CodeBlock and Fail.
  • syntax-catalog.test.ts — both changes claimed the id SY24b. ✨ Render arbitrary text safely with <CodeBlock> #658's <CodeBlock> row keeps it; the <Fail> row is renumbered SY24c.
  • specs/executable-mdx-spec.md §5.3 — one inventory sentence listing both.

registry.ts and architecture.md auto-merged. Both core(…) entries and both construct-inventory rows are present.

Scope

Included

  • <Fail>: the component, its form declaration, its registration and its catalog row
  • Tier FAIL evidence, and the Fail rows added to the existing selection and override tests
  • Specification §5.3, new §6.8.2, and the §5.4 / §6.10 recasting of missing <Return>
  • The architecture.md return-state paragraph and construct-inventory row

Intentionally unchanged

  • The two missing-<Return> messages and their implementation. What changes is their documented meaning — they report a body whose flow selected no value — not their text or their runtime behavior.
  • <Return> semantics. Ownership, single-execution and validation are untouched.
  • printErrors() and <PrintErrors>. No new error mode, structural branch, expansion case, middleware API, public error class or root special case is introduced; <Fail> uses the failure path that already exists.
  • No CLI, workflow, runtime-adapter or Agent package change. The component belongs wholly to @executablemd/core.

New abstractions

  • FailInvocationError exists so an invocation this component would not run reports itself rather than the author's sentence — a document that never reached its decision must never be reported as having made one. It is module-local and not exported from mod.ts, matching JsonRenderError and FileDeleteError.
  • Each new abstraction has multiple concrete uses or a clear justification.
  • No speculative functionality is included.

Risks and limitations

  • A future printErrors() on this component would silently recover every plain <Fail> in every document. FAIL1 and FAIL6 are the guard; do not weaken them.
  • scripts/tests/component-form-dispatch.test.ts was not run locally: it refuses without a compiled dist/xmd, which is its documented precondition. CI builds for it.

Scope confirmation

  • Every changed file supports the purpose described above.
  • Unrelated cleanup and formatting changes are excluded.
  • Generated or mechanical changes are clearly identified.
  • The description matches the final diff and test results.

`<Fail message="…" />` is a new ordinary `@executablemd/core` default that
raises the author's own sentence where it is written. A repository `Fail.md`
overrides it under the existing resolution order.

It accepts only the self-closing form and a closed schema of one required
non-empty `message`. Paired content, `as`, and every malformed prop are refused
before the authored message can be raised, so a document that never reached its
decision is never reported as having made one.

A valid invocation is the ordinary failure of a function component: an `Error`
carrying the exact message, positioned at the opening tag, rendering nothing and
binding nothing. It carries no `printErrors()` declaration, which is what leaves
recovery to an authored `<PrintErrors>` region; a value body's `throw` is not
replaced there, so the authored failure settles the body ahead of
missing-`<Return>` settlement.

The specification and architecture now say that missing `<Return>` is the
structural settlement error for a body that selected no value, and that a
deliberate abort or a bounded exhaustion is expressed with `<Fail>`.
@taras
taras force-pushed the agent/issue-659-fail-component branch from 573cdf7 to 3e082d5 Compare August 30, 2026 15:08
@taras
taras marked this pull request as ready for review August 30, 2026 15:08
@github-actions

Copy link
Copy Markdown

PR #662: ✨ Stop authored work deliberately with

7 files, +691 / -10

Scope

🟡 701 lines changed. PRs under 400 receive more thorough review.

Structural

✅ No structural bloat detected.

Slop

✅ Slop indicators look low.

Static Analysis

Oxlint: 2 diagnostics across 1 file (2 rules)
Density: 0.003 violations/added-line

no-shadow (1): packages/core/src/components/Fail.ts
no-base-to-string (1): packages/core/src/components/Fail.ts

Correctness

No extraneous code patterns detected.

@taras
taras merged commit 8ebca97 into main Aug 30, 2026
82 of 86 checks passed
@taras
taras deleted the agent/issue-659-fail-component branch August 30, 2026 16:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

✨ Deliberately fail an XMD document with <Fail>

1 participant