Skip to content

fix(metadata-protocol): stop interpolating raw driver text into client-facing messages (#8136) - #8334

Merged
os-zhuang merged 6 commits into
mainfrom
claude/issue-8136-metadata-protocol-driver-text
Aug 13, 2026
Merged

fix(metadata-protocol): stop interpolating raw driver text into client-facing messages (#8136)#8334
os-zhuang merged 6 commits into
mainfrom
claude/issue-8136-metadata-protocol-driver-text

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Part of #8136

Option C of #8086, at the producer. This lands the measured half of the sweep — the uninstall/overlay-delete cluster, which is the cluster #8130 reproduced end to end — and records the full producer inventory below so the remainder is a scoped follow-up rather than an unknown. Deliberately a partial sweep, stated rather than silently narrowed; the remainder is filed as #8333.

STEP 0 — the premise, re-measured on today's main

The card is a day old and protocol.ts moved under four merges since it was written, so the premise was re-measured rather than assumed. Producers were located by reading the file, never by grepping the strings the cards quote — the producer #8086 originally named no longer exists and survives only as docblock prose.

PM assumption 1 — confirmed by reading.deletePackage's first database touch is at method-body level:

constrows=(awaitthis.engine.find('sys_metadata',{ where }))asany[];

There is no enclosing try; the method's per-item catch wraps only the deleteMetaItem loop below it. So a driver error propagates whole and undeclared, rather than being collected into failed[].

PM assumption 2 — the disclosure still reproduces. Measured, not inferred: see the reverse verification in STEP 3, where the unfixed producer on today's main puts the driver line both in the thrown message and inside the failed[] array that rides on a 400.

STEP 1 — the rule, stated once at the producer

A caught error's sentence is quoted back to a caller only when that error declared itself a client-facing refusal — a 4xx status in the ADR-0112 envelope. Anything undeclared (a bare Error from a driver) or declared a server fault gets a stable sentence naming the operation that failed, and the original error rides on cause so the operator's log still receives it whole.

This is a positive list, not a negative heuristic. That is the whole point of option C: correctness that does not depend on having enumerated the world's SQL engines.

Not!declaresServerFault(err). That looks equivalent and would reinstate the entire defect: a bare Error from a driver declares nothing, so it fails that test and would be quoted — and a bare driver Error is precisely the measured case. The two predicates answer different halves and neither is the other's negation.

looksLikeInternalErrorLeak is untouched, per the card's binding guard. The three downstream belts stay exactly as they are; what changed is that this particular producer no longer needs them.

STEP 2 — the full producer inventory

Every site in packages/metadata-protocol/src/protocol.ts where caught error text reaches a client-facing sentence or payload. Log-only sites (console.warn) are correct as they are and are excluded — that is where the driver text is supposed to go.

Fixed in this PR — the uninstall / overlay-delete cluster:

#SiteSinkClient reach
P1deletePackage, first engine.find('sys_metadata')thrown message, undeclared 500DELETE /api/v1/packages/:id body message
P2deleteMetaItem, repository-path re-wrapthrown messageoverlay delete, and P4/P5 downstream
P3deleteMetaItem, legacy raw-engine re-wrapthrown messagesame
P4deletePackagecleanups[].errorresponse dataPACKAGE_DELETE_PARTIAL400, inside details
P5deletePackagefailed[].errorresponse datasame — clean derivatively, because P2/P3 are

P4 and P5 are the half no HTTP boundary can reach: they ride on a 400 inside details, not the message, so #8130's 5xx withhold and #8016's mapping both pass over them. That asymmetry is the argument for fixing the producer rather than adding a fourth belt.

Not fixed here — filed as #8333:

#SiteSink
P6publishPackageDraftsfailed[].errorbatch-publish response data
P7publish side effects materialize.failures[].errorsame
P8materializeApplied.error (feeds P7)same
P9seed-apply errorseed response data
P10duplicatePackagefailed[].errorcopy response data
P11revertCommitfailed[].errorrevert response data
P12revertToCommitfailed[].errorderivative of P11
P13migrateStoredMetadatarows[].reasonmigration report

Two sites that look like members and are not: the copy path's the flow does not canonicalize and conversion refused strings interpolate authored schema-validation text, not driver text. They are left alone deliberately.

STEP 3 — reverse verification, direction predicted BEFORE running

Predicted: reverting protocol.ts to origin/main turns the withhold cases (sections 1, 2, 4) RED, while section 0 — which measures the shared predicate this card does not touch — and the two [GUARD] cases stay GREEN, because a declared 4xx refusal is quoted verbatim in both directions.

Observed, exactly as predicted: 13 failed | 3 passed, the 3 green being section 0 and the two [GUARD] cases.

The raw driver text is visible in the failure output, which is the point — including the data-path leak that no boundary can reach:

Expected: "sys_metadata"
Received: {"success":false,"deletedCount":0,"failedCount":1,"deleted":[],
"failed":[{"type":"action","name":"acct_overlay",
"error":"Failed to delete customization overlay: relation \"sys_metadata\" does not exist"}],
"cleanups":[]}
Expected: "cleanup failed"
Received: "ORA-00942: table or view does not exist"

Restored afterwards and proved byte-identical with git hash-object (7f943bd7... before and after), not by an insertion count.

The over-block bound was measured separately: with declaresClientRefusal forced to return false unconditionally — "withhold everything" — 2 failed | 14 passed, and the 2 are exactly the [GUARD] cases. That is what stops this fix being satisfied by a blanket replacement that would delete the self-correcting refusals #4277 exists for.

STEP 3b — the operator half, and the second pin this change re-judged

Withholding text from the client is only half the contract; the other half is that the driver line still reaches the operator. packages/rest/src/rest-5xx-message-sanitization.test.ts (#5437's pins) asserts exactly that, and two of its cases went red here. They were not a fixture nit and were not repaired blindly — the wire assertions in them still passed, and only the loggedText(...) half failed, which is the interesting direction.

Measured with a throwaway probe at that seam rather than assumed:

[REST] Unhandled error: Error(Failed to delete customization overlay for object/showcase_account …)
CAUSE:: SQLITE_ERROR: no such table: sys_metadata

So the driver line does still reach the log, on cause; the helper simply read a.message and never walked the chain. Disposition: the helper now traverses cause, so the log guarantee keeps measuring, and the stale comment claiming "the only way this text can reach the log is if the protocol really did interpolate the driver error into the message" is corrected in place — that case's own prediction ("if the producer ever stops doing that, this goes red") came true, and it is re-pointed rather than deleted.

Proved non-vacuous: with the producer's two cause assignments stripped and dist rebuilt, those two cases go RED again.

⚠️ The dist rebuild is load-bearing and cost a false green: packages/rest imports the built@objectstack/metadata-protocol, so the first strip-and-rerun came back green purely because dist still held the fixed build. Judged before rebuilding, that would have been recorded as "the log guarantee is not pinned", which is the opposite of the truth.

STEP 4 — the #8130 pin, inverted rather than repaired

#8130 left a deliberately-red-in-future case asserting that protocol.deletePackage really does let the driver line out. This is that day. Per the card's binding instruction the pin is inverted, not mended — making it green again would mean re-teaching the protocol to leak. Its end-to-end walk (real ObjectQL, real protocol, real route) is kept; what changed is what it observes at the far end: a declared 503 SERVICE_UNAVAILABLE instead of an undeclared 500 whose prose the door had to withhold.

⚠️ This does not retire the door's withhold, and the rest of that file still pins it in full. sendThrownError guards every producer reaching that registrar, not just this one.

Scope honoured

protocol.ts has a concurrent editor in #7654 (the getMetaItems read merge — disjoint region). main was merged twice, most recently at c797473d9, and protocol.ts was untouched by those commits. Nothing was hand-resolved around another agent's work; the merge queue arbitrates the rest.

Remainder

P6–P13 are filed as #8333 (unassigned, unlabelled for triage) rather than swept blind, for a measured reason: several of them carry authored refusals as well as driver text. publishPackageDrafts' failed[].error is the clearest — it ships alongside code and structured spec-validation issues, and applying the 4xx rule there without first measuring which publish-path refusals actually declare a status risks blanking exactly the authoring feedback the [GUARD] section exists to protect. That measurement is a round of work in its own right, and doing it badly is worse than scheduling it.

Verification

CheckResult
pnpm --filter @objectstack/metadata-protocol test78 files, 1133 passed
new protocol.driver-text-disclosure.test.ts16 passed, and 13 red under the reverted producer
pnpm --filter @objectstack/rest test110 files, 1814 passed
pnpm --filter @objectstack/runtime test150 files, 2306 passed
pnpm --filter @objectstack/types test9 files, 246 passed
pnpm check:cross-package-test-inputsOK
pnpm check:durability-log-levelOK
pnpm check:filter-alias-parityOK
pnpm check:nul-bytesOK
pnpm check:error-code-casingOK
pnpm check:authz-resolverOK
pnpm check:changeset-gate-self-testsOK
pnpm check:meta-type-normalizedOK
pnpm check:objectui-changesetOK
pnpm check:query-options-erasureOK
node scripts/check-changeset-no-major.mjsOK
pnpm check:type-check-debtOK, exit 0 — re-measured with the full closure built, "surplus: none", so the new test file added zero tsc errors against a zero-margin ceiling

The consumer sweep direction is stated so it can be reviewed: metadata-protocol's downstream consumers (the prefix...@objectstack/metadata-protocol form) are 30+ packages, which is the full farm and CI's job. The radius that actually asserts the changed strings was bounded by grep to metadata-protocol, rest and types — all three run above, plus runtime, which owns the package-door parity pin.

Five of the gate families above were not in the dispatch prompt's list — they came from re-deriving scripts/pm/dispatch-gates.mjs against the actual changed paths, which is why that re-derivation step is worth its one command.

Changeset included — user-visible response-message change. No new error code (SERVICE_UNAVAILABLE is the standard catalog's own 503 code and this file already raises it for exactly this condition), no status change on the overlay-delete re-wraps, no error.code invented.


Generated by Claude Code

@vercel

vercelBot commented Aug 13, 2026

Copy link
Copy Markdown

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

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectstackIgnoredIgnoredAug 13, 2026 6:19am

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/metadata-protocol.

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

  • content/docs/concepts/metadata-lifecycle.mdx(via @objectstack/metadata-protocol)
  • content/docs/kernel/services-checklist.mdx(via @objectstack/metadata-protocol)
  • content/docs/protocol/kernel/http-protocol.mdx(via @objectstack/metadata-protocol)

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

  • content/docs/releases/v9.mdx(via @objectstack/metadata-protocol)

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.

…oducer dispatch contract (#8136)
`check:engine-double-contract` flagged the engine double in
protocol.driver-text-disclosure.test.ts (line 195): its `update()` accepted
call shapes `ObjectQL.update` refuses, which is how #4434 shipped a dead REST
route with its suite green. The double's `delete()` was already pinned via
`assertEngineDeleteDispatch`; only `update` was loose.
Open the fake's `update` with `assertEngineUpdateDispatch(data, options)` from
`@objectstack/metadata-core` — never `@objectstack/objectql`, which depends on
this package, so that import would close a cycle turbo refuses. The file
already imported the delete predicate from metadata-core, so this needs no
package.json change and no lockfile churn.
The shrink-only baseline ledger is untouched: this is the real pin, not an
exemption. update doubles go 90 -> 91 pinned.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012WMpuAfA2KSdDjGF6tm1bH
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@os-zhuang@claude