Skip to content

fix(objectql)!: expand and the master-detail parent root never hydrate a stored-metadata-body table - #24382

Merged
objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-24358-family-hydration-refusal
Oct 18, 2026
Merged

objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/issue-24358-family-hydration-refusal

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #24358

Clause-②: no (narrowing) (owes one CONTRACT_REVIEW_TIER review before the queue)

⚠️ Security card. This body keeps to the card's disclosure line: category and position only. No request, field path or value is written here. Tests use synthetic placeholders only.

What changes

The stored-metadata-body family (STORED_METADATA_BODY_OBJECTS) is projected at its doors, but only for the object a request names. Two relationship hydrations in packages/objectql read a family row as an ordinary row. Each now refuses a family target with the one family predicate (isStoredMetadataBodyObject), as PR #24344 does at the one-hop loader.

No second projection. Nothing in packages/spec, no new error code, and no edit to the doors' projection or the reader-context seam. resolveMasterDetailRelation is unchanged: the refusal sits at the reads, so the relation's other readers keep their answers.

Reach, measured before the fix

Live over HTTP, on main a2a3777bf (app-todo, SQLite, --fresh), with a synthetic stored datasource-type row carrying a placeholder credential, and a control row without one. "Served" means a value the family's own door withholds from that same reader: the credential material or the unkeyed hash. For every reader below, the family's own door served 0 such values.

Exit 1 (expand). Rows of the carrier that served a family value. Each cell holds for the list read with both expand spellings, the single-record read and the nested query form.

Reader Session API key
Administrator YES 1/1 YES 1/1
Member granted read on the family table YES 1/1 YES 1/1
Default member (read on the carrier only) NO 0/1 (stored id) NO 0/1 (stored id)
  • The MCP tools declare no expand parameter (read in code), so the MCP reader is not an entry point.
  • A hook body's ctx.api read reaches the same loop through find. It is pinned at the engine, not measured over HTTP.

Exit 2 (the parent root). The verdict for the same write under the header holding the placeholder and under the control header:

Reader Verdict depends on the row?
Administrator YES: requiredWhen 2/2 writes differ; readonlyWhen over the stored hash 2/2 differ
Member granted read on the family table YES: requiredWhen 2/2 differ
Default member NO: the same unbound refusal under both headers

Why #24316's dev saw "refused as unbound": the write's first header read is the caller's own read door (#22519, masterDetailHeadersCallerReads). For a caller whose door does not return the family header, parent stays unbound. For a caller whose door does return it, the second, elevated read binds the whole row.

Producers. Each door, for a lookup and for a master_detail into a family table:

  • metadata save door: ACCEPTED 1/1 for each;
  • os validate and os build: ACCEPTED, exit 0, for both;
  • package install over HTTP: its body refuses inline object definitions (400). An installed artifact's objects arriving by another path: NOT MEASURED.
  • In-repo producers declaring such a reference: 0. As a control, references to an ordinary platform table appear in 26 files.

Exposure.

After the fix

Live, after rebuilding objectql, on a fresh backend:

  • Exit 1: 0 family values served across all 6 reader and auth cells and all 4 read forms. The field answers with the stored id.
  • Exit 2: the same refusal under both headers, for all 3 readers.
  • Both save doors still accept the field. That is the route decision below.

Route, by the four axes

The engine guards were chosen. The author-time refusal (a lint rule refusing a reference or master_detail into a family table at save) was not added here.

  • Business need: 0 in-repo producers declare such a reference, so there is no pull for a new authoring rule.
  • Long-term: the engine is the one place every producer reaches, including an installed artifact that runs no authoring rule. So the guards are owed under either route.
  • AI error-proofing: a loud refusal at save would teach the author sooner. The trade-off is recorded for the seat and the maintainer rather than taken here.
  • Startup focus: a new authoring-rule surface is held back by default.

