Skip to content

feat(core): record an effect's intent before it runs, and re-drive it - #320

Merged
totollygeek merged 3 commits into
masterfrom
feat/core-durable-effects
Aug 10, 2026
Merged

feat(core): record an effect's intent before it runs, and re-drive it#320
totollygeek merged 3 commits into
masterfrom
feat/core-durable-effects

Conversation

@totollygeek

Copy link
Copy Markdown
Contributor

Why

A target body that dies halfway leaves no trace of what it was doing. There was no way to express work where that matters — posting a required status check, publishing a release, telling another system something finished. A process killed mid-post left a run nothing would pick up, and nothing that could say the post was owed.

This is R1 of the durable merge-gate work.

What it does

.effect(name, fn) on a target. The intent to run it is written to the run record and confirmed before the body runs, so a process killed anywhere inside the effect leaves a pending row, and a resume drives it again. An effect already recorded done is skipped rather than repeated. A store is required and enabled automatically; with state explicitly disabled the run is refused rather than performing an effect nothing recorded as owed.

At-least-once, not exactly-once. A process that dies after the side effect but before recording it repeats the effect. That is the honest cost of never losing one, and it is what the requester asked for.

Two details worth review attention:

The effect writes are strict, unlike every other write on the writer: each throws rather than dropping, because a caller that cannot record its intent must not perform the effect. They also refuse to write to a run that is not running, rechecked after every conflicting re-read — without that, a process still alive on a run another process already settled would arm its effect and publish a result over the settled one, which for a merge gate is a green check over a red.

A strict failure is kept off the writer's shared chain. Every write serialises through it, so leaving a rejection there would make each later best-effort write reject too, and one failed intent would take the run's whole bookkeeping down with it.

Third commit: the adversarial pass, which found a critical defect

Effects were driven from one place in the target lifecycle, and four other paths reach a target's real execution and return before it. A cache hit, a satisfied wait gate, a service, and a fan-out each skipped the effects and the run reported success with nothing recorded as owed — so the effect had not been deferred, it had never existed. Silent, and green.

Fixed: a target declaring an effect is no longer cacheable; a satisfied gate drives its effects before returning; a service and a fan-out refuse the declaration outright, following the wait gate's existing precedent. A condition that skips the target still skips its effects, which is the one case where not running is correct.

Also from that pass: two effects under one name were silently one effect, now refused at declaration; a refused intent no longer leaves its mutation on the live record for a later write to persist; giving up after repeated conflicts now marks the record degraded like every other permanent loss; a settlement no longer overwrites an effect another process completed; and a failing settle write no longer replaces the effect's real error with a message about bookkeeping.

The docs claimed a killed process's effect is re-driven by a resume. A resume acts on a suspended run and a killed process leaves its run running, so that claim needed its precondition stated — the intent is durable either way, but something has to move the run back first. That something is the reaper, in a later PR.

One correction to carry forward

A test written to prove that effect inputs are pinned across a re-drive failed, which is how this surfaced: resumeRun merges the recorded parameters with any freshly supplied ones, and fresh wins. That is deliberate — it is the only way to re-supply a secret, since secrets are kept out of the record — but it means a parameter is pinned only if nobody re-supplies it. The docs now say that, and three tests cover it, including one that demonstrates the override. A value that must not drift belongs in ctx.state.

Tests

  • Unit, 14: every silent path the best-effort writer forgives (vanished record, retries exhausted, store error), the run-status guard against all four settled statuses, the guard after a conflicting re-read, chain isolation, the no-phantom-intent and no-overwrite-done rules, the degraded marking, and a record claiming zero attempts being refused.
  • Integration, 13: the intent read from the store inside the effect body, auto-enabled state, a failed effect recorded, re-drive versus skip, redriven reporting, all three parameter/state pinning cases, and one regression per confirmed finding.
  • E2E, 1: a real subprocess SIGKILLed inside its effect, the record read from the parent showing the effect still owed, and a genuinely separate resume driving it again — marker file showing exactly two drives, intentAt preserved across the process boundary. One seam is stood in for and labelled: moving an abandoned running run back to suspended is the reaper's job.

Full gate green locally, 18 of 18 targets, plus the e2e suite.

🤖 Generated with Claude Code

totollygeek and others added 3 commits August 10, 2026 12:33
A target body that dies halfway leaves no trace of what it was doing.
There was no way to express work where that matters: posting a required
status check, publishing a release, telling another system something
finished. A process killed mid-post left a run nothing would ever pick up,
and nothing that could tell it the post was owed.

Add .effect(name, fn). The intent to run it is written to the run record
and confirmed before the body runs, so a process killed anywhere inside
the effect leaves a pending row, and a resume drives it again. An effect
already recorded done is skipped rather than repeated.

At-least-once, not exactly-once. A process that dies after the side effect
but before recording it repeats the effect, which is the honest cost of
never losing one. Bodies must tolerate that, and the context tells them
when a previous attempt already committed.

The writes are strict, unlike every other write on the writer. Each one
throws rather than dropping, because a caller that cannot record its
intent must not perform the effect: with no durable intent nothing knows
to re-drive it. They also refuse to write to a run that is no longer
running, rechecked after every conflicting re-read. Without that, a
process still alive on a run some other process already settled would arm
its effect and publish a result over the settled one, which for a merge
gate is a green check over a red.

A strict failure is kept off the writer's shared chain. Every write
serialises through it, so leaving a rejection there would make each later
best-effort write reject too, and one failed intent would take the run's
whole bookkeeping down with it.

