Skip to content

Commit 3f9e2ea

Browse files
fix(spec): list plugin-security's class-field error codes under its own ledger key (#19782)
Fixes #19441 Clause-②: yes ## What changed Two halves, in two packages, landed together (the triage rider: the registration and the comment are not interchangeable). 1. **`packages/spec` ledger** — `ERROR_CODE_LEDGER['@objectstack/plugin-security']` gains three provenance rows, `INVALID_STATE`, `NOT_FOUND`, `NOT_OVERRIDABLE`, each with a comment naming the throwing class, its status and its door. They cover all four emission points the card listed (`NOT_OVERRIDABLE` is stamped by two classes, one row). Every code was already registered under another package (`@objectstack/rest`, `@objectstack/metadata-protocol`), so the `ErrorCode` union and the wire are unchanged: provenance, not identity. The `NOT_FOUND` entry in `STANDARD_SYNONYM_WAIVERS` keeps its `code`/`shadows`; its `reason` text now names plugin-security among the emitters (the same edit the `FORBIDDEN` waiver got when cloud-connection joined). 2. **`plugin-security`** — the `PackagedPermissionSetLockedError` docblock no longer says "the code is a StandardErrorCode, so no ledger entry is minted". It now says what is true: `NOT_OVERRIDABLE` is a registered extension code, NOT a `StandardErrorCode` member, and this package's own owner key lists it. The two overlay-discard error classes get a one-line pointer to their rows. The plugin-security edits are comments only. ## Premise check (on `origin/main` `041c8cf6`) - The four stamps are at `packaged-permission-set-lock.ts:266`, `:287` and `permission-set-overlay-discard.ts:91`, `:101`, all spelled `readonly code = '...'`. - All four are live. `assertPermissionSetNotPackageDeclared` is called from `permission-set-projection.ts` (three sites) and `packaged-permission-set-lock-gate.ts`. `discardPermissionSetOverlay` is registered by `security-plugin.ts` and served by rest's `POST .../security/permission-sets/:id/discard-overlay`. The classes are exported from `index.ts`. - They ship: after the build, each of the three literals hits 1 file in `packages/plugins/plugin-security/dist/*.js` (control `UNIQUE_VIOLATION`: 1). - None of the three was listed under plugin-security before this PR. - `check:error-code-provenance` declares class fields out of its scan (see its header, "BLIND ... and a class field"), so it was green before this PR and is still green now (exit 0). ## Tests - New pin in `error-code-ledger.test.ts`: `lists the plugin-security class-field stamps under their stamping package`. It checks each code is listed under plugin-security AND is still listed under its first owner, and that it parses as an `ErrorCode`. It also checks `NOT_OVERRIDABLE` is absent from `StandardErrorCode.options`, with a lit control: `PERMISSION_DENIED` is present. - `pnpm --filter @objectstack/spec exec vitest run --maxWorkers=2 src/api/error-code-ledger.test.ts`: 21 passed. - `src/api` (44 files): 1518 passed. - plugin-security `packaged-permission-set-lock*.test.ts` and `permission-set-overlay-discard.test.ts` (3 files): 33 passed. - `typecheck` for `@objectstack/spec` (including scripts-typecheck and test-typecheck) and for `@objectstack/plugin-security`: exit 0. - **Ablation** (one-off, not left in the tree): I deleted the plugin-security `NOT_OVERRIDABLE` row with `scripts/ablation-replace.mjs` (anchor x1 -> x0, marker x0 -> x1) and ran the pin: `1 failed | 20 passed`, failing with `NOT_OVERRIDABLE listed under plugin-security: expected [ 'INVALID_METADATA', ...(5) ] to include 'NOT_OVERRIDABLE'`. After the restore the blob equals HEAD (`d8ad7285`) and `git diff HEAD` is empty. The suite imports the ledger from `src`, so no dist rebuild was involved. My first attempt was a no-op: the replacement text was already inside the anchor, so the tool refused and restored. It is not counted. ## Gates (head `5a1a2efd`) `dispatch-gates.mjs --commands` listed 89 commands; `--ran` reconciled all of them (86 run, 3 NOT MEASURED, 0 unrun). All 86 that ran exited 0, including `check:error-code-provenance`, `check:dispatcher-error-vocabulary`, `check:error-code-casing`, `check:api-surface`, `check:docs`, `check:authorable-surface`, `check:adr-0087-registration`, `check-changeset-no-major` and `check:nul-bytes`. NOT MEASURED, each exit 3 PREREQUISITE (they need a whole-workspace build that was not done locally; left to CI): `check:dual-build-cjs-loads`, `check:i18n`, `check:type-check-debt`. `pnpm lint` (a repo-wide scan) was not run locally; that is CI's run. ## Changeset level The dispatch suggested `patch`. The changeset grades `@objectstack/spec` **minor** instead: AGENTS.md's Post-Task Checklist says `Clause-②: yes` takes at least `minor`, `check-changeset-no-major.mjs`'s level axis enforces that, and the ledger header says registering a code widens the published face. The precedent is #19437 (the prior plugin-security ledger row: spec minor). plugin-security has no changeset entry because its diff is comments only. ## Acceptance notes - The card's other idea, making `check:error-code-provenance` see class-field spellings, is **not** done here. That is a gate-population change and a separate decision. Measurement for it: these four stamps were invisible to the gate because class fields are out of its scan. The new vitest pin holds only these rows; a future class-field stamp in any package would still be invisible to the gate. --- _Generated by [Claude Code](https://claude.ai/code/session_01VWsFyWDp8Rjb2Ma6a3Cyo8)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 55095cc commit 3f9e2ea

