Repository navigation
Description
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
PermissionDeniedErrorsoisPermissionDeniedErrormatches 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
Activity
os-help commentedon Aug 10, 2026
Claim — PM loop
domain:cli, sessionsession_0158ZQo7LiHSxGWpYKuPq1wu(os-help seat, #6024), wave 2 round 6.Route ruling (PM, under the escalation bar — veto window open). The card left the route to triage; triage queued it without picking one. Ruling: the dev measures route (a) first — delete runtime's local
PermissionDeniedError+isPermissionDeniedErrorinpackages/runtime/src/security/resolve-execution-context.tsand import both from@objectstack/plugin-security(already a plain dependency; the matcher stays duck-typed, so dual class identity remains handled). If (a) hits a real constraint — an import cycle, a bundling regression, a type break — fall back to route (c): keep the duplication and pin it with a cross-package test. Either way the deliverable includes a cross-recognition test: each package'sisPermissionDeniedErrormust match an instance constructed from the other package's class, plus field parity (name,code,statusCode, 403). Basis: the #7092 family precedent is "delete the copy where possible, pin it where not"; both routes are additive/reversible and neither forecloses the other, so this fails the needs-user-decision bar.claude/issue-7270-permission-denied-error-paritypackages/runtime/src/security/resolve-execution-context.ts,packages/plugins/plugin-security/src/errors.ts(read; edit only if exports need widening) + tests. No open PR touches these.claude-opus-5·mode:cloud(own container).Generated by Claude Code
os-help commentedon Aug 10, 2026
ACCEPT — PR #7462, head
e775a58bb. Route (a), as ruled at claim time.Reviewed on the diff and commit message rather than a report comment — the dev's container had git push but no GitHub API access.
@objectstack/plugin-securityowns the single declaration andresolve-execution-context.tsre-exports it. No dependency edge added (it was already a plaindependenciesentry), and tsup externalizes workspace deps — the bundle gained one import and lost the duplicated class (ESM 428.21 → 428.02 KB), which is the measurement that shows the copy is actually gone rather than merely shadowed.http-dispatcher.tsimportsisPermissionDeniedErrorfrom it;security/index.tsnever re-exported either, so the public barrel is unaffected.'[Security] Access denied'prefix arm: the arm is gone from this file only because the whole matcher moved upstream, where it is unchanged and still duck-typed (name/code/ prefix, neverinstanceof). That duck-typing is what makes the re-export safe under dual CJS/ESM output — verified by reading the diff, not the summary.success, TypeScript Type Checksuccess, Check Changesetsuccess, Test Core and Dogfood shardssuccess.The route ruling's veto window stays open until merge; the adjacent prose-match observation in the card body is untouched by this PR and remains on record.
Ready flipped, auto-merge armed, queue-added notice received.
Generated by Claude Code
refactor(runtime): `PermissionDeniedError` has ONE declaration again (o…