Skip to content

fix(spec): the external-federation error family declares its HTTP status, so a write refusal stops leaking as a bare 500 (#7739) - #7791

Merged
os-help merged 2 commits into
mainfrom
claude/issue-7739-external-write-forbidden-envelope
Aug 11, 2026
Merged

fix(spec): the external-federation error family declares its HTTP status, so a write refusal stops leaking as a bare 500 (#7739)#7791
os-help merged 2 commits into
mainfrom
claude/issue-7739-external-write-forbidden-envelope

Conversation

@os-help

@os-help os-help commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator

Fixes #7739

The defect

A write to a read-only federated external object was refused correctly on
the server. ExternalWriteForbiddenError names the datasource, its
schemaMode, and both flags that would be required, and nothing is applied —
ADR-0015's Gate 3 throws before the driver is reached at all. Only the envelope
was wrong: the refusal reached the client as a bare 500 INTERNAL_ERROR with
no code
, indistinguishable from the server falling over.

Root cause — the status, not the code

The code existed the whole way down. EXTERNAL_WRITE_FORBIDDEN is registered
in the ADR-0112 error-code ledger (error-code-ledger.zod.ts, under
@objectstack/spec), so the wire vocabulary was ready.

What the error did not carry was an HTTP status, and no exit can invent one.
So the refusal fell past every structured branch of mapDataError, matched no
message heuristic, and left through the terminal UNCLASSIFIED_FAULT — which
sanitises to 500 and, by design, ships no code the producer never declared.
The discriminator the client needed was dropped at the boundary for want of a
status to carry it.

The fix: the family declares its status, at the producer

packages/spec/src/shared/external-errors.ts gains one table beside the codes
it keys on, and each of the three error classes carries its value as status:

export const EXTERNAL_ERROR_HTTP_STATUS = {
  [EXTERNAL_ERROR_CODES.schemaMismatch]: 503,
  [EXTERNAL_ERROR_CODES.writeForbidden]: 403,
  [EXTERNAL_ERROR_CODES.schemaModeViolation]: 403,
} as const satisfies Record< ExternalErrorCode, number >;

Why at the producer rather than in a REST error map. ADR-0112 says the
producer names the condition, and every HTTP exit in this repo already agrees on
how to read one — status then statusCode, band 400-599:
mapDataError's declaredHttpStatus (#7525), resolveErrorResponse
(#5437/#5582), HttpDispatcher.errorFromThrown (#3867),
dispatcher-plugin.errorResponseBase, endpoint-executor, domains/actions,
plugin-hono-server. Declaring the status on the error is therefore what routes
the family through one place for every door at once; a branch in
rest-server.ts would have fixed one door and left the runtime dispatcher, the
endpoint executor and the CLI answering 500 for the same throw.

Same shape the repo already uses for this problem: service-analytics's
dataset-refusal.ts (#5367) and storage-service.ts's storageListRefusal
"one condition, one wire shape, chosen by the producer that knows".

rest-server.ts is untouched. #5949 records it at 8593 lines and a frequent
conflict site; this change adds zero lines to it.

Family mapping — and the status convention followed

The card asked for the family, not the instance, and for the 4xx choice to be
justified against the surrounding conventions.

code status reasoning
EXTERNAL_WRITE_FORBIDDEN 403 A policy refusal, not malformed input. The identical body succeeds the moment datasource.external.allowWrites and object.external.writable are both on, so 400/422 ("fix your request") would be a lie, and 409 ("conflict with current state") promises a retry that cannot help. 403 is what mapDataError already answers for a capability a flag switched off for the object — FEEDS_DISABLED, FILES_DISABLED, CLONE_DISABLED, RECORD_NOT_ACCESSIBLE — and what the standard catalog spells FORBIDDEN.
EXTERNAL_SCHEMA_MODE_VIOLATION 403 The same sentence about DDL rather than rows: schemaMode !== 'managed' forbids it, and no request the caller can rewrite changes that.
EXTERNAL_SCHEMA_MISMATCH 503 Deliberately not 4xx. Nothing about the request is wrong — the deployment's metadata and the remote table have diverged, only an operator can reconcile them, and it may clear. That is the reading ERR_DATASOURCE_UNAVAILABLE already gets ("the deployment cannot serve this object right now"); both are isExpectedDataStatus lifecycle outcomes rather than crashes. The 5xx band withholds the message by design, which is right here: this gate aborts boot, so its structured diffs audience is already operator-side, and the client still gets a code to branch on instead of a bare 500.

So the dispatch is one mapping; only the values differ, and the one that
differs is the one that is genuinely not a client error.

satisfies Record< ExternalErrorCode, number > makes a future gate that adds a
code without a status a compile error — which is the half that stops the
next QA run from refiling this card under a different code.

Verification

packages/rest/src/external-write-forbidden-envelope.test.ts (new, 9 cases)
drives the real CRUD routes in process against a protocol whose data verbs
are a real ObjectQL engine over an in-memory driver whose store the test
can read — not a rejecting mock.

That fixture choice is load-bearing. The card asks for two facts a
mockRejectedValue cannot pair: the refusal answers 403 with its code, and
nothing is applied
. A mock that rejects makes the second vacuously true, so it
would stay green against a future "fix" that got the status right by letting the
write through. Here §3 measures the store against a driver that can write,
and a CONTROL case flips the two opt-in flags and writes through the very same
route to prove the fixture is not inert. §3 also asserts the driver was never
reached at all, so a create-then-rollback "fix" fails even with an empty table.

packages/spec/src/shared/external-errors.test.ts gains the family pin: the map
covers exactly EXTERNAL_ERROR_CODES, every status is inside the 400-599 band
each exit reads, and each instance carries it as status.

Reverse verification

Direction predicted before running: RED on the unfixed producer, for the
reason the card describes. Measured by reverting external-errors.ts to
origin/main, rebuilding @objectstack/spec (the test imports dist, so
reverting the source alone would have proven nothing) and re-running:

Tests  7 failed | 2 passed (9)
AssertionError: expected 500 to be 403 // Object.is equality
  • §1 status / code / message-detail / object: RED — 500, and res.body.code
    undefined
  • §1 "not logged as an unhandled fault": RED, expected true to be false — the
    500 did emit [REST] Unhandled error, which is the operator-side half of the
    same symptom
  • §2 PATCH and DELETE: RED, same expected 500 to be 403
  • §3 POST "nothing applied": RED — but only on its leading status assertion;
    the store assertions in it were green before and after
  • The 2 survivors are direction-insensitive by construction, exactly as
    predicted: §3's "PATCH and DELETE reach the driver no more than POST does"
    (nothing was ever applied — the behaviour was never the bug) and §3's CONTROL
    case (the write-allowed arm, which this change does not touch). Recorded as
    measured rather than reshaped: a template demanding before-red on every
    assertion would have had those two cases lie about what they measure. They
    are asserted anyway because they are the half that stops a status-only "fix"
    from passing.

Scope

Not touched: #7663 (auth bodyless-500s), #7543 (raw TypeError leak), #5437
(5xx driver-throw passthrough) — same envelope-leak class, deliberately out of
scope. This change does not make them easier to close: each has a different
cause (a producer that writes no body, a native error name reaching a business
branch, a 5xx passthrough range), and none of them is a missing status
declaration.

.changeset/external-error-http-status.md added — the wire status of three
codes is user-visible. content/docs/releases/ untouched.

Local runs

command result
pnpm --filter @objectstack/spec test (full suite) 378 files / 9953 tests passed
pnpm --filter @objectstack/rest test (full suite) 92 files / 1471 tests passed
pnpm --filter @objectstack/rest test external-write-forbidden-envelope 1 file / 9 passed
pnpm --filter @objectstack/objectql test external-write-gate 11 passed
pnpm --filter @objectstack/runtime test external-validation 13 passed
pnpm --filter @objectstack/spec typecheck clean
pnpm --filter @objectstack/rest typecheck clean
eslint on the three changed/added files clean, exit 0
pnpm check:api-surface / check:export-origins current (both baselines regenerated)
pnpm check:error-code-casing 3646 files, no lowercase codes
pnpm check:nul-bytes 7131 files, clean

Generated by Claude Code

…#7739)

A write to a read-only federated external object was refused correctly —
`ExternalWriteForbiddenError` names the datasource, its `schemaMode` and both
flags that would be required, and nothing is applied — but reached the client
as a bare 500 INTERNAL_ERROR with no `code`, indistinguishable from a crash.

The `code` was never the missing half: `EXTERNAL_WRITE_FORBIDDEN` is already
registered in the ADR-0112 error-code ledger. The missing half was an HTTP
status, which no exit can invent — so the refusal fell past every structured
branch of `mapDataError` and left through the terminal `UNCLASSIFIED_FAULT`,
which sanitises to 500 and ships no code the producer never declared.

Fixed at the producer, for the whole `EXTERNAL_ERROR_CODES` family at once:
new `EXTERNAL_ERROR_HTTP_STATUS` maps every code to its status, and each error
class carries it as `status`. Every HTTP exit in the repo already resolves
`status` then `statusCode` (`declaredHttpStatus`, `resolveErrorResponse`,
`HttpDispatcher.errorFromThrown`, `dispatcher-plugin.errorResponseBase`,
`endpoint-executor`, `domains/actions`, `plugin-hono-server`), so one table
fixes every door; a branch in `rest-server.ts` would have fixed one.
`rest-server.ts` is untouched.

  EXTERNAL_WRITE_FORBIDDEN        403  policy refusal, not malformed input
  EXTERNAL_SCHEMA_MODE_VIOLATION  403  same, for DDL
  EXTERNAL_SCHEMA_MISMATCH        503  a deployment state, not the caller

`satisfies Record<ExternalErrorCode, number>` makes a future gate that adds a
code without a status a compile error.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0158ZQo7LiHSxGWpYKuPq1wu
@vercel

vercel Bot commented Aug 11, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 11, 2026 6:06pm

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/spec.

106 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/ai/agents.mdx (via @objectstack/spec)
  • content/docs/ai/skills-reference.mdx (via @objectstack/spec)
  • content/docs/ai/skills.mdx (via @objectstack/spec)
  • content/docs/api/client-sdk.mdx (via @objectstack/spec)
  • content/docs/api/environment-routing.mdx (via @objectstack/spec)
  • content/docs/api/error-catalog.mdx (via @objectstack/spec)
  • content/docs/api/error-handling-client.mdx (via @objectstack/spec)
  • content/docs/api/error-handling-server.mdx (via @objectstack/spec)
  • content/docs/api/index.mdx (via @objectstack/spec)
  • content/docs/automation/approvals.mdx (via @objectstack/spec)
  • content/docs/automation/connectors.mdx (via @objectstack/spec)
  • content/docs/automation/flows.mdx (via @objectstack/spec)
  • content/docs/automation/hook-bodies.mdx (via packages/spec)
  • content/docs/automation/hooks.mdx (via @objectstack/spec)
  • content/docs/automation/index.mdx (via @objectstack/spec)
  • content/docs/automation/webhooks.mdx (via @objectstack/spec)
  • content/docs/automation/workflows.mdx (via @objectstack/spec)
  • content/docs/concepts/architecture.mdx (via @objectstack/spec)
  • content/docs/concepts/design-principles.mdx (via packages/spec)
  • content/docs/concepts/index.mdx (via @objectstack/spec)
  • content/docs/concepts/metadata-driven.mdx (via @objectstack/spec)
  • content/docs/concepts/metadata-lifecycle.mdx (via packages/spec)
  • content/docs/concepts/north-star.mdx (via @objectstack/spec)
  • content/docs/data-modeling/analytics.mdx (via @objectstack/spec)
  • content/docs/data-modeling/drivers.mdx (via @objectstack/spec)
  • content/docs/data-modeling/external-datasources.mdx (via @objectstack/spec)
  • content/docs/data-modeling/field-types.mdx (via @objectstack/spec)
  • content/docs/data-modeling/fields.mdx (via @objectstack/spec)
  • content/docs/data-modeling/formulas.mdx (via @objectstack/spec)
  • content/docs/data-modeling/index.mdx (via @objectstack/spec)
  • content/docs/data-modeling/objects.mdx (via @objectstack/spec)
  • content/docs/data-modeling/queries.mdx (via @objectstack/spec)
  • content/docs/data-modeling/schema-design.mdx (via @objectstack/spec)
  • content/docs/data-modeling/seed-data.mdx (via @objectstack/spec)
  • content/docs/data-modeling/validation-rules.mdx (via @objectstack/spec)
  • content/docs/data-modeling/validation.mdx (via @objectstack/spec)
  • content/docs/deployment/cli.mdx (via @objectstack/spec)
  • content/docs/deployment/tenancy-modes.mdx (via @objectstack/spec)
  • content/docs/deployment/troubleshooting.mdx (via @objectstack/spec)
  • content/docs/deployment/validating-metadata.mdx (via @objectstack/spec)
  • content/docs/getting-started/build-with-claude-code.mdx (via @objectstack/spec)
  • content/docs/getting-started/common-patterns.mdx (via @objectstack/spec)
  • content/docs/getting-started/examples.mdx (via @objectstack/spec)
  • content/docs/getting-started/quick-reference.mdx (via @objectstack/spec)
  • content/docs/getting-started/quick-start.mdx (via @objectstack/spec)
  • content/docs/getting-started/your-first-project.mdx (via @objectstack/spec)
  • content/docs/kernel/cluster.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/auth-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/cache-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/data-engine.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/index.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/metadata-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/storage-service.mdx (via @objectstack/spec)
  • content/docs/kernel/index.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/data-service.mdx (via @objectstack/spec)
  • content/docs/kernel/runtime-services/email-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/examples.mdx (via @objectstack/spec)
  • content/docs/kernel/runtime-services/index.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/queue-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/sharing-service.mdx (via @objectstack/spec)
  • content/docs/kernel/runtime-services/sms-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/storage-service.mdx (via @objectstack/spec)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/spec)
  • content/docs/kernel/services.mdx (via @objectstack/spec)
  • content/docs/permissions/authorization.mdx (via @objectstack/spec)
  • content/docs/permissions/permission-sets.mdx (via @objectstack/spec)
  • content/docs/permissions/permissions-matrix.mdx (via @objectstack/spec)
  • content/docs/permissions/positions.mdx (via @objectstack/spec)
  • content/docs/permissions/rls.mdx (via @objectstack/spec)
  • content/docs/permissions/sharing-rules.mdx (via @objectstack/spec)
  • content/docs/permissions/system-context.mdx (via packages/spec)
  • content/docs/plugins/adding-a-metadata-type.mdx (via @objectstack/spec)
  • content/docs/plugins/development.mdx (via @objectstack/spec)
  • content/docs/plugins/index.mdx (via @objectstack/spec)
  • content/docs/plugins/packages.mdx (via @objectstack/spec)
  • content/docs/protocol/backward-compatibility.mdx (via @objectstack/spec)
  • content/docs/protocol/diagram.mdx (via packages/spec)
  • content/docs/protocol/kernel/config-resolution.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/http-protocol.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/i18n-standard.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/index.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/lifecycle.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/plugin-spec.mdx (via @objectstack/spec)
  • content/docs/protocol/knowledge.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/index.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/query-syntax.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/schema.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/security.mdx (via packages/spec)
  • content/docs/protocol/objectql/state-machine.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/actions.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/concept.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/index.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/layout-dsl.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/record-alert.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/widget-contract.mdx (via @objectstack/spec)
  • content/docs/ui/actions.mdx (via @objectstack/spec)
  • content/docs/ui/apps.mdx (via @objectstack/spec)
  • content/docs/ui/create-vs-edit-form.mdx (via @objectstack/spec)
  • content/docs/ui/dashboards.mdx (via @objectstack/spec)
  • content/docs/ui/field-grouping-and-order.mdx (via @objectstack/spec)
  • content/docs/ui/forms.mdx (via @objectstack/spec)
  • content/docs/ui/index.mdx (via @objectstack/spec)
  • content/docs/ui/public-data-collection.mdx (via @objectstack/spec)
  • content/docs/ui/setup-app.mdx (via @objectstack/spec)
  • content/docs/ui/translations.mdx (via @objectstack/spec)
  • content/docs/ui/views.mdx (via @objectstack/spec)

7 release-owned page(s) also reference the affected code. These are read-only:

  • content/docs/releases/implementation-status.mdx (via @objectstack/spec)
  • content/docs/releases/index.mdx (via @objectstack/spec)
  • content/docs/releases/v12.mdx (via @objectstack/spec)
  • content/docs/releases/v13.mdx (via @objectstack/spec)
  • content/docs/releases/v16.mdx (via @objectstack/spec)
  • content/docs/releases/v17.mdx (via @objectstack/spec)
  • content/docs/releases/v9.mdx (via @objectstack/spec)

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

os-help commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator Author

CI is red on Test Core (1/3), and it is not this PR. Blocked on main, tracked as #7802 — holding rather than re-queueing.

The failure is in @objectstack/spec:

FAIL src/data/api-methods-batch-conformance.test.ts:139
AssertionError: expected [ Array(1) ] to deeply equal []
+ "sys_api_key: [get, list, update] grants single-record writes but not 'bulk'
+  — /batch and the *Many routes will 405
+  (packages/platform-objects/src/identity/sys-api-key.object.ts)"

Test Files  1 failed | 377 passed (378)
     Tests  1 failed | 9952 passed (9953)

This PR does not touch sys_api_key, platform-objects, or anything apiMethods-shaped. The offender is 52200b4 (PR #7769, "give API-key revoke/restore a working product route"), merged to main today at 14:30:34Z — roughly 35 minutes before this PR's CI ran. It added update to sys_api_key's enable.apiMethods so the Setup UI's Revoke button would stop 405-ing at the ADR-0049 method gate.

Identified from the file's commit history, not inferred from timing.

Why it landed invisibly. The conformance scan lives in packages/spec; the object it judges lives in packages/platform-objects. #7769 touched platform-objects and plugin-auth and not spec, so the affected-subset PR CI never ran the scan. The first PR to touch spec afterwards inherits the red — that is this one. The developer here ran the full spec suite locally and got 378 files / 9953 tests green, on a worktree branched from main before 14:30.

Not re-queueing. A re-queue cannot fix a failure that lives on the base branch, and each attempt rebuilds every PR behind it in the batch. This PR stays as-is until #7802 is resolved; when main goes green I will merge main in and let CI re-run.

#7802 is not a "add bulk and move on". #7769 deliberately kept create/delete off sys_api_key, and bulk-updating API keys is plausibly something that object should not offer — in which case the conformance rule is what needs the opt-out, not the object. That call belongs to whoever owns the ADR-0049 method-gate surface.

Nothing is requested of this PR. Everything else here is green.


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

external-datasource-federated-read: external-write refusal (ExternalWriteForbiddenError) leaks to the client as a bare 500 INTERNAL_ERROR

2 participants