Skip to content
wayneColtPublic

About

A time-bound, single-use grant that lets an agent carry out a decision a human already made.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

granted

A time-bound, single-use grant that lets an agent carry out a decision a human already made — on a clock, with a receipt from the substrate, and never silently.

"you give your agent a task, then walk away and get a coffee, only to come back and find the agent got stuck on an approval on the first step and has made no progress. As a result, people often give in and set their agents to 'auto-approve', or --dangerously-skip-permissions, which is, obviously, unsafe."

— the cloudflare-os README

Cloudflare's answer is deferred approval: the agent proceeds against simulated results and the human approves later, in bulk. That fits a company where the human is back in minutes. granted is the answer for the owner-operator whose median time-to-keystroke is measured in days: approve once, in advance, with a deadline; the machine discharges it on the clock; the ledger — not the tool — confirms it; and silence is not a permitted outcome.

Not affiliated with Cloudflare. Python 3.11+, standard library only.

ci

30-second setup

pip install git+https://github.com/wayneColt/granted
granted mint --type report --id 2026-09-30 --by "owner keystroke" \
             --not-after 2026-10-01T00:00:00Z --allow send
granted status --type report --id 2026-09-30
granted discharge --type report --id 2026-09-30 --action send --exec ./send_report.sh

The last line prints one of discharged, refused, uncertain, expired with a reason, and exits 0, 3, 4 or 5 to match. Run it again: refused: consumed. Grants are files in ~/.granted/grants/; the ledger is ~/.granted/ledger.jsonl. GRANTED_HOME, GRANTED_STORE, GRANTED_LEDGER move them.

Five clauses

  1. A decision is a grant. One object, once. The human's decision becomes one file with the object's type and id in it. There is no grant for "things like this".
  2. A grant carries its deadline. not_after is required; not_before is optional. A grant with no deadline is a standing permission, and the validator refuses it.
  3. The executor discharges inside the grant and never outside it. It may never mint. The library has no path from an executor, an MCP client, or a runner to a new grant. Minting is a CLI verb, run by a hand.
  4. Every outcome is loud. discharged | refused | uncertain | expired, each with a reason from a closed vocabulary, each written to the ledger as an outcome row, each returned — never raised — to the caller. A refusal leaves a row. An expiry leaves a row.
  5. Confirmation comes from the substrate. An intent row before the action, a result row the executor writes after it, and the grant's own consumed flag and receipt, re-read from disk. The executor's return value alone confirms nothing: success with no result row is uncertain.

What stays human

The primitive is for decisions already made. It does not make them, rank them, or infer them. These verbs never ride a grant by default, and nothing here will discharge them unless a human mints for that exact object, on purpose:

  • hire
  • negotiate
  • sign
  • pay
  • first contact with no prior thread

not_scope exists so the grant can say so in words the approver reads.

The grant

{
  "kind": "granted/1",
  "decision": "APPROVE_ONCE",
  "object": {"type": "report", "id": "2026-09-30"},
  "scope": "send the weekly report to the list it went to last week",
  "not_scope": ["any other recipient", "any attachment not already in the draft"],
  "allowed_actions": ["send"],
  "not_before": null,
  "not_after": "2026-10-01T00:00:00Z",
  "minted_by": "owner keystroke",
  "minted_at": "2026-09-20T12:00:00Z",
  "consumed": false,
  "consumed_at": null,
  "receipt": null
}
field meaning
decision APPROVE_ONCE: spent on the first discharge. APPROVE_SESSION: many discharges of the same object inside one window; spent at the first outcome that is not discharged, or at not_after.
object {type, id}. The grant is about this and nothing else.
scope, not_scope words for the human; the library does not parse them
allowed_actions if non-empty, a discharge must name one of them
not_before, not_after ISO 8601 with a zone. Naive stamps are refused.
minted_by the hand. A string, not an identity system.
consumed, consumed_at, receipt written by the store; the receipt names the discharge that spent it and its outcome

The JSON Schema is at docs/grant.schema.json. One file per grant; the store finds the live one for an object and reports none | consumed | not_yet | expired | unreadable when there is none.

The state machine

find grant ──none/consumed/not_yet/unreadable──▶ refused
    │        └──expired──────────────────────────▶ expired
    ▼
write intent row ──fails──▶ refused (ledger_unwritable; nothing runs)
    ▼
claim grant (receipt: pending) ──lost──▶ refused
    ▼
executor(grant)
    ├─ raises Uncertain ─────────────────────▶ uncertain  (spent)
    ├─ raises ExecutorFailed / anything ────▶ refused    (spent: effects unknown)
    ├─ returns falsy ───────────────────────▶ refused    (executor_declined; grant stays live)
    └─ returns truthy
         ▼
       ledger.confirms(object)? no ─────────▶ uncertain  (no_result_row; spent)
         ▼ yes, and the row says ok
       settle receipt, re-read grant ──mismatch─▶ uncertain (spent)
         ▼
       discharged  (APPROVE_ONCE: spent)

