Memories / Documentation

On this page

Authorization Center

Self-service accounts and SMTP

Pass --auth-config /etc/memories-center/auth.toml (or set MEMORIES_CENTER_AUTH_CONFIG) in hosted mode. See deploy/center/auth.example.toml. Without an auth config, Center retains its token-only behavior. With a config, account login is enabled; registration defaults to disabled and email mode.

registration_enabled = true
registration_mode = "email" # or "username"
max_accounts = 10000

[smtp]
host = "smtp.example.com"
port = 587
tls = "starttls" # required STARTTLS; "tls" for implicit TLS, commonly port 465
from = "Memories <noreply@example.com>"
username = "smtp-user"
password_env = "MEMORIES_CENTER_SMTP_PASSWORD"

SMTP passwords are read from the named environment variable. Keep that variable in the protected service environment, never in arguments, URLs, source control, or browser configuration. A relay without authentication may omit username; certificate-verified TLS remains mandatory. Invalid configuration prevents startup. Missing SMTP disables email registration; transport failure returns 503 and creates no account. The public config response never includes SMTP settings.

Set registration_enabled = false to close new registrations without disabling existing accounts. Set registration_mode = "username" for username/password signup without email; omit [smtp] if unused. Restart after changing settings. Existing accounts of either kind continue to log in after a mode change.

Public endpoints (JSON, maximum request body 4 KiB):

Endpoint Body / behavior
GET /v1/auth/config Login availability, registration availability/mode, password minimum, session duration
POST /v1/auth/register Email mode: {identity,locale?} sends a code. Username mode: {identity,password} creates account
POST /v1/auth/verify {identity,code,password} proves mailbox ownership and creates account
POST /v1/auth/login {identity,password} returns a one-hour scoped bearer grant
POST /v1/auth/logout Bearer authorization; revokes that expiring grant and descendants

Email addresses and usernames are trimmed and ASCII case-folded. Email signup accepts ASCII mailbox addresses only. Usernames are 3-64 ASCII letters, digits, hyphens or underscores. Passwords are 12-256 UTF-8 bytes and stored as salted Argon2id PHC records (64 MiB, 3 passes, parallelism 1). Hash work runs outside the vault lock with at most two concurrent workers. Users cannot select a vault, scope, or role at registration: each receives a random independent hosted vault.

Verification codes contain 256 random bits, expire after 15 minutes and are single-use. Only hashes are held in server memory; a restart invalidates pending codes. Duplicate sends within that window return the same generic acceptance without another email; retry after expiry. Passwords are chosen at verification, not when requesting the email. Pending registrations are capped at 1024. The optional locale selects the email subject and body: en, zh-CN, zh-TW, ja, fr, de, or es. Regional variants use their base language; zh-Hant, zh-HK and zh-MO select Traditional Chinese, while explicit zh-Hans selects Simplified Chinese. Missing or unsupported locales fall back to English for existing API clients. Locale is per request and is not stored in the account. Changing language during an outstanding challenge does not send another email or bypass its rate limit. It does not change identity normalization or verification. Auth attempts have a global 60/minute ceiling and per-identity limits of 3 registration requests or 10 login/verification attempts per 15 minutes. These process-local limits reset on restart and can cause denial of service under attack; use an upstream abuse-control service for higher-volume deployments. They do not trust client-supplied IP headers.

Existing durable member tokens remain supported. The members.json format now has optional account credentials: protect its backups as password databases. Old Center builds cannot read account records; rollback after registration requires a compatible binary or a matching pre-upgrade data backup (which would discard later data). Password reset, mailbox changes and email recovery are not implemented. Admin member deletion disables login and invalidates sessions.

A Center is an external authorization control plane that distributes enrollment and capability tickets to Memories endpoints (CLI, desktop daemon, mobile, and other clients that share configuration).

Hosted Centers also support Memories Web, a browser client for memories, cards and files. New builds serve it at /app/. To connect from the public website, explicitly allow --web-origin https://memories.ivjn.us (or MEMORIES_CENTER_WEB_ORIGINS). Same-origin use requires no CORS setting; existing member/grant authorization still applies. See the guide for setup and limits.

Rules

  1. Endpoints may configure one or more Centers in config.toml ([[centers]]).
  2. If any Center is configured, the endpoint is Center-managed: - Local self-bootstrap is forbidden (memories init / restore). - Device creation uses a Center-signed bootstrap ticket (memories center enroll). - Local MCP grant minting is forbidden; import Center-signed grant tickets instead.
  3. Device vault crypto unlock remains local (device password). A ticket-only Center never receives device vault keys or plaintext secrets. Optional hosted mode instead owns a separate, operator-keyed server vault (see below). An optional signed unlock ticket authorizes a local password unlock; it carries neither the password nor device keys.
  4. Multiple Centers may be listed; a ticket must verify against the Center id and public_key that issued it.
  5. Listen tokens, Center signing secrets, and admin tokens are never stored in TOML.

Grant scopes

MCP grant tickets carry an explicit scope (permission level). Wire / config identifiers are snake_case; CLI and HTTP also accept human-friendly aliases.

Scope Aliases (examples) Allows
allow_all allow all, all, full Full set of ops this grant surface can authorize (today: read + create/update + delete of ordinary assets; reserved for future elevated Center-gated capabilities on the same surface)
read r List / get / search / retrieve / RAG read / ordinary graph — no mutations
write w Create/update ordinary memory and cards only (not read, not delete)
read_write read+write, rw Read + create/update
read_write_delete read+write+delete, rwd Read + create/update + delete ordinary memory/cards

