Skip to content

Commit 33b52fe

Browse files
os-litantclaude
andauthored
fix(metadata-protocol): classify a producer-marked refusal at the producer, so the 503 stays non-quoting (#12536) (#12936)
* fix(metadata-protocol): classify a producer-marked refusal at the producer, 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 * test(metadata-protocol): record the measured ablation directions in the 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 * chore(gates): record the new test file's pinned engine doubles in the 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 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7b97895 commit 33b52fe

4 files changed

Lines changed: 765 additions & 5 deletions

File tree

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
---
2+
"@objectstack/metadata-protocol": patch
3+
---
4+
5+
fix(metadata-protocol): a metadata app's MARKED refusal is classified as a refusal at the producer, instead of being wrapped as a store outage (#12536)
6+
7+
A metadata app's sandboxed hook on `sys_metadata` can refuse a read and mark its
8+
refusal with `userMessage` — the #9934 producer-side opt-in where the field's
9+
presence *is* the marking. Every such refusal was handed to
10+
`metadataStoreUnavailableError`, which builds a fresh `Error` carrying only
11+
`code` / `status` / `cause`, and `declaredUserMessage` reads the **top level**
12+
and never `cause`. The mark therefore died at the producer, on the whole
13+
`getMetaItem` / `getMetaItems` read family and on `deletePackage`.
14+
15+
**FROM** — a marked hook refusal and a `connect ECONNREFUSED` came out of
16+
`getMetaItems` as the same envelope, and no consumer could tell them apart:
17+
18+
```
19+
MARKED hook refusal -> 503 SERVICE_UNAVAILABLE declaredUserMessage=undefined
20+
"The metadata store could not be read, ..."
21+
driver fault -> 503 SERVICE_UNAVAILABLE declaredUserMessage=undefined
22+
"The metadata store could not be read, ..."
23+
```
24+
25+
**TO** — the failure is classified once, at the producer, before it is wrapped:
26+
27+
```
28+
MARKED hook refusal -> 400 (or the status the hook declared for itself)
29+
userMessage = the author's text, verbatim
30+
driver fault -> 503 SERVICE_UNAVAILABLE (byte-for-byte unchanged)
31+
"The metadata store could not be read, ..."
32+
```
33+
34+
`deletePackage`'s per-item path follows the same classification: `failed[]` and
35+
`cleanups[]` gain an optional `userMessage` member, present exactly when the
36+
item's own failure declared the mark. Those rows ride inside a
37+
`PACKAGE_DELETE_PARTIAL` 400's `details`, where no HTTP boundary can carry a
38+
`userMessage` on their behalf, so the channel has to exist on the row itself.
39+
`deleteMetaItem`'s two re-wrap exits carry the mark forward for the same reason
40+
they already carry a catalogued `code`.
41+
42+
Maintainer ruling 2026-08-27, option B. The declined alternative — forwarding
43+
the mark *across* the 503 — is not implemented and is pinned against: the
44+
store-unavailable door still quotes nothing from `cause`, and a test asserts the
45+
underlying failure text appears in no field outside it.
46+
47+
**Behaviour that does not change.** An unmarked failure keeps the #8136 503
48+
exactly as it was: same status, same code, same sentence, same `cause`
49+
relocation. A blank, whitespace-only or non-string `userMessage` is not a
50+
declaration, so nothing invents a marked refusal, and the #3821 generic
51+
substitution stays in force for everything unmarked.
52+
53+
**For hook authors.** A refusal that names its own `status` / `statusCode`
54+
keeps it (#7867). One that names none is answered 400 — the same default the
55+
REST sandbox door already applies to an undeclared hook refusal (#9967) — with
56+
the catalogued code for that status; declare `status` (and a catalogued `code`)
57+
when a different classification is wanted.

0 commit comments

Comments
 (0)