Effect boundaries fail loudly
An effect boundary is the point where work leaves the process and becomes
something other people observe: a package published, a row written, a file
generated, content propagated to other repositories, a release cut.
The contract:
On an effect boundary, partial success is failure. No catch-and-continue,
no defaulting to success on an unknown outcome. A degraded operation must fail
the operation, surface the delta, or emit an explicit degraded-mode signal.
Silent failure is the dominant recurring bug archetype across Cratis. The
2026-08-24 organization-wide review found one disease with six manifestations,
in the release action, the Arc proxy generator, Stage, the Chronicle container
host, Chronicle constraint enforcement, and corpus propagation. They are written
out in failure-archetypes.md; read them
before deciding that your case is different.
When you need this
- You are writing or reviewing a
catch around an operation with an effect —
publish, write, generate, copy, notify, tag, release.
- An operation processes a set and some members can fail independently: a
fan-out, a batch, a matrix, a per-file generator.
- A call returns an outcome you did not model: an unexpected status code, a
null, an empty result, a timeout.
- Two implementations of one interface exist and only one of them really
enforces the behavior — an in-memory or SQL sibling of a real store.
- A host, container, or long-running process can reach a "started" state while
the thing it started has already thrown.
When you do not
- Pure computation with no effect. A parser that returns a partial tree for
a caller that inspects it is not an effect boundary.
- A retry that will still report the final outcome truthfully. Retrying is
not swallowing; reporting success after the retries also failed is.
- A genuinely optional enrichment whose absence is stated in the result. An
optional cache warm that records
cache: skipped is a degraded-mode signal,
which is exactly what this contract asks for.
- Style, naming, or structure questions. Those belong to the C# and
TypeScript conventions.
- Deciding whether an operation should exist at all. That is a product or
scope ruling, not an error-handling one.
Steps
- Name the boundary before you write the handler. Say out loud what leaves
the process: which package, which rows, which files, which repositories.
If nothing leaves, this contract does not apply and you can stop here.
- Enumerate the outcomes the call can produce, including the ones you did not
design for. An unexpected status code, an empty response, and a timeout are
outcomes. A handler that maps everything it did not enumerate onto success is
the defect.
- Choose one of the three permitted responses to a degraded outcome, and say
which one you chose. Fail the operation; or complete and surface the delta
in the result; or emit an explicit degraded-mode signal the caller must
handle. Anything else is catch-and-continue.
- Make partial fan-out visible in the aggregate, not just in the log. Count
attempted, succeeded, and failed, and put all three in the returned result
and the summary line. "29 of 36 succeeded" and "36 of 36 succeeded" must not
produce the same output.
- Refuse to convert an unknown into a pass. An outcome the code could not
classify is
indeterminate. Report it as its own state; never roll it up
into the success count.
- Check the sibling implementations of the same interface. When a real
store enforces a constraint, its in-memory and SQL siblings must enforce the
same one or throw
NotSupported. A sibling that silently accepts what the
real one rejects makes every specification that uses it pass vacuously.
- Make the process state follow the work. If startup threw, the host is not
Running. A liveness or readiness state that survives a failed start is a
lie the orchestrator will believe.
- Plant the failure and watch it surface. Force the degraded outcome —
inject the status code, delete an input, fail one fan-out member — and
confirm the operation fails, the delta appears, or the degraded signal fires.
A boundary whose failure path was never executed is not known to have one.
What breaks
Every item below is a real Cratis defect, not an illustration. Detail and issue
references are in failure-archetypes.md.
- A swallowed conflict reported as a successful release. The release action
caught a 422 from a concurrent publish and reported the release as done. The
version was never published, and the only artifact that said so was a caught
exception nobody saw.
- A generator that degrades silently. The Arc proxy generator emitted fewer
proxies than its inputs implied and exited zero. The failure shows up much
later as a missing TypeScript type, far from the generator that dropped it.
- A renderer that quietly renders less. Stage produced degraded output on a
path that reported success, so the difference between correct output and
partial output was invisible at the boundary that produced it.
- A container that stays
Running after startup threw. The Chronicle host
reported healthy while the thing it hosts had already failed to start, so the
orchestrator kept routing to it.
- A constraint that only one implementation enforces. Chronicle unique
constraints were not enforced on the SQL and in-memory storage providers.
Every specification exercising them passed while proving nothing.
- A fan-out that succeeded 29 times out of 36 and said "done". Corpus
propagation aggregated per-target results into a single success, so seven
repositories silently did not receive the change.
The shared symptom: the failure is discovered downstream, by someone who
cannot see the boundary that caused it. That is what makes this archetype
expensive rather than merely annoying.
How it is proven
- The failure path was executed. Name the planted defect and the observed
result: the injected status code, the removed input, the failed fan-out
member — and what the operation did in response.
- Counts appear on success. The clean run reports how many subjects it
attempted and how many succeeded. A bare "OK" cannot be distinguished from a
run over an empty set.
- Exit codes carry the verdict.
0 ran clean, 1 found defects, 2 could
not run. A wrapper that exits 0 because the wrapper finished has thrown the
child's verdict away; check the child's status and, in a pipeline, the status
of every stage.
- The degraded signal is asserted, not just emitted. A specification reads
the delta or the degraded-mode field and fails when it is absent.
- Sibling implementations are covered by the same specification. The test
that proves the constraint runs against every implementation of the interface,
not only the one that enforces it.
1---2name: cratis-engineering-effect-boundaries3description: Apply the Cratis effect-boundary contract when writing or reviewing code that publishes, persists, generates, propagates, or releases. On those boundaries partial success is failure - no catch-and-continue, no defaulting to success on an unknown outcome. Use when a degraded run could still report success; defer style questions and specification authoring to their own workflows.4license: LICENSE5---67# Effect boundaries fail loudly89An **effect boundary** is the point where work leaves the process and becomes10something other people observe: a package published, a row written, a file11generated, content propagated to other repositories, a release cut.1213The contract:1415> On an effect boundary, **partial success is failure.** No catch-and-continue,16> no defaulting to success on an unknown outcome. A degraded operation must fail17> the operation, surface the delta, or emit an explicit degraded-mode signal.1819Silent failure is the dominant recurring bug archetype across Cratis. The202026-08-24 organization-wide review found one disease with six manifestations,21in the release action, the Arc proxy generator, Stage, the Chronicle container22host, Chronicle constraint enforcement, and corpus propagation. They are written23out in [failure-archetypes.md](references/failure-archetypes.md); read them24before deciding that your case is different.2526## When you need this2728- You are writing or reviewing a `catch` around an operation with an effect —29 publish, write, generate, copy, notify, tag, release.30- An operation processes a set and some members can fail independently: a31 fan-out, a batch, a matrix, a per-file generator.32- A call returns an outcome you did not model: an unexpected status code, a33 null, an empty result, a timeout.34- Two implementations of one interface exist and only one of them really35 enforces the behavior — an in-memory or SQL sibling of a real store.36- A host, container, or long-running process can reach a "started" state while37 the thing it started has already thrown.3839## When you do not4041- **Pure computation with no effect.** A parser that returns a partial tree for42 a caller that inspects it is not an effect boundary.43- **A retry that will still report the final outcome truthfully.** Retrying is44 not swallowing; reporting success after the retries also failed is.45- **A genuinely optional enrichment whose absence is stated in the result.** An46 optional cache warm that records `cache: skipped` is a degraded-mode signal,47 which is exactly what this contract asks for.48- **Style, naming, or structure questions.** Those belong to the C# and49 TypeScript conventions.50- **Deciding whether an operation should exist at all.** That is a product or51 scope ruling, not an error-handling one.5253## Steps54551. **Name the boundary before you write the handler.** Say out loud what leaves56 the process: which package, which rows, which files, which repositories.57 If nothing leaves, this contract does not apply and you can stop here.582. **Enumerate the outcomes the call can produce, including the ones you did not59 design for.** An unexpected status code, an empty response, and a timeout are60 outcomes. A handler that maps everything it did not enumerate onto success is61 the defect.623. **Choose one of the three permitted responses to a degraded outcome, and say63 which one you chose.** Fail the operation; or complete and surface the delta64 in the result; or emit an explicit degraded-mode signal the caller must65 handle. Anything else is catch-and-continue.664. **Make partial fan-out visible in the aggregate, not just in the log.** Count67 attempted, succeeded, and failed, and put all three in the returned result68 and the summary line. "29 of 36 succeeded" and "36 of 36 succeeded" must not69 produce the same output.705. **Refuse to convert an unknown into a pass.** An outcome the code could not71 classify is `indeterminate`. Report it as its own state; never roll it up72 into the success count.736. **Check the sibling implementations of the same interface.** When a real74 store enforces a constraint, its in-memory and SQL siblings must enforce the75 same one or throw `NotSupported`. A sibling that silently accepts what the76 real one rejects makes every specification that uses it pass vacuously.777. **Make the process state follow the work.** If startup threw, the host is not78 `Running`. A liveness or readiness state that survives a failed start is a79 lie the orchestrator will believe.808. **Plant the failure and watch it surface.** Force the degraded outcome —81 inject the status code, delete an input, fail one fan-out member — and82 confirm the operation fails, the delta appears, or the degraded signal fires.83 A boundary whose failure path was never executed is not known to have one.8485## What breaks8687Every item below is a real Cratis defect, not an illustration. Detail and issue88references are in [failure-archetypes.md](references/failure-archetypes.md).8990- **A swallowed conflict reported as a successful release.** The release action91 caught a 422 from a concurrent publish and reported the release as done. The92 version was never published, and the only artifact that said so was a caught93 exception nobody saw.94- **A generator that degrades silently.** The Arc proxy generator emitted fewer95 proxies than its inputs implied and exited zero. The failure shows up much96 later as a missing TypeScript type, far from the generator that dropped it.97- **A renderer that quietly renders less.** Stage produced degraded output on a98 path that reported success, so the difference between correct output and99 partial output was invisible at the boundary that produced it.100- **A container that stays `Running` after startup threw.** The Chronicle host101 reported healthy while the thing it hosts had already failed to start, so the102 orchestrator kept routing to it.103- **A constraint that only one implementation enforces.** Chronicle unique104 constraints were not enforced on the SQL and in-memory storage providers.105 Every specification exercising them passed while proving nothing.106- **A fan-out that succeeded 29 times out of 36 and said "done".** Corpus107 propagation aggregated per-target results into a single success, so seven108 repositories silently did not receive the change.109110The shared symptom: **the failure is discovered downstream, by someone who111cannot see the boundary that caused it.** That is what makes this archetype112expensive rather than merely annoying.113114## How it is proven115116- **The failure path was executed.** Name the planted defect and the observed117 result: the injected status code, the removed input, the failed fan-out118 member — and what the operation did in response.119- **Counts appear on success.** The clean run reports how many subjects it120 attempted and how many succeeded. A bare "OK" cannot be distinguished from a121 run over an empty set.122- **Exit codes carry the verdict.** `0` ran clean, `1` found defects, `2` could123 not run. A wrapper that exits `0` because the wrapper finished has thrown the124 child's verdict away; check the child's status and, in a pipeline, the status125 of every stage.126- **The degraded signal is asserted, not just emitted.** A specification reads127 the delta or the degraded-mode field and fails when it is absent.128- **Sibling implementations are covered by the same specification.** The test129 that proves the constraint runs against every implementation of the interface,130 not only the one that enforces it.