Skip to content

Commit e743fb5

Browse files
os-muskclaude
andauthored
fix(metadata-protocol): front-load the ADR-0110 D3 prescription in the /references refusal so the #5423 bound cannot cut the remedy (#17684)
* test(rest): pin that the /references refusal's remedy survives the #5423 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> * fix(metadata-protocol): front-load the ADR-0110 D3 prescription in the /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> * chore: changeset for the /references refusal remedy re-order Claude-Session: https://claude.ai/code/session_01RuoNSXUbBoWHkNS4AknTrM Co-authored-by: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 4af758d commit e743fb5

4 files changed

Lines changed: 200 additions & 6 deletions

File tree

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
---
2+
'@objectstack/metadata-protocol': patch
3+
---
4+
5+
fix(metadata-protocol): the `/references` refusal front-loads its ADR-0110 D3 prescription, so the #5423 bound cannot cut the remedy (#17584)
6+
7+
`GET /api/v1/meta/:type/:name/references` refuses an unanswerable target type
8+
(`field`, addressed by the composite key `<object>.<field>` that no reference
9+
site can hold) with a prescriptive 501: it names the question that IS
10+
answerable, `GET /api/v1/meta/object/<owner>/references`. That clause is the
11+
half ADR-0110 D3 exists to deliver — the admin "Used by" panel renders an empty
12+
answer as *"Nothing in the metadata graph points at this item. Safe to delete."*
13+
to an operator whose next click is a delete.
14+
15+
Since #16146 the refusal crosses the REST boundary through the shared #5423
16+
bound (`CLIENT_MESSAGE_MAX`, 500 characters), which truncates the **tail**. The
17+
sentence back-loaded the prescription and interpolates the object name twice, so
18+
it grew about three characters per character of name and the remedy was the
19+
first thing a long name cost. Measured through the real route on the unrepaired
20+
sentence: a 37-character object name beside a 37-character field name composed
21+
502 characters and arrived as `…/api/v1/meta/object/<obj>/referenc…` — the
22+
opener still readable, the URL cut mid-path, an instruction that 404s if
23+
followed. `crm_opportunity_line_item_snapshot_v2` is 37 characters, and nothing
24+
caps a metadata name near that (the ceiling is the storing column's
25+
`maxLength`; the widest is `sys_metadata.name` at 255).
26+
27+
The clauses are re-ordered so truncation costs the **explanation** instead. No
28+
behaviour moves: the refusal decides exactly what it decided before, the same
29+
`NOT_IMPLEMENTED` / `501` / `refusal` declaration is raised for exactly the same
30+
targets, and the bound is untouched. Callers matching on the message's opening
31+
words will see the new order; matching on `error.code` is unaffected.
32+
33+
FROM: `References to a 'field' item cannot be computed. … Ask the owning object
34+
instead: GET /api/v1/meta/object/<owner>/references.`
35+
TO: `Ask the owning object instead: GET /api/v1/meta/object/<owner>/references.
36+
References to a 'field' item cannot be computed, because …`

