Charter — Design Document
Purpose
Access control that decides which parts of an application exist for a
principal, rather than wrapping the application in checks. In this stack an
ActionDefinition is a complete behavioral unit and pure data, so a granted
catalog is the application. An ungranted action is not hidden or disabled —
it is absent. There is no if (canAccess) to forget.
One sentence: roles select globs over opaque string universes; the engine resolves them to a concrete set per principal, and the verifier refuses an incoherent charter before it ships.
Why actions are the right altitude
Every mainstream framework authorizes at the wrong level:
- Routes (Spring Security, NestJS guards) are addresses, not behaviors.
- Components (frontend RBAC wrappers) hide buttons; hiding is not a security model.
- Rows (RLS) cannot express "this user may not initiate a refund."
A Nova action is the complete contract in one serializable value: what you see
(layout), what you can do (triggers), what it touches (endpoints), what
it accepts (input). Granting at this altitude buys three properties nothing
else has:
- Deny-by-nonexistence. Navigation is push-by-id and the shell throws for unknown ids. A principal's catalog — the definitions their shell ever receives — is their application. Nothing outside it is reachable, renderable, or invokable.
- One gate for humans and agents. The same resolved catalog feeds the
human's shell, the agent's tool policy (Cortex
policyGate), and the server-function gate. One document governs every kind of principal. - Reviewable closure. Because actions and charters are both data, "what can this role reach" is a computation, not an audit project. Run the audit machinery (nav targets, channels) over a role's catalog and its reachability is a verifiable, diffable fact.
Core principles
1. Universe-blindness is the whole idea
The engine resolves globs over strings and never learns what a string means.
resolveRole(charter, universe, role, section) runs identical code whether the
universe is Nova action ids or vex table.verb leaves. This is not abstraction
for its own sake — it is what keeps access control one mechanism. A framework
that authorizes routes, components, and rows separately has three security
models and three places to be wrong. Charter has one algebra, applied per
section.
The consequence, enforced structurally: the engine never manufactures a
universe. It is always handed one. Each governed target exports its own
dialect (vex exports SCOPE_VERBS and scopeGrants(tables); a shell's universe
is its action ids; moss's is its layout-variant ids), and the composing layer
derives the universe and passes it in. Charter imports nothing and is imported
by the resolution, never the reverse — so it has zero dependencies,
permanently, not incidentally.
2. The grammar stays small; the opinions live in the verifier
The grammar is deliberately thin: roles, extends/without (compose whole
roles), and a Selection per section (allow/deny globs, with sugar for the
common cases). That is all resolution needs.
Everything judgmental — what counts as a mistake — lives in verifyCharter,
run at boot/CI, not at request time. Separating the two means the hot path
(resolution) is a pure set operation with no policy baked in, and the opinions
are auditable in one place. The rule is: if it boots, it's coherent. A
charter that resolves cleanly and passes the verifier cannot silently
mis-grant.
3. Severities are asymmetric on purpose
A dead deny — a deny glob matching nothing in the universe — is an error. A typo'd deny fails silent, and a silent deny means something is unprotected that the author believed was protected. A dead allow is only a warning: it grants nothing, so it is noise, not danger. The verifier's severity is chosen by "what happens if the author is wrong here," never by symmetry.
4. The documents are the app's, not the package's
The charter and the assignment rows (principal → roles) are artifacts the
application authors — one day rows in an artifact library, versioned and
diffable. Charter is the engine that resolves and verifies them; it ships no
document, no storage, no identity. This keeps the package a pure function and
lets the app own its policy as data.
The three rings
The charter is one ring of three. Each ring assumes the ring above it is compromised:
| Ring | Question | Enforced by |
|---|---|---|
| 1 — existence | Which actions are in your universe? | The charter (actions) → served catalog |
| 2 — shape | What does your variant of an action do? | Served layout variants (the layouts section → moss substitutes the variant's layout on the definition at shell build) |
| 3 — data | Which verbs on which entity? / Which rows/columns? | ACL: the charter (data) → a compiled Vex ScopePolicy. RLS: the policy's own row rules, locked fingerprints, closed mutation grammar |
Ring 3 has two halves this stack keeps apart. ACL — may you write deals at
all — is a verb question, and it is the charter's: the data section resolves
to table.verb grants that compile to a Vex ScopePolicy (which read/write
phases exist for you). RLS — which rows — is what a granted phase then does
(match/set rules), and the charter never touches it. Vex is the unforgeable
floor either way: an ungranted phase is simply absent from your policy, and
Vex's own default-deny refuses it; server-injected scope, identity stamping,
replay-only endpoints, and a mutation grammar that rejects $scope and blanket
writes stand under it. A malicious client that fabricates its own definitions
still cannot cross Vex.
Ring 2 is the charter's layouts section. A variant is a distinct id
selecting one action's layout; holding it swaps that layout in the served tree
(moss binds it on the definition per principal at shell build — see
@niscorp/moss). The base layout is the floor — the least-privileged holder's
shape — and variants enrich upward as grants, so extends composes them like
any capability and a forgotten grant fails closed (under-serves visibly). A
reducing variant is authored backwards: it forces deny-it-back in every richer
role and a forgotten deny over-serves silently. The charter still only
selects; what a variant looks like lives in the artifact.
So the charter does ring-1 existence, the ring-2 variant choice, and the ring-3 verb decision; it never does ring-3 row/column work. And it never enforces — it compiles: each consumer enforces its own native contract in its own language, and the charter speaks that language at the boundary (see Compilers).
The algebra
Resolution, per section, verbatim:
resolved(role) = (∪ resolved(extends) ∪ match(allow))
− match(deny)
− ∪ resolved(without)
Properties, all deliberate:
- Order-independent. Every subtraction is set-minus; there is no first-match precedence to reason about.
- Deny wins within a role.
allowthendenyin the same role: the deny removes. There is no re-allow within a role — to permit an exception, deny more narrowly (corpus case 7). - Denies do not inherit.
extendsunions resolved sets; a deny is local to the role that wrote it. A child may re-add what a parent denied — the resolver permits it (a legal composition), the verifier warns (re-allow), because it is usually a mistake but occasionally intended (F1). - Cycles and unknown references are errors, surfaced by the resolver and collected by the verifier per section.
extendsandwithoutcompose whole roles, resolved in the current section. A role is a reusable bundle, not a flat glob list.
The resolved set is hashed. That hash is the catalog cache key and the version token pushed to connected clients — grant changes propagate mid-session over the socket, and revocation is the same push with a smaller set.
Globs
One wildcard rule, no more: * matches any run of characters, including dots.
crm.* matches crm.deal.form. There is no **, no brace expansion, no regex.
The day a pattern cannot be expressed, the answer is a better id, not a richer
glob.
Sections
A Section is 'actions' | 'data' | 'layouts' | 'functions' — each a
universe. actions selects Nova action ids; data selects table.verb
capabilities; layouts selects layout-variant ids (ring 2); functions selects
the names of the app's functions. The sugar keeps the common role terse: a
bare string[] is an actions-only role, and top-level allow/deny are the
actions section (so an actions-only role never names a section). Explicit
actions:/data:/layouts:/functions: selections address each universe
directly. Having
both the sugar and an explicit actions: on one role is the
ambiguous-selection error — resolution would silently drop one.
The data verb leaves mirror vex's phases: read, plus the three write ops
under the write namespace — deals.read, deals.write.insert,
deals.write.update, deals.write.delete. write itself is never an atom
(leaves-only); the umbrella is a glob — deals.write.* grants every write,
*.write.delete denies deletes across the board. The universe is derived from
the schema, never authored.
extends/without compose every section at once. Resolution, deny-wins,
and the verifier all run per section against the right universe — one matcher,
one resolver, one set of lints, reused. Adding a governed surface is adding a
Section value, a universe, and a compiler in the target — never a grammar
change here. The layouts section was exactly that: one enum value, moss
hands the universe in, moss compiles it to substituted layouts. So was
functions: moss hands each shell only the functions its principal's roles
select.
Compilers — charter speaks each consumer's language
The charter never enforces; it compiles. Each governed library keeps its
own native, self-sufficient contract, enforced by its own machinery — Nova has
the action map (an absent id throws UnknownActionError), Vex has ScopePolicy
(an absent phase is default-denied), Cortex has ToolPolicy. None of them
imports the charter or knows it exists.
And the charter imports none of them — there is no dependency in either
direction ("no dependencies beats optional"). The charter's output is string
sets; each governed target exports its own grants → native contract intake,
in its own string dialect, and the composing server (@niscorp/moss) wires the
two:
| section | dialect (owned by the target) | intake | consumer |
|---|---|---|---|
actions |
action ids | the action map, filtered by ids | Nova's shell |
data |
SCOPE_VERBS leaves (deals.write.insert) |
createScopePolicy(grants, behaviors, scoping) |
Vex |
layouts |
variant ids | substitute the variant's layout on the definition | moss / Nova |
| (future) | tool names | a ToolPolicy constructor |
Cortex's policyGate |
Each side stays pure and standalone: the charter is a string-set engine always
handed a universe; Vex without a charter is a hand-authored ScopePolicy;
Nova is a hand-authored action map. Grafting policy onto something new is
additive — the target exports its dialect and intake, the app passes the
universe in. The test for whether a thing is charter-governable is this stack's
house rule: it is configured by declarative data, and absence means denial.
The data intake is the exemplar and the reason it's trivial: a Vex policy
already fuses two things — which phases an entity has (present or absent — ACL,
which the charter owns) and what a phase does (its match/set rules — row
behavior, static and app-owned). So resolved data grants → which phases exist,
behaviors table → what a phase does. The viewer's mark-won dies because their
compiled policy has no deals write phase — not a gate anyone added, one the
charter never emitted.
There is a third input, and it fills the gap that fusion left. A policy answered which phases exist (the charter's) and what a phase does (the behavior's) — but a behavior was a property of the TABLE, so every role holding any grant on it got the same reach. "The desk reads every booking; a member reads their own" was unsayable, and the workaround was a second table carrying the tighter rule: one fact in two places, kept level by a trigger, drifting the moment the trigger was wrong.
Reach is a property of the ROLE, so scoping is a role-level name — resolved
like every other string here, meaningless to the engine, handed to the target,
which looks it up among the behaviors a table declares. One grant, two reaches:
resolved data grants → which phases exist, behaviors[table][scoping] → what a phase does, for this role.
It is the one thing extends does NOT compose, and that asymmetry is load-
bearing rather than an omission. Every section accumulates upward because a desk
holding everything a member holds is right for actions and data. Reach inverts
it: a desk extends a member's screens and must not extend a member's "only my
own rows", or the roster it exists to read filters to whoever is operating it.
A role's reach is its own answer or none.
Two roles naming different profiles is a PERSON, not a contradiction — somebody who teaches at a studio and also trains there. So resolution is per role and the compiled policies merge: the principal may do anything any role permits, each at that role's reach. The refusal that stood here briefly ("incoherence, like two granted variants of one action") was an analogy standing in for a case, and the domain produced the counterexample within the hour.
The trusted path is a charter artifact too: the engine's default policy — what
direct callers (dev checks, an agent's query tool) run under — compiles from a
system role in the same document, never assigned to a human. There is no
policy in the app the charter does not own; even the engine's own floor is a
resolved, verified, diffable grant, and a verb the system role lacks dies for
the engine exactly as it does for a viewer.
The closure audit
verifyCharter accepts an optional ClosureAuditor — an injected hook,
(grantedIds, layoutIds?) => string[]. The verifier resolves each role's
granted action set (and, when the app governs layouts, its granted variant ids)
and hands them to the auditor; the consumer that owns actions supplies the
check. Moss builds it from Nova's action audit and substitutes granted variants
first, so the closure sees each role's effective definitions — a role that can
reach a screen but not the screen it pushes to is a broken closure, and a
variant's push targets are audited for exactly the roles that hold it. Charter
never imports the auditor — the dependency points inward, so the package stays
universe-blind even about what a coherent closure means.
Naming is the taxonomy
The charter has no tags, no categories, no metadata predicates. The id hierarchy is the taxonomy, and globs traverse it. This is a deliberate bet: developers already maintain exactly one taxonomy with care — the one they use to find their own things. A parallel tag vocabulary would drift; the namespace cannot, because it is structural.
Conventions this implies (scaffolding defaults, not grammar):
- Ids shaped
area.entity.variant:crm.deal.form,chrome.sidebar,finance.invoices. - Ids are leaves; namespaces are never actions. If
crm.dealsexists, nothing is named barecrm. Removes thecrmvscrm.*off-by-one. - Variants are distinct ids:
crm.dealandcrm.deal.vieware two actions. The charter selects; it never shapes. - A rename is a permissions change. That is a feature: the verifier diffs it.
The corpus
The grammar is only as good as the cases it survives. The instructive ones, typical first, then the ones that break other systems.
- Anonymous baseline —
"public": ["auth.login", "docs.*"]. - The ladder —
viewer→editor extends viewer allow crm.*→admin extends editor allow settings.* charter.*. Administering permissions is itself just actions (F6). - Superadmin —
"root": ["*"]. - Kiosk —
"warehouse-scanner": ["inventory.scan", "inventory.lookup"]. This terminal's entire application is two actions. Nothing else to attack. - Two hats —
{ "extends": ["sales", "manager"] }. Sets union; no diamond problem — a granted action is granted, nothing to override. - Everything-except —
{ "extends": ["sales"], "deny": ["*.delete", "*.export", "finance.*"] }. The case that kills enumerated-list systems, in one line. - Exception-to-the-exception — interns may delete nothing except their own
drafts:
"deny": ["crm.deal.delete", ...], "allow": ["drafts.delete"]. Awkward on purpose. You cannotdeny: ["*.delete"]then claw backdrafts.delete— deny wins, no re-allow within a role. You deny narrowly. The cost buys charters that never require simulating precedence in your head. - An agent narrower than its human —
"agent-unsafe": { "allow": ["*.form", "*.delete", "finance.*"] }, worn subtractively:"ray": { "extends": ["sales"], "without": ["agent-unsafe"] }. The AI-governance story is one line of the grammar that does everything else. - Plans / tiers / entitlements — a
pro-onlyrole,free-seatwears it viawithout,pro-seatextends without it. A downgrade is swapping one assignment row. RBAC, feature flags, and billing are the same mechanism. - This one guy —
"usr_017": { "extends": ["sales"], "deny": ["crm.export"] }. A principal-specific role is just a role. - Time-boxed / break-glass / suspension — not charter. These are
assignment rows (
{ principal, role, expires, grantedBy, reason }). Break-glass is two ordinary actions (request/approve) whose effect writes a row with an expiry; the catalog service watches assignments and pushes the new catalog. Suspension is deleting rows, or assigning the empty catalog — a valid application: a lock screen. The charter says what roles are; a table says who wears them, when. - A new action ships —
crm.quotesis published; every role matchingcrm.*gains it, pushed over the socket. Feature and footgun in one: the guard is not grammar but the publish-time diff — "crm.quotes → gained by: sales, intern, ray." If that line surprises anyone, the naming was wrong. - Same action, different depth per role —
"support": ["crm.deal.view"]vs"sales": ["crm.deal", "crm.deal.view"]. Clean because variants are distinct ids. - "Readonly for reps" / "no margin field" — ring 2. A reduced view is a
variant (a distinct layout, granted via the
layoutssection); field-level hiding is a patched variant with Prism redaction on the response transform. Shape lives in the artifact, not the grant. - "Needs manager approval" — not charter. Approval is part of what the action
is — a fragment composed onto it (the
confirm-deletepattern). Reps-need, managers-don't → two variants, granted respectively. - "Only their OWN deals" — not charter, loudly. Row-level is Vex scope policy
(
match: owner_id → userId). The charter decides whether the verb exists; Vex decides which rows. Mixing these makes an ACL unreviewable. - A global invariant — "nobody but finance touches
finance.*" is awkward per-role (deny in every role forever, one forgotten role breaks it). It is policy about the charter and belongs to the verifier as an assertion ({ "only": ["finance", "root"], "may-match": "finance.*" }) — CI lint, not a runtime boundary.
Known flaws, found on purpose
The design was red-teamed. None of the flaws required new grammar; all moved work to the verifier.
- F1 — Deny does not survive
extends. A child re-allows what a parent denied. The algebra is consistent (a parent role is just a set) and viral denies would break legitimate composition — but it dents "deny always wins" (true only within a role). Fix: visibility. The verifier flags every re-allow of an ancestor's deny, in every diff. - F2 — Subtractive roles are loaded guns if assigned.
agent-unsafeis a positive list that only subtracts becausewithoutuses it that way. Assign it by accident and the principal gains everything in it. Fix: the assignment diff renders in concrete actions, and the verifier lints any role referenced inwithoutthat is also assigned. - F3 — A typo'd deny fails silent.
"deny": ["crm.deal.delte"]matches nothing, forever. Fix: asymmetric linting — dead deny is an error, dead allow a warning. - F4 —
crmvscrm.*. Solved by convention: ids are leaves, namespaces are never actions. Verifier enforces (leaves-only). - F5 — Orphan actions. An action matched by no role is deployed but unreachable — deny-by-default working, but usually a mistake. Verifier reports orphans at publish (layout variants too).
- F6 — The charter can escalate itself. Whoever holds
charter.editcan grant themselves root. The runtime gate is the charter itself: nobody holdscharter.*unless the charter grants it, and edits execute as server-authoritative actions — audited, Vex-logged, approval-fragmented where wanted. The assertion pinning the meta-loop (onlyrootmay matchcharter.*) is CI lint: a tripwire, not the enforcement. - F7 — Tenant overlays. Per-tenant roles layer on a base charter with one
rule: overlays may add roles and extend base roles, never redefine a base
name. Shadowing
adminper-tenant is how SaaS horror stories start. - F8 — Versions. Charters name logical ids only. Which version of
crm.deal.forma catalog serves is the artifact library's published-pointer concern. A charter that needs to pin versions is a symptom, not a feature.
The verifier is the other half
The grammar stopped moving and every flaw found its fix in tooling. That is the intended shape. The charter stays something a non-programmer reads aloud; the verifier carries the opinions:
- resolves every role at boot / in CI — if it boots, it's coherent
- dead deny = error, dead allow = warning, orphan actions and variants reported
- flags re-allows of an ancestor's deny (F1)
- flags roles referenced in
withoutthat are also assigned (F2) - enforces id conventions (leaves only); overlay rules (no shadowing) are not built — there are no overlays yet (F7)
- checks
assertinvariants (F6) — not built:verifyChartertakes no assertions - runs the reachability closure per role through the injected auditor; moss's keeps the push targets that fall outside the role's closure
- and above all (not built in this package —
perRolein the report is the concrete per-role sets a diff would be rendered from): the diff renders in concrete actions, not globs — "this change: intern +crm.import, −confirm-delete; ray unchanged." A human approves that. This is only possible because the permissions are closed data over closed artifacts.
What it refuses to do
For every temptation, the forwarding address:
| Temptation | Home |
|---|---|
| Row filtering ("only my deals") | The Vex policy's own match rules — a behavior of a granted phase, not the charter |
| Column shaping ("no margin field") | Patched variant + Prism redaction (ring 2) |
| Approval / confirmation flows | Fragments composed onto the action (ring 2) |
| Time-boxed / conditional access | Assignment rows (a table) |
| Break-glass elevation | Two actions + an assignment row |
| Feature flags, plans, entitlements | Roles + without |
| Cross-role invariants | Verifier assert |
| Version pinning | Artifact library pointers |
| Tags / metadata predicates | The id namespace |
Grant-time shaping (apply a patch in a selector) |
Refused — mint a variant id (ring 2) |
| Enforce, store, transport, or manufacture a universe | Not the package's job — resolution and verification only |
| Grow the glob | One *. A richer pattern language is a worse id scheme in disguise |
The charter maps names to selections. It decides whether the verb exists in your world; everything about which rows, which columns, or what a variant looks like has a home that already exists.
Boundaries
Charter is consumed by the composing server (@niscorp/moss), which derives the
universes (action ids from the manifest, table.verb leaves from vex's
introspected schema, variant ids from the manifest's layouts), calls
resolvePrincipal per login, refuses to boot on verifyCharter errors, and
hands the resolved sets to the enforcers. Charter knows about none of that — it
is the algebra at the center, and nothing more. See DOCS.md for the
API.