Memories / Documentation

On this page

Vault authorization

This document describes the local daemon authorization service and the SaaS Center authorization HTTP surface. The local RPC operations below are available in the shared core, CLI, and MCP authorization tools. Existing memories mcp grant, memories mcp revoke, Center ticket import, and vault audit commands use the updated core checks.

Roles and scope ceilings

Vault role Maximum grant scope Can issue or delegate?
Reader read No
Editor read_write No
Admin read_write_delete Yes
Owner allow_all Yes

Scope omission means read, not allow_all. This applies to local grants, Center ticket issuance, static hosted grants, and member creation. Explicit legacy write: true still selects read_write; write: false selects read. write remains a write-only scope. allow_all requires an explicit selection and an Owner issuer, even though it currently covers the same ordinary-asset content operations as read_write_delete.

Issuance checks both the issuer's current vault role and, for a bearer caller, the issuing grant's scope. A grant cannot authorize another vault, exceed either ceiling, or outlive its parent. Durations are 1–60 minutes. Requests for broader or longer authority fail rather than silently narrowing the requested scope. Recipients must already be members of the selected vault. A content grant can provide a member additional content access, but it does not change their role: an Editor or Reader holding a broad grant still cannot delegate or approve requests.

Grant records expose id, vault, scope, issued_by (member ID), grantee, parent (issuing grant ID, or null), depth, and Unix-seconds expiry. A grant ID is a random opaque identifier independent of its bearer token. Issue returns the token once; access/grant listings never include it. Grant and request IDs and principal IDs are opaque, canonical identifiers; client-facing vault IDs retain the normal short-ID projection.

Local Admin/Owner grant issuance requires an unlocked vault, not terminal elevation. Revocation likewise needs no elevation, but is allowed only to the grant's issuer or a current Admin/Owner of its vault. Revocation is transitive. Removing an issuer or grantee, or lowering an issuer's role below its issued scope, invalidates affected grants and descendants when the policy is committed, including policy changes imported by sync and changes made after a restart. Grants persist in the encrypted device-local Store using only the SHA-256 bearer hash and authorization metadata; authentication compares hashes in constant time. Revocation and descendant removal commit with the encrypted state. Lock drops the in-memory grants and Store keys, making all grants unusable until unlock; unexpired grants resume after unlock or daemon restart. Absolute expiry is persisted, and expired grants and descendants are purged on load and daemon ticks. Grants are not exported through vault sync or recovery backups.

Membership changes and device approvals retain their existing elevation checks in addition to the new Admin/Owner requirement. Creating an invitation now requires Admin/Owner membership in at least one local vault. Empty-vault callers and callers with only Editor/Reader memberships fail closed. Admins can manage Admin membership but cannot add/remove/change an Owner. Ownership changes use the signed ownership-transfer operation and preserve exactly one Owner.

Permission requests

Designated grantees and delegation depth

Both the daemon and hosted Center reject issuance to a grantee whose role is higher than the issuer's effective role, even when the requested content scope is only read. Center also rejects the reserved center-admin grantee, including previously persisted grants with that subject.

A grant caller uses grant authority with a subject different from its real issuer (subject != issued_by). Such callers may create requests, but cannot approve, reject, cancel, or claim any permission request, including their own. The daemon returns Unauthorized; Center returns HTTP 403. These callers are not eligible approvers. Self-issued grants retain their existing request rules; Center claims still require the requester's durable member credential. Durable Center member bearers are member authority, not delegated grant authority.

The daemon retains its exact requester-credential binding: a request created with a designated-grantee bearer cannot later be claimed with a different member credential. Recreate such a request using the member or self-issued credential that will claim it. Center instead binds claims to the requester's durable member bearer.

Both implementations define pub const MAX_DELEGATION_DEPTH: u32 = 2. Grants issued directly by a member have depth 1. Issuance using grant authority, including a deferred approval's parent at claim time, has depth parent.depth + 1; depth 3 is rejected. depth is persisted and included in grant summaries, issuance responses, access views, and issued-grant lists. Legacy records without depth are reconstructed from their parent chain (a parentless grant is depth 1). Missing/cyclic or over-limit grant ancestry fails closed and is purged on load. Center's internal durable-member credentials are depth 0, not delegated grants.

Delegated issuance preserves the real issuer through the parent chain, rather than turning a designated grantee into a self-issued principal. Existing legacy chains are also checked when determining the caller's real issuer.

Deferred legacy approvals recorded using a designated-grantee grant also fail closed at claim time; obtain a new approval from eligible member authority.

Breaking changes: higher-role recipients and center-admin recipients can no longer receive grants; designated-grantee bearers can no longer participate in permission decisions/cancellation/claim; chains deeper than two grants are no longer usable. Use a member credential for permission decisions and claims, and issue a new root grant when an additional delegation would exceed the limit.

Any current member may submit a request for their own access in a vault:

{
  "op": "request_create",
  "vault": "VAULT",
  "target": {"type": "scope", "scope": "read_write"},
  "reason": "Update the shared project notes",
  "duration_minutes": 15,
  "approvers": []
}

target is either {"type":"scope","scope":"..."} (scope defaults to read) or {"type":"role","role":"editor"}. Reason, duration, and approvers are optional. Scoped approval defaults to 30 minutes if duration is omitted. Role changes are permanent: a duration on a role request is rejected, and time-bounded grant callers cannot approve a permanent role change. Approving a role request requires the same caller-bound elevation as direct member-role changes. An Owner approving an Owner-role request performs a signed ownership transfer.

An empty approver list broadcasts to all current Admin/Owner members of that vault. Otherwise only the listed member IDs may decide it; every listed ID must be an Admin/Owner member at creation, and eligibility is rechecked at decision. Readers and Editors cannot be listed as approvers. The requester cannot approve or reject their own request, even as an Admin/Owner or a named approver; they may still cancel it. Scope/role approval must fit the approver's role and bearer scope, and scoped expiry must fit the parent. An out-of-cap approval leaves the request pending.

Requests have exactly one transition from pending to approved, rejected, or cancelled. Only the requester can cancel a pending request. Scope approval records grant parameters, without minting a bearer. The requester uses request_claim once to mint and receive the token; request_list returns only metadata. Creation/generation and the grant duration start at claim, capped by the recorded parent expiry. Approval and claim state survive lock/restart, but revoked/expired approving authority cannot authorize a claim. No plaintext token is retained in request state or persisted Store state. Claimed grants survive lock/restart as hashes and metadata. Requests and tokens are not exported in vault sync.

request_list returns the caller's requests (including decided ones) plus pending requests they are eligible to decide. Other members cannot inspect a directed request. A vault supports at most 1,000 stored requests; reasons are limited to 4,096 bytes. At most 10,000 grants may be active at once, on both the local daemon and a hosted Center.

Pending limits: a requester may hold at most 20 pending requests per vault, and a vault at most 200 pending requests; over-limit creation fails with a limit error (HTTP 429 on a hosted Center). Pending and approved-but-unclaimed requests expire after 7 days (audited as request.expire, actor system); decided requests are pruned 30 days after their last transition.

RPC operations and surfaces

The daemon JSON op names are:

Operation Fields Authority
access_list vault Admin/Owner
grant_list vault Admin/Owner see all; other current members see only grants they issued
grant_issue vault, minutes, optional scope, grantee Admin/Owner
grant_revoke vault, id Issuer or Admin/Owner
request_create vault, target, optional reason, duration_minutes, approvers Any member
request_list vault Own or eligible pending requests
request_approve vault, id Eligible Admin/Owner
request_claim vault, id Approved scope request's original requester credential; once only
request_reject vault, id, optional reason Eligible Admin/Owner
request_cancel vault, id Pending request's requester
audit vault, verify_only Admin/Owner

Legacy mcp_grant and mcp_revoke remain supported. Bearer callers may use these authorization RPCs only for their grant's vault; this does not permit MCP to inherit terminal elevation or access stored credentials. Mobile FFI continues to reject grant management and the new permission-request operations; no native ABI changes are needed.

