graft. docs

Audit & approvals

Every invocation leaves a row. Destructive operations wait for a human decision.

Agents drive Graft hard. Two mechanisms keep that safe: an audit log and human-gated approvals.

Audit log

Every function invocation writes an audit_log row: actor, function and kind, branch, rate key, status, duration, correlation id, and the git SHA of the serving code. Rate limits count those rows. Handlers stay stateless.

Inputs are not recorded. The row says who ran what and how it went, not what they sent. An approval's exact input is stored on the approvals row instead, which is what binds a decision to one call.

Look up a call with x-graft-correlation-id from the response headers. See Observability.

Human-gated approvals

destructive: true functions never execute on the first call:

agent:  POST /api/fn/deleteSubmission {"id": "…"}
graft:  403 … { approvalId: "ap_…", fix: "…" }
human:  graft approvals
human:  graft approve ap_…
agent:  retry with x-graft-approval: ap_…   (MCP: approval: "ap_…")
graft:  200 { data: … }
PropertyBehavior
One-shotConsume flips approved → consumed once
Input-boundApprove A, execute B is impossible
Self-decision refusedApprover cannot be the requester

MCP form

Pass approval: "<id>" on run_function or delete_content. Do not send the HTTP header name over MCP — the bridge maps the argument for you.

Asking in-band (opt-in)

The loop above sends the human to another terminal. On a local server, where that human is sitting at the same machine, the server can ask them directly instead — MCP calls this elicitation.

createGraftMcp({
  // …
  approvalElicitation: { decider: { kind: "human", id: "you@example.com" } },
});

The destructive call then pauses, the client shows a confirmation, and an approval completes the call in one step.

It is off unless you configure it, and it is refused outright over HTTP. createGraftMcpHandler throws CONFIG_INVALID at construction if you pass approvalElicitation, because over HTTP the client being asked to approve is the agent that made the call, while decider is configured server-side — so an accepted prompt would be self-approval recorded under the operator's name. That is precisely what requested_by_id <> decided_by exists to prevent, and routing the question to the requester's own client walks around it. Elicitation is for a stdio server whose operator is at the machine.

A client that never declared the capability falls back to the id-and-retry loop, because a client that cannot ask its user is not a client whose user said yes. So does a client that declared it and then fails the request — an older SDK, a schema it will not render, a transport that times out with the dialog open. A client-side limitation should not become a broken destructive operation, and the gate is identical on either path.

Long inputs are truncated in the prompt at 1000 characters, and the prompt says so and points at graft approvals for the full text. A consent dialog that silently shows part of what is being consented to is worse than no preview, because the reader believes they have seen the call.

What does not change is everything underneath. The decision is still a row in approvals, still one-shot, still bound to the exact input, still stamped with decided_role server-side. decider is a separate setting rather than the connection's own identity because Postgres enforces requested_by_id <> decided_by in the UPDATE's own WHERE: name the operator at the machine, and if that operator is also the caller, the decision is refused exactly as it would be from the CLI. Deciding is a plain UPDATE on approvals, which the hardened runtime role deliberately cannot perform — so on a hardened deployment this fails by construction, which is the intended answer rather than a gap.

Dismissing the prompt leaves the approval pending. Only an explicit no records a denial: closing a dialog is not a decision, and recording it as one would put a verdict in the audit trail that nobody reached.

Policy

approvalPolicy has three values.

ValueGatesAvailable on
"none" (default)Only destructive: true functionsevery surface
"human"Every mutation, plus destructiveevery surface
"unattended"NothingHTTP functions only

Policy is code: greppable and reviewable.

"unattended" is not part of the MCP surface. GraftMcpOptions does not accept it, so no server setting makes run_function stop asking for a destructive call. An MCP mount exists because an agent is calling it, and the agent is the party the gate is there to stop — one option that turned every destructive tool into an ungated one is not a trade worth offering. On graft serve the split is visible: approvalPolicy: "unattended" lifts the gate on POST /api/fn and leaves POST /api/mcp gated.

Running with no human at all

"unattended" exists because the other two have no answer for a caller with nobody behind it. A scheduled cleanup job and a CI migration are both legitimate, and under "none" a destructive function refuses them forever. That was the absence of a policy rather than a policy.

createFunctionsHandler({ functions, db, approvalPolicy: "unattended" });

On graft serve, export it from graft.config.ts:

export const approvalPolicy = "unattended";

It is config rather than an env var deliberately. This is the setting that turns off the gate on irreversible work, and a dashboard variable is invisible to review — in config it appears in a diff. The server also warns on every boot while it is on, because the log is where a mistake actually gets noticed.

Understand what you are accepting. Git restores authored content, so a deleted document comes back. It does not restore operational data: deleteRecord removes rows outright and the asset store keeps no history. "unattended" is the setting that says those are acceptable losses for this deployment.

What you keep is the record. Every invocation still writes its audit row with the actor, the correlation id and the git SHA, and access rules and rate limits still apply. What "unattended" gives up is the waiting, not the accounting.

Hardened roles

Postgres enforces the split:

  • Consume (approved → consumed) runs through a SECURITY DEFINER function
  • Decide (pending → approved/denied) is an operator-role table UPDATE
  • decided_role is stamped server-side from current_user
CREATE ROLE graft_runtime LOGIN PASSWORD '…';
graft harden graft_runtime

Deploy with the runtime role as DATABASE_URL. Keep the operator URL for compile, migrate, merge, and graft approve. A leaked runtime credential cannot approve its own destructive calls.

See Deploy and Configuration.