packages/metadata-protocol/src/protocol.reference-target-unanswerable.test.ts

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -125,8 +125,41 @@ describe('[#9327] a `field` TARGET is refused, not cleared', () => {
125125
`the message opens with a bracketed tag: ${err.message.slice(0, 48)}`,
126126
).toBe(false);
127127
// …and the prose it opens with INSTEAD is asserted here too, so this pin
128-
// cannot go green by the message becoming empty or generic.
129-
expect(err.message).toMatch(/^References to a 'field' item cannot be computed\./);
128+
// cannot go green by the message becoming empty or generic. [#17584]
129+
// moved that opener: the ADR-0110 D3 prescription is now the FIRST
130+
// clause, so this anchor moved with the sentence it guards.
131+
expect(err.message).toMatch(
132+
/^Ask the owning object instead: GET \/api\/v1\/meta\/object\/account\/references\./,
133+
);
134+
});
135+
136+
it('THE PIN [#17584]: the prescription is FRONT-LOADED — it precedes the explanation', async () => {
137+
// The ORDER, pinned as an order rather than as a sentence. Since #16146
138+
// this refusal crosses the REST boundary through #5423's shared
139+
// `CLIENT_MESSAGE_MAX`, which truncates the TAIL; back-loaded, the
140+
// remedy was what a long name cost the operator, measured at the wire
141+
// in `rest-server-meta-references-refusal-envelope.test.ts` (37/37
142+
// composed 502 characters and the URL arrived cut mid-path).
143+
//
144+
// ⚠️ This asserts POSITION, not wording: a later re-wording may rewrite
145+
// every clause here and stay green, and may not push the answerable
146+
// question behind the explanation. The bound itself is NOT re-derived
147+
// in this package — the producer owes ordering, the wire pin owes the
148+
// number, and a second copy of 500 here would drift.
149+
const protocol = protocolWith({});
150+
151+
const err = await expectUnanswerableRefusal(
152+
() => protocol.findReferencesToMeta({ type: 'field', name: 'account.owner' }),
153+
);
154+
155+
const remedyAt = err.message.indexOf('GET /api/v1/meta/object/account/references');
156+
const explanationAt = err.message.indexOf('cannot be computed');
157+
expect(remedyAt, 'the refusal names no answerable question at all').toBeGreaterThanOrEqual(0);
158+
expect(explanationAt, 'the refusal stopped saying it cannot compute').toBeGreaterThanOrEqual(0);
159+
expect(
160+
remedyAt,
161+
'the remedy is back-loaded again — truncation will cost the operator their next step',
162+
).toBeLessThan(explanationAt);
130163
});
131164

