fix(data-objectstack): validate the ADR-0010 protection envelope at the layered() boundary - #6061
Merged
yinlianghui merged 1 commit intoAug 24, 2026
Conversation
…he layered() boundary `MetadataClient.layered()` cast ten wire fields of the metadata protection envelope straight through — no parse, no allowlist, no default — over a raw `res.json()` body. The consumer opens the lock banner on `lock !== 'none'`, true for any non-`none` value, so a server sending an unknown lock state opened the amber box, drew the padlock and rendered an empty title. The boundary now runs `GetMetaItemLayeredResponseSchema.safeParse`. On the conforming path the values are the producer's schema output and the ten assertions are gone. `safeParse`, never `parse`: rejecting an unrecognised dialect would answer a newer server with a blank page, which is worse than the wrong render. Unrecognised values are still forwarded and are named in a new optional `MetadataLayered._unrecognized` — "pass through and label", extending to the whole envelope the treatment chosen for `lock` alone. The check is per field, which is what makes it a degrade: the whole-object `safeParse` is all-or-nothing (measured on spec 17.2.0 — one unknown `lock` yields no `data` at all), so the failure branch re-checks each key against that same schema's `shape[key]` and only the offending field loses its type. Also fixed, because it defeats the same ruling: a 200 whose body was a bare JSON string or number rejected the promise with a `TypeError: Cannot use 'in' operator` from the envelope-detection guard's bare truthiness check. Part of #5676 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CSoz9uGhaaSgiq3hshtN7L
This was referenced Aug 24, 2026
Contributor
✅ Console Performance Budget
The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it. 📦 Bundle Size Report
Size Limits
|
yinlianghui
marked this pull request as ready for review
August 24, 2026 15:15
yinlianghui
deleted the
claude/issue-5676-layered-envelope-boundary-parse
branch
August 24, 2026 15:28
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #5676
Carries triage's answer rather than re-opening the card's question: pass through and label, now validated at the boundary — extending to the whole ADR-0010 envelope the treatment #5672 already chose for
lock.What changed
MetadataClient.layered()now runsGetMetaItemLayeredResponseSchema.safeParseon the response body.asassertions that block carried are gone. (code/overlay/effectivestay asserted: upstream declares themz.unknown()andTis the caller's own narrowing, which no schema here can check.)MetadataLayered._unrecognized, absent whenever everything parsed.safeParse, neverparse, and notry/catchanywhere near it.The lock-banner consumer is untouched — its unrecognised-token title from #5672 already labels the value, and the boundary's job was to keep forwarding it. The labelling half did not demand an edit there.
PM mechanism assumptions — measured
1. Two days of drift.
packages/data-objectstack/src/metadata-client.tshas had no commits since the card was filed (last touch9e725e06a, unrelated). All three re-derivations hold on the merge-base: the cast sites (in fact ten, not seven — the card's seven are the vocabulary-bearing ones;lockReason/lockDocsUrl/packageId/packageVersionare the four free-string casts alongside them), theisLockedgate atResourceEditPage.tsx:1547, and #5672's labelling mechanism (lockBannerTitle,tFormat('engine.edit.lockUnknown', …, { state: String(lock) })). #5672's pattern has not been generalised, so the fix is not smaller.2. The spec schema. Installed spec is 17.2.0, not the 17.1.0 the card names — no impact, the schema is present and reachable.
GetMetaItemLayeredResponseSchemais exported from@objectstack/spec/apiand declares all seven ruled fields plus the four string ones. No split, nothing invented. Note the four resolved verdicts (lock/editable/deletable/resettable) are required on this path, tightened from the optionalMetadataProtectionEnvelopeFieldsmixin.3. The labelling convention. #5672's is "forward the raw token; the consumer names it". No second convention invented: the boundary's contribution is to keep that forwarding true and to make "this value is off-spec" machine-readable rather than re-derived per consumer. Only the banner ever re-derived it, and only for
lock— the other six had no consumer-side check at all.4.
safeParsegranularity — the important measurement. Measured on spec 17.2.0, not assumed: the whole-objectsafeParseis all-or-nothing. One unknownlocktoken returnssuccess: falsewithdataundefined, so the other six fields lose their types too. Leaning on it alone would degrade the entire envelope whenever a server spoke a newer dialect — the subtler version of this very bug. The failure branch therefore re-checks each key against that same schema's ownshape[key], where only the offending field fails. Absence is never "unrecognised", so a pre-ADR-0010 backend (which sends no envelope, fails the object parse, and flags nothing) behaves exactly as before.Verification
Both directions pinned, in
metadata-client.layeredEnvelope.test.ts(22 tests):_unrecognizedabsent, not empty.locktoken — resolves; the raw token is forwarded (the preconditionlockBannerTitleconsumes);_unrecognizedis exactly['lock']; the other six still arrive typed. Table-driven across all seven ruled fields.lock: 42,lockSource: {},editable: 'yes',overlayScope: []) resolves, names every malformed field, and still hands back the three layers. Plus non-object / null / array bodies.Reverse verification, direction predicted before running. The test imports
./metadata-client— a relative same-package source import, noexports/disthop and no alias, so no rebuild leg applies; both mutations were proved on disk by grepping injected and removed text separately, with the landing site printed and anchor uniqueness asserted first. Restored undertrap … EXIT INT TERM;git diff HEAD --statempty afterwards.unrecognizedEnvelopeKeys→[])sends no protection envelope, and nothing is flaggedThe first ablation is the dispatch's point made concrete: (a) alone goes green on a client that has no labelling at all. The second is assumption 4 measured — a wholesale verdict degrades a legitimate older server's whole envelope.
One fix beyond the seven casts, named because it defeats the ruling
A 200 whose body was a bare JSON string or number rejected the promise with
TypeError: Cannot use 'in' operator to search for 'code' in nonsense, from the envelope-detection guard's bare truthiness check (body && ('code' in body) || …). Pre-existing onorigin/main(line 920-921 there), untouched by the seven-cast work, and confirmed by direct evaluation. "A malformed body must degrade, never throw" is the ruled shape, so this was in scope by the ruling rather than a drive-by; the counter-probe is what surfaced it. Guard is nowtypeof body === 'object' && body !== null && ….Gates (exit codes captured before any pipe; verdicts quoted from the gate's own output)
pnpm --filter @object-ui/data-objectstack type-check> @object-ui/data-objectstack@17.6.0 type-check/> tsc --noEmit, script name echoed. First run was exit 2, the unbuilt-closure red (TS6305 Output file … has not been built), cleared bypnpm --filter '@object-ui/data-objectstack^...' build— not the missing-node_modulesred.pnpm exec vitest run packages/data-objectstack/src(root form)Test Files 43 passed (43),Tests 603 passed (603)--filter '...@object-ui/data-objectstack'type-checkScope: 34 of 47, 33 packages ran a realtype-checkand printedDone; the 34th declares no such script. Prefix = downstream consumers, the correct direction: this change alters what the client returns.pnpm --filter @object-ui/data-objectstack lint(plaineslint ., no--no-inline-config)✖ 370 problems (0 errors, 370 warnings); all warnings pre-existingno-explicit-any, none on the changed linesb0800a62c(data-objectstack + app-shell metadata-admin)Test Files 238 passed (238),Tests 2585 passed, 1 skipped (2586)Changeset:
.changeset/5676-layered-envelope-boundary-parse.md(minor) — Clause-② applies, this changes what a published client accepts and forwards.Fork to report
None found. No consumer in this repo needs hard-reject semantics:
layered()has one call path into the metadata-admin editor, which renders a banner from the value and has no branch that would prefer an exception. If one appears, the natural shape is a caller-side check on_unrecognizedrather than a second boundary mode.Generated by Claude Code