refactor(metadata): split the stored envelope from the authored body at the api parse boundary (#5309) - #6576
Merged
Conversation
…at the `api` parse boundary (#5309) `ApiEndpointSchema` is parsed against STORED ROWS at two sites — `buildEndpointIndex` (the load-time backstop) and `gateApiItemsForPublish` (the publish gate) — and a stored row carries the metadata layer's own bookkeeping (`packageId`, `state`, `version`, `published*`), which is not endpoint vocabulary. Only the schema's unknown-key STRIPPING kept those keys from being judged, which is why `api` could not leave #4001's STILL_STRIP list. `peelStoredEnvelope` (new `packages/metadata/src/stored-envelope.ts`) takes the envelope off before the body parse, using the metadata layer's own existing body-selection rule (`metadata ?? row`) plus the declared bookkeeping key list. It returns views and never mutates the row, so every existing envelope reader is untouched and `publishedDefinition` still snapshots the same bytes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KDU3qAuJyajAQm3GkUXdfA
…point-envelope-body-split
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
Contributor
📓 Docs Drift CheckThis PR changes 1 package(s): 8 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
baozhoutao
marked this pull request as ready for review
August 8, 2026 06:00
This was referenced Aug 8, 2026
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 #5309
What this is
The metadata layer's storage envelope is now peeled off a stored row before
its body is handed to a spec schema. Purely internal: no authored format
changes, no spec change,
STILL_STRIPuntouched — that flip is #5384's step.ApiEndpointSchemahas two parse sites and both of them are fed STORED ROWS,not authored declarations:
buildEndpointIndex—packages/metadata/src/endpoint-matcher.ts, theload-time backstop;
gateApiItemsForPublish—packages/metadata/src/metadata-manager.ts, theADR-0121 publish gate.
A stored row carries the metadata layer's own bookkeeping —
packageId,package,state,version,publishedDefinition,publishedAt,publishedBy— written byMetadataManager.register/publishPackageandread back by
publishPackage's package filter. None of it is endpointvocabulary, and the only thing that kept it from being judged as vocabulary was
the schema's unknown-key stripping. That is exactly why
apicould notleave #4001's
STILL_STRIPlist.The split
New module
packages/metadata/src/stored-envelope.ts:metadatavalue is an envelope around it — the body isthat value, everything beside it is bookkeeping, by construction;
STORED_ENVELOPE_KEYS.Neither half is invented.
metadata ?? rowis already the layer'sbody-selection rule in three places (
publishedDefinition's snapshot,getPublished's fallback,gateApiItemsForPublish); the key list is exactlywhat
MetadataManagerwrites onto a row or filters rows by. The peel returnsviews and never mutates the row, so every existing envelope reader keeps
reading the same object, and
publishedDefinitionstill snapshotsdata.metadata ?? dataverbatim (revertPackagerestores from it).The prerequisite, demonstrated
Local probe only —
ApiEndpointSchemaflipped tostrictObjectand nevercommitted (that is #5384's step, spec seat).
pnpm --filter @objectstack/metadata test:origin/main@ 6f657f4origin/main@ 6f657f4strictObjectunrecognized_keyson envelope keysstrictObjectThe 11 reds under the probe on
mainwere:endpoint-matcher.test.ts'sstorage-annotation case, and ten of
publish-endpoint-gate.test.ts(the wholeD6 group, the other gates, both namespace cases, the publish-envelope case).
They are gone because the envelope no longer reaches the schema.
The one remaining red, explained — it is #5384's business, not this card's.
publish-endpoint-gate.test.ts›does NOT infer the namespace from the item being judged. Its fixture plants an authorednamespace: 'acme'key preciselyto prove the gate does not believe it. Under a strict schema that key is
refused by name before the gate ever runs:
(The angle brackets around
rootare spaced only so GitHub's body sanitizerdoes not eat them as a tag; the real message has no spaces.)
Publish still refuses the item (
success: false); what changes is whichrefusal speaks — the schema's named rejection instead of the gate's namespace
precondition, so the assertion
message.includes('manifest.namespace')nolonger holds. That is the tightening working, and re-spelling the fixture is
part of the flip, not of the split.
namespaceis deliberately NOT added toSTORED_ENVELOPE_KEYS: it is an authored key here, not layer bookkeeping, andpeeling it would hide from the schema exactly the typo class #5384 exists to
catch.
One behavioural consequence, stated rather than buried
buildEndpointIndexwas the last reader in the layer that did not followthe
metadata ?? rowbody-selection rule — it parsed the outer object. So apublish envelope (
{ name, packageId, state, metadata: {…} }) passed thepublish gate and was then excluded from the endpoint index: its route
answered 404 while publish had just accepted it. The two doors now read the same
document, which is what "the matcher and the publish gate parse the body" in the
issue's own direction says. Nothing in the repo pinned the old verdict, and it
only affects rows that today fail; every flat row — what artifact ingest,
register()andpublishPackageactually produce — indexes byte-identically.Pinned by
indexes a WRAPPED publish envelope off its 'metadata' body.Tests
stored-envelope.test.ts— 15 cases over the peel itself: everydeclared key off the body and on the envelope, the same-object return for a
row with no bookkeeping, no mutation of the input,
metadata: nullfallingthrough to the flat branch, a non-object
metadatahanded on for the schemato refuse, degenerate inputs, and
storedItemName's envelope-first order.endpoint-matcher.test.ts— a flat row carrying all seven bookkeeping keysindexes with none of them on the answer; a wrapped envelope indexes off its
body; an unparseable wrapped row is still named by its envelope
name; anauthored declaration is untouched.
publish-endpoint-gate.test.ts— a fully-published row yields the D6 verdictand not a schema error; register →
publishPackage→matchEndpointround-trips through both doors. The
apiItemfixture is split intoendpointBody(authored) +apiItem(stored), so the publish-envelope casestops nesting a stored row inside
metadata— a fixture accident only thestripping made invisible.
No rejection case here asserts through
toThrow(); the publish refusal surfaceis
PackagePublishResult.validationErrors[]({type, name, message}), whichcarries no ADR-0112
code/statusenvelope, so the assertions are on theverdict entries themselves.
Verification
@objectstack/metadatahas notypecheckscript (it carries a measured DEBTentry, 92);
tsc --noEmitover the package reports 88, below the ledger, andnone of the errors are in the files this PR touches. Every
check:*stepenumerated from
.github/workflows/lint.ymlwas run one by one — 31 in theESLint job and 27 in the TypeScript Type Check job, all green, including the full
turbo build+turbo typecheckandcheck:type-check-debt.Generated by Claude Code