Verification (head b3af029a0, after merging origin/main)

  • @objectstack/objectql test: 443 files, 8337 passed.
  • @objectstack/metadata-protocol test (before the merge, at 61df76467): 244 passed and 3 skipped files, 20641 passed and 19 skipped. The family pin was re-run after the merge: 9 passed.
  • New engine pin engine-hydration-stored-metadata-body.test.ts: 22 passed, with PR fix(objectql)!: the one-hop read through a reference never reads a stored-metadata-body table; the lint refuses it #24344's pin beside it (32 together).
  • typecheck: objectql exit 0 (its tests are covered by check:test-typecheck) and metadata-protocol exit 0 (the pin is in its tsc program).
  • dispatch-gates --commands: 70 derived, 70 run, each exit captured before any pipe, all 0. --ran printed "70 derived, 70 run, 0 NOT-MEASURED (a DERIVED zero)".
  • Ablations (scripts/ablation-replace.mjs; each mutation landed and was restored to the HEAD blob with git diff HEAD empty):
    • expand guard: 8 cases red;
    • single-id header guard: 4 red (the update path);
    • batch header guard: 4 red (the insert path);
    • preview guard: 4 red. Its first attempt was an anchor miss, which the tool refused: nothing was written, and it was re-run with the right anchor.

Acceptance notes

  • The preview guard in resolvePreviewParents is outside the claim's named functions. It is the third binder of the same parent root, in the same file.
  • A detail whose master is a family table and whose field rule reads parent now refuses every write. That is the fail-closed answer a default member already got before this change.
  • The author-time refusal is left for the seat and the maintainer to weigh. No producer exists today.

🤖 Generated with Claude Code

https://claude.ai/code/session_01MHGpe6k6Yrmt3TsGu3wgYP


Generated by Claude Code

… a stored-metadata-body table

Claude-Session: https://claude.ai/code/session_01MHGpe6k6Yrmt3TsGu3wgYP
Co-authored-by: Claude <noreply@anthropic.com>
…ngine hydrations, and the changeset

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

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

4 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run.

What this run could not see
  • 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 — 17 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 5da2e88818a006145330e3f323759ebef4d6ca87 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 5da2e88818a006145330e3f323759ebef4d6ca87

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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: b3af029a091d7728d2d51f9c27426f82f94415b4
Local-runs: none
Scope: packages/objectql/src/engine.ts, packages/objectql/src/engine-hydration-stored-metadata-body.test.ts, packages/metadata-protocol/src/stored-metadata-body-family.pin.test.ts + packages/objectql/src/master-detail.ts, packages/objectql/src/validation/rule-validator.ts, packages/spec/src/kernel/stored-metadata-body-objects.ts, packages/metadata-protocol/src/protocol.ts, packages/plugins/plugin-security/src/security-plugin.ts, packages/runtime/src/sandbox/body-runner.ts, packages/runtime/src/stored-metadata-body-boundary.ts, packages/runtime/src/stored-metadata-reader-seam.ts, packages/runtime/src/action-execution.ts, packages/core/src/utils/import-runner.ts, packages/mcp/src/stdio-data-bridge.ts, .changeset/24358-family-hydration-refusal.md (prose face, judged under ②)

Security card: this record keeps to category and position. It names symbols, files, reader classes, releases and dispositions only; no request string, no path into a stored body, no value.

Read-only shape held: the net diff against the merge-base 6868514b1c (4 files, +286/-3), the card and its four comments (triage 6251978797, claim 6252365137, dispatch 6252390178, report 6253491408), the PR body and file list, sibling #24316 and PR #24344's record 6251578116 as the model, and the 40 check-runs on this head: 34 success, 6 skipped (conditional or opt-in jobs: the dogfood shard matrix, Console Pin Gate, Build Docs, Temporal Conformance, Dogfood Verify CLI, the packed-tarball smoke), 0 failures, 0 in progress. The five required contexts (Lint & Repo Gates, TypeScript Type Check, Test Core, Build Core, Governed Surface Queue Guard) are all success, as are Check Changeset and the claim guards. Nothing built, run or re-run. The dev's report and the PR's reach tables were read as claims; every judgment below is from the cited code at this head.

① Derived judgments

