Skip to content

Commit bca21f7

Browse files
os-litantclaude
andauthored
fix(runtime): refuse POST /packages/:id/duplicate on a source that is not a base (#15849)
* fix(runtime): refuse `POST /packages/:id/duplicate` on a source that is not a base Duplicating a running CODE package answered HTTP 200 with `{"success":false,"copiedCount":0,"failedCount":0,"copied":[],"failed":[]}` and still created the target package record — a real, listed, empty package. Reproduced independently twice on two separate `os dev` processes, against `examples/app-todo` (one object, four flows, views, dashboards, reports; none of it copied). `copiedCount: 0` there is BY CONSTRUCTION. `duplicatePackage` clones the rows `sys_metadata` holds for the source, and a code package's metadata is delivered as code, so the scan could never have found anything — a read that could not happen, reported as a read that found nothing, which `packages/rest/src/package-routes.ts` already states as a rule one route over (#11063). ADR-0070 D4 is declared and NOT built ("D4-D6 remaining") and its object is a *base*, so cloning a code package's items would EXTEND the decision rather than implement it — and the ADR still lists that as an open question. The unbuilt case therefore refuses loudly: - `requireDuplicableSource` answers 422 `DUPLICATE_SOURCE_NOT_A_BASE` (new ledger row under `@objectstack/runtime`) for a code-loaded, platform- or marketplace-scoped source, naming the package and the ADR-0005 overlay remedy. It runs BEFORE the protocol call, because `duplicatePackage` mints the target record ahead of its copy loop. - Same predicate as every other writability verdict here (`isWritablePackage`, ADR-0070 D2), a different code: `WRITABLE_PACKAGE_REQUIRED` means "may not be written to", and its remedy reads as "make the source writable", which is neither possible nor the point. - `requireWritablePackage`'s own 422 stops prescribing a dead end: it used to send read-only-package callers at `POST /:id/duplicate`, the route that now refuses them. It points at the ADR-0005 overlay. Deliberately unchanged: a WRITABLE base that owns no active rows still answers 200 with `copiedCount: 0`. That read happened and found nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N * docs(permissions): re-anchor the system-context census row this diff shifted `content/docs/permissions/system-context.mdx` anchors source files by ABSOLUTE LINE NUMBER, so adding `requireDuplicableSource` to `packages/runtime/src/domains/packages.ts` rotted row 52's citation of the anonymous-deny seam: `domains/packages.ts:422` -> `:535`, a shift of exactly the 113 lines inserted above it. Repaired with `node scripts/check-system-context-census.mjs --fix` (1 anchor rewritten); no line number was hand-edited. Verified as a PURE SHIFT rather than a population change, by re-deriving `node scripts/isystem-census.mjs --json` on BOTH sides (origin/main 4f37912 and this branch) in the same session rather than reusing any earlier figure: sites 106 = 106, identifierAppearances 885 = 885, classified 462 = 462, scannedFiles 293 = 293 (non-zero control), nonElevationReads 6 = 6, staleLedgerRows 0 = 0, and every roleCounts / text member equal. Keyed on identity (file, package, receiver, text) and compared as a MULTISET so duplicate keys inside one file cannot collapse: 89 distinct keys carrying 106 sites on both sides, ARRIVED = 0, VANISHED = 0, and exactly one site's LINE moved — in the one file this branch edits, with zero line moves anywhere else. Row 55's sibling anchor `domains/packages.ts:241` is deliberately untouched: it sits above the first edit, so it did not shift. Swept the whole of `content/` and `docs/` for other anchors into the four files this branch resizes — those two are the only hits, against a control of 559 anchor-shaped citations tree-wide. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N * docs(runtime): strip the tracker id from the duplicate route's ledger note `check:doc-authoring` refuses a new internal issue-id reference in sibling-package STRING prose, and the note added to `packages/runtime/src/route-ledger.ts` carried two: the `[#NNNN]` prefix and a `pre-#NNNN` back-reference. A route-ledger note is a runtime string that reaches authors, operators and generated surfaces, none of whom can resolve a tracker id. Maintainer ruling 2026-08-12, verbatim and untranslated: 「处理 issue 时犯的错应该总结成经验,保留 issue id没有意义」 So the note now states the lesson self-containedly -- what the refusal is, where it lives, why it must precede the protocol call, and what the answer looked like before it -- with no id to resolve. The adjacent `//` comments in `domains/packages.ts` keep their ids: comments are the sanctioned carrier, and the gate reads only strings. ⛔ Not repaired by a baseline entry: `doc-authoring-prose-id.baseline.json` pins the adjudicated pre-existing population and is shrink-only, so an entry there weakens a ratchet and is maintainer-only. Gate now reads: sibling-package prose ids hold the baseline -- 829 pinned site(s) across 231 file(s), 85387 string(s) read in 1156 parsed source(s), no growth, no burn-down unrecorded. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6615a02 commit bca21f7

8 files changed

Lines changed: 370 additions & 10 deletions

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
"@objectstack/runtime": minor
3+
"@objectstack/spec": minor
4+
---
5+
6+
`POST /packages/:id/duplicate` now refuses a source that is not a writable base, instead of answering `200` with an empty copy.
7+
8+
Duplicating a **running code package** answered `HTTP 200` with `{"success":false,"copiedCount":0,"failedCount":0,"copied":[],"failed":[]}` — and still created the target package record, leaving a real, listed, empty package behind. The source package had one object, four flows, views, dashboards and reports; none of it was copied, and nothing said why.
9+
10+
`copiedCount: 0` there was **by construction**, not a copy that failed. `duplicatePackage` clones the rows `sys_metadata` holds for the source, and a code package's metadata is delivered as code — it has no such rows — so the scan could never have found anything. A caller could not tell that from a base that really is empty, which is the ambiguity the platform already refuses to ship elsewhere: *a read that could not happen must not be reported as a read that found nothing.*
11+
12+
- **The refusal.** A code-loaded, platform- or marketplace-scoped source is now refused `422` with the new error code `DUPLICATE_SOURCE_NOT_A_BASE` (registered under `@objectstack/runtime`), naming the package and prescribing the remedy that exists for it — duplicate a base you own, or customise the code package in place with an ADR-0005 org overlay. The refusal runs **before** the protocol call, so the empty target record is no longer created; the writability verdict is the same `isWritablePackage` predicate the authoring and lifecycle gates already use.
13+
- **The read-only lifecycle refusal stops prescribing a dead end.** `WRITABLE_PACKAGE_REQUIRED` (from `DELETE /packages/:id` and `PATCH /packages/:id/disable`) used to tell callers to "duplicate this one into a writable base (`POST /packages/:id/duplicate`) and change that" — a route which, for exactly the packages that refusal fires on, cannot help. It now points at the ADR-0005 overlay instead.
14+
15+
⚠️ Behaviour change for API callers: duplicating a code, platform or marketplace package was `200`, and is now `422`. Duplicating a **writable base** is untouched in every respect — including a base that owns no active rows, which still answers `200` with `copiedCount: 0`, because that read happened and found nothing.
16+
17+
Not changed: duplicate still does not clone a code package's items. ADR-0070 D4 duplicates a *base*, and is itself declared-and-not-built; teaching it to fork code packages would extend the decision rather than implement it, and the ADR still carries that as an open question.

content/docs/permissions/system-context.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,7 +160,7 @@ The largest single consumer — **17 of the 105 sites**.
160160
| 49 | Action `requiredPermissions` bypassed | runtime | Get: engine self-invocation runs any action | `action-execution.ts:401` |
161161
| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:5101`, `:6527`, `:6775`, `:7206`, `:7399` |
162162
| 51 | The shared metadata-write verdict itself returns `allowed` | metadata-core | Get: the one function all of row 50's doors consult answers yes before any capability is examined | `meta-write-capability.ts:134` |
163-
| 52 | Anonymous-deny seam satisfied on the domain dispatchers and the package/federation routes | runtime, rest | Get: passes with no `userId` | `domains/actions.ts:421`, `domains/ai.ts:60`, `domains/automation.ts:989`, `domains/meta.ts:232`, `domains/security.ts:78`, `domains/packages.ts:422`, `external-datasource-routes.ts:302`, `package-routes.ts:97` |
163+
| 52 | Anonymous-deny seam satisfied on the domain dispatchers and the package/federation routes | runtime, rest | Get: passes with no `userId` | `domains/actions.ts:421`, `domains/ai.ts:60`, `domains/automation.ts:989`, `domains/meta.ts:232`, `domains/security.ts:78`, `domains/packages.ts:535`, `external-datasource-routes.ts:302`, `package-routes.ts:97` |
164164
| 53 | MCP principal check satisfied | runtime | Get: MCP surface reachable with no user | `domains/mcp.ts:61` |
165165
| 54 | Package REST route capability gate bypassed | rest | Get: package read/write over REST without `manage_metadata` / `studio.access` / `setup.access` | `package-routes.ts:102` |
166166
| 55 | Package domain capability gates bypassed | runtime | Get: package management and package-inventory reads without the capability | `domains/packages.ts:241`, `:274` |

content/docs/references/api/contract.mdx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ const result = ApiErrorSchema.parse(data);
2727

2828
| Property | Type | Required | Description |
2929
| :--- | :--- | :--- | :--- |
30-
| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +294 more>` || Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) |
30+
| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +295 more>` || Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) |
3131
| **declaredCode** | `string` | optional | The producer-declared code, verbatim, when it is not a member of the closed `code` vocabulary — the open, author-authored channel (app-specific spellings; ADR-0112) |
3232
| **message** | `string` || Readable error message |
3333
| **userMessage** | `string` | optional | Producer-marked user-facing refusal text, verbatim. Present exactly when the producer opted in at throw time; consumers render it to end users and keep their generic substitution for anything unmarked. Status-agnostic; never replaces `message`. |
@@ -140,6 +140,7 @@ const result = ApiErrorSchema.parse(data);
140140
* `DOMAIN_VERIFICATION_FAILED`
141141
* `DRIVER_UNAVAILABLE`
142142
* `DUPLICATE_REQUEST`
143+
* `DUPLICATE_SOURCE_NOT_A_BASE`
143144
* `ELIGIBILITY_UNEVALUABLE`
144145
* `EMAIL_DOMAIN_NOT_ALLOWED`
145146
* `EMAIL_SEND_FAILED`

content/docs/references/api/error-code-ledger.mdx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,7 @@ const result = ErrorCode.parse(data);
256256
* `DOMAIN_VERIFICATION_FAILED`
257257
* `DRIVER_UNAVAILABLE`
258258
* `DUPLICATE_REQUEST`
259+
* `DUPLICATE_SOURCE_NOT_A_BASE`
259260
* `ELIGIBILITY_UNEVALUABLE`
260261
* `EMAIL_DOMAIN_NOT_ALLOWED`
261262
* `EMAIL_SEND_FAILED`

packages/runtime/src/domains/packages-readonly-gate.test.ts

Lines changed: 202 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@
4242
* the same listing.
4343
*/
4444

45-
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
45+
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
4646
import { mkdtempSync, rmSync } from 'node:fs';
4747
import { tmpdir } from 'node:os';
4848
import { join } from 'node:path';
@@ -95,14 +95,38 @@ function make() {
9595
// Present so a DELETE that is ALLOWED reaches its persisted half too —
9696
// the allow-path must be exercised end to end, not just to the gate.
9797
deletePackage: async () => ({ deletedCount: 1 }),
98+
// [#14451] This double MINTS THE TARGET PACKAGE RECORD, because the real
99+
// `duplicatePackage` does: its `registry.installPackage` call runs ahead
100+
// of the copy loop, which is why the reported defect left a real, empty
101+
// `com.acme.dupbase` in `GET /packages`.
102+
//
103+
// ⚠️ Not decoration — it is what makes the refusal cases falsifiable. A
104+
// double that only returned a value would leave "the target is not in
105+
// the listing" true whether or not the gate exists, i.e. an assertion
106+
// that can never go red. With the mint in place, deleting the gate makes
107+
// those cases fail on the listing, not merely on the status.
108+
duplicatePackage: vi.fn(async (req: any) => {
109+
registry.installPackage(manifest(req.targetPackageId));
110+
// The exact empty-success body the card measured on a running code
111+
// package: HTTP 200, `success: false`, nothing copied, nothing named
112+
// as failed.
113+
return {
114+
success: false,
115+
copiedCount: 0,
116+
failedCount: 0,
117+
targetPackageId: req.targetPackageId,
118+
copied: [],
119+
failed: [],
120+
};
121+
}),
98122
};
99123
const kernel: any = {
100124
context: {
101125
getService: (name: string) =>
102126
name === 'objectql' ? objectql : name === 'protocol' ? protocol : null,
103127
},
104128
};
105-
return { dispatcher: new HttpDispatcher(kernel), registry };
129+
return { dispatcher: new HttpDispatcher(kernel), registry, protocol };
106130
}
107131

108132
/** Authorized under #7033 — holds the write capability on every call below. */
@@ -292,3 +316,179 @@ describe('/packages lifecycle — an unknown package id keeps its 404 (#7560)',
292316
expect(r.response?.status).toBe(404);
293317
});
294318
});
319+
320+
// ══════════════════════════════════════════════════════════════════════════════
321+
// 6. [#14451] POST /:id/duplicate — the SOURCE must be a base
322+
//
323+
// ## The defect this section pins
324+
//
325+
// `POST /api/v1/packages/com.example.todo/duplicate` against a RUNNING code
326+
// package answered **HTTP 200** with
327+
// `{"success":false,"copiedCount":0,"failedCount":0,"copied":[],"failed":[]}`
328+
// and left a real, empty `com.acme.dupbase` behind in `GET /packages`.
329+
// Reproduced independently twice on two separate `os dev` processes.
330+
//
331+
// `copiedCount: 0` there is BY CONSTRUCTION: `duplicatePackage` clones the
332+
// source's `sys_metadata` rows, and a code package's metadata is delivered as
333+
// code, so the scan is one that could never have found anything. That is the
334+
// #11063 ruling one route over — "a read that could not happen must not be
335+
// reported as a read that found nothing" — and the caller could not tell it
336+
// from a base that really is empty.
337+
//
338+
// ⛔ The fix is NOT to make duplicate clone code items. ADR-0070 D4 is declared
339+
// and NOT built ("D4–D6 remaining") and its object is a *base*; cloning a code
340+
// package would EXTEND D4, and the ADR still lists that as an open question
341+
// ("should customising a code item also fork it into a writable base?").
342+
//
343+
// ## What is asserted, and why it is not the status code
344+
//
345+
// The harm was the empty shell, so every refusal case asserts the listing —
346+
// and the protocol double MINTS that shell exactly as the real implementation
347+
// does, so these assertions can actually go red. A double that merely returned
348+
// a value would make "the target is absent" true with or without the gate.
349+
// ══════════════════════════════════════════════════════════════════════════════
350+
351+
const DUP_TARGET = 'com.acme.dupbase';
352+
353+
describe('/packages duplicate — a source that is not a base is refused (#14451)', () => {
354+
for (const pkg of READ_ONLY_PACKAGES) {
355+
it(`422s duplicate of the ${pkg.label} package AND mints no target record`, async () => {
356+
const { dispatcher, registry, protocol } = make();
357+
const before = listedIds(registry);
358+
359+
const r = await dispatcher.handlePackages(
360+
`/${pkg.id}/duplicate`, 'POST', { targetPackageId: DUP_TARGET, targetName: 'Dup Base' }, {}, admin(),
361+
);
362+
363+
expect(r.response?.status).toBe(422);
364+
expect(r.response?.body?.error?.code).toBe('DUPLICATE_SOURCE_NOT_A_BASE');
365+
// THE observable the card was written from: the pre-fix answer left
366+
// a real, listed, `writable: true` package record behind.
367+
expect(listedIds(registry)).toEqual(before);
368+
expect(listedIds(registry)).not.toContain(DUP_TARGET);
369+
expect(registry.getPackage(DUP_TARGET)).toBeUndefined();
370+
// The refusal ran BEFORE the protocol call — which is the only
371+
// placement that can keep the shell from being minted, since
372+
// `duplicatePackage` installs it ahead of its own copy loop.
373+
expect(protocol.duplicatePackage).not.toHaveBeenCalled();
374+
// …and the source is untouched: this route never wrote to it, and
375+
// the refusal must not have changed that.
376+
expect(registry.getPackage(pkg.id)).toBeDefined();
377+
});
378+
}
379+
380+
it('answers the ADR-0112 envelope with the package id and the ADR pointer', async () => {
381+
const { dispatcher } = make();
382+
const r = await dispatcher.handlePackages(
383+
`/${CODE_LOADED}/duplicate`, 'POST', { targetPackageId: DUP_TARGET }, {}, admin(),
384+
);
385+
const err = r.response?.body?.error;
386+
expect(r.response?.status).toBe(422);
387+
expect(err?.code).toBe('DUPLICATE_SOURCE_NOT_A_BASE');
388+
expect(err?.httpStatus).toBe(422);
389+
expect(err?.details?.packageId).toBe(CODE_LOADED);
390+
expect(err?.details?.docs).toBe('docs/adr/0070-package-first-authoring.md');
391+
// The remedy is the one that EXISTS for a code package (ADR-0005
392+
// overlay), and the message says why the copy would be empty rather
393+
// than only that it is refused.
394+
expect(err?.message).toContain('ADR-0005');
395+
expect(err?.message).toContain('read-only');
396+
});
397+
398+
it('is its OWN code, not WRITABLE_PACKAGE_REQUIRED — the two conditions differ', async () => {
399+
const { dispatcher } = make();
400+
const dup = await dispatcher.handlePackages(
401+
`/${CODE_LOADED}/duplicate`, 'POST', { targetPackageId: DUP_TARGET }, {}, admin(),
402+
);
403+
const del = await dispatcher.handlePackages(`/${CODE_LOADED}`, 'DELETE', {}, {}, admin());
404+
// Same predicate (`isWritablePackage`), same status, different meaning:
405+
// DELETE is refused because the package may not be WRITTEN to; duplicate
406+
// is refused because the GESTURE does not apply to this source. A caller
407+
// told `WRITABLE_PACKAGE_REQUIRED` here would hunt for a way to make a
408+
// code package writable, which is neither possible nor the remedy.
409+
expect(del.response?.body?.error?.code).toBe('WRITABLE_PACKAGE_REQUIRED');
410+
expect(dup.response?.body?.error?.code).toBe('DUPLICATE_SOURCE_NOT_A_BASE');
411+
expect(dup.response?.body?.error?.code).not.toBe(del.response?.body?.error?.code);
412+
});
413+
});
414+
415+
// ══════════════════════════════════════════════════════════════════════════════
416+
// 7. [#14451] The gate is not an outage — the control that must be non-zero
417+
// ══════════════════════════════════════════════════════════════════════════════
418+
419+
describe('/packages duplicate — a WRITABLE base still duplicates (#14451)', () => {
420+
it('reaches the protocol with the source and target it was given, and mints the target', async () => {
421+
const { dispatcher, registry, protocol } = make();
422+
expect(listedIds(registry)).not.toContain(DUP_TARGET);
423+
424+
const r = await dispatcher.handlePackages(
425+
`/${WRITABLE}/duplicate`, 'POST', { targetPackageId: DUP_TARGET, targetName: 'Dup Base' }, {}, admin(),
426+
);
427+
428+
expect(r.response?.status).toBe(200);
429+
expect(protocol.duplicatePackage).toHaveBeenCalledTimes(1);
430+
expect(protocol.duplicatePackage.mock.calls[0]?.[0]).toMatchObject({
431+
sourcePackageId: WRITABLE,
432+
targetPackageId: DUP_TARGET,
433+
targetName: 'Dup Base',
434+
});
435+
// The non-zero control the refusal cases are read against: on the allow
436+
// path the double really does mint the record, so their `not.toContain`
437+
// is a measurement rather than a vacuous truth.
438+
expect(listedIds(registry)).toContain(DUP_TARGET);
439+
});
440+
441+
it('an EMPTY writable base still answers 200 / copiedCount 0 — that read HAPPENED', async () => {
442+
// ⛔ Deliberately unchanged by this card. The axis is whether the gesture
443+
// APPLIES to the source, never whether it found anything: a base that
444+
// owns no active rows is a read that ran and came back empty, which is
445+
// the legitimate arm of the #11063 ruling. Narrowing this to "refuse an
446+
// empty result" would refuse a legitimate duplicate of a fresh base.
447+
const { dispatcher } = make();
448+
const r = await dispatcher.handlePackages(
449+
`/${WRITABLE}/duplicate`, 'POST', { targetPackageId: DUP_TARGET }, {}, admin(),
450+
);
451+
expect(r.response?.status).toBe(200);
452+
expect(r.response?.body?.data?.copiedCount).toBe(0);
453+
expect(r.response?.body?.data?.success).toBe(false);
454+
});
455+
456+
it('an UNKNOWN source id falls through to the protocol — the gate is no existence oracle', async () => {
457+
// Same rule `requireWritablePackage` follows: an id that resolves to
458+
// nothing is treated as writable, so it reaches the route's own answer
459+
// instead of being re-labelled 422 by a gate that would then leak which
460+
// ids exist.
461+
const { dispatcher, protocol } = make();
462+
const r = await dispatcher.handlePackages(
463+
'/com.nobody.nothing/duplicate', 'POST', { targetPackageId: DUP_TARGET }, {}, admin(),
464+
);
465+
expect(r.response?.status).not.toBe(422);
466+
expect(protocol.duplicatePackage).toHaveBeenCalledTimes(1);
467+
});
468+
});
469+
470+
// ══════════════════════════════════════════════════════════════════════════════
471+
// 8. [#14451] The sibling refusal stops prescribing a dead end
472+
// ══════════════════════════════════════════════════════════════════════════════
473+
474+
describe('/packages lifecycle — the read-only refusal no longer sends callers at duplicate (#14451)', () => {
475+
for (const verb of [
476+
{ label: 'DELETE', path: (id: string) => `/${id}`, method: 'DELETE' },
477+
{ label: 'disable', path: (id: string) => `/${id}/disable`, method: 'PATCH' },
478+
]) {
479+
it(`${verb.label} prescribes the ADR-0005 overlay, not POST /:id/duplicate`, async () => {
480+
const { dispatcher } = make();
481+
const r = await dispatcher.handlePackages(verb.path(CODE_LOADED), verb.method, {}, {}, admin());
482+
const message = String(r.response?.body?.error?.message ?? '');
483+
484+
expect(r.response?.body?.error?.code).toBe('WRITABLE_PACKAGE_REQUIRED');
485+
// It used to read "…or duplicate this one into a writable base
486+
// (POST /packages/<id>/duplicate) and change that" — a remedy that,
487+
// for exactly the packages this refusal fires on, now answers 422
488+
// and before that answered an empty 200. A refusal that prescribes a
489+
// dead end is worse than one that prescribes nothing.
490+
expect(message).not.toContain('/duplicate');
491+
expect(message).toContain('ADR-0005');
492+
});
493+
}
494+
});

0 commit comments

Comments
 (0)