From 54b8b5e2cabaccd874aebead4f9b039f5892f261 Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Sun, 23 Aug 2026 14:44:44 +0000 Subject: [PATCH 1/3] 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 Claude-Session: https://claude.ai/code/session_019siH5jDmk5hrayvfyojUqR --- ...tructive-409-force-parity-compound-door.md | 18 + ...col.destructive-409-face-inventory.test.ts | 180 ++++++- packages/metadata-protocol/src/protocol.ts | 89 +++- .../meta-compound-save-force-parity.test.ts | 467 ++++++++++++++++++ packages/rest/src/rest-route-ledger.ts | 2 +- packages/rest/src/rest-server.ts | 56 ++- .../meta-save-destructive-remedy.test.ts | 336 +++++++++++++ packages/runtime/src/domains/meta.ts | 25 +- packages/runtime/src/http-dispatcher.test.ts | 29 +- 9 files changed, 1173 insertions(+), 29 deletions(-) create mode 100644 .changeset/destructive-409-force-parity-compound-door.md create mode 100644 packages/rest/src/meta-compound-save-force-parity.test.ts create mode 100644 packages/runtime/src/domains/meta-save-destructive-remedy.test.ts diff --git a/.changeset/destructive-409-force-parity-compound-door.md b/.changeset/destructive-409-force-parity-compound-door.md new file mode 100644 index 0000000000..2d39239e1a --- /dev/null +++ b/.changeset/destructive-409-force-parity-compound-door.md @@ -0,0 +1,18 @@ +--- +"@objectstack/rest": minor +"@objectstack/runtime": patch +"@objectstack/metadata-protocol": patch +--- + +**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). + +`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. + +The maintainer ruled a **split**, and the two halves are not the same fix: + +- **`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. +- **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. + +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. + +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. diff --git a/packages/metadata-protocol/src/protocol.destructive-409-face-inventory.test.ts b/packages/metadata-protocol/src/protocol.destructive-409-face-inventory.test.ts index ffb81520f7..847fea0464 100644 --- a/packages/metadata-protocol/src/protocol.destructive-409-face-inventory.test.ts +++ b/packages/metadata-protocol/src/protocol.destructive-409-face-inventory.test.ts @@ -43,8 +43,8 @@ * | # | caller | type | `force` | reaches gate | face | `issues` structurally | * |:--|:--|:--|:--|:--|:--|:--| * | 1 | `@objectstack/rest` `PUT /meta/:type/:name` | any | `?force` | **yes** | `handleRouteError` 409 body | **yes** — top-level `issues` | - * | 2 | `@objectstack/rest` `PUT /meta/:type/:a/:b` | any | never | **yes** | the same `handleRouteError` body | **yes** (same face as #1) | - * | 3 | `@objectstack/runtime` dispatcher `PUT /meta` | any | never | **yes** | `errorFromThrown` → `details.issues` | **yes** | + * | 2 | `@objectstack/rest` `PUT /meta/:type/:a/:b` | any | `?force` — since #11095 | **yes** | the same `handleRouteError` body | **yes** (same face as #1) | + * | 3 | `@objectstack/runtime` dispatcher `PUT /meta` | any | never — and no query string to put one on | **yes** | `errorFromThrown` → `details.issues` | **yes** | * | 4 | `@objectstack/runtime` ADR-0045 visibility flip | `'app'` | no | no — type | (`unhideError`) | n/a | * | 5 | `migrateStoredMetadata` (this file's protocol) | any | **true** | no — `force` | (`rows[].reason`) | n/a | * | 6 | {@link ObjectStackProtocolImplementation.duplicatePackage} | `row.type` incl. `object` | no | **yes** | `failed[].error` on a **200** | ⛔ **NO — sole carrier** | @@ -91,20 +91,48 @@ * ## [#11015] The same inventory, read one column further left * * The `force` column above is not decoration: it says which faces can lift - * this refusal, and only ROW 1 can. Rows 2, 3 and 6 all reach the gate with no - * way to set `force` — rows 2 and 3 because their routes never thread the - * parameter, row 6 because `duplicatePackage` has no `force` field at all — - * yet every one of them used to be handed the sentence `re-submit with - * ?force=true to proceed.` A caller who does what it says gets the identical - * refusal back. + * this refusal. When #11015 was written only ROW 1 could. Rows 2, 3 and 6 all + * reached the gate with no way to set `force` — rows 2 and 3 because their + * routes never threaded the parameter, row 6 because `duplicatePackage` has no + * `force` field at all — yet every one of them was handed the sentence + * `re-submit with ?force=true to proceed.` A caller who did what it said got + * the identical refusal back. * - * #11015 repairs the clause on ROW 6, where a genuinely different remedy + * #11015 repaired the clause on ROW 6, where a genuinely different remedy * exists to prescribe (a free target namespace, or reconciling the collision). - * Rows 2 and 3 are left as measured and filed as #11095: the honest repair for + * Rows 2 and 3 were left as measured and filed as #11095: the honest repair for * a `PUT` that cannot acknowledge a risk may be to thread `force` on those * routes, which is a contract decision and not a message fix. Section 4 pins * row 6; section 1's remedy guard pins that row 1's wording is untouched. * + * ## [#11095] Rows 2 and 3, disposed of — and they went DIFFERENT ways + * + * The maintainer ruled a SPLIT (2026-08-23), so the two rows this file used to + * carry as one open item are no longer one item at all: + * + * - **Row 2 threads `?force`.** `@objectstack/rest`'s compound-name + * `PUT /meta/:type/:a/:b` now reads the parameter and passes `force: true`, + * so the clause it renders became TRUE rather than being reworded — and its + * face is still row 1's `'meta-envelope'`, which is now a statement rather + * than an inherited default. The argument is #7019's, quoted at the call + * site: that route is "word for word the same operation" as its + * single-segment twin, and gating only the twin was MEASURED to leave this + * one a bypass. #8805 and #7035 closed later divergences on the same + * finding. **The `force` column above therefore reads `?force` on two rows, + * and this file no longer pins row 2 as a defect** — `packages/rest`'s + * `meta-compound-save-force-parity.test.ts` drives the door itself, both + * directions, because whether a ROUTE reads a query parameter is not a fact + * this package can observe. + * - **Row 3 does NOT.** The runtime dispatcher gets `'meta-dispatch'`, a face + * of its own, and section 5 below pins its clause. 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. + * + * ⛔ The split is the ruling. A later reader who "harmonises" the two — either + * by giving the dispatcher a `force` or by taking row 2's back out — is undoing + * a decision, not tidying an inconsistency. + * * ⛔ Never a bare `toThrow()` here. `duplicatePackage` does not throw, it * REPORTS, and what the report says IS the defect; and for the throw itself * 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.'; * exists. */ const DUPLICATE_REMEDY_HEAD = 'this copy cannot be forced'; +/** + * [#11095] …and as the runtime DISPATCHER renders it — the third distinct + * answer this one clause gives. Not a variant of the duplicate wording: that + * door has a `force`-shaped alternative (a free target namespace), this one has + * no query string at all, so what it denies and what it prescribes both differ. + * See section 5. + */ +const DISPATCH_REMEDY_HEAD = 'this save cannot be forced'; /** One finding's prose, as `detectDestructiveObjectChanges` words it. */ const FINDING_PROSE = "Field 'b' removed — existing data in this column will become inaccessible."; @@ -501,3 +537,127 @@ describe('[#11015] [GUARD] the destructive remedy clause is face-aware', () => { expect(err.message).not.toContain(DUPLICATE_REMEDY_HEAD); }); }); + +// ═══════════════════════════════════════════════════════════════════════════ +// 5. [#11095] [GUARD] Inventory row 3 — the dispatcher face, which has no +// `force` to prescribe and must stop prescribing one +// ═══════════════════════════════════════════════════════════════════════════ + +describe('[#11095] [GUARD] the `meta-dispatch` face prescribes a remedy that door HAS', () => { + it('declares the ADR-0112 envelope — the refusal itself is unchanged', async () => { + const err = await destructiveRefusal('meta-dispatch'); + + // Minimum assertion set, restated on the new face rather than assumed + // from row 1: this card moves a SENTENCE, and a card that moved the + // status or the code by accident would still satisfy a prose-only test. + expect(err.code).toBe('DESTRUCTIVE_CHANGE'); + expect(err.status).toBe(409); + expect(err.issues).toEqual(expect.arrayContaining([ + expect.objectContaining({ code: 'field_removed', field: 'b' }), + ])); + }); + + it('⛔ never prescribes `force` — the dispatcher has no query string to put one on', async () => { + const err = await destructiveRefusal('meta-dispatch'); + + // The defect, stated as the assertion that fails without the fix. The + // substring that must be gone is the MECHANISM NAME: a caller reading + // it goes looking for a parameter this transport cannot accept, does + // exactly what the sentence says, and is refused identically. + expect(err.message).not.toContain('force=true'); + expect(err.message).not.toContain(PUT_REMEDY); + }); + + it('prescribes what the caller CAN do here, and says why force is absent', async () => { + const err = await destructiveRefusal('meta-dispatch'); + + // Named door + denied mechanism + two real remedies — the same grammar + // the duplicate face composes, because it is the same kind of answer. + expect(err.message).toContain(DISPATCH_REMEDY_HEAD); + expect(err.message).toContain('accepts no `force`'); + expect(err.message).toContain('reconcile'); + // …and WHICH item, so a caller reading a batch of these can tell them + // apart. `name` is the only fixture value the clause interpolates. + expect(err.message).toContain('crm_task'); + }); + + it('[#10886 non-effect] the per-field findings prose is still there, untrimmed', async () => { + const err = await destructiveRefusal('meta-dispatch'); + + // ⛔ #10886's sole-carrier verdict is untouched by this card, exactly as + // it was untouched by #11015: only the remedy clause is face-aware, and + // the findings the refusal renders stay whole on every face. + expect(err.message).toContain(FINDING_PROSE); + expect(err.message).toContain('[destructive_change]'); + }); + + it('⛔ the three faces are a SWITCH — repairing one did not move the others', async () => { + const [plain, envelope, dispatch] = await Promise.all([ + destructiveRefusal(), + destructiveRefusal('meta-envelope'), + destructiveRefusal('meta-dispatch'), + ]); + + // Row 1 and row 2 — both REST `PUT` doors, both reading `?force` since + // #11095 — keep the sentence that is now true of both of them. + expect(plain.message).toContain(PUT_REMEDY); + expect(envelope.message).toContain(PUT_REMEDY); + // Row 3 does not, and does not borrow the duplicate door's wording + // either: three faces, three answers, no shared fallback. + expect(dispatch.message).not.toContain(PUT_REMEDY); + expect(dispatch.message).not.toContain(DUPLICATE_REMEDY_HEAD); + expect(envelope.message).not.toContain(DISPATCH_REMEDY_HEAD); + }); + + /** + * ⭐ The coupling this card had to get right, and the one a future edit is + * most likely to break. + * + * `writeFace` feeds TWO switches — {@link destructiveChangeRemedy} (409, + * "which remedy exists here") and `specValidationFindings` (422, "does a + * structured channel reach the consumer"). The dispatcher's answers differ: + * it has no `force`, but it DOES carry `issues[]` (`errorFromThrown` → + * `details.issues`), which is why it was `'meta-envelope'` in the first + * place. So splitting the face for the 409's sake had to leave the 422 + * exactly where it was. + * + * The 422's polarity makes that failure SILENT in the dangerous direction: + * silence renders the full prose, so a `'meta-dispatch'` that fell to the + * default would re-introduce #10888's duplication on one door only, with + * every 409 assertion above still green. This case is what says otherwise. + */ + it('⛔ [COUPLING] the new face changes the 409 clause and NOTHING about the 422', async () => { + const { protocol } = makeKernel({ seed: [objectRow('crm_task', ['a', 'b', 'c', 'd'])] }); + const invalid = async (writeFace?: string) => { + try { + await protocol.saveMetaItem({ + type: 'view', + name: 'task_list', + // A view whose `summary` carries a typo'd key — the same + // shape `protocol.invalid-metadata-422-face-inventory.test.ts` + // drives, so the two files agree about what a 422 looks like. + item: { + name: 'task_list', object: 'task', type: 'list', label: 'Tasks', + columns: [{ field: 'title', summary: { type: 'sum', fieldd: 'amount' } }], + }, + ...(writeFace ? { writeFace } : {}), + }); + } catch (e: any) { return e; } + throw new Error('expected saveMetaItem to refuse the invalid body'); + }; + + const envelope = await invalid('meta-envelope'); + const dispatch = await invalid('meta-dispatch'); + + // Same refusal, same envelope … + expect(dispatch.code).toBe('INVALID_METADATA'); + expect(dispatch.status).toBe(422); + // … and byte-for-byte the same headline the door had before it was + // given a face of its own. Not `toMatch(/\d+ issues?/)`: an equality + // against the sibling face is what catches a fall-through to the prose + // branch, which would also match a loose headline pattern. + expect(dispatch.message).toBe(envelope.message); + // The trim is still in force here — no finding restated in the sentence. + for (const i of dispatch.issues) expect(dispatch.message).not.toContain(i.message); + }); +}); diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 31f4462a08..f97f696730 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -2149,12 +2149,24 @@ function specValidationFindings( ): string { switch (face) { case 'meta-envelope': + case 'meta-dispatch': // The door answers with an ADR-0112 error envelope that carries // `issues[]` beside the message (`@objectstack/rest`'s // `sendError` threads a top-level `issues`; `@objectstack/runtime`'s // dispatcher threads `details.issues`). The prose lives there, // once. Same headline grammar the seed refusal and the author-time // gate compose — count plus `path [zod code]` locators. + // + // ⚠️ [#11095] TWO faces, one case, on purpose. The dispatcher split + // off `'meta-envelope'` because the 409 needed a different REMEDY + // there — nothing about the 422 moved, and this door's structured + // channel (`details.issues`) is the very one the comment above + // names. Letting `'meta-dispatch'` fall to the default instead would + // have re-introduced the #10888 duplication on one door only, with + // every 409 test green: the polarity below is "declare to trim", so + // a face that stops declaring loses the trim SILENTLY. Pinned in + // `protocol.destructive-409-face-inventory.test.ts`'s [#11095] + // section, which asserts the 422 clause did not move under it. return metadataIssueHeadline(issues); default: // Byte-identical to the pre-#10888 clause: the first three findings @@ -3304,17 +3316,39 @@ function detectDestructiveObjectChanges(prev: any, next: any): Array<{ * Each face therefore states the remedy it actually has. * * ⚠️ An absent `face` renders the `?force=true` wording, byte-identical to what - * every face carried before. That default is right on the single-segment REST - * `PUT` — but `protocol.destructive-409-face-inventory.test.ts` inventories two - * further doors that reach this gate and never thread `force` either - * (`@objectstack/rest`'s compound-name `PUT /meta/:type/:a/:b`, and - * `@objectstack/runtime`'s dispatcher `PUT /meta`). Those are wrong for the - * same reason and are deliberately NOT repaired here: unlike the duplicate - * gesture, which has a genuine collision-free alternative to prescribe, the - * right repair for a `PUT` that cannot acknowledge a risk may well be to thread - * `force` on those routes — a contract question, filed as #11095 rather than - * guessed at. Adding a face value here is one of the two candidate repairs it - * weighs. + * every face carried before. That default is right on BOTH REST `PUT` doors and + * on nothing else that reaches this gate today — see the `[#11095]` section of + * `protocol.destructive-409-face-inventory.test.ts` for the full inventory. + * + * ## [#11095] The two doors #11015 left open, and why they were split + * + * #11015's note recorded two further faces that reached this gate and never + * threaded `force`, and filed the disposition as a contract question rather + * than guessing it. The ruling was a SPLIT — one door repaired by threading the + * parameter, the other by telling the truth — and the split is the decision, + * not an accident of convenience: + * + * - `@objectstack/rest`'s compound-name `PUT /meta/:type/:a/:b` now THREADS + * `?force=true`, so the default clause became TRUE there instead of being + * reworded. That half 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 single-segment door was MEASURED to + * leave the compound one a bypass of it. Every divergence found between the + * pair since has been closed for that same reason (#6603/#7019's capability + * gate, #8805's write-side organization, #7035's 501 envelope). One door + * reading `?force` and its literal twin ignoring it was the same shape. + * - `@objectstack/runtime`'s dispatcher `PUT /meta` does NOT gain `force`, and + * states `'meta-dispatch'` below instead. It has no twin precedent and a + * different call shape: that branch is reached with a path, a method and a + * body — there is no query string on the transport at all, so `?force=true` + * names a slot that does not exist rather than one that was forgotten. Its + * clause says so, and prescribes what a caller can actually do there. + * + * ⛔ Do not "harmonise" these two later by giving the dispatcher a `force` + * field. The compound door's parity argument is a statement about ONE pair of + * routes that spell one name two ways; it is not a general licence, and the + * dispatcher is not the third member of that pair. */ /** * [#11015 / #10888] Which write door a `saveMetaItem` refusal is being @@ -3334,10 +3368,21 @@ function detectDestructiveObjectChanges(prev: any, next: any): Array<{ * * A door therefore answers both questions by naming itself once, and neither * switch may assume the other's default. `'meta-envelope'` deliberately keeps - * the 409's `?force=true` wording (it is the single-segment REST `PUT`'s - * genuine remedy) while changing the 422's clause. + * the 409's `?force=true` wording (it is both REST `PUT` doors' genuine remedy, + * since #11095 threaded the parameter on the compound-name twin) while changing + * the 422's clause. + * + * ⚠️ [#11095] `'meta-dispatch'` is the first face that differs from another on + * ONE of the two questions and agrees with it on the other: the runtime + * dispatcher carries `issues[]` structurally exactly as the REST doors do + * (`errorFromThrown` → `details.issues`), so its 422 clause is `'meta-envelope'`'s, + * while its 409 clause must differ because that door has no `force` to name. + * That is the two-switch independence this comment asserts, arriving as a real + * case rather than a hypothetical — which is why {@link specValidationFindings} + * lists the two faces on one `case` instead of letting `'meta-dispatch'` fall + * to a default that was never written for it. */ -type MetadataWriteFace = 'package-duplicate' | 'meta-envelope'; +type MetadataWriteFace = 'package-duplicate' | 'meta-envelope' | 'meta-dispatch'; function destructiveChangeRemedy( face: MetadataWriteFace | undefined, @@ -3352,6 +3397,22 @@ function destructiveChangeRemedy( return `this copy cannot be forced: the duplicate door accepts no \`force\`. ` + `Duplicate into a target namespace that does not already hold '${name}', ` + `or reconcile that item with the source first.`; + case 'meta-dispatch': + // [#11095] The runtime dispatcher's `/meta` PUT. Unlike the two REST + // `PUT` doors — which read `?force` off a query string and, since + // this card, BOTH thread it — this branch is reached with a path, a + // method and a body and nothing else. `?force=true` does not name a + // parameter this door forgot to read; it names a channel the + // transport does not have, which is why threading it here would be + // a new surface rather than a repair, and was ruled out. + // + // Both clauses are things the caller can do on THIS door: send a + // body that does not drop what the stored item still carries, or go + // and reconcile that item first. Same grammar as the duplicate face + // above — name the door, deny the mechanism, then prescribe. + return `this save cannot be forced: the dispatcher's \`PUT /meta\` accepts no \`force\`. ` + + `Re-submit '${name}' with a body that keeps the fields and types named above, ` + + `or reconcile that stored item first.`; default: return 're-submit with ?force=true to proceed.'; } diff --git a/packages/rest/src/meta-compound-save-force-parity.test.ts b/packages/rest/src/meta-compound-save-force-parity.test.ts new file mode 100644 index 0000000000..36e7538286 --- /dev/null +++ b/packages/rest/src/meta-compound-save-force-parity.test.ts @@ -0,0 +1,467 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#11095] `?force=true` on the compound-name `PUT /api/v1/meta/:type/:section/:name` + * — the third row of the destructive-409 face inventory, closed by threading + * the parameter rather than by rewording the sentence. + * + * ## The defect + * + * `saveMetaItem`'s Phase 3a-destructive gate raises ONE `409 + * DESTRUCTIVE_CHANGE`, and its remedy clause ends `— re-submit with + * ?force=true to proceed.` That clause was true of the single-segment + * `PUT /meta/:type/:name`, which reads `?force` and threads it. It was FALSE + * here: this route built its `saveMetaItem` request field by field and `force` + * was not one of the fields, so a caller refused at this door, doing exactly + * what the refusal told them to do, got the identical refusal back — and + * nothing in the second answer said the parameter had been ignored. + * + * ## Why threading, and not a face of its own + * + * The maintainer ruled a SPLIT (2026-08-23) over the two doors #11015 left + * open, and this is the half that gains the parameter. The argument is #7019's, + * inherited with its reason rather than re-derived: 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 instead of one — + * and gating only the twin was MEASURED to leave this door a bypass of the + * gate, not a narrower version of it. Every divergence found between the pair + * since has been closed on that same finding: #6603/#7019's `manage_metadata` + * gate, #8805's write-side organization, #7035's 501 envelope. A pair that + * disagrees about which risks a caller may acknowledge is that shape once more. + * + * ⛔ The other half of the ruling went the other way, and this file is not a + * precedent for it: `@objectstack/runtime`'s dispatcher `PUT /meta` does NOT + * gain `force` — it is reached with a path, a method and a body, so there is no + * query string for an acknowledgement to arrive on, and it states its own + * `writeFace` so the clause stops naming a parameter it does not have. See + * `packages/runtime/src/domains/meta-save-destructive-remedy.test.ts`. + * + * ## Why the REAL protocol and not a double + * + * The subject is a ROUTE's query-string handling, but the assertion worth + * making is end-to-end: does the sentence the caller is handed become TRUE. + * A double that refuses unless it sees `force` would pass whatever the route + * did with the parameter as long as the two agreed — it would be pinning this + * file's own idea of the gate. So the gate is the real + * `ObjectStackProtocolImplementation` over a `sys_metadata`-backed engine, and + * the acceptance cases assert the STORE, not just a 200: "refused, then + * accepted" and "refused, then accepted but wrote nothing" are different + * outcomes and only one of them is the fix. + * + * ⚠️ That import resolves through `exports` to `@objectstack/metadata-protocol`'s + * **`dist/`** (registered in `check-test-source-alias.mjs`'s + * `KNOWN_UNALIASED_TEST_IMPORTS` for this package), so this suite is a verdict + * about the BUILT protocol. Rebuild it before reading a result here after + * touching `protocol.ts` — a `dist/` merely behind reports the pre-fix clause + * with nothing in the output saying so. + * + * ## What the cases assert + * + * `status` AND `code` (the ADR-0112 envelope) on every refusal, in BOTH + * directions, on BOTH doors. These handlers *send* rather than throw, so a + * `toThrow`-shaped assertion could not separate "refused with the wrong + * envelope" from "did not refuse at all" — and on the unfixed code the second + * answer is a 409 that looks exactly like the first. + * + * ⚠️ TWO body shapes appear below, and they are the file's, not a typo. The + * refusals this card is about come out of `handleRouteError`, whose body is + * FLAT — `{ error: , code, issues }`, with the `code` at top + * level and `issues` beside it (row 2 of the face inventory calls that "a + * top-level `issues`", and this is what it means). The `400` from + * `refuseRepeatedQueryParams` is hand-built by the route and NESTED — + * `{ error: { code, message } }` — as are this file's sibling `403`/`501` + * refusals. Reading `body.error.code` off a `handleRouteError` answer yields + * `undefined`, and next to a status-only assertion that reads as a pass. + */ + +import { describe, it, expect, vi } from 'vitest'; +// `.js` on purpose — NodeNext resolution requires the extension (#7248). +import { RestServer } from './rest-server.js'; +import { assertEngineUpdateDispatch, assertEngineDeleteDispatch } from '@objectstack/metadata-core'; +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; + +const META = '/api/v1/meta'; +const COMPOUND_PATH = `${META}/:type/:section/:name`; +const SINGLE_PATH = `${META}/:type/:name`; + +/** The compound URL `section` + `name` spell, and the single-segment twin's. */ +const COMPOUND_NAME = 'crm/task'; +const SINGLE_NAME = 'crm_task'; + +/** + * A spec-valid `object` body. `sharingModel` is not decoration: ADR-0090 D1's + * author-time gate refuses an unset OWD (`security-owd-unset`), and without it + * the FORCED save would fail one phase past the one under test — a red that + * reads exactly like "force did not work". + */ +const objectBody = (name: string, fields: readonly string[]) => ({ + name, + label: name, + sharingModel: 'private', + fields: Object.fromEntries(fields.map((f) => [f, { name: f, type: 'text', label: f }])), +}); + +/** What the fixture starts with, and what a destructive save would drop. */ +const STORED_FIELDS = ['a', 'b', 'c', 'd']; +/** The body every case submits: three of the four columns, gone. */ +const SHRUNK_FIELDS = ['a']; + +function mockServer() { + return { + get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn(), patch: vi.fn(), + use: vi.fn(), listen: vi.fn().mockResolvedValue(undefined), close: vi.fn().mockResolvedValue(undefined), + }; +} + +function mockRes() { + const res: any = { + statusCode: 200, + json: vi.fn(function (this: any, body: any) { this._body = body; return this; }), + send: vi.fn(), + status: vi.fn(function (this: any, code: number) { this.statusCode = code; return this; }), + header: vi.fn(), + }; + return res; +} + +interface StoredRow { + id: string; + type: string; + name: string; + organization_id: string | null; + package_id: string | null; + state: string; + metadata: string; + checksum: string; + version: number; +} + +/** + * Boot both `PUT` doors over the REAL protocol, seeded so BOTH names exist with + * the same four columns — the single-segment twin is a control in every case, + * not a separate suite, because "the two doors agree" is the claim. + */ +function boot() { + const rows = new Map(); + const seed = (id: string, name: string) => rows.set(id, { + id, type: 'object', name, + organization_id: null, package_id: null, state: 'active', + metadata: JSON.stringify(objectBody(SINGLE_NAME, STORED_FIELDS)), + checksum: 'sha256_11095_fixture', version: 1, + }); + seed('row_compound', COMPOUND_NAME); + seed('row_single', SINGLE_NAME); + + const match = (r: any, where: Record): boolean => + Object.entries(where ?? {}).every(([k, v]) => { + if (k === '$or') return (v as Array>).some((c) => match(r, c)); + return v === null || v === undefined + ? r[k] === null || r[k] === undefined + : r[k] === v; + }); + + const engine: any = { + async find(table: string, o?: { where?: Record }) { + if (table !== 'sys_metadata') return []; + return [...rows.values()].filter((r) => match(r, o?.where ?? {})); + }, + async findOne(table: string, o: { where: Record }) { + if (table !== 'sys_metadata') return null; + for (const r of rows.values()) if (match(r, o?.where ?? {})) return r; + return null; + }, + async insert(table: string, data: Record) { + if (table === 'sys_metadata') { + const r = { ...(data as any), id: String(data.id ?? `r_${rows.size}`) } as StoredRow; + rows.set(r.id, r); + } + return { id: String(data.id ?? 'r_new') }; + }, + // ⛔ Routed through the producer-side predicates, never hand-mirrored: + // a double looser than `ObjectQL` turns a green suite into no suite + // (`check:engine-double-contract`, #4550 / #5480). This file NEEDS the + // write verbs — unlike the refusal-only inventory suites — because the + // acceptance half of every case is asserted against the STORE. + async update(_t: string, data: Record, opts?: Record) { + assertEngineUpdateDispatch(data, opts); + const id = (opts as any)?.where?.id; + const existing = id ? rows.get(String(id)) : undefined; + if (existing) rows.set(String(id), { ...existing, ...(data as any) }); + return { id: id ?? null }; + }, + async delete(_t: string, opts?: Record) { + assertEngineDeleteDispatch(opts); + return { deleted: 0 }; + }, + registry: { + registerItem: () => {}, registerObject: () => {}, listItems: () => [], + getItem: () => undefined, getArtifactItem: () => undefined, + removeRuntimeShadow: () => false, removeOverlayEntry: () => {}, uninstallPackage: () => {}, + }, + }; + + const protocol: any = new ObjectStackProtocolImplementation(engine, () => new Map()); + + /** + * Every request the doors hand the protocol, recorded at the seam. The + * store answers "did the write land"; this answers "with what" — and the + * distinction is the whole defect: the pre-fix door reached `saveMetaItem` + * on every one of these calls, it just never named `force` in the object. + */ + const seen: any[] = []; + const realSave = protocol.saveMetaItem.bind(protocol); + protocol.saveMetaItem = async (request: any) => { seen.push(request); return realSave(request); }; + + const rest = new RestServer( + mockServer() as any, + protocol as any, + { api: { requireAuth: false } } as any, + ); + // `manage_metadata` held — the #7019 capability gate is a different card and + // must not be what answers here. + (rest as any).resolveExecCtx = async () => ({ userId: 'u_author', systemPermissions: ['manage_metadata'] }); + rest.registerRoutes(); + + const route = (method: string, path: string) => (rest as any).getRoutes().find( + (r: any) => r.method === method && r.path === path, + ); + + const call = async (path: string, params: Record, query: Record) => { + const res = mockRes(); + await route('PUT', path)!.handler({ params, query, headers: {}, body: objectBody(SINGLE_NAME, SHRUNK_FIELDS) }, res); + return { status: res.statusCode, body: res.json.mock.calls.at(-1)?.[0] }; + }; + + return { + seen, + /** Field names of a stored row, read from the STORE not from a response. */ + fieldsOf: (id: string) => Object.keys(JSON.parse(rows.get(id)!.metadata).fields ?? {}).sort(), + compoundFields: () => Object.keys(JSON.parse(rows.get('row_compound')!.metadata).fields ?? {}).sort(), + singleFields: () => Object.keys(JSON.parse(rows.get('row_single')!.metadata).fields ?? {}).sort(), + /** The door under test. */ + compoundPut: (query: Record = {}) => + call(COMPOUND_PATH, { type: 'object', section: 'crm', name: 'task' }, query), + /** Its single-segment twin — the control, already correct before this card. */ + singlePut: (query: Record = {}) => + call(SINGLE_PATH, { type: 'object', name: SINGLE_NAME }, query), + }; +} + +/** The sentence the 409 ends with, and the thing this card had to make true. */ +const PUT_REMEDY = 're-submit with ?force=true to proceed.'; + +// ═══════════════════════════════════════════════════════════════════════════ +// 1. The compound door, REFUSED — and the refusal tells the truth now +// ═══════════════════════════════════════════════════════════════════════════ + +describe('[#11095] compound-name PUT — the destructive refusal', () => { + it('refuses a data-dropping save with the ADR-0112 envelope, and writes NOTHING', async () => { + const stack = boot(); + + const answer = await stack.compoundPut(); + + expect(answer.status).toBe(409); + expect(answer.body?.code).toBe('DESTRUCTIVE_CHANGE'); + // THE POINT of a refusal case: "refused after writing" satisfies both + // assertions above and is still the bug. + expect(stack.compoundFields()).toEqual(STORED_FIELDS); + }); + + it('the findings reach the caller structurally as well as in the prose (#10886 non-effect)', async () => { + const stack = boot(); + + const answer = await stack.compoundPut(); + + // `handleRouteError` threads `error.issues` onto a top-level `issues`. + // Row 2 of the face inventory says this door is NOT a sole carrier, and + // that claim is about THIS body. + expect(Array.isArray(answer.body?.issues)).toBe(true); + expect(answer.body.issues).toEqual(expect.arrayContaining([ + expect.objectContaining({ code: 'field_removed', field: 'b' }), + ])); + expect(answer.body?.error).toContain("Field 'b' removed"); + }); + + it('⭐ prescribes `?force=true` — the sentence this card had to make true', async () => { + const stack = boot(); + + const answer = await stack.compoundPut(); + + // Pre-fix this assertion ALSO passed: the clause was rendered on every + // face. What it could not do was survive the next case. + expect(answer.body?.error).toContain(PUT_REMEDY); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// 2. ⭐ The compound door, ACCEPTED — the case that fails without the fix +// ═══════════════════════════════════════════════════════════════════════════ + +describe('[#11095] compound-name PUT — `?force=true` is honoured', () => { + it('⭐ doing what the refusal says WORKS: 200, and the store actually changed', async () => { + const stack = boot(); + + // 1. Refused, and told to re-submit with the parameter. + const refused = await stack.compoundPut(); + expect(refused.status).toBe(409); + expect(refused.body?.code).toBe('DESTRUCTIVE_CHANGE'); + expect(refused.body?.error).toContain(PUT_REMEDY); + + // 2. The caller does exactly that. Before this card the answer here was + // a byte-identical 409 — the whole defect, in one line. + const forced = await stack.compoundPut({ force: 'true' }); + + expect(forced.status).toBe(200); + expect(forced.body?.error).toBeUndefined(); + // 3. …and the acknowledged change LANDED. A 200 that wrote nothing + // would pass a status-only assertion and be a different bug. + expect(stack.compoundFields()).toEqual(SHRUNK_FIELDS); + }); + + it('threads `force: true` into the protocol request, and only when asked', async () => { + const stack = boot(); + + await stack.compoundPut(); + await stack.compoundPut({ force: 'true' }); + + // The seam itself. The pre-fix door reached `saveMetaItem` on BOTH of + // these calls — it simply never named `force` in either request, which + // is why a store-only assertion could not localise the defect. + expect(stack.seen).toHaveLength(2); + expect(stack.seen[0].force).toBeUndefined(); + expect(stack.seen[1].force).toBe(true); + // The rest of the request is untouched by this card — same face, same + // compound name assembled from the two segments. + expect(stack.seen[1].name).toBe(COMPOUND_NAME); + expect(stack.seen[1].writeFace).toBe('meta-envelope'); + }); + + it.each([ + { spelling: 'true' }, { spelling: '1' }, { spelling: 'yes' }, { spelling: 'on' }, { spelling: 'TRUE' }, + ])('accepts the `$spelling` spelling, byte-identically to the twin', async ({ spelling }) => { + const stack = boot(); + + const answer = await stack.compoundPut({ force: spelling }); + + expect(answer.status).toBe(200); + expect(stack.compoundFields()).toEqual(SHRUNK_FIELDS); + }); + + it('an explicit opt-OUT is still an opt-out — `?force=false` refuses', async () => { + const stack = boot(); + + const answer = await stack.compoundPut({ force: 'false' }); + + // Not "any value present means force": the truthy table is a table. + expect(answer.status).toBe(409); + expect(answer.body?.code).toBe('DESTRUCTIVE_CHANGE'); + expect(stack.compoundFields()).toEqual(STORED_FIELDS); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// 3. [#6877] ⛔ The inversion this card had to avoid re-opening on a new door +// ═══════════════════════════════════════════════════════════════════════════ + +describe('[#11095 / #6877] a REPEATED `?force` is refused, never read as force-ON', () => { + /** + * #6877's sharpest measured case is on this exact parameter one route over: + * `?force=false&force=false` arrives as an ARRAY, the `typeof` ternary falls + * through to `!!forceRaw`, and a non-empty array is truthy — so a caller + * repeating an explicit opt-OUT turned the destructive guard ON, on a + * destructive verb, answered 200. + * + * Threading `force` here without adding it to this door's + * `refuseRepeatedQueryParams` list would have re-opened that inversion on a + * door that never had it. The parameter and the guard landed in one stroke; + * this is the case that says so. + */ + it('⛔ `?force=false&force=false` is a 400 — NOT a silent force-ON', async () => { + const stack = boot(); + + const answer = await stack.compoundPut({ force: ['false', 'false'] }); + + expect(answer.status).toBe(400); + expect(answer.body?.error?.code).toBe('VALIDATION_ERROR'); + // The inversion, stated as the assertion that would have caught it: the + // save must not have happened at all, let alone succeeded. + expect(stack.seen).toHaveLength(0); + expect(stack.compoundFields()).toEqual(STORED_FIELDS); + }); + + it('⛔ `?force=true&force=true` is refused too — multiplicity, not intent', async () => { + const stack = boot(); + + const answer = await stack.compoundPut({ force: ['true', 'true'] }); + + expect(answer.status).toBe(400); + expect(answer.body?.error?.code).toBe('VALIDATION_ERROR'); + expect(stack.compoundFields()).toEqual(STORED_FIELDS); + }); + + it('one occurrence encoded as an array still works — the guard unwraps, it does not blanket-refuse', async () => { + const stack = boot(); + + const answer = await stack.compoundPut({ force: ['true'] }); + + expect(answer.status).toBe(200); + expect(stack.compoundFields()).toEqual(SHRUNK_FIELDS); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// 4. ⭐ [#7019] The twins agree — the ruling this card inherits, executable +// ═══════════════════════════════════════════════════════════════════════════ + +describe('[#11095 / #7019] the two `PUT` doors answer the same question the same way', () => { + /** + * The single-segment door is UNTOUCHED by this card and is the control. Its + * behaviour is asserted here rather than assumed, so this pair of cases + * fails if either door moves — which is the only shape in which "the twins + * agree" is a pin rather than a comment. + */ + it('refused identically at both doors, with the same code and status', async () => { + const stack = boot(); + + const compound = await stack.compoundPut(); + const single = await stack.singlePut(); + + expect(compound.status).toBe(single.status); + expect(compound.status).toBe(409); + expect(compound.body?.code).toBe(single.body?.code); + expect(compound.body?.code).toBe('DESTRUCTIVE_CHANGE'); + // Both refusals prescribe the parameter, and now both mean it. + expect(compound.body?.error).toContain(PUT_REMEDY); + expect(single.body?.error).toContain(PUT_REMEDY); + expect(stack.compoundFields()).toEqual(STORED_FIELDS); + expect(stack.singleFields()).toEqual(STORED_FIELDS); + }); + + it('⭐ ACCEPTED identically at both doors — the divergence this card closed', async () => { + const stack = boot(); + + const compound = await stack.compoundPut({ force: 'true' }); + const single = await stack.singlePut({ force: 'true' }); + + // The one assertion that was FALSE before this card: these two statuses + // were 409 and 200. One name, spelled two ways, two different answers + // to "may I acknowledge this risk". + expect(compound.status).toBe(single.status); + expect(compound.status).toBe(200); + expect(stack.compoundFields()).toEqual(SHRUNK_FIELDS); + expect(stack.singleFields()).toEqual(SHRUNK_FIELDS); + }); + + it('and the twin is UNTOUCHED — its request shape is what it always was', async () => { + const stack = boot(); + + await stack.singlePut({ force: 'true' }); + + // The fence. This card threads a parameter on the compound door; it must + // not have edited the door that was already right. + expect(stack.seen).toHaveLength(1); + expect(stack.seen[0]).toMatchObject({ + type: 'object', name: SINGLE_NAME, force: true, writeFace: 'meta-envelope', + }); + }); +}); diff --git a/packages/rest/src/rest-route-ledger.ts b/packages/rest/src/rest-route-ledger.ts index ab64d8c363..02e00e1fbc 100644 --- a/packages/rest/src/rest-route-ledger.ts +++ b/packages/rest/src/rest-route-ledger.ts @@ -202,7 +202,7 @@ export const REST_ROUTE_LEDGER: readonly RestRouteLedgerEntry[] = [ { route: 'GET /api/v1/meta/:type/:section/:name', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.getItem', note: 'compound names pass through getItem unencoded (URL-pinned in client.test.ts); only deleteItem encodes' }, { route: 'PUT /api/v1/meta/:type/:section/:name', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.saveItem', - note: 'compound names pass through saveItem unencoded (URL-pinned in client.test.ts). [#7019] gated on `manage_metadata` (ADR-0066 D1), identical to the single-name PUT — it was MEASURED that with #6603 in place the same ADR-0106 masked round trip still deleted fields through this door' }, + note: 'compound names pass through saveItem unencoded (URL-pinned in client.test.ts). [#7019] gated on `manage_metadata` (ADR-0066 D1), identical to the single-name PUT — it was MEASURED that with #6603 in place the same ADR-0106 masked round trip still deleted fields through this door. [#11095] reads `?force=true` too — the fourth divergence from its single-segment twin closed on #7019\'s reason (after that capability gate, #8805\'s write-side organization and #7035\'s 501 envelope): the Phase 3a-destructive `409 DESTRUCTIVE_CHANGE` prescribes that parameter, and until this card the prescription was true of the twin and false here. Repeated `?force` is refused (#6877) in the same stroke — an array falls to `!!raw`, turning a doubled opt-OUT into force ON' }, // ── ui ──────────────────────────────────────────────────────────────────── { route: 'GET /api/v1/ui/view/:object/:type', family: 'ui', source: 'route-manager', disposition: 'sdk', client: 'meta.getView', diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index 94599a44ca..20b8f858eb 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -6643,7 +6643,51 @@ export class RestServer { // `?package=` to `undefined`, i.e. wrote the row as an // env-local overlay instead of into the package the caller // named — a silent change of where the save LANDS. - if (refuseRepeatedQueryParams(req, res, ['package'])) return; + // + // [#11095] `force` joins that list in the SAME stroke as the + // parameter itself, and the order is not cosmetic: #6877's + // sharpest measured case is on this very parameter one route + // over — `?force=false&force=false` reaches the `typeof` + // ternary below as an ARRAY, falls through to `!!forceRaw`, + // and a non-empty array is truthy, so a caller repeating an + // explicit opt-OUT turns the destructive-change guard ON. + // Threading `force` here without also naming it here would + // have re-opened that inversion on a fresh door, on a + // destructive verb, reported as 200. + if (refuseRepeatedQueryParams(req, res, ['force', 'package'])) return; + // [#11095] Phase 3a-destructive: `?force=true` opts past the + // destructive-change safety check — BYTE-IDENTICAL to the + // single-segment `PUT /meta/:type/:name` above, truthy + // spellings and all, because it is byte-identically the same + // decision. + // + // Until this landed the request below was built field by + // field with no `force` among the fields, so `saveMetaItem`'s + // Phase 3a-destructive gate refused a save through this door + // with `409 DESTRUCTIVE_CHANGE` and the remedy clause + // `— re-submit with ?force=true to proceed.`, and a caller + // who did exactly that got the identical refusal back. The + // clause was true of the single-segment twin and false here. + // + // Threading rather than rewording is #7019's ruling applied + // again, with its reason: this route is "word for word the + // same operation" as its twin — one generic `saveMetaItem` + // reached by a name spelled in two segments — and gating only + // the single-segment door was MEASURED to leave this one a + // bypass of it. #8805 (write-side organization) and #7035 + // (the 501 envelope) both cite that same finding. A twin pair + // that disagrees about which risks a caller may acknowledge + // is the same shape, one field along. + // + // ⛔ NOT a licence for every door that reaches this gate: the + // runtime dispatcher's `PUT /meta` was ruled the other way in + // the same stroke (it has no query string at all) and states + // its own `writeFace` so its 409 stops prescribing a + // parameter it does not have. See `destructiveChangeRemedy`. + const forceRaw = req.query?.force; + const force = typeof forceRaw === 'string' + ? ['true', '1', 'yes', 'on'].includes(forceRaw.toLowerCase()) + : !!forceRaw; const packageRaw = req.query?.package; const packageId = typeof packageRaw === 'string' && packageRaw && packageRaw !== 'all' ? packageRaw @@ -6676,10 +6720,20 @@ export class RestServer { // Server-stated: this object is built field by field // from named `req` values and never spreads the body, so // a client cannot smuggle a face in. + // + // [#11095] The face stays `'meta-envelope'` — the same + // one the single-segment twin states — and that is now + // the whole point rather than an inherited default: the + // 409 clause this face renders prescribes `?force=true`, + // and with the line below this door finally HAS one. The + // alternative repair (a face of its own, saying the + // parameter is unavailable) is the option the ruling + // rejected for this door and adopted for the dispatcher. writeFace: 'meta-envelope', ...(environmentId ? { environmentId } : {}), ...(parentVersion !== undefined ? { parentVersion } : {}), ...(actor ? { actor } : {}), + ...(force ? { force: true } : {}), ...(packageId ? { packageId } : {}), } as any); res.json(result); diff --git a/packages/runtime/src/domains/meta-save-destructive-remedy.test.ts b/packages/runtime/src/domains/meta-save-destructive-remedy.test.ts new file mode 100644 index 0000000000..0abb987bba --- /dev/null +++ b/packages/runtime/src/domains/meta-save-destructive-remedy.test.ts @@ -0,0 +1,336 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#11095] The runtime dispatcher's `PUT /meta` is the door that does NOT gain + * `force` — and its `409 DESTRUCTIVE_CHANGE` must stop prescribing one. + * + * ## The defect + * + * `saveMetaItem`'s Phase 3a-destructive gate raises ONE refusal, and its remedy + * clause used to end `— re-submit with ?force=true to proceed.` on every face. + * That is true of the two REST `PUT` doors, which read the parameter off a + * query string. It was false here: this branch is reached with a path, a method + * and a body, and it built its `saveMetaItem` request field by field with no + * `force` among the fields. A caller refused at this door and doing exactly + * what the refusal said got the identical refusal back. + * + * ## Why this half was NOT repaired by threading the parameter + * + * The maintainer ruled a SPLIT (2026-08-23) over the two doors #11015 left + * open, and the split is the decision rather than an inconsistency to be tidied + * away later: + * + * - `@objectstack/rest`'s compound-name `PUT /meta/:type/:a/:b` DID gain + * `?force`, inheriting #7019's "the compound route is word for word the same + * operation as its single-segment twin" with its measured reason. Pinned in + * `packages/rest/src/meta-compound-save-force-parity.test.ts`. + * - This door has no twin precedent and a different call shape. `?force=true` + * here does not name a parameter someone forgot to read — it names a channel + * the transport does not have. Threading one would be a NEW public surface, + * which no ruling has opened. + * + * So the repair is #11015's landed mechanism, applied mechanically: a face value + * (`'meta-dispatch'`), stated at this call site, that renders a clause naming + * what a caller can actually do HERE. + * + * ## The coupling that makes this more than a wording change + * + * `writeFace` feeds TWO switches — `destructiveChangeRemedy` (409, "which + * remedy exists on this door") and `specValidationFindings` (422, "does a + * structured channel reach the consumer beside the message"). This door's + * answers DIFFER: no `force`, but it does carry `issues[]` + * (`errorFromThrown` → `details.issues`), which is why it declared + * `'meta-envelope'` in the first place. Splitting the face for the 409's sake + * therefore had to leave the 422 exactly where it was, and the 422's polarity + * is "declare to trim" — silence renders the FULL prose — so a face that fell + * through would re-introduce #10888's duplication on this door alone, silently, + * with every 409 assertion green. Section 3 is that pin. + * + * ## Harness + * + * The REAL `ObjectStackProtocolImplementation` over a `sys_metadata`-backed + * engine double, behind the REAL `HttpDispatcher`. A protocol double that + * refused with a hand-written message would be pinning this file's own idea of + * the producer's clause, which is the one thing worth measuring here. + * + * ⚠️ `@objectstack/metadata-protocol` resolves through `exports` to its + * **`dist/`** (registered for this package in `check-test-source-alias.mjs`'s + * `KNOWN_UNALIASED_TEST_IMPORTS`), so this suite reports on the BUILT protocol. + * Rebuild it after touching `protocol.ts` before reading a result here — a + * `dist/` merely behind renders the pre-fix clause and says nothing about it. + * + * ## What the cases assert + * + * `status` AND `code` (the ADR-0112 envelope) on refusal AND on acceptance, not + * merely the prose — a card that moved the wording and the status together + * would pass a prose-only test. The dispatcher answers by RETURNING a response + * object rather than throwing, so a `toThrow`-shaped assertion could not tell + * "refused with the wrong envelope" from "did not refuse at all". + */ + +import { describe, it, expect } from 'vitest'; +import { assertEngineUpdateDispatch, assertEngineDeleteDispatch } from '@objectstack/metadata-core'; +import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; +import { HttpDispatcher } from '../http-dispatcher.js'; + +/** + * A spec-valid `object` body. `sharingModel` is load-bearing, not decoration: + * ADR-0090 D1's author-time gate refuses an unset OWD (`security-owd-unset`), + * and the ACCEPTANCE case would otherwise fail one phase past the one under + * test with a red that reads like "the save is broken". + */ +const objectBody = (name: string, fields: readonly string[]) => ({ + name, + label: name, + sharingModel: 'private', + fields: Object.fromEntries(fields.map((f) => [f, { name: f, type: 'text', label: f }])), +}); + +const NAME = 'crm_task'; +/** What the fixture starts with … */ +const STORED_FIELDS = ['a', 'b', 'c', 'd']; +/** … and the body that would drop three of its columns. */ +const SHRUNK_FIELDS = ['a']; + +interface StoredRow { + id: string; type: string; name: string; + organization_id: string | null; package_id: string | null; + state: string; metadata: string; checksum: string; version: number; +} + +function boot() { + const rows = new Map(); + rows.set('row_task', { + id: 'row_task', type: 'object', name: NAME, + organization_id: null, package_id: null, state: 'active', + metadata: JSON.stringify(objectBody(NAME, STORED_FIELDS)), + checksum: 'sha256_11095_fixture', version: 1, + }); + + const match = (r: any, where: Record): boolean => + Object.entries(where ?? {}).every(([k, v]) => { + if (k === '$or') return (v as Array>).some((c) => match(r, c)); + return v === null || v === undefined + ? r[k] === null || r[k] === undefined + : r[k] === v; + }); + + const engine: any = { + async find(table: string, o?: { where?: Record }) { + if (table !== 'sys_metadata') return []; + return [...rows.values()].filter((r) => match(r, o?.where ?? {})); + }, + async findOne(table: string, o: { where: Record }) { + if (table !== 'sys_metadata') return null; + for (const r of rows.values()) if (match(r, o?.where ?? {})) return r; + return null; + }, + async insert(table: string, data: Record) { + if (table === 'sys_metadata') { + const r = { ...(data as any), id: String(data.id ?? `r_${rows.size}`) } as StoredRow; + rows.set(r.id, r); + } + return { id: String(data.id ?? 'r_new') }; + }, + // ⛔ Through the producer-side predicates, never hand-mirrored — a double + // looser than `ObjectQL` turns a green suite into no suite + // (`check:engine-double-contract`, #4550 / #5480). The write verbs are + // needed here because the acceptance case is asserted against the STORE. + async update(_t: string, data: Record, opts?: Record) { + assertEngineUpdateDispatch(data, opts); + const id = (opts as any)?.where?.id; + const existing = id ? rows.get(String(id)) : undefined; + if (existing) rows.set(String(id), { ...existing, ...(data as any) }); + return { id: id ?? null }; + }, + async delete(_t: string, opts?: Record) { + assertEngineDeleteDispatch(opts); + return { deleted: 0 }; + }, + registry: { + registerItem: () => {}, registerObject: () => {}, listItems: () => [], + getItem: () => undefined, getArtifactItem: () => undefined, + removeRuntimeShadow: () => false, removeOverlayEntry: () => {}, uninstallPackage: () => {}, + }, + }; + + const protocol: any = new ObjectStackProtocolImplementation(engine, () => new Map()); + + /** Every request the door hands the protocol — "with what", beside the store's "did it land". */ + const seen: any[] = []; + const realSave = protocol.saveMetaItem.bind(protocol); + protocol.saveMetaItem = async (request: any) => { seen.push(request); return realSave(request); }; + + const kernel = { + context: { getService: (n: string) => (n === 'protocol' ? protocol : null) }, + } as any; + + return { + dispatcher: new HttpDispatcher(kernel), + seen, + storedFields: () => Object.keys(JSON.parse(rows.get('row_task')!.metadata).fields ?? {}).sort(), + }; +} + +/** + * `manage_metadata` held — #7019's capability gate is a different card and must + * not be the thing answering in any case here. + */ +const ctx = (): any => ({ + request: {}, environmentId: 'platform', + executionContext: { userId: 'u_author', systemPermissions: ['manage_metadata'] }, +}); + +/** The sentence that is TRUE on the REST doors and was never true on this one. */ +const PUT_REMEDY = 're-submit with ?force=true to proceed.'; + +const put = (stack: ReturnType, body: unknown) => + stack.dispatcher.handleMetadata(`/object/${NAME}`, ctx(), 'PUT', body); + +// ═══════════════════════════════════════════════════════════════════════════ +// 1. The refusal — same envelope, honest clause +// ═══════════════════════════════════════════════════════════════════════════ + +describe('[#11095] dispatcher PUT /meta — the destructive refusal', () => { + it('refuses a data-dropping save with the ADR-0112 envelope, and writes NOTHING', async () => { + const stack = boot(); + + const res: any = await put(stack, objectBody(NAME, SHRUNK_FIELDS)); + + expect(res.response?.status).toBe(409); + expect(res.response?.body?.error?.code).toBe('DESTRUCTIVE_CHANGE'); + // "Refused after writing" satisfies both assertions above and is still + // the bug — so the store is asserted, not just the answer. + expect(stack.storedFields()).toEqual(STORED_FIELDS); + }); + + it('⭐ ⛔ never prescribes `?force=true` — this door has no query string to carry it', async () => { + const stack = boot(); + + const res: any = await put(stack, objectBody(NAME, SHRUNK_FIELDS)); + const message: string = res.response?.body?.error?.message; + + // The defect, stated as the assertion that fails without the fix. What + // must be gone is the MECHANISM NAME: a caller who reads it goes looking + // for a parameter this transport cannot accept, does what the sentence + // says, and is refused identically. + expect(message).not.toContain('force=true'); + expect(message).not.toContain(PUT_REMEDY); + }); + + it('prescribes what a caller CAN do here, and says why force is absent', async () => { + const stack = boot(); + + const res: any = await put(stack, objectBody(NAME, SHRUNK_FIELDS)); + const message: string = res.response?.body?.error?.message; + + expect(message).toContain('this save cannot be forced'); + expect(message).toContain('accepts no `force`'); + expect(message).toContain('reconcile'); + // …and WHICH item, so a caller reading several of these can tell them apart. + expect(message).toContain(NAME); + }); + + it('[#10886 non-effect] the per-field findings survive, in the prose AND structurally', async () => { + const stack = boot(); + + const res: any = await put(stack, objectBody(NAME, SHRUNK_FIELDS)); + + // Only the remedy clause is face-aware. #10886's sole-carrier verdict is + // untouched by this card exactly as it was untouched by #11015. + expect(res.response?.body?.error?.message).toContain("Field 'b' removed"); + // Row 3 of the face inventory says this door is NOT a sole carrier, and + // the claim is about THIS body. + expect(res.response?.body?.error?.details?.issues).toEqual(expect.arrayContaining([ + expect.objectContaining({ code: 'field_removed', field: 'b' }), + ])); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// 2. ⛔ Acceptance — and the acceptance set this card did NOT widen +// ═══════════════════════════════════════════════════════════════════════════ + +describe('[#11095] dispatcher PUT /meta — the accept set is unchanged', () => { + it('a NON-destructive save still succeeds — this is a message repair, not a lockout', async () => { + const stack = boot(); + + // Same four columns plus a relabel: nothing is dropped, so the gate + // never fires. Without this case the whole card could be "satisfied" by + // a door that refuses everything. + const res: any = await put(stack, { ...objectBody(NAME, STORED_FIELDS), label: 'Task (renamed)' }); + + expect(res.handled).toBe(true); + expect(res.response?.status).toBe(200); + expect(res.response?.body?.error).toBeUndefined(); + expect(stack.storedFields()).toEqual(STORED_FIELDS); + }); + + it('⭐ ⛔ `force` in the BODY does not lift the refusal — the surface was not widened', async () => { + const stack = boot(); + + // The only channel a caller has on this transport. The request object is + // built field by field precisely so `item` stays data and never becomes + // a control channel — asserted from the OUTSIDE rather than by reading + // the type, because the type is what a future edit would widen. + const res: any = await put(stack, { ...objectBody(NAME, SHRUNK_FIELDS), force: true }); + + expect(res.response?.status).toBe(409); + expect(res.response?.body?.error?.code).toBe('DESTRUCTIVE_CHANGE'); + expect(stack.storedFields()).toEqual(STORED_FIELDS); + // …and the request that reached the producer carried no `force` at all, + // which is the fact the status alone cannot establish. + expect(stack.seen).toHaveLength(1); + expect(stack.seen[0].force).toBeUndefined(); + expect(stack.seen[0].writeFace).toBe('meta-dispatch'); + }); + + it('⛔ a `writeFace` in the body cannot be smuggled either — the face is SERVER-stated', async () => { + const stack = boot(); + + // If a caller could name the face they could re-select the `?force=true` + // clause on a door that has no `force` — restoring the exact defect this + // card removed, from the outside. + const res: any = await put(stack, { ...objectBody(NAME, SHRUNK_FIELDS), writeFace: 'meta-envelope' }); + + expect(res.response?.status).toBe(409); + expect(res.response?.body?.error?.message).not.toContain(PUT_REMEDY); + expect(stack.seen[0].writeFace).toBe('meta-dispatch'); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// 3. ⭐ [COUPLING] One field, two switches — the 422 must NOT have moved +// ═══════════════════════════════════════════════════════════════════════════ + +describe('[#11095] [GUARD] splitting the face changed the 409 and nothing else', () => { + it('⛔ the 422 still renders the #10888 HEADLINE, not the restated prose', async () => { + const stack = boot(); + + // A view body whose `summary` carries a typo'd key — the same shape the + // 422 face inventory drives, so the two files agree on what a 422 is. + const res: any = await stack.dispatcher.handleMetadata( + '/view/task_list', ctx(), 'PUT', + { + name: 'task_list', object: 'task', type: 'list', label: 'Tasks', + columns: [{ field: 'title', summary: { type: 'sum', fieldd: 'amount' } }], + }, + ); + + expect(res.response?.status).toBe(422); + expect(res.response?.body?.error?.code).toBe('INVALID_METADATA'); + + const message: string = res.response?.body?.error?.message; + const issues: Array<{ message: string }> = res.response?.body?.error?.details?.issues ?? []; + + // The headline grammar — count plus `path [zod code]` locators … + expect(message).toContain('failed spec validation: '); + expect(message).toMatch(/\d+ issues? — /); + // … and NOT one finding restated in the sentence. This is the assertion + // that goes red if `'meta-dispatch'` ever falls through to the prose + // default: the polarity is "declare to trim", so the regression is + // silent in exactly this direction. + expect(issues.length).toBeGreaterThan(0); + for (const i of issues) expect(message).not.toContain(i.message); + }); +}); diff --git a/packages/runtime/src/domains/meta.ts b/packages/runtime/src/domains/meta.ts index 3da595adca..b6ba77f2a7 100644 --- a/packages/runtime/src/domains/meta.ts +++ b/packages/runtime/src/domains/meta.ts @@ -421,9 +421,32 @@ export async function handleMetadataRequest(deps: DomainHandlerDeps, path: strin // a headline rather than restating the per-key prose that // already rides the envelope structurally. Not client-settable: // the request object names each field explicitly. + // + // [#11095] …and the face is this door's OWN, not the REST + // doors'. It read `'meta-envelope'` until the two switches + // that consume it disagreed for the first time. The 422 + // answer is unchanged and shares `'meta-envelope'`'s case; + // what moved is the `409 DESTRUCTIVE_CHANGE` REMEDY clause, + // which used to tell a caller refused HERE to `re-submit + // with ?force=true` — advice this transport cannot take. The + // two REST `PUT` doors read `?force` off a query string (the + // compound-name twin as of this same card, inheriting + // #7019's twin-parity ruling); this branch is reached with a + // path, a method and a body, so there is no query string for + // an acknowledgement to arrive on. + // + // ⛔ This is the door the ruling deliberately did NOT give a + // `force`, and the absence is settled rather than pending: + // adding one — as a body key, a request field or anything + // else — widens a public surface no ruling has widened, and + // turns the clause into a lie in the other direction. The + // request below is built field by field for exactly that + // reason: `item` is data, never a channel, so a caller + // cannot smuggle a `force` (or a `writeFace`) through it. + // Pinned both ways in `meta-save-destructive-remedy.test.ts`. const result = await protocol.saveMetaItem({ type, name, item, organizationId, - writeFace: 'meta-envelope', + writeFace: 'meta-dispatch', ...(packageId ? { packageId } : {}), }); return { handled: true, response: deps.success(result) }; diff --git a/packages/runtime/src/http-dispatcher.test.ts b/packages/runtime/src/http-dispatcher.test.ts index ceeb87efb3..476a2a6bbe 100644 --- a/packages/runtime/src/http-dispatcher.test.ts +++ b/packages/runtime/src/http-dispatcher.test.ts @@ -144,7 +144,22 @@ describe('HttpDispatcher', () => { // ever stops being stated, `saveMetaItem`'s 422 silently goes // back to restating prose the envelope already carries — an // exact-match assertion is what makes that visible. - writeFace: 'meta-envelope', + // + // [#11095] The exact-match did its job: this value CHANGED, and + // it changed HERE first, deliberately, rather than quietly + // starting to pass somewhere. `'meta-envelope'` asserted "I am + // one of the doors that carry `issues[]` structurally", which is + // still true of this one — but the same field also answers the + // 409's question, "which remedy exists on this door", and there + // the answer diverged: both REST `PUT`s read `?force` off a + // query string (the compound-name twin as of this card), while + // this branch is reached with a path, a method and a body and + // has no query string at all. So it names itself. The 422 + // behaviour is unmoved — `specValidationFindings` lists the two + // faces on ONE `case` — and what moved is the destructive 409's + // remedy clause, pinned in + // `domains/meta-save-destructive-remedy.test.ts`. + writeFace: 'meta-dispatch', }); expect(result.response?.body).toEqual({ success: true, @@ -169,7 +184,17 @@ describe('HttpDispatcher', () => { item: body, // [#10888] The compound-name door states the same face — it is // the same handler and the same envelope. - writeFace: 'meta-envelope', + // + // ⚠️ [#11095] "The compound-name door" here means the + // DISPATCHER's own compound arity (`/lead/views/all_leads`), + // which is the same `if (method === 'PUT')` branch as the case + // above — not `@objectstack/rest`'s `PUT /meta/:type/:a/:b`, + // which is a different file, a different transport, and the one + // that GAINED `?force` under this card. Two unrelated things + // called "the compound-name door" one paragraph apart is exactly + // how a later reader talks themselves into threading `force` + // here too, so: this arity has no query string either. + writeFace: 'meta-dispatch', }); }); From 11038529eac221d55b63be658497ce257ebcdecd Mon Sep 17 00:00:00 2001 From: os-zhuang Date: Sun, 23 Aug 2026 15:16:39 +0000 Subject: [PATCH 2/3] chore(scripts): record the two new pinned engine doubles in the contract ledger (#11095) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_019siH5jDmk5hrayvfyojUqR --- scripts/engine-double-contract.pinned.json | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index abec102555..6a855ca6c5 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -1626,6 +1626,16 @@ "verb": "update", "pinned": 1 }, + { + "file": "packages/rest/src/meta-compound-save-force-parity.test.ts", + "verb": "delete", + "pinned": 1 + }, + { + "file": "packages/rest/src/meta-compound-save-force-parity.test.ts", + "verb": "update", + "pinned": 1 + }, { "file": "packages/rest/src/meta-published-overlay.test.ts", "verb": "delete", @@ -1691,6 +1701,16 @@ "verb": "update", "pinned": 1 }, + { + "file": "packages/runtime/src/domains/meta-save-destructive-remedy.test.ts", + "verb": "delete", + "pinned": 1 + }, + { + "file": "packages/runtime/src/domains/meta-save-destructive-remedy.test.ts", + "verb": "update", + "pinned": 1 + }, { "file": "packages/runtime/src/domains/share-links-enforcement-context.test.ts", "verb": "delete", From a063a6e0ec97f1470178098a7d07a5f326703e94 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 23 Aug 2026 16:04:14 +0000 Subject: [PATCH 3/3] test(rest,runtime): refuse WHERE combinators in the two new /meta engine doubles (#11095) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `check:where-matcher` was red at 11038529 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 Claude-Session: https://claude.ai/code/session_019siH5jDmk5hrayvfyojUqR --- .../meta-compound-save-force-parity.test.ts | 43 ++++++++++++++++--- .../meta-save-destructive-remedy.test.ts | 43 ++++++++++++++++--- 2 files changed, 74 insertions(+), 12 deletions(-) diff --git a/packages/rest/src/meta-compound-save-force-parity.test.ts b/packages/rest/src/meta-compound-save-force-parity.test.ts index 36e7538286..12fce73fce 100644 --- a/packages/rest/src/meta-compound-save-force-parity.test.ts +++ b/packages/rest/src/meta-compound-save-force-parity.test.ts @@ -152,13 +152,44 @@ function boot() { seed('row_compound', COMPOUND_NAME); seed('row_single', SINGLE_NAME); - const match = (r: any, where: Record): boolean => - Object.entries(where ?? {}).every(([k, v]) => { - if (k === '$or') return (v as Array>).some((c) => match(r, c)); - return v === null || v === undefined + /** + * Scalar equality ONLY, and every combinator is REFUSED rather than + * approximated — `pnpm check:where-matcher` + * (`scripts/check-where-matcher-conformance.mjs`, #8494): "a discovered + * matcher must answer every combinator probe CORRECTLY, or REFUSE it by + * throwing". + * + * The refusal is MEASURED, not assumed. Instrumenting this `match` to log + * every `where` it receives across all 17 cases recorded 35 calls — every + * one of them a flat scalar object over `type` / `name` / `state` / + * `organization_id` / `package_id` — zero `$or`, zero `$and`. The `$or` arm + * this replaces was therefore dead code, and it was also HALF a surface: + * `$and` fell straight through to `r['$and']`, compared `undefined` against + * an array, excluded the row and returned an empty result set with nothing + * erroring. A suite can go green on that while asserting about a DIFFERENT + * query than the one the protocol sent — the exact "declared ≠ enforced" + * shape this card is about, one layer down in its own fixture. + * + * A `throw` cannot do that. The protocol does build `$or` against + * `sys_metadata` elsewhere (org-scoped reads); if one is ever routed through + * this door, this suite goes RED and asks for a real implementation instead + * of quietly asserting on nothing. `$`-prefixed keys are never field names — + * `protocol.ts`'s own `FILTER_LOGICAL_KEYS` rule — so the guard is a prelude + * rather than an arm inside the loop: a preceding scalar miss must not be + * able to short-circuit `.every` past an operator we cannot answer. + */ + const match = (r: any, where: Record): boolean => { + for (const k of Object.keys(where ?? {})) { + if (k.startsWith('$')) { + throw new Error(`fake engine: unsupported logical operator ${k}`); + } + } + return Object.entries(where ?? {}).every(([k, v]) => + v === null || v === undefined ? r[k] === null || r[k] === undefined - : r[k] === v; - }); + : r[k] === v, + ); + }; const engine: any = { async find(table: string, o?: { where?: Record }) { diff --git a/packages/runtime/src/domains/meta-save-destructive-remedy.test.ts b/packages/runtime/src/domains/meta-save-destructive-remedy.test.ts index 0abb987bba..3499504dd8 100644 --- a/packages/runtime/src/domains/meta-save-destructive-remedy.test.ts +++ b/packages/runtime/src/domains/meta-save-destructive-remedy.test.ts @@ -107,13 +107,44 @@ function boot() { checksum: 'sha256_11095_fixture', version: 1, }); - const match = (r: any, where: Record): boolean => - Object.entries(where ?? {}).every(([k, v]) => { - if (k === '$or') return (v as Array>).some((c) => match(r, c)); - return v === null || v === undefined + /** + * Scalar equality ONLY, and every combinator is REFUSED rather than + * approximated — `pnpm check:where-matcher` + * (`scripts/check-where-matcher-conformance.mjs`, #8494): "a discovered + * matcher must answer every combinator probe CORRECTLY, or REFUSE it by + * throwing". + * + * The refusal is MEASURED, not assumed. Instrumenting this `match` to log + * every `where` it receives across all 8 cases recorded 9 calls — every + * one of them a flat scalar object over `type` / `name` / `state` / + * `organization_id` / `package_id` — zero `$or`, zero `$and`. The `$or` arm + * this replaces was therefore dead code, and it was also HALF a surface: + * `$and` fell straight through to `r['$and']`, compared `undefined` against + * an array, excluded the row and returned an empty result set with nothing + * erroring. A suite can go green on that while asserting about a DIFFERENT + * query than the one the protocol sent — the exact "declared ≠ enforced" + * shape this card is about, one layer down in its own fixture. + * + * A `throw` cannot do that. The protocol does build `$or` against + * `sys_metadata` elsewhere (org-scoped reads); if one is ever routed through + * this door, this suite goes RED and asks for a real implementation instead + * of quietly asserting on nothing. `$`-prefixed keys are never field names — + * `protocol.ts`'s own `FILTER_LOGICAL_KEYS` rule — so the guard is a prelude + * rather than an arm inside the loop: a preceding scalar miss must not be + * able to short-circuit `.every` past an operator we cannot answer. + */ + const match = (r: any, where: Record): boolean => { + for (const k of Object.keys(where ?? {})) { + if (k.startsWith('$')) { + throw new Error(`fake engine: unsupported logical operator ${k}`); + } + } + return Object.entries(where ?? {}).every(([k, v]) => + v === null || v === undefined ? r[k] === null || r[k] === undefined - : r[k] === v; - }); + : r[k] === v, + ); + }; const engine: any = { async find(table: string, o?: { where?: Record }) {