Status: Accepted, 2026-06-18.
Supersedes: the binary visibility: public|owner read policy
introduced for per-instance access control (it becomes a special case of
this model). Backward-compatible — existing pages are unchanged.
Scope: the data-level ACL (read/write/chat over skill instances).
Capability/operator-tool RBAC is phase 2 (designed, not
implemented). Instance-level acl: overrides were phase 2 here and
shipped in #351 — see the amendment below.
escurel's access control was two global roles (Agent | Admin,
escurel-auth) plus
per-instance ownership
(escurel-index/src/acl.rs): a
skill page declared visibility: public | owner and an owner_field:,
and the read/write decision was a deterministic, fail-closed comparison
of the resolved owner against the token sub. There were no groups, no
custom roles, and no read/write/delete matrix beyond the read-vs-write
asymmetry; admin was all-or-nothing per tenant. Sharing an instance
beyond its single owner, or letting a role like moderator act across
owners, was impossible without making the data world-readable.
Replace the binary visibility with a name-based group model: each skill declares, per CRUD verb, a list of group names that may perform that verb. The decision stays deterministic and fail-closed — no LLM, ever.
owner_field: author
acl:
read: [public]
create: [owner]
update: [owner, moderator]
delete: [admin]- One primitive. A "group" is a flat per-tenant name. "Role" and "group" are the same thing (D4).
- Reserved groups
public/owner/adminare resolved structurally — always-present-for-authenticated / structural-owner / verified-admin. They are never grantable via a token claim or a membership row (stripped before the intersection), so a misconfigured IdP can never impersonate them (R2/§8.2).adminderives only fromadmin_role_value, never the literal string. - Custom groups are satisfied from either the JWT
groups_claimor the DuckDB-canonicalgroup_memberstable (D6/D11). A token-only group needs no escurel state. - Decision:
allowed ⇔ effective_groups ∩ acl.<verb> ≠ ∅, or the caller is admin (implicit bypass, D9). Pure allow-list union; no deny rules in v1. - Resolution order for
(skill, verb)(§4.5): skillacl.<verb>→ legacyvisibility:mapping → tenantacl_defaults(on theescurelmeta-skill page) → shipped default → fail-closed. The shipped default (read:[public], writes[admin]) reproduces today's behaviour, so an unconfigured tenant and any un-migratedvisibility:page authorise bit-identically. - Membership store: a first-class
group_memberstable (current- state only, withadded_at/added_byaudit), created via an idempotent every-boot ensure-step. Mutated only by the admin-onlyadd_group_member/remove_group_member/list_group_membersMCP tools (D14). - Groups claim:
groups_claim(defaultroles) is parsed leniently — a JSON array, or a single string split on whitespace/commas (R1).
These four refine the change request against the real codebase:
- Schema rollout — the table is added via an idempotent every-boot
CREATE TABLE IF NOT EXISTSensure-step, not a new schema-version framework. The v1 schema'sMigrator::upruns only on a fresh DB, so a per-boot ensure is what reaches already-provisioned tenant DBs. admin_role_valuestripped from token groups — beyond the three reserved names, the configured admin value (e.g.escurel:admin) is also stripped fromtoken_groupsat the server boundary, so it can never act as a phantom custom group. A mild extension of §8.2 (which strips only the three literals), for hygiene.- Tool visibility — the new admin tools are listed for everyone and
gated at dispatch (
require_admin→ JSON-RPC-32001), exactly like every existing admin tool. The change request's claim (§6) that admin tools are already hidden fromtools/listis inaccurate; there is no such hiding to mirror, and adding it would change behaviour for all admin tools. Not done in v1. - Demo app in scope — the membership tools are wired into
apps/escurel-exploreandscripts/verify-demo.sh, per the project contract that the demo tracks every backend capability.
- Backward-compatible. Proven by the unchanged
instance_acl/chat_acl/write_acl/stored_query_aclsuites plus a legacy-equivalence matrix test. deleteis enforced asupdatein v1. There is no distinct delete operation at the write boundary (deletion is a tombstone overwrite), so thedelete:list is parsed and reported but enforced through theupdatepath. A distinct delete gate is phase 2.- Membership is not time-travelled.
group_membersis current-state relational data, deliberately outside the CRDT/markdown lane (R3) — high-churn, joined in SQL on the read path, no history bloat. - Chat stays compat.
may_access_chatkeeps its owner-or-ungated behaviour (R4): a chat group with no owning member instance, or an unresolvable owner, stays open. - Phase 2 (not implemented): capability/operator-tool RBAC
(
require_capability) and role hierarchy. Instance-levelAmended 2026-08-13 (#351).acl:overrides (R5: the instance-frontmatteracl:key is reserved and ignored in v1).
R5 made the instance-frontmatter acl: key reserved-and-ignored. That
left the model skill-grained: a group grant necessarily covered every
instance of a skill, and only owner was instance-grained. The
consultancy/account shape — one shared record type, access scoped per
engagement rather than per author — could get isolation or team read, not
both. A consumer (Heron) was blocked on exactly that.
The key is now honoured. Resolution runs down a chain, per verb, most
specific first: the instance's acl: → the skill's acl:/legacy
visibility: → the tenant acl_defaults: → deny. Only
Indexer::resolve_policy changed shape; every enforcement point already
funnelled through may_read_instance / may_write_instance, so no call
site moved and no verb was left behind.
Two constraints kept from the v1 threat model:
- On a write, the deciding block is the one on the stored page,
never the incoming content — otherwise a caller could authorise its own
write by shipping a block granting itself.
createhas no stored page and stays skill-grained. - An instance with no block, or with a verb unset, falls through
unchanged. That is what makes this backward compatible without a
rollout flag: no page in the shipped fixtures carried an inert block,
so nothing silently starts being enforced. (The write path remains
behind
ESCUREL_WRITE_ACL, still defaulting toOff.)
escurel-auth/src/verifier.rs—groupsonAuthContext,groups_claimonOidcConfig,parse_groups_claim.escurel-index/src/read.rs—AclPolicy+aclonSkillInfo,parse_named_acl, legacy mapping.escurel-index/src/acl.rs—token_groupsonAclCaller,caller_groups/resolve_policy/tenant_acl_defaults, per-verb decision core.escurel-index/src/groups.rs+sql/0005_group_members.sql+schema.rs::ensure_group_members— membership store.escurel-server/src/mcp.rs— caller construction, the three membership tools,list_skillsaclprojection.escurel-types/src/agent.rs—SkillAcl+Skill.acl.