|
43 | 43 | * | # | caller | type | `force` | reaches gate | face | `issues` structurally | |
44 | 44 | * |:--|:--|:--|:--|:--|:--|:--| |
45 | 45 | * | 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** | |
48 | 48 | * | 4 | `@objectstack/runtime` ADR-0045 visibility flip | `'app'` | no | no — type | (`unhideError`) | n/a | |
49 | 49 | * | 5 | `migrateStoredMetadata` (this file's protocol) | any | **true** | no — `force` | (`rows[].reason`) | n/a | |
50 | 50 | * | 6 | {@link ObjectStackProtocolImplementation.duplicatePackage} | `row.type` incl. `object` | no | **yes** | `failed[].error` on a **200** | ⛔ **NO — sole carrier** | |
|
91 | 91 | * ## [#11015] The same inventory, read one column further left |
92 | 92 | * |
93 | 93 | * 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. |
100 | 100 | * |
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 |
102 | 102 | * 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 |
104 | 104 | * a `PUT` that cannot acknowledge a risk may be to thread `force` on those |
105 | 105 | * routes, which is a contract decision and not a message fix. Section 4 pins |
106 | 106 | * row 6; section 1's remedy guard pins that row 1's wording is untouched. |
107 | 107 | * |
| 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 | + * |
108 | 136 | * ⛔ Never a bare `toThrow()` here. `duplicatePackage` does not throw, it |
109 | 137 | * REPORTS, and what the report says IS the defect; and for the throw itself |
110 | 138 | * 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.'; |
244 | 272 | * exists. |
245 | 273 | */ |
246 | 274 | 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'; |
247 | 283 | /** One finding's prose, as `detectDestructiveObjectChanges` words it. */ |
248 | 284 | const FINDING_PROSE = "Field 'b' removed — existing data in this column will become inaccessible."; |
249 | 285 |
|
@@ -501,3 +537,127 @@ describe('[#11015] [GUARD] the destructive remedy clause is face-aware', () => { |
501 | 537 | expect(err.message).not.toContain(DUPLICATE_REMEDY_HEAD); |
502 | 538 | }); |
503 | 539 | }); |
| 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