Agent tool policy and guarded writes¶
This page is the reference for the optional agent tool-exposure policy
(agent-tools.yaml) and the guarded-write proposal lifecycle it enables.
For a walkthrough, see PyCharter for AI agents.
The policy is plain configuration, not a versioned artifact kind: it has no
api_version, no kind, and is never stored. Pass it to the server with
--config:
pycharter agent serve --config agent-tools.yaml
pycharter agent tools --config agent-tools.yaml # what does it expose?
Defaults: no file means read-only¶
With no --config, a server exposes every read tool and no write
tool. Write tools are neither advertised nor callable: a client that calls
one by name gets {"code": "write_disabled"} (invariant I-8, CI-enforced).
Schema¶
agent:
allow_write: true # default false — the one switch for writes
require_approval: true # always true; writes are two-phase
principal: claude-code # recorded as proposer/approver (default "agent")
role: reviewer # default bound role; --role overrides
workspace: 7b0c2d1e-0000-4000-8000-000000000001 # concept-scheme id; --workspace overrides
approval:
ttl_hours: 24 # proposal lifetime (0 < ttl <= 720)
require_distinct_approver: false # segregation of duties
propose_role: reviewer # minimum role for propose_* / bind_*
commit_role: reviewer # minimum role for commit_*
tools:
read: [list_contracts, fetch_contract_schema, validate_record] # omit = all
write: [propose_contract_version, commit_contract_version] # omit = all
| Key | Type | Default | Meaning |
|---|---|---|---|
allow_write |
bool | false |
Expose the guarded-write tier. |
require_approval |
true |
true |
Documents that writes are two-phase; false is rejected. |
principal |
string | agent |
Identity written as owner_id of proposals and as approver/committer. |
role |
viewer/reviewer/admin/superuser |
viewer |
Bound requester role when --role is not given. |
workspace |
string | none | Bound workspace when --workspace is not given. |
approval.ttl_hours |
number | 24 |
Hours a proposal stays committable. |
approval.require_distinct_approver |
bool | false |
Require approval by a principal other than the proposer before commit. |
approval.propose_role |
role | reviewer |
Minimum bound role for propose_contract_version and bind_field_to_concept. |
approval.commit_role |
role | reviewer |
Minimum bound role for commit_contract_version. |
tools.read |
list | all | Read tools to expose. |
tools.write |
list | all | Write tools to expose when allow_write is on. |
Loading is fail-fast: unknown keys, an unknown role, a tool name that does not exist, or a tool listed under the wrong tier stop the server at startup with a one-line error. A misspelt safety setting never falls back to a default.
The bound role still applies with allow_write: true: viewer is read-only
everywhere in PyCharter, so a server bound to viewer withholds write tools
and says so on stderr at startup.
Tool tiers¶
Tiers are defined in the capability registry (kind: agent_tool), which is
the single source of truth; pycharter capabilities --kind agent_tool lists
them, and a CI test pins the registry to the MCP tool catalog.
For pipeline source and target validation by validate_pipeline_config, see
Pipeline connector settings.
| Tier | Tools |
|---|---|
| read | list_contracts, fetch_contract_schema, validate_record, sketch_contract, diff_contract_candidate, lint_contract, fetch_concept_pack, list_concepts, query_context, explain_field, list_capabilities, describe_capability, get_schema, validate_pipeline_config, list_pipelines, fetch_pipeline_config, list_streams, fetch_stream_config, get_quality_score, list_drift_events, get_dlq_summary, list_concept_schemes, get_agent_conformance |
| write | propose_contract_version, commit_contract_version, bind_field_to_concept |
Read tools that return stored pipeline or streaming configs mask
credentials: values under secret-like keys (password, token, api_key,
credentials, …) and user-info in URLs become ***, while environment
references such as ${DB_PASSWORD} are kept. get_dlq_summary returns
counts only, never record payloads, and list_concept_schemes is scoped to
the server's bound workspace. get_agent_conformance likewise returns counts
by reason and source plus the quality score, never quarantined payloads.
MCP result shapes¶
The focused validation, explanation, proposal, conformance, and pipeline
validation tools advertise a JSON outputSchema. On success, they return the
result object in structuredContent and keep the same serialized JSON in a
text content block for clients that only read text. The preparation tool uses
the same output contract when its capability is available. The bundled schemas
live in src/pycharter/mcp/schemas/tool_outputs.json.
An invalid record (is_valid: false) and an invalid pipeline config
(valid: false) are normal tool results. A refused or failed tool call has
isError: true and structured code, retryable, and safe message
fields. Internal exception details are logged server-side and omitted from
both result channels. Error text is retained only for exact known-safe
messages; other errors use a fixed public message for their code. The tool
allowlists still filter discovery and
dispatch, and this result formatting does not change proposal approval or
commit rules.
Proposal lifecycle¶
propose_contract_version validates, lints, and diffs a candidate against
the contract's current latest version, then stores it as a draft in
proposed status. Its token is the draft id. Nothing is written to the
contract store until commit.
propose ──► proposed ──commit──► committed (immutable contract version)
│ │
│ └──(TTL elapses)──► expired
└──reject / base moved / version taken──► rejected
| Rule | Behaviour |
|---|---|
| One-shot | A token commits once; after commit, expiry, or rejection it is dead (not_pending). |
| TTL | Past expires_at the proposal is marked expired and commit is refused (expired). |
| Stale base | If the contract's latest version is no longer base_version, commit is refused and the proposal is rejected (stale_base). Re-propose. |
| Immutable | Commit uses the store's write-once save; a version that already exists is refused (version_exists). |
| Segregation of duties | With require_distinct_approver, commit needs a prior approval (needs_approval), and the proposer cannot approve their own proposal (self_approval). By default, committing is the approval, which suits a host that gates the commit call. |
Candidates are refused before staging when they do not parse, do not build a
validator, have error-severity lint findings, lack a name or version, or
propose a version that is not newer than the latest (invalid_candidate,
version_not_newer).
Out-of-band approval¶
A person can review proposals later, from anywhere with database access:
pycharter agent proposals list
pycharter agent proposals show <token>
pycharter agent proposals approve <token> --as alice --config agent-tools.yaml
pycharter agent proposals reject <token> --as alice --reason "breaks billing"
pycharter agent proposals commit <token> --as alice --config agent-tools.yaml
pycharter agent proposals gc --older-than-days 7 # purge old expired/rejected
Pass the same --config as the server so TTL and segregation-of-duties
settings match. These CLI approval labels apply to native contracts. Imported
ODCS guidance always needs a different, host-authenticated reviewer through the
HTTP approval route, with a verified JWT workspace scope. A local --as label
or anonymous development identity cannot approve that version bundle. Direct
store/API publication remains permitted while agents withhold its unreviewed
guidance. See ODCS review policy.
The web UI offers the same review under Contracts → Agents → Proposals,
backed by these endpoints (role reviewer or higher). The approver or
committer is always the signed-in user, never a request field:
| Method | Path | Does |
|---|---|---|
GET |
/api/v1/agent/proposals?include_closed= |
List pending (or all) proposals |
GET |
/api/v1/agent/proposals/{token} |
One proposal with its diff |
POST |
/api/v1/agent/proposals/{token}/approve |
Approve as the current user |
POST |
/api/v1/agent/proposals/{token}/reject |
Reject with {"reason": ...} |
POST |
/api/v1/agent/proposals/{token}/commit |
Commit as an immutable version |
POST |
/api/v1/agent/try |
JIT schema + agent feedback for a record (writes nothing) |
Refusals map to HTTP status: unknown token 404, invalid candidate 422, and state conflicts (expired, already committed, stale base, version exists) 409. A refusal that retires the proposal is still persisted.
A rejected or expired proposal's detail (GET .../{token},
pycharter agent proposals show) reports why it closed, read from the audit
log. The UI shows it on the proposal:
closed_by |
closed_reason |
Meaning |
|---|---|---|
| the reviewer | their --reason / {"reason": ...} text |
Rejected by a person |
system |
stale_base |
The contract moved on; re-propose against the current version |
system |
ttl |
Not committed before expires_at |
Both are null for pending and committed proposals.
Audit events¶
Every transition appends a row to the durable review-event log
(semantic_review_events, the same log suggestion reviews use), keyed by the
token. Superuser audit exports (GET /api/v1/audit/export) include these
as review events; workspace-scoped exports only include suggestion reviews.
decision |
Actor | When |
|---|---|---|
contract.proposed |
proposer | Proposal staged. |
contract.approved |
approver | Approval recorded. |
contract.committed |
committer | Immutable version created. |
contract.rejected |
reviewer, or system |
Rejected, stale base, or version taken. |
contract.expired |
system |
TTL elapsed. |
The event comment is JSON with contract, version, namespace,
base_version, and a transition detail (classification, reason, or ref).
bind_field_to_concept¶
Files a pending bind_field suggestion in the governed suggestions queue
for the server's bound workspace. Nothing is bound until a reviewer accepts it,
and acceptance runs through the same binding lifecycle as any other binding.
The contract, field, and concept must all exist; unknowns are reported
(not_found), never guessed. A server with no bound workspace refuses the
call (no_workspace).
Storage¶
Proposals live in the drafts table (columns status, expires_at,
approved_by, approved_at, base_version, added by revision
draft_proposals_20260913). The editor's draft list and edit, delete, and
promote routes never show or change proposals. Run pycharter db upgrade to
apply the revision. Downgrading it deletes proposal rows first, so a staged
proposal cannot come back as an ordinary editable draft.