132165
it('the refusal is PRESCRIPTIVE — it names the answerable question (ADR-0110 D3)', async () => {

packages/metadata-protocol/src/protocol.ts

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22066,6 +22066,36 @@ export class ObjectStackProtocolImplementation implements
2206622066
// object that owns it, which is where a field is authored and where the
2206722067
// reference graph has real edges.
2206822068
//
22069+
// [#17584] ⛔ And the prescription comes FIRST, before the explanation
22070+
// of why the question is unanswerable. That order is load-bearing, not
22071+
// style. Since #16146 this refusal crosses the REST boundary through
22072+
// `boundedDeclaredRefusalMessage`, which applies #5423's shared
22073+
// `CLIENT_MESSAGE_MAX` (500) by TRUNCATING THE TAIL — and that helper's
22074+
// own docblock declares the assumption it rests on: "These messages
22075+
// front-load the main clause … and back-load attribution and issue
22076+
// numbers, which belong in the log rather than the response."
22077+
//
22078+
// This sentence interpolates the object name twice (inside `targetName`
22079+
// and again as `owner`) and the field name once, so it grows ~3
22080+
// characters per character of name. Back-loaded, it broke that
22081+
// assumption at reachable lengths: measured through the real route, a
22082+
// 37/37 object/field pair composed 502 characters and was delivered as
22083+
// `…/api/v1/meta/object/<obj>/referenc…` — the opener still readable and
22084+
// the URL cut mid-path, which is an instruction that 404s if the
22085+
// operator follows it. `crm_opportunity_line_item_snapshot_v2` is 37
22086+
// characters, and nothing in `packages/spec` caps a metadata name at
22087+
// all (#12144: the ceiling is the storing column's `maxLength`, and the
22088+
// widest is `sys_metadata.name` at 255).
22089+
//
22090+
// Front-loaded, truncation costs the EXPLANATION instead — the half an
22091+
// operator can still act without. ⛔ Do not reorder this back, and ⛔ do
22092+
// not repair a future overflow by raising the bound or exempting this
22093+
// door: the bound is the security floor under the refusal channel
22094+
// (#5423) and the #16146 ruling put this door explicitly under it. The
22095+
// invariant is pinned at the WIRE, where the bound actually applies, by
22096+
// `rest-server-meta-references-refusal-envelope.test.ts` — and the
22097+
// producer-side ORDER by `protocol.reference-target-unanswerable.test.ts`.
22098+
//
2206922099
// ⛔ And it opens with NO bracketed tag. The `[item_locked]`-style tags
2207022100
// this file writes elsewhere are lowercase restatements of the throw's OWN
2207122101
// declared `code`, so the wire carries the same token on the `code` axis;
@@ -22079,12 +22109,12 @@ export class ObjectStackProtocolImplementation implements
2207922109
if (REFERENCE_SITES.unanswerableTargetTypes.includes(singularTarget)) {
2208022110
const owner = targetName.includes('.') ? targetName.slice(0, targetName.indexOf('.')) : '<object>';
2208122111
const err = new Error(
22082-
`References to a '${singularTarget}' item cannot be computed. `
22083-
+ `A '${singularTarget}' is addressed by the composite key '<object>.<field>' `
22112+
`Ask the owning object instead: GET /api/v1/meta/object/${owner}/references. `
22113+
+ `References to a '${singularTarget}' item cannot be computed, because `
22114+
+ `a '${singularTarget}' is addressed by the composite key '<object>.<field>' `
2208422115
+ `(here '${targetName}'), while every metadata property that names a field holds the `
2208522116
+ `BARE field name — so no reference site can ever match this key and an empty answer `
22086-
+ `would mean "not computable", not "nothing depends on it". `
22087-
+ `Ask the owning object instead: GET /api/v1/meta/object/${owner}/references.`,
22117+
+ `would mean "not computable", not "nothing depends on it".`,
2208822118
);
2208922119
(err as any).code = 'NOT_IMPLEMENTED';
2209022120
(err as any).status = 501;

packages/rest/src/rest-server-meta-references-refusal-envelope.test.ts

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,4 +269,99 @@ describe('#15685 the /references door answers its two refusals in ONE envelope',
269269
expect(refused.body?.error).toBe(INTERNAL_ERROR_MESSAGE);
270270
});
271271
});
272+
273+
// ── ④ the ADR-0110 D3 REMEDY survives the #5423 bound ─────────────────
274+
//
275+
// [#17584] #16146 routed this door through `boundedDeclaredRefusalMessage`,
276+
// so the shared 500-character bound applies HERE now and it cuts the TAIL.
277+
// A refusal whose remedy is back-loaded therefore loses the remedy first,
278+
// and this file's ① pin could not see it: `account.owner` composes 410
279+
// characters, well under the bound, so ① is green at every name length
280+
// while the delivered message stops being actionable at 37.
281+
//
282+
// Measured on the pre-repair sentence, through this same harness: an
283+
// object/field pair of 37 characters each composed 502 characters and was
284+
// delivered as `…/api/v1/meta/object/<obj>/referenc…` — the prescription's
285+
// opener still readable, its URL cut mid-path, i.e. an instruction that
286+
// 404s if the operator follows it. `crm_opportunity_line_item_snapshot_v2`
287+
// is 37 characters.
288+
//
289+
// ⭐ What is pinned below is the INVARIANT, not the wording. A later
290+
// re-wording may move every other clause; what it may not do is push the
291+
// answerable question past the bound. So the assertions read the REMEDY —
292+
// the question plus its complete URL — and never the sentence.
293+
//
294+
// POPULATION, because "survives the bound" is meaningless without one. The
295+
// enforced ceiling on a metadata item name is the `maxLength` of the column
296+
// that stores it, not a `.max()` in `packages/spec`: the identifier schemas
297+
// declare a floor and a grammar and deliberately no ceiling (#12144), and
298+
// the widest storing column is `sys_metadata.name` at 255
299+
// (`packages/metadata-core/src/objects/sys-metadata.object.ts`; the
300+
// length-ceiling note on `SystemIdentifierSchema` is the authority). Both
301+
// halves of the composite key are separately-stored names, so 255/255 is
302+
// the ceiling case — and its composite key is 511 characters, already twice
303+
// what any single stored name can be.
304+
describe('④ [#17584] the remedy survives the bound at the longest admitted name', () => {
305+
/** `sys_metadata.name` maxLength — the enforced identifier ceiling. */
306+
const STORED_NAME_MAX = 255;
307+
/**
308+
* A real-shaped name length: `crm_opportunity_line_item_snapshot_v2` is
309+
* 37 characters, and 37/37 is the pair at which the PRE-REPAIR sentence
310+
* first overflowed the bound (502 characters). ⛔ Deliberately not
311+
* "the first overflowing pair" of whatever sentence is current — that
312+
* number moves with the wording, and this file pins the invariant.
313+
*/
314+
const REACHABLE_NAME_LEN = 37;
315+
316+
const key = (objLen: number, fieldLen: number) =>
317+
`${'o'.repeat(objLen)}.${'f'.repeat(fieldLen)}`;
318+
319+
/** The remedy an operator can ACT on: the question and its whole URL. */
320+
const remedyFor = (objLen: number) =>
321+
`GET /api/v1/meta/object/${'o'.repeat(objLen)}/references`;
322+
323+
async function deliveredAt(objLen: number, fieldLen: number): Promise<string> {
324+
const refused = await boot()(UNANSWERABLE_TARGET, key(objLen, fieldLen));
325+
expect(refused.thrown, `the door threw: ${refused.thrown?.message}`).toBeUndefined();
326+
expect(refused.status).toBe(501);
327+
const message = refused.body?.error?.message;
328+
expect(message, 'no nested message reached the caller at all').toEqual(expect.any(String));
329+
return message as string;
330+
}
331+
332+
it('control — the bound really FIRES here, or every pin below is vacuous', async () => {
333+
// Without this the two pins could be green because nothing was ever
334+
// truncated, which is exactly the state ① measured and ① alone
335+
// cannot distinguish from the repair.
336+
const message = await deliveredAt(STORED_NAME_MAX, STORED_NAME_MAX);
337+
expect(message.length).toBe(500);
338+
expect(message.endsWith('…')).toBe(true);
339+
});
340+
341+
it('THE PIN: at the ceiling, the answerable question arrives with its URL INTACT', async () => {
342+
const message = await deliveredAt(STORED_NAME_MAX, STORED_NAME_MAX);
343+
expect(
344+
message,
345+
'the ADR-0110 D3 remedy did not survive the bound — the operator is left with no next step',
346+
).toEqual(expect.stringContaining(remedyFor(STORED_NAME_MAX)));
347+
});
348+
349+
it('THE PIN: and at a real-shaped name pair that the bound also cuts', async () => {
350+
const message = await deliveredAt(REACHABLE_NAME_LEN, REACHABLE_NAME_LEN);
351+
expect(message.length).toBe(500);
352+
expect(message).toEqual(expect.stringContaining(remedyFor(REACHABLE_NAME_LEN)));
353+
});
354+
355+
it('control — the ORDINARY name is not truncated at all, so the pins above are about the BOUND', async () => {
356+
// Anti-vacuity from the other side. ① drives `account.owner`, which
357+
// composes well under 500 and is delivered whole — so ① cannot tell
358+
// a surviving remedy from a message that was never cut. That is
359+
// precisely why ① stayed green through the defect, and why this
360+
// block reads lengths the bound actually reaches.
361+
const refused = await refusalA();
362+
const message = refused.body?.error?.message as string;
363+
expect(message.length).toBeLessThan(500);
364+
expect(message.endsWith('…')).toBe(false);
365+
});
366+
});
272367
});

0 commit comments

Comments
 (0)