Skip to content

fix(metadata-protocol): front-load the ADR-0110 D3 prescription in the /references refusal so the #5423 bound cannot cut the remedy - #17684

Merged
os-musk merged 3 commits into
mainfrom
claude/issue-17584-references-refusal-front-load-remedy
Sep 11, 2026
Merged

fix(metadata-protocol): front-load the ADR-0110 D3 prescription in the /references refusal so the #5423 bound cannot cut the remedy#17684
os-musk merged 3 commits into
mainfrom
claude/issue-17584-references-refusal-front-load-remedy

Conversation

@os-musk

@os-musk os-musk commented Sep 11, 2026

Copy link
Copy Markdown
Collaborator

Fixes #17584

Clause-②: no

What changed

findReferencesToMeta (packages/metadata-protocol/src/protocol.ts) now composes its
ADR-0110 D3 refusal with the prescription first. Nothing about what it refuses
moves: same REFERENCE_SITES.unanswerableTargetTypes, same canonical-type fold, same
NOT_IMPLEMENTED / 501 / refusal: true declaration, same targets. Only the order of
the clauses.

before  References to a 'field' item cannot be computed. A 'field' is addressed by … .
        Ask the owning object instead: GET /api/v1/meta/object/account/references.
after   Ask the owning object instead: GET /api/v1/meta/object/account/references.
        References to a 'field' item cannot be computed, because a 'field' is addressed by … .

Why

Since #16146 this door relays through boundedDeclaredRefusalMessage, so #5423's shared
CLIENT_MESSAGE_MAX (500) applies here and truncates the tail. The sentence
interpolates the object name twice and the field name once, so it grows about three
characters per character of name — and the remedy was the last clause, i.e. the first
thing a long name cost. truncateClientMessage's own docblock declares the assumption
the old order broke: "These messages front-load the main clause … and back-load
attribution and issue numbers, which belong in the log rather than the response."