5 files changed

Lines changed: 83 additions & 6 deletions

File tree

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
`ERROR_CODE_LEDGER['@objectstack/plugin-security']` now lists the three codes the package stamps as class fields and ships in `dist`: `INVALID_STATE` (`PermissionSetOverlayStateError`, 409), `NOT_FOUND` (`PermissionSetNotFoundError`, 404) and `NOT_OVERRIDABLE` (`PackagedPermissionSetLockedError` and `PackagedPermissionSetProvenanceUnknownError`, 403) (#19441).
6+
7+
Clause-②: yes
8+
9+
Provenance, not identity: each code was already registered under another package (`@objectstack/rest`, `@objectstack/metadata-protocol`), so the `ErrorCode` union, the wire, and every other package's rows are unchanged. What widens is the per-package face a consumer reads from `ERROR_CODE_LEDGER['@objectstack/plugin-security']`. The `NOT_FOUND` synonym waiver's `reason` text now names plugin-security among its emitters; its `code` and `shadows` are unchanged. Nothing to migrate.

‎packages/plugins/plugin-security/src/packaged-permission-set-lock.ts‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -245,8 +245,12 @@ export function classifyPackagedPermissionSet(
245245
*
246246
* `NOT_OVERRIDABLE` / 403 is deliberately the SAME envelope the metadata
247247
* protocol's ADR-0005 tier gate already answers with for this exact condition
248-
* — one condition, one vocabulary (ADR-0112's closed set; the code is a
249-
* StandardErrorCode, so no ledger entry is minted). What changes is the
248+
* — one condition, one vocabulary. The code is NOT a `StandardErrorCode`
249+
* member: it is an ADR-0112 registered extension code, and because this
250+
* package stamps it, `ERROR_CODE_LEDGER['@objectstack/plugin-security']`
251+
* lists it beside metadata-protocol's row (provenance, not identity — a code
252+
* shipped in `dist` is registered under every package that stamps it; the
253+
* same holds for the two overlay-discard codes). What changes is the
250254
* MESSAGE: the producer's says the type has not opted into overlay writes,
251255
* which tells an admin nothing they can act on. The ruling's whole point is
252256
* that the refusal teaches the sanctioned path.

‎packages/plugins/plugin-security/src/permission-set-overlay-discard.ts‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,11 @@ import { readDeclared } from './bootstrap-declared-permissions.js';
8686
import { PermissionDeniedError } from './errors.js';
8787
import { isTenantAdmin } from './delegated-admin-gate.js';
8888

89-
/** Thrown when the referenced `sys_permission_set` row does not exist → HTTP 404. */
89+
/**
90+
* Thrown when the referenced `sys_permission_set` row does not exist → HTTP 404.
91+
* `NOT_FOUND` is listed under `ERROR_CODE_LEDGER['@objectstack/plugin-security']`
92+
* (provenance row — this package stamps it).
93+
*/
9094
export class PermissionSetNotFoundError extends Error {
9195
readonly code = 'NOT_FOUND';
9296
readonly statusCode = 404;
@@ -96,7 +100,11 @@ export class PermissionSetNotFoundError extends Error {
96100
}
97101
}
98102

99-
/** Thrown when there is no active overlay to discard → HTTP 409. */
103+
/**
104+
* Thrown when there is no active overlay to discard → HTTP 409.
105+
* `INVALID_STATE` is listed under `ERROR_CODE_LEDGER['@objectstack/plugin-security']`
106+
* (provenance row — this package stamps it).
107+
*/
100108
export class PermissionSetOverlayStateError extends Error {
101109
readonly code = 'INVALID_STATE';
102110
readonly statusCode = 409;

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

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,31 @@ describe('ErrorCode (standard ∪ registered)', () => {
260260
expect(ERROR_CODE_LEDGER['@objectstack/objectql']).toContain('NAMESPACE_CONFLICT');
261261
});
262262

263+
it('lists the plugin-security class-field stamps under their stamping package (#19441)', () => {
264+
// Provenance, not identity: each of these codes was already registered by
265+
// another package, and `@objectstack/plugin-security` stamps it too, as a
266+
// class field (`readonly code = '…'`) — the spelling the provenance gate
267+
// declares itself blind to, so this suite is what holds the rows.
268+
const stamps: Record<string, keyof typeof ERROR_CODE_LEDGER> = {
269+
INVALID_STATE: '@objectstack/rest', // PermissionSetOverlayStateError, 409
270+
NOT_FOUND: '@objectstack/rest', // PermissionSetNotFoundError, 404
271+
NOT_OVERRIDABLE: '@objectstack/metadata-protocol', // PackagedPermissionSet{Locked,ProvenanceUnknown}Error, 403
272+
};
273+
for (const [code, firstOwner] of Object.entries(stamps)) {
274+
expect(ERROR_CODE_LEDGER['@objectstack/plugin-security'], `${code} listed under plugin-security`)
275+
.toContain(code);
276+
expect(ERROR_CODE_LEDGER[firstOwner], `${code} still listed under ${firstOwner}`).toContain(code);
277+
expect(ErrorCode.parse(code)).toBe(code);
278+
}
279+
// The fact the exempting comment in `packaged-permission-set-lock.ts` had
280+
// backwards: NOT_OVERRIDABLE is an extension code, not a standard member —
281+
// so "no ledger entry is minted" never followed. Control leg: the same
282+
// membership test answers true for a standard member.
283+
const standard = new Set<string>(StandardErrorCode.options);
284+
expect(standard.has('NOT_OVERRIDABLE')).toBe(false);
285+
expect(standard.has('PERMISSION_DENIED')).toBe(true);
286+
});
287+
263288
it('rejects unregistered, lowercase, and numeric codes', () => {
264289
expect(() => ErrorCode.parse('TOTALLY_MADE_UP_CODE')).toThrow();
265290
expect(() => ErrorCode.parse('validation_error')).toThrow(); // pre-ADR-0112 dialect

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

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1090,6 +1090,36 @@ export const ERROR_CODE_LEDGER = {
10901090
// packages is listed once per emitting package — provenance, not identity
10911091
// (see above; #7504).
10921092
'INVALID_METADATA',
1093+
// [#19441] Provenance rows for the class-field stamps
1094+
// (`readonly code = '…'`) this package ships in `dist` — a spelling
1095+
// `check:error-code-provenance` declares itself blind to, which is how
1096+
// they went unlisted. Each code is already registered by another package;
1097+
// per this file's header a code emitted by several packages is listed
1098+
// once per emitting package — provenance, not identity — so the union,
1099+
// its casing and every other package's rows are unchanged.
1100+
//
1101+
// `PermissionSetOverlayStateError` (`permission-set-overlay-discard.ts`),
1102+
// 409: `discardPermissionSetOverlay` found no active overlay to discard.
1103+
// Served by `@objectstack/rest`'s `POST …/security/permission-sets/:id/
1104+
// discard-overlay` route, whose `handleError` reads the thrown `code`
1105+
// ahead of its `INTERNAL` default.
1106+
'INVALID_STATE',
1107+
// `PermissionSetNotFoundError` (`permission-set-overlay-discard.ts`),
1108+
// 404: the addressed `sys_permission_set` row does not exist. Same route
1109+
// and door as the row above. A waived synonym of `RESOURCE_NOT_FOUND`
1110+
// (`STANDARD_SYNONYM_WAIVERS` admits it per code, not per package — this
1111+
// row widens that waiver's emitter list, never the waiver table).
1112+
'NOT_FOUND',
1113+
// `PackagedPermissionSetLockedError` and
1114+
// `PackagedPermissionSetProvenanceUnknownError`
1115+
// (`packaged-permission-set-lock.ts`), 403: a data-path write targets a
1116+
// package-declared permission set, or one whose provenance cannot be
1117+
// determined (fail-closed). Deliberately the SAME envelope
1118+
// `@objectstack/metadata-protocol`'s ADR-0005 tier gate answers for this
1119+
// condition — one condition, one vocabulary. NOT a `StandardErrorCode`
1120+
// member: it is an extension code registered here, under that package and
1121+
// now this one.
1122+
'NOT_OVERRIDABLE',
10931123
'SUGGESTION_NOT_FOUND',
10941124
'SUGGESTION_STATE', // suggestion exists but is not in a confirmable/dismissable state
10951125
// [#19307] The data door's duplicate-name refusal on `sys_permission_set`
@@ -1507,8 +1537,9 @@ export const STANDARD_SYNONYM_WAIVERS: readonly StandardSynonymWaiver[] = [
15071537
{
15081538
code: 'NOT_FOUND',
15091539
shadows: 'RESOURCE_NOT_FOUND',
1510-
reason: 'Pre-gate synonym on the wire from @objectstack/rest and plugin-sharing. ' +
1511-
'Wire value kept; consolidation deferred per #8211.',
1540+
reason: 'Pre-gate synonym on the wire from @objectstack/rest and plugin-sharing; ' +
1541+
'#19441 added the plugin-security provenance row for the same pre-existing wire value ' +
1542+
'(its permission-set overlay-discard 404). Wire value kept; consolidation deferred per #8211.',
15121543
},
15131544
{
15141545
code: 'UNAUTHORIZED',

0 commit comments

Comments
 (0)