|
81 | 81 | * the ablation therefore needs no rebuild, and its RED result is what rules |
82 | 82 | * out the stale-artifact false green. |
83 | 83 | * |
| 84 | + * ## [#11015] The same inventory, read one column further left |
| 85 | + * |
| 86 | + * The `force` column above is not decoration: it says which faces can lift |
| 87 | + * this refusal, and only ROW 1 can. Rows 2, 3 and 6 all reach the gate with no |
| 88 | + * way to set `force` — rows 2 and 3 because their routes never thread the |
| 89 | + * parameter, row 6 because `duplicatePackage` has no `force` field at all — |
| 90 | + * yet every one of them used to be handed the sentence `re-submit with |
| 91 | + * ?force=true to proceed.` A caller who does what it says gets the identical |
| 92 | + * refusal back. |
| 93 | + * |
| 94 | + * #11015 repairs the clause on ROW 6, where a genuinely different remedy |
| 95 | + * exists to prescribe (a free target namespace, or reconciling the collision). |
| 96 | + * Rows 2 and 3 are left as measured and filed as #11095: the honest repair for |
| 97 | + * a `PUT` that cannot acknowledge a risk may be to thread `force` on those |
| 98 | + * routes, which is a contract decision and not a message fix. Section 4 pins |
| 99 | + * row 6; section 1's remedy guard pins that row 1's wording is untouched. |
| 100 | + * |
84 | 101 | * ⛔ Never a bare `toThrow()` here. `duplicatePackage` does not throw, it |
85 | 102 | * REPORTS, and what the report says IS the defect; and for the throw itself |
86 | 103 | * the minimum assertion is `code` + `status` (ADR-0112 envelope), with the |
@@ -206,8 +223,19 @@ async function destructiveRefusal(): Promise<any> { |
206 | 223 | throw new Error('expected saveMetaItem to refuse the destructive change'); |
207 | 224 | } |
208 | 225 |
|
209 | | -/** The remedy sentence that must survive ANY future trim (#10886 non-effect). */ |
210 | | -const REMEDY = 're-submit with ?force=true to proceed.'; |
| 226 | +/** |
| 227 | + * The remedy sentence that must survive ANY future trim (#10886 non-effect), |
| 228 | + * as the ordinary REST `PUT` door renders it. `?force=true` is a real query |
| 229 | + * parameter THERE — the route reads it and threads it into the request. |
| 230 | + */ |
| 231 | +const PUT_REMEDY = 're-submit with ?force=true to proceed.'; |
| 232 | +/** |
| 233 | + * [#11015] …and as the DUPLICATE door renders it, which is a different |
| 234 | + * sentence because `?force=true` is not a thing a caller can set on that face. |
| 235 | + * See section 4 — the remedy stays, the mechanism it names becomes one that |
| 236 | + * exists. |
| 237 | + */ |
| 238 | +const DUPLICATE_REMEDY_HEAD = 'this copy cannot be forced'; |
211 | 239 | /** One finding's prose, as `detectDestructiveObjectChanges` words it. */ |
212 | 240 | const FINDING_PROSE = "Field 'b' removed — existing data in this column will become inaccessible."; |
213 | 241 |
|
@@ -243,7 +271,10 @@ describe('[#10886] the 409 renders its findings into the message AND attaches th |
243 | 271 | // refusal, this is a risk-ACKNOWLEDGEMENT flow: the remedy is the |
244 | 272 | // whole point, it is not one of the `issues`, and nothing else on any |
245 | 273 | // face carries it. |
246 | | - expect(err.message).toContain(REMEDY); |
| 274 | + // No `writeFace` on this request — the ordinary REST/Studio save, the |
| 275 | + // one door where `?force=true` is real. [#11015] made this clause |
| 276 | + // face-aware; this default is byte-identical to what it always said. |
| 277 | + expect(err.message).toContain(PUT_REMEDY); |
247 | 278 | const wire = JSON.stringify(err.issues); |
248 | 279 | expect(wire).not.toContain('force=true'); |
249 | 280 | }); |
@@ -317,18 +348,119 @@ describe('[#10886] [GUARD] `duplicatePackage`’s `failed[].error` is the SOLE c |
317 | 348 | expect(entry.issues).toBeUndefined(); |
318 | 349 | }); |
319 | 350 |
|
320 | | - it('⛔ carries the `?force=true` remedy, on a response with no other channel for it', async () => { |
| 351 | + it('⛔ carries the remedy, on a response with no other channel for it', async () => { |
321 | 352 | const { protocol } = duplicateIntoOccupiedNamespace(); |
322 | 353 |
|
323 | 354 | const r = await protocol.duplicatePackage({ |
324 | 355 | sourcePackageId: PKG, targetPackageId: TARGET_PKG, |
325 | 356 | }); |
326 | 357 |
|
327 | | - expect(r.failed[0].error).toContain(REMEDY); |
| 358 | + // ⚠️ [#11015] This assertion USED to read `toContain(REMEDY)` with |
| 359 | + // REMEDY = the `?force=true` sentence, and it passed — because the |
| 360 | + // producer rendered that sentence on every face. It was pinning the |
| 361 | + // defect: this door accepts no `force`, so the prescription it quoted |
| 362 | + // was unactionable. Replaced rather than re-spelled, because what it |
| 363 | + // asserted stopped being true of a correct producer. What #10886 put |
| 364 | + // it here to protect is unchanged and still asserted: SOME remedy |
| 365 | + // reaches the caller through this string and through nothing else. |
| 366 | + expect(r.failed[0].error).toContain(DUPLICATE_REMEDY_HEAD); |
328 | 367 | // The whole response, not just the entry: nothing anywhere else on it |
329 | 368 | // states the remedy or the findings. |
330 | 369 | const wire = JSON.stringify({ ...r, failed: r.failed.map((f: any) => ({ ...f, error: '' })) }); |
331 | | - expect(wire).not.toContain('force=true'); |
| 370 | + expect(wire).not.toContain('cannot be forced'); |
332 | 371 | expect(wire).not.toContain('inaccessible'); |
333 | 372 | }); |
334 | 373 | }); |
| 374 | + |
| 375 | +// ═══════════════════════════════════════════════════════════════════════════ |
| 376 | +// 4. [#11015] [GUARD] The remedy names a mechanism THIS face actually has |
| 377 | +// ═══════════════════════════════════════════════════════════════════════════ |
| 378 | + |
| 379 | +describe('[#11015] [GUARD] the destructive remedy clause is face-aware', () => { |
| 380 | + /** Same reachability fixture as section 3 — the duplicate-AGAIN workflow. */ |
| 381 | + const duplicateIntoOccupiedNamespace = () => makeKernel({ |
| 382 | + seed: [ |
| 383 | + objectRow('crm_task', ['a']), |
| 384 | + objectRow('crm2_task', ['a', 'b', 'c', 'd'], TARGET_PKG), |
| 385 | + ], |
| 386 | + }); |
| 387 | + |
| 388 | + const duplicateFailure = async (extra: Record<string, unknown> = {}) => { |
| 389 | + const { protocol } = duplicateIntoOccupiedNamespace(); |
| 390 | + const r = await protocol.duplicatePackage({ |
| 391 | + sourcePackageId: PKG, targetPackageId: TARGET_PKG, ...extra, |
| 392 | + }); |
| 393 | + return r; |
| 394 | + }; |
| 395 | + |
| 396 | + it('⛔ the duplicate face never prescribes `force` — the door accepts none', async () => { |
| 397 | + const r = await duplicateFailure(); |
| 398 | + |
| 399 | + // The defect, stated as the assertion that would have failed before |
| 400 | + // the fix. Not `not.toContain(PUT_REMEDY)` alone: the substring that |
| 401 | + // must be gone is the MECHANISM NAME, because a caller reading it goes |
| 402 | + // looking for a parameter that does not exist on this door. |
| 403 | + expect(r.failed[0].error).not.toContain('force=true'); |
| 404 | + expect(r.failed[0].error).not.toContain(PUT_REMEDY); |
| 405 | + }); |
| 406 | + |
| 407 | + it('prescribes the remedies that DO exist on this face, and names the collision', async () => { |
| 408 | + const r = await duplicateFailure(); |
| 409 | + const error: string = r.failed[0].error; |
| 410 | + |
| 411 | + // Both real remedies, in the caller's own vocabulary — `targetNamespace` |
| 412 | + // is a parameter this door genuinely accepts. |
| 413 | + expect(error).toContain('target namespace'); |
| 414 | + expect(error).toContain('reconcile'); |
| 415 | + // …and WHICH item collides, which is the copy's re-namespaced name |
| 416 | + // (`crm_task` → `crm2_task`), not the source row's. |
| 417 | + expect(error).toContain('crm2_task'); |
| 418 | + }); |
| 419 | + |
| 420 | + it('[#10886 non-effect] the per-field findings prose is still there, untrimmed', async () => { |
| 421 | + const r = await duplicateFailure(); |
| 422 | + |
| 423 | + // ⛔ This card repaired the remedy clause ONLY. #10886's verdict — the |
| 424 | + // findings prose stays, because `failed[].error` is its sole carrier on |
| 425 | + // this face — is untouched, and this is the assertion that says so. |
| 426 | + expect(r.failed[0].error).toContain(FINDING_PROSE); |
| 427 | + expect(r.failed[0].error).toContain('[destructive_change]'); |
| 428 | + }); |
| 429 | + |
| 430 | + it('the refusal still REFUSES — this is a message repair, not a behaviour one', async () => { |
| 431 | + const r = await duplicateFailure(); |
| 432 | + |
| 433 | + // Clause-② line: no accept/reject behaviour moved. The copy is still |
| 434 | + // rejected, still reported as data on the 200, still counted. |
| 435 | + expect(r.success).toBe(false); |
| 436 | + expect(r.copiedCount).toBe(0); |
| 437 | + expect(r.failedCount).toBe(1); |
| 438 | + expect(r.copied).toEqual([]); |
| 439 | + }); |
| 440 | + |
| 441 | + it('⛔ the face is stated by the SERVER — a caller cannot smuggle one in', async () => { |
| 442 | + // The duplicate route builds `duplicatePackage`'s request field by |
| 443 | + // field and this method hard-codes the face on its internal |
| 444 | + // `saveMetaItem` call, so neither a `force` nor a `writeFace` on the |
| 445 | + // caller's request can reach the gate. Asserted from the OUTSIDE |
| 446 | + // rather than by reading the type, because the type is what a future |
| 447 | + // edit would widen: if adding `force` to this door ever becomes the |
| 448 | + // decision, this test is the one that has to be rewritten deliberately |
| 449 | + // instead of quietly starting to pass. |
| 450 | + const smuggled = await duplicateFailure({ force: true, writeFace: undefined }); |
| 451 | + |
| 452 | + expect(smuggled.failedCount).toBe(1); |
| 453 | + expect(smuggled.failed[0].error).toContain(DUPLICATE_REMEDY_HEAD); |
| 454 | + expect(smuggled.failed[0].error).not.toContain('force=true'); |
| 455 | + }); |
| 456 | + |
| 457 | + it('the OTHER faces keep the `?force=true` wording — a switch, not a global delete', async () => { |
| 458 | + // Row 1 of the inventory, driven at the producer with no face stated. |
| 459 | + const err = await destructiveRefusal(); |
| 460 | + |
| 461 | + expect(err.code).toBe('DESTRUCTIVE_CHANGE'); |
| 462 | + expect(err.status).toBe(409); |
| 463 | + expect(err.message).toContain(PUT_REMEDY); |
| 464 | + expect(err.message).not.toContain(DUPLICATE_REMEDY_HEAD); |
| 465 | + }); |
| 466 | +}); |
0 commit comments