Skip to content

fix(metadata-protocol): a seed reference written blank ('' or whitespace) is no reference, by the engine's isMissing - #24502

Merged
objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-24459-seed-blank-reference
Oct 19, 2026
Merged

objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-24459-seed-blank-reference

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #24459

Clause-②: no (no published accept set or surface moves: the row was already written, and only the false "unresolved" report and its success: false go away; owes the seat's TIER_DEFAULT review of record before the queue)

Dispatched under claim 6261694593 (domain:engine seat 1, session session_01JtSQQ3EWsbNPzyYMug1tXy); dev session session_01KxmveGHL9EpPEGVggMSzDB.

What changed

The seed loader decided "this reference has a value" with undefined / null only, while the engine's blank rule, isMissing (@objectstack/core, packages/core/src/utils/required-emptiness.ts), also calls '' and whitespace-only strings missing. Every site in packages/metadata-protocol/src/seed-loader.ts that judges a reference value now asks isMissing (imported, not re-spelled):

Site (symbol) Before After
single reference, recordRefs loop (if (isMissing(fieldValue)) continue;) skipped undefined / null skips every isMissing value; left as written for the engine
each element of a multiple: true reference, pass 1 dropped a null / undefined hole drops every isMissing hole
each element of a deferred value, pass 2 (resolveDeferredUpdates) same as pass 1 same rule as pass 1 (the deferred value is the whole authored array, holes included)
referenceVia pointer id half skipped undefined / null / '' skips every isMissing value (whitespace-only joins '')

Other "has a value" sites in the file were read and left alone, on purpose: externalIdKey judges the record's own natural-key components (not a reference); the organization stamps compare == null on organization_id, which is resolved by its own path; formatAttempted is display only. No change to isMissing, the engine's required check, or any error code.

A blank element inside a multi-valued reference: dropped (decided by the four axes)

  • Real business need: measured on main, one blank element made the whole field defer and fail, so the row landed with none of its associations (books: null for ['Dune', '', ' ', 'Emma']). Dropping the hole lands [Dune, Emma], which is what the author wrote.
  • Long-term soundness: one hole rule. null holes were already dropped, and isMissing is the platform's single definition of "no value", so blank holes follow the same rule. Leaving the whole value as written for the engine is not possible, because the other elements are natural keys that must be resolved first.
  • Guarding against AI-authored metadata errors: the drop is not a lenient fallback for off-spec input. A blank element names no row under the engine's own rule. A key that names a missing row is still reported loudly, as before (control pin).
  • No scope creep: no new key, option or error code.

Measured (real ObjectQL + real SqlDriver on better-sqlite3, SeedLoaderService, multiPass: true)

Seed: blank_ref_book (Dune, Emma) and blank_ref_loan (loan-real → 'Dune', loan-empty → '', loan-whitespace → ' ', loan-multi → books: ['Dune', '', ' ', 'Emma']). Plus a required lookup written ''.

Reading main (01fafdee, before) this branch (after)
first load success: false, inserted 6, errored: 3: three Deferred reference unresolved after pass 2: …book = '' / ' ' / …books = '' → blank_ref_book.title not found success: true, inserted 6, errored: 0, no errors
replay success: false, skipped 6, errored: 3 (same three) success: true, skipped 6, updated 0, errored: 0
loan-empty / loan-whitespace .book null (the deferral deleted the key) '' / ' ': left as written; isMissing true (see Acceptance notes)
loan-multi .books null: the whole association was lost [Dune.id, Emma.id]
loan-real .book (control) Dune.id Dune.id
required lookup written '' errored: 2: Failed to write … record #0 (name=loan-required-empty): book is required and Deferred reference unresolved after pass 2: …book = '' → … errored: 1: Failed to write … record #0 (name=loan-required-empty): book is required only; row not written

Pins

  • packages/runtime/src/seed-blank-reference-real-driver.integration.test.ts (new): the table above, on the real driver. It lives in runtime because that package already has the real-driver seed harness and the driver-sql dependency (seed-multi-value-lookup-real-driver.integration.test.ts is its sibling). metadata-protocol has neither. This is outside the claim's "pins beside it" fence, and the reason is the measurement the dispatch asked for.
  • packages/metadata-protocol/src/seed-loader-multi-value-reference.test.ts (6 new cases in a new describe): single '' and whitespace skipped and left as written (not deferred, not dropped); a blank element dropped in pass 1 (no back-fill update); a blank element dropped in pass 2 (a forward self-reference, so it is proven by the back-fill update); a whitespace pointer skipped like '', including the un-addressable branch, plus a live-pair control; a control showing that a real key resolves and an unknown key still errors. These cases reuse that file's already-pinned engine double, so check:engine-double-contract needs no ledger edit. A new file with its own double was tried first, and the gate reported it.
  • Pin sweep: git grep for Deferred reference unresolved / UNRESOLVED finds no test that pins the old blank behaviour (the only hits are the loader, CHANGELOG.md, and these new pins). Existing '' seed values in tests are a field-definition reference: '' (seed-loader-reference-carrier.test.ts) and an unrelated ownership claim (claim-seed-ownership.test.ts). No pin needed flipping.

Ablations (committed fix first, scripts/ablation-replace.mjs, trap restore, restore proven by blob == HEAD and an empty porcelain)

  • Unit, per site, src/ resolution (19-case file): :1464 single makes the 2 single-blank cases red; pass-1 element makes the pass-1 element case red; pass-2 element makes the pass-2 case red; pointer makes the pointer case red. Each restores to 19/19 green.
  • Real driver, dist/ resolution (runtime reads @objectstack/metadata-protocol through exports): all three reference skips set back to undefined/null, rebuilt, ablation-dist-preflight shows the markers present in dist/, and both real-driver cases go red. After restore and rebuild, --absent passes and both cases are green.
    • First attempt declared: ablating :1464 alone was a void reading, in two ways. (a) The first marker did not reach dist/, because the build re-spells undefined as void 0; the preflight reported this, so a string-literal marker was used. (b) With the marker live, the real-driver file stayed green, because the pass-1 element skip still drops a scalar '' (it is wrapped as a one-element list) and the insert lands NULL. The single-site difference (left as written vs dropped) is pinned by the unit cases above. The faithful "remove the skip" is all three sites together, and that run is the red reading above.

Verification (final head 3e5746dd6, after git merge origin/main at ce2e08ee3 and a full turbo build, 76/76)

  • pnpm --filter @objectstack/metadata-protocol test: exit 0, 247 files passed (3 skipped), 20698 tests passed.
  • pnpm --filter @objectstack/metadata-protocol typecheck: exit 0.
  • pnpm --filter @objectstack/runtime typecheck: exit 0 (check:test-typecheck OK).
  • pnpm --filter @objectstack/runtime test: exit 0, 422 files, 6637 tests passed (pre-merge head f749e65d1; post-merge, the two real-driver seed files were re-run: 5/5).
  • pnpm --filter @objectstack/core test:repo: exit 0, 6 files, 82 tests.
  • node scripts/pm/dispatch-gates.mjs --commands (no paths, 4 changed paths vs merge base ce2e08ee3): 64 commands, all exit 0. --ran reconciliation: 64 derived, 64 run, 0 NOT-MEASURED (a derived zero: every line carries its exit code). check:dual-build-cjs-loads first exited 3 (PREREQUISITE NOT MET, 39 packages without dist/); it was re-run after the full build and exited 0.

Acceptance notes

  • What "the field is empty" stores. The loader leaves a blank single reference as written, so the engine decides it. The engine's insert door (normalizeBlankTypedValues) deliberately does not null a blank string-stored value: its doc lists "lookup ids" among the columns where '' is a value, and calls what "empty" means there "a separate question, not answered here". So an optional lookup seeded '' stores '' (and whitespace stores ' '), exactly as a direct engine.insert of the same payload does. Every reader that asks isMissing sees it as empty, and the required check refuses it. Before this change the deferral deleted the key, so the row stored NULL. Carrier: none. This records the engine's own declared open question; it is not filed here.

Generated by Claude Code

…e engine's isMissing

The seed loader skipped a reference only on undefined/null, so a lookup
written '' or whitespace was resolved as a natural key, deferred, and
reported 'Deferred reference unresolved after pass 2' (errored, success
false) for a row that had landed. The single-reference skip, the
multi-value element skip (pass 1 and pass 2) and the pointer-pair skip now
ask @objectstack/core's isMissing, the rule the engine's insert door and
required check use. A blank element of a multiple lookup is dropped like a
null hole, so the other elements still land.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KxmveGHL9EpPEGVggMSzDB
…ader and on a real SqlDriver; changeset

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KxmveGHL9EpPEGVggMSzDB
…nk pins load-bearing

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KxmveGHL9EpPEGVggMSzDB
…s already-pinned engine double

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

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

2 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 — 11 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 7e58e0a27998194640c145344b7519f1ccc9fad4 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 7e58e0a27998194640c145344b7519f1ccc9fad4

⚠️ 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: TIER_DEFAULT
Head-sha: 3e5746dd679759abb14ab095650eddc03204ad96
Local-runs: none

What this record read:

① Derived judgments

One blank rule, reused, not copied. seed-loader.ts imports isMissing from @objectstack/core, which @objectstack/metadata-protocol already depends on (workspace:*). isMissing is undefined, null, or a string whose trim() is empty. The diff carries no second definition.

Four sites, and each is a site that judges "has a reference value":

  • the single reference in the recordRefs loop (:1463), which was undefined/null only;
  • each element of a multiple: true reference in pass 1 (:1541);
  • each element again in pass 2, in resolveDeferredUpdates (:2220);
  • the referenceVia pointer id half (:1403), which already skipped ''.

The claim's fence named the first, the pass-1 element and the pointer; the pass-2 element is inside the same file and is needed. The deferred value is the whole authored array, so without it a blank element beside a forward key would still be reported unresolved. Its ablation pin is red. Accepted.

The single reference: left as written, which is right. A skipped blank is neither resolved nor reported. The engine then decides it:

  • an optional lookup stores what was written;
  • a required lookup is refused by the engine's own required check, which reads isMissing.

normalizeBlankTypedValues nulls a blank only on non-string-stored types, and it says lookup ids are string-stored and out of its reach "by design". So an optional lookup seeded '' now stores ''. On main it stored NULL, but only because the failed deferral deleted the key, on a load that answered success: false. A direct engine.insert of the same payload stores '' today. The loader now agrees with the door.

Ruling B on #20311 makes '' an empty value on a text-class column ("is empty" = null OR ''). So '' is a legitimate empty under the platform's own reading, not a new state. The dev's open question 2, answer A (no loader-only null), is accepted. A second dialect for one payload is exactly what the card closes.

The multi-value element: dropped, which is right. A blank element names no row by the engine's rule. Dropping it matches the null hole the loader already dropped. On main, one blank element made the whole field defer and fail, so the row landed with none of its associations (measured: ['Dune', '', ' ', 'Emma'] → null). Open question 1, answer A, is accepted.

The pointer half: one answer changes, in the blank rule's direction. A whitespace-only id with an empty type half used to be refused as un-addressable; now it is skipped, exactly as '' already was. This aligns whitespace with '' under one rule; it adds no new kind of accepted seed. Pinned, and its ablation is red.

What does not move:

  • isMissing, the engine's required check, and every error code;
  • every export and result shape;
  • a real natural key resolves, and one that names no row is still reported (the control pin asserts both).

The pins: real controls.

  • Unit: six cases in the existing seed-loader-multi-value-reference.test.ts, on that file's already-pinned engine double, so check:engine-double-contract needs no ledger row. Each case asserts the error list, success, and the stored value. Ablating each site turned exactly its own pin red (2/1/1/1 of 19), and each restore read back as the HEAD blob.
  • The dev's own earlier ablation round: it found the pass-1 element pin and the pointer pin were masked (by pass 2 and by the single-reference skip). Both were tightened and re-ablated red. That is the pin sweep's second rule applied.
  • Real driver: ObjectQL on SqlDriver (better-sqlite3), two cases.
    • The optional case asserts no errors, success: true, and isMissing on both blank rows. It also asserts the multi field is [dune.id, emma.id] and that a replay skips 6 with no insert or update.
    • The required case asserts one error, no Deferred reference unresolved, the field named, and no row.
    • On main, both were red. A dist ablation of the three reference skips turned both red again, guarded by the dist preflight in both directions.

② Semver level

One patch changeset for @objectstack/metadata-protocol, Clause-②: no. A false error report and its success: false go away, and a blank element no longer costs the field its other associations. No published accept set or surface moves. Right.

③ Boundary flags

Implemented-by: claude/issue-24459-seed-blank-reference
Reviewed-by: session_01JtSQQ3EWsbNPzyYMug1tXy

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

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

Head: 3e5746dd67: the skip, the pins and changeset, the load-bearing tightening, a merge of main, then the pins moved onto the existing double. Card #24459 (p2, domain:engine, area:records), claim 6261694593, dispatch 6261730486. Dev report: 6263378427.

Item Reading
Shape Draft PR → main. The body opens Fixes #24459. 4 files, +378/−7. Not governed.
Scope Every site in seed-loader.ts that judges "has a reference value" now asks isMissing from @objectstack/core: the single reference, each multi-value element in pass 1 and in pass 2, and the referenceVia id half. There is no second definition. A blank single reference is left for the engine. A blank multi-value element is dropped like a null hole.
Review TIER_DEFAULT record 6263609121, PASS on this head.
Declaration Clause-②: no on the claim, the PR body and the changeset (@objectstack/metadata-protocol patch).
Cross-lane The real-driver pin in packages/runtime is declared to domain:cli: 6263574303 on #6024 and 6263581601 on #22648.
Gates Dev, on 3e5746dd67: 64 derived, 64 run, 64 exit 0. Core test:repo exit 0.
CI At this read on 3e5746dd67: every check completed, none failed.

Disposition:

Landing: ordinary queue (pr_ready + automerge_enable through the relay). Fixes #24459 then closes the card.


Generated by Claude Code

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