A store is required and enabled automatically. With state explicitly
disabled the run is refused rather than performing an effect nothing
recorded as owed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The in-process suite can stage a half-done effect but not produce one, so
it cannot show that the intent survives an unclean death or that a
different process finds it.

Add an e2e that kills a real subprocess inside its effect, reads the
record from the parent to confirm the effect is still recorded as owed,
and then runs a real resume that drives it again. The fixture appends one
line per drive, so the count is observed rather than inferred.

One seam is stood in for and labelled: moving an abandoned running run
back to suspended is the reaper's job, and the reaper does not exist yet,
so the parent performs that single transition by hand.
An adversarial pass over the effect machinery found that the effects were
driven from exactly one place in the target lifecycle, and four other paths
reach a target's real execution and return before it. A cache hit, a wait
gate whose trigger was already satisfied, a service, and a fan-out each
skipped the effects entirely — and because the run then reported success
with nothing recorded as owed, the effect had not been deferred, it had
never existed. That is worse than a failure: it is invisible.

A target that declares an effect is no longer cacheable, since a cache hit
returns before the effects and the seconds saved are not worth losing one.
A satisfied wait gate now drives its effects before returning, because the
gate opening is the target's real run. A service and a fan-out run no body
of their own, so both refuse the declaration outright rather than accept
one they would drop; the fan-out refusal follows the wait gate's existing
precedent, for the same reason. A condition that skips the target still
skips its effects, which is the one case where not running is correct, and
that is now stated where the others are.

Two effects declared under one name were also silently one effect: the name
is the identity of the record row, so the first to complete made the second
skip. Refused at declaration.

Four fixes to the recording itself. A refused intent no longer leaves its
mutation on the live record for the next write to persist, which had turned
a provably-unrun effect into an armed one and made a body's first execution
report itself as a re-drive. Giving up after repeated conflicts now marks
the record degraded, like every other permanent loss, so a resume demands
the operator's authorisation before repeating a non-idempotent step. A
settlement no longer writes over an effect another process already
completed. And a settle write that fails no longer replaces the effect's
real error with a message about bookkeeping, which is the one thing anyone
reading the incident needs.

The docs and JSDoc claimed a killed process's effect is re-driven by a
resume. A resume acts on a suspended run and a killed process leaves its
run running, so the claim needed its precondition: the intent is durable
either way, but something has to move that run back to suspended first.
Stated rather than implied, pending the reaper.

Every fix has a regression test. The two that mattered most are
mutation-checked: reverting either makes its test fail.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

🤖 Zuke AI review

🔎 security review — review

Score: 3/10 · Severity: low · 1 finding(s)

Tokens: 21700 in · 194 out · 21894 total

Severity Finding Location
low Effect error text is persisted after redaction, which can leak sensitive data into state packages/core/src/state/writer.ts:353
Dismiss a false positive

Add a finding's ID to the suppress list to hide it next time:

  • 269qplh4qt4gv — Effect error text is persisted after redaction, which can leak sensitive data into state

The change adds crash-durable effects with state-backed re-drive semantics; the main security concern is a new code path that can preserve or reveal effect errors in state, but no obvious injection, auth, SSRF, or path traversal issues were introduced.

@github-actions

Copy link
Copy Markdown
Contributor

🤖 Zuke AI review

🔎 generic review — review

Score: 0/10 · Severity: none · 0 finding(s)

Tokens: 24439 in · 57 out · 28357 total

The changes introducing crash-durable effects are exceptionally well-engineered, with comprehensive test coverage, excellent concurrency/error handling, and strict adherence to the project's TypeScript guidelines.

@totollygeek

Copy link
Copy Markdown
Contributor Author

Review finding

269qplh4qt4gv — "Effect error text is persisted after redaction, which can leak sensitive data into state" at packages/core/src/state/writer.ts:353false positive as stated; no change made.

Two reasons, both checkable:

The line does not do that. writer.ts:353 is inside beginEffect, and it is the existing?.status === "done" check that decides whether to skip an already-completed effect. No error text passes through it — beginEffect never takes an error argument at all.

Where the text is handled, the treatment is byte-identical to the path that already existed. markEffectSettled (writer.ts:381-384) does:

const message = error === undefined ? undefined : this.#redactor.redact(error);

which is the same expression, verbatim, that markTargetSettled has used for a target's failure message since before this PR (writer.ts:206-209). An effect's error is redacted exactly as a target's error is; this PR introduces no new handling of it.

The residual is real, pre-existing, and documented. Redaction masks the secrets the run knows about — those declared through parameters or secret sources. A third party that echoes an unfamiliar credential back inside an error string would have that string persisted, for an effect and for a target alike. That is why TargetStateHandle carries the standing warning that run state is plain JSON read back by later runs and by zuke runs show, and why the writer's module header describes redaction as covering values it can recognise. Narrowing it further would mean not recording failure messages at all, which would cost the diagnosis this feature exists to preserve — an effect that failed to post a required check is precisely the incident where the message is the whole story.

Not adding the id to the suppress list in zuke.ts. "An error message reaches durable state" is a class of finding worth hearing again if it ever points at a path that genuinely differs from the target one; dismissing it permanently would hide that.

@totollygeek
totollygeek merged commit 909b09c into master Aug 10, 2026
10 checks passed
@totollygeek
totollygeek deleted the feat/core-durable-effects branch August 10, 2026 09:55
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.

1 participant