Skip to content

fix(action,http,core)!: one caller's idempotent response went to another, and ten codes paged the on-call for a caller's mistake - #112

Merged
sebyx07 merged 2 commits into
mainfrom
fix/http-action-idempotency-errormap
Aug 17, 2026
Merged

sebyx07 merged 2 commits into
mainfrom
fix/http-action-idempotency-errormap

Conversation

@sebyx07

@sebyx07 sebyx07 commented Aug 17, 2026 •

Copy link
Copy Markdown
Contributor

The http and action remainder of slices 02 (tier 2–3 bugs) and 06 (concurrency &
lifecycle), plus the gate step slice 02 asks for by name.

Four agents on disjoint file sets in one checkout: packages/action · packages/http (error-map +
input) · packages/http/server.ts + packages/core/lifecycle.ts · scripts/.

auth and entity are not here because they are already done. Every finding in these two
slices naming those packages was closed by #104 and #106 — verified file-by-file before this wave
was scoped, which is why this PR is 43 files rather than the ~70 estimated.

The two that matter

One caller's idempotent response was replayed to another. idempotencyKeyFor(actionName, key)
namespaced by action name only — no actor — so alice's stored charge response went to bob whenever
bob sent the same key; with a differing payload bob got X_IDEMPOTENCY_CONFLICT instead, which is
a cross-actor denial of service against any key. And Headers.get() answers '' — never null —
for Idempotency-Key:, so a blank header was a live key every blank sender shared.

Ten codes answered 500 and paged the on-call for a caller's mistake. The status table is closed:
no row means DEFAULT_STATUS, and stages.ts reports every status >= 500 to the error monitor. A
reused key, an expired cursor, a weak password, a duplicate signup — each woke somebody. The sharpest
case was the framework contradicting its own published contract: action/http.ts:151 declares
'409' for X_IDEMPOTENCY_CONFLICT in OpenAPI while the runtime answered 500.

Findings

# Package Defect Status
1 action idempotency key omits the actor → cross-actor replay + conflict DoS fixed, reproduced
2 action a blank Idempotency-Key is a live shared key fixed, new X_IDEMPOTENCY_KEY_INVALID
3 http 10 caller-caused codes fall through to 500 and page 13 rows added
4 http+core a second server after a drain binds a port it can never serve from fixed, new X_LIFECYCLE_DRAINED
5 http ?__proto__= replaces the parsed object's prototype fixed
6 schema (via 5) key in record let a schema coerce an inherited function fixed by the same change
7 http an empty issues array reads as validation success → 500 + page fixed
8 action the third copy of FNV-1a/32 over client-chosen input fixed
9 action SETTLE/FAIL carry no status fence; a late settle overwrites fixed, both stores
10 docs 04-error-contract.md documents a status, an owner and a code that do not exist fixed

The gate step, and why it is a ratchet

Slice 02 asks for this in three places — "the error-map completeness check is a verify step, not a
convention"
. Built, with one deliberate departure from the specified rule.

The rule as written — every code owned by a tier ≤ 4 package needs a row — flags 237 of 394
codes
, including X_MIGRATION_DESTRUCTIVE and X_CRON_INVALID. That is a step an agent disables
in week one. Whether a code can reach a request turns out not to be derivable:
X_MIGRATION_DESTRUCTIVE and X_TENANCY_CROSS_DENIED are the same tier and the same shape, and
blanket index.ts re-exports collapse import reachability to "the whole package" at every boundary.
Every declaration-derived signal available (OAUTH_ROUTE_STATUS, OpenAPI problemResponse — one
call site, HTTP_ERROR_TITLES) would together have caught 0 of the 10.

So: a ratchet, the same expectedRed idiom the tracked-app gate already uses. 226 undecided
codes pinned with a reason per group; the list may only shrink. A pin means "nobody has decided
yet", never "this can never reach a request".

Three codes — X_ERROR_STATUS_MISSING, X_ERROR_STATUS_BACKLOG_STALE, X_ERROR_STATUS_UNKNOWN_CODE
(a mistyped row reads as enforced and maps nothing). It reads the exported ERROR_STATUS object
rather than parsing source, so Object.hasOwn distinguishes "no row" from "row = 500" — which
statusFor structurally cannot.

It caught two codes on its first run against live work — both added by its own teammates in this
same PR, which is the best evidence available that it is aimed correctly.

