Skip to content

fix(metadata-protocol): classify a producer-marked refusal at the producer, so the 503 stays non-quoting (#12536) - #12936

Merged
os-litant merged 3 commits into
mainfrom
claude/issue-12536-classify-marked-refusal-at-producer
Aug 28, 2026
Merged

os-litant merged 3 commits into
mainfrom
claude/issue-12536-classify-marked-refusal-at-producer

Conversation

@os-litant

Copy link
Copy Markdown
Collaborator

Fixes #12536

Implements the maintainer's 2026-08-27 ruling (option B, verbatim 「同意」): a
producer-marked application refusal must not be wrapped as a store-unavailable
error at all. The wrapper classifies at the producer — a marked refusal travels its
own refusal category with the author's text intact, and a genuine store fault keeps
#8136's deliberately non-quoting 503. Both prior rulings stand; they stop colliding
at this seam.

⛔ The declined fallback — forwarding the mark across the 503 — is not
implemented, and is pinned against. Nothing from cause crosses that door.

Reproduced first, driving real code (not read off the source)

Measured on the branch point aef1b7e64, through the real
ObjectStackProtocolImplementation over an engine whose sys_metadata read rejects
with a hook refusal carrying userMessage (the #9934 authoring shape):

getMetaItems / MARKED hook refusal
  -> status=503 code=SERVICE_UNAVAILABLE declaredUserMessage=undefined
     message="The metadata store could not be read, so whether this item exists is unknown. ..."
getMetaItem  / MARKED hook refusal
  -> status=503 code=SERVICE_UNAVAILABLE declaredUserMessage=undefined
     message="The metadata store could not be read, ..."
getMetaItems / driver fault (connect ECONNREFUSED 10.0.0.5:5432)
  -> status=503 code=SERVICE_UNAVAILABLE declaredUserMessage=undefined
     message="The metadata store could not be read, ..."          <- byte-identical
deletePackage / MARKED refusal on the per-item delete
  -> failed[0] = { type, name, error: "Failed to delete customization overlay for
     object/acct. The metadata store rejected the delete; ..." }   <- no channel at all

The author's text existed only on cause, which declaredUserMessage never reads.

The same harness after the change:

getMetaItems / MARKED hook refusal
  -> status=400 code=VALIDATION_ERROR
     declaredUserMessage="Your Acme trial plan does not include custom objects. ..."
     message=[the same author text]   cause=[the hook's own error, intact]
getMetaItems / driver fault
  -> status=503 code=SERVICE_UNAVAILABLE declaredUserMessage=undefined
     message="The metadata store could not be read, ..."          <- unchanged
deletePackage / MARKED refusal
  -> failed[0] = { type, name, error: [#8136's sentence, unchanged],
                   userMessage: "Your Acme trial plan does not ..." }

What changed

metadataReadFailureError(cause) — one classifying door in front of the wrapper.
A mark present (read through declaredUserMessage, never an inline probe) builds the
refusal; anything else goes to metadataStoreUnavailableError, which is untouched,
byte for byte
. Routed at both throw sites the card names: the read guard
rethrowUnlessMetadataStoreUnprovisioned (the whole getMetaItem / getMetaItems /
lock / history / audit family) and deletePackage's own overlay read.

markedApplicationRefusalError(cause, userMessage) — the refusal category. The
marked text is the message; it is the only thing taken from the cause, because it is
the one string the producer declared is addressed to the end user. Status and code are
resolved through the imported resolveThrownHttpError, never re-spelled — a hook that
names its own status/statusCode keeps it (#7867), one that names none is answered
400, which is error-response.ts's own declared ?? 400 for an undeclared sandbox
hook refusal (#9967) rather than a number chosen here, and an uncatalogued code
demotes so ApiErrorSchema's closed union (ADR-0112 D4) cannot receive a driver's
dialect.

The per-item path. DeletePackageResponse.failed[] and UninstallCleanupOutcome
gain an optional userMessage, populated from the item's own failure. Those rows ride
inside a PACKAGE_DELETE_PARTIAL 400's details, where no HTTP boundary can carry a
userMessage on their behalf, so the channel has to exist on the row.

Named explicitly rather than slipped in — carryDeclaredUserMessage on
deleteMetaItem's two re-wrap exits.
A re-wrap that drops the mark destroys the
channel exactly as completely as building a fresh error does, and those two exits sit
on the ruled per-item path: without the carry, the failed[] channel above is
reachable only from the lock read and is dead for the actual delete — which is where a
sys_metadata hook refusal lands. It is one line each, the exact sibling of the
carryCatalogedErrorCode those exits already call, and it copies only the marked
field: the sentence selection, status and code at both exits stay #8136's, unchanged.
Evidence that the scope is bounded: overlayDeleteFailureMessage is not touched, and
§3's driver-fault case pins that an unmarked failure's row is byte-identical.

The falsifiable criterion, both halves

packages/metadata-protocol/src/protocol.marked-refusal-classification.test.ts (+17 tests).

  • Marked half — the author's text arrives verbatim as declaredUserMessage, on
    getMetaItems, getMetaItem, the draft read, deletePackage's read, failed[] and
    cleanups[].
  • Fault half — an explicit NEGATIVE PIN: the underlying failure text appears in no
    field outside the door. The scan walks every own property of the thrown error (plus
    message / name / stack) and the resolveThrownHttpError projection a boundary
    actually ships, with cause excluded on purpose — that is the operator channel.
    It also pins that the 503 does not so much as own a userMessage property.
  • Positive control for that zero, and it is not a substring of the term under test:
    three disjoint sentinels (driver text, author mark, hook diagnostic) with a pairwise
    non-containment guard that throws at load; the same scanner is proven to find a
    sentinel carried on a non-message field, and to find the mark on the marked
    refusal. Without that, "outside the door is clean" could be a dead scan.

Reverse verification — ablation restoring protocol.ts from the branch point,
mutation confirmed on disk by blob hash + anchored counts, restored with
git checkout HEAD -- on an absolute path under a trap, proven by a matching blob
hash and an empty git diff HEAD. No rebuild is owed: the subject is imported by
relative path from inside its own package, and that is measured (the pre- and post-fix
runs of the same harness differ with no package build between them), not assumed.

Predicted §1 red / §3 marked red / §2 green throughout. Measured 11 red, 6 green —
and the prediction was wrong about §2's granularity: two of §2's cases speak about the
marked side and must go red. The two genuinely fault-only cases (the byte-for-byte
envelope and the negative pin) stayed green, and those two are the real over-reach
bound. Recorded in the file header as measured rather than tidied.

Ruled sweep of the sibling wrappers — scanned, deliberately NOT fixed

Findings are filed by the PM, not absorbed here. Two shapes, over
packages/metadata-protocol/src/** non-test sources:

Shape A — a wrapper that builds a new error around a caught one (sets .cause): 4
sites, 0 still mark-destroying after this PR.
metadataStoreUnavailableError is now
reached only for unmarked failures; the other three carry. Inspected and not findings
because they structurally cannot carry a #9934 mark: throwMetadataServiceUnavailable
(its cause is built from a string[], there is no thrown object) and the four
ConflictError → METADATA_CONFLICT re-wraps (they wrap a repository-produced type,
not application code). The remaining ~61 new Error + status/code builders in the
package are the protocol's own originating refusals — no caught error, nothing to lose.

Shape B — failure reported as response DATA, where no boundary can carry a
userMessage: 17 sinks, 2 carry the mark after this PR, 15 still drop it.
Same shape
as failed[], in nine other methods:

site method
protocol.ts:1698 toRowApiError (batch row message)
protocol.ts:14708, :14738, :14840 migrateStoredMetadata (reason)
protocol.ts:15575 runPublishSideEffects
protocol.ts:15683 applySeedBodies
protocol.ts:16599, :16611, :16747 publishPackageDrafts
protocol.ts:16884 discardPackageDrafts
protocol.ts:17452, :17466, :17543 duplicatePackage
protocol.ts:18385 revertCommit
protocol.ts:18523 rollbackToPackageCommit

Verification

Run at b68b09808, the final commit.

  • pnpm --filter @objectstack/metadata-protocol test — 143 passed | 2 skipped (145)
    files; 2001 passed | 10 skipped (2011) tests
    . Branch-point baseline for the same
    package: 144 files / 1984 tests.
  • Downstream consumers (pnpm --filter '...@objectstack/metadata-protocol' typecheck
    — the prefix form, i.e. the packages that depend on this one): 44 of 79 workspace
    projects in scope, green, 0 error TS.
  • pnpm check:type-check-debt — --re-measure: OK — 31 ledger entr(ies) re-measured in 412.0s, 1570 raw tsc error(s) total, none above its recorded number ·
    surplus: none — every entry sits exactly at its measurement, so any new error is red.
    The new test file therefore added zero type errors, and this package's ledger note
    shows its measurement does read *.test.ts.
  • pnpm check:engine-double-contract — green; the new doubles route through
    assertEngineDeleteDispatch / assertEngineFindOnePredicate, and the gate asked for
    the pinned ledger to learn about them by name (--write, 2 rows added or grown, 0
    lost
    — purely additive, no baseline raised).
  • Gate families re-derived from the actual diff with scripts/pm/dispatch-gates.mjs
    after the last edit; the ledger commit pulled in seven families no earlier derivation
    named (agent-test-spelling, bash32-floor, cli-command-ids, entry-guard,
    parse-guard, pnpm-filter-targets, watch-hint-literal). All 30 path-derived plus
    the convention-triggered families ran green.
  • scripts/pm/check-half-states.mjs exits 3 = NOT MEASURED (no GitHub credential in
    this container) — not a red, and no reading either way.
  • Repo-wide pnpm lint was not run locally; CI owns that run. Declared narrowing,
    not a claim.

Generated by Claude Code


Generated by Claude Code

claude added 3 commits August 28, 2026 08:50
…ducer, not as a store outage

A metadata app's sandboxed hook on `sys_metadata` can refuse a read and mark its
refusal with `userMessage` (#9934 — presence IS the marking). Every such refusal
was handed to `metadataStoreUnavailableError`, which builds a fresh Error
carrying only code/status/cause; `declaredUserMessage` reads the top level and
never `cause`, so the mark died at the producer. A marked refusal and a driver
outage left `getMetaItems` as the same 503 with the same sentence.

The wrapper now classifies first: a marked refusal travels its own refusal
category with the author's text verbatim, and an unmarked failure keeps #8136's
deliberately non-quoting 503, byte for byte. `deletePackage`'s per-item
`failed[]` / `cleanups[]` follow the same classification and gain the optional
`userMessage` member the rows had no channel for.

The declined alternative — forwarding the mark across the 503 — is not
implemented: nothing from `cause` crosses that door, and a negative pin with a
positive control asserts the underlying failure text appears in no field
outside it.

Maintainer ruling 2026-08-27 (option B).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UjujZN219uFzBhSYfMykCd
…he file header

The prediction was wrong about section 2's granularity — two of its cases speak
about the marked side and must go red on an unfixed producer. Recorded as
measured rather than reconciled.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UjujZN219uFzBhSYfMykCd
… contract ledger

check:engine-double-contract asked for it by name: the new doubles route
through assertEngineDeleteDispatch / assertEngineFindOnePredicate, and the
pinned ledger has to learn about them or it never protects this file.
Regeneration is additive — 2 rows added or grown, 0 lost.

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

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/metadata-protocol, touching 11 documentable anchor(s).

2 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/concepts/metadata-lifecycle.mdx (via ObjectStackProtocolImplementation (symbol))
  • content/docs/kernel/services-checklist.mdx (via deleteMetaItem (symbol))

⛔ 2 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v16.mdx (via ObjectStackProtocolImplementation (symbol))
  • content/docs/releases/v17.mdx (via ObjectStackProtocolImplementation (symbol), deletePackage (symbol), userMessage (symbol))

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.

What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 7 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 6228c1f0ef3aad1946763b543e9fb207b8bf7de2 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 59dd8bab4969f4e9e3dc1a38e0e2f1810f0bc8bd — the merge of head b68b09808d4f19b38dcc828d4fd5f862b4dfc5cb into base 6228c1f0ef3aad1946763b543e9fb207b8bf7de2, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 59dd8bab4969f4e9e3dc1a38e0e2f1810f0bc8bd && git checkout 59dd8bab4969f4e9e3dc1a38e0e2f1810f0bc8bd
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 6228c1f0ef3aad1946763b543e9fb207b8bf7de2 b68b09808d4f19b38dcc828d4fd5f862b4dfc5cb && git checkout -B drift-repro 6228c1f0ef3aad1946763b543e9fb207b8bf7de2 && git merge --no-ff b68b09808d4f19b38dcc828d4fd5f862b4dfc5cb

node scripts/docs-audit/affected-docs.mjs --json 6228c1f0ef3aad1946763b543e9fb207b8bf7de2

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 6228c1f0ef3aad1946763b543e9fb207b8bf7de2 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

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

2 participants