Notes:

  • write is intentionally write-only so Centers can mint narrowly scoped agents. Prefer read_write when the agent also needs to search or read.
  • Delete covers ordinary (non-private, memory/card) deletes enforced for MCP callers; private/credential deletes still require local elevation and remain outside MCP grants.
  • Legacy tickets that only have the old write: bool field (no scope): write: false → read, write: true → read_write.
  • New tickets without an explicit scope default to read at issuance (CLI / memories-center HTTP).
  • These ordinary-asset/elevation restrictions describe daemon MCP, not hosted vault RPC. Hosted grants cover private cards, attachments and protected profiles too; their exact authority is described below. Daemon MCP is not elevated by this feature, even when its scope is allow_all.

Local ticket import now checks Admin/Owner membership and the role/scope ceiling, not just write access, and does not require terminal elevation. See vault authorization for local delegation, request, access, and audit behavior and the staged Center/CLI/MCP rollout.

Threat model fit

Concern Behavior
Device enrollment Center decides which devices may initialize a store
MCP / agent grants Center issues time-bounded grant tickets with scopes; endpoints import them
Device vault confidentiality Still Argon2 + local store encryption; ticket-only Center is policy, not key escrow
Hosted vault confidentiality Server-side encrypted Store; host operators hold the password and can read all hosted plaintext; not end-to-end encryption
Compromised Center signing key Attacker can enroll devices / issue grants for that Center id — rotate key and remove stale public_key from endpoints
Compromised Center admin Can issue time-bounded device unlock tickets, lock the hosted Store and mint/update/remove hosted members; cannot decrypt a ticket-only device Store without its local password
Compromised endpoint Holds vault material after unlock; Center cannot revoke ciphertext already on disk (same as today)

Endpoint configuration

[[centers]]
id = "corp"
url = "https://center.example.com"
public_key = "…"   # from `memories center public-key` or memories-center /v1/public-key
mode = "ticket"    # default: enrollment/grants/unlock tickets only

[[centers]]
id = "team"
url = "http://127.0.0.1:9191"   # loopback http allowed for local Center
public_key = "…"

Non-loopback Center URLs must be https://. SaaS attachment/direct CLI URLs must be origins only, without userinfo, paths, queries or fragments; ticket-mode configuration retains existing URL compatibility.

SaaS client attachment

Set exactly one configured Center to mode = "saas" to use its hosted content backend instead of a parallel local vault:

[[centers]]
id = "corp"
url = "https://center.example.com"
public_key = "…"
mode = "saas"

Inject MEMORIES_CENTER_TOKEN="…" into the daemon environment: this is a hosted member, static grant or time-bounded access bearer, never the admin token. Start memories daemon using that config. Daemon content requests are sent to that Center's /v1/rpc using the core TLS/HTTP client, outside the local writer lock. Unsupported hosted operations fail closed; they do not fall back to local storage. Use the bound vault name (for example default) and canonical asset IDs returned by hosted RPC. Local status/Center listing remains local. Init, restore, local MCP minting and local enrollment remain forbidden; an old local database is not the content backend for this attachment. Content and permissions belong to the hosted Center's grants/members. Other ticket-only Centers may coexist.

The configured daemon URL is authoritative (no environment URL override). The daemon bearer is shared by authenticated daemon clients; protect IPC access and any daemon listen/relay token as delegation of that member's authority. Local MCP capabilities are not forwarded with the daemon bearer; use a separately scoped hosted member with a direct HTTP/MCP adapter instead. No Center admin MCP tools are added.

For direct CLI HTTP calls, URL selection is --center-url, then MEMORIES_CENTER_URL, then the configured SaaS Center. Admin calls require MEMORIES_CENTER_ADMIN_TOKEN; content RPC requires MEMORIES_CENTER_TOKEN. None of these bearer tokens belongs in TOML. TLS settings reuse --ca-file / --insecure; do not disable TLS verification in production. Redirects are not followed. The direct helper does not require a running daemon:

export MEMORIES_CENTER_URL="https://center.example.com"
export MEMORIES_CENTER_TOKEN="…"
# request.json contains an ordinary hosted Request, e.g. {"op":"list","vault":"default"}
memories center rpc --request request.json

CLI

# Admin: derive public key for config.toml
export MEMORIES_CENTER_SECRET="…"   # 32-byte URL-safe base64 or 64 hex
memories center public-key

# Admin: issue tickets (or use memories-center HTTP service)
memories center issue-bootstrap --center corp --device-name laptop
memories center issue-grant --center corp --vault VAULT_ID --minutes 15
memories center issue-grant --center corp --vault VAULT_ID --scope read --minutes 15
memories center issue-grant --center corp --vault VAULT_ID --scope write --minutes 15
memories center issue-grant --center corp --vault VAULT_ID --scope read_write --minutes 15
memories center issue-grant --center corp --vault VAULT_ID --scope 'read+write+delete' --minutes 15
memories center issue-grant --center corp --vault VAULT_ID --scope allow_all --minutes 15
# Legacy: --write alone ⇒ read_write (when --scope omitted)

# Endpoint (daemon already running with centers in config)
memories center list
memories center enroll --ticket "$BOOTSTRAP_TICKET"
memories sudo
memories center import-grant --ticket "$GRANT_TICKET"

Local (non-Center) MCP grants accept the same scopes:

memories mcp grant VAULT_ID ./grant.token --scope read --minutes 15
memories mcp grant VAULT_ID ./grant.token --scope read_write_delete --minutes 15
# Legacy: --write ⇒ read_write

Center HTTP service (memories-center)

export MEMORIES_CENTER_SECRET="…"
export MEMORIES_CENTER_ADMIN_TOKEN="…"
memories-center --listen http://127.0.0.1:9191 --center-id corp

The Center serves plain HTTP over TCP. --listen https://HOST:PORT only selects the bind address; it does not enable native TLS. For public exposure, bind the Center to loopback or a protected internal address behind a TLS reverse proxy, and configure clients with the proxy's public https:// URL. Protect the admin token and signing secret; do not expose the plain HTTP listener directly.

  • GET /health
  • GET /v1/public-key
  • POST /v1/tickets/bootstrap (Bearer admin token)
  • POST /v1/tickets/mcp-grant (Bearer admin token)
  • POST /v1/tickets/unlock (Bearer admin token; {"ttl":300}, 1–3600 seconds)

