Found while implementing #7092 (removing that file's hand-mirrored appDefaultPermissionSetName). Filing per Prime Directive #10 — not fixed in PR #7252. Unassigned.
Dedup: searched open issues for PermissionDeniedError (0 hits) and for the "structurally identical" wording (0 relevant hits) before filing.
What
packages/runtime/src/security/resolve-execution-context.ts:278 declares its own PermissionDeniedError, and says so:
Kept structurally identical to @objectstack/plugin-security's
PermissionDeniedError so isPermissionDeniedError matches whichever class
instance crosses the boundary, regardless of which package owns the actual
class identity at runtime.
Both the class and the isPermissionDeniedError matcher beside it are duplicated in packages/plugins/plugin-security/src/errors.ts:8. Measured today they are character-identical — same code = 'PERMISSION_DENIED', same statusCode = 403, same name, same details, same matcher body.
Nothing enforces that. No test compares the two declarations, and no check:* gate covers the pair. The ADR-0112 envelope halves (code and status) are therefore declared twice with no mechanism keeping them equal — if one side's statusCode or code moved, both packages would keep compiling and every suite would stay green.
Same family as #7092, one level up: #7092 was a test asserting against a copy of a rule; this is production code holding a copy of a declaration, with the "keep these identical" instruction living only in a comment.
Why observation-class
Nothing a user hits today: the two are identical right now, and isPermissionDeniedError matches on name or code or a message prefix, so it is tolerant enough that a single-field divergence would probably still be caught. Unexercised drift, not a live defect. Grading left to triage.
Adjacent detail, same function
The matcher's third arm is a prose match:
(typeof anyE.message === 'string' && anyE.message.startsWith('[Security] Access denied'))
That makes a specific message prefix load-bearing without declaring it as contract (the #5240 family — one condition, one wording). It is an || arm, so it only widens tolerance and cannot cause a false negative on its own; worth noting because a message rewrite would silently retire it, and nothing would report that.
Note on the obvious fix
@objectstack/plugin-security is already a plain dependencies entry of @objectstack/runtime (this is what made #7092's fix a no-dependency-edge change), so runtime could import the class. But that does not obviously remove the need for the duck-typed matcher: its stated reason is two class identities coexisting at runtime (dual CJS/ESM output, bundling), which an import does not prevent. So the routes differ in kind — re-export and keep the structural matcher, or add a parity gate over the two declarations, or accept the duplication and pin it with a cross-package test. That choice looks like triage's, not a dev's, which is why this is filed rather than fixed.
Refs: #7092, PR #7252, ADR-0112, #5240.
Generated by Claude Code
Found while implementing #7092 (removing that file's hand-mirrored
appDefaultPermissionSetName). Filing per Prime Directive #10 — not fixed in PR #7252. Unassigned.Dedup: searched open issues for
PermissionDeniedError(0 hits) and for the "structurally identical" wording (0 relevant hits) before filing.What
packages/runtime/src/security/resolve-execution-context.ts:278declares its ownPermissionDeniedError, and says so:Both the class and the
isPermissionDeniedErrormatcher beside it are duplicated inpackages/plugins/plugin-security/src/errors.ts:8. Measured today they are character-identical — samecode = 'PERMISSION_DENIED', samestatusCode = 403, samename, samedetails, same matcher body.Nothing enforces that. No test compares the two declarations, and no
check:*gate covers the pair. The ADR-0112 envelope halves (codeandstatus) are therefore declared twice with no mechanism keeping them equal — if one side'sstatusCodeorcodemoved, both packages would keep compiling and every suite would stay green.Same family as #7092, one level up: #7092 was a test asserting against a copy of a rule; this is production code holding a copy of a declaration, with the "keep these identical" instruction living only in a comment.
Why observation-class
Nothing a user hits today: the two are identical right now, and
isPermissionDeniedErrormatches onnameorcodeor a message prefix, so it is tolerant enough that a single-field divergence would probably still be caught. Unexercised drift, not a live defect. Grading left to triage.Adjacent detail, same function
The matcher's third arm is a prose match:
That makes a specific message prefix load-bearing without declaring it as contract (the #5240 family — one condition, one wording). It is an
||arm, so it only widens tolerance and cannot cause a false negative on its own; worth noting because a message rewrite would silently retire it, and nothing would report that.Note on the obvious fix
@objectstack/plugin-securityis already a plaindependenciesentry of@objectstack/runtime(this is what made #7092's fix a no-dependency-edge change), so runtime could import the class. But that does not obviously remove the need for the duck-typed matcher: its stated reason is two class identities coexisting at runtime (dual CJS/ESM output, bundling), which an import does not prevent. So the routes differ in kind — re-export and keep the structural matcher, or add a parity gate over the two declarations, or accept the duplication and pin it with a cross-package test. That choice looks like triage's, not a dev's, which is why this is filed rather than fixed.Refs: #7092, PR #7252, ADR-0112, #5240.
Generated by Claude Code