Skip to content

rfc: delegated, branch-scoped credentials for agents #978

Open

Description

@ragnorc

Problem

People want to let an agent (Claude Code, Codex, a script) use the graph with the omnigraph CLI under these rules:

  • it can read anything;
  • it writes only to branches of its own;
  • it never merges into or writes a protected branch;
  • its commits say they came from the agent acting for that person.

The server cannot express that today:

  • Branch scopes are coarse. A rule may only use any, protected or unprotected (omnigraph-policy/src/lib.rs#L213-L217), and "protected" is exact membership in protected_branches (#L592-L593). No rule can say "branches this actor created" or "branches under agent/<id>/", so an agent allowed to change unprotected branches can change everyone's feature branches.
  • Credentials carry no grants, and version-1 restricted credentials are refused (policy.md, Actor identity). A narrower principal needs an administrator to add an actor, a token and policy rules, so a person cannot hand an agent less than their own authority.
  • The commit actor comes from the bearer token, so an agent using the person's token is indistinguishable from the person in history.

The only workaround is a client-side gateway that holds the person's token and decides which writes to forward. Studio, OmniGraph's desktop client, would need one for its agent panel. It sits outside the engine (invariant 10). It has to classify writes itself, including statement mutations whose targets live only in the query text, commit publish/drop whose branch only the server knows, and stored queries whose mutability is in the server's registry. It can only protect agents that go through it.

Proposal (for an RFC)

Delegated credentials: an authenticated actor mints a short-lived credential for a delegate principal (e.g. delegate:<actor>/<id>).

  • Its grants are a subset of the minting actor's and are evaluated at the same engine gate as policy.
  • Writes are limited to branches the delegate creates, under a prefix such as agent/<id>/. Reads stay as broad as the parent's or are narrowed.
  • No branch_merge or change on protected branches, whatever the parent may do.
  • Expiry and revocation.
  • Commits record both the delegate and the actor it acts for.

Questions for the RFC:

  • where delegations live (cluster ledger or manifest);
  • branch ownership (record the creator at branch creation, or match a prefix);
  • revocation latency for a running agent;
  • whether a delegate's writes must be unpublished commits (as commit) that its parent publishes, making publication the approval step;
  • how policy explain shows a delegate's effective rights.

Related: #32 (grants API, closed), #951 (RFC: actor views and subgraph sharing).

Activity

  1. added
    needs-rfcSize L: the design must land as an RFC before any code
    featureFeature proposal
    rfcDesign-level proposal
    on Oct 13, 2026
  2. ragnorc commented on Oct 19, 2026

    @ragnorc
    ContributorAuthor

    Summary

    A person cannot give an agent less authority than the person has, and cannot limit that agent to branches the agent creates. The server can allow change on every unprotected branch, or on none. A credential cannot carry a smaller grant. A commit records one actor, and that actor is the bearer token. A client gateway that tries to filter writes sits outside the engine (invariant 10). It must classify writes whose target the client does not fully know.

    Checked on main at 9e2425d26a03b1b5628030a359ee51f096d3ca1c (2026-10-19, test(gq): shrink six large-seed cases to their measured floors (#1087)). The issue is still open. It has no comments. updated_at is still 2026-10-13T18:25:13Z. The timeline has labels plus cross-references from #434 and #951. There is no linked fix pull request.

    Reproduction

    Environment: Linux, workspace binaries omnigraph and omnigraph-server 0.14.0, internal schema 16, HTTP contract 0.13. Local cluster file:///tmp/og978/cluster, server 127.0.0.1:18080. Two static tokens, for act-person and act-agent. Graph knowledge, one Person node type. Policy protected_branches: [main]. Both actors have read on any, change on unprotected, and branch_create with target_branch_scope: unprotected. Every request sent Omnigraph-Http-Api: 0.13.

    The issue cites d8d2e3a8. PolicyBranchScope is still at crates/omnigraph-policy/src/lib.rs lines 213–217. The exact-membership test moved from lines 592–593 to lines 651–652.

    Claim 1. Branch scopes are only any, protected, or unprotected. Reproduced.

    cluster validate rejects any other scope:

    rules[0].allow.branch_scope: unknown variant created, expected one of any, protected, unprotected

    The same error rejects branch_scope: "agent/act-agent/". A rule may set branch_scope or target_branch_scope, not both (lib.rs lines 424–428). "Protected" is protected_branches.contains(branch) (lib.rs lines 651–652).

    Live result:

    • act-person created feature/person. The response actor_id was act-person. commit was null.
    • act-person inserted Ada on feature/person. Commit actor act-person.
    • act-agent inserted Mallory on feature/person. HTTP 200. Commit actor act-agent.
    • act-agent inserted on main. HTTP 403. policy denied action 'change' on branch 'main' for actor 'act-agent'.
    • act-agent created and wrote release and main-backup. Both HTTP 200. A name that merely contains main is not protected.
    • policy explain --actor act-agent --action change --branch feature/person allowed the action by rule agents-change-unprotected.
    • GET /graphs/knowledge/access?branch=feature/person for the agent token returned read, change, commit_publish, and commit_drop.
    • GET /branches returned names only: feature/person, main, main-backup, release.

    What differed: branch creation does not store a creator. branch_create_from_as passes the actor into the policy check, then branch_create_from_impl writes the ref with no actor (crates/omnigraph/src/db/omnigraph.rs lines 3721–3740). Open PR #1012 would report a creator. It is not on this main, and a listing field would not by itself enforce ownership.

    Claim 2. Credentials carry no grants, and version-1 restricted credentials are refused. Reproduced.

    The docs still say this (docs/user/operations/policy.md, Actor identity). IdentityTokenClaims is version 2 and has no grant field (crates/omnigraph-server/src/data_tokens.rs lines 38–55). valid_identity_claims requires version == 2 (line 239). Unknown fields fail deny_unknown_fields. Failure returns no actor (lines 205–207). HTTP maps that to one message (handlers.rs lines 444–445).

    A local ES256 trust file was bound to file:///tmp/og978/cluster. Results on POST /graphs/knowledge/mutate:

    • Version 1 with a grants array: HTTP 401, {"error":"invalid bearer token","code":"unauthorized"}.
    • Version 1 with the grants field removed: the same 401.
    • Version 2 with a grants field added: the same 401.
    • Version 2 identity for subject person978, no grants: HTTP 403, policy denied action 'change' for unknown actor 'principal:person978'.
    • The same version 2 token on GET /graphs/discovery: HTTP 200. Authentication succeeded. The principal is not a policy actor, so the write is denied.

    What differed: there is no distinct error text for "version-1 restricted credential". A bad version, a grant field, and a bad signature all return invalid bearer token. A valid identity still cannot carry a subset of the minter's rights. An administrator must add the actor, the token or principal, and the rules.

    Claim 3. The commit actor is the bearer token. Reproduced.

    act-person's token wrote feature/person with headers X-Actor-Id: act-agent and Omnigraph-If-Actor: act-agent. HTTP 200. Response and commit actor_id were act-person. GET /commits?branch=feature/person shows only actor_id. There is no delegate field and no principal field.

    The catalog row has one actor column, actor_id (crates/omnigraph-catalog/src/record.rs lines 103–115). Omnigraph-If-Actor is not on this main. Open PR #1074 would add it as a check that the token already resolved to that actor. It would not let the client choose the actor.

    Extra observation for the proposal below: act-person published an unpublished commit that act-agent wrote. The published commit kept actor_id: act-agent and the original created_at. The response has no publisher.

    Claim 4. The workaround is a client gateway outside the engine. Reproduced.

    Invariant 10 says HTTP resolves the bearer token, and every mutating engine entry point applies the same gate (docs/dev/invariants.md). A gateway that holds the person's token and chooses which calls to forward is not that gate.

    Live classification gaps:

    • branch create "other" from main sent with "branch":"feature/person" returned HTTP 400: a branch statement names its branches itself; drop the request target. The target is in the statement text.
    • commit publish "<id>" with no branch field returned HTTP 200 and branch feature/person. The server reads the recorded target before the policy check (crates/omnigraph-server/src/handlers/dispatch.rs lines 424–439). The statement does not contain the branch.

    The stored-query case was not sent. This cluster has no stored query. The source matches the claim: the registry decides is_mutation(), then the server applies change or read (handlers.rs lines 2402–2446). A caller without invoke_query gets the same 404 as a missing name (lines 2362–2388).

    Root cause

    The policy resource is the graph. The only branch facts in the Cedar context are two booleans, branch_is_protected and target_branch_is_protected. There is no creator, no prefix, and no delegation. The signed credential authenticates principal:<sub> and carries no attenuation (accepted RFC docs/rfcs/2026-09-09-identity-credentials-and-applied-policy.md and RFC 0053). The graph commit stores one actor_id. Those three facts together make "this agent, for this person, on branches it creates" inexpressible.

    Recommendation

    Keep one decision at the engine gate. Intersect three inputs on every attempt: the current Cedar decision for the responsible principal, a delegation cap that can only remove rights, and branch ownership recorded by the graph. Do this for HTTP, CLI direct writes, CLI served writes, and MCP. Cover ad-hoc queries, stored queries, statement mutations, loads, branch create/delete/merge, commit publish/drop, schema apply, export, the change feed, diff, Blob read, and snapshot. Discovery can stay identity-only.

    Do not put a grant list in the token. RFC 0053 removed that list because it becomes a second policy. A policy change cannot grant what the token omitted, and a token can outlive a revocation. Do not add a client gateway as the control. It misses the classes above, and any agent that does not use it keeps the person's full authority. History then names the person.

    This favors a correct decision over a small patch to PolicyBranchScope. A new scope value such as prefix would still let any holder of that rule write every matching branch, and it would not record who acted for whom.

    Where the delegation lives

    Put issuance, expiry, and revocation in the cluster's applied authority, on the same activation path as policy. The credential names a delegation id, the delegate, and the principal. It does not carry the rights. The engine loads that record with the policy snapshot for the attempt (invariants 3, 10, and 12).

    Do not put the permission in each graph manifest. Revocation would have to publish every graph. A graph that the revocation missed would stay open. Do not put the permission only in Lance transaction properties. Those properties are already the staging witness (omnigraph.staged_against_branch_incarnation, omnigraph.staged_against_graph_head in crates/omnigraph-core/src/staging.rs). They are not the graph commit.

    Branch ownership

    Record the creator when the branch is created, in the graph branch authority, and enforce on that record. change, branch_delete, commit_publish, and commit_drop are allowed when the caller is that creator, or when a normal policy rule allows that action. The name is not the owner.

    A prefix such as agent/<id>/ is a creation limit so the delegate cannot take release. It is not the ownership test. Lance branch paths forbid a live branch that is an ancestor or descendant of another live branch (crates/omnigraph-core/src/branch_control.rs lines 865–869). A parent path occupied by a real branch makes the child prefix impossible. Open PR #1012 can supply the creator field for listing. The gate still has to read it.

    The delegation cap also removes branch_merge, change on a protected branch, and schema_apply, even when the principal has those rights. Schema apply remains main-only in the current support boundaries. Re-check the intersection at use time. A later grant to the principal must not widen the delegate.

    Revocation latency

    Check revocation at the start of each attempt, on the same snapshot as policy. An attempt that already passed the gate may finish. The next attempt sees the revocation. Do not stop a Lance write in the middle. That can leave a durable effect outside the publication door (invariants 4 and 5).

    Keep the token lifetime short. Signed credentials already allow 60 seconds to 86,400 seconds. The OIDC human tokens documented for this server expire within 300 seconds. Do not use process restart as revocation. That is the open gap in #434, and it does not cover a signed credential, which the server already will not revoke before exp (docs/user/operations/server.md).

    Must the delegate write as commit?

    Yes, for a delegate. No, as a new rule for every writer. The current as commit path is a voluntary review. An actor with ordinary change can omit it (docs/user/operations/policy.md, and docs/rfcs/2026-10-10-unpublished-graph-commits.md, Policy and lifetime). The engine must refuse a delegate's ordinary publication. The delegate does not receive commit_publish. The principal does.

    The published commit must record the delegate who wrote it and the principal who published it. Today's publish does not. The live publish above kept the writer's actor and recorded no publisher. A moved head still expires the proposal under the existing rule, so the principal must publish before the next ordinary write of that branch.

    What policy explain and GET /access show

    GET /graphs/{graph_id}/access is on this main (#1073, merged). policy explain and that route evaluate Cedar for one actor. They do not know a delegation. For a delegate they must return the intersection only: delegate, principal, delegation id, and the actions that remain. They must not return the principal's other rights or another delegate's rights. When a cap removes an action, the answer should name the cap. The route can keep its rule of naming actions rather than rule ids.

    Liability

    This removes the case where history blames the person for the agent's write, and the case where a gateway misses one write class. It adds a revocation window of one admitted attempt, expiry of an unpublished proposal when the branch head moves, and path collisions if a prefix is treated as a tree of branch names. A delegate whose read stays as wide as the principal can still export and follow the change feed of other branches. Narrow read, export, changes, diff, Blob, and snapshot together, or state that broad read is intentional.

    The per-attempt revocation check is a bounded read of applied authority. Do not add a second cache that can lag the applied record.

    Lance

    Lance does not fix this. Pinned Lance is 11.0.0. Transaction.transaction_properties is map<string, string> field 4, with reserved key __lance_commit_message, in the 11.0.0 proto and unchanged at tags v12.0.0, v13.0.0, and lance-format/lance main. It arrived in lance#4078 and lance#6246, both merged before 11.0.0. OmniGraph already uses that map for staging witnesses. The graph actor is __manifest column actor_id. Lance branch metadata is a free string map (https://lance.org/format/table/branch_tag/). This main does not write a creator there. Open PR #1072 upgrades Lance to 13.0.0 and does not add an actor or delegation model. Lance has no principal. Do not wait for a Lance release to record delegate and principal.

    Related work

    No open issue has the same need with a wider or more accurate scope. This issue should stay open.

    Limitations

    The session used one local file cluster and one server process. It did not use S3 or Azure. It did not send a stored-query call, an MCP call, a branch_merge, a schema apply, or an export. Export is POST /export and uses the export action (handlers.rs lines 1571–1578). This policy did not grant export. Direct --store writes were not the bearer-token case. The bearer-token case is the one the issue describes.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    featureFeature proposalneeds-rfcSize L: the design must land as an RFC before any coderfcDesign-level proposal

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions