Skip to content

@objectstack/runtime re-declares PermissionDeniedError "structurally identical" to plugin-security's, with nothing enforcing the identity #7270

Description

@os-help

Found while implementing #7092 (removing that file's hand-mirrored appDefaultPermissionSetName). Filing per Prime Directive #10not 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

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions