Skip to content

fix(mcp)!: the stdio data bridge asks each nested-relation condition's target its exposure for list (#22777) - #22820

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

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-22777-mcp-relation-filter-exposure

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #22777
Clause-②: yes (narrowing)

Census row 8 of #22661: the decision #22737 brought to the data door, carried to the MCP stdio door. Security card: classes, positions and functions only.

What this changes

  • The bridge asks each condition's target. createStdioDataBridge (packages/mcp/src/stdio-data-bridge.ts): query() and aggregate() call the new enforceRelationTargetExposure right after the addressed object's gate. It finds the nested-relation conditions in where and asks each TARGET enforceApiExposure(metadataService, target, GATED_ACTIONS.query, context, position). That is the gate an addressed object already gets, so the operation is list and there is no second rule.
  • Refused, not withheld. An unserved target refuses the call before the engine is asked, in the bridge's existing envelope: 404 OBJECT_API_DISABLED, or 405 OBJECT_API_METHOD_NOT_ALLOWED with allowedOperations. enforceApiExposure gains an optional condition argument; when it is set, the message names the target, the position (where.FIELD) and the remedy, in the data door's words. No new code.
  • One walk. relationConditionSites moves unchanged from packages/metadata-protocol/src/protocol.ts, where it was module-private, to packages/core/src/utils/relation-condition-sites.ts, and is exported from @objectstack/core. protocol.ts imports it. The data door's behaviour does not change: refuseUnservedRelationTarget is untouched.
  • Untouched: packages/objectql and packages/types.
  • Census pin (packages/core/src/security/second-object-read-exposure.pin.test.ts): the moved walk is a new caller of referenceTargetOf, classified not-a-served-read (it finds sites and reads no record; both doors that call it ask each target the decision). The discriminator does not see the bridge, which resolves targets inside that walk and never calls the arbiter itself. So row 8 does not join as a decided row, and the discriminator's semantics are unchanged. The header's not-seen paragraph names the bridge and where it is held.

Measured on a real stack, before and after

The harness was a scratch probe, not committed. It used a real bootStack over fixture objects (the #22661 dogfood fixture). An administrator and a member each had an API key minted through the key route. The principal was resolved through the chain plugin.ts uses (resolveAuthzContext, then assembleExecutionContext). The real createStdioDataBridge and registerObjectTools ran over an in-memory MCP client/server pair. Not exercised: StdioServerTransport and the process boundary.

  • Before (base 23419bafb7), for both personas, on query_records and aggregate_records:
    • A condition on a lookup whose target is API-disabled, get-only or create-only was evaluated: a match answered rows and a miss answered none. The same held under $and, $or and $not.
    • The same targets addressed directly were refused.
  • After, for both personas and both tools:
    • Every such condition is refused, match and miss alike.
    • Controls: the list-only and undeclared targets answer as before (rows on a match, none on a miss). A condition through an injected owner lookup to a list-served platform object answers as before. A second-level condition is still refused by the engine before anything is read.
  • Caller set. Every stdio call carried isSystem: false and principalKind: human. assembleExecutionContext emits isSystem: false as a literal, and resolvePrincipal throws when it resolves nothing. So the bridge's own isSystem bypass, which this reuses, is unreachable from the stdio door, and every reachable caller gets the verdict 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 gives at the data door.
  • Tool surface. query_records and aggregate_records flatten a thrown error to its message (errorResult(messageOf(err))), exactly as they already do for an addressed-object refusal. The caller sees isError with text naming the target and the position. The code, status and allowedOperations sit on the bridge's error and are pinned there.

Pins

  • packages/mcp/src/stdio-data-bridge.relation-condition-target-exposure.test.ts (27 cases):
    • Refusals: four refused target shapes (API-disabled, ['create'], ['get'], deny-all []), each direct, under $or and under $not, on query and aggregate. That is 24 cases. Each asserts code, status, allowedOperations on 405, the target and the position in the message, and that neither engine.find nor engine.aggregate was called.
    • Controls: a list-only target, an undeclared target, an id comparand and an operator bag each reach the engine with where unchanged, on both verbs.
    • Tool surface: one query_records leg over an in-memory MCP pair. It is refused naming the target and position; a served target is answered.
  • packages/core/src/utils/relation-condition-sites.test.ts (2 cases): sites at the top level and under each combinator, with targets (including the implicit user target), and a control that finds none.

Ablations (each from a committed state, restore proven)

  • A: the bridge's ask removed. In enforceRelationTargetExposure, the walk's result was replaced with an empty list, through scripts/ablation-replace.mjs (anchor 1 → 0, blob cf59e766 → 822adfc1).
    • The new mcp pin went 25 red (the 24 refusals plus the tool leg) and 2 green (the controls). The existing stdio-data-bridge.exposure.test.ts stayed 27/27 green.
    • Restore: blob == HEAD and git diff HEAD empty.
  • B: the shared walk stops descending combinators, on @objectstack/core's dist. Mutated with --hold; core rebuilt; ablation-dist-preflight.mjs found the marker present in 2 built files.
    • Core walker test: 1 red (combinators), 1 green (control).
    • mcp pin: 16 red (exactly the $or and $not cells), 11 green.
    • metadata-protocol's protocol.relation-condition-target-exposure.test.ts: 8 red (its $or filter cases, both contexts), 5 green. This proves the data door now runs the moved walk.
    • Restore: blob == HEAD and git diff HEAD empty; core rebuilt; preflight --absent exit 0; git status --porcelain clean.
  • Reverse type check. A scratch file in mcp assigned an object literal with an extra key to the new RelationConditionSite type. tsc answered TS2353 on that key, which proves mcp reads the rebuilt core declarations. The file was removed, and tsc exits 0.

Verification (final head 2261c253ec, origin/main c11b75870a merged; closure rebuilt after the merge)

  • mcp vitest: 36 files / 417 passed.
  • core: local 94 files / 2364 passed; repo 5 files / 55 passed (the census pin included).
  • metadata-protocol vitest: 225 files passed, 3 skipped / 28129 tests passed.
  • dogfood second-object-exposure.dogfood.test.ts: 34/34.
  • Typecheck: mcp (tsc and check:test-typecheck OK), metadata-protocol (tsc) and core (tsc, examples and check:test-typecheck OK), all exit 0.
  • Gates: the union of the dispatch list (59) and dispatch-gates --commands on this diff (67) is 67 commands. 66 exit 0. check:dual-build-cjs-loads exited 3 (PREREQUISITE NOT MET: the gate needs every package's dist, and 19 packages outside this closure are unbuilt). That is NOT MEASURED, not a pass. The narrowed probe: require() of the CJS entry of core, mcp and metadata-protocol exits 0, and core's CJS entry exports relationConditionSites as a function. dispatch-gates --ran: 67 derived, 66 run, 1 NOT-MEASURED, 0 UNRUN.
  • Lint, narrowed: eslint --no-inline-config --format json over the 7 changed .ts files reports 7 files, 0 errors, 0 warnings. --print-config shows 5 or 6 active rules per file and no parserOptions.project or projectService, so no untouched file's verdict can move.

Deviations

  1. Base. The worktree was cut from origin/main 23419bafb7 (it had moved past the dispatch's 14f4912390). origin/main c11b75870a was merged as 2261c253ec. Since then, main has gained 3 commits that touch none of these 9 paths.
  2. Line budget. 394 changed lines over 9 files (+348 / −46), against a suggested 300.
    • Source is +124 / −45, net +79. mcp is +48 / −3; core's walk is +69, a move offset by −42 / +1 in protocol.ts; core's index is +6.
    • The excess is the moved function's docblock and its exported type, and an mcp pin parameterised over 24 refusal cells.
  3. Positions walked. The walk covers where on query and aggregate. It does not cover aggregations[i].filter: the aggregate_records tool's aggregation schema carries no filter member, and the engine refuses a nested-relation condition there.
  4. One hop. The engine serves one level and refuses a relation beneath a condition before reading. The data door walks deeper only because its internal-field refusal recurses.
  5. The isSystem bypass is kept by reuse. It is unreachable from this door (measured; see above), so no caller gets a different answer from the one 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 gives.
  6. Changesets. @objectstack/mcp is minor, BREAKING, with FROM → TO and the migration, and carries the Clause-② line and the ADR-0087 marker. @objectstack/core is minor (one export) and @objectstack/metadata-protocol is patch (import only, no behaviour change), in one non-breaking changeset.
  7. Labels. None written: the dispatch named none, and changesets exist.

Acceptance notes


Generated by Claude Code

… target its exposure for list

The data door's relation-condition walk moves to @objectstack/core
(relationConditionSites) unchanged, and both doors import it. The stdio
bridge's query and aggregate ask each condition's target the same gate
an addressed object gets, for list, and refuse an unserved target.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JfJfBUC3cQ6hhgm9MQK76T
…relationConditionSites export

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JfJfBUC3cQ6hhgm9MQK76T
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Oct 11, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/core, @objectstack/mcp, @objectstack/metadata-protocol, touching 6 documentable anchor(s). ⚠️ 2 changed file(s) yielded no anchor (packages/core/src/index.ts, packages/metadata-protocol/src/protocol.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

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

  • content/docs/permissions/system-context.mdx (via enforceApiExposure (symbol, a top-level function))
What this run could not see
  • 2 changed file(s) yielded no anchor (packages/core/src/index.ts, packages/metadata-protocol/src/protocol.ts) — pages documenting those are invisible to this run
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • 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 — 39 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 67b669e6890a20f443c0aa9309d6b6238913718a → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 19b8bbf86132b0c20fe3054fabbd383775fc6d32 — the merge of head 2261c253ec75c2412914df2c11a998a9ec9713c6 into base 67b669e6890a20f443c0aa9309d6b6238913718a, 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 19b8bbf86132b0c20fe3054fabbd383775fc6d32 && git checkout 19b8bbf86132b0c20fe3054fabbd383775fc6d32
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 67b669e6890a20f443c0aa9309d6b6238913718a 2261c253ec75c2412914df2c11a998a9ec9713c6 && git checkout -B drift-repro 67b669e6890a20f443c0aa9309d6b6238913718a && git merge --no-ff 2261c253ec75c2412914df2c11a998a9ec9713c6

node scripts/docs-audit/affected-docs.mjs --json 67b669e6890a20f443c0aa9309d6b6238913718a

⚠️ 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 67b669e6890a20f443c0aa9309d6b6238913718a → 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: 2261c253ec75c2412914df2c11a998a9ec9713c6
Local-runs: none

Inputs, and nothing else: card #22777 (body; the claim 6107453288; the os-dev-report 6108171450), the family precedent (PR #22768's contract reviews 6106147584 and 6107185299, and the seat's ACCEPT 6107195645 on #22737), PR #22820 (body; its 9-file list; the net diff from merge-base c11b75870a to the head: 9 files, +348/−46, byte-matching the file list), git show ec7c7e0637:packages/metadata-protocol/src/protocol.ts against the head's packages/core/src/utils/relation-condition-sites.ts and protocol.ts (object-store reads in the shared checkout: no checkout, no build, no test, no gate), origin/main at b7cd1af9df through git merge-tree --write-tree, and the head's check-runs, read 2026-10-11T10:58Z: 33 check-runs, 23 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, 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, Temporal Conformance, 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), and 7 IN PROGRESS (Lint and Repo Gates; Test Core 1/6 through 6/6; the bare Test Core context not yet posted). Judged on what has concluded; see ③. Security card: classes, positions and functions only.

① Derived judgments

  1. The moved walk. relationConditionSites at the head (packages/core/src/utils/relation-condition-sites.ts) against the one ec7c7e0637 held module-private in protocol.ts (lines 4487–4516 there): the function body is identical line for line under diff -w. What differs is the indentation (the data door's four spaces, core's two), the export, the inline return type replaced by the new exported interface RelationConditionSite (the same three members: field, target, condition), the docblock's 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 paragraph, and the three-line private isPlainRecord carried into the module (protocol.ts keeps its own, which has other callers). "Moved unchanged" is right for the logic and the accept set; not byte-for-byte, and the changeset claims the logic, not the bytes. Right.
  2. The data door still calls it at every position it did, and refuseUnservedRelationTarget is untouched. Between ec7c7e0637 and the head the net diff of protocol.ts inside this PR is two hunks: the @objectstack/core import line gains relationConditionSites, and the private function is deleted. The 136-line region holding assertNoInternalFieldEvaluated, refuseInternalInFilter (the one textual call site of the walk, reached for where, the filter alias, each aggregations[i].filter, and each expand entry's own where at every level through refuseInternalInExpand) and refuseUnservedRelationTarget is byte-identical at the two shas (diff empty). A third hunk between those shas (near line 16669) is main's own drift, outside this PR's net diff. The dev's ablation B (the walk mutated on core's dist, the data door's own pin going red) is the measurement that the door now runs the moved walk; this record judges the wiring, which it confirms. Right.
  3. One decision, operation list, before any engine read, on both verbs. enforceRelationTargetExposure (stdio-data-bridge.ts, the function after enforceApiExposure) walks opts.where with the shared walk against the addressed object's field map and calls enforceApiExposure(metadataService, site.target, GATED_ACTIONS.query, context, position) for each site, the position spelled where.FIELD. GATED_ACTIONS.query is 'query' and DATA_ACTION_TO_API_OPERATION.query is 'list' (@objectstack/spec/data), the operation refuseUnservedRelationTarget asks (RELATION_CONDITION_TARGET_OPERATION), for the reason the precedent gave: the engine's lowering reads the target as the caller with the condition as the whole predicate (ObjectQL.lowerRelationConditions runs this.find on the target), the target's own list read. The gate asked is the bridge's pre-existing The two MCP transports run their data verbs through different seams — stdio skips the ADR-0049 apiEnabled / apiMethods exposure gate that HTTP applies #8083 gate, the one an addressed object gets; no new rule. On the face: enforceApiExposure asks resolveEffectiveApiMethods and isApiOperationAllowed directly after its own apiEnabled === false branch, in the order apiExposureDenialReason composes those same two functions; the data door asks the composed face. One decision, two faces; named because the census pin's D2 leg reads only the composed face (①.8). Ordering: in query() the call sits after the addressed object's gate and before storedMetadataBodyRefusal, storedMetadataHashRefusal and engine.find; in aggregate after the addressed gate (GATED_ACTIONS.aggregate) and before the body and hash refusals and engine.aggregate. No engine read precedes it on either verb (the metadata service, not the engine, is read for the field map). Placing it ahead of the [security] Stored-metadata-body family: two exits #21120 did not reach. An engine-only door serves an administrator credential material in cleartext, and the data door serves a content hash computed over the withheld credentials #21207 refusals only decides which refusal wins when both apply; both refuse before the engine. Right.
  4. Positions walked: where only, and that is every filter position this door carries. McpDataBridge.query takes where, fields, limit, offset, orderBy; aggregate takes where, groupBy, aggregations, timezone. The stdio bridge is engine-only and carries no filter alias and no expand (the HTTP bridge's extras, as the file header records). aggregations[i].filter, verified at the head: the aggregate_records tool's aggregation item schema declares function, field, alias and nothing else; strictToolInput applies .strict() to the top-level object only, so an unknown nested key is dropped by zod's default rather than refused, and never reaches the bridge. At the bridge's own interface the engine's EngineAggregateOptions['aggregations'] type does admit a filter, and there the engine refuses a nested-relation condition: relation-filter-lowering.ts's ruling, "An aggregation's own filter and having keep the no-operator-object refusal: the engine evaluates both itself, over rows it already holds". Refused, not lowered, no target read. Deviation (c) verifies. The data door's AST-carrier parse (isFilterAST) has no input here: the tool admits a plain-object where only. Right.
  5. One hop. The engine's admitRelationCondition refuses a site beneath a site (second-level, and the dotted-key spelling) with INVALID_FILTER / 400 before lowerRelationConditions reads anything; the data door recurses deeper only for its internal-field judgement. One level of asking covers every site the engine will run. Deviation (d) verifies. Right.
  6. The isSystem bypass the reused gate keeps. enforceApiExposure returns at once for context.isSystem (The two MCP transports run their data verbs through different seams — stdio skips the ADR-0049 apiEnabled / apiMethods exposure gate that HTTP applies #8083, matching callData's first condition); 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's refuseUnservedRelationTarget takes no caller and refuses a system context too. Reachability from this door, read at the head: the bridge's only context source is deps.resolvePrincipal, wired once in plugin.ts to resolveStdioExecutionContext, which returns assembleExecutionContext over the resolved API-key authz; the assembler's entryFields emits isSystem: false as a literal (assemble-execution-context.ts:318) with no input that can flip it, and resolvePrincipal throws when the assembler returns undefined. The isSystem: true contexts inside resolveAuthzContext are its own internal lookups, not the returned principal. plugin-execution-context.test.ts already pins context.isSystem === false on the stdio chain. So no caller of this door reaches the bypass, and every reachable caller gets 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's verdict. The divergence exists in the function (an in-package host injecting a system principal would bypass) and is unreachable at the head; neither createStdioDataBridge nor enforceApiExposure is published from @objectstack/mcp's index. Right as measured; named. The flat-shape read (def.enable, else def) is the other inherited difference from the door: the bridge can refuse a legacy flat definition the door would serve, stricter, The two MCP transports run their data verbs through different seams — stdio skips the ADR-0049 apiEnabled / apiMethods exposure gate that HTTP applies #8083's, not this class.
  7. Tool surface. query_records and aggregate_records both end in catch (err) { return errorResult(messageOf(err)); }, the same flattening an addressed-object refusal gets; the thrown McpExposureError carries code, status and allowedOperations, and the message names the target, the position (where.FIELD) and the remedy in the data door's words (the consequence suffix added to both branches of enforceApiExposure). The pin's tool leg holds isError and both names over an in-memory MCP pair. Disclosure judged as the precedent did: the lookup's declared target is in the source's describe_object answer already, and the 405 set is what the target answers when addressed. Right.
  8. The census pin. The new row classifies packages/core/src/utils/relation-condition-sites.ts not-a-served-read (finds sites, reads no record), naming both callers in its reason. Applying the pin's own discriminator to the diff: the new file calls referenceTargetOf on a code line, so an unclassified head would red test 1 and the row is owed; protocol.ts still calls the arbiter at its expand sites, so its decided row does not go stale; the decided list stays exact at six; stdio-data-bridge.ts calls neither the arbiter nor either face of the decision, so the discriminator does not see it, as the dev says. Not decided, for two structural reasons the dev's one covers: a CALLERS key is one file with one decision, and the walk file serves two doors; and a decided row for the bridge would fail the D2 leg's letter, which reads apiExposureDenialReason / canServeApiOperation and not the two constituents the bridge's The two MCP transports run their data verbs through different seams — stdio skips the ADR-0049 apiEnabled / apiMethods exposure gate that HTTP applies #8083 gate asks (①.3), and converting that gate to the composed face would be a change to the gate "it already applies", outside the card's direction. Soundness: a later read that resolves its target through referenceTargetOf still turns the pin red, unchanged; a later door that reaches the engine's lowering without the data door and without the arbiter was invisible before this PR (the bridge itself was, which is the card's premise) and stays invisible after it, so publishing the walk opens no blind spot the pin did not already have, and the header now names the class instead of discovering it later. Held rather than named: the bridge's ask is held by stdio-data-bridge.relation-condition-target-exposure.test.ts, 24 refusal cells (four target shapes: API-disabled, create-only, get-only, deny-all; direct, under $or, under $not; query and aggregate), each asserting code, status, allowedOperations on 405, the target and the position in the message, and that neither engine.find nor engine.aggregate was called. The get-only cell is what reds a switch of the operation to get; the aggregate cells red a dropped second call. Controls hold a list-only target, an open target, an id comparand and an operator bag reaching the engine with where by reference. The dev's ablation A (25 red / 2 green) is the measurement; not re-run here. The walk's own pin (relation-condition-sites.test.ts) holds the top level, each combinator, the implicit user target and the no-field-map stand-down. The new row's prose names enforceRelationTargetExposure without a wiring check; that is the header's "named, not held" and the mcp pin is the hold. Right.
  9. Public surface. @objectstack/core gains relationConditionSites and the interface RelationConditionSite via export * from index.ts, beside the sibling door helpers (object-not-found, record-not-found); the module imports REFERENCE_VALUE_TYPES and referenceTargetOf from @objectstack/spec/data, a dependency core already declares and exercises (import-field-meta.ts is a classified arbiter caller). @objectstack/mcp: enforceApiExposure's optional fifth parameter is in-package (not exported from index.ts), so the package's published change is the accept set at the stdio tool surface only. @objectstack/metadata-protocol: the walk leaves its dist and is imported; no exported member moves. @objectstack/objectql and @objectstack/types untouched, as claimed. Right.
  10. Reach. Stated on a real stack at base 23419bafb7 over the 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 fixture, administrator and member, both tools, through the plugin's own principal chain over an in-memory MCP pair; the stdio byte transport and the process boundary not exercised, the bound 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's dev stated when it found this door. Right; the bound is named.

② Semver level

  • .changeset/22777-mcp-stdio-relation-condition-target-exposure.md: @objectstack/mcp minor, ! summary, **BREAKING** banner, FROM (both tools judged the addressed object only) → TO (each condition's related object asked the addressed gate for list, refused with the existing pair), a migration paragraph, the PR's Clause-②: yes (narrowing) line, and exactly one ADR-0087 marker, adr-0087: not-required (no-migration-prescription) in an HTML comment. Right: a narrowing is breaking and the launch window ships breaking as minor (check-changeset-no-major.mjs); Check Changeset, which runs that gate and check-adr-0087-registration.mjs against the merge base (pr-automation.yml lines 979–980 and 1130), concluded success on this head. The marker's honesty, judged by the checker's own header rather than by its silence: the category is refused "when the changeset's own body carries a migration prescription" in the sense of "instructions for rewriting a consumer's code", an old-to-new pair, a rename or a removal, the thing a ledger entry would drive objectstack migrate meta to rewrite. This body moves no spec key, spelling, export or stored shape; its **Migration.** sentence names a runtime request the caller can already write and an opt-in (list on the related object's enable.apiMethods) that already exists; and the marker argues that in writing and closes the other four categories on facts (published; no id named; no exported declaration removed or narrowed), which is what the header's fix(service-package): classify a publish driver fault as 5xx and stop returning driver text as caller data (#8131) #8277 paragraph asks of an author relying on this category. The imperative-sentence residue the header calls "deliberately out of reach" is where this body sits, and the marker does not rest on it. Same shape as 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's changeset, which both precedent records and the ACCEPT held right. Honest.
  • .changeset/22777-core-relation-condition-sites.md: @objectstack/core minor, @objectstack/metadata-protocol patch, one non-breaking changeset. Right: one additive export (the yes of the PR's line) is the floor for minor; metadata-protocol ships a different dist (the function replaced by an import) with no behaviour change, and patch is the honest floor for a released package whose bytes move. The core export's name, module and exported type are the right published surface (①.9): the name the data door already used and the card named, the type naming what the inline type carried, the module where core keeps the helpers both doors share.
  • Clause-②: yes (narrowing) on line 2 of the PR body and in the breaking changeset. Right value, right arm: the value answers whether the card widens an accept set or the public surface (clause2-line.mjs), and core's export does; the arm names the mcp narrowing, which is the breaking fact; the ADR-0087 checker's own self-test names this exact spelling as "a diff that widens AND narrows". Deviation (i) is answered.

③ Boundary flags

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

VERDICT: PASS

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

2 participants