Skip to content

fix(lint)!: a lookup or master_detail into a stored-metadata-body table is refused at author time - #24800

Merged
objectstack-fleet[bot] merged 9 commits into
mainfrom
claude/issue-24677-reference-into-metadata-body-refused
Oct 20, 2026
Merged

objectstack-fleet[bot] merged 9 commits into
mainfrom
claude/issue-24677-reference-into-metadata-body-refused

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #24677

Clause-②: no (narrowing)

The authoring doors refuse a lookup or master_detail field whose reference is a stored-metadata-body table (STORED_METADATA_BODY_OBJECTS: sys_metadata, sys_metadata_history). This is the rule the maintainer ruled B on #24358 (「24358 同意」, then 「ok」 to its shape), pulled into 18.0 on 「都派」 (record 6282780489). Claim 6282819504.

What changes

  • @objectstack/lint. The expression rule's per-object pass (runStackExpressionPasses in validate-expressions.ts) refuses the field. It sits beside the [security] Stored-metadata-body family: a formula field's one-hop read through a reference is an exit the family projection does not reach — reach not yet measured, detail withheld pending maintainer #24316 hop refusal, keyed on that refusal's own collector, storedMetadataBodyReferencesOf, and its per-object index, so the two refusals judge one set.
    • The finding is located at the key the author edits: object 'NAME' · field 'FIELD' reference.
    • It is reported under the existing expression-invalid rule, so there is no new error code and no new registry entry. It rides the object crossing the rule already has (runtimeTypes is unchanged), so os validate, os build and the object save door give the same finding.
    • The message names the field, its type, the target and the rule, and ends on STORED_METADATA_BODY_PRESCRIPTION.
    • The pass runs after masterDetailCount, so an unreadable reference carrier is still refused by the pass that owned that verdict first. It reads the field's type from the walk's field-type index rather than off the field (the validate-expressions / validate-security-posture 也有同形的 spec 不声明键的 ?? 别名读法(#5009 建议 3 的核对结果) #5017 receiver meta-test pins the keys read off f).
  • @objectstack/spec. The FieldSchema.reference describe and its TSDoc state the convention. This is the field's own reference at field.zod.ts:1476; :989 is a grid cell column's. The reference pages were regenerated by gen:docs only (content/docs/references/{data/field,data/object,system/migration}.mdx, the describe line and nothing else).
  • Docs. content/docs/data-modeling/field-types.mdx: the reference rows of lookup / master_detail, plus a short section saying why the reference is refused.
  • Riders, declared on [PM seat] domain:engine — 🟢 os-project-manager #6367 (comment 6282830389). Test files only, ⛔ no protocol.ts change:
    • a door block in packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts, through the real saveMetaItem / publishMetaItem;
    • one row in the family enumeration pin stored-metadata-body-family.pin.test.ts (28 to 29).
  • ⛔ Not touched: the engine guards (they stay, for every path that runs no authoring rule), object keys, apiEnabled, and error codes.

Premises, re-measured

P1: the doors accepted it. Measured before the change, at base 4b114eda57. Probes: one object carrying meta, a lookup or a master_detail into each family table; the controls point to sys_user.

Door Before (4b114eda57) After (head 5e54169edc)
os validate (CLI built from the tree) 4/4 family probes exit 0; controls 2/2 exit 0 4/4 exit 1, refused at object 'probe_carrier' · field 'meta' reference under expression-invalid; controls 2/2 exit 0
os build 4/4 exit 0, artifact written; controls 2/2 exit 0 4/4 exit 1; controls 2/2 exit 0
PUT /api/v1/meta/object/NAME on a fresh pnpm dev:crm -- --fresh backend, as the dev admin 4/4 200; controls 2/2 200; gate-live control (a sqrt(record.amount) formula) 422 4/4 422 INVALID_METADATA, with the issue at object 'probe_…' · field 'meta' reference and the message ending on the prescription; controls 2/2 200
lint's object-door gate in process (runRuntimeAuthoringRules) 0 errors for all 4 and both controls 1 error for each of the 4; 0 for the controls

P2: where the hop refusal runs. refuseStoredMetadataBodyHop and its collector index live in runStackExpressionPasses (validate-expressions.ts, about :1934 and :1953 on base). It is registered as validateStackExpressions in authoring-rules.ts, with commands: ALL, surfaces: CLI_AND_RUNTIME and runtimeTypes: ['flow', 'action', 'hook', 'object']. On an object write, the gate's runtimeWriteType runs the passes over the object's own body. The new refusal is in that same per-object loop, so it reaches the same doors. P1's "after" column is that reading.

P3: exposure. A git grep for a family table as a reference (string literal, JSON, the Field.lookup / Field.masterDetail builders, SystemObjectName.METADATA):

  • over examples/**, packages/apps/**, packages/platform-objects/**, packages/create-objectstack/src/templates/** and packages/qa/** at 4b114eda57: 0. A positive control matched 3 of 3 synthetic lines.
  • over the whole repository at 4b114eda57: 0. On origin/main a43405d596: 0.
  • control, references to an ordinary platform table over the same paths: 45 files (36 in platform-objects, 4 in app-showcase, 1 in app-todo, 4 in qa).

The family objects themselves (packages/metadata-core) reference other tables (sys_user, sys_package_version, sys_environment), and that is unaffected. Neither premise failed, and the family set is unchanged.

Tests

Run on head 5e54169edc, each under os-verify-lock.sh, --maxWorkers=2:

Package Result
@objectstack/lint test 149 files / 6965 passed
@objectstack/lint typecheck exit 0
@objectstack/metadata-protocol test 251 passed + 3 skipped files / 20818 passed + 19 skipped
@objectstack/metadata-protocol typecheck exit 0
@objectstack/spec test (--project local) 702 files / 21352 passed + 1 todo
@objectstack/spec typecheck exit 0
@objectstack/objectql (--project local) 450 / 8490
@objectstack/platform-objects 79 / 1195
@objectstack/runtime (--project local) 430 / 6779 + 19 skipped
@objectstack/cli (--project unit; integration is declared to CI) 314 / 4641
os validate and os build on every example app (app-crm, app-multi-package, app-showcase, app-todo) 8/8 exit 0

Consumer sweep (G2). No fixture outside packages/lint / packages/spec / content/docs went red, so there is no fixture for a rider. The family-iterating engine tests in @objectstack/objectql register objects directly and run no authoring rule (they are the installed-artifact path), and they are green.

The new pins:

  • packages/lint/src/validate-expressions.stored-metadata-body-reference.test.ts, 21 cases:
    • each family table × lookup / master_detail is refused at the build, at os validate / os build (runAuthoringRules), and at the object door with the build's own finding;
    • a user naming the family is refused too (the collector's type set);
    • a stored object re-saved is still refused;
    • a stored sibling is not charged (the differential);
    • the controls (sys_user and an app object, as lookup and as master_detail) draw nothing;
    • the hop refusal is unchanged beside it.
  • The protocol door block (8 cases): 4 refusals with the 422 / INVALID_METADATA envelope and nothing landing; a draft's promotion refused; 2 controls saved; door finding equal to build finding.

Ablation (G4)

Run through scripts/ablation-replace.mjs on 6c2c70f3eb. The later commits change only the message text and add a user pin; the mutated line is unchanged.

  • Mutation. if (!target || !type) continue; became if ('ablation-24677') continue;: anchor 1 to 0, blob e85a4327e28f to 6564a909a8fd.
    • The lint pins (they import src) went 15 red / 11 green of the 26 cases then on the branch (the user pin came later). The 11 green are the 4 controls, the differential and the 6 hop pins.
    • The lint build's JS pass emitted the mutation, and ablation-dist-preflight found the marker in 4 dist files. The DTS pass failed on the mutation itself (TS2872 / TS2345), which does not touch the JS the suite reads.
    • The protocol door block (it reads lint's dist/) went 6 red / 2 green. The 2 green are the controls.
  • Restore. By the tool's trap: blob back to e85a4327e28f (equal to HEAD), git diff HEAD empty, whole-tree git status --porcelain clean.
    • Lint was rebuilt (exit 0), and preflight --absent found the marker in 0 of 20 dist files.
    • The lint pins went 26/26 green and the door block 8/8. On the final head the lint pins are 27/27 (with the hop file's 6).

Gates

  • node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack, no paths, on 5e54169edc: 111 commands. Each exit code was captured before any pipe.
  • --ran: Run reconciliation — 111 derived, 111 run, 0 NOT-MEASURED, 0 UNRUN.
    • pnpm check:type-check-debt exited 3 in the battery: a queue-timeout on the verify lock, so nothing was measured. It was re-run under the lock: exit 0, 1 ledger entr(ies) re-measured … none above its recorded number.
    • Every other command exited 0. The clone was deepened (--shallow-since=2026-07-13) first.
  • Local-only families: check:dev-prereqs exit 0 and check:skill-frame-freshness exit 0.
  • @objectstack/spec check:generated: All 14 generated artifacts are up to date after gen:docs.
  • check:adr-0087-registration: green, not-required (no-migration-prescription). check-changeset-no-major: green, no major.
  • CI check-runs on this PR are not yet reported at open.

Changeset

.changeset/24677-reference-into-stored-metadata-body-refused.md: @objectstack/lint minor, @objectstack/spec patch (the describe text only).

  • It is BREAKING, an authoring narrowing, shipped minor under the launch-window convention. It carries the FROM / TO / fix mapping and the stored-row sentence.
  • The stored-row sentence: a stored object row that already declares such a field keeps loading (rehydration runs no authoring rule, and the engine guards hold its reads). Its next active save or draft promotion is refused until the field is removed or retargeted. The lint pin "a stored object that already declares one is still refused on its next save" holds this.
  • The ADR-0087 disposition is in the changeset.
  • .changeset/pre.json on main is {"mode": "exit", "tag": "next"}. So this changeset is consumed by the release that leaves the next pre-release line, and nothing here changes that mode.

Acceptance notes

  • expression-invalid for a field declaration. The rule's id is the expression rule's, and that is where the ruling put the refusal ("where the hop refusal already runs"). The location (… field 'FIELD' reference) and the message carry the specifics.
  • A user field. The collector's type set is REFERENCE_VALUE_TYPES, and the parse accepts an authored reference on a user field (measured: ObjectSchema.safeParse accepts { type: 'user', reference: 'sys_metadata' }). So a user naming the family is refused the same way, and is pinned. The collector's docblock claimed otherwise and now says what was measured. A tree cannot name the family: the object schema refuses a foreign tree target.
  • Out of scope, reported to the seat rather than fixed here. os validate accepts a user field whose reference names any other object (measured: { type: 'user', reference: 'probe_account' }, exit 0). Field.user's contract says "target fixed to the sys_user system object", and referenceTargetOf returns the authored carrier first.
  • Branch base. The branch merged origin/main once (37aa1d68d6). origin/main has since moved 10 commits (a43405d596), and none of them touches this diff's files.
  • Review. A CONTRACT_REVIEW_TIER review of record on the narrowing face is owed before the queue.

Generated by Claude Code

…ody table at author time

The expression rule's object pass refuses a relationship field whose
reference is in STORED_METADATA_BODY_OBJECTS, keyed on the hop refusal's
own collector, so os validate, os build and the object save door refuse
it alike. The engine never hydrates such a field.

Claude-Session: https://claude.ai/code/session_019oFY6Hi35J7atvgVFnmoaK
Co-authored-by: Claude <noreply@anthropic.com>
…eference describe and the field-types page

Claude-Session: https://claude.ai/code/session_019oFY6Hi35J7atvgVFnmoaK
Co-authored-by: Claude <noreply@anthropic.com>
…rence into a stored-metadata-body table

Claude-Session: https://claude.ai/code/session_019oFY6Hi35J7atvgVFnmoaK
Co-authored-by: Claude <noreply@anthropic.com>
…ed-metadata-body table

Claude-Session: https://claude.ai/code/session_019oFY6Hi35J7atvgVFnmoaK
Co-authored-by: Claude <noreply@anthropic.com>
…ot off the field

The #5017 receiver meta-test pins the keys the field walk reads off its
receiver; the type is already in the index the walk builds.

Claude-Session: https://claude.ai/code/session_019oFY6Hi35J7atvgVFnmoaK
Co-authored-by: Claude <noreply@anthropic.com>
… field, and pins the user case its collector admits

A user field's authored reference is not refused at parse, so the hop
collector already admits a user naming the family; the collector's
docblock said otherwise.

Claude-Session: https://claude.ai/code/session_019oFY6Hi35J7atvgVFnmoaK
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/lint, @objectstack/spec, touching 11 documentable anchor(s).

29 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json a43405d596d4d670dc761e90c9fc20822dc806a9.

⛔ 8 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 1 anchor(s) matched too much of the corpus to be a work list: master_detail (literal, 33 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 — 143 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 a43405d596d4d670dc761e90c9fc20822dc806a9 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json a43405d596d4d670dc761e90c9fc20822dc806a9

⚠️ 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 a43405d596d4d670dc761e90c9fc20822dc806a9 → 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: 5e54169edcbb57124019aa23efe3745edafb706e
Local-runs: none
Scope: .changeset/24677-reference-into-stored-metadata-body-refused.md, content/docs/data-modeling/field-types.mdx, content/docs/references/data/field.mdx, content/docs/references/data/object.mdx, content/docs/references/system/migration.mdx, packages/lint/src/authoring-rules.ts, packages/lint/src/validate-expressions.stored-metadata-body-reference.test.ts, packages/lint/src/validate-expressions.ts, packages/metadata-protocol/src/protocol.runtime-authoring-gate.test.ts, packages/metadata-protocol/src/stored-metadata-body-family.pin.test.ts, packages/spec/src/data/field.zod.ts + packages/spec/src/kernel/stored-metadata-body-objects.ts, packages/spec/src/data/field-value.zod.ts (REFERENCE_VALUE_TYPES, IMPLICIT_REFERENCE_TARGETS, referenceTargetOf, referenceCarrierOf), packages/spec/src/data/object.zod.ts (refuseForeignTreeReference), packages/lint/src/runtime-gate.ts (buildRuntimeWriteSnapshotSet, runRuntimeAuthoringRules), packages/metadata-protocol/src/protocol.ts (runAuthoringGate, saveMetaItem, publishMetaItem), packages/lint/src/validate-expressions.stored-metadata-body-hop.test.ts, packages/lint/src/validate-expressions.test.ts (the #5017 receiver meta-guard), packages/objectql/src/engine.ts (the family guards at expandRelatedRecords and the three parent binders), packages/cli/src/commands/lint.ts (lintDataModel), scripts/check-adr-0087-registration.mjs, scripts/check-changeset-no-major.mjs, .changeset/pre.json

Read-only, on the narrowing face, adversarially against the dispatch and the dev report. Inputs: card #24677 (body and all six comments, the ruling 6282780489 verbatim 「都派」, the claim 6282819504, the dispatch 6282884996, the dev report 6285300787), #24358 (dev report 6253491408 and the recorded ruling 6279185009), #24316, the rider 6282830389 on #6367, PR #24800 (body, 11-file list, net diff against merge base 580fd4ed3c), the cited code at base and head, and the head's check-runs. Nothing was built, run, re-run or ablated. The overlap with PR #24789 was read with git diff of each head against its merge base and one git merge-tree --write-tree of the two heads in the object store (no worktree, no checkout).

Check-runs on the head, read at 2026-10-20T07:13Z: 39 runs; 27 success (among them Lint & Repo Gates, Build Core, Governed Surface Queue Guard, Check Changeset, Check PR Size, Lint · ESLint, Lint · repo & tooling gates, Lint · PM dispatch-gates, Type Check · source gates, Type Check · consumer gates, Type Check · debt ledger, Spec property liveness, Check Documentation Links); 5 skipped (Console Pin Gate, Dogfood Verify CLI, Dogfood matrix shard, Temporal Conformance, Packed-tarball smoke, all full-battery or opt-in); 0 failure; 7 in_progress (Test Core 1/6 to 6/6, Type Check · workspace). Their conclusions are the gate verdicts; the in-progress seven are not a pass and the queue waits on them. A reading eleven minutes earlier had 22 success and 12 in progress with 0 failure; nothing moved to red between the two.

① Derived judgments

Every public-surface and behaviour change the diff implies, each judged against the code at base and head.

  1. One authoring refusal, ruling B's shape and no wider — right. runStackExpressionPasses (validate-expressions.ts head :2886-2911) adds one block in the per-object loop, after masterDetailCount, that pushes one error issue per field the [security] Stored-metadata-body family: a formula field's one-hop read through a reference is an exit the family projection does not reach — reach not yet measured, detail withheld pending maintainer #24316 collector storedMetadataBodyReferencesOf (head :1745) returns, keyed on isStoredMetadataBodyObject and STORED_METADATA_BODY_OBJECTS (unchanged at head, sys_metadata and sys_metadata_history). It reads the same lazy per-object index (storedMetadataBodyReferencesFor, head :1973) the hop refusal reads, so the two refusals judge one set by construction. No new object key, no engine-guard change (packages/objectql is not in the diff; the guards at engine.ts :10111, :10171, :10269 and :13369 are untouched), no apiEnabled change, no new error code, no registry entry: authoring-rules.ts is a comment-only hunk, and runtimeTypes: ['flow', 'action', 'hook', 'object'] with surfaces: CLI_AND_RUNTIME, commands: ALL is unchanged.

  2. Three doors, one finding — right. The rule is the one os validate and os build run (runAuthoringRules) and the one the object save door runs (protocol.ts saveMetaItem :24042 → runAuthoringGate :7262 → the platform gate → runRuntimeAuthoringRules, on an active save and on a draft's promotion; a draft save is not gated by design, D1). The new block is not fenced on objectWrite, so the door's finding is the build's at the build's position; the wrapper maps every issue to rule: EXPRESSION_INVALID, path: where, hint: '', and with source: '' leaves the message untouched (authoring-rules.ts :736), so the message does end on STORED_METADATA_BODY_PRESCRIPTION. The lint pin's door case and the protocol block's case (d) compare rule, where, path, message, hint across door and build. One issue per collected field, located at object 'NAME' · field 'FIELD' reference, the key the author edits.

  3. The expression-invalid family — the right existing family here, with the id's name as the only cost. The ruling put the refusal "where the hop refusal already runs" and the card left the issue kind to the lint's existing families. The semantically closer id, relationship/missing-reference, lives in lintDataModel (data-model-rules.ts :846), which only os lint calls (packages/cli/src/commands/lint.ts :604); the other registered reference-reading rules from that module are CLI_ONLY. So that family does not reach the object save door, and a refusal there would need a second collector beside the hop refusal's. validateReferenceIntegrity (CLI_AND_RUNTIME, runtimeTypes includes object) would reach the door, but it does not hold the family collector either. The expression rule is the one registration that reaches all three doors on an object write and already owns the collector and its index. Judged right; the id names an expression while the finding is a field declaration, and the location string plus the message carry the specifics, as the PR body says.

  4. The user coverage (dev deviation 1) — inside the ruling, pinned, docblock true. The collector's type set is REFERENCE_VALUE_TYPES = lookup, master_detail, user, tree (field-value.zod.ts :154). The card asked for the refusal to key on "the existing collector or its twin", so user is the collector's consequence, not a second set, and the hop refusal has judged user the same way since [security] Stored-metadata-body family: a formula field's one-hop read through a reference is an exit the family projection does not reach — reach not yet measured, detail withheld pending maintainer #24316. The engine guards key on the target, not the field type (expandRelatedRecords :13369 tests isStoredMetadataBodyObject(referenceObject)), so a user field naming the family is equally never hydrated; the refusal's premise holds for it, and the ruling's intent (the family is never a relationship target) covers it. The corrected docblock (head :1737-1743) is true: FieldSchema's superRefine judges reference only for the referenceVia exclusivity and the lookup/master_detail non-empty requirement, so an authored reference on a user field is not refused at parse; a tree cannot name the family because object.zod.ts :1656 refuseForeignTreeReference refuses any target but the own name. The user case is pinned (sys_metadata refused, sys_user control draws nothing). The changeset names it; the field-types page does not, which is acceptable since the describe states the rule for the key regardless of type.

  5. The message — every clause true, and apart from the hop message on purpose. storedMetadataBodyReferenceMessage (head :1793) names the field, its type, the target, the rule ("A relationship field may not target a stored-metadata-body table") and ends on the one prescription, as HookSchema (hook.zod.ts :132) and the flow write-node refusal (flow-node-config-refusals.ts :597) do. $expand keeps the stored id: expandRelatedRecords continues on a family target (:13369). The master-detail parent root stays unbound: resolveMasterDetailParent returns null (:10111), resolveMasterDetailParents returns unbound (:10171), resolvePreviewParents skips the read (:10269). The body is projected at the meta API: redactStoredMetadataBody (engine.ts :4150) and the protocol's own projection. The hop pins select their findings by i.message.includes('a stored-metadata table') (validate-expressions.stored-metadata-body-hop.test.ts :32); the new message says "a table of stored metadata" and "a stored-metadata-body table" and never the selected phrase, so the hop pins still read the hop findings alone, and the new pin file's last case asserts both selections are disjoint. No tracker number in the runtime string.

  6. Charged only to the written object; a stored self is refused on its own next save — right. buildRuntimeWriteSnapshotSet (runtime-gate.ts :692-734) drops the written item from the baseline by name and adds it to the candidate; the stored universe is read only for findings located on another entry (:1146-1156). So a stored object re-saved with the field: its finding is in the candidate and not in the baseline, and the stored self's own finding cancels nothing, so it is charged (the pin "stored self re-save refused", and the changeset's stored-row sentence). A stored sibling carrying the field: present in baseline and candidate alike, its fingerprint cancels, so an unrelated write is not charged (the pin "the differential"). Rehydration runs no authoring rule (runAuthoringGate is called only from the save path), so such a row keeps loading until its next active save or promotion.

  7. Pins — 21 lint cases, 8 protocol cases, counted and read. Lint: 2 tables × 2 types × 3 (build, runAuthoringRules validate and build, object door with door = build on five keys) = 12; the user case; the master_detail-vs-lookup consequence clause; stored self re-save; stored sibling not charged; 4 controls (sys_user and an app object × 2 types, at build, runAuthoringRules and door); the hop refusal unchanged and disjoint = 21 (9 it( statements at head). Protocol: 4 refusals through the real saveMetaItem with the 422 / INVALID_METADATA envelope and no row landing; the draft's promotion through the real publishMetaItem refused with no active row; 2 controls landing active; door = build = 8. Row, makeProtocol, runAuthoringRules and EXPRESSION_INVALID all exist in that file at base.

  8. Ablation arithmetic consistent with the pins' control flow. At 6c2c70f3eb the pin file had 8 it( statements = 20 cases, plus the hop file's 6 = 26; the mutated line if (!target || !type) continue; is at :2899 there and is untouched by the two later commits (they change comment text, the message wording and add the user pin, read in git diff 6c2c70f3eb..head). An always-continue leaves the block pushing nothing: red = 12 family refusals + the consequence-clause case + stored self + hop-unchanged (it asserts one reference finding) = 15; green = 4 controls + the differential (asserts no finding) + 6 hop pins = 11. Door block: 4 refusals + promotion + door-equals-build red = 6; 2 controls green. Both readings match the report.

  9. Docs — true and minimal. content/docs/data-modeling/field-types.mdx: two reference rows gain one sentence each and one section is added; every claim in it is the engine code above; no new page, so no meta.json change is owed. The three content/docs/references/** pages change the one describe line only (five occurrences across the three tables that embed FieldSchema), which is gen:docs output from the describe; Check Documentation Links is green on the anchor. Type Check · source gates (the spec generated-artifact gates) is green.

  10. Spec change is text only. field.zod.ts adds a TSDoc paragraph and three describe sentences on the field's own reference (head :1476-1494, the one at :989 is a grid cell column's and is untouched). No schema refinement, no new key, no behaviour.

② Semver level

  • @objectstack/lint minor, breaking — right. A declared no (narrowing) is a BREAKING change and ships minor under the launch-window convention (scripts/check-changeset-no-major.mjs :58 and :1631-1633). The changeset title carries ! and the body opens **BREAKING** (an authoring narrowing); the PR title carries !.
  • @objectstack/spec patch (dev deviation 2) — right. The spec diff is a .describe() text and a TSDoc paragraph, published in the .d.ts and the generated JSON-schema description and nothing else; a patch is the grade for a published text change with no new key and no behaviour. The dispatch's minor for both read the spec change wider than the diff made it.
  • Clause-②: no (narrowing) on both carriers — present. PR body line 3 and changeset line 14, spelled identically, which is what puts this review on the narrowing face.
  • FROM / TO / fix — present and true. FROM: saved with 200, built with exit 0, then only ever an id. TO: 422 INVALID_METADATA at the door, an error at os validate / os build. Fix: remove or retarget, or store the item's type and name in text fields and read it through the meta API. The stored-row sentence is the gate's differential, judged true in ①.6.
  • ADR-0087 not-required (no-migration-prescription) — honest. Nothing authorable is added, removed or renamed; no stored shape changes; objectstack migrate meta has nothing to rewrite, since the only remedy is a hand edit of a field that has no mechanical replacement. The FROM: / TO: bullet is a behaviour statement, not a consumer rewrite, and the gate's detector (findMigrationPrescription) accepted it on the dev's local run (exit 0); no check-run on the head had failed at my reading.
  • pre.json reading — right. origin/main and the head both carry {"mode": "exit", "tag": "next"}; the next changeset version leaves the next line and consumes this changeset into that release; the diff does not touch the file.

③ Boundary flags

Every dev flag, deviation and finding, answered.

Implemented-by: claude/issue-24677-reference-into-metadata-body-refused
Reviewed-by: session_019oFY6Hi35J7atvgVFnmoaK

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review adopted · domain:spec seat 2 · session session_019oFY6Hi35J7atvgVFnmoaK · 2026-10-20T07:33Z


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 20, 2026 07:34
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 20, 2026 07:34
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 20, 2026
Merged via the queue into main with commit 3983965 Oct 20, 2026
42 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-24677-reference-into-metadata-body-refused branch October 20, 2026 08:26
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 protocol:data size/m tests tooling

Projects

None yet

2 participants