Grant body (JSON):

{
  "vault": "…",
  "scope": "read_write",
  "minutes": 30,
  "ttl": 3600
}

scope accepts the same wire names and aliases as the CLI. When scope is omitted, write: true maps to read_write, otherwise the default is read. Static hosted grants may omit scope (TOKEN or TOKEN::VAULT), and member creation defaults to read when scope is omitted. Request allow_all explicitly rather than relying on the former permissive default.

Full hosted vault (SaaS)

Enable the hosted vault with --data DIR (or MEMORIES_CENTER_DATA). Supply a nonempty MEMORIES_CENTER_VAULT_PASSWORD through your service manager's protected environment/secret injection. There is deliberately no password command-line flag. The password is required only with --data; losing it loses access to the Store. Use a strong, independently generated password, not a bearer token or signing key.

# Inject MEMORIES_CENTER_SECRET, MEMORIES_CENTER_ADMIN_TOKEN,
# MEMORIES_CENTER_VAULT_PASSWORD and MEMORIES_CENTER_GRANTS securely first.
memories-center --listen http://127.0.0.1:9191 --center-id corp --data ./center-data

From a checkout, prepend cargo run -p memories-center -- to the options. Without --data, the process remains a backward-compatible ticket-only Center. Tickets and enrollment continue to work in either mode.

Hosted mode reuses memories_core::service::Service, Request, Content, and the daemon's encrypted SQLite Store. It can replace Center + daemon for hosted vault content, including:

  • Full memory/card fields: bodies, tags, sources, aliases, notes, categories, private flag, links, allowed hosts and secret references.
  • Brain, persona, workflow and workflow-template cards; protected account, env, token, login, TOTP/HOTP and otpauth profiles.
  • Signed revision DAGs, history, diffs, historical reads and reapplication; same-vault card links and graphs.
  • Persona world snapshots, rollover, archive cards, things/attributes, chat conversations/read cursors, automation hooks, allowlists and triggers.
  • Encrypted file and credential attachments, protected-secret replacement, explicit credential delivery and OTP generation.

Storage and migration

DIR/
  store/
    memories.db       # Same encrypted Store format as the daemon
    memories.lock     # Exclusive process lock
  memories.json.bak   # Only if imported from the old notes library

SQLite may also create its normal transactional journal files. Store saves are transactional; vault identity, signed revisions and audit state persist together. One Center process owns the Store at a time. The default vault and named vaults used by configured or issued grants are created as needed. Grant bindings use canonical vault IDs internally and cannot enumerate/access other vaults. Unix directories are owner-only (0700); Store files and the legacy backup are owner-only (0600). Protect the directory and service account on every platform.

If DIR/memories.json exists, startup imports its ordinary notes, preserving IDs and vault names, in one atomic content transaction, then renames the input to memories.json.bak. Invalid records fail startup without discarding the source or publishing a partial note batch. A retry after an interrupted rename skips identical imported records; conflicting records or an already-existing backup fail closed for operator review instead of overwriting data. The legacy backup is still plaintext: securely remove or independently encrypt it after verifying the import and arranging a backup. Newly written content is never saved as JSON.

Encryption and threat model

The Store is unlocked at process start with the operator-held password (Argon2 password wrapping and the same authenticated encryption as local Store). On each authorized content operation, hosted dispatch refreshes the server session before idle-expiry checks; it does not idle-lock after fifteen quiet minutes. Shutdown and admin lock release in-memory Store keys and cached content. Admin lock/unlock lives on separate admin HTTP routes, never the grant content RPC. There are no hosted sudo or password-change RPCs.

This is server-side encryption, not end-to-end client encryption. Operators controlling the host/process/password can read every hosted vault, including private content and credentials. Anyone holding both the password and a disk copy can decrypt the Store. Host memory, crash dumps, environment inspection, malicious authorized clients and compromised TLS termination are outside this confidentiality boundary. private still controls safe summaries/retrieval, but is not a barrier against a hosted grant authorized to read that vault.

The daemon instead uses a device password and may use local OS-sealed unattended keys. Hosted mode has no sealed device keys or per-device password UX. Device vault keys are not uploaded or shared with this hosted Store automatically. Use the TLS reverse proxy described above; HTTP requests and authorized secret delivery are plaintext to the Center and TLS terminator. Do not log bearer tokens, RPC bodies, credential responses or passwords.

Remote lock/unlock

Hosted routes require the admin bearer and exist only with --data:

Route Behavior
GET /v1/admin/status Returns unlocked, hosted mode, member count and restart_required, without secrets
POST /v1/admin/lock Drops Store keys/state, clears caches and suspends grants until unlock
POST /v1/admin/unlock Accepts {"password":"…"} and reopens the same existing Store with the operator password

While locked, content routes and hosted access-token minting return 503; no grant can serve plaintext. Admin status, member listing/removal and device ticket issuance remain available. Member creation/scope/vault update requires an unlocked Store. Wrong hosted passwords return 403; failed authentication uses core exponential backoff (subsequent attempts during backoff return 429). A successful unlock restores static/member access, not revoked minted tokens. Requests already completed before lock cannot have their returned plaintext retracted. Startup still requires MEMORIES_CENTER_VAULT_PASSWORD; remote unlock does not persist the supplied password or change startup configuration.

export MEMORIES_CENTER_ADMIN_TOKEN="…"
memories center status --center-url https://center.example.com
memories center lock --center-url https://center.example.com
memories center unlock --center-url https://center.example.com  # password prompt