access_list returns principals grouped under owner, admin, editor, and reader, plus active grants, devices (authorized, non-revoked devices from the vault policy: device, owning member, name; the policy records no creation time), pending_requests and approved_unclaimed_requests (each with id, requester, target scope/role, approvers list or "broadcast", Unix-seconds created and expires). Visibility remains Admin/Owner. grant_list returns {"vault":"VAULT","grants":[...]} without principal groups. Each issued grant contains id, parent (null for a root grant), issued_by, grantee, scope, vault, Unix-seconds expiry, generation, depth, and Unix-seconds created when available. Local generation is the committed state generation at issuance, not the current generation at listing. Expired/revoked grants are excluded. Admin/Owner can list all grants in the vault; other current members may list only their own issued grants, not grants merely received. Bearer callers remain bound to their grant's vault. No token, bearer hash, or asset body appears in either response. The daemon RPC, MCP grant_list, and memories grant list VAULT share this service response.

The CLI wrappers are implemented with these exact commands (IDs are opaque):

memories access list VAULT
memories grant issue VAULT --minutes N [--scope S] [--grantee ID]
memories grant list VAULT
memories grant revoke VAULT ID
memories request create VAULT [--scope S | --role R] [--reason T] [--duration M] [--approver ID]...
memories request list VAULT
memories request approve VAULT ID
memories request claim VAULT ID
memories request reject VAULT ID [--reason T]
memories request cancel VAULT ID
memories audit list VAULT [--verify-only]

The legacy memories audit VAULT [--verify] syntax remains supported. Scope defaults to read; local scope requests without a duration retain the daemon's 30-minute approval default. Role requests cannot include a duration. Output is JSON in the normal CLI envelope.

Hosted Center equivalents use the HTTP authorization routes:

memories center access VAULT
memories center grant issue VAULT --minutes N [--scope S] [--grantee ID]
memories center grant list [VAULT]
memories center grant revoke ID
memories center request create VAULT [--scope S] [--reason T] [--duration M] [--approver ID]...
memories center request list [VAULT]
memories center request approve ID
memories center request claim ID
memories center request reject ID [--reason T]
memories center request cancel ID
memories center audit [--vault V] [--format json|jsonl]

These commands accept --center-url, otherwise using MEMORIES_CENTER_URL or the configured SaaS Center. They use MEMORIES_CENTER_TOKEN when set, otherwise MEMORIES_CENTER_ADMIN_TOKEN; permission failures do not retry with another bearer. Hosted requests support scope, not local role changes. Scope defaults to read and request duration to 30 minutes. JSONL audit export prints one JSON entry per line without the CLI envelope; other output uses the JSON envelope.

The daemon MCP tools are access_list, grant_issue, grant_list, grant_revoke, request_create, request_list, request_approve, request_claim, request_reject, request_cancel, and audit_list. Their arguments follow the RPC fields above (minutes for grant issuance, target and duration_minutes for request creation, and verify_only for audit). Omitted grant scope or request target defaults to read. Every call uses the existing MCP capability, never terminal elevation or a hosted Center bearer.

The SaaS Center implements GET /v1/access/{vault}, POST/GET /v1/grants, DELETE /v1/grants/{id}, POST/GET /v1/requests, POST /v1/requests/{id}/approve|claim|reject|cancel, and Admin/Owner-only GET /v1/audit. These routes require hosted mode (--data). See center.md for the exact HTTP bodies, query parameters, responses, and persisted files.

SaaS Center authorization

Hosted members have an optional explicit role (reader, editor, admin, owner) independent of their content scope. When role is omitted, existing scope tiers map to Reader (read), Editor (write/read_write), Admin (read_write_delete), or Owner (allow_all). The operator bearer acts as the center-admin Owner across hosted vaults. Static bearer principals use static:OPAQUE_ID IDs and the same scope-to-role mapping. Member IDs cannot use the reserved center-admin name. Content scope does not elevate a Reader/Editor recipient into an administrative role.

POST /v1/grants defaults scope to read and duration_minutes to 30; minutes is a duration alias. Issuance requires Admin/Owner role, with the same role/scope/vault/expiry bounds as local issuance. Responses use subject for the recipient and include parent, issued_by, expiry, generation, and depth. Grant tokens appear only in issuance/claim responses, never in approval, lists or the audit. The recipient defaults to the issuer for member callers; an explicit grantee must identify an existing principal in the selected vault. Operator issuance without grantee creates an anonymous grant principal identified by its grant ID, never a center-admin subject. This also applies to hosted ticket issuance.

Each hosted member has a persisted generation counter. Minted grants freeze both issuer and recipient generations at issuance; authentication never refreshes them. Changing a member's scope, role, or vault, or removing them, bumps only that member's generation. Removed-member counters persist so recreating an ID does not reset its generation. Removal or update revokes grants issued by or to that member and all descendants, with audit records, while unrelated grants keep working. The public generation field is the recipient's generation at issuance. Static and operator principals have a fixed generation. Durable member credentials are re-established only at the explicit policy transition, not by authentication.

Hosted minted grants survive restart in owner-only DIR/grants.json, containing only SHA-256 bearer hashes, frozen generations, expiry/parent metadata and revocations. Hash comparison is constant time. Expired grants and descendants are purged on load, periodically and during authentication/issuance. Lock drops Store keys and suspends grants without revoking them; unexpired grants and static/durable credentials resume after unlock. Persistence failures lock the service and require operator repair/restart rather than risking resurrection.

POST /v1/requests accepts vault, optional scope, reason, duration_minutes (alias duration), and approvers. Empty/omitted approvers broadcasts through eligible Admin/Owner pending-request lists; supplied IDs direct the request. GET /v1/requests returns pending requests owned by the caller or within their approval authority, plus the requester's approved-unclaimed requests, optionally filtered by vault. Approval and rejection both require the selected approver's role/scope/vault/expiry authority. The requester cannot approve or reject their own request, even as an Admin/Owner or a named approver; only the requester may cancel. One decision is serialized and persisted; repeats return 409. Approval returns only request metadata with status: approved, claimed: false, and validated scope/duration/issuer bounds; it does not mint a grant or return a token. The requester then calls POST /v1/requests/{id}/claim with their durable member bearer, receiving request plus a one-time grant response. Approvers, other members, and other grants for the same subject cannot claim using those credentials. A second claim returns 409. Generation, creation time, and duration start at claim, with expiry capped by the approving authority's recorded expiry. Revoked/expired approving credentials or changed member generations cannot be used to claim.

The daemon uses the same deferred minting flow for scope requests: request_approve stores approval bounds and request_claim returns grant metadata plus token once. Local OS-authenticated callers must still be the requesting member; MCP must present the exact capability that created the request, not a different grant for the same subject. Lists never return tokens. Role approvals remain immediate policy changes and have no claimable grant. Encrypted Store state and DIR/requests.json contain only request metadata; grants persist only bearer hashes. Startup reconciles Center decisions and claims against the verified audit so interrupted metadata saves cannot reopen a decision or claimed request. If a successful claim response is lost, the token cannot be retrieved again; request a new approval instead.

Audit format and verification

Local audit entries retain vault, previous, sequence, timestamp, action, object, result, signing certificate, and signature. New optional top-level fields are actor, subject (grantee), scope, expiry, parent, reason, issued_by, via_grant, and request_id. Actor identifies the acting member; the signer identifies the daemon device recording the event. A delegated action can therefore have a different actor and signing user. No password, stored credential, or bearer token is included in audit entries.

