Skip to content

Commit 9bdeed2

Browse files
feat(spec): register PACKAGE_DISABLE_RESTRICTED_BY_VALIDATION_RULE, the package disable door's 409 when an enabled package's validation rule reads a field the package adds (#30775)
<!-- claude-projects-attribution: {"github_login":"os-sales"} --> _Requested by **Sales** · [project thread](https://claude.ai/code/project/chan_01D5bWFY3rFzgJcytD5gD4Uu?thread=cmsg_01D5bWFY3rFzgJcytD5gD4UuV5cRafodM2YQHxeDkdiWJM)_ domain:spec#2 · os-sales · session_01L6bhgVugtCCwBs98FeR27P Fixes #30724 Clause-②: yes (widening) Metadata-protocol: none ## 维护者速读 - 改了什么:错误码账本新增一个 `@objectstack/runtime` 码 `PACKAGE_DISABLE_RESTRICTED_BY_VALIDATION_RULE`。管理员停用一个包时,如果另一个仍启用的包的校验规则读了这个包通过 `objectExtensions` 加的字段,停用会被 `409` 拒绝,并列出包 · 对象 · 规则。 - 为什么改:#30379 的裁决 A(你 2026-11-05 的「同意」)要求停用被拒时带一个新的、登记过的错误码,让设置页、agent 和脚本能按码分支,而不是看到一个通用码。本 PR 只登记这个码;停用门的扫描由 #30379 随后接上。 - 风险与回滚:很小。只加一行账本、两处测试和三页自动生成的参考文档,任何现有错误码与线上行为都不变;回滚即撤 PR。 - 先看:`packages/spec/src/api/error-code-ledger.zod.ts` 中 `PACKAGE_DELETE_PARTIAL` 下方新增的那一行及其注释。 - 两分钟验证:`pnpm --filter @objectstack/spec exec vitest run --project local src/api/error-code-ledger.test.ts src/api/api-error-code-type.test.ts`,预期 2 个测试文件、28 个测试全部通过。 - 需要你做的:无,席位落地。 ## What changes - **One `ERROR_CODE_LEDGER` row** under `@objectstack/runtime`, in its alphabetical place after `PACKAGE_DELETE_PARTIAL`: `PACKAGE_DISABLE_RESTRICTED_BY_VALIDATION_RULE`. The row comment says when it is answered (`409`, `PATCH /packages/:id/disable`), what the refusal names (each package · object · rule triple, one or several) and the remedy (disable or change the reading rule first, or keep the package enabled). It is registered ahead of its producer: the disable door's dependent scan stamps it and asserts the string by value. - **Pins.** `error-code-ledger.test.ts` gains one case: `ErrorCode` accepts the code, the runtime owner key lists it, `REGISTERED_ERROR_CODES` contains it, the synonym detector does not flag it and no waiver names it (with a control leg showing the detector still reads the RESTRICTED token). `api-error-code-type.test.ts` assigns the literal to `ApiError['code']` (judged by `tsc -p tsconfig.test.json`) and parses it through `ApiErrorSchema` (judged by vitest). - **Generated pages.** The three reference pages that print the `ErrorCode` vocabulary (`content/docs/references/api/error-code-ledger.mdx`, `contract.mdx`, `automation-api.mdx`), regenerated by `check:generated --fix`, which proved only `content/docs/references/**` stale. - **One changeset**, `@objectstack/spec: patch`, carrying both declaration lines. ## Verification All readings below are at head `2efc7b56d9` (the branch's two commits plus a merge of `origin/main` `3214615d7a`, after `pnpm install --frozen-lockfile`, a full `pnpm build` at 118/118 and removal of `packages/runtime/.objectstack`). <details><summary>Spec build, generated artifacts, tests, typecheck, lint</summary> ```text pnpm build --concurrency=2 (full, under the verify lock) :: exit 0 (118/118 tasks) pnpm --filter @objectstack/spec check:generated :: exit 0 (all 14 up to date) pnpm --filter @objectstack/spec test :: exit 0 (817/817 files, 25827 tests passed, 1 todo) pnpm --filter @objectstack/spec typecheck :: exit 0 (check:test-typecheck OK; both pin files compiled, 0 errors in them) eslint --no-inline-config on the three changed .ts files :: exit 0 node scripts/check-changeset-no-major.mjs --event (this body's two declaration lines) :: exit 0 LEVEL AXIS: clause-② yes (widening), metadata-protocol none, no entry graded minor or above: a non-protocol change at patch ``` </details> <details><summary>dispatch-gates --commands on this diff (105 commands) and --ran reconciliation</summary> ```text node scripts/check-adr-0087-registration.mjs --base origin/main :: exit 0 node scripts/check-adr-0087-registration.mjs --self-test :: exit 0 node scripts/check-adr-links.mjs :: exit 0 node scripts/check-adr-links.mjs --self-test :: exit 0 node scripts/check-changeset-no-major.mjs --base origin/main :: exit 0 node scripts/check-closing-keyword-parity.mjs :: exit 0 node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0 node scripts/check-comment-mask-adoption.mjs :: exit 0 node scripts/check-comment-mask-adoption.mjs --self-test :: exit 0 node scripts/check-comment-mask-corpus.mjs :: exit 0 node scripts/check-dev-prereqs.mjs --self-test :: exit 0 node scripts/check-doc-route-spelling.mjs --advisory :: exit 0 node scripts/check-doc-route-spelling.mjs --self-test :: exit 0 node scripts/check-docs-section-name.mjs :: exit 0 node scripts/check-docs-section-name.mjs --self-test :: exit 0 node scripts/check-dts-emitted.mjs --self-test :: exit 0 node scripts/check-empty-changeset.mjs --base origin/main :: exit 0 node scripts/check-empty-changeset.mjs --self-test :: exit 0 node scripts/check-issue-citations.mjs :: exit 0 node scripts/check-plugin-teardown-shape.mjs :: exit 0 node scripts/check-plugin-teardown-shape.mjs --self-test :: exit 0 node scripts/check-section-landing-index.mjs :: exit 0 node scripts/check-section-landing-index.mjs --self-test :: exit 0 node scripts/ci/assert-turbo-remote-cache.mjs --self-test :: exit 0 node scripts/ci/plan-test-core-shards.mjs --self-test :: exit 0 node scripts/ci/prune-turbo-cache.mjs --self-test :: exit 0 node scripts/docs-audit/check-affected-docs.mjs :: exit 0 node scripts/docs-audit/check-drift-comment.mjs :: exit 0 node scripts/pm/release-rehearsal-clone.mjs --self-test :: exit 0 node scripts/release-pending-publish.mjs --self-test :: exit 0 pnpm --filter @objectstack/lint run check:doc-formula-expressions :: exit 0 pnpm --filter @objectstack/lint run check:doc-security-posture :: exit 0 pnpm --filter @objectstack/spec run check:api-surface :: exit 0 pnpm --filter @objectstack/spec run check:authorable-surface :: exit 0 pnpm --filter @objectstack/spec run check:browser-reachable-entries :: exit 0 pnpm --filter @objectstack/spec run check:docs :: exit 0 pnpm --filter @objectstack/spec run check:dual-source-exports :: exit 0 pnpm --filter @objectstack/spec run check:duration-unit-keys :: exit 0 pnpm --filter @objectstack/spec run check:empty-state :: exit 0 pnpm --filter @objectstack/spec run check:entry-nameability :: exit 0 pnpm --filter @objectstack/spec run check:error-code-provenance :: exit 0 pnpm --filter @objectstack/spec run check:export-origins :: exit 0 pnpm --filter @objectstack/spec run check:exported-any :: exit 0 pnpm --filter @objectstack/spec run check:generated :: exit 0 pnpm --filter @objectstack/spec run check:liveness :: exit 0 pnpm --filter @objectstack/spec run check:llms-txt :: exit 0 pnpm --filter @objectstack/spec run check:skill-examples :: exit 0 pnpm --filter @objectstack/spec run check:skill-refs :: exit 0 pnpm --filter @objectstack/spec run check:strictness-ledger :: exit 0 pnpm --filter @objectstack/spec run check:variant-docs :: exit 0 pnpm --filter @objectstack/spec run check:yaml-examples :: exit 0 pnpm check:changeset-gate-self-tests :: exit 0 pnpm check:ci-filter-parity :: exit 0 pnpm check:closed-ceremony-text :: exit 0 pnpm check:corpus-claim-drift :: exit 0 pnpm check:cross-package-test-inputs :: exit 0 pnpm check:dispatcher-error-vocabulary :: exit 0 pnpm check:doc-anchors :: exit 0 pnpm check:doc-authoring :: exit 0 pnpm check:doc-frontmatter :: exit 0 pnpm check:docs-audit-scope :: exit 0 pnpm check:docs-redirects :: exit 0 pnpm check:docs-single-h1 :: exit 0 pnpm check:docs-spec-enumerations :: exit 0 pnpm check:docs-transcript-drift :: exit 0 pnpm check:driver-memory-census :: exit 0 pnpm check:dts-closure :: exit 0 pnpm check:dual-build-cjs-loads :: exit 0 pnpm check:engine-double-contract :: exit 0 pnpm check:error-code-casing :: exit 0 pnpm check:error-status-conformance :: exit 0 pnpm check:gitlink-declared :: exit 0 pnpm check:issue-citations :: exit 0 pnpm check:keyed-text-bounds :: exit 0 pnpm check:lean-entry-closure :: exit 0 pnpm check:logger-receiver-detach :: exit 0 pnpm check:merge-driver :: exit 0 pnpm check:nul-bytes :: exit 0 pnpm check:objectql-double-limit :: exit 0 pnpm check:org-identifier :: exit 0 pnpm check:page-declaration-shape :: exit 0 pnpm check:platform-object-tenancy-census :: exit 0 pnpm check:pm-prior-rulings :: exit 0 pnpm check:published-files :: exit 0 pnpm check:published-readme-links :: exit 0 pnpm check:query-options-erasure :: exit 0 pnpm check:react-page-adapter-contract :: exit 0 pnpm check:refd-timer-probe :: exit 0 pnpm check:registry-log-declared :: exit 0 pnpm check:rest-log-spy-declared :: exit 0 pnpm check:role-word :: exit 0 pnpm check:skill-identifier-liveness :: exit 0 pnpm check:slot-lookup :: exit 0 pnpm check:sourcemap-no-sources-content :: exit 0 pnpm check:spec-docblock-symbol-anchors :: exit 0 pnpm check:spec-parsed-alias :: exit 0 pnpm check:system-context-census :: exit 0 pnpm check:test-source-alias :: exit 0 pnpm check:tier-file-adoption :: exit 0 pnpm check:type-check-coverage :: exit 0 pnpm check:type-check-debt :: exit 0 pnpm check:undeclared-dep-imports :: exit 0 pnpm check:vendor-version-stamps :: exit 0 pnpm check:watch-hint-literal :: exit 0 pnpm check:where-matcher :: exit 0 ``` `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --ran ran.list`: exit 0 — "105 derived famil(ies) accounted for — 105 run, 0 NOT-MEASURED (a DERIVED zero — all 105 recorded an exit code and none of them is 3)" Local-only families: `pnpm check:dev-prereqs` :: exit 0 · `pnpm check:skill-frame-freshness` :: exit 0 </details> <details><summary>Ablation (taken on the pre-merge head 48bb8d5, whose ledger file is byte-identical to this head's)</summary> `node scripts/ablation-replace.mjs --file packages/spec/src/api/error-code-ledger.zod.ts --anchor " 'PACKAGE_DISABLE_RESTRICTED_BY_VALIDATION_RULE'," --delete -- …` ```text anchor x1 -> x0, blob 21e349e -> 2acbdf4d1b72 (mutation landed) vitest (the two pin files) :: exit 1 — exactly 2 failures, the two new cases: error-code-ledger.test.ts › accepts the package disable door's dependent-validation-rule refusal code, registered ahead of its runtime producer api-error-code-type.test.ts › every body the type admits parses check:test-typecheck :: exit 1 — src/api/api-error-code-type.test.ts: 1 type error in a file the ledger does not cover restore: blob 21e349e == HEAD blob, git diff HEAD empty, git status clean re-run from the committed state: 2 files, 28/28 tests pass; check:test-typecheck OK ``` </details> ## Acceptance notes - **The name** was proposed by the dev and confirmed by the seat (the claim's Decision of record). It follows three precedents: the `/packages` door's `PACKAGE_VERB_OUTCOME` rows (`PACKAGE_DELETE_PARTIAL`, `PACKAGE_DELETE_FAILED`, `PACKAGE_PUBLISH_FAILED`); the standard catalog's `DELETE_RESTRICTED`, its own word for a lifecycle verb refused over a dependent at the same `409`; and the `_BY_CAUSE` tail of `ORGANIZATION_SLUG_LOCKED_BY_ACTIVE_ENVIRONMENTS`. It names why the disable is refused and the thing the operator acts on (the rule), so it is not a `*_DISABLED` re-spelling, and `VALIDATION_RULE` keeps it apart from the ledger's sharing-rule `RULE_*` rows and from `manifest.dependencies`. - **`patch`, not `minor`.** The dispatch expected `minor` for a new public code. `check-changeset-no-major`'s level axis grades by the metadata-protocol kind alone, and a declared `Metadata-protocol: none` must grade every entry `patch`; driven offline with this body's two lines, it passes at `patch`. The seat accepted the deviation. `Clause-②: yes (widening)` stays: registering a code widens the published face. - **The triage note on this card says the code is "already served". It is not.** On `origin/main` the disable branch of `packages/runtime/src/domains/packages.ts` runs no dependent scan and stamps no such code, and the spelling has no hit outside `packages/spec` and the generated pages. That is why the row is registered ahead of its producer. - **Serial follower: #30379** (`domain:server#4`). Its disable-door scan in `packages/runtime/src/domains/packages.ts` answers this code once this lands. Provenance is already satisfied (the stamp will sit under `@objectstack/runtime`, which lists the code). If that PR also publishes a `409` status for the code in `content/docs/api/error-catalog.mdx`, that page's guard then wants a heading for it. - **Companion tables checked, none owes a row:** `STANDARD_SYNONYM_WAIVERS` (not flagged), `PROVENANCE_WAIVERS` and `check:error-code-provenance` (no stamp site yet), `HttpStatusErrorCodeMap` / `HTTP_REASON_PHRASE_STATUS` (standard catalog only), `retired-error-codes.ts` (no collision), `content/docs/api/error-catalog.mdx` (its guard covers codes a doc publishes a status for), the dispatcher vocabulary's `UNREGISTERED_CODE_SITES` (no pending row), the disable row in `packages/runtime/src/route-ledger.ts` (lists no codes) and `check-error-code-casing.mjs` (no table). - No GitHub MCP call was made; the PR and the report comment went through the `scripts/pm/` relay. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01L6bhgVugtCCwBs98FeR27P --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8ddb6b7 commit 9bdeed2

7 files changed

Lines changed: 59 additions & 3 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
feat(spec): register `PACKAGE_DISABLE_RESTRICTED_BY_VALIDATION_RULE`, the code the package disable door answers when an enabled package's validation rule reads a field the package adds
6+
7+
Clause-②: yes (widening)
8+
Metadata-protocol: none
9+
10+
The error-code ledger (`ERROR_CODE_LEDGER`, and so `ErrorCode`) gains one `@objectstack/runtime` code. `PATCH /api/v1/packages/:id/disable` will answer it with `409` when a validation rule of a package that stays enabled (any severity, an app's `.custom` pair included) reads a field the package being disabled adds through `objectExtensions`. Disabling the package would withhold that field, and every write to the object would then fail that rule. The refusal names each package, object and rule it found, and there can be several. To go ahead, disable or change the reading rule first, or keep the package enabled.
11+
12+
Registration only: the disable door does not answer this code yet. It starts to when the door's dependent scan lands. No existing code changes, and no standard-catalog member is shadowed, so no synonym waiver is needed.

‎content/docs/references/api/automation-api.mdx‎

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

418418
| Property | Type | Required | Description |
419419
| :--- | :--- | :--- | :--- |
420-
| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +360 more>` | ✅ | The inner refusal's code in its WIRE spelling - the ADR-0112 member a record save of the same write answers in `error.code` (e.g. `UNIQUE_VIOLATION`); the closed set `ApiErrorSchema.code` parses against |
420+
| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +361 more>` | ✅ | The inner refusal's code in its WIRE spelling - the ADR-0112 member a record save of the same write answers in `error.code` (e.g. `UNIQUE_VIOLATION`); the closed set `ApiErrorSchema.code` parses against |
421421
| **message** | `string` | ✅ | The inner refusal's own diagnostic text, verbatim, without the node and operation prefixes the run's failure text adds |
422422

423423
### Allowed Values: `FlowInnerRefusal.code`
@@ -671,6 +671,7 @@ const result = AutomationApiErrorCode.parse(data);
671671
* `OS_PROTOCOL_INCOMPATIBLE`
672672
* `PACKAGE_DELETE_FAILED`
673673
* `PACKAGE_DELETE_PARTIAL`
674+
* `PACKAGE_DISABLE_RESTRICTED_BY_VALIDATION_RULE`
674675
* `PACKAGE_MANIFEST_INVALID`
675676
* `PACKAGE_PUBLISH_FAILED`
676677
* `PASSWORD_ALREADY_SET`

‎content/docs/references/api/contract.mdx‎

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

2929
| Property | Type | Required | Description |
3030
| :--- | :--- | :--- | :--- |
31-
| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +360 more>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) |
31+
| **code** | `Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| 'INVALID_FORMAT' \| 'VALUE_TOO_LONG' \| 'VALUE_TOO_SHORT' \| 'VALUE_OUT_OF_RANGE' \| … +361 more>` | ✅ | Error code (e.g. VALIDATION_ERROR; StandardErrorCode ∪ the ledger the serving side registers — ERROR_CODE_LEDGER for framework packages) |
3232
| **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) |
3333
| **message** | `string` | ✅ | Readable error message |
3434
| **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`. |
@@ -289,6 +289,7 @@ const result = ApiErrorSchema.parse(data);
289289
* `OS_PROTOCOL_INCOMPATIBLE`
290290
* `PACKAGE_DELETE_FAILED`
291291
* `PACKAGE_DELETE_PARTIAL`
292+
* `PACKAGE_DISABLE_RESTRICTED_BY_VALIDATION_RULE`
292293
* `PACKAGE_MANIFEST_INVALID`
293294
* `PACKAGE_PUBLISH_FAILED`
294295
* `PASSWORD_ALREADY_SET`

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -456,6 +456,7 @@ const result = ErrorCode.parse(data);
456456
* `OS_PROTOCOL_INCOMPATIBLE`
457457
* `PACKAGE_DELETE_FAILED`
458458
* `PACKAGE_DELETE_PARTIAL`
459+
* `PACKAGE_DISABLE_RESTRICTED_BY_VALIDATION_RULE`
459460
* `PACKAGE_MANIFEST_INVALID`
460461
* `PACKAGE_PUBLISH_FAILED`
461462
* `PASSWORD_ALREADY_SET`

‎packages/spec/src/api/api-error-code-type.test.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,13 +41,15 @@ const codeIsTyped: [
4141
] = [false, false, false];
4242
const standardCode: ApiError = { code: 'VALIDATION_ERROR', message: 'x' };
4343
const registeredCode: ApiError['code'] = 'INVALID_ARTIFACT_PACKAGES';
44+
// The package disable door's 409 for a dependent validation rule: a ledger row the type accepts.
45+
const disableRefusalCode: ApiError['code'] = 'PACKAGE_DISABLE_RESTRICTED_BY_VALIDATION_RULE';
4446
// @ts-expect-error -- `code` is a vocabulary member, not a number.
4547
const numericCode: ApiError = { code: 42, message: 'x' };
4648
// @ts-expect-error -- nor a string outside the vocabulary.
4749
const inventedCode: ApiError = { code: 'NOT_A_REGISTERED_CODE', message: 'x' };
4850
// @ts-expect-error -- the same `code` reaches every envelope built on `BaseResponseSchema`.
4951
const envelopeNumericCode: BaseResponse = { success: false, error: { code: 42, message: 'x' } };
50-
void [codeIsTyped, standardCode, registeredCode, numericCode, inventedCode, envelopeNumericCode];
52+
void [codeIsTyped, standardCode, registeredCode, disableRefusalCode, numericCode, inventedCode, envelopeNumericCode];
5153

5254
// ── `makeApiErrorSchema`: standard catalogue ∪ the caller's codes ─────────────────────────────
5355

@@ -87,6 +89,7 @@ describe('ApiError.code is typed as the vocabulary its schema parses against', (
8789
it('every body the type admits parses', () => {
8890
expect(ApiErrorSchema.safeParse(standardCode).success).toBe(true);
8991
expect(ApiErrorSchema.safeParse({ code: registeredCode, message: 'x' }).success).toBe(true);
92+
expect(ApiErrorSchema.safeParse({ code: disableRefusalCode, message: 'x' }).success).toBe(true);
9093
expect(AcmeApiErrorSchema.safeParse(acmeSupplied).success).toBe(true);
9194
expect(AcmeApiErrorSchema.safeParse(acmeStandard).success).toBe(true);
9295
});

‎packages/spec/src/api/error-code-ledger.test.ts‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,24 @@ describe('ErrorCode (standard ∪ registered)', () => {
290290
expect(STANDARD_SYNONYM_WAIVERS.map((w) => w.code)).not.toContain(code);
291291
});
292292

293+
it('accepts the package disable door\'s dependent-validation-rule refusal code, registered ahead of its runtime producer', () => {
294+
// ADR-0141 Open item 4, ruling A on #30379: the disable door answers 409
295+
// while an enabled package's validation rule reads a field the package
296+
// adds through `objectExtensions`. The door's scan (#30379) stamps it and
297+
// asserts this exact string by value — pinned here too.
298+
const code = 'PACKAGE_DISABLE_RESTRICTED_BY_VALIDATION_RULE';
299+
expect(ErrorCode.parse(code)).toBe(code);
300+
expect(ERROR_CODE_LEDGER['@objectstack/runtime'], `${code} registered under runtime`).toContain(code);
301+
expect(REGISTERED_ERROR_CODES).toContain(code);
302+
// PACKAGE and DISABLE are tokens no standard member carries: not a synonym
303+
// of DELETE_RESTRICTED or RESOURCE_CONFLICT, and no waiver.
304+
expect(standardSynonymOf(code), `${code} needs no waiver`).toBeUndefined();
305+
expect(STANDARD_SYNONYM_WAIVERS.map((w) => w.code)).not.toContain(code);
306+
// Control leg: the detector still sees the RESTRICTED token, so the
307+
// `undefined` above is a verdict, not a detector that went blind.
308+
expect(standardSynonymOf('RESTRICTED_DELETE')).toBe('DELETE_RESTRICTED');
309+
});
310+
293311
it('accepts the nine-code batch — every code that ships in dist, door or no door (ruled: the ledger is the published face)', () => {
294312
// The #16404 ruling (director seat, decision batch #62, 2026-09-07, option
295313
// D): the published face is this ledger, so a code that ships in `dist` is

‎packages/spec/src/api/error-code-ledger.zod.ts‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -493,6 +493,26 @@ export const ERROR_CODE_LEDGER = {
493493
// direct-mount door of the same route; the two doors now state the same
494494
// failure the same way. Provenance, not identity (see above).
495495
'PACKAGE_DELETE_PARTIAL',
496+
// [ADR-0141 Open item 4, ruling A on #30379] 409 from the package disable
497+
// door (`PATCH /packages/:id/disable`, `domains/packages.ts`): the disable
498+
// is refused because a validation rule of a package that stays enabled
499+
// (any severity; an app's `.custom` pair, where tenant-written rules live,
500+
// included) reads a field this package adds through `objectExtensions`.
501+
// Disabling it would withhold that field, and every write to the object
502+
// would then fail that rule. The refusal names each package · object ·
503+
// rule triple it found, one or several. The remedy: disable or change the
504+
// reading rule first, or keep this package enabled. A formula over the
505+
// field is no reason to refuse: it answers `null`. Registered AHEAD of its
506+
// producer (the `ACTION_CONFIRMATION_REQUIRED` split shape): the door's
507+
// scan is #30379's and asserts this string by value. ⛔ NOT a `*_DISABLED`
508+
// re-spelling: those name WHICH thing is off, and this names WHY a disable
509+
// is refused, and the rule it names is what the operator acts on. Nor a
510+
// mirror of `DEPENDENCY_MISSING`: no `manifest.dependencies` entry is
511+
// read, so "RESTRICTED" (the standard `DELETE_RESTRICTED` word for a 409
512+
// refused over a dependent) carries `BY_VALIDATION_RULE` to say which
513+
// dependent. PACKAGE and DISABLE are tokens no standard member carries, so
514+
// it needs no waiver.
515+
'PACKAGE_DISABLE_RESTRICTED_BY_VALIDATION_RULE',
496516
'PROJECT_MEMBERSHIP_REQUIRED',
497517
'RECORD_GONE', // share link resolves but the record was deleted
498518
'ROUTE_NOT_FOUND',

0 commit comments

Comments
 (0)