Repository navigation
fix(metadata-protocol)!: a nested-relation filter condition asks the related object its declared exposure (#22737) - #22768
Conversation
…et the API does not serve Claude-Session: https://claude.ai/code/session_01JfJfBUC3cQ6hhgm9MQK76T Co-authored-by: Claude <noreply@anthropic.com>
… 4 decided; changesets Claude-Session: https://claude.ai/code/session_01JfJfBUC3cQ6hhgm9MQK76T Co-authored-by: Claude <noreply@anthropic.com>
…-relation refusal Claude-Session: https://claude.ai/code/session_01JfJfBUC3cQ6hhgm9MQK76T Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JfJfBUC3cQ6hhgm9MQK76T Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 3 package(s): 8 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 4 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 141 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 e0db752956bd760c37de8405c10f00594fbe249f && git checkout e0db752956bd760c37de8405c10f00594fbe249f
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin c74d843997262b7c5bfccc5b2eab3acf75070461 606e50f7ef6adc4d39bd54586e3b6a76bf3b7cec && git checkout -B drift-repro c74d843997262b7c5bfccc5b2eab3acf75070461 && git merge --no-ff 606e50f7ef6adc4d39bd54586e3b6a76bf3b7cec
node scripts/docs-audit/affected-docs.mjs --json c74d843997262b7c5bfccc5b2eab3acf75070461
|
…ct on the REST body Claude-Session: https://claude.ai/code/session_01JfJfBUC3cQ6hhgm9MQK76T Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JfJfBUC3cQ6hhgm9MQK76T Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: Inputs, and nothing else: card #22737 (body; comments 6104514778 claim with its round-2 revision, 6105527933 and 6106039434 dev reports), parent card #22661 (body; comments 6096329009 triage, 6101260211 claim, 6102815147 dev report, 6102873261 census split, 6102951996 ACCEPT, 6104471901 landing) and its contract review 6102920614 on PR #22735, PR #22768 (body, its 12-file list, the net diff from merge-base ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS |
Conflict in packages/core/src/security/second-object-read-exposure.pin.test.ts, resolved as the union: census row 4 (refuseUnservedRelationTarget, list) and rows 5-6 (servesPayloadDisplayTarget, servesSummaryTitleTarget, get) all decided. Claude-Session: https://claude.ai/code/session_01JfJfBUC3cQ6hhgm9MQK76T Co-authored-by: Claude <noreply@anthropic.com>
Conflict in packages/core/src/security/second-object-read-exposure.pin.test.ts (header sentence), resolved as the union with #22739's import-door row: census rows 4-7 all decided, none left open. Claude-Session: https://claude.ai/code/session_01JfJfBUC3cQ6hhgm9MQK76T Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: Supplementary record on the head produced by two conflict-resolving merges of ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS |
Fixes #22737
Clause-②: no (narrowing)
Census row 4 of the second-object exposure census (the #22661 card). Classes, positions and functions only; the fixtures are synthetic.
What changes
A nested-relation filter condition, a condition on a lookup's related object, is evaluated on that related object:
ObjectQL.lowerRelationConditionsreads the TARGET as the caller and keeps the source rows that point at a match. The data door never asked the target's declared exposure, so the list and query routes evaluated such a condition even for a target every data route refuses.The door's nested-relation walk (
refuseInternalInFilteroverrelationConditionSites, infindData'sassertNoInternalFieldEvaluated) now asks each condition's TARGET the spec's one exposure decision before anything of it is judged. The new method isrefuseUnservedRelationTarget(packages/metadata-protocol/src/protocol.ts), and it callsapiExposureDenialReason. A target the decision does not serve is REFUSED with the data door's existing exposure answer:apiEnabled: false→404 OBJECT_API_DISABLED;apiMethodswhitelist without the operation →405 OBJECT_API_METHOD_NOT_ALLOWED, with the effective operation set.No new code, no second rule or list. The engine and the filter compilation are unchanged, so the engine's privileged callers keep their path.
list, notget.$expand(security(data, analytics): a lookup target's exposure declaration is not judged when the data door's$expand, or the dataset door's dimension-label pass, reads it — detail withheld pending maintainer #22661) asksgetbecause it turns ids the caller already holds into records. A condition is a PREDICATE over the target: the engine runs afindon the target with the condition, across the whole object, bounded by a match cap rather than by the source rows' ids. That is the read the target's own filtered list performs, andquery→listinDATA_ACTION_TO_API_OPERATION. So aget-only target is refused here while it stays served to$expand. Alist-only target is served.403 PERMISSION_DENIEDthere, measured below and pinned unchanged.where, thefilteralias, eachaggregations[i].filter, and eachexpandentry's ownwhereat every level. The grouped branch is refused beforeengine.aggregate. An$expandentry into an unexposed target is still withheld first (servedExpand), so its filter is never judged./metaobject read mentions the target for both personas), and theinternal: truerefusal at this same hop names it too.@objectstack/metadata-protocolnow stamps both exposure codes, so its section of the error-code ledger lists them. This is one pure append in that section: no schema shape change, no existing entry touched.Measurement (real stack:
bootStack, the real security, engine, SQL driver and REST layers; administrator and member)Fixture source object with lookups into targets of each exposure shape. Each answer was read on the list route and on the query route, once for a matching related value and once for a non-matching one.
bf515e724d)apiEnabled: falseOBJECT_API_DISABLEDapiMethods: ['create']OBJECT_API_METHOD_NOT_ALLOWEDapiMethods: []OBJECT_API_METHOD_NOT_ALLOWEDapiMethods: ['get']OBJECT_API_METHOD_NOT_ALLOWEDapiMethods: ['list'](control)enableblock (control)PERMISSION_DENIEDAdministrator and member answered alike on every row except the precedent row, where only the member lacks the grant. The #22661 dev's reading (1–2 rows on a match, 0 on a miss) is confirmed.
Pins
packages/metadata-protocol/src/protocol.relation-condition-target-exposure.test.ts(new, 13 cases). Each refused shape is asserted oncode+status(andallowedfor 405), for a member context and a system context, on the POSTwhereand on a$filterstring under$or. Each case also asserts thatengine.find/engine.aggregateis never reached. It also covers the grouped branch and anexpandentry's own filter. Controls: alist-only target, an open target, and a plain id comparand on the same lookups.packages/qa/dogfood/test/second-object-exposure.dogfood.test.ts(security(data, analytics): a lookup target's exposure declaration is not judged when the data door's$expand, or the dataset door's dimension-label pass, reads it — detail withheld pending maintainer #22661's real-stack pin, +5 cases). For both personas: refused (status+code) on the list and query routes, matched or not; control targets still evaluated, 2 rows / 0 rows. Precedent: a member refused 403PERMISSION_DENIED, unchanged.packages/core/src/security/second-object-read-exposure.pin.test.ts: census row 4 moves fromopentodecided(operationlist; decisionrefuseUnservedRelationTarget; wiringthis.refuseUnservedRelationTarget(; pin as above). The type admits'get' | 'list'.Verification
fe8d208a87viaablation-replace.mjs: the hop call deleted (anchor 1 → 0, blob changed) → the unit pin goes 10 red / 3 green (the three controls). Restored: blob == HEAD,git diff HEADempty.ablation-dist-preflightmarker present indist/. The dogfood pin goes 2 red (the refused legs, admin and member) / 28 green. Restore leg: blob == HEAD, rebuilt, preflight--absentexit 0. A first B attempt with a non-compiling mutation (an unused private method) had a build exit 1 while the JS still carried the marker. It was re-run with the compiling mutation, and these numbers are from that run.refuseUnservedRelationTarget.check:error-code-provenanceFAIL, naming both stamp sites. The first D attempt was refused byablation-replace(replacement text was a substring of the anchor), so nothing ran. It was re-run in delete mode.fe8d208a87: metadata-protocol vitest 225 files / 28129 passed (19 skipped); metadata-protocoltsc --noEmitexit 0 with the new test in--listFiles; coretypecheck(includingcheck:test-typecheck) exit 0.151278b21c(origin/maine86530088amerged, closure rebuilt): unit pins 28/28, corerepoproject 5 files / 55, dogfood pin 30/30, dogfoodtsc --noEmitexit 0 with the file listed, and speccheck:generated(14 artifacts) up to date.151278b21c(round 1's merge head), the union of the dispatch list (82) anddispatch-gates --commandson this diff (91) is 95 commands, and all 95 exit 0. Each exit code was captured before any pipe.--ranreconciliation: 91 derived, 91 run, 0 NOT-MEASURED, 0 UNRUN; 4 more were run from the dispatch list outside this diff's derivation.check:dual-build-cjs-loadswas firstPREREQUISITE NOT MET(exit 3) on a tree missing 8 dists; it exited 0 after building them, and again on this head. Named:check:error-code-provenanceOK (red without the ledger rows, ablation D),check:error-status-conformance,check:error-code-casing,check:dispatcher-error-vocabulary, the ADR-0087 marker (not-required (no-migration-prescription)),check-changeset-no-major,check-empty-changeset,check:nul-bytes,check:cross-package-test-inputs,check:test-source-alias,check:type-check-coverage/-debt,check:published-files,check:authorable-surfaceandcheck:api-surface.964aceffd6: types 27 files / 754 passed plus repo 11; rest local 272 files / 5239 passed (run in three chunks) plus repo 5 files / 209; dogfood pin 34/34; ablation E (arm deleted, types rebuilt, preflight--absent) reds the types pin 2 / 4, the rest guards 8 / 210 and the dogfood target leg 2 / 34, with controls green; restored blob == HEAD; 95 gates exit 0.mainonly, with no rebase and no force-push:04cf472669(after fix(plugin-approvals, plugin-audit)!: a lookup title is served only for a target whose declared exposure serves get #22766, census rows 5–6) and606e50f7ef(after fix(core)!: an import's reference resolution asks the lookup target its declared exposure before matching a cell (#22739) #22770, census row 7), the final commit. Each conflicted only in the census pin (second-object-read-exposure.pin.test.ts), resolved as the union: sixdecidedreads, and no CALLERS row leftopen. Every other file's net change is byte-identical to round 2's. At606e50f7ef, with the dogfood closure rebuilt: corerepo5 files / 55; metadata-protocol's two exposure pins 28/28; the dogfood pin 34/34; the types exposure-arm pin 4/4; restrepo5 files / 209; speccheck:generated(14 artifacts) up to date; the same 95 gates exit 0 (--ran: 91 derived, 91 run, 0 NOT-MEASURED, 0 UNRUN)..tsfiles:eslint --no-inline-config --format jsonreports 5 files, 0 errors, 0 warnings.--print-configshows 5 active rules per file, none ignored.eslint.config.mjsenables no type-aware linting (noparserOptions.project), so no untouched file's verdict can move. Repo-widepnpm lintis CI's.Acceptance notes
@objectstack/typesstructuredCodeAnswer(asked first byclassifyDataError, so both REST doors agree) now answers with the error's ownobject, and relaysallowedon 405, when the error names an object other than a defined route object. An error naming the route object, or a door given none (the analytics dataset route), answers byte for byte as before.stdio-data-bridge.ts) hands a caller's filter to the engine directly, after its own addressed-object gate. Measured in round 2 at the MCP tool surface: the same class as this card. It is carried by security(mcp): a nested-relation filter condition through the MCP stdio data bridge is evaluated without asking the target's exposure (census row 8 of #22661) #22777 (a sub-issue of security(data, analytics): a lookup target's exposure declaration is not judged when the data door's$expand, or the dataset door's dimension-label pass, reads it — detail withheld pending maintainer #22661), which waits for this card;packages/mcpis untouched here.minorBREAKING one for@objectstack/metadata-protocoland apatchone for@objectstack/spec's ledger value, the shape the analytics rows' precedent took. (b) The real-stack legs sit in security(data, analytics): a lookup target's exposure declaration is not judged when the data door's$expand, or the dataset door's dimension-label pass, reads it — detail withheld pending maintainer #22661's dogfood pin, outside the named file surface, beside their sibling legs. (c) The diff is 473 changed lines (+457 / -16) across 12 files at the final head: source +57/-4 (protocol.ts) and +23 (data-error-classification.ts); the rest is tests, three changesets, the pin row and the ledger rows. (d) Round 2 added test-only rows to twopackages/restdrift guards that require coverage of every new arm (error-response-structured-arm-door-parity.test.ts,error-response-sandbox-arm-message.test.ts), and a third changeset (@objectstack/typespatch).Generated by Claude Code