Entries made using grant authority include issued_by (the real issuer) and via_grant (the caller's grant ID); actor remains the grant subject. The issuance-event exception (mcp.grant / Center grant.issue) records the real issuer as actor and the grantee as subject, including delegated issuance. Both optional fields use serde defaults and are omitted when absent, preserving the serialization and verification of older signed audits and Center JSONL logs. Hosted content audits carry the same attribution in the shared Store.

Audits are signed per-device hash chains. previous points to the SHA-256 hash of the preceding serialized signed entry, and the audit map is keyed by those hashes. Vault::verify validates signatures, hashes, sequence continuity, and the existing vault-history invariants. Old records omit all new fields and retain their exact legacy signing bytes and hashes. New fields use serde defaults and are omitted when absent; populated details are covered by the signature.

Events include device.authorize, mcp.grant, mcp.grant.center, mcp.revoke, session.lock, session.unlock, and request.create|request.approve|request.claim|request.reject|request.cancel, alongside existing membership and device events. Explicit locks propagate audit persistence errors but still erase unlocked state. Lifecycle/idle locks attempt an audit and always erase state even if persistence fails; they must not leave secrets unlocked to preserve an audit record. Locked failures cannot sign an audit.

The SaaS Center has a separate append-only, mode-0600 DIR/audit.jsonl SHA-256 chain, not the local signed audit format. GET /v1/audit requires Admin/Owner, accepts vault and format=json|jsonl, and verifies the complete chain before filtering/export. Vault-bound administrators cannot read another vault's events; the operator can also view global lock/unlock/enrollment events. Entries contain sequence, timestamp, prev_hash, hash, action, actor, subject, scope, vault, expiry, parent, reason, request_id, and object; optional details are null. The hash covers the serialized tuple (sequence, timestamp, prev_hash, event_fields); prev_hash is null for the first entry and is the preceding entry's hash afterward.

verify_audit_chain(path) in crates/center/src/saas/audit.rs checks hashes, sequence continuity, previous hashes, and complete JSONL lines. Startup verifies the existing file; append/read also compare its head to the running process's known head. Events include device.authorize, grant.issue, grant.revoke, member.add|member.set|member.remove, session.lock|session.unlock, and request.create|request.approve|request.claim|request.reject|request.cancel. Claim events record the requester as actor and the minted grant ID as object, never the token. Audit append or request/member persistence failures lock the hosted service, set restart_required, and block remote unlock until an operator repairs and restarts it. Authorization mutations and permission decisions also stop while restart_required is set; read-only operator inspection remains available. These unkeyed chains are not proof against an operator rewriting the entire log or rolling all files back; stronger assurances need external head anchors.

Threat model and behavior changes

  • Local OS-authenticated callers act as the unlocked device's member. Same-user malicious processes remain inside the existing endpoint trust boundary.
  • Capability bearers are secrets; anyone possessing one may use its bounded authority until revocation, lock, or expiry. A bearer cannot widen its own scope, escape its vault, extend expiry, or bypass its subject's delegation role.
  • Revocation does not erase previously copied plaintext or keys and cannot instantly reach offline peers. Policy and grant invalidation is effective on the daemon that has committed the updated policy.
  • Hash chains detect alteration relative to known state, not complete rollback of the database or omitted events by a compromised device. External anchors and an independent security audit remain necessary for stronger assurances.
  • Breaking changes: unscoped issuance is read-only; Editors/Readers cannot mint grants or invitations; audit access requires Admin/Owner instead of elevation; member changes invalidate delegated grants. New Admin/Owner grant issuance and issuer/Admin revocation no longer require elevation. Empty-vault invitation creation is rejected rather than silently treating a caller as an administrator.
  • Breaking change: approval no longer mints or delivers a bearer, and request lists no longer deliver an approved bearer. Requesters must explicitly claim once. Previously approved requests without recorded deferred approval bounds cannot be claimed; create a new request. Existing separately issued grants continue to follow their normal expiry/revocation rules.

See SECURITY.md for limits and center.md for the separate ticket-only and operator-keyed hosted Center trust boundaries.

Permission request limits, expiry and retention

Applies to both the daemon (requests in the encrypted Store) and Center (requests.json):

  • MAX_PENDING_PER_REQUESTER_PER_VAULT = 20 and MAX_PENDING_PER_VAULT = 200 count pending requests only. Decided, cancelled, expired, and approved-but-unclaimed requests don't count toward these limits. (This replaces the old global cap of 1000 requests, which counted every request ever made.) If you go over a limit, the daemon returns a limit error and Center returns HTTP 429.
  • PENDING_REQUEST_TTL = 7 days: a pending request older than this moves to status expired. It's audited as request.expire with actor system, and it can no longer be approved. An approved scope request that hasn't been claimed within 7 days of approval also expires, the same way, and can't be claimed after that.
  • REQUEST_RETENTION = 30 days: requests that are rejected, cancelled, expired, or claimed are deleted this long after their last transition (the decided timestamp, or created for older records that have none).
  • Expiry and pruning happen lazily: the daemon runs them at the start of any authorization request, and Center runs them on request create, list, decide, and claim. Nothing runs them at startup.
  • Center startup reconciliation ignores audit entries for pruned requests and accepts request.expire entries. An expired request must have an audited expiry. Request IDs are random 128-bit values that are never reused, so a pruned request (including a claimed one) doesn't exist anymore and can't be claimed. Because of that, Center doesn't keep a separate tombstone set.