Device unlock is different: POST /v1/tickets/unlock is available on both ticket-only and hosted Centers. The admin signs a short-lived unlock ticket; the local daemon verifies its kind, signature, configured Center and expiry, then unlocks with the operator's locally prompted device password. It grants no sudo/MCP elevation. memories center unlock --ticket always uses local, OS-authenticated IPC, not the selected remote daemon or the Center HTTP API. There is no automatic device-password delivery, escrow or push channel.

# Admin: save the returned ticket securely; do not put it in logs.
memories center issue-unlock --center-url https://center.example.com --ttl 300
# On the device (daemon running with the same --home/config):
memories center unlock --ticket "…"  # local device password prompt

Unlock tickets are reusable until expiry and authorize unlock on endpoints trusting that Center, subject to each endpoint's password; they are not single-use or device-bound. Existing local password unlock remains available on ticket-only managed devices. SaaS attachments cannot use a ticket to enroll or unlock a parallel local content Store.

Password custody: the hosted operator holds the hosted Store password and sends it to the trusted Center/TLS terminator only for hosted unlock. Device passwords and vault keys stay on the device/local daemon in ticket mode. A compromised Center can issue unlock tickets but still needs a device password to unlock its ciphertext. A compromised hosted admin can lock the Store and mint members with plaintext authority whenever it is unlocked. Hosted operators already control the process and can read all unlocked plaintext; admin/member separation is not encryption against the operator.

Durable members

Hosted members are named, durable bearer principals with the same five GrantScope tiers, not identity-provider accounts. An optional role is reader, editor, admin, or owner; omitted roles retain the legacy mapping from read, write/read_write, read_write_delete, and allow_all, respectively. An explicit role caps the member scope (Reader: read, Editor: read_write, Admin: read_write_delete, Owner: allow_all). Member CRUD routes still require the operator admin bearer, not a member's Admin/Owner role:

Route Body / response
GET /v1/admin/members members array of id, vault, scope, role, created and updated; no bearer tokens
POST /v1/admin/members {"id":"agent","vault":"default","scope":"read"}; metadata plus generated token, shown only in this response
PUT /v1/admin/members/{id} Optional scope, vault, and/or role; same bearer, metadata-only response
DELETE /v1/admin/members/{id} Removes member and immediately invalidates bearer

IDs are stable 1–64 character names (ASCII letters/digits, -, _); center-admin is reserved for the operator Owner. Duplicate IDs return 409. Tokens are random, unique and distinct from the admin/static tokens. Use a protected terminal/output sink for member creation; the CLI prints the returned bearer once by design. Never capture creation responses in logs. Members authenticate through the same grant table on /v1/memories and /v1/rpc; scope/vault changes apply to subsequent requests without restarting. Requests authenticated before an intervening membership mutation are rejected before content dispatch. Removing/recreating a member is the token rotation procedure.

DIR/members.json is an atomically replaced, owner-only (0600 on Unix) durable file under the owner-only hosted directory. It contains the bearer tokens in plaintext, but no passwords, keys or vault content; protect it like credentials, include it in protected backups, and do not commit it. Startup reloads/validates members and fails closed on corrupt or colliding bearers. A failed durable member save locks content and sets restart_required: remote unlock returns 503 until the operator repairs the filesystem and restarts to reload durable authorization. This prevents uncertain persistence results from resurrecting revoked bearers.

memories center member list --center-url https://center.example.com
memories center member add --center-url https://center.example.com --id agent --vault default --scope read
memories center member set --center-url https://center.example.com --id agent --scope read_write
memories center member remove --center-url https://center.example.com --id agent

This is still a single operator-keyed Store with logical per-vault grant binding, not hard multi-tenant isolation, OIDC/SSO, user passwords, refresh tokens, quotas or self-service recovery.

Grants and API

Static grants use repeatable --grant TOKEN[:SCOPE[:VAULT]] and/or comma-separated MEMORIES_CENTER_GRANTS; an omitted scope means read, and an omitted vault means default (TOKEN::VAULT chooses a vault with the default scope). Their lifetime is the process lifetime. Admin-authenticated POST /v1/tickets/mcp-grant returns the unchanged signed ticket plus a hosted access_token bound to its vault/scope. That access token expires after minutes (1–60) and survives hosted restart. The ticket's ttl separately governs ticket import validity. Hosted minted grants are persisted atomically in owner-only (0600) DIR/grants.json: only SHA-256 bearer hashes and authorization metadata, never plaintext minted tokens. Bearer hashes are compared in constant time. Revocations persist, and member removal still cascades through restored grant ancestry after restart. Expired grants and their descendants are purged on load, every minute and during authentication/issuance. Treat this owner-controlled authorization file and its backups as security-sensitive; rolling back it or the Store can restore old state. Persistence failure locks the service and requires repair/restart.

Locking the hosted Store drops its keys and makes grants unusable, without revoking them; unexpired minted grants, static grants and durable members resume after unlock. Each durable member has a persisted generation counter. Issuer and recipient generations are frozen at issuance and never refreshed by authentication; the public generation field is the recipient's issuance generation. A scope/role/vault update or removal bumps only that member's generation; removed-member counters are retained so recreating an ID does not reset it. Removal/update explicitly revokes grants issued by or to that member and all descendants transitively; unrelated grants remain usable, including after restart. Static/durable credentials are re-established at the explicit policy transition, not at authentication. Static principal and durable-member parent IDs are stable across restarts so restored delegation chains remain connected.

The admin bearer alone receives 401 on /v1/memories and /v1/rpc, even for reads. It cannot directly mutate vault content. Administrators can still mint separate grants through the ticket or authorization API; that administrative capability is not a confidentiality barrier against the administrator.

Authorization HTTP API

These routes exist only in hosted mode. They authenticate either the operator admin bearer (an Owner across all vaults) or a hosted principal's bearer. The operator controls server lock/unlock/member CRUD; a hosted Admin/Owner may manage authorization only within the bearer vault and scope. No terminal elevation is needed. Readers and Editors cannot issue or delegate, even when granted broader content access. Role/scope/vault/expiry violations return 403 with a clear authority error; handler validation failures return 400, missing objects 404, repeat decisions 409, and expired/revoked/stale-generation bearers 401. Malformed or mistyped JSON can be rejected by Axum's JSON extractor with 400 or 422.

Route Fields and response
GET /v1/access/{vault} Admin/Owner; vault, principals grouped under owner, admin, editor, reader, active grants including expiry, plus pending_requests and approved_unclaimed_requests (id, requester, scope, approvers list or "broadcast", Unix-seconds created and expires)
POST /v1/grants Admin/Owner; vault, optional scope (default read), duration_minutes (default 30, alias minutes), grantee; grant metadata plus one-time token
GET /v1/grants Optional vault; Admin/Owner see all issued grants in the vault, other current principals see only their own issued grants; grants array, excluding static/durable credentials and expired/stale grants
DELETE /v1/grants/{id} Issuer or Admin/Owner within the vault; revokes minted grant and descendants, returns revoked and count
POST /v1/requests Any member; vault, optional scope (default read), reason, duration_minutes (default 30, alias duration), approvers; pending request metadata
GET /v1/requests Optional vault; pending requests belonging to requester or eligible approver, plus requester's approved-unclaimed requests; no tokens
POST /v1/requests/{id}/approve Eligible Admin/Owner; no body; approved-unclaimed request metadata and validated bounds only, no grant or token
POST /v1/requests/{id}/claim Requester's durable member bearer only; no body; request plus newly minted grant with one-time token
POST /v1/requests/{id}/reject Eligible Admin/Owner; optional JSON reason; request with rejected status
POST /v1/requests/{id}/cancel Requester only; no body; request with cancelled status
GET /v1/audit Admin/Owner; optional vault, format=json|jsonl (default json); verified entries/verified, or newline-delimited export

All issuance/request durations are 1–60 minutes. At most 10,000 grants may be active at once (memories_core::service::MAX_ACTIVE_GRANTS); the local daemon uses the same cap. Issuers cannot exceed their role ceiling or bearer scope, leave their vault, or extend their parent expiry. Recipients default to the member issuer; explicit grantee names must be existing principals of the selected vault. The reserved center-admin grantee is always rejected with HTTP 403; previously persisted grants with that subject are also unusable. Operator issuance without a grantee (including hosted ticket issuance) creates an anonymous principal whose subject is its grant ID, not center-admin. No recipient may have a higher role than the issuer's effective role, even for a read-only grant.

Both Center and daemon define pub const MAX_DELEGATION_DEPTH: u32 = 2. Direct member/operator issuance is depth 1; using a grant issues at parent.depth + 1, and depth 3 returns HTTP 403. Durable member credentials are direct member authority (internal depth 0); grants they mint are depth 1 with no grant parent. Persisted legacy grants without depth are reconstructed from their full parent chain, including configured static and durable credentials. Missing/cyclic or over-limit ancestry is unusable and purged on startup.

Grant authority with subject != issued_by is a designated grant caller. Such callers cannot approve, reject, cancel, or claim any permission request, even their own (HTTP 403); they are not eligible approvers. They may create requests, but must use their real member credential for permission workflows. Delegated issuance preserves the real issuer so a designated bearer cannot launder itself into a self-issued grant. Self-issued grants keep their existing request rules, and claims still require the requester's durable member bearer. Deferred legacy approvals made using a designated-grantee grant cannot authorize a claim; obtain a new approval from eligible member authority.

Breaking changes: higher-role and center-admin recipients, designated-grantee permission decisions/cancellation/claims, and delegation deeper than two grants are rejected. Operator-issued subjects are now anonymous rather than center-admin; member-issued roots no longer list the durable member credential as a delegation parent. Reissue an appropriate root or use a member credential instead of relying on these former behaviors.

Grants record id (an independent opaque ID), subject, scope, vault, Unix-seconds expiry, parent, issued_by, generation, depth, and role. Static principal IDs are static:OPAQUE_ID. GET /v1/grants returns only issued time-bounded grants with id, parent, issued_by, grantee, scope, vault, Unix-seconds expiry, generation, depth, and Unix-seconds created when available. It never returns a token or bearer hash. grantee is the recipient (subject in issuance/access responses). Generation remains the generation at issuance. This issued-grant view differs from the unchanged GET /v1/access/{vault} principal/access view: static and durable credentials appear only in the latter and are removed via configuration/member CRUD, not DELETE /v1/grants/{id}. Issuers may list their own grants; receiving a grant alone does not make it visible to a non-admin recipient. Hosted bearers are restricted to their vault; an omitted vault selects that vault, while the operator can list across vaults. memories center grant list [VAULT] returns this same issued-grant response.

Requests with empty/omitted approvers are broadcast via eligible pending lists; nonempty lists contain named Admin/Owner approver IDs (or center-admin). Both approval and rejection are bounded by the approver's scope, vault, role, and remaining grant lifetime. The requester cannot approve or reject their own request, even as an Admin/Owner or a named approver; they may still cancel it. Decisions are serialized under the same authorization lock and may occur only once. Request states are pending, approved, rejected, or cancelled. Approval records approved grant parameters and the approver's scope/vault/role/ expiry bounds, but mints nothing and returns no token. The requesting member uses their durable member credential to call POST /v1/requests/{id}/claim, or memories center request claim ID. Approvers, other members, and minted grants for the same requester subject are rejected. Claims are serialized: only one claim succeeds, and subsequent attempts return 409, including after restart. The grant's generation and creation/expiry clock start at claim; its expiry is capped by the recorded approving authority's expiry. Expired/revoked parents and changed issuer/requester generations prevent claims. Only the token hash is persisted with grant metadata. request.claim audits the requester actor and grant ID, never the token. If the one-time response is lost, request a new approval. An approved content grant does not change its requester's member role or delegation authority.

