Skip to content

fix(runtime): stop publish-drafts disclosing driver and subscriber text on unhideError and rebindError - #8816

Merged
qq9340100 merged 3 commits into
mainfrom
claude/issue-8516-publish-drafts-undeclared-driver-text
Aug 15, 2026
Merged

fix(runtime): stop publish-drafts disclosing driver and subscriber text on unhideError and rebindError#8816
qq9340100 merged 3 commits into
mainfrom
claude/issue-8516-publish-drafts-undeclared-driver-text

Conversation

@qq9340100

Copy link
Copy Markdown
Collaborator

Fixes#8516

The two remaining undeclared-text fields on the POST /packages/:id/publish-drafts 200 body — the same response whose seedApplied field #8443 converted. Both closed, so this closes the card.

Honest framing, per the card: this is over-disclosure of driver/subscriber text on a success body, not a privilege escalation. Nothing here grants access; what it did was hand internal sentences to a caller who was already authorized to publish.

Reproduced first, as the card required

The card was explicit that both sites were read from source, not driven end to end — found by grepping for the string shape #8443's site used. Both were driven for real through HttpDispatcher.handlePackagesbefore anything was changed, and both reproduced. Measured on origin/main:

injectionpre-change field, on a 200
getMetaItems throws (the flip's app read)"unhideError": "SQLITE_ERROR: no such table: sys_metadata"
saveMetaItem throws on app 2 of 2the same text, beside "unhiddenApps": ["crm"]
a subscriber throws on the announce"rebindError": "TypeError: Cannot read properties of undefined (reading 'triggers') at AutomationPlugin.rebind (/srv/objectstack/packages/services/service-automation/dist/index.js:412:31)"

The announce is the worse of the two and the one no boundary could ever have caught: an internal stack frame plus a server filesystem path and the shipped module layout. Both fields ride a success body as data, so no HTTP boundary's 5xx message withhold can reach them — the disclosure had to be closed at the producer.

The fix

The rule is imported, never re-spelled: clientFacingFailureText from @objectstack/metadata-protocol, the one implementation #8443 exported, called at both sites. A caught sentence is quoted only when the error declared itself a 4xx client refusal (ADR-0112); anything else gets the stable sentence the field could already carry, and the original goes to the server log.

Both halves of the rule, because the two sites started in different states. The flip already logged its cause in full at error with an operator remedy, so only its payload changed and that log is untouched. The announce had no log line at all — withholding alone would have converted an over-disclosure into a silent failure, which is worse — so it gains one naming the cause, the concrete consequence (a newly published record-triggered flow does not bind its trigger until the process restarts) and the fix (re-run the idempotent publish, or restart).

That new line is warn, not error, on three independent grounds: nothing here claimed to persist and did not (the drafts are published, the flip is stored — what is lost is an in-memory re-sync); AGENTS.md's own worked example of a functional degradation is verbatim "a trigger is not armed"; and the sibling announce of this same event, MetadataPlugin._reloadAndAnnounce, already logs it at warn. announceKernelEvent is not in the check:durability-log-level vocabulary, and escalating would be the over-application AGENTS.md warns trains everyone to skim error.

The authored population — measured, not inherited

The judgement that decided the shape of #8333 and #8443: does either catch receive an authored population that declares nothing and would be blanked?

  • unhideError: yes, and it declares.saveMetaItem's refusals all carry 4xx — NOT_OVERRIDABLE/403, ITEM_LOCKED/403, OBJECT_OVERLAY_PACKAGE_MISMATCH/422, the org and destructive-change refusals — verified at each helper, and measured through the door: a locked app still tells its publisher which app and why, verbatim. The withhold blanks none of it, and the unhiddenApps half-flip report beside it is untouched.
  • rebindError: no.context.trigger dispatch is PROPAGATING, and all three subscribers of metadata:reloaded are platform code doing internal re-sync — resyncFlowsFromProtocol, resyncAuthoredHooks/...Actions, ingestReloadedObjects, the authored translation sync. What arrives is internal text an author cannot act on. A guard case pins the bound anyway: if a subscriber ever does declare 4xx, the positive list quotes it with no change here.

So no fix was needed at the producer end, and no safeParse-style declaration was called for — unlike #8443, neither catch has a population that had to be made quotable.

Tests

packages/runtime/src/domains/packages-flip-announce-disclosure.test.ts, 8 cases: a positive control, the three withholds, both halves of the log rule, and two [GUARD] cases pinning that a declared 4xx refusal is still quoted verbatim.

Reverse verification, both directions predicted before running:

  • domains/packages.ts reverted to origin/main — predicted 4 failed | 4 passed, measured 4 failed | 4 passed, every red failing on the text (expected 'SQLITE_ERROR: no such table: sys_metadata' to be 'visibility flip failed').
  • both call sites forced to withhold unconditionally (the "withhold everything" shortcut) — predicted 2 failed | 6 passed, measured 2 failed | 6 passed, exactly the two guards. Without them this file would be satisfied by blanking every message, which deletes the self-correcting refusals the positive list exists to preserve.

Three pre-existing pins in http-dispatcher.test.ts asserted the raw driver text on unhideError. Each was triaged individually and re-spelled, not weakened: every one has a live subject that survives untouched (reports-rather-than-throws; the error log's consequence + fix; the #5242 mid-loop split report), and in each the injected error is a bare undeclared Error — the population the rule withholds. The driver sentence is still asserted in each, now in the log line where it belongs.

Swept out-of-package for anything pinning these two field names or this response's shape: only CHANGELOG prose and one same-package success-path assertion. No consumer pins either field.

Verification

All at ae47a64d5, the final commit:

  • pnpm --filter @objectstack/runtime test158 files, 2401 tests, all passing; typecheck clean.
  • Gate families re-derived from the actual changed paths with scripts/pm/dispatch-gates.mjs, all green: check:nul-bytes, check:route-envelope, check:cross-package-test-inputs, check:changeset-gate-self-tests, check:objectui-changeset, check:query-options-erasure, check:type-check-coverage, check-adr-0087-registration, check-changeset-no-major, check-empty-changeset, plus check:durability-log-level added because this diff adds a log line inside a catch.
  • check:type-check-debt first refused to run (unbuilt closure — NOT MEASURED, not a pass). The full closure was built as lint.yml does and it was re-run: green, 33 ledger entries re-measured, none above its recorded number. Its one informational note (@objectstack/lint could be lowered by 1) is pre-existing and in a package this PR does not touch.

Generated by Claude Code


Generated by Claude Code

@vercel

vercelBot commented Aug 15, 2026

Copy link
Copy Markdown

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

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectstackIgnoredIgnoredAug 15, 2026 4:30am

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

  • content/docs/api/client-sdk.mdx(via packages/runtime)
  • content/docs/api/index.mdx(via @objectstack/runtime)
  • content/docs/api/wire-format.mdx(via @objectstack/runtime)
  • content/docs/automation/hook-bodies.mdx(via @objectstack/runtime)
  • content/docs/concepts/metadata-lifecycle.mdx(via @objectstack/runtime)
  • content/docs/concepts/north-star.mdx(via packages/runtime)
  • content/docs/data-modeling/drivers.mdx(via @objectstack/runtime)
  • content/docs/deployment/index.mdx(via @objectstack/runtime)
  • content/docs/deployment/production-readiness.mdx(via @objectstack/runtime)
  • content/docs/deployment/single-project-mode.mdx(via @objectstack/runtime)
  • content/docs/deployment/vercel.mdx(via @objectstack/runtime)
  • content/docs/getting-started/your-first-project.mdx(via @objectstack/runtime)
  • content/docs/kernel/cluster.mdx(via @objectstack/runtime)
  • content/docs/permissions/authentication.mdx(via @objectstack/runtime)
  • content/docs/permissions/authorization.mdx(via packages/runtime)
  • content/docs/permissions/system-context.mdx(via packages/runtime)
  • content/docs/plugins/packages.mdx(via @objectstack/runtime)
  • content/docs/protocol/kernel/http-protocol.mdx(via @objectstack/runtime)
  • content/docs/protocol/kernel/index.mdx(via @objectstack/runtime)
  • content/docs/protocol/kernel/lifecycle.mdx(via @objectstack/runtime)

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

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

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.

@qq9340100Claude

Copy link
Copy Markdown
CollaboratorAuthor

ACCEPT — PM review, domain:cli seat

All four rulings hold, and the reproduce-first requirement earned its keep twice over.

Ruling 1 — reproduced before fixing, and reality was worse than the card described. Both sites driven for real through HttpDispatcher.handlePackages on origin/mainbefore any change. The card called these "driver text"; rebindError measured as an internal stack frame plus a server filesystem path plus the shipped module layout (/srv/objectstack/packages/services/service-automation/dist/index.js:412:31). Grepping alone would never have shown that, and it is the field no boundary could ever have caught — it rides a 200 body as data.

Ruling 2 — both halves, and the asymmetry handled exactly.unhideError's existing error log is untouched (payload only). rebindError had none, so it gains one naming cause, concrete consequence (a newly published record-triggered flow does not bind its trigger until restart) and fix. Withholding without that would have converted an over-disclosure into a silent failure — the specific trap in the dispatch.

The log-level reasoning is better than I would have specified: warn not error, on three independent grounds — nothing claimed to persist and did not (drafts published, flip stored; what is lost is an in-memory re-sync), AGENTS.md's own worked example of functional degradation is verbatim "a trigger is not armed", and the sibling announce in MetadataPlugin._reloadAndAnnounce already logs at warn. Noting that announceKernelEvent is outside check:durability-log-level's vocabulary, and that escalating would be the over-application which trains everyone to skim error, is the right instinct about a shared signal.

The authored-population judgement — measured both ways, which is what decided #8333 and #8443.unhideError: yes and it declaressaveMetaItem's refusals carry 4xx (NOT_OVERRIDABLE/403, ITEM_LOCKED/403, OBJECT_OVERLAY_PACKAGE_MISMATCH/422, org and destructive-change), verified per-helper and measured through the door: a locked app still tells its publisher which app and why, verbatim. rebindError: nocontext.trigger dispatch is PROPAGATING and all three metadata:reloaded subscribers are platform re-sync code. A [GUARD] case pins the bound anyway, so if a subscriber ever declares 4xx the positive list quotes it with no further change.

The second ablation is the sophisticated one. Reverting packages.ts (predicted 4 red / measured 4 red) shows the fix is present. Forcing both sites to withhold unconditionally (predicted 2 red / measured 2 red, exactly the guards) shows it is not over-broad — without those guards the file would be satisfied by blanking every message, which deletes the self-correcting refusals the positive list exists to preserve. Testing that a fix does not overshoot is rarer than testing that it exists.

The three pre-existing pins were re-spelled, not weakened — verified from the diff. Each keeps exact toBe equality; only the expected value moved, because the behaviour deliberately moved. In each the injected error is a bare undeclared Error — precisely the population the rule withholds — each pin's live subject survives untouched, and the driver sentence is still asserted, relocated to the log line where it now belongs. That is a pin tracking intended behaviour, not a demand being lowered.

check:type-check-debt refused on an unbuilt closure, was reported as NOT MEASURED rather than passed, then built and re-run green. Out-of-package sweep found no consumer pinning either field.

Flip held until every gate job concludes success on its own reading.


Generated by Claude Code

@qq9340100
qq9340100 marked this pull request as ready for review August 15, 2026 04:43
@qq9340100
qq9340100 added this pull request to the merge queueAug 15, 2026
Merged via the queue into main with commit 19db5faAug 15, 2026
27 checks passed
@qq9340100
qq9340100 deleted the claude/issue-8516-publish-drafts-undeclared-driver-text branch August 15, 2026 04:56
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.

publish-drafts has two more undeclared-driver-text fields on the same 200 body: unhideError and rebindError

2 participants

@qq9340100@claude