From ad3ed5e8daad726697ea3e90d008ca3957de1e7b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 11 Sep 2026 10:56:18 +0000 Subject: [PATCH 1/3] 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 --- ...r-meta-references-refusal-envelope.test.ts | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) diff --git a/packages/rest/src/rest-server-meta-references-refusal-envelope.test.ts b/packages/rest/src/rest-server-meta-references-refusal-envelope.test.ts index 2561309a54..900fc01f07 100644 --- a/packages/rest/src/rest-server-meta-references-refusal-envelope.test.ts +++ b/packages/rest/src/rest-server-meta-references-refusal-envelope.test.ts @@ -269,4 +269,88 @@ describe('#15685 the /references door answers its two refusals in ONE envelope', expect(refused.body?.error).toBe(INTERNAL_ERROR_MESSAGE); }); }); + + // ── ④ the ADR-0110 D3 REMEDY survives the #5423 bound ───────────────── + // + // [#17584] #16146 routed this door through `boundedDeclaredRefusalMessage`, + // so the shared 500-character bound applies HERE now and it cuts the TAIL. + // A refusal whose remedy is back-loaded therefore loses the remedy first, + // and this file's ① pin could not see it: `account.owner` composes 410 + // characters, well under the bound, so ① is green at every name length + // while the delivered message stops being actionable at 37. + // + // Measured on the pre-repair sentence, through this same harness: an + // object/field pair of 37 characters each composed 502 characters and was + // delivered as `…/api/v1/meta/object//referenc…` — the prescription's + // opener still readable, its URL cut mid-path, i.e. an instruction that + // 404s if the operator follows it. `crm_opportunity_line_item_snapshot_v2` + // is 37 characters. + // + // ⭐ What is pinned below is the INVARIANT, not the wording. A later + // re-wording may move every other clause; what it may not do is push the + // answerable question past the bound. So the assertions read the REMEDY — + // the question plus its complete URL — and never the sentence. + // + // POPULATION, because "survives the bound" is meaningless without one. The + // enforced ceiling on a metadata item name is the `maxLength` of the column + // that stores it, not a `.max()` in `packages/spec`: the identifier schemas + // declare a floor and a grammar and deliberately no ceiling (#12144), and + // the widest storing column 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 — and its composite key is 511 characters, already twice + // what any single stored name can be. + describe('④ [#17584] the remedy survives the bound at the longest admitted name', () => { + /** `sys_metadata.name` maxLength — the enforced identifier ceiling. */ + const STORED_NAME_MAX = 255; + /** The smallest symmetric pair that overflows the bound (36/36 = 499). */ + const FIRST_OVERFLOWING = 37; + + const key = (objLen: number, fieldLen: number) => + `${'o'.repeat(objLen)}.${'f'.repeat(fieldLen)}`; + + /** The remedy an operator can ACT on: the question and its whole URL. */ + const remedyFor = (objLen: number) => + `GET /api/v1/meta/object/${'o'.repeat(objLen)}/references`; + + async function deliveredAt(objLen: number, fieldLen: number): Promise { + const refused = await boot()(UNANSWERABLE_TARGET, key(objLen, fieldLen)); + expect(refused.thrown, `the door threw: ${refused.thrown?.message}`).toBeUndefined(); + expect(refused.status).toBe(501); + const message = refused.body?.error?.message; + expect(message, 'no nested message reached the caller at all').toEqual(expect.any(String)); + return message as string; + } + + it('control — the bound really FIRES here, or every pin below is vacuous', async () => { + // Without this the two pins could be green because nothing was ever + // truncated, which is exactly the state ① measured and ① alone + // cannot distinguish from the repair. + const message = await deliveredAt(STORED_NAME_MAX, STORED_NAME_MAX); + expect(message.length).toBe(500); + expect(message.endsWith('…')).toBe(true); + }); + + it('THE PIN: at the ceiling, the answerable question arrives with its URL INTACT', async () => { + const message = await deliveredAt(STORED_NAME_MAX, STORED_NAME_MAX); + expect( + message, + 'the ADR-0110 D3 remedy did not survive the bound — the operator is left with no next step', + ).toEqual(expect.stringContaining(remedyFor(STORED_NAME_MAX))); + }); + + it('THE PIN: and at the smallest name pair the bound cuts at all', async () => { + const message = await deliveredAt(FIRST_OVERFLOWING, FIRST_OVERFLOWING); + expect(message.length).toBe(500); + expect(message).toEqual(expect.stringContaining(remedyFor(FIRST_OVERFLOWING))); + }); + + it('control — one below that pair is delivered WHOLE, so 37 is the real edge', async () => { + const message = await deliveredAt(FIRST_OVERFLOWING - 1, FIRST_OVERFLOWING - 1); + expect(message.length).toBeLessThan(500); + expect(message.endsWith('…')).toBe(false); + expect(message).toEqual(expect.stringContaining(remedyFor(FIRST_OVERFLOWING - 1))); + }); + }); }); From 7e8d9a5febdcc61a78885e9f740d51fb35af48df Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 11 Sep 2026 10:58:04 +0000 Subject: [PATCH 2/3] 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 --- ...ocol.reference-target-unanswerable.test.ts | 37 +++++++++++++++++- packages/metadata-protocol/src/protocol.ts | 38 +++++++++++++++++-- ...r-meta-references-refusal-envelope.test.ts | 27 +++++++++---- 3 files changed, 88 insertions(+), 14 deletions(-) diff --git a/packages/metadata-protocol/src/protocol.reference-target-unanswerable.test.ts b/packages/metadata-protocol/src/protocol.reference-target-unanswerable.test.ts index 577cdac7ad..ceb212f8cf 100644 --- a/packages/metadata-protocol/src/protocol.reference-target-unanswerable.test.ts +++ b/packages/metadata-protocol/src/protocol.reference-target-unanswerable.test.ts @@ -125,8 +125,41 @@ describe('[#9327] a `field` TARGET is refused, not cleared', () => { `the message opens with a bracketed tag: ${err.message.slice(0, 48)}`, ).toBe(false); // …and the prose it opens with INSTEAD is asserted here too, so this pin - // cannot go green by the message becoming empty or generic. - expect(err.message).toMatch(/^References to a 'field' item cannot be computed\./); + // cannot go green by the message becoming empty or generic. [#17584] + // moved that opener: the ADR-0110 D3 prescription is now the FIRST + // clause, so this anchor moved with the sentence it guards. + expect(err.message).toMatch( + /^Ask the owning object instead: GET \/api\/v1\/meta\/object\/account\/references\./, + ); + }); + + it('THE PIN [#17584]: the prescription is FRONT-LOADED — it precedes the explanation', async () => { + // The ORDER, pinned as an order rather than as a sentence. Since #16146 + // this refusal crosses the REST boundary through #5423's shared + // `CLIENT_MESSAGE_MAX`, which truncates the TAIL; back-loaded, the + // remedy was what a long name cost the operator, measured at the wire + // in `rest-server-meta-references-refusal-envelope.test.ts` (37/37 + // composed 502 characters and the URL arrived cut mid-path). + // + // ⚠️ This asserts POSITION, not wording: a later re-wording may rewrite + // every clause here and stay green, and may not push the answerable + // question behind the explanation. The bound itself is NOT re-derived + // in this package — the producer owes ordering, the wire pin owes the + // number, and a second copy of 500 here would drift. + const protocol = protocolWith({}); + + const err = await expectUnanswerableRefusal( + () => protocol.findReferencesToMeta({ type: 'field', name: 'account.owner' }), + ); + + const remedyAt = err.message.indexOf('GET /api/v1/meta/object/account/references'); + const explanationAt = err.message.indexOf('cannot be computed'); + expect(remedyAt, 'the refusal names no answerable question at all').toBeGreaterThanOrEqual(0); + expect(explanationAt, 'the refusal stopped saying it cannot compute').toBeGreaterThanOrEqual(0); + expect( + remedyAt, + 'the remedy is back-loaded again — truncation will cost the operator their next step', + ).toBeLessThan(explanationAt); }); it('the refusal is PRESCRIPTIVE — it names the answerable question (ADR-0110 D3)', async () => { diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 17abf97418..f44ae04a2d 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -22066,6 +22066,36 @@ export class ObjectStackProtocolImplementation implements // object that owns it, which is where a field is authored and where the // reference graph has real edges. // + // [#17584] ⛔ And the prescription comes FIRST, before the explanation + // of why the question is unanswerable. That order is load-bearing, not + // style. Since #16146 this refusal crosses the REST boundary through + // `boundedDeclaredRefusalMessage`, which applies #5423's shared + // `CLIENT_MESSAGE_MAX` (500) by TRUNCATING THE TAIL — and that helper's + // own docblock declares the assumption it rests on: "These messages + // front-load the main clause … and back-load attribution and issue + // numbers, which belong in the log rather than the response." + // + // This sentence interpolates the object name twice (inside `targetName` + // and again as `owner`) and the field name once, so it grows ~3 + // characters per character of name. Back-loaded, it broke that + // assumption at reachable lengths: measured through the real route, a + // 37/37 object/field pair composed 502 characters and was delivered as + // `…/api/v1/meta/object//referenc…` — the opener still readable and + // the URL cut mid-path, which is an instruction that 404s if the + // operator follows it. `crm_opportunity_line_item_snapshot_v2` is 37 + // characters, and nothing in `packages/spec` caps a metadata name at + // all (#12144: the ceiling is the storing column's `maxLength`, and the + // widest is `sys_metadata.name` at 255). + // + // Front-loaded, truncation costs the EXPLANATION instead — the half an + // operator can still act without. ⛔ Do not reorder this back, and ⛔ do + // not repair a future overflow by raising the bound or exempting this + // door: the bound is the security floor under the refusal channel + // (#5423) and the #16146 ruling put this door explicitly under it. The + // invariant is pinned at the WIRE, where the bound actually applies, by + // `rest-server-meta-references-refusal-envelope.test.ts` — and the + // producer-side ORDER by `protocol.reference-target-unanswerable.test.ts`. + // // ⛔ And it opens with NO bracketed tag. The `[item_locked]`-style tags // this file writes elsewhere are lowercase restatements of the throw's OWN // declared `code`, so the wire carries the same token on the `code` axis; @@ -22079,12 +22109,12 @@ export class ObjectStackProtocolImplementation implements if (REFERENCE_SITES.unanswerableTargetTypes.includes(singularTarget)) { const owner = targetName.includes('.') ? targetName.slice(0, targetName.indexOf('.')) : ''; const err = new Error( - `References to a '${singularTarget}' item cannot be computed. ` - + `A '${singularTarget}' is addressed by the composite key '.' ` + `Ask the owning object instead: GET /api/v1/meta/object/${owner}/references. ` + + `References to a '${singularTarget}' item cannot be computed, because ` + + `a '${singularTarget}' is addressed by the composite key '.' ` + `(here '${targetName}'), while every metadata property that names a field holds the ` + `BARE field name — so no reference site can ever match this key and an empty answer ` - + `would mean "not computable", not "nothing depends on it". ` - + `Ask the owning object instead: GET /api/v1/meta/object/${owner}/references.`, + + `would mean "not computable", not "nothing depends on it".`, ); (err as any).code = 'NOT_IMPLEMENTED'; (err as any).status = 501; diff --git a/packages/rest/src/rest-server-meta-references-refusal-envelope.test.ts b/packages/rest/src/rest-server-meta-references-refusal-envelope.test.ts index 900fc01f07..181cfdc579 100644 --- a/packages/rest/src/rest-server-meta-references-refusal-envelope.test.ts +++ b/packages/rest/src/rest-server-meta-references-refusal-envelope.test.ts @@ -304,8 +304,14 @@ describe('#15685 the /references door answers its two refusals in ONE envelope', describe('④ [#17584] the remedy survives the bound at the longest admitted name', () => { /** `sys_metadata.name` maxLength — the enforced identifier ceiling. */ const STORED_NAME_MAX = 255; - /** The smallest symmetric pair that overflows the bound (36/36 = 499). */ - const FIRST_OVERFLOWING = 37; + /** + * A real-shaped name length: `crm_opportunity_line_item_snapshot_v2` is + * 37 characters, and 37/37 is the pair at which the PRE-REPAIR sentence + * first overflowed the bound (502 characters). ⛔ Deliberately not + * "the first overflowing pair" of whatever sentence is current — that + * number moves with the wording, and this file pins the invariant. + */ + const REACHABLE_NAME_LEN = 37; const key = (objLen: number, fieldLen: number) => `${'o'.repeat(objLen)}.${'f'.repeat(fieldLen)}`; @@ -340,17 +346,22 @@ describe('#15685 the /references door answers its two refusals in ONE envelope', ).toEqual(expect.stringContaining(remedyFor(STORED_NAME_MAX))); }); - it('THE PIN: and at the smallest name pair the bound cuts at all', async () => { - const message = await deliveredAt(FIRST_OVERFLOWING, FIRST_OVERFLOWING); + it('THE PIN: and at a real-shaped name pair that the bound also cuts', async () => { + const message = await deliveredAt(REACHABLE_NAME_LEN, REACHABLE_NAME_LEN); expect(message.length).toBe(500); - expect(message).toEqual(expect.stringContaining(remedyFor(FIRST_OVERFLOWING))); + expect(message).toEqual(expect.stringContaining(remedyFor(REACHABLE_NAME_LEN))); }); - it('control — one below that pair is delivered WHOLE, so 37 is the real edge', async () => { - const message = await deliveredAt(FIRST_OVERFLOWING - 1, FIRST_OVERFLOWING - 1); + it('control — the ORDINARY name is not truncated at all, so the pins above are about the BOUND', async () => { + // Anti-vacuity from the other side. ① drives `account.owner`, which + // composes well under 500 and is delivered whole — so ① cannot tell + // a surviving remedy from a message that was never cut. That is + // precisely why ① stayed green through the defect, and why this + // block reads lengths the bound actually reaches. + const refused = await refusalA(); + const message = refused.body?.error?.message as string; expect(message.length).toBeLessThan(500); expect(message.endsWith('…')).toBe(false); - expect(message).toEqual(expect.stringContaining(remedyFor(FIRST_OVERFLOWING - 1))); }); }); }); From 868545d767436d398c160a82a2b266a0f94ee21c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 11 Sep 2026 11:14:59 +0000 Subject: [PATCH 3/3] chore: changeset for the /references refusal remedy re-order Claude-Session: https://claude.ai/code/session_01RuoNSXUbBoWHkNS4AknTrM Co-authored-by: Claude --- ...84-references-refusal-front-load-remedy.md | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 .changeset/17584-references-refusal-front-load-remedy.md diff --git a/.changeset/17584-references-refusal-front-load-remedy.md b/.changeset/17584-references-refusal-front-load-remedy.md new file mode 100644 index 0000000000..ccb794c89a --- /dev/null +++ b/.changeset/17584-references-refusal-front-load-remedy.md @@ -0,0 +1,36 @@ +--- +'@objectstack/metadata-protocol': patch +--- + +fix(metadata-protocol): the `/references` refusal front-loads its ADR-0110 D3 prescription, so the #5423 bound cannot cut the remedy (#17584) + +`GET /api/v1/meta/:type/:name/references` refuses an unanswerable target type +(`field`, addressed by the composite key `.` that no reference +site can hold) with a prescriptive 501: it names the question that IS +answerable, `GET /api/v1/meta/object//references`. That clause is the +half ADR-0110 D3 exists to deliver — the admin "Used by" panel renders an empty +answer as *"Nothing in the metadata graph points at this item. Safe to delete."* +to an operator whose next click is a delete. + +Since #16146 the refusal crosses the REST boundary through the shared #5423 +bound (`CLIENT_MESSAGE_MAX`, 500 characters), which truncates the **tail**. The +sentence back-loaded the prescription and interpolates the object name twice, so +it grew about three characters per character of name and the remedy was the +first thing a long name cost. Measured through the real route on the unrepaired +sentence: a 37-character object name beside a 37-character field name composed +502 characters and arrived as `…/api/v1/meta/object//referenc…` — the +opener still readable, the URL cut mid-path, an instruction that 404s if +followed. `crm_opportunity_line_item_snapshot_v2` is 37 characters, and nothing +caps a metadata name near that (the ceiling is the storing column's +`maxLength`; the widest is `sys_metadata.name` at 255). + +The clauses are re-ordered so truncation costs the **explanation** instead. No +behaviour moves: the refusal decides exactly what it decided before, the same +`NOT_IMPLEMENTED` / `501` / `refusal` declaration is raised for exactly the same +targets, and the bound is untouched. Callers matching on the message's opening +words will see the new order; matching on `error.code` is unaffected. + +FROM: `References to a 'field' item cannot be computed. … Ask the owning object +instead: GET /api/v1/meta/object//references.` +TO: `Ask the owning object instead: GET /api/v1/meta/object//references. +References to a 'field' item cannot be computed, because …`