Breaking change: approval responses no longer contain grant or its token; clients must explicitly claim with the requester member bearer. Request lists show approved-unclaimed metadata, not tokens. Legacy approved requests lacking deferred approval bounds cannot be claimed; create a new request instead.

Center audit and request files

DIR/requests.json is atomically replaced with mode 0600 and stores request metadata and completed decisions across restarts. DIR/audit.jsonl is opened in append mode with mode 0600, fsynced after each entry, and never rewritten by Center. Both live under the mode-0700 hosted directory. Reasons are audit metadata: do not put passwords or other secrets in them. Audit records do not include bearer tokens, passwords, keys, or vault content.

Audit entries expose sequence, timestamp, prev_hash, hash, action, and optional issued_by / via_grant for grant-authorized actions. The former is the real issuer, the latter the caller's grant ID; actor remains the grant subject except that grant.issue records the real issuer as actor and the grantee as subject. The fields default to absent and are omitted when absent, so old audit logs retain their existing hashes. The shared Store's signed hosted content audits also record these fields. The existing fields include actor, subject, scope, vault, expiry, parent, reason, request_id, and object. The first prev_hash is null; later values reference the preceding hash. SHA-256 covers the JSON tuple (sequence, timestamp, prev_hash, event_fields). verify_audit_chain(path) in crates/center/src/saas/audit.rs verifies continuity, hashes, and complete JSONL lines. Startup verifies the file and reconciles request decisions and claims against it, so a crash after auditing a decision or claim but before replacing request metadata cannot enable a repeat. Appends and reads verify the chain against the current in-process head; audit reads verify the entire chain before applying a vault filter.

Actions are device.authorize (bootstrap ticket issuance), grant.issue, grant.revoke, member.add, member.set, member.remove, session.lock, session.unlock, request.create, request.approve, request.claim, request.reject, and request.cancel. Lock and member-change revocations are audited too. JSONL exports use Content-Type: application/x-ndjson; filtered exports retain original hashes/sequences and are not independent complete chains. Vault-bound Admin/Owner callers only see their vault's entries; the operator also sees global events and may export the complete log. Any audit append/request persistence failure locks the service, sets restart_required, and disables remote unlock until repair and restart. Authorization mutations and permission decisions return 503 while restart_required is set; read-only operator inspection remains available. Keep these files in protected backups, not source control.

This is an unkeyed chain, not a device signature or external anchor: a compromised operator can rewrite/recompute it, omit events, or roll back all files together. Complete tail truncation before startup cannot be detected without an external known head. Do not treat chain verification as an independent security audit.

Hosted content scope and API

Hosted content scopes apply to the operation, not merely the HTTP method. Authorization-management routes additionally check the principal's role as described above; content scope alone never grants host administration:

Scope Hosted authority in the bound vault
read Safe list/search/graph; full authorized reads, file reads, history/diff/audit and explicit credential_copy
write Content creation/updates, protected/OTP enrollment, files/credentials, world/chat/automation writes; no prior-content read or delete
read_write Both; also otp_code (requires read + write because HOTP advances state)
read_write_delete Both plus asset, thing, attribute and automation-hook deletion
allow_all All of the above within the content-only hosted surface, not server/device administration

Unlike daemon MCP, hosted dispatch supplies operation-local elevation only after the closed operation allowlist, scope and vault binding pass. Daemon check_mcp and its local elevation restrictions are unchanged. Hosted agents/MCP adapters can use their scoped bearer on the full RPC content surface; no local terminal elevation or daemon connection is required. Cross-vault brain/archive references cannot resolve another vault's content through a grant.

  • GET /v1/session → current vault, role, scope, expiry and read/write/delete capabilities, without bearer material. Write-only tokens can inspect their session; operator admin tokens cannot. See mobile hosted mode.
  • GET /v1/memories → {"memories":[...]}. Ordinary non-private note records retain id, vault, title, body; cards/private/file/credential records use safe summaries, never credential bodies or resolved secrets.
  • GET /v1/memories/{id} → full safe client content + id, vault. Credentials require explicit credential_copy rather than generic reads.
  • POST /v1/memories and PUT /v1/memories/{id} accept title/body plus full Content metadata. PUT preserves omitted fields. Adding card metadata at creation infers kind: "card" if no kind is supplied. Structured/protected profile creation uses its dedicated RPC, not generic PUT. A write-only PUT returns only submitted fields and record identity, not existing private fields.
  • Optional JSON vault / ?vault=… must match the grant name or canonical ID; omission selects the grant's vault. DELETE /v1/memories/{id} requires delete.
  • POST /v1/rpc accepts the daemon Request JSON (op in snake_case), returns its direct JSON result, and checks each operation independently. Use canonical asset/revision IDs from responses; vault accepts the grant's name or canonical ID. list without vault selects only the grant's vault. Errors use HTTP 401 (unknown/admin/expired token), 403 (scope/surface/vault), 404 (missing), 409 (conflict), 400 (invalid input), or 422 (malformed Request JSON/schema).

Examples (send with the grant bearer, not the admin bearer):

{"op":"brain_card","vault":"default","title":"Research","abstract":"Summary","links":[]}
{"op":"history","vault":"default","asset":"CANONICAL_ASSET_ID"}
{"op":"persona_world_get","vault":"default","asset":"PERSONA_ASSET_ID"}
{"op":"put","vault":"default","content":{"title":"attachment.bin","kind":"file","private":true,"body":"BASE64URL_BYTES"}}

Files/credentials use the existing URL-safe base64 body convention, not a second blob store. Core's 16 MiB content-body limit still applies; the HTTP JSON body limit is 24 MiB. protect_secret maintains credential references atomically. Generic reads/lists/diffs never resolve stored credential bytes; authorized credential_copy is the intentional explicit plaintext delivery endpoint.

