Skip to content

fix(core): close the operation host audit follow-ups - #414

Merged
nickruigrok merged 2 commits into
mainfrom
fix/operation-host-audit-follow-ups
Oct 11, 2026
Merged

nickruigrok merged 2 commits into
mainfrom
fix/operation-host-audit-follow-ups

Conversation

@nickruigrok

Copy link
Copy Markdown
Contributor

Follow-ups from the post-merge audit of the operation host (GRA-442, after #400, #402 and #405). The audit found the composition sound. These close the gaps it named.

What changes

  1. The read limits apply per run (apps/core/src/operations.ts).
    • Each run of the call's own function gets a fresh read budget, shared by everything it calls: a query run again for stale reads, or a mutation's next attempt.
    • Before this, reruns spent from one budget. A 100-read query rerun twice under contention answered the non-retryable operation.read_limit on its third run. A mutation that staged about 700 KB could run out of stagedReadMaxBytes across attempts.
    • Nested calls still share their run's budget, so fan-out stays bounded (callMaxNested and the depth limit). The rerun limit bounds the whole call.
  2. A refused staged write fails the mutation.
    • The failure is reproduced first: a write the schema refuses, not awaited (.catch(() => null)), rejected before the handler settled. The mutation committed without it and answered ok.
    • Now the host remembers the first write it refused, and the mutation fails with that error, whether or not App code awaited or caught it.
    • Staging refusals mean the change set is wrong: invalid fields, an access denial, an unknown table, too large. A mutation that wants a conditional write checks before writing.
    • Nested calls and resource requests keep their catchable failures.
  3. The guard limit is enforced at the read. The read that would assert a revision for a 101st record now answers data.guard_limit. Before, the mutation ran in full and then failed at commit with an opaque data.invalid.
  4. Cleanup.
    • Two stale comments are corrected (actions do run; #withGenerations became #readIn).
    • callMaxDepth now comes from appCallLimits.depth.
    • Refusals that can never fire are gone; a channel without a refusal falls back to operation.not_found.
    • settle now cancels its deadline timer once the calls settle.
  5. SDK design notes (on ActionCtx, packages/sdk/src/server.ts):
    • an action isn't atomic across its calls: a query's reads don't fence a later mutation;
    • derived-key places follow the data: the same mutation at a place with other arguments answers submission.key_conflict.

Tests (workerd, through the real store and host)

New in apps/core/test/operations.test.ts, under "audit follow-ups". Each one reproduced its finding before the fix:

  • a 100-read query rerun twice under contention answers, after 3 runs;
  • a mutation staging 6×100 KB with 20 reads answers on its second attempt;
  • a floating refused write fails the mutation with data.invalid, and nothing is stored;
  • 100 asserted revisions pass, and 101 answer data.guard_limit at the read.

Mutation-checked: removing the per-run reset fails the first two tests; ignoring the refused write fails the third; removing the read-time guard check fails the fourth.

754 targeted tests pass (actions, operations, operation-roles, data-stores, data-store-receipts, store-reader, store-queries, shared, sdk), run with at most 3 workers. vp check is clean.

Each run of a call gets its own read budget, shared by what it calls, so
reruns under contention still answer. A staged write the host refused
fails the mutation whether App code awaited it or not. The read that
would assert one guard more than a commit holds is refused with
data.guard_limit. Stale comments go, callMaxDepth comes from
appCallLimits, dead refusals are dropped, settle clears its timer, and
the SDK documents that actions aren't atomic across calls and that
derived-key places follow the data.
@nickruigrok
nickruigrok marked this pull request as ready for review October 11, 2026 16:49
@nickruigrok

Copy link
Copy Markdown
Contributor Author

@greptileai review

@greptile-apps

greptile-apps Bot commented Oct 11, 2026 •

Copy link
Copy Markdown

RetriggerConfidence Score: 5/5

[High impact] The PR appears safe to merge; no new actionable issues were found.

Summary

This PR gives each top-level query run and mutation attempt a fresh read budget. It also prevents mutations from committing after a refused write.

  • Each query run and mutation attempt gets a fresh read budget.
  • A refused write makes the whole mutation fail.
  • Mutations stop when a read would exceed the revision-guard limit.
  • SDK action notes explain call boundaries and replay order.
  • Host limits use shared settings and settle waits stop when calls finish.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart TD
  A[Mutation attempt] --> B[Fresh read budget and staged writes]
  B --> C[Run handler and settle pending calls]
  C --> D{Any write refused?}
  D -->|Yes| E[Fail without committing]
  D -->|No| F[Check current roles and commit]
  F -->|Reads changed| A
  F -->|Committed| G[Return result]
Loading

Reviews (2) · Last reviewed commit: "fix(core): cap guards only in mutations ..." · Reviewed by Greptile

Comment thread apps/core/src/store-reader.ts Outdated
Comment thread apps/core/src/operations.ts Outdated
… the channel

A plain query never commits its guards, so asserting more revisions
than a commit can check no longer fails it. A write refused at the
channel boundary (a value that isn't JSON, for example) is now
remembered like one refused by staging, so a mutation that catches it
can't commit the rest. Refused nested calls and resource requests stay
catchable.
@nickruigrok

Copy link
Copy Markdown
Contributor Author

@greptileai review

@nickruigrok
nickruigrok merged commit fe72a48 into main Oct 11, 2026
9 checks passed
@nickruigrok
nickruigrok deleted the fix/operation-host-audit-follow-ups branch October 11, 2026 17:14
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