The V3-P Capability Vault: Per-Use Grants and Graph-Walked Audit Chains for LLM Agent Tool Surfaces

Fernet-encrypted credential storage, time-bounded scope-bounded grants, and an audit trail that is itself a queryable graph

Tab Levitas

Memagen™

2026-05-08 · Version v1 · ~7 pages ·Subsystem: capability vault
↓ Download PDF

Abstract

Most agent frameworks treat tool access as a binary per-server toggle or as a trust-based decision the model is allowed to make. Both shapes fail when the tool surface includes credentials whose leak or misuse causes real damage. We describe the Memagen™ V3-P capability vault and its audit chain: a Fernet-encrypted at-rest credential store fronted by a per-use capability protocol whose grant, use, and revocation events persist as nodes in the agent's compound graph and are queried by graph walk rather than log archaeology. The vault ships in the open-core (V1-lite, MIT) distribution; this paper documents the implementation in detail. We cover the constraint dataclasses binding a grant to a particular `action_kind`, the mint–consume–revoke lifecycle in `core/security/capabilities.py`, the graph-promotion side-effects that turn each grant into a `capability_policy` node edged `owned_by` to its user, the audit-as-graph-walk pattern that makes a security review a graph filter rather than a regex over JSON lines, the `_unsealed_read` trust boundary, the append-only audit log at `~/.memagen/safety_audit/`, and the soft-fail mode under which the flat capability table remains canonical when the graph is unavailable. We are honest about what we have and have not measured: 60 tests pass against the post-V3-S P3 wiring; audit-query latency at scale is unprofiled; and the schema-fragility of an `_ensure_schema` path under non-canonical fixtures, mitigated by the `_safe_get_user_graph_node_id` direct-read fallback, was the dominant delivery risk.

Keywords — capability-based security, agent infrastructure, audit chain, tool gating, fernet

1. Introduction

Tool access in current LLM agent frameworks takes one of two shapes. Either tools are gated by a static per-server toggle — server X is enabled, every tool it exposes is callable for the lifetime of that toggle — or access is trust-based: the model decides, the runtime executes, and the operator hopes the model’s judgment scales with the consequences of its errors. Both shapes work when the worst tool in the catalog reads public data. Both fail the moment the catalog acquires a credential, an outbound payment, a git push, or a destructive shell command.

Capability-based security is the answer the operating-systems community converged on more than forty years ago (Hardy, 1985; Shapiro & Smith, 1999). A capability is an unforgeable token whose existence is necessary and sufficient for a particular operation; possession is permission. Capabilities have been applied to language runtimes (E, Caja), to operating-system kernels (KeyKOS, EROS, seL4), and to web identity flows (OAuth scopes being a capability-shaped specialization). Applying them to LLM-driven agents is overdue.

This paper describes the Memagen™ V3-P capability vault: an open-core (V1-lite, MIT) implementation of capability-based security for agent tool surfaces. The contribution is the integration, not the idea: a Fernet-encrypted credential store; a small set of typed constraint shapes; a mint–consume–revoke lifecycle whose audit trail co-locates with the agent’s main memory substrate; and an audit-query pattern in which a security review is a graph filter rather than a flat-log scan. The vault sits beneath the safety-toggle layer documented in the companion paper Default-Restrictive Safety Toggles: toggles set policy bounds; capabilities enforce per-use authorisation inside those bounds.

Section 2 surveys related work. Section 3 states design goals. Section 4 describes the architecture and shows the per-use flow as a sequence diagram. Section 5 walks the mint–consume–revoke lifecycle with real signatures. Section 6 describes the audit-as-graph-walk pattern. Section 7 covers implementation details and the lite-mode fallback. Section 8 reports what works, what passes tests, and what remains unmeasured. Sections 9 and 10 discuss tradeoffs and future work.

2.1 Operating-system capabilities

KeyKOS (Hardy, 1985) was the first operating system to make capabilities the sole mechanism by which authority could transfer between principals, building on Dennis & Van Horn (1966) and the CAP computer (Wilkes & Needham, 1979). EROS (Shapiro & Smith, 1999; Shapiro et al., 1999) inherited KeyKOS’s model and addressed the capability myths — claims that capability systems could not enforce confinement, could not revoke, and could not handle accountability — by showing that each rested on a conflation of capabilities-as-data with capabilities-as-references. V3-P uses capabilities-as-data: persisted records, not in-memory pointers, with unforgeability sourced from the difficulty of guessing a 128-bit uuid4().hex rather than the impossibility of forging a kernel reference. This is weaker than a true ocap system; it is the right tradeoff for a runtime whose principal boundary is a process, not an address space.

2.2 Object-capability languages

E (Miller, 2006) and Caja (Miller, Samuel, Laurie, Awad, & Stay, 2008) are the canonical object-capability languages for the web. Both make capability-as-reference the default communication shape: no ambient authority, only objects passed by reference. We did not adopt either as the runtime substrate because the implementation language is Python — which has ambient authority everywhere — and because the audit requirement (every grant, use, and revocation as durable structured data) is not a natural fit for references that disappear when they go out of scope. V3-P capabilities are explicitly persisted records.

2.3 LLM-agent tool gating

Vendor tool_use blocks and function-calling formats validate the shape of a model-emitted tool call against a declared schema. Validation rejects malformed shapes; it does not gate execution on a per-use authority check. The Memagen™ lenient parser (master paper, Section 4) handles the shape problem; the V3-P vault handles the orthogonal authority problem. The two are layered: the parser produces a canonical ToolCall, the capability check decides whether this particular invocation is authorised, and only then does dispatch occur.

OAuth 2.0 scopes (Hardt, 2012) are the closest application-layer analog. A scope binds an access token to a set of permitted operations. V3-P diverges in two ways. Scopes are typically static per-token; a V3-P capability is per-use, decremented atomically by consume_capability_use. OAuth’s audit trail is the issuance record plus whatever the resource server chooses to log; V3-P makes the audit trail a first-class graph subgraph.

2.4 Why none of these were adopted directly

We considered a vendored OAuth library, an ocap-Python wrapper, and an external policy engine (Open Policy Agent, Cedar). Each was rejected on the same ground: the audit story. The capability lifecycle has to be queryable as graph-shaped data because the rest of working memory is graph-shaped data. An external policy engine produces a separate audit log that has to be joined back to memory by application code; the substrate-level co-location we describe in Section 6 is what we wanted, and that meant building it.

3. Design goals

V3-P has five explicit design goals.

Per-use grants, not per-server toggles. Every tool dispatch passes through an authority check that names the specific action authorised — this tool, this HTTP method, these URL patterns, this payload ceiling. A grant for net.fetch against an approved API host must not accidentally authorise net.fetch against an internal admin endpoint.

Time-bounded, scope-bounded, payload-bounded. A grant carries an optional expires_at, a typed constraints object whose shape varies by action_kind, and an optional uses_remaining counter the consume path decrements atomically. Each axis is enforced at the point of use, not only at the point of mint.

Audit chain queryable as a graph subgraph. Every grant, use, and revocation appears as nodes and edges in the same compound graph that holds working memory. A review of “every grant for tool=net.fetch in the last 30 days, walked to every use, with every external recipient surfaced” should be a graph filter plus two hops, not a SQL JOIN against a flat audit table in a different schema.

Encrypted at rest. The vault stores credentials as Fernet ciphertext (Bernstein, 2014). Plaintext exists only inside _unsealed_read and only after capability verification. Audit entries record a capability id and a content-hash, never the credential itself.

Soft-fail when the graph is unavailable. The Lite distribution runs in graph-stripped mode (MEMAGEN_LITE_MODE=1) without the compound-graph engine. The flat capability table in SQLite remains canonical; graph stamping is a side-effect, gated by the lite-mode flag, and any failure inside the graph path is logged and swallowed without breaking the mint or consume return path. This principle made the V3-S P3 wiring deliverable: a fragile graph-schema branch never blocks the capability layer.

4. Architecture

The vault is a four-layer structure. Figure 1 traces the per-use flow end to end: an agent requests a capability against a vault entry, the vault verifies the policy row and prompts the user for a per-use grant, consume_capability_use decrements atomically, _unsealed_read performs the Fernet decrypt, the tool dispatches against the bound scope, and an audit entry is appended to the JSONL log under ~/.memagen/safety_audit/.

