Skip to main content
Glama
wayneColt
by wayneColt
README.md
# 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](https://github.com/cloudflare/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](https://github.com/wayneColt/granted/actions/workflows/ci.yml/badge.svg)](https://github.com/wayneColt/granted/actions/workflows/ci.yml)

## 30-second setup

```bash
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

```json
{
  "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`](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 |

```python
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

```bash
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:

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

## The clock

[`adapters/systemd/`](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:

```bash
$ 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](adapters/cloudflare-os-gatekeeper/README.md) + TypeScript skeleton; not compiled or deployed |
| Door (signed admission endpoint) | [contract only](docs/DOOR.md) |
| 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

```bash
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`](RELEASE_DRAFTS.md) and wait for an operator keystroke.

TDQS

A3.7/5.0

Scored across 3 tools

Disambiguation5/5

Each tool has a clearly distinct purpose: grant_status checks the current state of a grant, grant_discharge executes a recorded human decision, and ledger_tail provides raw ledger history. Even though status and ledger_tail both touch ledger rows, their focus and output differ enough to avoid confusion.

Naming Consistency4/5

All tool names use lowercase snake_case and follow a consistent 'domain_action/noun' style, such as grant_status, grant_discharge, and ledger_tail. They deviate from a strict verb_noun pattern but are still predictable and internally consistent.

Tool Count5/5

With only three tools, the surface is tightly scoped to the server's apparent purpose: checking grant state, discharging grants, and auditing the ledger. Each tool covers a distinct operation and none feels redundant or unnecessary.

Completeness5/5

The server intentionally does not create or mint grants—those decisions are recorded by humans and executed via grant_discharge. Within that scope, status checking, execution, and ledger auditing form a complete workflow with no obvious dead ends.

Maintenance

ActivityMaintained
ResponsivenessNo issues