Premises the hive falsified

  • "Eleven caller-caused codes." Ten. X_QUERY_NOT_PAGEABLE is a developer bug by its own
    fix: — an edit to the read's own SQL, nothing the caller sends changes it — and
    X_IDEMPOTENCY_REPLAYED_FAILURE surfaces as itself only when the first attempt carried no code.
    Both got explicit 500 rows so the check sees a decision, not an omission.
  • "Follow the shape scheduler.ts:153 uses." That line is a colon-joined string over
    framework-controlled values with no actor in it — copying it would have produced exactly the
    ambiguous encoding the brief warned against.
  • "SQL_ACK/SQL_NACK are in the same file." They are in jobs/driver-pg-sql.ts, and they
    fence on id AND state because jobs' settle API carries the job id. Idempotency's does not, so
    only the state half was available.
  • "Reuse oauth-route-status.test.ts's error-map parser." It has none — it imports statusFor.
    No parser was written at all; the check imports the live table.
  • "Six sites carry a bare 30_000." Eleven. All now point at one REPO_SCAN_TIMEOUT_MS.
  • The prototype bug is not the textbook one. out['__proto__'] on a plain object reads a value
    that is not undefined, so the first occurrence takes the repeated-key branch and assigns an
    array. Object.prototype is never mutated — it is per-object prototype replacement.

