Skip to content

Commit d2b2381

Browse files
claude[bot]zhuangjianguoclaude
authored
fix(rest,runtime,metadata-protocol): make the destructive-409 remedy true on both remaining /meta doors — compound-name PUT threads ?force, dispatcher states its own face (#11392)
* fix(rest,runtime,metadata-protocol): make the destructive-409 remedy true on both remaining /meta doors (#11095) The Phase 3a-destructive `409 DESTRUCTIVE_CHANGE` prescribed `?force=true` on two doors that never read it. Per the maintainer's split ruling: the compound-name REST `PUT /meta/:type/:a/:b` now threads `?force` (inheriting #7019's twin-parity ruling with its reason, plus #6877's repeated-param guard in the same stroke); the runtime dispatcher `PUT /meta` does NOT gain force and instead states its own `meta-dispatch` write face, whose clause names what a caller can actually do at that door. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019siH5jDmk5hrayvfyojUqR * chore(scripts): record the two new pinned engine doubles in the contract ledger (#11095) Both new suites need real `update`/`delete` verbs — their acceptance halves are asserted against the STORE — and both route through `assertEngineUpdateDispatch` / `assertEngineDeleteDispatch`. Regenerated with `--write` as the gate itself prescribes: 4 rows added, 0 lost. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019siH5jDmk5hrayvfyojUqR * test(rest,runtime): refuse WHERE combinators in the two new /meta engine doubles (#11095) `check:where-matcher` was red at 1103852 on both fixtures added by this PR: "NEW silently-wrong WHERE matcher ... no combinator branch (combinator read as a field name)". Both doubles handled `$or` correctly but had no `$and` arm, so `{ $and: [...] }` fell through to `r['$and']`, compared `undefined` against an array, excluded the row and returned an empty result set with nothing erroring — the gate's shape (b). A suite can go green on that while asserting about a DIFFERENT query than the one the protocol sent. MEASURED before choosing between the gate's two acceptable outcomes. Instrumenting `match` to log every `where` it receives recorded 35 calls in the rest suite and 9 in the runtime suite, every one a flat scalar object over `type` / `name` / `state` / `organization_id` / `package_id` — zero `$or` and zero `$and` driven through either. The `$or` arm was dead code, so refusal costs no live coverage and implementing `$and` would have pinned nothing. So both matchers now REFUSE every `$`-prefixed key, the shape 173 of the repo's conforming matchers already use and the one the gate's header names as the cheap correct answer for a double that only ever sees scalar equality (house style copied from `engine-autonumber-default-format.test.ts`: `if (key.startsWith('$')) throw new Error(...)`). The guard is a PRELUDE, not an arm inside the loop, so a preceding scalar miss cannot short-circuit `.every` past an operator the double cannot answer. Nothing was added to any baseline — the gate's own words, "The baseline never grows". Baseline key set verified unchanged against d395692; the refusing count moved 173 -> 175 and silently-wrong 2 -> 0. Suite counts unchanged: 17/17 and 8/8, and the full packages 2218/2218 and 2719/2719. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019siH5jDmk5hrayvfyojUqR --------- Co-authored-by: os-zhuang <zhuangjianguo@steedos.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 2d1290a commit d2b2381

10 files changed

Lines changed: 1255 additions & 29 deletions
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
"@objectstack/rest": minor
3+
"@objectstack/runtime": patch
4+
"@objectstack/metadata-protocol": patch
5+
---
6+
7+
**Fix:** the `409 DESTRUCTIVE_CHANGE` on the two remaining `/meta` write doors stops prescribing a `?force=true` those doors never read — the compound-name REST `PUT` now reads it, and the runtime dispatcher says plainly that it cannot (#11095).
8+
9+
`saveMetaItem`'s Phase 3a-destructive gate raises one refusal and ends it with a remedy clause. That clause read `— re-submit with ?force=true to proceed.` on every door, and was true of exactly one of them. A caller refused on either of the other two, doing precisely what the sentence told them to do, got the identical refusal back, with nothing in the second answer saying the parameter had been ignored. #11015 repaired the duplicate-package face; these are the two doors it measured and deliberately left, because the honest repair for each was a contract question rather than a wording one.
10+
11+
The maintainer ruled a **split**, and the two halves are not the same fix:
12+
13+
- **`PUT /api/v1/meta/:type/:section/:name` (compound name) now accepts `?force=true`**, so the sentence became true rather than being reworded. This is #7019's ruling applied once more with its reason: the compound route is "word for word the same operation" as its single-segment twin — one generic `saveMetaItem`, reached by a name spelled in two segments — and gating only the twin was *measured* to leave this door a bypass of the gate. Every divergence found between the pair since has closed on that same finding (#6603/#7019's capability gate, #8805's write-side organization, #7035's 501 envelope). The truthy spellings (`true`/`1`/`yes`/`on`, case-insensitive) match the twin exactly, and a **repeated** `?force` is refused with `400 VALIDATION_ERROR` in the same stroke — #6877's sharpest measured case is on this very parameter one route over, where an array falls through to `!!raw` and turns a doubled explicit opt-*out* into force ON.
14+
- **The runtime dispatcher's `PUT /meta` does not gain `force`, and does not pretend to.** It has no twin precedent and a different call shape: the branch is reached with a path, a method and a body, so `?force=true` names a channel the transport does not have rather than a parameter someone forgot to read. It now states its own write face (`meta-dispatch`) and its refusal says so, prescribing what a caller can actually do at that door — submit a body that keeps what the stored item still carries, or reconcile that item first.
15+
16+
For callers this is one widened surface and one corrected instruction. A Studio or SDK caller that hit the compound-name door on a destructive object edit and had no way forward now has the same acknowledgement path the single-segment door has always offered; a dispatcher caller stops being sent in a circle. Nothing that was accepted before is refused now: the dispatcher's accept set is unchanged, and `?force` on the compound door only ever *widens* what that door takes.
17+
18+
The `422 INVALID_METADATA` behaviour is untouched on every door — the new face shares the existing headline case, so the structured `issues[]` channel and the trimmed message stay exactly as #10888 left them.

packages/metadata-protocol/src/protocol.destructive-409-face-inventory.test.ts

Lines changed: 170 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,8 @@
4343
* | # | caller | type | `force` | reaches gate | face | `issues` structurally |
4444
* |:--|:--|:--|:--|:--|:--|:--|
4545
* | 1 | `@objectstack/rest` `PUT /meta/:type/:name` | any | `?force` | **yes** | `handleRouteError` 409 body | **yes** — top-level `issues` |
46-
* | 2 | `@objectstack/rest` `PUT /meta/:type/:a/:b` | any | never | **yes** | the same `handleRouteError` body | **yes** (same face as #1) |
47-
* | 3 | `@objectstack/runtime` dispatcher `PUT /meta` | any | never | **yes** | `errorFromThrown` → `details.issues` | **yes** |
46+
* | 2 | `@objectstack/rest` `PUT /meta/:type/:a/:b` | any | `?force` — since #11095 | **yes** | the same `handleRouteError` body | **yes** (same face as #1) |
47+
* | 3 | `@objectstack/runtime` dispatcher `PUT /meta` | any | never — and no query string to put one on | **yes** | `errorFromThrown` → `details.issues` | **yes** |
4848
* | 4 | `@objectstack/runtime` ADR-0045 visibility flip | `'app'` | no | no — type | (`unhideError`) | n/a |
4949
* | 5 | `migrateStoredMetadata` (this file's protocol) | any | **true** | no — `force` | (`rows[].reason`) | n/a |
5050
* | 6 | {@link ObjectStackProtocolImplementation.duplicatePackage} | `row.type` incl. `object` | no | **yes** | `failed[].error` on a **200** | ⛔ **NO — sole carrier** |
@@ -91,20 +91,48 @@
9191
* ## [#11015] The same inventory, read one column further left
9292
*
9393
* The `force` column above is not decoration: it says which faces can lift
94-
* this refusal, and only ROW 1 can. Rows 2, 3 and 6 all reach the gate with no
95-
* way to set `force` — rows 2 and 3 because their routes never thread the
96-
* parameter, row 6 because `duplicatePackage` has no `force` field at all —
97-
* yet every one of them used to be handed the sentence `re-submit with
98-
* ?force=true to proceed.` A caller who does what it says gets the identical
99-
* refusal back.
94+
* this refusal. When #11015 was written only ROW 1 could. Rows 2, 3 and 6 all
95+
* reached the gate with no way to set `force` — rows 2 and 3 because their
96+
* routes never threaded the parameter, row 6 because `duplicatePackage` has no
97+
* `force` field at all — yet every one of them was handed the sentence
98+
* `re-submit with ?force=true to proceed.` A caller who did what it said got
99+
* the identical refusal back.
100100
*
101-
* #11015 repairs the clause on ROW 6, where a genuinely different remedy
101+
* #11015 repaired the clause on ROW 6, where a genuinely different remedy
102102
* exists to prescribe (a free target namespace, or reconciling the collision).
103-
* Rows 2 and 3 are left as measured and filed as #11095: the honest repair for
103+
* Rows 2 and 3 were left as measured and filed as #11095: the honest repair for
104104
* a `PUT` that cannot acknowledge a risk may be to thread `force` on those
105105
* routes, which is a contract decision and not a message fix. Section 4 pins
106106
* row 6; section 1's remedy guard pins that row 1's wording is untouched.
107107
*
108+
* ## [#11095] Rows 2 and 3, disposed of — and they went DIFFERENT ways
109+
*
110+
* The maintainer ruled a SPLIT (2026-08-23), so the two rows this file used to
111+
* carry as one open item are no longer one item at all:
112+
*
113+
* - **Row 2 threads `?force`.** `@objectstack/rest`'s compound-name
114+
* `PUT /meta/:type/:a/:b` now reads the parameter and passes `force: true`,
115+
* so the clause it renders became TRUE rather than being reworded — and its
116+
* face is still row 1's `'meta-envelope'`, which is now a statement rather
117+
* than an inherited default. The argument is #7019's, quoted at the call
118+
* site: that route is "word for word the same operation" as its
119+
* single-segment twin, and gating only the twin was MEASURED to leave this
120+
* one a bypass. #8805 and #7035 closed later divergences on the same
121+
* finding. **The `force` column above therefore reads `?force` on two rows,
122+
* and this file no longer pins row 2 as a defect** — `packages/rest`'s
123+
* `meta-compound-save-force-parity.test.ts` drives the door itself, both
124+
* directions, because whether a ROUTE reads a query parameter is not a fact
125+
* this package can observe.
126+
* - **Row 3 does NOT.** The runtime dispatcher gets `'meta-dispatch'`, a face
127+
* of its own, and section 5 below pins its clause. It has no twin precedent
128+
* and a different call shape: the branch is reached with a path, a method
129+
* and a body, so `?force=true` names a channel the transport does not have
130+
* rather than a parameter someone forgot to read.
131+
*
132+
* ⛔ The split is the ruling. A later reader who "harmonises" the two — either
133+
* by giving the dispatcher a `force` or by taking row 2's back out — is undoing
134+
* a decision, not tidying an inconsistency.
135+
*
108136
* ⛔ Never a bare `toThrow()` here. `duplicatePackage` does not throw, it
109137
* REPORTS, and what the report says IS the defect; and for the throw itself
110138
* the minimum assertion is `code` + `status` (ADR-0112 envelope), with the
@@ -244,6 +272,14 @@ const PUT_REMEDY = 're-submit with ?force=true to proceed.';
244272
* exists.
245273
*/
246274
const DUPLICATE_REMEDY_HEAD = 'this copy cannot be forced';
275+
/**
276+
* [#11095] …and as the runtime DISPATCHER renders it — the third distinct
277+
* answer this one clause gives. Not a variant of the duplicate wording: that
278+
* door has a `force`-shaped alternative (a free target namespace), this one has
279+
* no query string at all, so what it denies and what it prescribes both differ.
280+
* See section 5.
281+
*/
282+
const DISPATCH_REMEDY_HEAD = 'this save cannot be forced';
247283
/** One finding's prose, as `detectDestructiveObjectChanges` words it. */
248284
const FINDING_PROSE = "Field 'b' removed — existing data in this column will become inaccessible.";
249285

@@ -501,3 +537,127 @@ describe('[#11015] [GUARD] the destructive remedy clause is face-aware', () => {
501537
expect(err.message).not.toContain(DUPLICATE_REMEDY_HEAD);
502538
});
503539
});
540+
541+
// ═══════════════════════════════════════════════════════════════════════════
542+
// 5. [#11095] [GUARD] Inventory row 3 — the dispatcher face, which has no
543+
// `force` to prescribe and must stop prescribing one
544+
// ═══════════════════════════════════════════════════════════════════════════
545+
546+
describe('[#11095] [GUARD] the `meta-dispatch` face prescribes a remedy that door HAS', () => {
547+
it('declares the ADR-0112 envelope — the refusal itself is unchanged', async () => {
548+
const err = await destructiveRefusal('meta-dispatch');
549+
550+
// Minimum assertion set, restated on the new face rather than assumed
551+
// from row 1: this card moves a SENTENCE, and a card that moved the
552+
// status or the code by accident would still satisfy a prose-only test.
553+
expect(err.code).toBe('DESTRUCTIVE_CHANGE');
554+
expect(err.status).toBe(409);
555+
expect(err.issues).toEqual(expect.arrayContaining([
556+
expect.objectContaining({ code: 'field_removed', field: 'b' }),
557+
]));
558+
});
559+
560+
it('⛔ never prescribes `force` — the dispatcher has no query string to put one on', async () => {
561+
const err = await destructiveRefusal('meta-dispatch');
562+
563+
// The defect, stated as the assertion that fails without the fix. The
564+
// substring that must be gone is the MECHANISM NAME: a caller reading
565+
// it goes looking for a parameter this transport cannot accept, does
566+
// exactly what the sentence says, and is refused identically.
567+
expect(err.message).not.toContain('force=true');
568+
expect(err.message).not.toContain(PUT_REMEDY);
569+
});
570+
571+
it('prescribes what the caller CAN do here, and says why force is absent', async () => {
572+
const err = await destructiveRefusal('meta-dispatch');
573+
574+
// Named door + denied mechanism + two real remedies — the same grammar
575+
// the duplicate face composes, because it is the same kind of answer.
576+
expect(err.message).toContain(DISPATCH_REMEDY_HEAD);
577+
expect(err.message).toContain('accepts no `force`');
578+
expect(err.message).toContain('reconcile');
579+
// …and WHICH item, so a caller reading a batch of these can tell them
580+
// apart. `name` is the only fixture value the clause interpolates.
581+
expect(err.message).toContain('crm_task');
582+
});
583+
584+
it('[#10886 non-effect] the per-field findings prose is still there, untrimmed', async () => {
585+
const err = await destructiveRefusal('meta-dispatch');
586+
587+
// ⛔ #10886's sole-carrier verdict is untouched by this card, exactly as
588+
// it was untouched by #11015: only the remedy clause is face-aware, and
589+
// the findings the refusal renders stay whole on every face.
590+
expect(err.message).toContain(FINDING_PROSE);
591+
expect(err.message).toContain('[destructive_change]');
592+
});
593+
594+
it('⛔ the three faces are a SWITCH — repairing one did not move the others', async () => {
595+
const [plain, envelope, dispatch] = await Promise.all([
596+
destructiveRefusal(),
597+
destructiveRefusal('meta-envelope'),
598+
destructiveRefusal('meta-dispatch'),
599+
]);
600+
601+
// Row 1 and row 2 — both REST `PUT` doors, both reading `?force` since
602+
// #11095 — keep the sentence that is now true of both of them.
603+
expect(plain.message).toContain(PUT_REMEDY);
604+
expect(envelope.message).toContain(PUT_REMEDY);
605+
// Row 3 does not, and does not borrow the duplicate door's wording
606+
// either: three faces, three answers, no shared fallback.
607+
expect(dispatch.message).not.toContain(PUT_REMEDY);
608+
expect(dispatch.message).not.toContain(DUPLICATE_REMEDY_HEAD);
609+
expect(envelope.message).not.toContain(DISPATCH_REMEDY_HEAD);
610+
});
611+
612+
/**
613+
* ⭐ The coupling this card had to get right, and the one a future edit is
614+
* most likely to break.
615+
*
616+
* `writeFace` feeds TWO switches — {@link destructiveChangeRemedy} (409,
617+
* "which remedy exists here") and `specValidationFindings` (422, "does a
618+
* structured channel reach the consumer"). The dispatcher's answers differ:
619+
* it has no `force`, but it DOES carry `issues[]` (`errorFromThrown` →
620+
* `details.issues`), which is why it was `'meta-envelope'` in the first
621+
* place. So splitting the face for the 409's sake had to leave the 422
622+
* exactly where it was.
623+
*
624+
* The 422's polarity makes that failure SILENT in the dangerous direction:
625+
* silence renders the full prose, so a `'meta-dispatch'` that fell to the
626+
* default would re-introduce #10888's duplication on one door only, with
627+
* every 409 assertion above still green. This case is what says otherwise.
628+
*/
629+
it('⛔ [COUPLING] the new face changes the 409 clause and NOTHING about the 422', async () => {
630+
const { protocol } = makeKernel({ seed: [objectRow('crm_task', ['a', 'b', 'c', 'd'])] });
631+
const invalid = async (writeFace?: string) => {
632+
try {
633+
await protocol.saveMetaItem({
634+
type: 'view',
635+
name: 'task_list',
636+
// A view whose `summary` carries a typo'd key — the same
637+
// shape `protocol.invalid-metadata-422-face-inventory.test.ts`
638+
// drives, so the two files agree about what a 422 looks like.
639+
item: {
640+
name: 'task_list', object: 'task', type: 'list', label: 'Tasks',
641+
columns: [{ field: 'title', summary: { type: 'sum', fieldd: 'amount' } }],
642+
},
643+
...(writeFace ? { writeFace } : {}),
644+
});
645+
} catch (e: any) { return e; }
646+
throw new Error('expected saveMetaItem to refuse the invalid body');
647+
};
648+
649+
const envelope = await invalid('meta-envelope');
650+
const dispatch = await invalid('meta-dispatch');
651+
652+
// Same refusal, same envelope …
653+
expect(dispatch.code).toBe('INVALID_METADATA');
654+
expect(dispatch.status).toBe(422);
655+
// … and byte-for-byte the same headline the door had before it was
656+
// given a face of its own. Not `toMatch(/\d+ issues?/)`: an equality
657+
// against the sibling face is what catches a fall-through to the prose
658+
// branch, which would also match a loose headline pattern.
659+
expect(dispatch.message).toBe(envelope.message);
660+
// The trim is still in force here — no finding restated in the sentence.
661+
for (const i of dispatch.issues) expect(dispatch.message).not.toContain(i.message);
662+
});
663+
});

0 commit comments

Comments
 (0)