Skip to content

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.