Sequence diagram of the V3-P capability flow with five lifelines: Agent, Vault, User, Tool, AuditLog. Steps: capability request, verify_capability, grant prompt, per-use grant, consume_capability_use, _unsealed_read with Fernet decrypt, log_vault_read append to ~/.memagen/safety_audit/YYYY-MM-DD.jsonl, dispatch to Tool, tool_execute audit, result returned to Agent.
Figure 1. V3-P per-use capability flow. Solid arrows are forward calls; dashed arrows are returns. The vault is the trust boundary; _unsealed_read is the only function that materialises plaintext and only after verify_capability succeeds.

The same flow expressed as a Mermaid sequence diagram, for reproducibility:

sequenceDiagram
    autonumber
    participant Agent
    participant Vault
    participant User
    participant Tool
    participant AuditLog
    Agent->>Vault: capability_request(action_kind, entry_id, constraints)
    Vault->>Vault: verify_capability(token) — row, expiry, uses
    Vault->>User: grant_prompt(action_kind, scope, ttl)
    User-->>Vault: grant(per-use, typed phrase, cooldown ok)
    Vault->>Vault: consume_capability_use() — atomic decrement
    Vault->>Vault: _unsealed_read(entry_id) — Fernet decrypt
    Vault->>AuditLog: log_vault_read → ~/.memagen/safety_audit/YYYY-MM-DD.jsonl
    Vault->>Tool: dispatch(plaintext, payload_summary)
    Tool->>AuditLog: tool_execute(content_hash)
    Tool-->>Vault: result
    Vault-->>Agent: result (plaintext rebound to None)
    Note over Vault: graph: capability_use node, caused_by → capability_policy

4.1 The encrypted credential store

core/security/vault.py implements an SQLite-backed store with one table:

CREATE TABLE vault_entries (
    entry_id        TEXT PRIMARY KEY,
    user_id         TEXT NOT NULL,
    service         TEXT NOT NULL,
    setting_key     TEXT NOT NULL,
    ciphertext      TEXT NOT NULL,
    created_at      REAL NOT NULL,
    updated_at      REAL NOT NULL,
    redacted_preview TEXT NOT NULL,
    metadata_json   TEXT DEFAULT '{}'
);

store_secret(user_id, service, setting_key, plaintext, metadata) returns an entry_id. The plaintext argument is encrypted via core.security.encryption.encrypt, written to ciphertext, and the local reference is rebound to None immediately. CPython does not zero memory on rebind, so this does not defeat a hostile-memory adversary; it does prevent subsequent code in the same frame from logging or transmitting plaintext through the original reference. A redacted_preview is computed by _make_preview, which detects known credential prefixes and returns a prefix...XXXX shape exposing at most four trailing characters.

The list-and-update surface (list_entries, get_redacted_preview, update_secret, delete_secret) is metadata-only: at no point does the public surface return ciphertext or plaintext. The single function that does is _unsealed_read, which is the trust boundary.

4.2 The capability policy table

core/security/capabilities.py implements the per-use authority record with one table and one always-on side-index:

CREATE TABLE capabilities (
  capability_id     TEXT PRIMARY KEY,
  user_id           TEXT NOT NULL,
  vault_entry_id    TEXT NOT NULL,
  action_kind       TEXT NOT NULL,
  constraints_json  TEXT NOT NULL,
  uses_remaining    INTEGER,
  expires_at        REAL,
  revoked_at        REAL,
  created_at        REAL NOT NULL,
  description       TEXT NOT NULL,
  graph_node_id     INTEGER  -- added by migration 0013
);

uses_remaining is nullable: NULL is the unlimited shape, an integer is a hard counter the consume path decrements atomically. expires_at is a Unix timestamp; verify_capability rejects on time.time() > row["expires_at"]. revoked_at is set by revoke_capability; once set, the capability is dead.

The graph_node_id column points at the capability_policy graph node (Section 4.3) when graph promotion succeeds. It remains NULL in lite mode and in any case where the graph stamp fails. The flat row is canonical; the graph reference is an optional side-effect.

