Skip to content

Commit 8f6d831

Browse files
huangyiireneclaude
andauthored
fix(plugin-security): the sys_permission_set duplicate-name refusal carries UNIQUE_VIOLATION, and the packaged-set lock answers first (#19437)
Fixes #19307 Clause-②: yes The data door's duplicate-name refusal on `sys_permission_set` had two halves, both reproduced on today's head (`c33707933`) before any edit and re-measured after, on `examples/app-showcase` with a seeded admin over a cookie session. ## The two halves **1. No machine-readable `code`.** The insert leg threw a bare `Error` carrying `.status = 409` and no `.code`. The flat `{ error, code }` responder invents nothing for a producer that declared nothing, so the caller got prose — against ADR-0112's 2026-08-17 amendment (#9232), under which the flat door carries the closed member too. **2. ⭐ It ran BEFORE `assertPermissionSetNotPackageDeclared`.** A package-declared set has a projected row, so its name is duplicate AND locked at once. The admin who opens the Clone dialog on a packaged set and types the base set's own name — the single most likely thing to type — got `already exists`, which names no remedy, and never reached `NOT_OVERRIDABLE`, which names the clone path. ## Live readings — five legs, same script, before and after Script: `POST /api/v1/data/sys_permission_set` as the seeded admin; full text in the report comment on #19307. | leg | BEFORE (`c33707933`) | AFTER (this branch, built) | |:--|:--|:--| | 1. duplicate name = **package-declared** `showcase_manager` | `409` · `{"error":"[Security] permission set 'showcase_manager' already exists","object":"sys_permission_set"}` — **no `code` key** | `403` · `"code":"NOT_OVERRIDABLE"`, message: *"…Choose a different name for your set, or clone 'showcase_manager' (the "Clone" action…)"* | | 2. create a NON-packaged set | `201` | `201` (unchanged) | | 3. ⚖️ negative control — duplicate of that NON-packaged set | `409` · no `code` | `409` · `"code":"UNIQUE_VIOLATION"`, message byte-identical | | 4. ⚖️ contrast control — unauthenticated `PATCH`, same resource | `401` · `"code":"UNAUTHENTICATED"` | `401` · `"code":"UNAUTHENTICATED"` (unchanged) | | 5. ⚖️ contrast control — `PATCH` the packaged set (non-duplicate route) | `403` · `"code":"NOT_OVERRIDABLE"` | unchanged | Legs 4 and 5 are what make legs 1 and 3 readings rather than constants: the flat door already varied its `code` by path, so the absence was this producer's and never the door's. ## Why `UNIQUE_VIOLATION`, reused and not minted `sys_permission_set` declares `{ fields: ['name'], unique: 'organization' }`, and the reading recorded on that index itself (#8554) is `org_yi 409 UNIQUE_VIOLATION`. So the platform **already** answers this exact collision with this exact envelope whenever the index catches it instead of the projection's pre-check. A second spelling here would make one condition answer two envelopes depending only on which layer got there first — the drift `@objectstack/rest` and `@objectstack/driver-memory` deliberately registered the SAME code to avoid. `RESOURCE_CONFLICT` (the standard member 409 derives from) is what the door supplies for a producer that named no condition; using it would be that second spelling. ## The `packages/spec` touch is one provenance row `check:error-code-provenance` recognises `objlit`, `assign` and `*_CODE` `constdef` stamp sites and states in its own bounds line that it is **blind to class fields**. Written as a class-field literal, this package would have become an unlisted EMITTER of a registered code with every gate in the repo green — the invisibility the ledger header names ("no admission rule checks WHO emits"), found by hand three times already (#7504 / #13254 / #13353). So the code is stamped through an exported `PERMISSION_SET_NAME_CONFLICT_CODE`, which puts the emitter inside the gate's field of view, and the ledger gains the matching row under `@objectstack/plugin-security`. Measured red then green, in that order: - class-field spelling, no ledger row: gate **exit 0** — it never saw the stamp; - `*_CODE` constant, no ledger row: gate **exit 1** — *"@objectstack/plugin-security stamps 'UNIQUE_VIOLATION' (constdef) at packages/plugins/plugin-security/src/errors.ts:372 — not listed under its own owner key"*; - `*_CODE` constant + the row: gate **exit 0**, 339 stamp sites, 322 listed. **It is provenance, not identity, and that is measured rather than asserted.** The deduped ledger union is **282 distinct codes before and 282 after, added [] / removed []** — `UNIQUE_VIOLATION` was already a member under two other packages. `pnpm --filter @objectstack/spec check:generated` reports all 15 generated artifacts up to date, which supports ONE claim only — no regeneration is MISSING. ⛔ It does NOT show the published TYPE is unchanged, and here it is not: `ERROR_CODE_LEDGER` is exported `as const satisfies`, so this row moves `typeof ERROR_CODE_LEDGER['@objectstack/plugin-security']` from a 3-tuple to a 4-tuple. `check:api-surface` stayed GREEN THROUGH that change, because `api-surface/api.json` records the symbol name (`ERROR_CODE_LEDGER (const)`) and no signature, `api-surface-signatures.json` covers 27 `define*` helpers and not this const, and `api-surface-declarations/` was withdrawn by #19024 and does not exist on this base. That is why this PR declares `Clause-②: yes` and grades `@objectstack/spec` `minor`. ⚠️ `check-widening-tells` still fires **T4** on that line with `Clause-②: no` (exit 4), because the matcher cannot see the dedupe — it reads any new registry entry as an acceptance-set growth. The measurement above is the evidence it is false here. Reported for the owning seat rather than repaired in this PR; the declaration line is copied verbatim from the claim and is not mine to move. ## One corner moved with the order, declared not incidental An ordinary duplicate attempted while **no artifact source can answer** now takes the lock's fail-closed `unknown` refusal — `403` `NOT_OVERRIDABLE` (`PackagedPermissionSetProvenanceUnknownError`, "retry once the metadata layer is readable") — instead of the 409. Both are refusals and neither writes; case 5 of the new suite pins it so a reader finds a decision rather than an accident. ## Tests `packages/plugins/plugin-security/src/permission-set-duplicate-name-refusal.test.ts` — five cases: the ordinary duplicate's envelope (`code` + `status` + both status spellings, never a bare "it threw" — the unfixed producer threw too), the packaged-set case answering the **lock** (proved by the lock's own message and by `saves.length === 0`, since ADR-0005's tier gate answers the same code with a different message), the negative control that ordinary duplicates WHOSE PROVENANCE RESOLVES did not become `NOT_OVERRIDABLE` (the corner above is the case that qualifier excludes), the happy path, and the fail-closed corner. **Reverse verification — two ablations, each proved on disk and restored byte-identical to `HEAD`:** - **A — put the guard back behind the duplicate check.** Mutation landed (anchor 1 → 0, marker 0 → 1, blob `fed1af15857f` → `3d92cb95990f`); suite **2 failed / 3 passed** — cases 2 and 5, the two ordering-dependent ones, and *only* those. Restored: `git diff HEAD` empty, blob back to `fed1af15857f`. - **B — drop the `code` stamp.** Mutation landed (target 1 → 0, marker 0 → 1); suite **2 failed / 3 passed** — cases 1 and 3, `expected undefined to be 'UNIQUE_VIOLATION'`. Restored: blob back to `7b0914b1749a`. Both legs ran under a `trap ... EXIT INT TERM` with absolute paths, restored through `git checkout HEAD -- path`, and verified by `git hash-object` against the `HEAD` blob rather than by an exit code. ## Verification Run at final head `72d68b905` unless stated. - `pnpm --filter @objectstack/plugin-security test` — **116 files / 2240 tests passed**; `typecheck` OK (test layer 0 files / 0 errors). - `pnpm --filter @objectstack/spec test` — **505 files / 14752 tests passed**; `typecheck` OK; `check:generated` — all 15 artifacts up to date. - `pnpm lint` — the **whole repo**, `eslint . --no-inline-config`, **exit 0**. Not a narrowed run, so no narrowing needs defending. - `scripts/pm/dispatch-gates.mjs --ran` — **98 derived families, 98 run, 0 NOT-MEASURED, 0 UNRUN**, each with a recorded exit code, all 0. Two of them were genuinely red mid-flight and are green only because the fix landed: `check:engine-double-contract` (the new double's `update` now opens with `assertEngineUpdateDispatch`, and its pinned seams are registered) and `check:objectql-double-limit` (the `find` double now holds the caller's bound by presence). - Control-character self-scan over every touched file: clean. Builds and test runs went through `scripts/pm/os-verify-lock.sh`. --- _Generated by [Claude Code](https://claude.ai/code/session_01AhQASwqJr2Z7XfGWUdvnbF)_ --- _Generated by [Claude Code](https://claude.ai/code)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 097d268 commit 8f6d831

6 files changed

Lines changed: 496 additions & 6 deletions

File tree

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
---
2+
'@objectstack/plugin-security': patch
3+
'@objectstack/spec': minor
4+
---
5+
6+
fix(plugin-security): the `sys_permission_set` duplicate-name refusal carries `UNIQUE_VIOLATION`, and the packaged-set lock answers first (#19307)
7+
8+
Clause-②: yes
9+
10+
Two halves of one defect on the data door's insert leg for `sys_permission_set`
11+
(`permission-set-projection.ts`), both measured live on `examples/app-showcase`
12+
with a seeded admin over a cookie session.
13+
14+
**1. The refusal carried no machine-readable code.** It threw a bare `Error`
15+
with `.status = 409` and no `.code`, and the flat `{ error, code }` responder
16+
invents nothing for a producer that declared nothing, so the client got prose:
17+
18+
```
19+
POST /api/v1/data/sys_permission_set {"name":"dev_local_set"}
20+
→ 409 {"error":"[Security] permission set 'dev_local_set' already exists","object":"sys_permission_set"}
21+
```
22+
23+
ADR-0112's 2026-08-17 amendment closed `error.code` at the flat door too, so a
24+
409 with no code is that contract unhonoured — and a UI that has to branch on
25+
the refusal was pushed back to string-matching. The same request now answers
26+
`409 … "code":"UNIQUE_VIOLATION"`, message byte-identical.
27+
28+
⚠️ `UNIQUE_VIOLATION` is REUSED, not minted. `sys_permission_set` declares
29+
`{ fields: ['name'], unique: 'organization' }`, so this very collision already
30+
answers `409 UNIQUE_VIOLATION` when the index catches it instead of this
31+
pre-check; a second spelling would make one condition answer two envelopes
32+
depending only on which layer got there first. The ledger gains a provenance
33+
row for `@objectstack/plugin-security` — the union, its casing and every other
34+
package's rows are unchanged, and no schema shape moves.
35+
36+
**2. It ran BEFORE the packaged-set lock, so the most likely path answered the
37+
less useful of two true refusals.** A package-declared set has a projected row,
38+
so its name is duplicate AND locked at once. An admin who opened the Clone
39+
dialog on a packaged set and typed the base set's own name — the single most
40+
likely thing to type — got `already exists`, which names no remedy, and never
41+
reached `NOT_OVERRIDABLE`, which names the clone path. The lock now runs first:
42+
43+
```
44+
POST /api/v1/data/sys_permission_set {"name":"showcase_manager"}
45+
→ 403 {"error":"[Security] Permission set 'showcase_manager' is declared by package
46+
'com.example.showcase' and is locked … Choose a different name for your set, or clone
47+
'showcase_manager' …","code":"NOT_OVERRIDABLE","object":"sys_permission_set"}
48+
```
49+
50+
**What did NOT move**, measured on the same runtime: an ordinary
51+
(non-package-declared) duplicate **whose provenance the lock can resolve** still
52+
answers the duplicate refusal and not `NOT_OVERRIDABLE` — that qualifier is
53+
load-bearing, and the corner below is the case it excludes; an unauthenticated
54+
write on the same resource still answers `401 UNAUTHENTICATED`; and an `update`
55+
targeting a packaged set answers `403 NOT_OVERRIDABLE` exactly as before.
56+
57+
⚠️ **One corner moved with the order**: an ordinary duplicate attempted while no
58+
artifact source can answer now takes the lock's fail-closed `unknown` refusal —
59+
`403` `NOT_OVERRIDABLE` (`PackagedPermissionSetProvenanceUnknownError`, "retry
60+
once the metadata layer is readable") — instead of the 409. Both are refusals and
61+
neither writes; it is pinned so the behaviour is declared rather than incidental.
62+
63+
⚠️ **And the order has a cost, stated rather than discovered**: the lock's probe
64+
(`protocol.getMetaItemLayered`) used to be evaluated only AFTER the duplicate
65+
check passed, so a duplicate insert never paid for it. It is now evaluated
66+
unconditionally, ahead of that check. Two consequences, both deliberate: every
67+
**duplicate** insert on `sys_permission_set` costs one extra metadata round trip
68+
(the accepted path's cost is unchanged — it always paid this probe), and the
69+
duplicate path is now COUPLED to metadata-layer reachability, where before it
70+
answered from the record alone. That coupling is the mechanism behind the corner
71+
above, and it is the price of putting the refusal that names the remedy first.

‎packages/plugins/plugin-security/src/errors.ts‎

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -364,6 +364,90 @@ export class ExplainObjectNotFoundError extends Error {
364364
}
365365
}
366366

367+
/**
368+
* The ADR-0112 code {@link PermissionSetNameConflictError} stamps — see that
369+
* class for why this collision is `UNIQUE_VIOLATION` and why the value is a
370+
* named constant rather than a class-field literal.
371+
*/
372+
export const PERMISSION_SET_NAME_CONFLICT_CODE = 'UNIQUE_VIOLATION';
373+
374+
/** The HTTP status {@link PermissionSetNameConflictError} declares. */
375+
export const PERMISSION_SET_NAME_CONFLICT_STATUS = 409;
376+
377+
/**
378+
* [#19307] The data door's duplicate-name refusal on `sys_permission_set`:
379+
* a set with this machine name already exists in the caller's organization, so
380+
* the insert is refused.
381+
*
382+
* ## Why this is a CLASS and not a bare `Error` with `.status = 409`
383+
*
384+
* It was the bare form until now, and the bare form has no `code`. The flat
385+
* `{ error, code }` responder in `packages/rest` puts a thrown `code` on the
386+
* wire and invents nothing when the producer declared none, so the refusal
387+
* reached the client as prose alone — against ADR-0112's 2026-08-17 amendment
388+
* (#9232), under which the flat door carries the closed member too. Measured
389+
* before the fix: `409 {"error":"[Security] permission set 'showcase_manager'
390+
* already exists","object":"sys_permission_set"}`, with no `code` key at all,
391+
* while an unauthenticated write on the same resource answered
392+
* `401 UNAUTHENTICATED` — so the absence was this producer's, never the door's.
393+
* A dialog that has to branch on the refusal was pushed to string-matching.
394+
*
395+
* ## Why `UNIQUE_VIOLATION` and not a newly minted code
396+
*
397+
* It is the wire identity this platform ALREADY answers for this exact
398+
* condition on this exact column. `sys_permission_set` declares
399+
* `{ fields: ['name'], unique: 'organization' }`, and a collision that reaches
400+
* the storage layer comes back as `409 UNIQUE_VIOLATION` — the reading
401+
* recorded on that index's own comment (#8554) is `org_yi 409
402+
* UNIQUE_VIOLATION`. This middleware refuses the same collision one layer
403+
* earlier, so a second spelling here would make ONE condition answer two
404+
* envelopes depending only on whether the projection's pre-check or the index
405+
* caught it — the drift `@objectstack/rest` and `@objectstack/driver-memory`
406+
* already registered the SAME code to avoid ("the wire identity is
407+
* deliberately the SAME"). #5240's one-condition-one-wording, on the code axis.
408+
*
409+
* ⛔ Not `RESOURCE_CONFLICT` (the standard member 409 derives from): that is
410+
* what the door would supply for a producer that named no condition, and it
411+
* would be the second spelling described above.
412+
*
413+
* ## Why BOTH `status` and `statusCode`
414+
*
415+
* The same reason every class above records: the two transports read different
416+
* property names (`mapDataError` passes a domain error through on `.status`;
417+
* the runtime dispatcher's `errorFromThrown` reads `.status` then falls back to
418+
* `.statusCode`), and this throws on the DATA path, which reaches both.
419+
*
420+
* The message is byte-identical to the bare `Error`'s — the wording was never
421+
* the defect, and the flat door's 4xx arm ships it verbatim.
422+
*
423+
* ## Why the code is a NAMED CONSTANT and not a bare class-field literal
424+
*
425+
* Same spelling `@objectstack/driver-memory` uses for its own registration of
426+
* this code ("via the package's exported `UNIQUE_VIOLATION_CODE` /
427+
* `UNIQUE_VIOLATION_STATUS`"), and the reason is mechanical rather than
428+
* stylistic: `check:error-code-provenance` recognises `objlit`, `assign` and
429+
* `*_CODE` `constdef` stamp sites and is blind to class fields by its own
430+
* declared bounds. Written as a class-field literal this package would have
431+
* become an unlisted EMITTER of a registered code with every gate in the repo
432+
* green — the exact invisibility the ledger header names ("no admission rule
433+
* checks WHO emits, so an unlisted emitter is invisible to every gate the repo
434+
* has", three hand sweeps, #7504 / #13254 / #13353). The constant puts this
435+
* emitter inside the gate's field of view, so the provenance row under
436+
* `@objectstack/plugin-security` is enforced and not merely intended.
437+
*/
438+
export class PermissionSetNameConflictError extends Error {
439+
readonly code = PERMISSION_SET_NAME_CONFLICT_CODE;
440+
readonly status = PERMISSION_SET_NAME_CONFLICT_STATUS;
441+
readonly statusCode = PERMISSION_SET_NAME_CONFLICT_STATUS;
442+
/** The permission-set machine name that was already taken. */
443+
readonly setName: string;
444+
constructor(setName: string) {
445+
super(`[Security] permission set '${setName}' already exists`);
446+
this.name = 'PermissionSetNameConflictError';
447+
this.setName = setName;
448+
}
449+
}
450+
367451
export function isPermissionDeniedError(e: unknown): e is PermissionDeniedError {
368452
if (!e || typeof e !== 'object') return false;
369453
const anyE = e as any;

0 commit comments

Comments
 (0)