⛔ The bound is untouched. CLIENT_MESSAGE_MAX, truncateClientMessage and
boundedDeclaredRefusalMessage are not in this diff and no door exemption is added —
the bound is the security floor under the refusal channel (#5423) and the #16146 ruling
put this door explicitly under it. The only packages/rest change here is test-only.

Measurement — re-derived on this tip, not quoted from the card

Both earlier readings on this card had gone stale and both were re-measured rather than
inherited. Triage measured (01:0xZ) that the 501 door relays unbounded and instructed
that a truncated /references 501 could not be reproduced; #16146 has since landed, so
it is reproducible and is reproduced below. The card's own premise sentence
("every boundary that relays this message applies truncateClientMessage") was wider
than the tree when written.

Driven over the live template (parsed out of protocol.ts, not retyped), with
CLIENT_MESSAGE_MAX read out of error-response.ts:

object/field name length before: message len before: full remedy after: message len after: full remedy
7 / 7 412 intact 420 intact
30 / 30 481 intact 489 intact
36 / 36 499 intact 507 intact
37 / 37 502 CUT 510 intact
50 / 50 541 CUT 549 intact
255 / 255 1156 CUT 1164 intact

⚠️ The card's coarse predicate understated the harm. At 37/37 the opener of the
prescription still reads; what the bound removes is the URL's tail, so the operator
is handed … GET /api/v1/meta/object/crm_opportunity_line_item_snapshot_v2/referenc…
an instruction that 404s if followed. The pins below therefore read the remedy as
question plus complete URL, never as the opener alone.

Exact edge on the pre-repair sentence: 36/36 composes 499 and is delivered whole; 37/37
composes 502 and is the first symmetric pair the bound cuts.
crm_opportunity_line_item_snapshot_v2 is 37 characters.

The invariant, with its population

the remedy survives the bound at the longest name the platform admits, measured
rather than asserted

Population. Nothing in packages/spec caps a metadata name: the identifier schemas
declare a floor and a grammar and deliberately no .max() (#12144) — the enforced
ceiling is the maxLength of the column that stores the name, and the widest is
sys_metadata.name at 255 (packages/metadata-core/src/objects/sys-metadata.object.ts;
the length-ceiling note on SystemIdentifierSchema is the authority). Both halves of the
composite key are separately-stored names, so 255/255 is the ceiling case — its composite
key is 511 characters, already twice what any single stored name can be.

Result. At 255/255 the composed message is 1164 characters, is delivered truncated at
exactly 500 with the ellipsis, and the complete prescription plus URL is present.
Headroom: the front-loaded remedy is first cut at an object-name length of 434
179 characters beyond the enforced ceiling.

Verification

Reproduced red first, end-to-end through the real route, on the unrepaired producer
(rest-server-meta-references-refusal-envelope.test.ts, commit ad3ed5e8d):

 Test Files  1 failed (1)
      Tests  2 failed | 9 passed (11)
 × THE PIN: at the ceiling, the answerable question arrives with its URL INTACT
   AssertionError: the ADR-0110 D3 remedy did not survive the bound — the operator is
   left with no next step
 × THE PIN: and at a real-shaped name pair that the bound also cuts

Both anti-vacuity controls were green in that same run — the bound really fired
(delivered length exactly 500, ends with the ellipsis) and the ordinary account.owner
case was delivered whole. That is the pair that makes the red a real defect rather than a
harness artefact, and it is also why the pre-existing pin ① could never see this: account.owner
composes 410 characters and is never truncated at all.

Green after the repair (868545d76):

packages/rest            src/rest-server-meta-references-refusal-envelope.test.ts   11 passed (11)
packages/metadata-protocol  src/protocol.reference-target-unanswerable.test.ts      12 passed (12)

Ablation — the new producer-side order pin can fail. Mutated protocol.ts back to
the back-loaded order, proved the mutation reached disk (HEAD blob
f44ae04a… vs mutated 80bb70ce…, plus occurrence counts on the injected and removed
text), ran the suite, restored under a trap:

mutated blob = 80bb70ceda912524eca0b74c6af0e7392b26fcf7 (differs from HEAD — mutation landed)
 × THE PIN [#17584]: the prescription is FRONT-LOADED — it precedes the explanation
 Tests  2 failed | 10 passed (12)
restored blob = f44ae04a2d5f9361ccbbd4dc082796371c284b20
git diff HEAD for the path (empty == restored): []

The wire test needs no ablation: its red is the actual pre-repair tree, which is stronger.
The producer suite imports ./protocol.js relatively, so no rebuild sits between the
mutation and the reading; the wire suite resolves @objectstack/metadata-protocol through
dist, which was rebuilt and confirmed to carry the repaired head before the green run.

Pins moved with the sentence. Two content pins read the live producer message. In
packages/metadata-protocol the caret-anchored opener pin moved to the new opener (it is
the anti-vacuity half of the "opens with prose, no bracketed tag" pin), and a new pin
asserts the order positionally rather than the wording. In packages/rest the existing
① assertions survive the re-order untouched; the new block ④ is what pins the bound
invariant, because the bound only exists at that door. No behavioural change under
packages/rest, and error-response.ts is not in the diff.

Gates

Derived with node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack
against the actual changed files (4 paths, merge base 4c42fd1c3), every command run with
its exit code captured before reading, then reconciled:

✓ dispatch-gates --ran: 61 derived famil(ies) accounted for — 61 run,
  0 NOT-MEASURED (a DERIVED zero — all 61 recorded an exit code and none of them is 3).

Two returned exit 3 (PREREQUISITE NOT MET) on the first pass and neither was read as a
pass: check:dual-build-cjs-loads wanted built output (resolved by
turbo run build --filter='./packages/*' --filter='./packages/*/*', 72/72 successful), and
check:type-check-debt OOMed under my runner's tighter NODE_OPTIONS — re-run at the
gate's own pinned ceiling (--max-old-space-size=6144, the value lint.yml's
typecheck-debt step pins) it reports OK — 5 ledger entr(ies) re-measured in 100.3s, 55 raw tsc error(s) total, none above its recorded number.

Also run at 868545d76, after the final commit:

  • pnpm --filter @objectstack/metadata-protocol test — 176 passed | 2 skipped (178 files), 2526 passed
  • pnpm --filter @objectstack/rest test — 190 passed (190 files), 3181 passed | 1 skipped
  • pnpm --filter @objectstack/metadata-protocol typecheck && pnpm --filter @objectstack/rest typecheck — exit 0 (rest's check:test-typecheck covers the new test block)
  • pnpm lint (eslint . --no-inline-config, whole repo, no narrowing claimed) — exit 0

Acceptance notes

  • boundedDeclaredRefusalMessage's docblock in packages/rest/src/error-response.ts
    states "The measured /references sentence is 410 characters, so it survives whole".
    That number is now 418 and the sentence it describes has been re-ordered, so the
    docblock is numerically stale. Noted, not filed — it is a stale comment, not a
    reproducible defect, a contract violation or an authoring trap. error-response.ts is
    read-only for this card. Carrier: the next PR in the packages/rest lane that touches
    error-response.ts; if none comes, the sentence the docblock prescribes ("a producer
    writing a longer one owes the caller a front-loaded remedy") is now satisfied by this
    producer, so the drift is in the example, not the rule.
  • Three other files spell a /references-shaped refusal as a synthetic literal
    (packages/spec/src/api/contract.test.ts, packages/spec/src/api/errors.test.ts,
    packages/types/src/thrown-http-error-refusal.test.ts). They are self-contained
    fixtures exercising ApiErrorSchema.refusal / declaredRefusalMessage, not reads of
    the live producer — checked by content, and they do not move with this sentence.
    Noted, not filed.

Generated by Claude Code

 bound

Drives the real `/meta/:type/:name/references` route with object/field names
past the truncation threshold and asserts the delivered message still carries
the answerable question WITH its complete URL. Red at this commit: the
producer back-loads the prescription, so truncation takes the URL first.

Claude-Session: https://claude.ai/code/session_01RuoNSXUbBoWHkNS4AknTrM
Co-authored-by: Claude <noreply@anthropic.com>
…e /references refusal

Since #16146 this refusal crosses the REST boundary through #5423's shared
500-character bound, which truncates the tail. The prescription was the last
clause, so a long object/field name cost the operator the one half of the
message they can act on. Re-ordered so truncation costs the explanation
instead; the refusal decides exactly what it decided before.

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

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

9 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, the bare tail of client method meta.getReferences, bound to GET /api/v1/meta/:type/:name/references), meta.getReferences (sdk, the route ledger binds it to GET /api/v1/meta/:type/:name/references, selected by route anchor /:type/:name/references))
  • content/docs/api/data-flow.mdx (via /api/v1/meta/object (route, a path literal in findReferencesToMeta))
  • content/docs/api/wire-format.mdx (via /api/v1/meta/object (route, a path literal in findReferencesToMeta))
  • content/docs/protocol/diagram.mdx (via /api/v1/meta/object (route, a path literal in findReferencesToMeta))
  • content/docs/protocol/kernel/error-handling.mdx (via /api/v1/meta/object (route, a path literal in findReferencesToMeta))
  • content/docs/protocol/kernel/http-protocol.mdx (via /api/v1/meta/object (route, a path literal in findReferencesToMeta))
  • content/docs/protocol/objectql/state-machine.mdx (via /api/v1/meta/object (route, a path literal in findReferencesToMeta))
  • content/docs/ui/forms.mdx (via /api/v1/meta/object (route, a path literal in findReferencesToMeta))
  • content/docs/ui/react-pages.mdx (via /:type/:name/references (route, bridged from symbol findReferencesToMeta — its route source's handler names it))

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

  • content/docs/releases/v17/17-1.mdx (via findReferencesToMeta (symbol, a method of class ObjectStackProtocolImplementation), /api/v1/meta/object (route, a path literal in findReferencesToMeta))

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
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 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; 100 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.

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

Which tree this was computed on

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

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

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

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

Development

Successfully merging this pull request may close these issues.

rest/meta: the /references 501 back-loads its ADR-0110 D3 prescription, so the #5423 bound silently cuts it off for long object names

2 participants