Skip to content

fix(rest): the direct-mount package door withholds a leaky 5xx message (#8086) - #8130

Merged
hotlong merged 1 commit into
mainfrom
claude/issue-8086-package-door-5xx-sanitize
Aug 12, 2026
Merged

fix(rest): the direct-mount package door withholds a leaky 5xx message (#8086)#8130
hotlong merged 1 commit into
mainfrom
claude/issue-8086-package-door-5xx-sanitize

Conversation

@hotlong

@hotlong hotlong commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Fixes #8086

Applies the 5xx leak rule in sendThrownError (packages/rest/src/package-routes.ts) — option B, and only option B, per the PM ruling on the card.

STEP 1 — the premise was Unverified, and it is now verified

The card said plainly: "No end-to-end reproduction: I did not boot a REST server and force a sys_metadata write failure through POST /packages/publish." Grep proved the filter was absent, which is a different claim from the leak being reachable. Only the second justifies a fix, so it was measured first.

Reproduced in process against unfixed code, with a real ObjectQL engine, a real ObjectStackProtocolImplementation, and a driver that fails every sys_metadata access the way a missing table does. Nothing hand-built: the message, its interpolated driver text and its status all come from shipping code, and the route is the one a client calls.

DELETE /api/v1/packages/:id answered, verbatim:

HTTP 500
{
  "success": false,
  "error": {
    "code": "INTERNAL_ERROR",
    "message": "SQLITE_ERROR: no such table: sys_metadata"
  }
}

The path, off the captured stack:

Object.find                     (the failing driver)
_ObjectQL.find                  packages/objectql/src/engine.ts:7164
  ...deletePackage              packages/metadata-protocol/src/protocol.ts:12056
  handler                       packages/rest/src/package-routes.ts:528
  -> catch -> sendThrownError -> sendError

The producer is not the one the card named, and the real one is better. The card pointed at Failed to persist customization overlay to sys_metadata: ... and Failed to delete customization overlay: .... The first of those no longer exists in protocol.ts — it survives only as prose in a docblock, its branch having been deleted by #5264. The second exists but sits behind deleteMetaItem. What actually carries a driver line through this door is one level up: a full uninstall (no ?version=) routes to protocol.deletePackage, whose first database touch, this.engine.find('sys_metadata', { where }), sits outside that method's per-item try — its catch only wraps the deleteMetaItem loop below it. So the driver error propagates whole rather than being collected into failed[]. The premise holds; the specific producer named in the card had moved.

STEP 2 — the ruled fix

const thrown = resolveThrownHttpError(error);
// The dispatcher twin's expression, byte for byte -- one rule, two doors.
const message = thrown.status >= 500 && looksLikeInternalErrorLeak(thrown.message)
  ? INTERNAL_ERROR_MESSAGE
  : thrown.message;

Same predicate and same replacement constant (packages/types/src/error-leak.ts) as the two siblings on this surface: HttpDispatcher.error (#3867) and rest-server.ts's three call sites. Deliberately not a new rule — the rule this surface already follows, applied at the door #5437 / PR #5464 missed because this registrar does not go through resolveErrorResponse at all.

Scope honoured:

  • packages/types/src/response-envelope.ts untouched — that is option A, escalated, not ruled.
  • metadata-protocol producers untouched — that is option C, a separate card.
  • No status and no error.code changed; details untouched. Message only.
  • 4xx untouched.

STEP 3 — pins

New: packages/rest/src/package-door-5xx-message-sanitization.test.ts, 50 cases. Every case asserts the positive sanitized shape (INTERNAL_ERROR_MESSAGE) plus the full ADR-0112 envelope (code AND status) — never merely that the message "changed" or "no longer contains the table name", both of which pass for any rewrite including a worse one.

  1. The live reproduction, now sanitized — plus a premise guard asserting the protocol really does still throw the driver line, so the section cannot pass over a path nothing traverses.
  2. A leaky 5xx is withheld at all four catch sites, across the band: a bare driver throw, the overlay producer verbatim, a constraint dump, a statement prefix, and a declared 503 with a registered code (which keeps both and loses only the prose).
  3. A 5xx that does not look like a leak passes through unchanged — the predicate does the deciding. Without this, the whole file is satisfied by a blanket if (status >= 500), which is a different rule that would delete every self-authored server-fault sentence.
  4. The 4xx over-block guard. Chosen to trip the predicate on purpose[tenant_scope_required] prose naming a foreign key, a 409 DESTRUCTIVE_CHANGE naming a unique constraint — so only the status keeps them intact. Plus the bound from both sides: 499 verbatim, 500 withheld.
  5. The direct-mount REST package door answers 500 INTERNAL_ERROR for coded 4xx errors the dispatcher twin maps correctly #8016 non-regression — the coded mapping still answers, and details survive the withhold.

STEP 4 — reverse verification, direction predicted before running

Predicted: the leak cases go red, and every pass-through and 4xx case stays green, because the predicate is what decides and neither trips it.

Observed with the two-line withhold removed — 23 failed | 27 passed, and the 23 are exactly 1 live + (4 sites x 5 leaks) + the 500 half of the bound case + the details case. All 12 pass-through and all 12 over-block cases stayed green.

The raw driver text is visible in the failure output, which is the point:

AssertionError: expected 'SQLITE_ERROR: no such table: sys_meta...' to be 'Internal server error'
Expected: "Internal server error"
Received: "SQLITE_ERROR: no such table: sys_metadata"

Expected: "Internal server error"
Received: "Failed to delete customization overlay: SQLITE_ERROR: no such table: sys_metadata"

Restored; sha256sum -c confirms both files byte-identical to before the removal.

Known ceiling, stated so a green suite is not read as full coverage

looksLikeInternalErrorLeak is a heuristic over the message and recognises no Postgres relation "..." does not exist phrasing — measured false. That dialect's line therefore still travels, through this door and through the dispatcher twin alike, since both run the same predicate. The two doors still agree, which is what this card was about; what remains is a property of the shared heuristic.

Widening it here would be a new rule at one door and would re-create the divergence this closes. The cure is option C. This is pinned as a live case that goes red the day either the predicate learns the phrasing or C lands — deliberately, so a reader comes back and re-reads it rather than consuming a green suite as proof the door is covered.

Also updated

packages/runtime/src/package-door-error-parity.test.ts — comment only. Its docblock asserted "the REST package door applies no such filter and ships the thrown message verbatim", which this change makes false. Corrected in place with the reason it still does not assert message parity (disclosure is each boundary's own rule; each door pins its own half).

Verification

Check Result
pnpm --filter @objectstack/rest test 101 files, 1698 passed
pnpm --filter @objectstack/rest typecheck clean
pnpm check:type-check-debt OK, exit 0 — @objectstack/rest measured at its recorded 155, absent from the "can be lowered" list, so the new test file added zero tsc errors against a zero-margin ceiling (full closure built first, as the gate demands)
pnpm check:route-envelope green incl. --self-testpackage-routes.ts stays at its 0/0/0 ratchet; every body still goes through the shared sendError
node scripts/check-nul-bytes.mjs OK, 7373 files
check-empty-changeset / check-adr-0087-registration / check-changeset-no-major / check-error-code-casing all exit 0
eslint --no-inline-config on changed files exit 0
runtime/package-door-error-parity.test.ts 8 passed

Changeset included — user-visible response-message change.


Generated by Claude Code

#8086)

A driver failure under `/api/v1/packages` returned the driver's own line to
the API client. Reproduced end to end before fixing — a real `ObjectQL`
engine and a real `ObjectStackProtocolImplementation` whose driver fails the
`sys_metadata` read the way a missing table does:

    DELETE /api/v1/packages/:id
    HTTP 500
    {"success":false,"error":{"code":"INTERNAL_ERROR",
     "message":"SQLITE_ERROR: no such table: sys_metadata"}}

A full uninstall (no `?version=`) routes to `protocol.deletePackage`, whose
first database touch sits outside its own per-item `try`, so the driver line
propagates whole into this registrar's catch-all and onto the wire.

Not a new rule — the rule this surface already follows, at the door that was
missed. The dispatcher twin (`HttpDispatcher.error`) has run this expression
since #3867 and `rest-server.ts` runs the same predicate at three call sites;
#5437 / PR #5464 closed the class one seam over and could not reach this
registrar, which does not go through `resolveErrorResponse` at all. Two doors
serve `/api/v1/packages` and this one mounts first in production.

Only the prose is withheld: `status`, `code` and `details` are untouched, so
#8016's coded mapping still answers. 4xx is deliberately untouched — a
refusal's message is caller-facing by design.

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

vercel Bot commented Aug 12, 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 12, 2026 5:25pm

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

  • content/docs/ai/connect-mcp.mdx (via @objectstack/rest)
  • content/docs/api/error-handling-server.mdx (via @objectstack/rest)
  • content/docs/api/index.mdx (via @objectstack/rest)
  • content/docs/permissions/authentication.mdx (via @objectstack/rest)
  • content/docs/permissions/system-context.mdx (via packages/rest)
  • content/docs/plugins/index.mdx (via @objectstack/rest)
  • content/docs/plugins/packages.mdx (via @objectstack/rest)
  • content/docs/protocol/kernel/http-protocol.mdx (via @objectstack/rest)
  • content/docs/protocol/kernel/i18n-standard.mdx (via packages/rest)

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

  • content/docs/releases/implementation-status.mdx (via @objectstack/rest)
  • content/docs/releases/v12.mdx (via @objectstack/rest)
  • content/docs/releases/v17.mdx (via @objectstack/rest)

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.

hotlong commented Aug 12, 2026

Copy link
Copy Markdown
Contributor Author

PM review — domain:cli seat (#6024): accepted. Enqueueing once CI is green.

Step 1 did its job, and what it caught was my ruling being stale

The card was marked Unverified and I made reproducing the leak a precondition rather than a formality. It paid immediately, and not in the direction I expected:

The card pointed at Failed to persist customization overlay to sys_metadata: ... — that branch no longer exists in protocol.ts, its code deleted by #5264; it survives only as prose in a docblock.

My ruling repeated that producer as fact. Anyone who had "just fixed it" against the named producer would have been sanitizing a path nothing traverses — a filter over an unreachable leak, which is the exact shape this lane spends its days deleting. The real carrier is one level up and is a genuinely better find: a full uninstall routes to protocol.deletePackage, whose first database touch — this.engine.find('sys_metadata', { where }) — sits outside that method's per-item try, whose catch only wraps the deleteMetaItem loop below it. So the driver error propagates whole instead of being collected into failed[].

And it was measured, not argued: a real ObjectQL engine, a real protocol implementation, a driver failing sys_metadata, and the verbatim body off the wire —

HTTP 500  { "error": { "code": "INTERNAL_ERROR", "message": "SQLITE_ERROR: no such table: sys_metadata" } }

That is the third time today a dispatch's most specific sentence was the wrong one, and the second time the stale detail was mine. The instruction to verify rather than inherit is now carrying more weight than the leads themselves.

The pins reason about what would make them vacuous

Two of the 50 cases exist because the developer asked what a weaker suite would still pass:

  • Pass-through of a non-leaky 5xx"Without this, the whole file is satisfied by a blanket if (status >= 500)", which is a different and worse rule that would delete every self-authored server-fault sentence. Exactly right: without that case, "sanitize everything" and "sanitize leaks" are indistinguishable to the suite.
  • The 4xx over-block guard, chosen to trip the predicate on purpose[tenant_scope_required] prose naming a foreign key, a 409 DESTRUCTIVE_CHANGE naming a unique constraint — so that only the status keeps them intact. A 4xx case that did not trip the predicate would prove nothing about the status condition. Plus the bound from both sides: 499 verbatim, 500 withheld.

And the live case carries a premise guard asserting the protocol really does still throw the driver line, so the section cannot silently start passing over a path nothing traverses.

Reverse verification is accounted arithmetically rather than gestured at: 23 failed / 27 passed, and the 23 are enumerated in advance — 1 live + (4 sites × 5 leaks) + the 500 half of the bound case + the details case — with all 12 pass-through and all 12 over-block cases green. Being able to predict which tests fail, and match the count, is a much stronger claim than "it went red."

The known ceiling — accepted, and here is the binding reading

looksLikeInternalErrorLeak recognises no Postgres relation "..." does not exist phrasing — measured false. That dialect's line still travels, through this door and the dispatcher twin alike, since both run the same predicate.

Reporting a gap in the thing you just shipped, in the PR that ships it, is the behaviour this seat wants and rarely gets. The scoping is also correct: widening the predicate here would be a new rule at one door and would re-create the very divergence this card closes. The two doors still agree; what remains is a property of the shared heuristic, and its cure is option C.

Binding reading of that pinned case, for whoever meets it next: it documents a known gap, it does not pin desired behaviour. When it goes red, the gap has closed — either the predicate learned the phrasing or C landed. Delete or invert it then. Do not "repair" it back to green. This is the #7314 shape (a pin that looks like a mistake and is actually load-bearing), and it is the failure mode most likely to erase this finding.

Scope held exactly: response-envelope.ts untouched (option A, escalated, not ruled), metadata-protocol untouched (option C, separate card), message only — no status, no error.code, details intact, 4xx untouched. The predicate and replacement constant are the siblings' byte for byte, so this is the existing rule reaching the door #5437 / PR #5464 missed, not a new one.

Also caught without being asked: runtime/package-door-error-parity.test.ts's docblock asserted "the REST package door applies no such filter and ships the thrown message verbatim" — which this change makes false. Corrected in place rather than left as a comment that had quietly become a lie.

Not raised: @objectstack/rest measured at its recorded 155 and absent from the "can be lowered" list — zero new tsc errors against the repo's only zero-margin ceiling. check:route-envelope green with package-routes.ts still at its 0/0/0 ratchet. 101 files / 1698 tests pass.

Flipping ready and enabling auto-merge once CI converges — both steps.


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.

The direct-mount package door ships 5xx messages verbatim — no leak heuristic, unlike the dispatcher twin and unlike rest-server's own /data exit

2 participants