Skip to content

fix(rest): refuse when findReferencesToMeta is absent instead of answering "nothing depends on this item" - #9425

Merged
os-zhuang merged 1 commit into
mainfrom
claude/issue-9326-references-capability-gap
Aug 18, 2026
Merged

fix(rest): refuse when findReferencesToMeta is absent instead of answering "nothing depends on this item"#9425
os-zhuang merged 1 commit into
mainfrom
claude/issue-9326-references-capability-gap

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Fixes #9326.

The measurement came first, and it chose the fix

The card's own honesty note said live-reachability was never measured, and the
dispatch fence made producing that measurement the first deliverable. Here it is.

Who can supply the protocol this route reads? resolveProtocol() returns
either the protocol service of the request's per-environment kernel
(kernelManager.getOrCreate(envId)) or the one injected into RestServer by
RestApiPlugin — whose service name is host-configurable (protocolServiceName).

Is there an in-repo assembly that produces one lacking findReferencesToMeta?
No.
ObjectStackProtocolImplementation
(packages/metadata-protocol/src/protocol.ts) is the only implementation
registered under the protocol service in production code, it reaches the kernel
through the one seam both mounts share (assembleMetadataProtocol()), and it
defines the method unconditionally as a class member. The sibling cloud repo
registers no protocol service of its own and never mentions the method
(control-queried: 24 registerService( sites there, zero for 'protocol').

Is the branch therefore dead? No — and this is the part that decides the fix.
RestProtocol is DataProtocol & MetadataProtocol, and findReferencesToMeta
is a member of neither
. It is not declared anywhere in packages/spec at all
(control query: getMetaItem( matches in that very interface; the three
beyond-spec read verbs match zero times). It is an ADR-0076 D9 server-only
extension, which is exactly why the handler reaches it through a runtime cast.

So a host that implements the declared contract exactly and correctly is a
conforming deployment that lands on this branch, and tsc cannot say a word.
In-process partial protocol doubles already do precisely this today (five in
packages/services/service-automation). The gap is not "latent because nobody
could reach it"; it is "unreachable from today's in-repo assemblies, and reachable
from any conforming embedder, because the contract does not require the method."

Why option 1 (refuse at the route), and why option 3 is not a complement

The fence put options 1 and 3 in scope and asked the measurement to choose. It
chose 1, and it rules 3 out rather than pairing with it:

  • An assembly-time assertion would promote an undeclared, deliberately optional
    extension into a required one
    . That is a contract widening in everything but
    the file it is written in, and the contract lives in packages/spec — the
    territory this card explicitly does not carry authority over.
  • ADR-0076 D9 is the standing decision that server-only extensions are
    runtime-detected rather than widened into RestProtocol. A boot assertion
    contradicts it.
  • It would reject the partial protocol doubles that legitimately exist, and it
    could not cover the embedder path anyway, since RestServer's constructor
    accepts anything typed RestProtocol.

And the house already answered this exact question, in this exact file:
GET /meta/:type/:name/layers — registered about fifty lines below /references,
in the same block, for the same class of missing extension — refuses with 501
NOT_IMPLEMENTED, and its comment reasons verbatim as this card does. Three more
do the same (getMetaDiagnostics, listDrafts, migrateStoredMetadata).
/references was one of only two outliers.

The change

501  { error: { code: 'NOT_IMPLEMENTED',
                message: 'protocol.findReferencesToMeta() is not available in this kernel' } }

The ADR-0112 nested envelope that #7035 converged the sibling /meta 501
refusals onto — never the bare-string or sibling-code dialects, which make
body.error.code read undefined. Deliberately not the shape /layers uses:
that one is the sibling-code dialect, counted by both shrink-only dialect
ratchets check:route-envelope holds on this file (stringError: 44,
siblingCode: 69), so copying it would have pushed a ratchet up. The nested shape
is counted by neither, and the ratchets are unmoved — verified, not assumed.

Message wording follows the three converted siblings
(protocol.NAME() is not available in this kernel).

Does any caller's observed response change?

Yes, on one deployment shape, and only there. A protocol that has the method is
untouched: empty and non-empty results both still pass through verbatim as 200.
What changes is the answer when the protocol has no such method — previously 200
with an empty list, now 501. No in-repo assembly produces that today (above).

The pin

packages/rest/src/meta-references-capability-gap.test.ts asserts the thing the
defect actually was: an absent capability does not produce the same body as a
genuine zero-references answer.
Both used to be 200 { references: [] }.

Assertions are the ADR-0112 pair (status and body.error.code) plus the
message at the declared position — never a bare toThrow, which could not
separate "answered with the wrong body" from "did not refuse at all", and the
wrong body is the defect. A companion case keeps the healthy side honest, so the
inequality cannot be satisfied by breaking the success path instead of fixing the
broken one.

Reverse verification — direction predicted in writing before running

Predicted: restoring the empty-body branch turns exactly three of the four new
cases red — the 501 assertion, the equivalence pin, and the machine-readable case
— while "a protocol that CAN answer is untouched" stays green (the ablation
does not touch the success path), and nothing else in the package moves. Observed,
exactly that:

 ❯ src/meta-references-capability-gap.test.ts (4 tests | 3 failed)
   × an absent capability answers 501 NOT_IMPLEMENTED at the ADR-0112 nested position
     AssertionError: expected 200 to be 501
   × ⭐ THE PIN — the capability gap and a genuine zero are not the same answer
     AssertionError: expected 200 not to be 200
   × the refusal is machine-readable by the SAME read as its `/meta` 501 siblings
     AssertionError: expected undefined to be 'NOT_IMPLEMENTED'
 Test Files  1 failed | 123 passed (124)

expected 200 not to be 200 is the defect stating itself. The fix was committed
before the ablation and restored with git checkout, then proved byte-identical
to the committed state (git diff --exit-code empty).

Zone 2 — the PM's assumptions, re-tested

2a — PR #9324 did not close this. Confirmed, not inherited. It is merged
(2026-08-17T14:38:10Z) and touched eight files, every one under
packages/metadata-protocol, packages/objectql or .changesetzero files in
packages/rest
. Its own tier-fence note records "no new refusal, no
response-shape change". It replaced a curated registry with a derived one inside
the protocol implementation, which cannot help a route that never calls it.

2b — falsified. The references route is NOT the only limb with this shape.
GET /meta/:type/:name/audit answers a missing auditMetaItem with
res.json({ events: [] }) — the same capability gap rendered as a well-formed
empty answer, on a compliance surface, where it reads as "this item has no audit
trail". Filed separately, deliberately not a rider here. The sweep discriminates
rather than merely finding nothing: the same scan located four sibling limbs that
do refuse, so a zero was a possible outcome.

2c — options 1 and 3 are not complements; see above. The measurement chose,
and the reasoning is recorded here as the fence asked.

One more finding, filed separately: the fix is necessary but not sufficient

Refusing at the route makes the wire honest. It does not by itself remove the
user-visible harm, because the consumer swallows it:
objectui ResourceEditPage.tsx catches every failure from client.references()
into setRefs([]), which renders the same "Nothing in the metadata graph points at
this item. Safe to delete." empty state, with only a console.error. So a truthful
501 is currently collapsed back into the lie at the consumer. That is a second
defect, in a second repo, and it is filed rather than ridden on this PR — but the
producer is still the right place to fix the producer's half, and this half is
what makes the consumer's half fixable at all.

Verification — all at a412daaf8, the final commit

pnpm --filter @objectstack/rest test        Test Files 124 passed (124)
                                                 Tests 2034 passed (2034)
pnpm --filter @objectstack/rest typecheck   tsc --noEmit, exit 0
pnpm exec eslint --no-inline-config [the two changed files]   exit 0

Gates re-derived from the actual changed paths with
node scripts/pm/dispatch-gates.mjs, which added several beyond the dispatched
list (the changeset family, and the convention-triggered family a new test file
pulls in). All pass:

check:authz-resolver                  OK
check:route-envelope                  OK   (rest-server.ts dialect ratchets unmoved)
check:dispatcher-error-vocabulary     OK   (0 awaiting a ledger entry)
check:cross-package-test-inputs       OK   (33 self-test cases; 12 packages declared)
check:changeset-gate-self-tests       OK
check:objectui-changeset              OK
check:query-options-erasure           OK   (baseline verified against 51a46a4: no files added)
check:engine-double-contract          OK   (319 pinned, 133 DEBT, 2 exempt)
check:where-matcher                   OK   (baseline verified against 51a46a4: no files added)
check:type-check-coverage             OK   (64/77 packages, 13 DEBT, 1 exempt)
check-adr-0087-registration           OK
check-changeset-no-major              OK
check-empty-changeset                 OK
check-cross-package-test-inputs.mjs   OK
check-affected-docs.mjs               OK   (197 self-test cases)
check-nul-bytes.mjs                   OK   (6118 files, no raw control bytes)

Ratchet family at the final head, after the full closure build
(turbo run build --filter=./packages/* --filter=./packages/*/*, 70/70 successful):

check-type-check-coverage --re-measure: OK — 33 ledger entr(ies) re-measured in 213.5s,
  1926 raw tsc error(s) total, none above its recorded number.
  surplus: none — every entry sits exactly at its measurement.

Tier fence

Not tripped. No packages/spec edit, no *.zod.ts, no error-code ledger row —
NOT_IMPLEMENTED is already a registered code with canonical status 501
(error-code-ledger.zod.ts), so nothing is minted. Option 2 (a wire-level
coverage discriminator) was ruled out of scope by the fence and the measurement
did not point there: the honest distinction this route needed is a refusal, not a
new response key.

Declared file surface honoured: packages/rest/src/rest-server.ts (the references
route only), its test, and .changeset/**. Nothing else touched.


Generated by Claude Code

…ering "nothing depends on this item" (#9326)

GET /api/v1/meta/:type/:name/references feature-detects findReferencesToMeta on
the resolved protocol. When the method was absent the route answered
200 { references: [] }, so a capability gap reached the wire as the statement
"nothing depends on this item" — ADR-0110 D3 collapsed, with the admin "Used by"
panel rendering that empty case as "Nothing in the metadata graph points at this
item. Safe to delete." for an operator about to delete something.

The branch now answers 501 with the ADR-0112 nested envelope the sibling /meta
501 refusals converged on (#7035), so body.error.code is readable by the same
consumer code that already reads the others. A protocol that has the method is
untouched: empty and non-empty results both still pass through as 200.

Refusing at the route rather than asserting at assembly is deliberate.
findReferencesToMeta is not a member of RestProtocol and is not declared in
@objectstack/spec at all — it is an ADR-0076 D9 server-only extension reached
through a runtime cast — so a boot-time assertion would promote an undeclared
optional extension into a required one, which is a spec contract decision.

Co-Authored-By: Claude <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

Nothing in this diff resolved to a documentable surface (no symbol, route or SDK anchor derived from 1 changed package(s)), so this run has no opinion about the docs.

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/rest/src/rest-server.ts) — pages documenting those are invisible to this run

Coarse fallback — 13 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 origin/mainpackageMentionDocs.

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Aug 18, 2026
@os-zhuang
os-zhuang marked this pull request as ready for review August 18, 2026 00:57
@os-zhuang
os-zhuang enabled auto-merge August 18, 2026 00:57
@os-zhuang
os-zhuang added this pull request to the merge queue Aug 18, 2026
Merged via the queue into main with commit 499f55e Aug 18, 2026
27 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-9326-references-capability-gap branch August 18, 2026 01:20
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