Hosted archive_history/archive_read address signed revisions of the bound live vault (including historical/tombstoned revisions), not daemon recovery archives. archive_audit addresses that vault's audit. Hosted reapply appends a signed historical or quarantined revision as a new head, including structured/ protected metadata; reapplying a tombstone also requires delete. Unlike inventing protected references through generic PUT, this restores references already authenticated in the vault's signed history. It does not resurrect a deleted linked credential automatically; reapply that credential revision separately if needed.

Intentional differences from the daemon

  • POST /v1/bootstrap and /v1/restore always return 403: this remains a center-managed library, not device self-service enrollment/restoration.
  • No sudo, device enrollment inside hosted content, vault policy membership administration, grant mint/revoke content RPC, full-store backup/restore RPC, remote sessions/proxies, cloud/relay sync, unattended custody, execution or crypto-job execution. Such requests fail closed even with allow_all.
  • Hosted lock/unlock/status and durable member administration are separate admin-authorized HTTP routes, not capabilities conferred by allow_all.
  • Recovery archives and cross-vault content resolution are not exposed. Historical reads/reapplication of the hosted vault's own revisions are supported.
  • Semantic/RAG model configuration/generation and model installation are not hosted endpoints. Lexical search and the synchronous retrieve path reuse the core retrieval policy (no private content, file bodies or credential bodies).
  • Automation runs on explicit RPC and existing content-trigger events; there is no background scheduler. Outbound webhooks use the core's exact URL allowlist; treat a hosted writer who can configure webhooks as authorized outbound network access and enforce operator egress restrictions for untrusted tenants.
  • SaaS-configured daemons and the thin CLI helper use hosted /v1/rpc; native mobile uses its separate hosted_* FFI path with a smaller content allowlist. Ticket-only local IPC remains intact. There is no JSON-RPC MCP admin tool server.

Store persistence still uses whole-state transactional saves and serialized service operations; this is not independent tenant-key encryption or a claim of production multi-tenant isolation/security certification.

Mobile / FFI

The shipped Flutter app chooses Standalone, Center or SaaS in its own interface. It does not load the daemon's config.toml or configure a ticket-managed local service. Its Center choice connects to a self-hosted server with hosted storage, while SaaS connects to https://memories.ivjn.us. Both use the hosted FFI operations below.

The shared service enforces ticket-managed Init/Restore and grant restrictions when a caller configures Centers on that service. This is separate from the phone's mode selector. Mobile FFI continues to deny Run/CryptoJob/local MCP grant management/remote/proxy and local grant/permission-request RPCs; selecting a hosted mode does not widen that local interface.

Residual work

Remote instruction approvals and other elevated policy surfaces may later accept Center-signed approval tickets. Until then, Center-managed endpoints still use local sudo for vault-elevated operations other than MCP grant minting and bootstrap. allow_all is reserved to grow with those surfaces without renaming scopes.

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.

Mobile hosted mode (phone client)

The mobile app's Center and SaaS modes connect directly to a Center running with --data DIR; the phone does not need a daemon mode = "saas" entry. See mobile usage for first connection and switching, and configuration for profile migration. The FFI memories_call API accepts these hosted_* operations:

  • {"op":"hosted_connect","center_url":"https://…","bearer":"…","ca_file":null,"insecure":false} → {"session":N,"url":…,"server_readable":true}
  • {"op":"hosted_rpc","session":N,"request":{…}} uses the content allowlist below
  • {"op":"hosted_status","session":N} authenticates via GET /v1/session
  • {"op":"hosted_requests","session":N}, hosted_approve / hosted_reject (id), hosted_access (vault)
  • {"op":"hosted_disconnect","session":N}; memories_lock_all also drops every hosted session

HostedClient::rpc in crates/core/src/center.rs permits list, read, search, delete, type_list, type_show, type_create, type_update, type_delete, card_create, card_fields, card_field, credential_set, credential_show, and put when content.kind is memory, card or credential. The server still applies the member's vault, role and scope. This FFI allowlist is not a promise that every operation has a Flutter screen: hosted knowledge supports list, server search, detail, ordinary memory/card editing, creation and confirmed deletion. Structured profiles, protected cards, type templates and sealed cards require specialized editors. Authorization requests and paired-device controls remain available.

GET /v1/session returns vault, vault_id, scope, role, expires_at (Unix seconds or null), can_read, can_write, can_delete and server_readable. It accepts any active scoped content token, including write-only tokens, but rejects operator admin tokens. It returns no bearer or secret material. Each content request still rechecks authorization; this response is a UI snapshot, not a grant. Deploy this endpoint before upgrading the app: older servers without it cannot validate a new app connection.

Search uses the shared retrieval service and excludes private content, credentials and files. The unfiltered knowledge list can include private cards for an authorized reader. Failed saves retain the draft; deletion requires confirmation and removes the item only after server success. Updates use the existing revision service without an atomic expected-revision precondition; simultaneous edits can replace each other's latest fields.

Hosted mode is server-readable: the Store is encrypted at rest, but the operator holds the password and can read hosted content. The app marks it “server can read”. Rust keeps each live client's bearer in memory, redacted from Debug and errors; Flutter persists each mode's URL/token pair in platform secure storage. Switching ends the active session while retaining the profile. Disconnect removes that profile's saved bearer; it does not revoke the server token. Backgrounding drops hosted sessions and visible content; reconnection on return is explicit.

Device-to-device E2E secrets (hosted Center)