Exit 1, expandRelatedRecords — right, at every depth and from every entry point. The new branch (engine.ts:13001) sits after the three pure checks the loop already made (fieldDef, REFERENCE_VALUE_TYPES, referenceTargetOf) and before any id is collected (allIds, 13004) or any find is issued, so a target the family predicate names is never the subject of a sub-read. Nesting recurses into the same function with the target as source (13117), so the guard runs at every level up to MAX_EXPAND_DEPTH. A repository-wide grep finds exactly three callers: the recursion, find (14112) and findOne (14399); no write response expands. Every door that carries an expand tree reaches one of those two: the data door's list read (protocol.ts:14501-14507, after assertExpandTargetsExist and servedExpand), its single read (14922-14934, into engine.findOne), an action's data read (action-execution.ts:390, through the door), the import runner (import-runner.ts:164, into engine.find), the hook body's ctx.api repository facade (body-runner.ts:1075, options passed through to ql.find), and the nested form (fillQueryAstDefaults, 13900-13912, rebuilds the nested map the loop recurses into). The MCP package declares no expand option on any tool; its stdio bridge is engine-only and names expand only as something the HTTP layer has (stdio-data-bridge.ts:50) — the dev's "not an entry point" is exact. The data door's projection is unchanged and still keyed on the requested object only (redactStoredMetadataRows, protocol.ts:14796; redactStoredMetadataRow, 14942), as the claim required.

What the field answers, and that it discloses nothing — right. continue leaves record[fieldName] untouched: the stored id, for a single and a multiple-valued reference alike. The branch runs before any read, so an existing, an absent and a forbidden family row answer byte-identically, and identically to the two answers the loop already gives — the door's servedExpand dropping an unexposed target (#22661, id kept) and the loop's own catch retaining the id after a refused sub-read. The door's expand gate (assertExpandTargetsExist, protocol.ts:13419-13469) still admits the field (it exists, is a reference, has a target), so the response is the ordinary 200 with the bare id and nothing names the target. The only observable difference from before is the absence of the warn log a refused sub-read used to leave, which no caller receives. The engine pin's familyReads() counts every driver call on the family table and asserts 0 for the expand cases under a member and under SYSTEM; servesNothing asserts no placeholder reaches any result; nested depth 1, findOne with a projection naming the two protected columns, and the multiple-valued reference are all driven.

