Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions .changeset/17584-references-refusal-front-load-remedy.md
Original file line number Diff line number Diff line change
@@ -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 `<object>.<field>` that no reference
site can hold) with a prescriptive 501: it names the question that IS
answerable, `GET /api/v1/meta/object/<owner>/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/<obj>/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/<owner>/references.`
TO: `Ask the owning object instead: GET /api/v1/meta/object/<owner>/references.
References to a 'field' item cannot be computed, because …`
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
38 changes: 34 additions & 4 deletions packages/metadata-protocol/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/<obj>/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;
Expand All @@ -22079,12 +22109,12 @@ export class ObjectStackProtocolImplementation implements
if (REFERENCE_SITES.unanswerableTargetTypes.includes(singularTarget)) {
const owner = targetName.includes('.') ? targetName.slice(0, targetName.indexOf('.')) : '<object>';
const err = new Error(
`References to a '${singularTarget}' item cannot be computed. `
+ `A '${singularTarget}' is addressed by the composite key '<object>.<field>' `
`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 '<object>.<field>' `
+ `(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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -269,4 +269,99 @@ 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/<obj>/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;
/**
* 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)}`;

/** 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<string> {
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 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(REACHABLE_NAME_LEN)));
});

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);
});
});
});
Loading