Repository navigation
rfc: delegated, branch-scoped credentials for agents #978
Description
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,protectedorunprotected(omnigraph-policy/src/lib.rs#L213-L217), and "protected" is exact membership inprotected_branches(#L592-L593). No rule can say "branches this actor created" or "branches underagent/<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_mergeorchangeon 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 explainshows a delegate's effective rights.
Related: #32 (grants API, closed), #951 (RFC: actor views and subgraph sharing).
Activity
ragnorc commentedon Oct 19, 2026
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
changeon 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
mainat9e2425d26a03b1b5628030a359ee51f096d3ca1c(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_atis still2026-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
omnigraphandomnigraph-server0.14.0, internal schema 16, HTTP contract 0.13. Local clusterfile:///tmp/og978/cluster, server127.0.0.1:18080. Two static tokens, foract-personandact-agent. Graphknowledge, onePersonnode type. Policyprotected_branches: [main]. Both actors havereadonany,changeonunprotected, andbranch_createwithtarget_branch_scope: unprotected. Every request sentOmnigraph-Http-Api: 0.13.The issue cites
d8d2e3a8.PolicyBranchScopeis still atcrates/omnigraph-policy/src/lib.rslines 213–217. The exact-membership test moved from lines 592–593 to lines 651–652.Claim 1. Branch scopes are only
any,protected, orunprotected. Reproduced.cluster validaterejects any other scope:rules[0].allow.branch_scope: unknown variant created, expected one of any, protected, unprotectedThe same error rejects
branch_scope: "agent/act-agent/". A rule may setbranch_scopeortarget_branch_scope, not both (lib.rslines 424–428). "Protected" isprotected_branches.contains(branch)(lib.rslines 651–652).Live result:
act-personcreatedfeature/person. The responseactor_idwasact-person.commitwasnull.act-personinsertedAdaonfeature/person. Commit actoract-person.act-agentinsertedMalloryonfeature/person. HTTP 200. Commit actoract-agent.act-agentinserted onmain. HTTP 403.policy denied action 'change' on branch 'main' for actor 'act-agent'.act-agentcreated and wrotereleaseandmain-backup. Both HTTP 200. A name that merely containsmainis not protected.policy explain --actor act-agent --action change --branch feature/personallowed the action by ruleagents-change-unprotected.GET /graphs/knowledge/access?branch=feature/personfor the agent token returnedread,change,commit_publish, andcommit_drop.GET /branchesreturned names only:feature/person,main,main-backup,release.What differed: branch creation does not store a creator.
branch_create_from_aspasses the actor into the policy check, thenbranch_create_from_implwrites the ref with no actor (crates/omnigraph/src/db/omnigraph.rslines 3721–3740). Open PR #1012 would report a creator. It is not on thismain, 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).IdentityTokenClaimsis version 2 and has no grant field (crates/omnigraph-server/src/data_tokens.rslines 38–55).valid_identity_claimsrequiresversion == 2(line 239). Unknown fields faildeny_unknown_fields. Failure returns no actor (lines 205–207). HTTP maps that to one message (handlers.rslines 444–445).A local ES256 trust file was bound to
file:///tmp/og978/cluster. Results onPOST /graphs/knowledge/mutate:grantsarray: HTTP 401,{"error":"invalid bearer token","code":"unauthorized"}.grantsfield removed: the same 401.grantsfield added: the same 401.person978, no grants: HTTP 403,policy denied action 'change' for unknown actor 'principal:person978'.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 wrotefeature/personwith headersX-Actor-Id: act-agentandOmnigraph-If-Actor: act-agent. HTTP 200. Response and commitactor_idwereact-person.GET /commits?branch=feature/personshows onlyactor_id. There is no delegate field and no principal field.The catalog row has one actor column,
actor_id(crates/omnigraph-catalog/src/record.rslines 103–115).Omnigraph-If-Actoris not on thismain. 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-personpublished an unpublished commit thatact-agentwrote. The published commit keptactor_id: act-agentand the originalcreated_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 mainsent 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 branchfeature/person. The server reads the recorded target before the policy check (crates/omnigraph-server/src/handlers/dispatch.rslines 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 applieschangeorread(handlers.rslines 2402–2446). A caller withoutinvoke_querygets 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_protectedandtarget_branch_is_protected. There is no creator, no prefix, and no delegation. The signed credential authenticatesprincipal:<sub>and carries no attenuation (accepted RFCdocs/rfcs/2026-09-09-identity-credentials-and-applied-policy.mdand RFC 0053). The graph commit stores oneactor_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 asprefixwould 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_headincrates/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, andcommit_dropare 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 takerelease. 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.rslines 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,changeon a protected branch, andschema_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 commitpath is a voluntary review. An actor with ordinarychangecan omit it (docs/user/operations/policy.md, anddocs/rfcs/2026-10-10-unpublished-graph-commits.md, Policy and lifetime). The engine must refuse a delegate's ordinary publication. The delegate does not receivecommit_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 explainandGET /accessshowGET /graphs/{graph_id}/accessis on thismain(#1073, merged).policy explainand 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_propertiesismap<string, string>field 4, with reserved key__lance_commit_message, in the 11.0.0 proto and unchanged at tagsv12.0.0,v13.0.0, andlance-format/lancemain. 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__manifestcolumnactor_id. Lance branch metadata is a free string map (https://lance.org/format/table/branch_tag/). Thismaindoes 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
act. Row and column views do not give branch ownership or commit attribution. This issue should stay the credential issue. rfc: granular permissioning #951 should stay the view issue.Omnigraph-If-Actorchecks the actor the token already resolved. It does not supply an actor.WWW-Authenticatepart.--aswhere the command records no actor.omnigraph studiolauncher and graph owner discovery #980, open, and omnigraph-server: runtime reload of the bearer-token source (SIGHUP or admin endpoint) #434, open. Studio launch, and bearer-token reload. Neither is a delegation mechanism.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 isPOST /exportand uses theexportaction (handlers.rslines 1571–1578). This policy did not grantexport. Direct--storewrites were not the bearer-token case. The bearer-token case is the one the issue describes.