Once the executor has been called, the grant is spent whatever happens next — except when the executor positively declines, which means "nothing done, try me later". Retrying anything else is a human act: mint again. The full reason vocabulary is granted.discharge.REASONS.

The executor

The library never knows how to send an email or deploy anything. The host supplies executor(grant):

it means
returns truthy claims success — and must have written a result row
returns falsy did nothing; the grant stays live
raises granted.Uncertain cannot say whether it happened
raises granted.ExecutorFailed failed part-way; effects unknown
raises anything else a bug; treated like a failure
from granted import Store, Ledger, discharge

store, ledger = Store("~/.granted/grants"), Ledger("~/.granted/ledger.jsonl")

def send_report(grant):
    message_id = mailer.send(draft_for(grant.object_id))      # the host's code
    ledger.result(grant.object, ok=True, receipt={"message_id": message_id})
    return True

outcome = discharge(store, ledger, {"type": "report", "id": "2026-09-30"}, send_report, action="send")
print(outcome)            # discharged: confirmed (report/2026-09-30) -- result row line 42

The shell executor behind granted discharge --exec writes the result row itself (exit status, stdout and stderr tails). Exit 0 claims success; exit 75 (EX_TEMPFAIL) declines; any other exit is a failure; a --timeout is uncertain. The command sees GRANTED_OBJECT_TYPE, GRANTED_OBJECT_ID, GRANTED_DISCHARGE_ID, GRANTED_GRANT_PATH, GRANTED_LEDGER, GRANTED_NOT_AFTER in its environment.

CLI

granted mint      --type T --id I --by "who" --not-after ISO [--not-before ISO] [--decision D] [--scope ..] [--not-scope ..] [--allow ..]
granted status    --type T --id I
granted discharge --type T --id I --exec "cmd" [--action A] [--timeout S]
granted ledger    [--tail N] [--id I]
granted lag       [FILE|-]
granted mcp       [--executor NAME=COMMAND ...]

--json on any of them. python -m granted works from a clone without installing.

MCP server

granted mcp --executor send_report=./send_report.sh

stdio, JSON-RPC 2.0, no third-party code. Implements initialize, ping, tools/list, tools/call. Three tools:

tool does
grant_status live | none | consumed | not_yet | expired | unreadable, the grant on file, the last ledger rows
grant_discharge discharge through an executor the host registered by name; returns one of the four outcomes
ledger_tail the last N rows

There is deliberately no mint. Minting is the human's act and lives in the CLI. An agent on this server can learn, discharge, and read back; it cannot widen its own permissions and cannot hand the executor a command of its own.

Client configuration, for a host that takes the usual shape:

{"mcpServers": {"granted": {"command": "granted", "args": ["mcp", "--executor", "send_report=./send_report.sh"]}}}

The clock

adapters/systemd/ is a granted-discharge@.timer + .service pair and a runner. The timer ticks every 15 minutes; the grant's own window decides; the runner stops the timer once there is nothing left to ask. uncertain is left as a failed unit on purpose.

Measuring the lag

The motivation is a number. Keep a JSONL of {"raised": ISO, "answered": ISO} for every time an agent waited on a keystroke, then:

$ granted lag asks.jsonl
n=10 open=1 bad=1 p50=10.0h p80=72.0h max=240.0h

Nearest-rank percentiles. open is the count still waiting. If p50 is minutes, use deferred approval. If it is days, mint.

Status

Surface Status
Core: schema, store, ledger, discharge yes; 63 tests under python -m unittest
CLI: mint, status, discharge, ledger, lag, mcp yes
MCP server (stdio; initialize, ping, tools/list, tools/call) yes; no mint by design
systemd adapter yes (unit, timer, runner)
cloudflare-os Gatekeeper design note + TypeScript skeleton; not compiled or deployed
Door (signed admission endpoint) contract only
Signed intents in the CLI / MCP path not in 0.1.0; both trust the process boundary
Store across hosts not in 0.1.0; a directory on one disk, one lock file per grant
Windows untested

Build

git clone https://github.com/wayneColt/granted
cd granted
python -m unittest -v
python -m granted --help

No dependencies. License: Apache-2.0 OR MIT.

What this is not

  • Not an approval queue. Nothing waits here; a grant exists or it does not.
  • Not an identity system. minted_by is a string the human types. The door contract in docs/DOOR.md says where signatures go; this release has none.
  • Not a scheduler. The systemd timer is a tick; the deadline is the grant's.
  • Not a policy engine. It cannot tell a safe action from a dangerous one. That is what the human did when they minted, and what not_scope is for.

Release drafts (X, blog) live in RELEASE_DRAFTS.md and wait for an operator keystroke.

About

A time-bound, single-use grant that lets an agent carry out a decision a human already made.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages