Skip to content

fix(metadata-protocol)!: a nested-relation filter condition asks the related object its declared exposure (#22737) - #22768

Merged
objectstack-fleet[bot] merged 8 commits into
mainfrom
claude/issue-22737-relation-filter-exposure
Oct 11, 2026
Merged

objectstack-fleet[bot] merged 8 commits into
mainfrom
claude/issue-22737-relation-filter-exposure

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor

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.lowerRelationConditions reads 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 (refuseInternalInFilter over relationConditionSites, in findData's assertNoInternalFieldEvaluated) now asks each condition's TARGET the spec's one exposure decision before anything of it is judged. The new method is refuseUnservedRelationTarget (packages/metadata-protocol/src/protocol.ts), and it calls apiExposureDenialReason. A target the decision does not serve is REFUSED with the data door's existing exposure answer:

  • apiEnabled: false → 404 OBJECT_API_DISABLED;
  • an apiMethods whitelist 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.

  • Operation: list, not get. $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) asks get because it turns ids the caller already holds into records. A condition is a PREDICATE over the target: the engine runs a find on 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, and query → list in DATA_ACTION_TO_API_OPERATION. So a get-only target is refused here while it stays served to $expand. A list-only target is served.
  • Refused, not withheld. Dropping a condition would widen the result set. The precedent at this position is also a refusal: a related object the member may not read answers 403 PERMISSION_DENIED there, measured below and pinned unchanged.
  • Every position the walk covers: where, the filter alias, each aggregations[i].filter, and each expand entry's own where at every level. The grouped branch is refused before engine.aggregate. An $expand entry into an unexposed target is still withheld first (servedExpand), so its filter is never judged.
  • The refusal names the target object and the condition's position in its message. The lookup's declared target is already in the source object's served schema (measured: the source's /meta object read mentions the target for both personas), and the internal: true refusal at this same hop names it too.
  • Provenance. @objectstack/metadata-protocol now 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.

target shape before (base bf515e724d) after (this branch)
apiEnabled: false 200, 1 row on match / 0 on miss 404 OBJECT_API_DISABLED
apiMethods: ['create'] 200, 1 / 0 405 OBJECT_API_METHOD_NOT_ALLOWED
apiMethods: [] 200, 1 / 0 405 OBJECT_API_METHOD_NOT_ALLOWED
apiMethods: ['get'] 200, 1 / 0 405 OBJECT_API_METHOD_NOT_ALLOWED
apiMethods: ['list'] (control) 200, 1 / 0 200, 1 / 0, unchanged
no enable block (control) 200, 1 / 0 200, 1 / 0, unchanged
exposed, member holds no read (precedent) member 403 PERMISSION_DENIED unchanged

Administrator 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 on code + status (and allowed for 405), for a member context and a system context, on the POST where and on a $filter string under $or. Each case also asserts that engine.find / engine.aggregate is never reached. It also covers the grouped branch and an expand entry's own filter. Controls: a list-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 403 PERMISSION_DENIED, unchanged.
  • packages/core/src/security/second-object-read-exposure.pin.test.ts: census row 4 moves from open to decided (operation list; decision refuseUnservedRelationTarget; wiring this.refuseUnservedRelationTarget(; pin as above). The type admits 'get' | 'list'.

Verification

  • Ablation A, on committed fe8d208a87 via ablation-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 HEAD empty.
  • Ablation B: the call replaced by a compiling marker, metadata-protocol rebuilt (exit 0), ablation-dist-preflight marker present in dist/. The dogfood pin goes 2 red (the refused legs, admin and member) / 28 green. Restore leg: blob == HEAD, rebuilt, preflight --absent exit 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.
  • Ablation C (the pin's D2 leg): the decision call replaced by a hand-spelled rule → the enumeration pin goes 1 red, naming refuseUnservedRelationTarget.
  • Ablation D: the two ledger rows deleted → check:error-code-provenance FAIL, naming both stamp sites. The first D attempt was refused by ablation-replace (replacement text was a substring of the anchor), so nothing ran. It was re-run in delete mode.
  • Packages, at fe8d208a87: metadata-protocol vitest 225 files / 28129 passed (19 skipped); metadata-protocol tsc --noEmit exit 0 with the new test in --listFiles; core typecheck (including check:test-typecheck) exit 0.
  • On the merge head 151278b21c (origin/main e86530088a merged, closure rebuilt): unit pins 28/28, core repo project 5 files / 55, dogfood pin 30/30, dogfood tsc --noEmit exit 0 with the file listed, and spec check:generated (14 artifacts) up to date.
  • Gates: at 151278b21c (round 1's merge head), the union of the dispatch list (82) and dispatch-gates --commands on this diff (91) is 95 commands, and all 95 exit 0. Each exit code was captured before any pipe. --ran reconciliation: 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-loads was first PREREQUISITE 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-provenance OK (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-surface and check:api-surface.
  • Round 2 at 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.
  • Rounds 3 and 4 merged main only, 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) and 606e50f7ef (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: six decided reads, and no CALLERS row left open. Every other file's net change is byte-identical to round 2's. At 606e50f7ef, with the dogfood closure rebuilt: core repo 5 files / 55; metadata-protocol's two exposure pins 28/28; the dogfood pin 34/34; the types exposure-arm pin 4/4; rest repo 5 files / 209; spec check:generated (14 artifacts) up to date; the same 95 gates exit 0 (--ran: 91 derived, 91 run, 0 NOT-MEASURED, 0 UNRUN).
  • Lint, narrowed to the 5 changed .ts files: eslint --no-inline-config --format json reports 5 files, 0 errors, 0 warnings. --print-config shows 5 active rules per file, none ignored. eslint.config.mjs enables no type-aware linting (no parserOptions.project), so no untouched file's verdict can move. Repo-wide pnpm lint is CI's.

Acceptance notes


Generated by Claude Code

@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Oct 11, 2026
@github-actions

github-actions Bot commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/metadata-protocol, @objectstack/spec, @objectstack/types, touching 8 documentable anchor(s).

8 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/client-sdk.mdx (via ERROR_CODE_LEDGER (symbol, a top-level const object))
  • content/docs/api/error-catalog.mdx (via ERROR_CODE_LEDGER (symbol, a top-level const object))
  • content/docs/api/error-handling-server.mdx (via ERROR_CODE_LEDGER (symbol, a top-level const object))
  • content/docs/concepts/metadata-lifecycle.mdx (via ObjectStackProtocolImplementation (symbol, a top-level class))
  • content/docs/kernel/contracts/data-engine.mdx (via ERROR_CODE_LEDGER (symbol, a top-level const object))
  • content/docs/protocol/kernel/error-handling.mdx (via structuredCodeAnswer (symbol, a top-level function))
  • content/docs/protocol/kernel/http-protocol.mdx (via OBJECT_API_DISABLED (literal, a string literal in ERROR_CODE_LEDGER; a string literal in refuseUnservedRelationTarget; a string literal in structuredCodeAnswer), OBJECT_API_METHOD_NOT_ALLOWED (literal, a string literal in ERROR_CODE_LEDGER; a string literal in refuseUnservedRelationTarget; a string literal in structuredCodeAnswer))
  • content/docs/ui/setup-app.mdx (via OBJECT_API_DISABLED (literal, a string literal in ERROR_CODE_LEDGER; a string literal in refuseUnservedRelationTarget; a string literal in structuredCodeAnswer))

⛔ 4 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v16.mdx (via ObjectStackProtocolImplementation (symbol, a top-level class), OBJECT_API_METHOD_NOT_ALLOWED (literal, a string literal in ERROR_CODE_LEDGER; a string literal in refuseUnservedRelationTarget; a string literal in structuredCodeAnswer))
  • content/docs/releases/v17/17-0.mdx (via ERROR_CODE_LEDGER (symbol, a top-level const object), ObjectStackProtocolImplementation (symbol, a top-level class))
  • content/docs/releases/v17/17-1.mdx (via ERROR_CODE_LEDGER (symbol, a top-level const object))
  • content/docs/releases/v17/17-4.mdx (via ERROR_CODE_LEDGER (symbol, a top-level const object))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 anchor(s) matched too much of the corpus to be a work list: /data/{target} (route, 58 pages)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 141 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json c74d843997262b7c5bfccc5b2eab3acf75070461 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from e0db752956bd760c37de8405c10f00594fbe249f — the merge of head 606e50f7ef6adc4d39bd54586e3b6a76bf3b7cec into base c74d843997262b7c5bfccc5b2eab3acf75070461, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs c74d843997262b7c5bfccc5b2eab3acf75070461 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 964aceffd6e47c116ed90109d1a52b947d3cf8ba
Local-runs: none

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 9859e4ec42 to the head: 12 files, +456/−16, byte-matching the file list), and the head's check-runs, read 2026-10-11T06:13Z. Of 35 check-runs: 32 concluded success (Auto Label, Build Core, Check Changeset, Check Documentation Links, Check PR Size, Dogfood Regression Gate and its three shards, Dogfood Verify CLI, Flag docs affected, Governed Surface Queue Guard, Lint & Repo Gates, No other open PR may claim the same issue, No other open PR may claim the same single-writer path, Part-of PR must not also close its card, Spec property liveness, Temporal Conformance, Test Core and its six shards, The card this PR closes must claim this branch, Type Check · consumer gates / debt ledger / source gates / workspace, TypeScript Type Check, filter) and 3 skipped by their own triggers (Build Docs, Console Pin Gate, Packed-tarball smoke); none in progress. Security card: classes, positions and functions only.

① Derived judgments

  1. @objectstack/metadata-protocol — accept-set narrowing at ObjectStackProtocolImplementation.findData. refuseInternalInFilter now calls the new private refuseUnservedRelationTarget(site.target, position) for every site relationConditionSites yields, before it recurses into that site. Positions reached, read at the head: where and the filter alias, each aggregations[i].filter (all three from assertNoInternalFieldEvaluated), and every expand entry's own where at every level (refuseInternalInExpand hands each entry's where to the same walk against that level's target). A $filter string is parsed onto where before the walk, and the walk itself accepts a filter AST, so the carrier does not matter. One intake: every REST route and the runtime dispatcher reach findData; getData builds its expand entries with no where, so the single-record route has no filter position the walk could miss. The grouped branch is covered because assertNoInternalFieldEvaluated runs before the engine.aggregate / engine.find fork. A dotted relation spelling cannot bypass the ask: the door refuses it at assertFilterFieldsExist (the [finding] The FILTER axis has no DOTTED-path verdict — where: { project_id.name: 'x' } rides its head segment past both doors, where SORT refuses the same spelling (#4256) #8371 verdict, classifyDottedFilterHead → relation) and the engine's lowering refuses it too (dotted-key), so the nested-object spelling the walk covers is the only one that reaches lowerRelationConditions. Right.
  2. Operation list (RELATION_CONDITION_TARGET_OPERATION), where fix(metadata-protocol,service-analytics)!: a read that follows a lookup asks the target object its declared exposure — $expand and the dataset label passes (#22661) #22735 chose get (EXPANSION_TARGET_OPERATION). Right. The engine's lowering (relation-filter-lowering.ts, ObjectQL.lowerRelationConditions) runs a find on the target with the condition as the whole predicate, bounded by a match cap and not by ids the source rows hold — the read the target's own list route performs, and DATA_ACTION_TO_API_OPERATION maps query and find to list. The two reads differ, so the split (a get-only target served to $expand and refused here; a list-only target the reverse) is the one decision answering two different questions, not a second rule. An expand entry's own where is id-bounded (the engine composes it under the id filter in expandRelatedRecords), so the entry's target stays judged get by servedExpand, and only the nested conditions inside that where are judged list — consistent with both definitions.
  3. Refused, not withheld, at a filter position. Right. Dropping a predicate widens the result set, the opposite failure to the withhold fix(metadata-protocol,service-analytics)!: a read that follows a lookup asks the target object its declared exposure — $expand and the dataset label passes (#22661) #22735 chose for a read; the card's direction says a refusal in the declared exposure codes; and the measured precedent at this very position is a refusal (a related object the member may not read answers 403 PERMISSION_DENIED), pinned unchanged in the dogfood PRECEDENT leg. No new code: 404 OBJECT_API_DISABLED for the off switch, 405 OBJECT_API_METHOD_NOT_ALLOWED with allowed from effectiveOperationsArray(resolveEffectiveApiMethods(enable)), the derivation the REST door's apiAccessDenialFromEnable already uses for the addressed object.
  4. One decision. refuseUnservedRelationTarget asks apiExposureDenialReason(enable, 'list') on engine.registry.getObject(target).enable, the same declaration read servesExpansionTarget makes; apiEnabled: false ahead of the whitelist is the decision's own order; the decision takes no caller (a member context and a system context are pinned alike). The enumeration pin's D2 leg holds the decision function to the decision. Right.
  5. Ordering at the door. servedExpand runs before assertNoInternalFieldEvaluated in findData, so an expand entry whose target refuses get is withheld before its own where is walked — nothing of a withheld target is judged or named. At each hop the exposure ask precedes the internal-field judgement, so a refused target's field map is never read by the walk. Right.
  6. The refusal envelope names the target. err.object is the target, err.param the position, and the message names both with the remedy. Disclosure judged: the lookup's declared target is already in the source's served /meta read, the internal: true refusal at the same hop names it, and the 405 allowed set is what the target's own route answers when addressed directly (the dogfood leg holds the body equal to the target's own route refusal, both personas). No new disclosure class. Runtime strings carry no tracker number; the card id sits in comments only. Right.
  7. Failure modes and stand-downs. A target with no enable block answers null from the decision and is served — the spec's default-open rule, not a gap. A source whose field map is a legacy array makes resolveQueryFields return null, and the walk stands down for internal fields and for this ask alike — inherited from security(data): the data door's filter and group-by positions do not honour a field's internal: true the way its row read does — detail withheld pending maintainer #22646 and named by fix(metadata-protocol,service-analytics)!: a read that follows a lookup asks the target object its declared exposure — $expand and the dataset label passes (#22661) #22735's review, not widened here. The depth backstop (INTERNAL_FIELD_WALK_DEPTH) is inherited too. Right as inherited; named.
  8. Engine untouched. packages/objectql is not in the file list; filter compilation is unchanged; a flow's or hook's own query keeps its privileged path. Right.
  9. Public surface of @objectstack/metadata-protocol. The new member is private, the new constant is module-local, and the widened import names three helpers @objectstack/spec already publishes. No widening.
  10. Error-code ledger rows (packages/spec/src/api/error-code-ledger.zod.ts): two array entries in @objectstack/metadata-protocol's own section, alphabetically placed, no other entry or schema shape touched; both codes were already registered under @objectstack/rest (and carried by the @objectstack/service-analytics and @objectstack/mcp sections), and REGISTERED_ERROR_CODES is the deduplicated union, so it does not move. The provenance gate's assign pattern sees the two err.code = … sites in protocol.ts, so the rows are owed (the dev's ablation D reds without them; the gate is inside the green Lint & Repo Gates). The pre-approved ledger-append class holds on all three conditions: the codes exist, the fallback is unchanged and the Clause-② path still gets this review, and the change is a pure append in the appender's own section. Reading the claim's "one row" as one block of two entries is right: both codes are stamped, the gate names both sites, and the sibling sections take the same two-entry shape. Right.
  11. The @objectstack/types arm in structuredCodeAnswer. Position: the shared arm table that classifyDataError (behind mapDataError) asks first, and that resolveErrorResponse in @objectstack/rest's error-response.ts (behind sendThrownError and classifiedRefusalAnswer) asks too — so both REST doors answer alike, which the rest parity guard now pins for these two codes. Gate: the arm fires only when the code is one of the two, the error's own object is a non-empty string, the route object is a non-empty string, and the two differ. No earlier arm matches these codes (the table above it runs DELETE_RESTRICTED through INVALID_FIELD), so the arm is reachable, and fiveXxArmDisplacesDeclared4xx is moot for a 4xx arm. Byte-identity, judged against the producer census at the head (non-test): rest-server.ts's apiAccessDenialFromEnable returns a body and never throws, so it is never classified; service-analytics' api-exposure-door.ts throws both codes, and every REST site that classifies it goes through classifiedRefusalAnswer(error) → resolveErrorResponse(error) with no route object, so the gate is false and the passthrough answers as before (the dev's no-route control pin); packages/mcp's stdio bridge stamps at the MCP tool surface, which never asks the shared classifier; core's import-runner.ts asks mapDataError(err, objectName) but produces no exposure error at that site. The only thrower whose object can differ from a defined route object is this PR's refuseUnservedRelationTarget, so the four changed cells are exactly the new producer's and the dev's 14-of-18 reading is consistent with the source. Body: error through armSentence (the sandbox-safe read the sibling arms use), code, the error's own object, and allowed only on 405 and only when it is an array — the REST door's own shape for these codes. Right.
  12. Pins. Unit pin (protocol.relation-condition-target-exposure.test.ts): four refused shapes × two contexts × two filter carriers, asserting code, status, object and allowed and that engine.find is never reached; the grouped branch and an expand entry's own filter; controls (a list-only target, an open target, a plain id comparand on the same lookups) asserting the predicate reaches the engine unchanged. Dogfood pin (real stack, both personas): refused on the list and query routes, matched or not; the body equal to the target's own route refusal; controls evaluated (2 rows / 0 rows); the 403 precedent unchanged. Types pin: two target cases and two byte-identical controls. Rest guards: the door-parity and sandbox-arm-message rows, with the latter's re-derivation count moved 14/11 → 16/13. Enumeration pin: row 4 moves open → decided (list, refuseUnservedRelationTarget, its call site, its pin); the operation type widens to 'get' | 'list'; the decided list grows by one. Ablations A–E are the dev's measurement and were not re-run here (read-only brief); what this record judges is the pins' construction, which cannot pass with the decision bypassed (engine-reach assertions plus the D2 leg). Right.
  13. Reach. Stated on a real stack at base bf515e724d over fixture objects, administrator and member; in-repo producers were not re-censused here (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 census at eae3368a found no lookup into an unexposed object from an exposed one). The bound is stated, not hidden. Right.

② Semver level

③ Boundary flags

  • open_questions. Round 1 carried one (the flat REST body named the route object beside the exposure code): answered by the seat's round-2 ruling (option B as a rider), the claim revised in place to reach packages/types, the arm judged in ①.11. Round 2's list is empty.
  • Round-1 deviations. (a) one ledger block of two entries: answered — right (①.10). (b) a second changeset for the spec ledger value: answered — right (②). (c) the real-stack legs 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 surface: answered — right, beside their sibling legs, test-only, declared to domain:cli on [PM seat] domain:cli — ⏳ vacant #6024 (6105548307). (d) base bf515e724d and one main merge before the PR: no effect, this record judges the net diff. (e) the route-object body: carried into the open question and closed in round 2. (f) the model-free commit trailer: process note, no effect. (g) zero label writes: right, changesets exist.
  • Round-2 deviations. The arm's gate narrower than the claim's "prefer the error's object" (a DEFINED route object): answered — right, ①.11; the seat's premise that every existing producer names the route object was measured false for the no-route analytics door, and the narrower gate is what keeps that door byte-identical. The two packages/rest drift-guard rows: answered — right, the guards' own prescription for a new arm, test-only, declared on [PM seat] domain:cli — ⏳ vacant #6024 (addendum 6106072362). The PR body not patched by the dev: answered — the three edits the dev spelled out are present in the body at this read (the Acceptance-notes bullet, deviation (c), the round-2 verification paragraph); a body edit spends no head. The rest suite in three chunks: process note, complete coverage.
  • The MCP stdio data bridge (out_of_scope_findings, class a, measured at the MCP tool surface in round 2): 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, census row 8 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, Blocked-by: #22737. Right carrier: the same class at a door that never passes the data door, with the fix in packages/mcp — outside this claim's surface and lane — and it waits for this PR so its refusal takes the data door's shape (list, the two codes). It is not a reason to hold this PR, and nothing in packages/mcp changed here.
  • Landing fact, flagged for the owning seat (it does not move this verdict on this head). The PR reads mergeable_state: dirty against current origin/main (e84aeb36ce): fix(plugin-approvals, plugin-audit)!: a lookup title is served only for a target whose declared exposure serves get #22766 (896a4342e6, census rows 5–6) edited the enumeration pin's decided list and CALLERS rows in packages/core/src/security/second-object-read-exposure.pin.test.ts, and this head edits the same lines; a git merge-tree read in the shared checkout (object store only, no checkout, no gate) confirms one file in conflict and protocol.ts auto-merging. The resolution is the union of the two decided lists, with row 4's row beside rows 5–6. That re-merge produces a NEW head, which this record does not govern: a conflict-resolving merge is not the pure-regeneration exception, so the next head owes its own record (expected mechanical — the same diff plus the union) and its own green check-runs before any queue act.
  • Check-runs: all 35 concluded at this read; nothing in progress.
  • Governed surfaces: none of the 12 paths hits a GOVERNED_SURFACES row; Governed Surface Queue Guard concluded success. This record is the Clause-② contract-tier review the narrowing owes before the queue, not a Tier S landing record.

Implemented-by: claude/issue-22737-relation-filter-exposure
Reviewed-by: session_01JfJfBUC3cQ6hhgm9MQK76T

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>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 606e50f7ef6adc4d39bd54586e3b6a76bf3b7cec
Local-runs: none

Supplementary record on the head produced by two conflict-resolving merges of main. Inputs, and nothing else: the prior record 6106147584 on this PR (head 964aceffd6; its judgments stand for everything this head carries unchanged, said so per file below rather than re-derived), card #22737's round-3 and round-4 os-dev-report comments 6106452488 and 6107096768, this PR (body, its 12-file list, the net diff from merge base c74d843997 to the head), the reviewed net diff 9859e4ec42..964aceffd6 compared per file with the head's c74d843997..606e50f7ef, the two merge commits 04cf472669 and 606e50f7ef read as objects (their --cc hunks and a plain three-way merge of each one's parents through git merge-tree --write-tree, an object-store read: no checkout, no build, no test, no gate; none of the 12 paths is routed to the os-regen driver, so no driver ran), origin/main at 5fc57b382e, and the head's check-runs, read 2026-10-11T08:32Z: 35 check-runs, 32 concluded success (Auto Label, Build Core, Check Changeset, Check Documentation Links, Check PR Size, Dogfood Regression Gate and its three shards, Dogfood Verify CLI, Flag docs affected, Governed Surface Queue Guard, Lint and Repo Gates, No other open PR may claim the same issue, No other open PR may claim the same single-writer path, Part-of PR must not also close its card, Spec property liveness, Temporal Conformance, Test Core and its six shards, The card this PR closes must claim this branch, Type Check consumer gates / debt ledger / source gates / workspace, TypeScript Type Check, filter), 3 skipped by their own triggers (Build Docs, Console Pin Gate, Packed-tarball smoke), none in progress. The seven required contexts are all success. Security card: classes, positions and functions only.

① Derived judgments

  1. What this head is. 964aceffd6 is an ancestor of the head; the first-parent chain is 606e50f7ef → 04cf472669 → 964aceffd6; no rebase, no force-push. The PR holds 8 commits: the five content commits the prior record judged plus three merges of main. The merge base is c74d843997 (the head's second parent). Net diff at the head: 12 files, +457/−16, byte-matching the PR file list. The dev's round-3 reading (12 files, +456/−15 against e84aeb36ce) also verified.
  2. Carried, byte for byte. For 11 of the 12 files the head's net hunks (offsets aside) are identical to the reviewed ones, so the prior record's ① judgments stand unchanged for them: the three changesets (①/② there), protocol.relation-condition-target-exposure.test.ts (①.12), protocol.ts (①.1–①.9), second-object-exposure.dogfood.test.ts (①.12), error-response-sandbox-arm-message.test.ts and error-response-structured-arm-door-parity.test.ts (①.11–①.12), error-code-ledger.zod.ts (①.10), data-error-classification.exposure-arm.test.ts (①.12), data-error-classification.ts (①.11). Not re-derived here.
  3. Moved: the census pin packages/core/src/security/second-object-read-exposure.pin.test.ts. Its net change against main at the head is three hunks and nothing else: the header sentence gains the security(data): a nested-relation filter condition on a lookup target is evaluated without asking the target's exposure (census row 4 of #22661) #22737 clause on main's wording; CALLERS row packages/objectql/src/relation-filter-lowering.ts moves open → decided (operation list, decision refuseUnservedRelationTarget in packages/metadata-protocol/src/protocol.ts, wiring this.refuseUnservedRelationTarget(, pin named); and 'refuseUnservedRelationTarget' joins the expected decided list. The operation type widening to get or list that the reviewed diff carried is now main's (security(import): an import's reference resolution matches a lookup cell against a target whose exposure refuses reads (census row 7 of #22661) #22739 brought it identically), so it rightly drops out of the net diff. Right.
  4. The union, as committed. decided names six: refuseUnservedRelationTarget (row 4, list), servesExpansionTarget (get), servesLabelTarget (get), servesPayloadDisplayTarget (row 5, get), servesReferenceTarget (row 7, list), servesSummaryTitleTarget (row 6, get). Zero CALLERS rows are open (the open arm of the type stays, admitting a future census row). CALLERS holds 15 keys. Right.
  5. The merged pin is still a sound enumeration. Applying the pin's own discriminator (a code-line call of referenceTargetOf in a non-test .ts under packages/, docblock and comment lines excluded) to the head's tree yields exactly the 15 CALLERS keys: no unclassified caller, no stale key. packages/lint/src/validate-object-field-refs.ts names the arbiter in a docblock only (unchanged since 9859e4ec42) and is rightly outside. Each of the six decision functions is declared in its named file, that file calls apiExposureDenialReason or canServeApiOperation and imports from @objectstack/spec/data, each wiring call string occurs in its file, and each of the six behaviour-pin files exists in the head's tree. The three holds that make a later read red are intact: an unclassified arbiter caller reds the first test; a decided row whose function does not ask the decision, lacks the import, lacks the wiring call or lacks its pin reds the second; and the decided list is held EXACT (toEqual over six sorted names), so a decision added or removed without editing the list reds it too. The header's stated blind spot (a read whose target is not resolved through referenceTargetOf) is unchanged; the import door moved out of that not-seen paragraph into a decided row, which is main's change and the right direction. CI runs this pin: ci.yml runs turbo run test test:repo, core's test:repo is vitest run --project repo, whose list carries this file; Test Core's six shards and the bare context concluded success on the head. Right.
  6. Round 3 (04cf472669, parents 964aceffd6 and e84aeb36ce) is the union and nothing else. A plain three-way merge of its parents conflicts on exactly one file, the census pin, in one region: the decided-list assertion. The committed tree differs from that auto-merged tree only inside the markers, and there it is main's five-entry multi-line list plus the one line 'refuseUnservedRelationTarget', (+1/−4 against the marked text). Every other file, and every other region of the pin (rows 5–6 decided from main's side, row 4 decided from the branch's side, the type union), is the auto-merge. Right.
  7. Round 4 (606e50f7ef, parents 04cf472669 and c74d843997) is the union and nothing else. The plain three-way merge conflicts on the same file only, in the header sentence. The committed tree differs from the auto-merged tree only there: main's sentence with the security(data): a nested-relation filter condition on a lookup target is evaluated without asking the target's exposure (census row 4 of #22661) #22737 clause added (four lines). The decided list (five plus servesReferenceTarget, six), the type union (both sides identical) and the CALLERS rows (row 7 from main) auto-merged. Right.
  8. Auto-merged protocol.ts against what main brought at the same regions. main's validation: a severity: 'warning' rule's hit is only logged — the data write answer carries no warnings, so no client can show the advisory #13889 calls UI-level #22726 landed type ValidationAdvisoryEvent in the same @objectstack/spec/data import statement (head line 143), six lines below the branch's widened import line (head line 137); both are present at the head. main's other hunks sit in createData, cloneData and updateData (head lines about 13534–13702), roughly 900 lines below the branch's hop call in refuseInternalInFilter (12600), refuseUnservedRelationTarget (12619–12632) and RELATION_CONDITION_TARGET_OPERATION (4475). No shared region; the branch's five hunks are byte-identical to the reviewed ones; TypeScript Type Check and Build Core concluded success on the head. Right.
  9. Auto-merged data-error-classification.ts against what main brought. main brought nothing to this file between the bases; the arm is byte-identical. The producer census of ①.11 re-read at the merged head: the two exposure codes are stamped at protocol.ts (this PR), service-analytics/src/api-exposure-door.ts (classified with no route object), rest/src/rest-server.ts (a body, never thrown) and mcp/src/stdio-data-bridge.ts (never the shared classifier). The deciders main brought for rows 5–7 (servesPayloadDisplayTarget, servesSummaryTitleTarget, servesReferenceTarget) are boolean withholds over canServeApiOperation and throw nothing, so import-runner.ts's mapDataError(err, objectName) site still produces no exposure error. The prior record's "only thrower whose object can differ from a defined route object" conclusion holds at the merged head. Right. error-code-ledger.zod.ts: main brought nothing, consistent with rows 5–7 stamping no code. The remaining eight files: main brought nothing between the bases.

② Semver level

③ Boundary flags

Implemented-by: claude/issue-22737-relation-filter-exposure
Reviewed-by: session_01JfJfBUC3cQ6hhgm9MQK76T

VERDICT: PASS

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 11, 2026 08:37
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 11, 2026 08:37
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 11, 2026
Merged via the queue into main with commit ec7c7e0 Oct 11, 2026
44 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-22737-relation-filter-exposure branch October 11, 2026 09:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

security(data): a nested-relation filter condition on a lookup target is evaluated without asking the target's exposure (census row 4 of #22661)

2 participants