fix(runtime): the /meta generic branch refuses an item-less envelope, like its own object branch - #18655
Conversation
… like its own object branch
`handleMetadataRequest`'s generic `:type/:name` branch returned
`protocol.getMetaItem`'s answer straight through with `deps.success(data)`.
That producer answers a miss with the protection envelope wrapped around an
absent item (`{ type, name, item: undefined, lock, editable, deletable,
resettable }`) rather than with `undefined`, so a name with nothing behind it
was announced as a 200 whose body — after `JSON.stringify` drops the member —
is the declared envelope minus its required `item`.
The `object` branch of the same function already refuses that exact shape and
404s, so one function answered "does absence mean success?" both ways
depending on which type you asked for. `GetMetaItemResponseSchema` declares
`item` required, and the REST twin of this door refuses the same shape.
Apply the sibling branch's hit test, and let the miss fall through to the
MetadataService resolver and then to the branch's own existing 404. No new
refusal dialect is introduced.
Claude-Session: https://claude.ai/code/session_01DvvamiacK328idtBYJBxV3
Co-authored-by: Claude <noreply@anthropic.com>
…ing through it `check:test-typecheck` is an exact, shrink-only ratchet and the new pin's file is inside the checked zone, so the sibling files' `res.response?.` spelling is not available to it. A local narrowing helper is the stronger form anyway: the negative assertions in this file (`toBeUndefined()`, `not.toBe(200)`) pass vacuously against an unhandled result, which is the one outcome that must not read as a pass here. Claude-Session: https://claude.ai/code/session_01DvvamiacK328idtBYJBxV3 Co-authored-by: Claude <noreply@anthropic.com>
`@objectstack/runtime` publishes `dist/`, and the built `dist/index.js` carries the changed hit test (marker count 3, matching the post-fix source, against a positive control that hits), so published bytes move and `skip-changeset` is not available. Claude-Session: https://claude.ai/code/session_01DvvamiacK328idtBYJBxV3 Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 26 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 1b10c0fcac5d3905da411984167d1f5b0c40cb12 && git checkout 1b10c0fcac5d3905da411984167d1f5b0c40cb12
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 99fcb4ac104d44625136df690b51a2ff30a99d7c 7a8e5f3a43af2efd9155b094d5bb517d2ce81ef1 && git checkout -B drift-repro 99fcb4ac104d44625136df690b51a2ff30a99d7c && git merge --no-ff 7a8e5f3a43af2efd9155b094d5bb517d2ce81ef1
node scripts/docs-audit/affected-docs.mjs --json 99fcb4ac104d44625136df690b51a2ff30a99d7c
|
Fixes #18401
handleMetadataRequest's generic:type/:namebranch returnedprotocol.getMetaItem's answer straight through withdeps.success(data). That producer answers a miss with the protection envelope wrapped around an absent item —{ type, name, item: undefined, lock, editable, deletable, resettable }, becauseresolveLockState(undefined, false)is unconditional — never withundefined. So a name with nothing behind it was announced as a200, andJSON.stringifyat the transport then dropped theitemmember: the declaredGetMetaItemResponseSchemaenvelope minus its required member, served as a hit.The
objectbranch of the same function already refused that exact shape and answered404. One function, two opposite answers to "does absence mean success?", selected by which type you asked for.What changed
packages/runtime/src/domains/meta.ts— the generic branch now runs the same hit test its sibling runs (data?.item != null) before answering success. An item-less answer falls through to theMetadataServiceresolver exactly as the object branch falls through to the ObjectQL registry, and only a read no resolver can satisfy reaches the branch's own pre-existingdeps.error('Not found', 404).⛔ No new refusal dialect. The refusal this change routes to is the one this block already emitted, the ADR-0112 nested
{ success:false, error:{ code, message, httpStatus } }every other refusal in this file speaks. Sibling card #18402 — which asks howGET /meta/:type/:namespells its refusals, in carriers underpackages/rest/src/— is therefore neither answered nor pre-empted here. #18402 is not addressed by this PR and remains open.Premises, measured before relying on them
getMetaItemenvelopegetMetaItemcall sites in the file: the twoobject-branch ones were already guarded (data?.item != null), this one was not. Control that could fail: the same sweep finds and names the two guarded siblings, and the ablation below moves the count 3 → 2 on exactly the line under test.objectbranch's refusal is the right one to copydeps.error('Not found', 404)throughapiErrorResponse/buildApiError, i.e. the ADR-0112 nested envelope withcodederived from the status asRESOURCE_NOT_FOUND. The generic branch already ended at the identical call, so the change adds no second refusal.@objectstack/runtimesuite green (265 files / 3660 tests). Repo-wide, the onlyhandleMetadataconsumers outside the package are avi.fn()transport stub (packages/adapters/hono/src/__mocks__/runtime.ts) and an authz-conformance ledger row that names the handler for its anonymous-deny gate, neither behavioural on this shape; every dogfood/metaitem probe addresses theobjectbranch or/published, both untouched.Clause-②: no
The heading above carries the key inside a
##prefix, whichCLAUSE2_KEY_LINEdoes not accept — so the gate read it as a near miss. The bare line above it is the declaration; the reading it declares is unchanged.Clause-②: no— declared from the measured diffexportlines across the whole diff.ERROR_CODE_LEDGER+StandardErrorCode'RESOURCE_NOT_FOUND'inside a test assertion, an existing member thatstandardErrorCodeForHttpStatus(404)already derives.check-widening-tells --declaration no --diffpackages/spec, which this diff does not touch.check-clause2-carriers --pair⇒ no
needs:contract-review. What a caller observes does change — a request previously answered200with an item-less body is now answered404— but no declaration moves, which is the axis this gate is about. A request that resolves to a real item is byte-identical to before, protection envelope included.Tests
packages/runtime/src/domains/meta-item-absent-404.test.ts, six pins: the404with itscodeandstatus; the wire body measured againstGetMetaItemResponseSchemain both directions; the two branches answering absence the same way (the finding itself); the fall-through to theMetadataServicestill happening; and two controls that must stay green — a real hit on each branch, every value read from inside the answer.Ablation — the fix reverted to the unguarded pass-through, proven on disk before reading anything (
if (data?.item != null)occurrences 3 → 2, raw pass-through 1 → 2, blob4e5e86dde→51f48acab), restore bygit checkout HEAD --verified byte-identical back to4e5e86ddewithgit diff HEADempty:Commands, all foreground, heavy ones through
scripts/pm/os-verify-lock.sh:pnpm --filter '@objectstack/runtime...' build --concurrency=2— VERDICT command-exit 0pnpm --filter @objectstack/runtime typecheck— green (tsc --noEmitpluscheck:test-typecheck: 27 files / 191 errors / 69 pinned signatures held, the new file carrying none of them)pnpm --filter @objectstack/runtime test— 265 files / 3660 tests passedpnpm lintrepo-wide (eslint . --no-inline-config, full population, no narrowing) — exit 0 at7a8e5f3a4node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderived 59 families (52, then 7 more once the changeset existed); reconciled with--rancarrying every exit code: 59 accounted, 57 run, 0 unrun.check:dual-build-cjs-loadsandcheck:type-check-debtfirst exited 3 (PREREQUISITE NOT MET, nothing measured); both re-ran green after a fullturbo run buildover./packages/*and./packages/*/*(72/72 tasks).check:cross-package-test-inputsexits 1 — proven not this diff's, twice: see Acceptance notes.Changeset
skip-changesetis not available.@objectstack/runtimeis published (private: false,files[]=dist,README.md,CHANGELOG.md) and the changed hit test is in the shipped bytes: the marker occurs 3 times in the builtpackages/runtime/dist/index.js, matching the post-fix source count of 3 against a pre-fix 2, with a positive control (Save not supported) hitting. So: apatchchangeset, carrying theClause-②line.Acceptance notes
To file (class a — a reproducible defect, dedupe words below).
pnpm check:cross-package-test-inputsreturns two opposite verdicts for one commit, decided by whether the box has builtpackages/spec. Isolated to a single variable in this worktree, same tree, same commit:The walk-radius limb enumerates the real filesystem under the walked root, so the radius is empty on an unbuilt tree and populated on a built one. It reds for exactly the developer who followed AGENTS.md's "rebuild before you believe it" and greens for the one who did not. Independently, an ablation putting this diff back to
origin/main(proven on disk) left the same FAIL, so it is not this PR's. Dedupe words:cross-package-test-inputs walked radius·packages/spec/dist walk root·init-created-files-summary.e2e walk·ACCEPTED_WALK_RADII build state·gate verdict depends on dist presence.noted, not filed. The
/publishedbranch of this same file guards itsgetPublishedhit withdata === undefined, so a producer answeringnullwould pass through as a200— a different producer and a different envelope from the one this card is about, and no producer returningnullwas measured, so it is undrilled drift rather than a defect. Successor: none — no queued card or open PR touches that branch (#18402's carriers are inpackages/rest/src/).Generated by Claude Code