Skip to content

fix(metadata-protocol): refuse field reference-target queries instead of clearing them for deletion - #9603

Merged
os-sam merged 1 commit into
mainfrom
claude/issue-9327-field-reference-target-unanswerable
Aug 18, 2026
Merged

fix(metadata-protocol): refuse field reference-target queries instead of clearing them for deletion#9603
os-sam merged 1 commit into
mainfrom
claude/issue-9327-field-reference-target-unanswerable

Conversation

@os-sam

Copy link
Copy Markdown
Collaborator

Fixes#9327

The defect

A field metadata item is addressed by the composite key OBJECT.FIELD (e.g. account.owner), but every metadata property that names a field holds only the bare field name (owner). The two sides are drawn from disjoint vocabularies, so the reference scan answered { references: [] } for every field, on every deployment, regardless of real usage.

objectui's metadata-admin renders that empty answer verbatim as "Nothing in the metadata graph points at this item. Safe to delete." — an unanswerable question shown as a positive clearance, on the screen where someone decides to delete. Same harm family as #9537, one layer in.

The defect predates PR #9324 and survives it; derivation made it visible, it did not cause it.

Counted, not sampled

The card says "~30 more" reference sites and names three. Measured against the real schemas on this ref:

UnitCount
Derived (sourceType, property) pairs targeting field12
Distinct non-recursive schema paths154, across 9 source types
Distinct paths with recursive containers expanded4314 at the walker's depth-12 bound

Per source type (non-recursive): view=82, object=34, page=23, translation=5, dashboard=4, action=2, dataset=2, permission=1, skill=1.

The unbounded third row is precisely why #9324 indexes properties rather than paths. Every one of the 12 sites documents its value as a bare field name — "Field name (snake_case)", "Field name to filter on", object.fields{} being a z.record keyed by the bare name. None carries an OBJECT.FIELD metadata key, so no site can ever match the addressed key.

The answer to the dispatch's hard-stop question

#9324's discriminator does NOT extend to TARGET types — it needed a sibling.

unwalkableSourceTypes is populated only when a source type's schema cannot be read. field reads perfectly, is walked as a source, and contributes reference sites of its own — it could never be a member. The fault here is on the target side and has the opposite cause: the shape was read fine and still cannot match.

So this adds unanswerableTargetTypes as a sibling on ReferenceSiteIndex, not a widening of the existing set. A pin asserts the two sets stay disjoint, because a type in both would mean one of them had become a general "something is wrong here" bucket.

Clause ② was not reached. Honesty here did not require the response-shape discriminator that #9190's dispatch fenced to the spec seat. Nothing is added to the 200 body and no new error code is introduced: the refusal rides the existing ADR-0112 nested envelope with the same 501 NOT_IMPLEMENTED that this exact route already returns when the protocol cannot compute the graph at all (#9326). One route, one dialect for "the question was never asked".

⚠️ One sub-finding worth recording: following unwalkableSourceTypesliterally — a build-time record with a pinning test and no response change — would not have repaired the harm. #9190 could move its discriminator off the response because the gap it records is bounded (some answers get shorter). This gap is total: every answer for the type is empty, and a constant in a build never reaches the operator standing in front of "Safe to delete." The sibling therefore had to be consumed as a refusal rather than merely recorded — wire-visible behaviour, but not a wire shape change.

What changed

  • packages/metadata-protocol/src/reference-sites.tsunanswerableTargetTypes on ReferenceSiteIndex, derived from a one-member seed (field) that is guarded by the schemas: nameVocabularyRejectsKey drops a member whose own name vocabulary has widened to admit its key, so email_template and capability (both legitimately dotted, where the name is the key) can never land in the set. The seed is typed to the declared universe, so naming an undeclared type is a compile error.
  • packages/metadata-protocol/src/protocol.tsfindReferencesToMeta refuses an unanswerable target with 501 NOT_IMPLEMENTED, prescriptively per ADR-0110 D3: the message names the answerable question, GET /api/v1/meta/object/account/references, recovered from the key the caller already typed.

Scope held to the ruled shape (3). Shape (1) (qualifying bare names against the owning object) is a capability upgrade needing object context the walker lacks — a separate card. Shape (2) (matching bare names against the composite key) stays rejected: it trades false negatives for false positives on delete confirmations, the worse direction on this screen.

Verification

All commands run on 37a38302a, the final commit; gate union run after it, on the pushed tree.

Reverse verification — reverted only the protocol seam, left the derived set in place:

5 failed | 26 passed (31)
AssertionError: expected a refusal, got a resolved answer: expected undefined to be an instance of Error

Exactly the five refusal-asserting cases went red, and for the right reason — the unfixed protocol resolved with { references: [] } rather than throwing something else. Restored byte-identically (git diff HEAD empty), restoration leg 31/31 green. Both legs read src/ through relative imports inside one package, so no dist/ is involved and no rebuild applies.

⚠️ The derivation pin file stayed fully green across that ablation. That asymmetry is the argument for the end-to-end file: pins on the derived set alone would not have caught a missing refusal.

SuiteResult
@objectstack/metadata-protocol122 passed, 2 skipped (124 files) · 1684 passed, 10 skipped
@objectstack/objectql217 files · 3836 passed
@objectstack/rest124 files · 2034 passed

⚠️@objectstack/metadata-protocol carries no typecheck script, so pnpm --filter … typecheck would be a zero-match exit-0 false green. Type safety was established two other ways instead: a direct tsc --noEmit in the package (my files contribute 0 errors against a 63-error pre-existing DEBT baseline), and check:type-check-debt --re-measure on a built closure, which reports "surplus: none — every entry sits exactly at its measurement, so any new error is red".

Gate union, each captured as cmd > log 2>&1; EXIT=$? (never piped into tail#9552), all EXIT=0: check:changeset-gate-self-tests, check:cross-package-test-inputs, check:durability-log-level, check:filter-alias-parity, check:objectui-changeset, check-adr-0087-registration, check-changeset-no-major, check-cross-package-test-inputs, check-empty-changeset, check-affected-docs, check:query-options-erasure, check:engine-double-contract, check:where-matcher, check:type-check-coverage, check:type-check-debt --re-measure, check:nul-bytes.

Gate set re-derived with node scripts/pm/dispatch-gates.mjs (no path arguments) against the real 5-path changeset.


Generated by Claude Code

…ad of clearing them
`field` metadata items are addressed by the composite key `<object>.<field>`,
while every reference site that names a field holds the bare field name. The two
vocabularies are disjoint, so `findReferencesToMeta` answered `{ references: [] }`
for every field, always — rendered by the admin "Used by" panel as "Nothing in
the metadata graph points at this item. Safe to delete."
Adds `unanswerableTargetTypes` as the TARGET-side sibling of #9190's
`unwalkableSourceTypes` (which records a source shape that could not be READ —
a different fact) and refuses at the protocol seam with the 501 NOT_IMPLEMENTED
the route already uses for its sibling refusal. No response field and no new
error code.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017qYPmkKEsfbWY1yVg83p8F
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/metadata-protocol, touching 13 documentable anchor(s).

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

  • content/docs/api/client-sdk.mdx(via getReferences (sdk), meta.getReferences (sdk))
  • content/docs/api/data-flow.mdx(via /api/v1/meta/object (route))
  • content/docs/api/wire-format.mdx(via /api/v1/meta/object (route))
  • content/docs/concepts/metadata-lifecycle.mdx(via ObjectStackProtocolImplementation (symbol))
  • content/docs/protocol/diagram.mdx(via /api/v1/meta/object (route))
  • content/docs/protocol/kernel/error-handling.mdx(via /api/v1/meta/object (route))
  • content/docs/protocol/kernel/http-protocol.mdx(via /api/v1/meta/object (route))
  • content/docs/ui/forms.mdx(via /api/v1/meta/object (route))

2 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v16.mdx(via ObjectStackProtocolImplementation (symbol))
  • content/docs/releases/v17.mdx(via ObjectStackProtocolImplementation (symbol))

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.

What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)

Coarse fallback — 7 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 ca2e020e47bbe1121aa7e12aaeeae0f6d402ce5fpackageMentionDocs.

Which tree this was computed on

This run read content/docs from 37c6b1e25daae4ff65a04485cfd7a0bc0d4ea7b2 — the merge of head 37a38302ab5abdf53722affde032a5d6ec2a6064 into base ca2e020e47bbe1121aa7e12aaeeae0f6d402ce5f, 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 37c6b1e25daae4ff65a04485cfd7a0bc0d4ea7b2 && git checkout 37c6b1e25daae4ff65a04485cfd7a0bc0d4ea7b2
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin ca2e020e47bbe1121aa7e12aaeeae0f6d402ce5f 37a38302ab5abdf53722affde032a5d6ec2a6064 && git checkout -B drift-repro ca2e020e47bbe1121aa7e12aaeeae0f6d402ce5f && git merge --no-ff 37a38302ab5abdf53722affde032a5d6ec2a6064
node scripts/docs-audit/affected-docs.mjs --json ca2e020e47bbe1121aa7e12aaeeae0f6d402ce5f

⚠️ 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 ca2e020e47bbe1121aa7e12aaeeae0f6d402ce5f → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@github-actions

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 32140662775 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

失败的 job(日志抽取,best effort):

  • Test Core (3/3) — 失败步骤: Run this shard's tests

    �[41m�[1m FAIL �[22m�[49m src/error-envelope.conformance.test.ts�[2m > �[22m#8087 — every code the dispatcher door can emit is parsed against ApiErrorSchema�[2m > �[22mevery pending code is still unre
    

历史信号:

  • 本 PR 过去 24h 无队列失败记录(首次)。
  • 过去 24h 队列共有 7 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 在其他 PR 的同类评论里搜同名测试;出现过 ⇒ flaky 实锤,开 issue 修/隔离那条测试。修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

Generated by Claude Code · merge-queue-triage workflow (#4859)

Merged via the queue into main with commit 14935abAug 18, 2026
25 checks passed
@os-sam
os-sam deleted the claude/issue-9327-field-reference-target-unanswerable branch August 18, 2026 13:37
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@os-sam@claude