Exit 2, the parent root — right, in all three binders, for every caller. resolveMasterDetailParent (9762): the guard at 9777 follows resolveMasterDetailRelation and precedes masterIdOf, read 1 (masterDetailHeadersCallerReads) and read 2 (the elevated whole-row findOne). resolveMasterDetailParents (9826): the guard at 9837 returns the unbound closure before any id is collected. resolvePreviewParents (9914): the guard is folded into the condition at 9935, so neither its read 1 (a caller-context whole-row find on the master, 9939-9942) nor its read 2 runs, and headerOf answers null for every id. Each guard sits ahead of the context?.isSystem short-circuit inside masterDetailHeadersCallerReads (9572), so a SYSTEM caller — which previously went straight to read 2 — is refused exactly as a member is; the pin runs every case under both. The callers are the insert (15949, gated on hasParentScopedRequiredWhen), the by-id update (17437 through settleMasterDetailLanding.resolve, 17457 for the repoint's previous header), the bulk update (17739, 17751) and the preview (14956); settleMasterDetailLanding (9665) reads only rel.fk off the relation. With parent unbound, a requiredWhen is unevaluable and the write is refused with VALIDATION_FAILED and the fixed text at rule-validator.ts:3957-3960, which names the slot and the field and never the row; a readonlyWhen is LOCKED (master-detail.ts header, #4889), so the field is stripped identically whatever the header holds. The pin asserts the insert refusal is toEqual across the holding and the control header, the update answers are equal, and the preview's two outputs differ only by the header id. No verdict depends on the row.

The family reads a detail write still makes — id-only, right. The write's reference check (assertReferencesResolve, 9394-9480) asks, for a master_detail, masterDetailHeaderExists: the caller's read door projected to id (masterDetailHeadersCallerReads, fields: ['id']), then referenceExists (elevated, fields: ['id']). A caller whose door does not return the family header gets reference_not_found whether or not the row exists — the same bit the family's own door gives that caller — and nothing beyond id is ever materialised. The pin's familyBodyReads() permits exactly an id-only read and asserts 0 others. Right, and consistent with the dev's explanation of #24316's "refused as unbound" (the caller read door, #22519).

Every other place a reference target is read by id — searched, none unguarded. referenceTargetOf has exactly three call sites in engine.ts: 9452 (the write's existence probe, id-only), 10135 (resolveOneHopRelated, guarded at 10145 by PR #24344's branch, 69147abf25, which is in this PR's base and precedes that loader's own masterDetailHeadersCallerReads call at 10233) and 12991 (this exit). resolveFileReferences reads sys_file only. A hook body's ctx.title() related read (body-runner.ts:291) goes through the body api, where a family object is refused as a subject by storedMetadataBodyReadRefusal (stored-metadata-body-boundary.ts:131, applied at stored-metadata-reader-seam.ts:185/241/319). plugin-security's by-id reads (fetchRecord 5023, the pre-image 7042, checkAuthoredRowWrite 5474) read the addressed object, never a reference target, and its controlled-by-parent derivation reads master ids only (4180). No binder or hydration that reads a family row whole by reference remains in the engine or the doors read.

Non-family references unchanged on both exits — right. Both guards key on isStoredMetadataBodyObject alone, the one membership set (STORED_METADATA_BODY_OBJECTS, two tables, stored-metadata-body-objects.ts:47-54; no second list). The pin drives an ordinary lookup on the same carrier (other, still served as the related object) and an ordinary master (probe_ordinary_detail, whose requiredWhen over the header is still judged: refused without the field, accepted with it, the refusal not the unbound-parent text). resolveMasterDetailRelation (master-detail.ts:220) is unchanged, so the relation's other reader keeps its answer, as the PR says.

The family pin — right. FAMILY_SURFACES 26 → 28 (8 original + 15 of #21207 + 3 of #24316 + 2 here, counted). Each new row names its owning pin, @objectstack/objectql (engine-hydration-stored-metadata-body.test.ts), the file this PR adds, with dispositions withheld (the id is kept) and refuses (no verdict), both in the pin's vocabulary. The header's per-package list gains the same pin. The surfaces the rows name — the carrying object's data read in both expand spellings and the nested form, findOne, a hook body's ctx.api read; requiredWhen / readonlyWhen on insert and update, the validate() preview — are exactly the drives the engine pin makes, and together with #24316's three rows the enumeration now names every exit the card and this record name.

② Semver level

Right. The changeset bumps @objectstack/objectql minor, carries the ! summary, the **BREAKING** (a served-set narrowing) banner and Clause-②: no (narrowing); the PR body carries the same line with a trailing parenthetical, and scripts/pm/clause2-line.mjs's reader resolves both to value no, arm narrowing. no is truthful: nothing widens; a served value (the expansion) and a verdict (a parent rule over a family header) narrow. ADR-0087's ratified launch-window section (amended 2026-09-13, #18003) states the level: pre-GA a break ships minor carrying the banner and its disposition, scripts/check-changeset-no-major.mjs refuses a major, and the fixed group is at 18.0.0-next.3. The disposition not-required (no-migration-prescription) is honest on the facts the changeset states: no spec key, export, option, object definition or stored shape moves, so objectstack migrate meta has nothing to rewrite; the "What to do" paragraph is an authoring repair (read stored metadata through the metadata API, where the body is projected; the id read is unchanged) and not a FROM → TO rewrite — the registration gate refuses this exemption only when the body carries a migration prescription, and it is green on this head because the claim is true, not on a detector miss. No changeset for @objectstack/metadata-protocol: its only change is a test file, which publishes nothing; Check Changeset is green. The ADR-0087 handling is the model record's, and PR #24344's, exactly.

③ Boundary flags

Disclosure line — kept. The PR body, the changeset, the card's four comments, every code comment, every test name and both pin rows describe category and position: which reader classes were served before the fix (an administrator and a member granted read on the family table, by session and by API key; a default member not), with counts, and which disposition each surface now has. No request string, no value. The engine pin's fixture is a synthetic placeholder body plus a control, in the shape the family pin already publishes on main (its storedDatasourceRow), and the two columns its predicates read are the spec's published constants (STORED_METADATA_BODY_COLUMN, STORED_METADATA_HASH_COLUMNS), named in the family pin's header on main. The changeset's prescription names the metadata API's public read route, the same prescription PR #24344's lint message carries. Not a FAIL finding.

Deviations — each judged.

  • resolvePreviewParents outside the claim's named functions: right, and required. It is the third binder of the same parent root in the same file, and its read 1 is an ENGINE find under the caller's context — the engine applies no family projection, so for a caller whose own read returns the family row that read fetched the whole row and servedAsStored then kept every column, so the preview judged a parent predicate over the body before the guard (the dev's ablation: 4 red). Reported as the claim required.
  • No @objectstack/metadata-protocol changeset: right (test-only change).
  • Branch cut from a2a3777bf rather than the dispatch's a21fd6c531: both are on main, a2a3777bf three commits newer; the PR then merged 6868514b1c and this review reads the net diff against that merge-base. No contract effect.
  • Eight packages built for the live backend, the detached primary checkout, no subscribe_pr_activity, and the model-free commit trailers (AGENTS.md's pair over the harness reminder, which is not the repo's rule): process notes; right where a rule applies, no contract effect otherwise.

Open questions — answered.

  • (a) Author-time route — A (engine guards only, landed) vs B (an authoring rule refusing a lookup or master_detail into a family table at save, declared to domain:spec). The dev's reading that axes 3 and 4 conflict is right, and it does not block this PR: the card's direction named the engine refusal with the one predicate as the route and the author-time refusal as the alternative the lane weighs; the engine guard is owed under either (an installed artifact runs no authoring rule — record 6251578116 ①, settled in code there); and with A the save doors leave a stored id that never expands and a parent rule that refuses every write loudly at the first write, not a silent pass. B would add a new verdict surface in packages/lint and a domain:spec declaration, which is the maintainer's call. The owning seat should carry it to the maintainer as a needs-user-decision with the four-axes reading rather than leave it only in the report; it is not a defect of this PR.
  • (b) Withhold the whole expansion (A, landed) vs expand with the family projection applied (B): A. B is the second projection the card forbids, at the one layer the family's direction says the projection never runs (the reader-seam header: at the doors and the seam, never at the engine). A matches 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 withheld answer and PR fix(objectql)!: the one-hop read through a reference never reads a stored-metadata-body table; the lint refuses it #24344's whole-table refusal, and the stored id stays readable. Measured pull for B: 0 producers.

Out-of-scope findings — judged. Not filed by this review (no GitHub writes).

Exposure — the dev's claim, checked against this repository's tagged trees at category level. The clone's commit history is shallow (horizon 2026-09-10), so no windowed count is answered here; the release tags are present, and their trees were read. Exit 1: expandRelatedRecords is absent in engine.ts at @objectstack/objectql@3.0.11 and present at 3.1.0, matching the dev's tarball bisect. The door projection redactStoredMetadataRows is absent in protocol.ts at @objectstack/metadata-protocol@17.5.0 and present at 17.6.0, 17.7.0 and 18.0.0-next.3 (introduced by cfad7de9bc, #21086). At @objectstack/objectql@17.6.0, 17.7.0 and 18.0.0-next.3 the expand loop carries no family check, so exit 1 bypasses the projection in every published release that carries it, latest and next included. Exit 2: resolveMasterDetailParent is absent at 17.0.0-rc.2 and present at 17.0.0-rc.5 (no rc.3 or rc.4 tag in this clone; the dev's rc.4 lies in that bracket) and carries no family guard at 17.6.0, 17.7.0 or 18.0.0-next.3; resolvePreviewParents exists only from 18.0.0-next.3 (da989bbb24, 2026-10-09), so the preview binder is in next only. Triage named this reading as the input to any change to the next cut; that is the seat's and the maintainer's release question (Prime Directive #15), not this record's.

One observation, not a defect of this PR. The plain-lookup existence probe (referenceExists, elevated, projected to id) is a pre-existing existence oracle on any reference target for any writer of a carrier, not specific to the family and not a read of the body or the hash; the master_detail probe asks the caller's door first. Outside this card's position; noted for the seat.

Neither exit named by the card remains open at this head: both hydrations refuse a family target with the one predicate, no second projection, nothing in packages/spec, no new error code, no edit to the doors' projection or the reader-context seam, and the epic:#15194 regions of engine.ts untouched, as the claim required.

Implemented-by: claude/issue-24358-family-hydration-refusal
Reviewed-by: session_01JtSQQ3EWsbNPzyYMug1tXy

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对 · domain:engine seat 1 · session_01JtSQQ3EWsbNPzyYMug1tXy · 2026-10-18T19:16Z · ACCEPT

⚠️ Security card. This comment keeps to the card's disclosure line: category and position only. It carries no request, field path or value.

Head: b3af029a09 (the fix, the pin and changeset, then a merge of main). Card #24358, claim 6252365137, dispatch 6252390178. Dev report: 6253491408.

Item Reading
Shape Draft PR → main. The body opens Fixes #24358. 4 files, +286 / −3. Not governed (check-governed-merges.mjs --pr 24382: 0 of 4 paths).
Scope The claim's file surface: packages/objectql/src/engine.ts (expandRelatedRecords, resolveMasterDetailParent, resolveMasterDetailParents), a new engine pin, the family enumeration pin (26 → 28 rows), and one changeset for @objectstack/objectql. The one function outside the claim's named list, resolvePreviewParents, is the third binder of the same parent root in the same file; it is reported as a deviation and accepted (below). There is no second projection, no packages/spec edit and no new error code, and the doors' projection, the reader-context seam and the epic:#15194 regions of engine.ts are untouched.
Seat's own read At the head, the family predicate guards four places. In expandRelatedRecords it sits after the reference-target checks and before any id is collected or any find is issued. In resolveMasterDetailParent, resolveMasterDetailParents and resolvePreviewParents it sits before any header read. expandRelatedRecords has exactly three callers: its own recursion, find and findOne. So every depth and every entry point that reaches the loop is covered.
Reach (measured before the fix) Per the report: on both exits, the reader classes #24316 named were served (an administrator, and a member granted read on the family table, by session and by API key). A default member was not. Both save doors and os validate / os build accepted the carrier. After the fix, no reader class is served on either exit, and the parent root gives every caller the same refusal.
Review face The narrowing. CONTRACT_REVIEW_TIER record 6254034266 on this head is PASS. The seat verified its central claims at the head (the row above).
Declaration Clause-②: no (narrowing), on the claim, the PR body and the changeset alike. The changeset is minor, BREAKING (a served-set narrowing), with the adr-0087 not-required (no-migration-prescription) marker; the record ② finds both right.
Pins and ablation The engine pin has 22 cases, including no family read at all on the expand cases and no body read on the parent cases, under a member and under SYSTEM, with ordinary-target controls. Four ablations, one per guard, each turned its own cases red. Each restore read back as the HEAD blob.
Gates Dev at b3af029a09: 70 derived, 70 run, 0 NOT MEASURED. Typecheck exit 0 for objectql and metadata-protocol.
CI At this read: 40 check-runs on b3af029a09, 34 success, 6 skipped, 0 failed. The skips are the rostered path and opt-in skips. Test Core, every Type Check lane, Lint & Repo Gates, Check Changeset and Governed Surface Queue Guard are green.
Disclosure line The seat checked the PR body, the changeset, the dev report and the record: each keeps to category and position. Fixtures use synthetic placeholders only.

Disposition:

  • open_questions:
    • (b) Withhold the whole expansion, keeping the stored id: A, as record 6254034266 answers it. A projected expansion would be the second projection the card forbids, at the one layer the family's projection never runs.
    • (a) The author-time route (also refusing a lookup or master_detail into a family table at save) is not blocking. The engine guards are owed under either answer, because an installed artifact runs no authoring rule. Whether to add that authoring rule is put to the maintainer by this seat; any follow-up is its own card.
  • deviations: all accepted, as record ③ judges them:
    • resolvePreviewParents gets the guard. Its first header read was a caller-context engine read that would otherwise have fetched the family row (4 red before the guard).
    • No @objectstack/metadata-protocol changeset: its change is test-only.
    • Process notes only: the newer cut point (both points are on main), the eight packages built for the live backend, and the detached checkout.
  • out_of_scope_findings: the package install door is settled in code by record 6251578116 (①). The engine guards hold whatever door admits the carrier. The cloud pin reading is the seat's and triage's.
  • Exposure: the report and record ③ place both exits in published releases. That is a release question for the maintainer, and this seat has put it to them.
  • Noted, not a defect of this PR (record ③): a plain lookup's write-time existence probe is a pre-existing id-only existence check on any reference target, not specific to the family.

Landing: ordinary queue (pr_ready + automerge_enable through the relay). Every check on this head is green or a rostered skip. Fixes #24358 then closes the card.


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 18, 2026 19:17
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 18, 2026 19:17
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 18, 2026
Merged via the queue into main with commit cf16a0d Oct 18, 2026
41 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-24358-family-hydration-refusal branch October 18, 2026 19:48
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