[Deep Dive #53] Security Scope Runebooks - discussion
Deep Dive #53 treats security as a routing primitive: define a scope in the shell, let component routes request capabilities, and render the decision (allow/deny/fallback) as evidence.
The runebook idea is appealing because it's repeatable: requests, evaluation, recording, redirects, warnings. It's audit-friendly because it lives at the route boundary.
Does your app have a single scope per shell, or do scopes stack per group/segment? How do you represent capabilities in a way that can be rendered and screenshot-debugged (strings, structured ids, versioned scopes)? Do you prefer deny-by-default with fallbacks, or allow-by-default with warnings recorded into the ledger?
Comments (10)
Back to latestThe best part is moving checks out of leaf components. Scattered checks are impossible to migrate.
Related: The Route-First Core Concept.
Rendering allow/deny is surprisingly calming. Users don't feel 'broken app', they feel 'policy'.
But you have to make fallback UX good or it just becomes an error boundary with extra steps.
We used a scope id + last decision marker and it made audit logs line up with UI instantly:
tsx
const ledger = useSecurityLedger('scope:admin');
return <div data-security-scope={ledger.read('scopeId')} data-security-decisions={String(ledger.read('decisions').length)} />;Counterpoint: capability strings can sprawl ('cap:ops:override' becomes 'cap:ops:override:urgent').
I'd want a versioned scope contract so capabilities can be renamed without breaking the whole app.
This deep dive also feels like a continuation of client trust rails: the UI should never pretend it has authority it doesn't have.
Related: Client Trust Rails (Deep Dive #25).
We ended up with stacked scopes: shell scope for identity, group scope for product area, track scope for risky actions.
If everything lives in one scope, it becomes too broad to audit. If it's too granular, it becomes impossible to reason about.
Capability representation we liked: structured ids + a printable label, so the UI can render it without string parsing.
tsx
type Capability = { id: string; label: string; version: string };
export function useCapabilityRequest(scopeId: string, cap: Capability) {
const ledger = useSecurityLedger(scopeId);
ledger.write("requested", [...ledger.read("requested"), cap]);
return ledger.read("granted").some((g) => g.id === cap.id && g.version === cap.version);
}
Rendering label + version in the denial state made support tickets way easier.
Deny-by-default + fallback has been better for us, but only if fallback is treated as a real route posture (not a generic error).
We render a distinct "policy fallback" screen with a clear recovery path.
Related: Security and Safety.
Counterpoint: rendering allow/deny evidence can leak information. If an attacker can see "deny:cap:admin:export", you've told them what exists.
We ended up redacting capability ids in prod and only rendering a scope decision code.
Good call. We landed on: render the decision + a short hash in prod, render full ids in dev/QA.
The key thing is the decision still exists as evidence; the detail level can be posture-dependent.
Also: this becomes *much* easier if you make capability requests part of the route story instead of ad hoc button handlers.
When it's tied to navigation intent, you can reason about it as a transition instead of a random check.