Skip to content

fix(metadata-protocol): SeedLoader asks the registry for a name column before probing it - #9172

Merged
os-zhuang merged 3 commits into
mainfrom
claude/issue-9071-seedloader-name-probe
Aug 16, 2026
Merged

os-zhuang merged 3 commits into
mainfrom
claude/issue-9071-seedloader-name-probe

Conversation

@os-zhuang

@os-zhuang os-zhuang commented Aug 16, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #9071

What was wrong

SeedLoaderService.resolveFromDatabase() resolves a reference authored as a natural key by
walking a probe chain — the target dataset's declared externalId, then the historical
name default, then the internal id. The name leg was spelled unconditionally,
including on objects that have no name column at all.

On those objects the probe is not a cheap miss. The driver refuses it:

[sql-driver] INVALID_FILTER — Filter on 'name' names a column that object 'crm_contact'
has no column for, so the predicate never ran.
ERROR Find operation failed {"object":"crm_contact", ...}

One refusal per reference value, per pass — hundreds of ERROR-level Find operation failed
lines on every seeded boot and every per-organization replay, each reading exactly like a
real failure everyone downstream has to learn to ignore.

What this does NOT do

The driver's refusal is correct and is left exactly as it is. A predicate naming a
column the object does not have never ran, so answering "no rows" would be a lie
(ADR-0110 D3 — a miss and a fault are different facts). Nothing here catches it more
quietly, downgrades it, lowers a log level or filters it out of the log. The fix is to
stop asking, not to change the answer.

The fix