4.3 The compound-graph promotion layer

When mint_capability writes the flat row, it attempts to stamp an L5 capability_policy node in the agent_memory domain. The node carries:

  • kind = "capability_policy"
  • name = description or f"{action_kind}:{capability_id[:8]}"
  • content — JSON-serialised {action_kind, constraints}
  • embedding_text — concatenated description and action_kind, used by the compound graph’s cosine recall path
  • attrscapability_id, user_id, action_kind, vault_entry_id, expires_at

Two edges attach. An owned_by edge runs from the capability_policy node to the user’s L4 anchor (user_account), via attach_owned_by in core.compound_graph.edges. A grants_access_to edge runs to the vault entry’s graph node when the vault entry has been graph-promoted (a future V3-C task) and vault_entries.graph_node_id is non-null; otherwise it is skipped silently.

consume_capability_use stamps a sibling capability_use node edged caused_by → grant. revoke_capability does two things: it sets attrs.revoked = True on the grant node via graph.update_node() (preserving the original attrs map), and it stamps a separate capability_revoke node edged revokes → grant. The two-record shape is intentional: an attribute flip is invisible to a query that filters only on node kind; a separate revoke node makes the event independently selectable.

4.4 The audit module

core/security/audit.py is the flat-table audit layer. Every successful and unsuccessful _unsealed_read writes a row through log_vault_read. The row carries user_id, entry_id, service, setting_key, capability_id, success, and reason; it does not carry credential plaintext, ciphertext, or request payload. Rows are also append-streamed to a daily JSONL file under ~/.memagen/safety_audit/YYYY-MM-DD.jsonl via the temp-file-plus-os.replace discipline shared with the toggle audit log (see Default-Restrictive Safety Toggles, Section 6). A tamper-detection hash chain (V3-C.6) sets each row’s row_hash to sha256(prev_row_hash + canonical_json_of_row_data); verify_chain() walks the table to detect edits, deletions, or reordering.

The flat audit table and the compound-graph audit subgraph carry overlapping but distinct information. The flat table is the sole source of truth for credential reads (it is the layer that exists in lite mode). The graph layer is the queryable structured record of grant–use–revoke lifecycle events.

5. The mint–consume–revoke lifecycle

The public surface of core/security/capabilities.py has four functions and is exhaustively typed.

5.1 mint_capability

def mint_capability(
    *, user_id: str, vault_entry_id: str, action_kind: str,
    constraints: dict, description: str,
    uses_remaining: int | None = None,
    expires_at: float | None = None,
) -> str:

Returns a 32-character capability_id (hex uuid4). The constraints dict is validated by _parse_constraints(action_kind, raw), which looks up the constraint dataclass for action_kind, checks for missing required fields, and constructs an instance:

_CONSTRAINT_CLASSES: dict[str, type] = {
    "http_request":        HttpRequestConstraint,
    "captcha_solve":       CaptchaSolveConstraint,
    "tool_call":           ToolCallConstraint,
    "file_read":           FileReadConstraint,
    "tts_synthesize":      TtsSynthesizeConstraint,
    "onboarding_playback": OnboardingPlaybackConstraint,
}

Each constraint dataclass is frozen and exposes the axes that matter for its action kind. HttpRequestConstraint carries allowed_url_patterns (glob or regex:-prefixed), allowed_methods, max_requests_per_day (0 is unlimited), and max_bytes_per_request. ToolCallConstraint carries allowed_tool_names and max_calls_per_hour. FileReadConstraint carries allowed_path_prefixes and max_bytes_per_read. The shape is deliberately small; free-form constraint expressions were rejected because every additional axis is a potential audit-query miss.

The flat row is inserted under _write_lock (process-level threading lock; multi-process concurrency relies on the SQLite file lock). The graph stamp follows. Any exception inside _stamp_capability_graph_node is caught and logged; the flat row remains, and the function returns the capability id.

5.2 verify_capability

def verify_capability(
    capability_token: str, *,
    expected_entry_id: str | None = None,
    expected_action_kind: str | None = None,
) -> tuple[bool, str]:

Six checks: row exists, not revoked, not expired, uses remaining, vault entry matches, action kind matches. The two expected_* kwargs bind verification to the specific use site — _unsealed_read passes expected_entry_id so a capability minted for vault entry A cannot be redeemed against entry B. Returns (True, "") on success and (False, reason) on failure; the reason string is the audit input.

5.3 consume_capability_use

def consume_capability_use(
    capability_id: str,
    *,
    caller: str | None = None,
    payload_summary: str | None = None,
) -> bool:

The atomic decrement happens in a _write_lock-guarded _db() connection: SELECT uses_remaining, revoked_at, graph_node_id, then if uses_remaining is non-null and positive, UPDATE capabilities SET uses_remaining = uses_remaining - 1. Returns False if the row is absent, revoked, or at zero uses. On success, returns True and fires _stamp_capability_use_node, which creates an L5 node with kind="capability_use", attrs {capability_id, grant_node_id, used_at, caller, payload_summary}, and a caused_by edge to the grant node.

caller and payload_summary were added in V3-S P3 without breaking any existing call site: both default to None, so positional callers (consume_capability_use(cap_id)) continue to work and produce a use node without caller-tagging. The pattern — keyword-only kwargs with None defaults, never adding a positional argument — is the standard backwards-compatible audit-enrichment idiom across the security surface.

5.4 revoke_capability

def revoke_capability(capability_id: str, *, user_id: str) -> bool:

Marks revoked_at = now() on the flat row. The user_id kwarg is enforced: a capability can only be revoked by its owner. Idempotent: a second call against an already-revoked capability returns True without writing.

On the graph side, two operations fire. _mark_graph_node_revoked reads the existing attrs, copies the dict, sets revoked = True and revoked_at = now, and writes back via graph.update_node(); the original attrs are preserved. _stamp_capability_revoke_node then creates a separate capability_revoke L5 node edged revokes → grant. The two-record shape costs one extra node but makes the event independently selectable: a query filtering kind="capability_revoke" finds every revoke without an attrs.revoked scan.

5.5 _safe_get_user_graph_node_id — the fragile-schema fallback

The canonical user-anchor lookup is core.auth.users.get_user_graph_node_id, which calls _ensure_schema to add indexes against the multi-column users schema. Test fixtures and minimal installs sometimes provision a stripped users table with only user_id and graph_node_id; on those, _ensure_schema raises OperationalError: no such column: username. The fallback bypasses schema enforcement and reads graph_node_id directly:

def _safe_get_user_graph_node_id(user_id: str) -> int | None:
    try:
        conn = open_db(timeout=5)
        try:
            row = conn.execute(
                "SELECT graph_node_id FROM users WHERE user_id = ?",
                (user_id,),
            ).fetchone()
        finally:
            conn.close()
        if row is not None and row[0] is not None:
            return int(row[0])
    except Exception:  # noqa: BLE001
        pass
    try:
        from core.auth.users import get_user_graph_node_id
        return get_user_graph_node_id(user_id)
    except Exception:  # noqa: BLE001
        return None

The two-stage fallback (direct read, then canonical helper) made V3-S P3 shippable without a global users-table migration in the test fixtures. The corresponding _try_attach_owned_by_direct re-reads the user node id directly when attach_owned_by fails for the same reason.

6. Audit-as-graph-walk

This is the contribution that distinguishes V3-P from a conventional audit-log approach.

A traditional audit query writes SQL against a flat audit_log table, parses rows, optionally JOINs against a separate principal table, and reconstructs context by matching ids across schemas. V3-P walks the same graph the agent already walks for working memory, filters on node kind and attrs, and relies on the existing edge index to traverse from grant to use to revocation in one pass.

The test suite (tests/security/test_capabilities_graph.py::TestConstraintPersistenceAndAuditQuery::test_audit_query_caps_by_action_in_window) exercises the canonical shape:

rows = conn.execute(
    "SELECT id, attrs FROM nodes WHERE kind = 'capability_policy' "
    "AND json_extract(attrs, '$.action_kind') = ?",
    (action_kind,),
).fetchall()

That single query — filter kind to capability_policy, filter attrs.action_kind to the action of interest — yields every grant for that action kind. The same shape with kind = 'capability_use' yields every use; with kind = 'capability_revoke' yields every revocation. A post-filter on created_at from the flat capabilities table establishes the time window.

