API reference
Governance
Policy changes an agent may ask for but never make. An agent proposes after repeated refusals, a different identity ratifies, the system enacts, and the grant gate consults the result.
GET/v1/governance/proposalsPOST/v1/governance/proposalsGET/v1/governance/proposals/{proposal_id}POST/v1/governance/proposals/{proposal_id}/enactPOST/v1/governance/proposals/{proposal_id}/ratifyPOST/v1/governance/proposals/{proposal_id}/rejectPOST/v1/policy/test
GET /v1/governance/proposals
List proposals
Every proposal on this account, newest first, with who proposed, ratified, enacted or rejected it and when.
Parameters
statusquery, string or null- proposed, ratified, enacted or rejected
subagentquery, string or null- Exact subject sub-agent name
Request body
None.
Example request
curl
curl -sS -X GET "https://api.afaprotocol.com/v1/governance/proposals" \
-H "X-API-Key: afa-beta-EXAMPLE-e4qs"
Python
import requests
API = "https://api.afaprotocol.com"
headers = {"X-API-Key": "afa-beta-EXAMPLE-e4qs"}
r = requests.get(f"{API}/v1/governance/proposals", headers=headers, timeout=30)
r.raise_for_status()
print(r.json())
Example response
HTTP 200
{
"count": 2,
"proposals": [
{
"enacted_at": "2026-09-07T14:07:00+00:00",
"enactor": {
"identity": "b2e4d6f8-1a3c-4e5f-8b9d-0c1e2f3a4b5c",
"kind": "api_key",
"machine": "reviewer-laptop"
},
"evidence": {
"blocked_attempts": 3,
"threshold": 3
},
"kind": "deny_scope",
"note": "worker-1 asked for Bash three times outside its grant",
"proposal_id": "prop-4c1e9a7b2d5f8036",
"proposed_at": "2026-09-07T14:00:00+00:00",
"proposer": {
"identity": "7a1c3e5b-2d4f-4a6b-9c8e-0f1a2b3c4d5e",
"kind": "api_key",
"machine": "worker-host"
},
"ratified_at": "2026-09-07T14:06:00+00:00",
"ratifier": {
"identity": "b2e4d6f8-1a3c-4e5f-8b9d-0c1e2f3a4b5c",
"kind": "api_key",
"machine": "reviewer-laptop"
},
"rejected_at": null,
"rejector": null,
"status": "enacted",
"subject": {
"subagent": "worker-1",
"tool": "Bash"
}
},
{
"enacted_at": null,
"enactor": null,
"evidence": {
"blocked_attempts": 3,
"threshold": 3
},
"kind": "deny_scope",
"note": "worker-1 asked for Bash three times outside its grant",
"proposal_id": "prop-9d3f7b1e5a2c6048",
"proposed_at": "2026-09-07T14:00:00+00:00",
"proposer": {
"identity": "7a1c3e5b-2d4f-4a6b-9c8e-0f1a2b3c4d5e",
"kind": "api_key",
"machine": "worker-host"
},
"ratified_at": null,
"ratifier": null,
"rejected_at": "2026-09-07T14:10:00+00:00",
"rejector": {
"identity": "7a1c3e5b-2d4f-4a6b-9c8e-0f1a2b3c4d5e",
"kind": "api_key",
"machine": "worker-host"
},
"status": "rejected",
"subject": {
"subagent": "worker-2",
"tool": "Write"
}
}
]
}
Errors
| Status | Code | Meaning |
|---|---|---|
| 401 | missing_token / invalid_or_expired_api_key | No credential, an expired session, or a revoked or expired key. |
| 403 | scope_missing | A key limited by scopes lacks governance; the body names it. |
What would show this is false
Each transition is also an event on your chain (governance.proposed, governance.ratified, governance.enacted, governance.rejected). GET /v1/chain/verify covers them.
POST /v1/governance/proposals
Propose a policy change
An agent that was refused repeatedly asks for a rule, naming the sub-agent, the tool, and how many refusals it saw. It cannot enact the rule itself.
Parameters
None.
Request body
evidenceEvidenceBody required- No description in the schema.
EvidenceBody fields
blocked_attemptsinteger required- Blocked attempts observed for this subject; must reach the configured threshold
kindstring required- deny_scope, allow_scope or policy_note
notestring or null- No description in the schema.
subjectSubjectBody required- No description in the schema.
SubjectBody fields
scopestring or null- Synonym for tool, for callers that name scopes; stored as tool
subagentstring required- Exact sub-agent name the rule applies to
toolstring or null- Optional tool name; absent means the whole sub-agent
Example request
curl
curl -sS -X POST "https://api.afaprotocol.com/v1/governance/proposals" \
-H "X-API-Key: afa-beta-EXAMPLE-e4qs" \
-H "Content-Type: application/json" \
-d '{
"evidence": {
"blocked_attempts": 3,
"threshold": 3
},
"kind": "deny_scope",
"note": "worker-1 asked for Bash three times outside its grant",
"subject": {
"subagent": "worker-1",
"tool": "Bash"
}
}'
Python
import requests
API = "https://api.afaprotocol.com"
headers = {"X-API-Key": "afa-beta-EXAMPLE-e4qs"}
payload = {
"evidence": {
"blocked_attempts": 3,
"threshold": 3
},
"kind": "deny_scope",
"note": "worker-1 asked for Bash three times outside its grant",
"subject": {
"subagent": "worker-1",
"tool": "Bash"
}
}
r = requests.post(f"{API}/v1/governance/proposals", headers=headers, json=payload, timeout=30)
r.raise_for_status()
print(r.json())
Example response
HTTP 201
{
"proposal": {
"enacted_at": null,
"enactor": null,
"evidence": {
"blocked_attempts": 3,
"threshold": 3
},
"kind": "deny_scope",
"note": "worker-1 asked for Bash three times outside its grant",
"proposal_id": "prop-4c1e9a7b2d5f8036",
"proposed_at": "2026-09-07T14:00:00+00:00",
"proposer": {
"identity": "7a1c3e5b-2d4f-4a6b-9c8e-0f1a2b3c4d5e",
"kind": "api_key",
"machine": "worker-host"
},
"ratified_at": null,
"ratifier": null,
"rejected_at": null,
"rejector": null,
"status": "proposed",
"subject": {
"subagent": "worker-1",
"tool": "Bash"
}
}
}
Errors
| Status | Code | Meaning |
|---|---|---|
| 401 | missing_token / invalid_or_expired_api_key | No credential, an expired session, or a revoked or expired key. |
| 403 | scope_missing | A key limited by scopes lacks governance; the body names it. |
| 422 | validation error | A required field is missing or a value has the wrong type. |
| 422 | threshold_not_met | fewer blocked attempts than the configured threshold (default 3) |
| 422 | kind outside deny_scope, allow_scope, policy_note |
What would show this is false
Read the proposal back with GET. Its status is proposed and the grant gate is unchanged; GET /v1/grants/status still answers as before.
GET /v1/governance/proposals/{proposal_id}
One proposal
The full record of one proposal, including the identities behind each transition.
Parameters
proposal_idpath, string required- No description in the schema.
Request body
None.
Example request
curl
curl -sS -X GET "https://api.afaprotocol.com/v1/governance/proposals/prop-4c1e9a7b2d5f8036" \
-H "X-API-Key: afa-beta-EXAMPLE-e4qs"
Python
import requests
API = "https://api.afaprotocol.com"
headers = {"X-API-Key": "afa-beta-EXAMPLE-e4qs"}
r = requests.get(f"{API}/v1/governance/proposals/prop-4c1e9a7b2d5f8036", headers=headers, timeout=30)
r.raise_for_status()
print(r.json())
Example response
HTTP 200
{
"proposal": {
"enacted_at": "2026-09-07T14:07:00+00:00",
"enactor": {
"identity": "b2e4d6f8-1a3c-4e5f-8b9d-0c1e2f3a4b5c",
"kind": "api_key",
"machine": "reviewer-laptop"
},
"evidence": {
"blocked_attempts": 3,
"threshold": 3
},
"kind": "deny_scope",
"note": "worker-1 asked for Bash three times outside its grant",
"proposal_id": "prop-4c1e9a7b2d5f8036",
"proposed_at": "2026-09-07T14:00:00+00:00",
"proposer": {
"identity": "7a1c3e5b-2d4f-4a6b-9c8e-0f1a2b3c4d5e",
"kind": "api_key",
"machine": "worker-host"
},
"ratified_at": "2026-09-07T14:06:00+00:00",
"ratifier": {
"identity": "b2e4d6f8-1a3c-4e5f-8b9d-0c1e2f3a4b5c",
"kind": "api_key",
"machine": "reviewer-laptop"
},
"rejected_at": null,
"rejector": null,
"status": "enacted",
"subject": {
"subagent": "worker-1",
"tool": "Bash"
}
}
}
Errors
| Status | Code | Meaning |
|---|---|---|
| 401 | missing_token / invalid_or_expired_api_key | No credential, an expired session, or a revoked or expired key. |
| 403 | scope_missing | A key limited by scopes lacks governance; the body names it. |
| 422 | validation error | A required field is missing or a value has the wrong type. |
| 404 | no proposal with that id on this account |
What would show this is false
proposer.identity and ratifier.identity differ. If they were equal the ratify call would have been refused.
POST /v1/governance/proposals/{proposal_id}/enact
Enact
Puts a ratified rule into force. From this call on, the grant gate answers denied_by_governance for the named sub-agent and tool, naming the proposal.
Parameters
proposal_idpath, string required- No description in the schema.
Request body
TransitionBody or null
Example request
curl
curl -sS -X POST "https://api.afaprotocol.com/v1/governance/proposals/prop-4c1e9a7b2d5f8036/enact" \
-H "X-API-Key: afa-beta-EXAMPLE-e4qs" \
-H "Content-Type: application/json" \
-d '{}'
Python
import requests
API = "https://api.afaprotocol.com"
headers = {"X-API-Key": "afa-beta-EXAMPLE-e4qs"}
payload = {}
r = requests.post(f"{API}/v1/governance/proposals/prop-4c1e9a7b2d5f8036/enact", headers=headers, json=payload, timeout=30)
r.raise_for_status()
print(r.json())
Example response
HTTP 200
{
"proposal": {
"enacted_at": "2026-09-07T14:07:00+00:00",
"enactor": {
"identity": "b2e4d6f8-1a3c-4e5f-8b9d-0c1e2f3a4b5c",
"kind": "api_key",
"machine": "reviewer-laptop"
},
"evidence": {
"blocked_attempts": 3,
"threshold": 3
},
"kind": "deny_scope",
"note": "worker-1 asked for Bash three times outside its grant",
"proposal_id": "prop-4c1e9a7b2d5f8036",
"proposed_at": "2026-09-07T14:00:00+00:00",
"proposer": {
"identity": "7a1c3e5b-2d4f-4a6b-9c8e-0f1a2b3c4d5e",
"kind": "api_key",
"machine": "worker-host"
},
"ratified_at": "2026-09-07T14:06:00+00:00",
"ratifier": {
"identity": "b2e4d6f8-1a3c-4e5f-8b9d-0c1e2f3a4b5c",
"kind": "api_key",
"machine": "reviewer-laptop"
},
"rejected_at": null,
"rejector": null,
"status": "enacted",
"subject": {
"subagent": "worker-1",
"tool": "Bash"
}
}
}
Errors
| Status | Code | Meaning |
|---|---|---|
| 401 | missing_token / invalid_or_expired_api_key | No credential, an expired session, or a revoked or expired key. |
| 403 | scope_missing | A key limited by scopes lacks governance; the body names it. |
| 422 | validation error | A required field is missing or a value has the wrong type. |
| 409 | not_ratified | the proposal has not been ratified |
| 404 | unknown proposal |
What would show this is false
Call GET /v1/grants/status?subagent=worker-1&tool=Bash. The answer is denied_by_governance with this proposal_id. Before enact it was not.
POST /v1/governance/proposals/{proposal_id}/ratify
Ratify (a different identity)
A second identity, a different key or machine than the proposer, approves the proposal. The proposer's own key is refused: the party that was constrained never holds the pen.
Parameters
proposal_idpath, string required- No description in the schema.
Request body
TransitionBody or null
Example request
curl
curl -sS -X POST "https://api.afaprotocol.com/v1/governance/proposals/prop-4c1e9a7b2d5f8036/ratify" \
-H "X-API-Key: afa-beta-EXAMPLE-e4qs" \
-H "Content-Type: application/json" \
-d '{}'
Python
import requests
API = "https://api.afaprotocol.com"
headers = {"X-API-Key": "afa-beta-EXAMPLE-e4qs"}
payload = {}
r = requests.post(f"{API}/v1/governance/proposals/prop-4c1e9a7b2d5f8036/ratify", headers=headers, json=payload, timeout=30)
r.raise_for_status()
print(r.json())
Example response
HTTP 200
{
"proposal": {
"enacted_at": null,
"enactor": null,
"evidence": {
"blocked_attempts": 3,
"threshold": 3
},
"kind": "deny_scope",
"note": "worker-1 asked for Bash three times outside its grant",
"proposal_id": "prop-4c1e9a7b2d5f8036",
"proposed_at": "2026-09-07T14:00:00+00:00",
"proposer": {
"identity": "7a1c3e5b-2d4f-4a6b-9c8e-0f1a2b3c4d5e",
"kind": "api_key",
"machine": "worker-host"
},
"ratified_at": "2026-09-07T14:06:00+00:00",
"ratifier": {
"identity": "b2e4d6f8-1a3c-4e5f-8b9d-0c1e2f3a4b5c",
"kind": "api_key",
"machine": "reviewer-laptop"
},
"rejected_at": null,
"rejector": null,
"status": "ratified",
"subject": {
"subagent": "worker-1",
"tool": "Bash"
}
}
}
Errors
| Status | Code | Meaning |
|---|---|---|
| 401 | missing_token / invalid_or_expired_api_key | No credential, an expired session, or a revoked or expired key. |
| 403 | scope_missing | A key limited by scopes lacks governance; the body names it. |
| 422 | validation error | A required field is missing or a value has the wrong type. |
| 409 | separation_of_powers | the caller is the identity that proposed it |
| 409 | invalid_transition | already ratified, enacted or rejected |
| 404 | unknown proposal |
What would show this is false
Ratify with the proposer's key first and read the 409. Then ratify with another key and read status ratified. The rule is still not in force until enact.
POST /v1/governance/proposals/{proposal_id}/reject
Reject
Closes a proposal without enacting it. Allowed from proposed or ratified; an enacted rule cannot be rejected, it can only be superseded by a new proposal.
Parameters
proposal_idpath, string required- No description in the schema.
Request body
TransitionBody or null
Example request
curl
curl -sS -X POST "https://api.afaprotocol.com/v1/governance/proposals/prop-9d3f7b1e5a2c6048/reject" \
-H "X-API-Key: afa-beta-EXAMPLE-e4qs" \
-H "Content-Type: application/json" \
-d '{
"reason": "withdrawn"
}'
Python
import requests
API = "https://api.afaprotocol.com"
headers = {"X-API-Key": "afa-beta-EXAMPLE-e4qs"}
payload = {
"reason": "withdrawn"
}
r = requests.post(f"{API}/v1/governance/proposals/prop-9d3f7b1e5a2c6048/reject", headers=headers, json=payload, timeout=30)
r.raise_for_status()
print(r.json())
Example response
HTTP 200
{
"proposal": {
"enacted_at": null,
"enactor": null,
"evidence": {
"blocked_attempts": 3,
"threshold": 3
},
"kind": "deny_scope",
"note": "worker-1 asked for Bash three times outside its grant",
"proposal_id": "prop-9d3f7b1e5a2c6048",
"proposed_at": "2026-09-07T14:00:00+00:00",
"proposer": {
"identity": "7a1c3e5b-2d4f-4a6b-9c8e-0f1a2b3c4d5e",
"kind": "api_key",
"machine": "worker-host"
},
"ratified_at": null,
"ratifier": null,
"rejected_at": "2026-09-07T14:10:00+00:00",
"rejector": {
"identity": "7a1c3e5b-2d4f-4a6b-9c8e-0f1a2b3c4d5e",
"kind": "api_key",
"machine": "worker-host"
},
"status": "rejected",
"subject": {
"subagent": "worker-2",
"tool": "Write"
}
}
}
Errors
| Status | Code | Meaning |
|---|---|---|
| 401 | missing_token / invalid_or_expired_api_key | No credential, an expired session, or a revoked or expired key. |
| 403 | scope_missing | A key limited by scopes lacks governance; the body names it. |
| 422 | validation error | A required field is missing or a value has the wrong type. |
| 409 | invalid_transition | already enacted or rejected |
| 404 | unknown proposal |
What would show this is false
The grant gate is unchanged after a reject. A rejected proposal never appears as a reason in GET /v1/grants/status.
POST /v1/policy/test
What would the dedup gate decide
Answers hit, miss, bypass or uncacheable for a hypothetical call, writing nothing.
The key and the window are the ones the real gate uses, so the answer predicts what a real call would get. The response also reports the window in events and seconds.
Parameters
None.
Request body
intentstring or null- fresh | cached_ok
parent_event_idstring or null- No description in the schema.
toolstring required- No description in the schema.
tool_inputobject- No description in the schema.
Example request
curl
curl -sS -X POST "https://api.afaprotocol.com/v1/policy/test" \
-H "X-API-Key: afa-beta-EXAMPLE-e4qs" \
-H "Content-Type: application/json" \
-d '{
"tool": "Read",
"tool_input": {
"file_path": "/srv/app/config.toml"
}
}'
Python
import requests
API = "https://api.afaprotocol.com"
headers = {"X-API-Key": "afa-beta-EXAMPLE-e4qs"}
payload = {
"tool": "Read",
"tool_input": {
"file_path": "/srv/app/config.toml"
}
}
r = requests.post(f"{API}/v1/policy/test", headers=headers, json=payload, timeout=30)
r.raise_for_status()
print(r.json())
Example response
HTTP 200
{
"cached_at": "2026-09-01T14:10:21+00:00",
"cached_from_event_id": "evt-5e6f708192a3b4c5",
"cached_from_sequence": 4,
"dedup_action": "reuse",
"dedup_status": "hit",
"semantic_key": "sha256:12ba8b0e2703a0156eb1339f44574881babf4b3af933d2b56faa3b6fd6f3d04f",
"would_hit": true
}
Errors
| Status | Code | Meaning |
|---|---|---|
| 401 | missing_token / invalid_or_expired_api_key | No credential, an expired session, or a revoked or expired key. |
| 403 | scope_missing | A key limited by scopes lacks governance; the body names it. |
| 422 | validation error | A required field is missing or a value has the wrong type. |
What would show this is false
Ask with intent fresh: dedup_status bypass and would_hit false regardless of history. Then POST /v1/events/search shows no new row.