fix(metadata-protocol): front-load the ADR-0110 D3 prescription in the /references refusal so the #5423 bound cannot cut the remedy - #17684
Conversation
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>
Claude-Session: https://claude.ai/code/session_01RuoNSXUbBoWHkNS4AknTrM Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 9 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 10 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # 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
|
Fixes #17584
Clause-②: no
What changed
findReferencesToMeta(packages/metadata-protocol/src/protocol.ts) now composes itsADR-0110 D3 refusal with the prescription first. Nothing about what it refuses
moves: same
REFERENCE_SITES.unanswerableTargetTypes, same canonical-type fold, sameNOT_IMPLEMENTED/501/refusal: truedeclaration, same targets. Only the order ofthe clauses.
Why
Since #16146 this door relays through
boundedDeclaredRefusalMessage, so #5423's sharedCLIENT_MESSAGE_MAX(500) applies here and truncates the tail. The sentenceinterpolates 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 assumptionthe 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,truncateClientMessageandboundedDeclaredRefusalMessageare 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/restchange 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
/references501 could not be reproduced; #16146 has since landed, soit is reproducible and is reproduced below. The card's own premise sentence
("every boundary that relays this message applies
truncateClientMessage") was widerthan the tree when written.
Driven over the live template (parsed out of
protocol.ts, not retyped), withCLIENT_MESSAGE_MAXread out oferror-response.ts: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_v2is 37 characters.The invariant, with its population
Population. Nothing in
packages/speccaps a metadata name: the identifier schemasdeclare a floor and a grammar and deliberately no
.max()(#12144) — the enforcedceiling is the
maxLengthof the column that stores the name, and the widest issys_metadata.nameat 255 (packages/metadata-core/src/objects/sys-metadata.object.ts;the length-ceiling note on
SystemIdentifierSchemais the authority). Both halves of thecomposite 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, commitad3ed5e8d):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.ownercase 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.ownercomposes 410 characters and is never truncated at all.
Green after the repair (
868545d76):Ablation — the new producer-side order pin can fail. Mutated
protocol.tsback tothe back-loaded order, proved the mutation reached disk (HEAD blob
f44ae04a…vs mutated80bb70ce…, plus occurrence counts on the injected and removedtext), ran the suite, restored under a
trap:The wire test needs no ablation: its red is the actual pre-repair tree, which is stronger.
The producer suite imports
./protocol.jsrelatively, so no rebuild sits between themutation and the reading; the wire suite resolves
@objectstack/metadata-protocolthroughdist, 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-protocolthe caret-anchored opener pin moved to the new opener (it isthe 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/restthe 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, anderror-response.tsis not in the diff.Gates
Derived with
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackagainst the actual changed files (4 paths, merge base
4c42fd1c3), every command run withits exit code captured before reading, then reconciled:
Two returned exit 3 (PREREQUISITE NOT MET) on the first pass and neither was read as a
pass:
check:dual-build-cjs-loadswanted built output (resolved byturbo run build --filter='./packages/*' --filter='./packages/*/*', 72/72 successful), andcheck:type-check-debtOOMed under my runner's tighterNODE_OPTIONS— re-run at thegate's own pinned ceiling (
--max-old-space-size=6144, the valuelint.yml'stypecheck-debtstep pins) it reportsOK — 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 passedpnpm --filter @objectstack/rest test— 190 passed (190 files), 3181 passed | 1 skippedpnpm --filter @objectstack/metadata-protocol typecheck && pnpm --filter @objectstack/rest typecheck— exit 0 (rest'scheck:test-typecheckcovers the new test block)pnpm lint(eslint . --no-inline-config, whole repo, no narrowing claimed) — exit 0Acceptance notes
boundedDeclaredRefusalMessage's docblock inpackages/rest/src/error-response.tsstates "The measured
/referencessentence 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.tsisread-only for this card. Carrier: the next PR in the
packages/restlane that toucheserror-response.ts; if none comes, the sentence the docblock prescribes ("a producerwriting 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.
/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-containedfixtures exercising
ApiErrorSchema.refusal/declaredRefusalMessage, not reads ofthe live producer — checked by content, and they do not move with this sentence.
Noted, not filed.
Generated by Claude Code