Hosted vaults are server-readable. For secrets the Center must never see, pair devices and deliver sealed envelopes (crypto::Envelope: X25519 + signed by the sender's Ed25519 key). The Center stores only public keys and ciphertext.

Pairing flow

  1. CLI: memories device hosted init --backend <keychain|secret-service|dpapi|owner-file> creates the device identity (32-byte seed in OS custody; X25519 + Ed25519 derived).
  2. CLI (member token in MEMORIES_CENTER_TOKEN): memories device hosted pair --vault V → POST /v1/devices/pairings with both public keys and a self-signature. The Center creates a pending PermissionRequest carrying the device fingerprint and returns {code, request_id, qr: {pairing_id, fingerprint, center}} (center is the Center's --center-id). Pairings expire after 5 minutes. The CLI prints a terminal QR (stderr) of {"type":"memories-pair","v":1,"pairing_id","fingerprint","center","code"} — the one-time code is included so scanning is enough; stdout JSON is unchanged. Re-pairing a key that is already active returns 409; after revocation the same key can pair again.
  3. Phone (admin of the vault) scans the QR (or types the code) → GET /v1/devices/pairings/{code}. The phone refuses a QR that is not a Memories pairing QR, whose center differs from the connected Center, or whose fingerprint differs from the fetched device. Unknown, expired, or used codes all return 404. — single-use, expiring, 10 failed lookups/minute → 429. Shows the device fingerprint.
  4. Phone approves with POST /v1/requests/{id}/approve and body {"attestation": DeviceAttestation} signed by the phone's Ed25519 key over device fingerprint, both device keys, pairing id, approver, scope and expiry.
  5. CLI: memories device hosted claim --request-id ID (once). The device-scoped grant token is stored in OS custody; the Center keeps only its hash.
  6. Compare the SAS: the phone shows it (grouped 123 456) before approving; after approval memories device hosted sas --pairing-id P prints the same digits on the CLI. Different digits: revoke the device and pair again.

Mobile permissions: Android CAMERA (camera feature optional), iOS NSCameraUsageDescription. If camera access is denied the phone explains how to enable it in system Settings and offers manual code entry. The CLI and phone translate Center statuses into actionable messages (404 expired/used/other Center, 409 already done or already paired, 403 not permitted or revoked device, 422 e2e content refused, 429 rate limited).

SAS = first 6 decimal digits of u64_be(SHA-256("memories/device-sas/v1\0" ‖ phone_ed25519_pub ‖ "\0" ‖ cli_ed25519_pub ‖ "\0" ‖ pairing_id)[0..8]) mod 10^6, zero padded. The phone has the CLI key from the fetch; the CLI learns the phone key from the attestation. A mismatch means a substituted key: revoke immediately.

Rules

  • Device grants are bound to the device (unrevoked, attested) and the member's generation; delegation depth ≤ 2. Devices cannot issue grants or decide requests.
  • Bumping or removing the member invalidates the device and deletes its envelopes.
  • PUT|GET|DELETE /v1/sealed/{vault}/{card}/{device}, GET /v1/sealed?device=: envelopes only for attested devices, addressed to the device's X25519 key, from a known signer; versions must increase. Audit records fingerprints only.
  • Cards with e2e: true refuse plaintext secret content on the hosted Center.
  • /v1/access/{vault} lists devices. MCP: device_list, sealed_reveal (requires intent: "reveal_plaintext" and a reason).

Threat model

The Center operator cannot read sealed secrets, but can withhold or replay old envelopes (versions are monotonic per device), and sees metadata (who, which card, when). Pairing trusts the SAS comparison and the approver's phone key. Revocation cannot recall plaintext already delivered to a device: after revoking, rotate the secret upstream.

E2E hardening (P2)

  • Sticky e2e flag. Once a card is e2e: true, writes that drop the flag are refused (422). Only an Owner grant (not a device grant) may clear it, and only with a write that carries no secret material; later writes then follow normal rules.
  • OTP parameters. A sealed OTP secret may be a bare Base32 seed (SHA1, 6 digits, 30 s) or a full otpauth://totp/... URI; memories device hosted sealed-get --otp honors algorithm (SHA1/SHA256/SHA512), digits (6/8) and period (30/60) from the URI. device hosted pair also prints the pairing payload as a terminal QR code (stderr).
  • Re-seal after revocation. Revoking (or invalidating) a device deletes its envelopes and marks each affected card needs_rotation (shown in GET /v1/sealed?device=, GET /v1/sealed/rotation?vault=, the access list, and the phone's re-seal page). Revocation cannot recall plaintext already delivered: rotate the secret upstream, then use the phone's Re-seal for all devices (hosted_reseal_all) which seals to every currently approved device with a new version and clears the flag. Revoked devices are never included.
  • Phone recovery. The Center never holds the phone identity. When created, the phone shows a one-time recovery code (Base32 of the 32-byte seed + 2-byte checksum, groups of four) to write down; Restore from recovery code (hosted_phone_restore) rebuilds the same identity.
  • Identity rotation. Move to a new phone key (hosted_identity_rotate) creates a new identity and posts POST /v1/devices/identity-rotations with a record signed by both the old key and the new key; only an admin member (not a device) may post it, and the old key must already be trusted for some device. CLIs pin the first approving phone key (hosted-device-trust.json, trust on first use) and accept envelope signers reachable from that root through verified rotations only.
  • Hardware-backed storage. The phone seed is stored with flutter_secure_storage using the strongest built-in options: iOS/macOS Keychain passcode accessibility (this device only, not synced, requires a device passcode); Android Keystore-wrapped storage with biometric or device-credential gating and resetOnError: false. Limits: the seed is still exported into app memory to sign/seal (Secure Enclave / StrongBox cannot do X25519/Ed25519 natively), so this protects at rest, not against a compromised running app.
  • Transparency log. The Center keeps an append-only, hash-chained device log (device.pair, device.approve, identity.rotate, device.revoke) at GET /v1/devices/log. Each entry embeds the CLI pairing self-signature, the phone attestation, or the rotation record. memories device hosted log --verify checks chain linkage, every signature, and that the log extends the head it verified last time (hosted-device-log.json); a rewritten history is reported as an error.