Deliberately not fixed

  • action's stableStringify still folds -0, NaN and Infinity. It cannot take query's
    fix: it is also the OpenAPI document serializer, and emitting the bare token NaN would make a
    published spec invalid JSON. Reachable — JSON.parse('{"n":-0}') yields -0, so {n:-0} and
    {n:0} share one requestHash and one job dedupe key. Needs the spec serializer split from the
    hash canonicalizer. Known-Gaps row landed.
  • invoke()'s cache bust is not transaction-aware. A real design change and a db seam tier-3
    action deliberately does not import; no tracked app calls transaction( in apps/.
    Known-Gaps row landed.
  • admin (tier 5) is out of the completeness check's scope, though X_ADMIN_DENIED reaches a
    browser. The dashboard is dev-only and refused in production, so its 500 pages nobody; widening
    costs ~80 more pins for no signal.
  • The last idempotency race — a straggler landing while the replacement reservation is in
    flight — needs the reservation id in settle, i.e. a public IdempotencyStore signature change.
    Disproportionate for a LOW finding.

Breaking

idempotencyKeyFor takes a required third Actor argument · the stored key's shape changed, so a
retry crossing the deploy boundary re-runs the handler on the shared Postgres store (truncate x_idempotency makes that honest) · Idempotency-Key is enforced at the 255 chars OpenAPI already
published · action's fingerprint is SHA-256/16, changing job-handle.ts's dedupe key ·
markReady() throws on a drained lifecycle instead of declining silently.

Codes

X_IDEMPOTENCY_KEY_INVALID, X_LIFECYCLE_DRAINED, X_ERROR_STATUS_MISSING,
X_ERROR_STATUS_BACKLOG_STALE, X_ERROR_STATUS_UNKNOWN_CODE — all in wiki/Error-Codes.md,
manifest regenerated (394 codes, 29 packages).

Gate

bun run verify green first run. bun run scripts/reference-app-gate.ts — both tracked apps on
their ratchet.

Note on review: CodeRabbit reported "Review rate limited" on #110 and left no comments. If that
persists here, the local gate and the app ratchet are the only checks that actually ran.


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

…her, and ten codes paged the on-call for a caller's mistake

The http and action remainder of audit slices 02 and 06, plus the gate step
slice 02 asks for by name. auth and entity are absent because they are already
closed — every finding naming them landed in #104 and #106, verified before
this wave was scoped.

`idempotencyKeyFor(actionName, key)` namespaced by action name only, with no
actor anywhere, so alice's stored `charge` response was returned to bob whenever
bob sent the same key; a differing payload gave bob X_IDEMPOTENCY_CONFLICT
instead, which is a cross-actor denial of service against any key. And
`Headers.get()` answers '' rather than null for `Idempotency-Key:`, so a blank
header was a live key every blank sender shared.

The status table is closed, so a code with no row falls to 500 — and stages.ts
reports every status >= 500 to the error monitor. Ten caller-caused codes were
in that state: a reused key, an expired cursor, a weak password, a duplicate
signup. The sharpest was the framework contradicting its own published
contract, action/http.ts:151 declaring '409' for X_IDEMPOTENCY_CONFLICT while
the runtime answered 500.

Nothing would have caught the eleventh, so the errors step gained a fourth host
rule. The specified predicate — every code owned by a tier <= 4 package needs a
row — flags 237 of 394, which is a step an agent disables in week one; and
whether a code can reach a request is not derivable, since
X_MIGRATION_DESTRUCTIVE and X_TENANCY_CROSS_DENIED are the same tier and the
same shape and blanket re-exports collapse import reachability to the whole
package. So it is a ratchet on the expectedRed idiom: 226 undecided codes
pinned with a reason, the list may only shrink, and a pin says "nobody has
decided yet" rather than "this can never reach a request". It caught two codes
on its first run, both added by its own teammates in this PR.

Also: a second server after a drain bound a port it could never serve from and
kept accepting connections after its own stop() returned; ?__proto__= replaced
the parsed query object's prototype, which also let a schema coerce an inherited
function through `key in record`; an empty issues array read as validation
success and reached a handler as an impossible undefined; the third copy of
FNV-1a/32 over client-chosen input, here backing both the idempotency
requestHash and the job dedupe key; and an unfenced settle overwriting a record
already replaced.

BREAKING CHANGE: `idempotencyKeyFor` takes a required third Actor argument; the
stored key's shape changed, so on the shared Postgres store a retry crossing the
deploy boundary re-runs the handler inside the 24h window (`truncate
x_idempotency` makes that state honest); `Idempotency-Key` is now enforced at the
255 characters OpenAPI already published; action's `fingerprint` is SHA-256/16,
changing job-handle.ts's dedupe key; `markReady()` throws X_LIFECYCLE_DRAINED on
a drained lifecycle instead of declining silently.

New codes: X_IDEMPOTENCY_KEY_INVALID, X_LIFECYCLE_DRAINED,
X_ERROR_STATUS_MISSING, X_ERROR_STATUS_BACKLOG_STALE, X_ERROR_STATUS_UNKNOWN_CODE.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RBwWKBJkiogA4mDaJiJf3D
@coderabbitai

coderabbitai Bot commented Aug 17, 2026 •

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

You’ve reached a temporary PR review limit under our Fair Usage Limits Policy.

Your recent review volume is higher than typical usage, so adaptive limits are currently applied.

Next review available in: 58 minutes

Limit details: You’ve used all 1 included review currently available under your plan. You completed 78 included PR reviews in the past 7 days; at that activity level, included reviews refill at 1 review per hour.

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 1efce59a-c6ad-48ae-a8c0-e6278ce96835

📥 Commits

Reviewing files that changed from the base of the PR and between 63a95fe and 70fab9f.

📒 Files selected for processing (44)
  • CHANGELOG.md
  • docs/architecture/04-error-contract.md
  • docs/plans/2026/08/16/101-deep-dive-bug-audit/status.yml
  • framework.manifest.json
  • packages/action/CLAUDE.md
  • packages/action/README.md
  • packages/action/src/audit.test.ts
  • packages/action/src/errors.ts
  • packages/action/src/http.test.ts
  • packages/action/src/idempotency-failure.test.ts
  • packages/action/src/idempotency-key.test.ts
  • packages/action/src/idempotency-key.ts
  • packages/action/src/idempotency-memory.ts
  • packages/action/src/idempotency-postgres.test.ts
  • packages/action/src/idempotency-postgres.ts
  • packages/action/src/idempotency.test.ts
  • packages/action/src/idempotency.ts
  • packages/action/src/index.ts
  • packages/action/src/invoke.ts
  • packages/action/src/stable.test.ts
  • packages/action/src/stable.ts
  • packages/action/src/telemetry.test.ts
  • packages/core/src/lifecycle-errors.ts
  • packages/core/src/lifecycle.test.ts
  • packages/core/src/lifecycle.ts
  • packages/http/src/error-map.test.ts
  • packages/http/src/error-map.ts
  • packages/http/src/request-query.test.ts
  • packages/http/src/request.test.ts
  • packages/http/src/request.ts
  • packages/http/src/server.test.ts
  • packages/http/src/server.ts
  • packages/http/src/validate.test.ts
  • packages/http/src/validate.ts
  • scripts/boundaries.test.ts
  • scripts/error-map-backlog.ts
  • scripts/error-map.test.ts
  • scripts/error-map.ts
  • scripts/lib/run.ts
  • scripts/manifest.test.ts
  • scripts/verify.test.ts
  • scripts/verify.ts
  • wiki/Error-Codes.md
  • wiki/Known-Gaps.md
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/http-action-idempotency-errormap

Comment @coderabbitai help to get the list of available commands.

auth and entity needed nothing — every finding naming them was already closed by
#104 and #106, verified file-by-file before #112 was scoped. That is what took
the PR from an estimated ~70 files to 43.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RBwWKBJkiogA4mDaJiJf3D
@sebyx07
sebyx07 merged commit 54920a4 into main Aug 17, 2026
5 checks passed
@sebyx07
sebyx07 deleted the fix/http-action-idempotency-errormap branch August 17, 2026 06:08
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