The query language is SQLite plus json_extract; V3-P does not require a separate graph-query DSL. The indices are the standard nodes-table indices. The compound-graph engine already does the work that makes this fast at any scale we currently target; the audit subgraph rides on top without imposing a new index.

Lifecycle queries are graph walks. Given a capability_policy node, the set of capability_use nodes is found by walking inbound caused_by edges; the revocation event is found by walking inbound revokes edges; the owning user is the outbound owned_by neighbour; the vault entry, when graph-promoted, is the outbound grants_access_to neighbour. A complete audit dossier for a single capability — owner, every credential touched, every dispatch, revocation event — is four hops from a single starting node.

The audit trail co-locates with working memory. A recall query that surfaces “this user has previously authorised email sends to this contact” is the same kind of query as the audit “this capability was used to call email_send with payload_summary mentioning that contact”. One traversal substrate, one index set, one API.

7. Implementation notes

7.1 The graph schema lives at layer 5

The compound graph (a Pro subsystem in the master paper) carries six layers. Layer 5 is operational: actions, uses, revocations, dispatches. Capability policies and uses both stamp at layer 5 in the agent_memory domain. Layer 4 (entities) is wrong because a capability policy is an action artifact, not a stable entity; layer 6 (motifs) is wrong because the lifecycle is dominated by use-and-revoke events sharing the temporal granularity of other layer-5 records.

7.2 Lite mode

_lite_mode() returns True when MEMAGEN_LITE_MODE is set to one of 1, true, yes, on (case-insensitive). When true, every graph stamp is a no-op:

  • _stamp_capability_graph_node returns None early.
  • _stamp_capability_use_node returns None early.
  • _mark_graph_node_revoked returns early.
  • _stamp_capability_revoke_node returns None early.

The flat capability table and flat audit table remain canonical. A Lite installation has the full mint–consume–revoke surface, full constraint enforcement, and full encrypted credential store. What it lacks is the graph-walk audit query — but it has the flat audit table with hash-chained tamper detection. The split is intentional: graph-shaped audit is a Pro-tier convenience; flat-table audit with hash chain is what every installation gets.

7.3 Backwards-compat at the consume call site

consume_capability_use was extended with caller and payload_summary kwargs without breaking the existing positional signature. The V3-S P3 test cases (TestConsumeBackwardsCompat::test_positional_consume_still_works) verify that positional calls continue to decrement uses correctly. The pattern — keyword-only kwargs with sensible defaults, never adding a positional argument that pre-existing code would have to pass — applies across the audit surface and is the recommended extension idiom for security-adjacent APIs.

7.4 The _audit_uses rate-limit hook

check_constraint enforces per-axis limits at the point of use:

def _audit_uses(capability_id: str, window_seconds: float) -> int:
    try:
        from core.security import audit as _audit
        return _audit.count_capability_uses(
            capability_id, since_ts=time.time() - window_seconds,
        )
    except Exception as exc:
        log.warning("audit_uses: %r", exc)
        return 0

The optional-dependency import mirrors the lite-mode fallback: if the audit module is unavailable, the count returns zero (the conservative answer; zero is below every limit, so the check defers to the static config bound). The max_requests_per_day, max_calls_per_hour, max_solves_per_session, max_solves_per_day, and max_uses_per_day axes all flow through this hook — a single count_capability_uses call against the flat audit table, scoped to the capability id and a trailing window.

7.5 Graph promotion is non-fatal — always

Every graph-side function in capabilities.py is wrapped in a try/except Exception whose except arm logs and returns None. The pattern repeats four times (_stamp_capability_graph_node, _stamp_capability_use_node, _mark_graph_node_revoked, _stamp_capability_revoke_node) and once more in _safe_get_user_graph_node_id. The discipline: the flat row is canonical; the graph stamp is a side-effect; any failure inside the stamp must be loggable, must not propagate, and must leave the flat row committed.

The discipline is enforced negatively: a future change that propagates a graph-side failure will start raising on capability-mint under the same conditions. Production traces show zero such propagations; the soft-fail is tested explicitly in the lite-mode test class. An adversarial fixture that breaks arbitrary points in the graph stack and verifies the mint return path remains on the future-work list (Section 10).

7.6 The backfill helper

backfill_capability_graph_nodes(batch_size=100) exists for installations that pre-date V3-C.8. It iterates over every capability row with graph_node_id IS NULL and calls _stamp_capability_graph_node for each. Idempotent: rows already promoted are skipped. Returns {"processed", "stamped", "failed"} for migration logs. Useful once per installation; it lives in the public surface because it is the supported upgrade path from a pre-graph build.

8. Evaluation

We are honest about what we have measured and what we have not.

8.1 Test pass rate

The capability test suite has 60 cases across tests/security/test_capabilities_graph.py (V3-S P3 wiring: use/revoke nodes, edges, attrs round-trip, lite-mode gate, backwards-compat), test_capabilities_graph_promotion.py (V3-C.8: grant node, owned_by, attrs.revoked, grants_access_to against a stub vault graph node), test_audit_hash_chain.py (V3-C.6 tamper detection), test_audit_uses_rate_limit.py (_audit_uses window enforcement), test_audit_retention_floor.py (30-day floor), test_audit_prune_batching.py (chunked pruning), and tests/integration/test_capability_execute_e2e.py (mint → verify → unsealed read → audit).

Before V3-S P3 the suite passed 42 of 43; the failure was a graph-stamp regression from the non-canonical users-table fixture. After _safe_get_user_graph_node_id and _try_attach_owned_by_direct, the suite passes 60 of 60.

8.2 What we have not benchmarked

Audit-trail query latency at scale. The json_extract-on-attrs query is fast against a few thousand rows but has not been profiled against the millions-of-rows shape long-running installations will eventually produce. SQLite’s JSON1 functional index (CREATE INDEX ... ON nodes(json_extract(attrs, '$.action_kind'))) is the right next step; it is on the V4 list, not shipped.

Adversarial schema fuzzing. The _safe_get_user_graph_node_id fallback handles the one non-canonical schema we encountered. We have not constructed a test matrix of every plausible deviation. The production guarantee is the soft-fail — a graph stamp that hits an unexpected schema returns None and logs — not a strong claim of handling every deviation correctly.

Cryptographic hardening. The vault uses Fernet (AES-128-CBC + HMAC-SHA256, plus a timestamp) with the at-rest key held in an environment variable or a derived KDF output. We have not run a formal threat model of at-rest-key handling; the master paper’s Section 10 footprints a hardware-rooted vault as future work.

Side-channel attacks on _unsealed_read. Timing depends on whether the capability check passes (early return) and whether the row exists (late return). We have not attempted a timing analysis. For the current threat model — a malicious agent with full process access — this matters less than for a multi-tenant RPC service.

8.3 What worked

“Flat row first, graph stamp as best-effort side-effect” was the structural property that made V3-S P3 ship without regressions. Five functions implement the same try/except/log/return pattern; the uniformity is the feature.

8.4 What is fragile

The biggest delivery risk during V3-S P3 was the _ensure_schema interaction with non-canonical test fixtures. The mitigation bypasses _ensure_schema with a direct read. The cost: the fallback does not auto-migrate the users table, so an installation hitting it continues partially-migrated until explicit migration runs. We accept this cost; auto-migrating from inside a security-critical path would have introduced a worse failure mode.

9. Discussion

9.1 What we get

Structured audit is the headline. A “show me everything tool X did with credentials in the past 30 days” query is one filter plus two graph hops. Real revocation — not “the toggle is off now” but “this authority is dead and the record of its death is queryable” — is the second. Blast-radius bounding by per-use grants is the third. None is individually novel; the integration is.

9.2 What we give up

Simplicity. Each dispatch is a multi-step protocol: mint, verify, consume, audit. Constraint dataclasses add upfront design work for any new action_kind. The fallback-everywhere discipline is a maintenance overhead — every graph-side helper wraps in the try/except idiom.

The cost is paid once at the infrastructure layer and amortised across every tool. Users see no friction — mint and consume run inside the executor, not in the prompt loop. Operators see a structured audit trail rather than a flat log. Tool-surface contributors write one constraint dataclass and inherit the rest.

9.3 The honest comparison

Compared to a no-capability framework, V3-P is strictly more work. Compared to OAuth scopes, it is per-use rather than per-token. Compared to an ocap language, it is weaker on unforgeability (the token is a 128-bit hex string, not a kernel reference) and stronger on persistent audit. Compared to a separate policy engine, it co-locates policy with working memory at the cost of being less language-agnostic — the shape assumes Python and SQLite.

The right comparison for an agent runtime is the no-capability baseline most frameworks ship with, and against that baseline V3-P is a genuine improvement on the safety axis. The architecture cost is real but amortisable; the cost of not having it becomes apparent only after a public bad outcome.

10. Future work

Capability federation. An installation on machine A holds a capability for vault entry X; machine B wants to delegate derived authority to a sub-agent on A. The current shape requires re-minting; a federation protocol would let the original capability carry a delegation chain. Prior art lies in ocap languages (E’s certificate-bearing capabilities) and OAuth delegation (the actor claim in token-exchange). The protocol is unspecified.

Hierarchical scoping and delegation chains. A capability for tool_call against email_send could derive from a parent capability for any tool_call, with the constraint set narrowed at each derivation step. The current shape is flat. Hierarchical scoping would make audit queries more expressive at the cost of added complexity in verify_capability — it must follow the chain and check every link’s expiration and revocation.

Hardware-rooted vault. The Fernet at-rest key is currently held in a process-readable location. A TPM (Linux) or Secure Enclave (macOS) backing would move the key into a non-extractable context, with the vault module obtaining ephemeral decryption authority via a platform API. The work is platform integration; the interface to the at-rest crypto layer is small enough that retrofitting is straightforward.

Functional indexes for audit query. An SQLite JSON1 functional index on attrs.action_kind (and possibly attrs.capability_id) would convert the audit-window query from a scan to an index lookup. A one-line schema migration plus a small benchmark.

Adversarial schema test matrix. A property-based test (Hypothesis or a hand-built fuzzer) constructing arbitrary deviations of the users and vault tables, verifying that mint, consume, and revoke continue to return correctly, is the structural answer to the schema-fragility risk. V4 test plan.

Audit-as-recall. The compound graph supports cosine recall against embedding_text. A capability_policy node carries embedding text, so the audit query “find every capability whose description matches this prompt” is one cosine recall away. The substrate is in place; we have not exercised it beyond the existing test (test_capabilities_graph_promotion.py uses a deterministic embedder stub).

11. References

  1. Bernstein, D.J. (2014). The Fernet specification. https://github.com/fernet/spec
  2. Dennis, J.B., & Van Horn, E.C. (1966). Programming semantics for multiprogrammed computations. Communications of the ACM, 9(3), 143–155.
  3. Hardt, D. (2012). The OAuth 2.0 Authorization Framework. RFC 6749.
  4. Hardy, N. (1985). KeyKOS architecture. ACM Operating Systems Review, 19(4).
  5. Levitas, T. (2026). Memagen™: An Open-Core Agent Platform with Lenient Tool Routing, Capability-Gated Execution, and a Compound-Graph Memory Substrate. Memagen™ master paper, this series.
  6. Levitas, T. (2026). Default-Restrictive Safety Toggles with Hard Invariants. Memagen™ specialty paper, this series.
  7. Miller, M.S. (2006). Robust composition: Towards a unified approach to access control and concurrency control. PhD dissertation, Johns Hopkins University.
  8. Miller, M.S., Samuel, M., Laurie, B., Awad, I., & Stay, M. (2008). Caja: Safe active content in sanitized JavaScript. Google research report.
  9. Shapiro, J.S., & Smith, J.M. (1999). Capability myths demolished. Technical Report MS-CIS-03-23, University of Pennsylvania.
  10. Shapiro, J.S., Smith, J.M., & Farber, D.J. (1999). EROS: A fast capability system. Proceedings of the 17th ACM Symposium on Operating Systems Principles, 170–185.
  11. Wilkes, M.V., & Needham, R.M. (1979). The Cambridge CAP computer and its operating system. North-Holland.

Source: https://github.com/tablevitas123/crackedclaw