ACL & quota granularity
Parent: access-control
Core model
Notation:
X= a capability or tool being checked for access
Exposure is pure AND narrowing across three layers:
exposed(X) = global_enabled(X) ∧ role_grants(X) ∧ ¬code_denies(X) ∧ connector_deps_met ∧ quota_ok
No layer can open what an upper layer didn't grant. Each must pass for access to succeed.
Freeze/live asymmetry
Access control enforcement depends on when each layer is evaluated (confusables):
- Global layer: live. Stored in
capability_settings, evaluated at request time. Flipping a global capability instantly kills all running sessions using it. - Role layer: frozen at issue. Snapshot into role-snapshot-frozen when the code is created. A role change doesn't retroactively affect existing codes.
- Code layer: frozen at issue. Deny tables
code_capability_denials/code_skill_denials/code_corpus_denialsare locked at code creation. Past codes never see future code-side denials.
The tri-state elimination
The earlier tri-state code_capability_overrides (allow/deny/inherit) has been killed. It is now replaced by sparse deny tables:
code_capability_denials: presence of an entry means the capability is denied; no state column.code_skill_denials: presence of an entry means the skill is denied; no state column.code_corpus_denials:(code_id, uri_pattern)— a glob this code takes back from the role's positive corpus list (the third kind, landed 2026-07-16,6395374b0;backend/db/schema.sql). Frozen into the snapshot asdeniedCorpusURIsand checked at match time, not subtracted from the grant list (a glob cannot subtract from a list entry).
A code can only subtract, never re-allow. This eliminates the "code resurrects a capability" privilege escalation corner cases by construction.
V1 scope
The management-plane phase landed the global layer. V1 coverage:
- Capability/tool ACL: fully implemented.
- Skill ACL: fully implemented.
- Corpus-glob ACL: LANDED —
allowsCorpusEntry(scope, genre, path, published)→access.AllowsCorpusEntry(scope, CorpusEntryRef)enforced on every corpus read (backend/internal/corpus/usecase/corpus_lister_pg.go:53,backend/internal/access/entity/path_acl.go:143); the frozen scope (grants ∧ ¬code corpus denials ∧ published-only for the public identity) rides the RoleSnapshot (AllowsCorpus(uri, published)/CorpusScope()),raw://**hard-denied; forwarded to the externalized retrieval plugin as one opaquecorpus_scopeblob via_meta. - Owner-MCP-server ACL: LANDED — ext-mcp servers attach per role via
role_mcp_servers(SetMCPServers+ ownership validation); frozen into the snapshot asmcpServerIDs.
Implementation detail: ACL=always capabilities
Some capabilities are marked ACL=always (retrieval, ask_visitor, summarize) and do not appear in allowedTools. Their denial is gated at the exposure door via RoleSnapshot.deniedCapabilities, not table lookup.
Verification
The heaviest e2e coverage in the repo:
acl-capability-matrix: tests all combinations of global/role/code layers.acl-skill-matrix: tests skill ACL isolation.acl-freeze-isolation: verifies freeze/live asymmetry under concurrent changes.