Ask the metadata registry whether the target declares a name column before adding that
leg — through the same resolveObjectDefinition resolver the loader already builds its
reference graph from (metadata service first, then the engine's own schema registry). The
answer is memoised per load (the question is asked once per unresolved reference value)
and re-asked on the next one, since a publish between two loads can add the very column it
is about.

Three properties worth stating explicitly:

  • Which probe answers cannot change. On an object with no name column that leg could
    only ever throw, never match — so removing it cannot take a resolution away from any
    object. PM mechanism-assumption 1 ("does anything rely on the name leg answering?") is
    answered structurally rather than by sampling: the leg is dropped in exactly the
    population where it was incapable of answering, and kept everywhere else. The
    both-directions test pins that.
  • The guard covers the leg in BOTH positions. The same refusal is provoked one position
    earlier when a referenced target carries no dataset in this load and therefore keeps the
    metadata-level name default as its FIRST probe (buildReferenceMap). Same defect
    class, same file, closed by the same guard rather than left for a second card.
  • An unknown is not a denial. When neither the metadata service nor the engine's schema
    registry can describe the object, the leg is kept — today's behaviour — rather than
    narrowed on a fact nobody established.

On PM mechanism-assumption 2 (⚠️ do not assume the registry is importable): measured, and
it holds here — no new import was needed. resolveObjectDefinition is a private method
of SeedLoaderService itself, already called once per object at graph-build time, and
IMetadataService is a constructor argument. The alternative reading of the card — resolve
the object's ADR-0079 nameField and probe THAT instead of the literal name — was
rejected: on an object whose nameField points at some other column it would probe a
column never probed before and could resolve references that previously did not resolve,
which is the one thing the card says the fix must not do.

Tests

New: packages/metadata-protocol/src/seed-loader-name-probe.test.ts. Its fake engine
resolves WHERE columns against a declared column set and refuses what it cannot
resolve, with the driver's own INVALID_FILTER / 400 envelope and first sentence — a
double that answered such a filter with an empty list would be looser than the producer it
stands in for and would make the whole file green over the defect. Every case counts the
refusals it raised.

Test-count direction: up — one new file, no existing case removed or rewritten. Both
directions are pinned: the leg disappears on an object with no name column (and the
lookup still resolves through the leg that always answered it), it still fires and still
answers on an object that declares one, it is skipped in its first position too, it is
KEPT when nothing can describe the object, and it is re-asked on the next load.

Reproduction on a real boot

The card's observation came from a real serve boot, so the proof is one too — the same
app booted twice, once with each build of @objectstack/metadata-protocol.

Fixture. examples/app-crm already has the card's exact shape: crm_contact declares
no name column (first_name / last_name / full_name / email / account) and its
dataset is keyed by email, while crm_activity.contact references it by that natural
key. On a clean boot every reference hits the in-memory map, so the DB probe never runs —
so for the measurement, and only for it, the five crm_activity.contact values were
pointed at unseeded addresses (ghost.*@lattice.example.com), which is what makes the
lookup chain reach the database. That edit is a local experiment and is not in this PR;
examples/ is untouched by the diff.

Command. pnpm dev:crm -- --fresh -p PORT (real serve, real sqlite driver, fresh
tempdir), 180 s window, log captured whole.

ERROR Find operation failed [sql-driver] INVALID_FILTER seed outcome
before (seed-loader.ts at origin/main) 10 10 28 ok / 5 errors, {"inserted":28,"updated":0,"skipped":0,"errored":5,"referencesDropped":0}
after (this branch) 0 0 28 ok / 5 errors, {"inserted":28,"updated":0,"skipped":0,"errored":5,"referencesDropped":0}

Ten, from five rows across two passes — the card's "per row, per pass" exactly. Every one
of them was the same refusal:

[sql-driver] INVALID_FILTER — a WHERE column could not be resolved on 'crm_contact'
('name'). … select * from `crm_contact` where `name` = 'ghost.ada@lattice.example.com'
limit 1 - no such column: name
ERROR Find operation failed {"object":"crm_contact","error":{"message":"Filter on 'name'
names a column that object 'crm_contact' has no column for, so the predicate never ran. …

The seed outcome is byte-identical across the two boots — same counters, same five
legitimately-unresolved references, same [SeedLoader] Deferred reference UNRESOLVED after pass 2 lines (those are the loader correctly reporting that the ghost addresses do not
exist; they are a different thing from the noise and are untouched). That is PM
mechanism-assumption 1 answered empirically as well as structurally: nothing relied on
the name leg answering.
What disappeared is exactly the ten provoked refusals.

Verification

Local, at the exact head this body describes — 52f9fcba9, the final commit (union
re-run after it, not before).

  • pnpm --filter '@objectstack/metadata-protocol^...' build — the dependency closure first,
    in a fresh worktree.
  • pnpm --filter @objectstack/metadata-protocol test — 113 files / 1572 tests passed.
  • npx vitest run src/seed-loader-name-probe.test.ts — 1 file / 5 tests passed.
  • npx tsc --noEmit in packages/metadata-protocol — 63 errors, identical to the
    frozen DEBT ledger entry; the new file contributes none. (It contributed one TS2835
    before its import was spelled ./seed-loader.js — an extensionless relative import does
    not resolve under moduleResolution: NodeNext. Caught by re-running the ratchet, and
    fixed rather than baselined.)
  • Gate families, re-derived from the actual changed paths with
    node scripts/pm/dispatch-gates.mjs and all green: check:nul-bytes,
    check:changeset-gate-self-tests, check:cross-package-test-inputs,
    check:durability-log-level, check:objectui-changeset,
    check:engine-double-contract, check:where-matcher, check:query-options-erasure,
    check:type-check-coverage, check:test-source-alias, and the ratchet
    check:type-check-debt --re-measure (33 entries re-measured, none above its recorded
    number
    ).

Reverse verification — the fix committed first, then taken out with
git checkout origin/main -- packages/metadata-protocol/src/seed-loader.ts and restored
from the branch. Direction predicted before running, and observed: red, 3 of 5 cases
failing —

× never spells `where: { name }` on an object that declares no `name` column
× skips the leg in its FIRST position too — a target with no dataset defaults to `name`
× re-asks the registry on the next load — a publish may have added the column
AssertionError: expected [ { object: 'crm_contact', …(1) } ] to deeply equal []
AssertionError: expected [ 'name', 'id', 'name', 'id' ] to not include 'name'

The two that stay green under the revert are exactly the two asserting preserved
behaviour (the leg still fires when the column is declared; the leg is kept when nothing
can describe the object) — which is the property this change claims.

Generated by Claude Code

claude added 3 commits August 16, 2026 15:41
…he SeedLoader probes it (#9071)

The external-key lookup chain spelled `where: { name }` unconditionally, so on
an object with no `name` column the driver refused the filter (INVALID_FILTER)
once per reference value, per pass — hundreds of ERROR lines per seeded boot.

The driver is correct: a predicate on a column the object does not have never
ran, so answering 'no rows' would be a lie. The fix is to stop asking, not to
change the answer — the refusal path is untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTKPDRoynY8i3HmdSFUxFj
…s WHERE matcher (#9071)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTKPDRoynY8i3HmdSFUxFj
…le specifier (#9071)

An extensionless relative import does not resolve under moduleResolution
NodeNext, adding a TS2835 to this package's shrink-only type-check ledger.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTKPDRoynY8i3HmdSFUxFj
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/metadata-protocol.

3 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/concepts/metadata-lifecycle.mdx (via @objectstack/metadata-protocol)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/metadata-protocol)
  • content/docs/protocol/kernel/http-protocol.mdx (via @objectstack/metadata-protocol)

⛔ 1 release-owned page(s) also reference the affected code. These are read-only:

  • content/docs/releases/v9.mdx (via @objectstack/metadata-protocol)

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

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Aug 16, 2026

Copy link
Copy Markdown
Contributor Author

Docs drift sweep — nothing owed by this change

Swept the whole content/docs/** corpus rather than trusting the three-page advisory list,
with this change's own tokens (seed, SeedLoader, externalId, natural key,
reference near resolve, name near lookup|probe|default, INVALID_FILTER,
dataset). Release pages excluded — release-owned.

The live question — does any page document the probe chain, its name fallback, or a
seeded boot's log shape? No.
No page anywhere states externalId → name → id, or that
reference resolution falls back to name, or what a seeded boot's log looks like.

The three advisory pages score zero on every token: concepts/metadata-lifecycle.mdx
and protocol/kernel/http-protocol.mdx have no hit at all, and
kernel/services-checklist.mdx has exactly one — dataset in
/analytics/dataset/query, a different sense of the word.

Two near-misses checked and both correct as they stand:

  • references/data/seed-loader.mdx — AUTO-GENERATED — documents
    ReferenceResolution.targetField as optional (default: "name"). Still accurate.
    This change does not touch the metadata-level default: buildDependencyGraph still
    stamps name on a reference whose target carries no dataset. What became conditional is
    only whether that field is spelled into a database filter. Not hand-edited either way.
  • data-modeling/seed-data.mdx — describes supplying a lookup's natural key value "(such
    as name, email, or code)" and says the runner resolves it to a database id. True
    before and after; it documents the authoring contract, never the probe order.
  • protocol/kernel/lifecycle.mdx — has a "Boot Logs (Example)" block, but it is a
    phase-by-phase startup transcript with no seed or find lines in it.

No pre-existing error found to report either — the pages that touch this area are right,
they simply never described the chain.

Generated by Claude Code


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/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

SeedLoader's external-key probe queries where: { name } on objects that have no name column — a hard INVALID_FILTER refusal per row, per pass

2 participants