Skip to content

PyCharter — A Toolkit FOR AI Agents (AG)

Status: in progress (read tier shipped 2026-09-13; guarded write AG-8/9/10 implemented 2026-09-13) · Created: 2026-09-13 · Audience: maintainers · Absorbs: agent-native-contracts.md (AN-1…AN-8 = the foundation layer) · Depends on: the usability Phase-1 trust items (COR-1, COR-2, DX-1, TEST-1; all shipped — the usability roadmap has been folded into BACKLOG.md) · Honours: cross-package-decoupling.md, ARCHITECTURE.md (the two laws).

Scope decisions (locked with maintainer, 2026-09-13)

  1. Consumers: both dev-time coding agents (Claude Code, Cursor) and runtime data agents — served over one MCP-first surface.
  2. Autonomy: read + guarded write — grounding/validation always available; mutations are policy-gated, fail-closed by default, and require explicit approval (human-in-the-loop).
  3. Surface (v1): focused core — contract authoring (sketch/diff/lint), validation grounding, and concept context. Pipelines, streaming, quality, and vocabulary come in a later wave.
  4. Bundling: raw tools + adapters only — no new artifact kind. kind: Toolset and kind: Agent are rejected (see §12). PyCharter exposes individual, governed tools; the caller composes them.

1. Vision & positioning

PyCharter is the toolkit an AI agent reaches for when it must be correct about your data. The same versioned contract that validates an API payload, a Kafka record, and an ETL batch becomes the agent's ground truth: the schema it is handed, the validator that checks its output, the vocabulary that tells it what a field means, and the governed set of actions it is allowed to take — all over MCP, with zero pycharter install on the agent side.

This extends the core value proposition ("the contract is what actually runs") to the agent surface without changing it. PyCharter does not become an agent, define an agent, or run an agent loop. It provides three things an agent needs and nothing more:

  • Eyes — just-in-time, governance-stripped schemas and governed concept context (an agent is told only what it needs, policy-filtered).
  • Conscience — graduated validation returned as model-consumable feedback, deterministic field mapping, PII/restricted redaction.
  • Hands (guarded) — a focused set of actions (sketch/diff/lint/publish a contract) that are policy-gated, approval-required, and audit-logged.

The agent's model, prompt, and loop stay entirely with the caller. That boundary is what keeps PyCharter a generalizable toolkit rather than an agent framework.


2. Design invariants (the guardrails)

The "toolkit for agents" idea is PyCharter's biggest bloat / lock-in risk. Every AG item is checked against these; several become CI gates.

# Invariant Enforced by
I-1 No agent in src/. No persona, prompt, loop, or vertical "bot." Tools are registered capabilities over existing services. CI check: src/pycharter/agent/ is an engine (registry/resolver/MCP wiring), never a domain agent.
I-2 Stateless, LLM-free core. No core module (runtime_validator, contract_*, pipeline_*) imports a provider SDK or calls a model. Import-lint test: no anthropic/openai/provider import outside agent/, semantic/ai/, contrib/.
I-3 Provider-agnostic, no bundled LLM. All optional model use goes through AIProvider; [ai] stays optional; NoOpProvider keeps defaults working. semantic/ai/provider.py; tests run under NoOpProvider.
I-4 No orchestration framework. PyCharter emits schemas/feedback and exposes tools; the loop is caller-owned or a thin contrib/ adapter. AN-8 non-goals; adapters are import-guarded leaf modules.
I-5 Extraction stays in pyintake. No document/RAG ingestion duplicated here. cross-package-decoupling.md.
I-6 Runtime determinism. No per-record embedding/semantic lookup on any hot path; agent-facing resolution (aliases/mappings) is frozen at publish time (CB rule). AN-4 freezing; reproducibility tests.
I-7 Governance is mandatory. Every tool call is policy-filtered (role/workspace/PII/restricted) and audit-logged, reusing the context-pack policy layer. Reuse mcp/server.py policy filter + audit/lineage surface.
I-8 Writes fail-closed. Mutating tools are disabled unless explicitly enabled in config, and even then require an approval step (§5). Config default allow_write=false; two-phase commit.

3. Architecture placement — MCP-first shell, no core changes

   ┌──────────────────────────────────────────────────────────────┐
   │  AGENT / FRAMEWORK  (Claude Code, Cursor, Claude Agent SDK,    │
   │  Pydantic AI, a script)  ── owns the model, prompt, and loop   │
   └──────────────┬───────────────────────────────┬───────────────┘
             MCP  │ (zero-install, primary)        │ contrib adapter (pip)
   ┌──────────────▼───────────────────────────────▼───────────────┐
   │        AGENT SHELL  (new: src/pycharter/agent/, thin)         │
   │  tool registry · read/guarded-write tiers · approval + audit  │
   │  · feedback renderer · provider boundary · policy filter      │
   └──────────────┬───────────────────────────────────────────────┘
   grounding (AN-1/5)        guardrails (AN-2/3/4)      guarded actions
   ┌──────────────▼───────────────────────────────────────────────┐
   │             STATELESS CORE  (unchanged, LLM-free)             │
   │  Validator · contracts · concepts/vocabulary · diff · sketch  │
   │  · quality · DLQ · stores · audit                             │
   └───────────────────────────────────────────────────────────────┘

The agent shell is peer to the HTTP and worker shells. It composes and exposes existing core capabilities as governed tools; it adds no capability to the core.


4. The tool catalog (the product)

The deliverable is a small, sharp, governed set of MCP tools (plus adapter equivalents), focused on contracts + concepts, tiered by autonomy. Both audiences are served by one catalog.

4.1 Read / advise tier — always available (policy-filtered)

Tool Does Serves Built on
list_contracts Namespace-scoped contract discovery both store + policy (AN-5)
fetch_contract_schema JIT pruned, governance-stripped schema, optional token budget both to_llm_schema (AN-1)
validate_payload Validate candidate output → is_valid + structured feedback runtime Validator + to_agent_feedback (AN-2)
suggest_field_mapping Rejected (AG-6, §12). Replaced by a did-you-mean hint on undeclared-field diagnostics in validate_record feedback runtime ValidationResult.to_agent_feedback()
fetch_concept_pack Governed concept/relationship context for fields both existing MCP concept tools
sketch_contract_preview Infer a candidate contract from sample rows — returns a draft, does not persist coding contract sketch
diff_contract_candidate Classify a proposed change vs a stored version (breaking/safe) coding contract diff
lint_contract Static checks on a candidate contract coding contract lint
list_capabilities / describe_capability Enumerate registered building blocks coding existing capability tools

Read tools never mutate the store and never surface PII/restricted raw values (redaction per policy).

4.2 Guarded-write tier — off by default, approval-required (§5)

Tool Does Guard
propose_contract_version Stage a new/updated contract → returns a diff + plan + approval token; does not commit write-enabled + role + workspace
commit_contract_version Commit a previously-proposed change by token (immutable new version) approval token + audit event
bind_field_to_concept Propose a field↔concept binding → suggestion-queue or approval governed flywheel

run_pipeline / streaming actuation are deliberately deferred past v1 (focused-core scope) and, when added, use the same guard.

4.3 Dual-audience mapping

  • Coding agent (dev-time): list_contracts → fetch_contract_schema → sketch_contract_preview/diff_contract_candidate/lint_contract → propose_/commit_contract_version. "Help me evolve this contract safely."
  • Runtime data agent: fetch_contract_schema → (emit) → validate_payload (feedback with did-you-mean hints) → repair loop → quarantine. "Make my output conform, or tell me exactly why it doesn't."

5. Guarded-write design (the safety core of decision #2)

Mutations are the highest-risk surface, so they get a first-class, config-driven safety model — not an afterthought.

  1. Fail-closed (I-8). Write tools are absent from the catalog unless allow_write=true. A read-only deployment is the default and needs no configuration.
  2. Two-phase, human-in-the-loop. A write is always propose_* → (human approves) → commit_*(token). propose_* returns a rendered diff + impact classification (breaking/safe) + a short-lived token; it commits nothing. This maps cleanly onto how MCP hosts (Claude Code) already surface actions for approval, and onto a UI approval panel.
  3. Policy-scoped. The requester's role/workspace must permit the write; PII/restricted fields are honoured; the same audited policy filter as the read tier applies.
  4. Immutable + audited. A commit creates a new immutable contract version (existing immutable-versions semantics) and emits an audit event with the requester, token, and diff — so every agent-made change is reviewable and reversible by publishing a prior version.
  5. Escalation. On rejected/expired approval, nothing is written; the tool returns a clean, model-readable reason (no traceback — ties to DX-1).

This is what makes "let an agent help author contracts" safe enough to enable in a real org.


6. The friendliest configuration-driven surface (no new artifact kind)

Decision #4 rules out a Toolset artifact, but "config-driven" still governs the tool surface — via plain config, not a versioned domain artifact. A single, small, optional tool-exposure policy controls who gets which tools at what autonomy:

# agent-tools.yaml — plain config, passed with --config (as implemented, AG-8)
agent:
  # --- autonomy (fail-closed) ---
  allow_write: false             # I-8: writes off unless set
  require_approval: true         # writes are always two-phase (§5); false is rejected
  # --- identity + governance (reuses the existing requester policy layer) ---
  principal: claude-code         # recorded as proposer / approver
  role: reviewer                 # bound role; --role overrides
  workspace: <concept-scheme id> # bound workspace; --workspace overrides
  approval:
    ttl_hours: 24                # S2
    require_distinct_approver: false   # S3
    propose_role: reviewer
    commit_role: reviewer
  # --- scope (omit a list for "all tools of that tier") ---
  tools:
    read:  [list_contracts, fetch_contract_schema, validate_record]
    write: [propose_contract_version, commit_contract_version]   # ignored if allow_write:false

Implementation notes (AG-8). The draft sketch above had enabled, transport, roles, and redact keys; they were left out rather than shipped as no-ops, because unknown keys are rejected at load and a key that silently does nothing is a safety hazard in a policy file. PII redaction is already enforced by the read tools, and identity is a single bound role, as it is for the HTTP layer. The [agent] block in pycharter.cfg was also left out, so there is no new implicit config lookup; --config is explicit. The authoritative schema lives in docs/reference/agent-tools-policy.md.

Properties that keep it friendly and disciplined: - Zero-config default is safe and useful: with no file, the MCP server serves the full read tier, policy-filtered, writes off. - One knob to enable authoring: allow_write: true (still approval-gated). - It is config, not a new artifact kind — versionable in the app repo, no engine schema, no Toolset abstraction to learn.


7. Foundation layer — adopt AN-1…AN-8

These primitives are the tools' internals and ship first (useful even before the catalog is packaged):

  • AN-1 to_llm_schema / to_tool_schema(provider) — JIT schemas.
  • AN-2 ValidationResult.to_agent_feedback() — correction text.
  • AN-3 repair_loop(...) — bounded, shell-layer, DLQ escalation.
  • AN-4 SemanticMapper — deterministic, publish-frozen alias mapping. Rejected (AG-6, §12).
  • AN-5 MCP fetch_contract_schema / validate_payload / list_contracts.
  • AN-6 contrib adapters (Claude Agent SDK, Pydantic AI).
  • AN-7 "PyCharter for AI agents" docs + llms.txt.
  • AN-8 non-goals (== invariants).

AG adds on top: the read/guarded-write tiering + approval model (§5), the config-driven exposure policy (§6), the coding-agent tools (sketch/diff/lint preview, §4.1), and the dual-audience packaging (§8).


8. Friendly developer/agent experience

  • Zero-install path (primary): point any MCP host at pycharter agent serve --mcp; the agent immediately has the read tier. One line in the host's MCP config; nothing to pip install agent-side.
  • CLI:
  • pycharter agent serve [--mcp] [--config agent-tools.yaml] — run the governed tool surface.
  • pycharter agent tools — list exposed tools with tier, contracts, and policy (the "what can the agent do here?" answer).
  • pycharter agent try --record r.json — dry-run the runtime loop (fetch schema → validate → feedback → repair → quarantine) offline with NoOpProvider; plug a provider to see real repair. Mirrors the pycharter init "works in 30s" win.
  • pycharter schema extended to describe agent tool I/O (closes DX-4).
  • contrib adapters: turn a contract into a native tool/response-format for Claude Agent SDK / Pydantic AI (AN-6) — for teams that prefer SDK over MCP.
  • Errors as UX: the global CLI/tool error handler (DX-1) turns bad config / unknown tool / rejected write into clean, model-readable messages — never tracebacks.
  • UI (later): an approval panel for guarded writes and a "Try it" panel reusing the AN-2 renderer, built on the existing editor + SWR (UX-1), not a new stack.

9. Generalizability & anti-bloat plan

  • Thin new shell. src/pycharter/agent/ target < 1.5k LOC (registry, tiering, approval, MCP wiring). Status 2026-09-14: ~1.76k LOC after AG-17 (proposal lifecycle ~840, conformance ~320); every module is under the 400-line cap. Revisit when ARCH-1 lands. No new artifact kind (decision #4) means no parser/store/migration surface added.
  • Consolidate before building. AG is gated on ARCH-1 (now tracked in BACKLOG.md) (collapse the sprawling semantic/ ai/kb/overlapping subpackages) so the provider boundary and grounding logic have one home.
  • One provider boundary. Unify semantic/ai/* behind the single AIProvider protocol, re-homed under agent/providers/; [ai] stays the only SDK-pulling extra.
  • The capability registry is the sole extension point. New tools register through the existing registry that backs pycharter capabilities; no parallel plugin system.
  • CI invariant checks (I-1, I-2, I-8) run in scripts/ci.sh.

10. Prerequisites (from the usability review, all shipped)

An agent toolkit is only as trustworthy as the ground it stands on: - ⛔ COR-1 enforce additionalProperties (an agent told a schema is closed must have that be true). - ⛔ COR-2 unify artifact format to v2 (agents fetch/emit the format the engine accepts). - ⛔ DX-1 global error handler (tool errors are model-readable, not tracebacks). - ⛔ TEST-1 doc-example CI gate (every agent recipe stays runnable).


11. Workstreams, IDs & phased roadmap

IDs AG-N; foundation items reference AN-N. Effort S/M/L/XL.

Phase 0 — Prerequisites

  • ⛔ COR-1, COR-2, DX-1, TEST-1.

Phase 1 — Grounding & feedback primitives (runtime agents)

  • AG-1 [SHIPPED — runtime_validator/llm_schema.py] (AN-1) to_llm_schema / to_tool_schema — S/M
  • AG-2 [SHIPPED — ValidationResult.to_agent_feedback()] (AN-2) to_agent_feedback() — S
  • AG-3 [SHIPPED — MCP fetch_contract_schema/list_contracts + validate_record feedback] (AN-5) MCP read tools: fetch_contract_schema, validate_payload, list_contracts (policy-filtered) — M
  • AG-4 [SHIPPED — pycharter.agent.repair_loop/arepair_loop] (AN-3) repair_loop + DLQ escalation — M

Phase 2 — Coding-agent read tools + deterministic mapping

  • AG-5 [SHIPPED — MCP sketch_contract/diff_contract_candidate/lint_contract] MCP read tools: sketch_contract_preview, diff_contract_candidate, lint_contract (draft-only, no persist) — M
  • AG-6 [REJECTED 2026-09-13 — see §12; replaced by a did-you-mean hint in validation feedback (runtime_validator/_suggestions.py)] (AN-4) SemanticMapper + suggest_field_mapping tool, publish-frozen alias map — L
  • AG-7 [SHIPPED — agent_tool capability kind with tier; registry is the tiering source of truth; drift test pins it to the MCP catalog] Register agent-facing operations in the capability registry; pycharter agent tools — S/M

Phase 3 — Guarded write (autonomy, decision #2)

  • AG-8 [SHIPPED — agent/policy.py + mcp/_exposure.py; --config on serve/tools; I-8 CI invariant] Read/guarded-write tiering + config-driven exposure policy (§6), fail-closed default — M
  • AG-9 [SHIPPED — agent/proposals.py; drafts migration draft_proposals_20260913 (SQLite+Postgres round-trip tested); MCP propose/commit; pycharter agent proposals; contrib catalog_tools] Two-phase propose_/commit_contract_version with diff+token, immutable version, audit event (§5) — L
  • AG-10 [SHIPPED — mcp/_binding_tool.py; files a pending bind_field suggestion accepted via BindingLifecycle] bind_field_to_concept via approval/suggestion queue — M

Phase 4 — Packaging, DX, positioning

  • AG-11 [SHIPPED — pycharter agent serve/tools/try] CLI agent serve/tools/try; schema for tool I/O (DX-4) — M
  • AG-12 [SHIPPED — pycharter.contrib.agents (Anthropic + Pydantic AI)] contrib adapters: Claude Agent SDK, Pydantic AI (AN-6) — M
  • AG-13 [SHIPPED — guides/pycharter-for-ai-agents.md + llms.txt + README] (AN-7) "PyCharter for AI agents" guide, cookbook (runnable vs ecommerce seed), README section, llms.txt — M
  • AG-14 [SHIPPED — tests/unit/test_agent_invariants.py (I-1/I-2)] Provider consolidation under agent/providers/ + I-1/I-2/I-8 CI invariant checks (coordinate with ARCH-1) — M
  • AG-15 [SHIPPED — Contracts → Agents → Proposals approval panel + Contracts → Test → Try it; /api/v1/agent/* routes (approver = authenticated user); first SWR adoption (UX-1 pattern: typed hooks in lib/agent/hooks.ts); new components kept small (UX-2)] UI approval panel + "Try it" panel (existing editor + SWR) — L

Phase 5 — Later scope expansion (explicitly out of v1)

  • AG-16 [SHIPPED (read tier) — list/fetch pipeline + stream configs (credentials masked), get_quality_score, list_drift_events, get_dlq_summary (counts only), list_concept_schemes (workspace-scoped). Maintainer decision 2026-09-14: read tools only; actuation and config-version writes stay out] Extend the catalog to pipelines/streaming/quality/vocabulary tools, each under the same read/guarded-write model — L/XL
  • AG-17 [SHIPPED — Prometheus pycharter_agent_validations_total / _repairs_total / _repair_attempts via AgentConformanceObserver (repair_loop(observer=, contract=), pycharter agent serve --metrics-port); per-contract quarantine + quality summary as MCP get_agent_conformance, GET /api/v1/agent/conformance, and Contracts → Agents → Conformance. Maintainer decision 2026-09-14: existing signals plus new counters, no new storage. Deviation: agent/ is now ~1.76k LOC, over the §9 < 1.5k target (develop was 1.37k before AG-17)] Conformance monitoring: reframe quarantine + quality + drift as "agent-output conformance" over the existing worker/Prometheus — S/M

Dependency spine: Phase 0 → AG-½ → AG-3 → AG-5 → AG-8 → AG-9 → AG-11/12/13. AG-4 parallel AG-⅗. AG-14 runs with ARCH-1.


12. Rejected alternatives (recorded decisions)

  • kind: Toolset artifact — rejected (decision #4). A versioned bundle artifact adds parser/store/migration/UI surface and a new abstraction to learn, for value a plain exposure-policy config (§6) delivers. Revisit only if real usage shows a need to version and share tool bundles across orgs.
  • kind: Agent manifest (persona/prompt) — rejected. Defining an agent is the caller's job; putting personas in PyCharter would breach I-1/I-4 and the "toolkit, not framework" boundary.
  • Full-surface tool catalog on day one — deferred (decision #3) to keep v1 sharp and hardenable; expansion is AG-16.
  • Autonomous writes without approval — rejected (decision #2); all mutations are two-phase and fail-closed (§5).
  • Field aliases / SemanticMapper (AG-6, AN-4) — rejected by the maintainer (2026-09-13). Contract-level alternate names (x-aliases, vocabulary-derived synonyms, a rename-on-read map) would give one field two names, hide upstream naming drift, and add a contract-format keyword, all to solve a problem the toolkit already solves another way. Agents get exact field names up front (fetch_contract_schema / to_tool_schema); closed contracts reject undeclared fields; to_agent_feedback() + repair_loop make the producer correct itself, and the feedback now carries a did-you-mean hint (undeclared field 'user_email'; did you mean 'email_address'?) computed on the fly from declared names — never stored, never applied. Sources that cannot change use the explicit pipeline rename transform.

13. Risks & mitigations

Risk Mitigation
Toolkit drifts into an agent framework I-1/I-4; no persona/loop in src/; CI invariant.
LLM/vendor lock-in in the core I-2/I-3 import-lint CI; NoOpProvider default; [ai] optional.
An agent writes a bad/breaking contract §5 two-phase + diff + breaking-classification + human approval + immutable versions + audit; fail-closed default.
Tool exposes PII / cross-workspace data I-7 policy filter + redaction on every tool, reusing the audited context path.
semantic/ sprawl worsens AG gated on ARCH-1; new agent/ shell capped and single-homed (§9).
Scope creep beyond focused core Decision #3; expansion isolated to AG-16.
Provider tool-schema formats churn Golden-file tests per provider; wrappers are one-file leaf code.

14. Success metrics / acceptance

Friendliness (config-driven): - With no config, an MCP host gets the full read tier, policy-filtered, in one line — no install, no artifact to author. - allow_write: true is the single switch that (approval-gated) lets a coding agent help author contracts. - pycharter agent try --record r.json shows a full runtime loop offline in < 60s, no model, no traceback.

Generalizability (hard constraint): - No new artifact kind; no agent/persona/loop in src/; no provider import in the stateless core — all CI-enforced (I-1/I-2/I-8). - The same tool catalog serves ecommerce, healthcare, or trading contracts with zero engine change.

Value-prop reinforcement: - Every agent action (read or write) is contract-typed, policy-filtered, and audit-logged; guarded writes produce reviewable, reversible immutable versions.

Engineering health: - Net new core LOC ≈ 0; agent/ shell < 1.5k LOC; semantic/ LOC down after ARCH-1; ./scripts/ci.sh + docs build green.


15. Decisions & open questions

  1. MCP write ergonomics — DECIDED: two tools (propose_contract_version / commit_contract_version). Reads clearly in an MCP host's approval UI; the proposal call is side-effect-free, the commit call is the one the host gates.
  2. Adapter home — DECIDED: contrib/ (import-guarded, matching contrib/fastapi / airflow / dagster). Not a downstream package.
  3. Approval mechanism & proposal backing — DECIDED: store-backed (option C), reusing the existing drafts table. Out-of-band / different-person approval is in scope (S1=yes), so the proposal must be durable and shared across processes and time. A proposal = a draft in a proposed state; the token = the draft id; commit = a promotion to an immutable contract version. Governance defaults: S2 24h TTL, one-shot commit, GC of expired proposals; S3 segregation of duties supported (distinct proposer/approver roles), default config collapses them so the single-user laptop case stays frictionless; S4 stale-diff rejection — if the base version changed between propose and commit, commit is refused and a re-propose is required. See §16. Drives AG-9.

16. Guarded-write proposal backing (DECIDED)

Decision: option C — store-backed, reusing the drafts table, with out-of-band approval in scope and the S1–S4 resolutions recorded at the end of this section. The rest of this section is retained as the rationale.

What the token must do. propose_contract_version(...) stages a change without committing and returns {diff, breaking|safe, token}. commit_ contract_version(token) persists an immutable new version after a human has approved. So the token references staged state: proposed bytes + base version + requester + computed diff/classification + status + expiry.

The decision is really about deployment topology:

Topology Description Needs
T1 Single-process stdio MCP on a dev laptop (Claude Code/Cursor) approval = the MCP host's own per-call gate; any backing works
T2 Multi-worker HTTP MCP/API for an org commit may hit a different worker than propose → shared state
T3 Async approval: agent proposes over MCP; a human approves later in the PyCharter UI; then commit durable, queryable, cross-process "pending proposals"

Three candidate backings:

  • A. In-memory (uuid → dict in the MCP process). Trivial; covers T1 only. Breaks under T2 (wrong worker) and T3 (no cross-process/UI visibility); lost on restart. A false economy given T2/T3 are on the roadmap (HTTP transport, UI approval panel AG-15).
  • B. Signed stateless (token = HMAC/JWT-signed serialization of the proposed contract). No proposal store; commit verifies + persists. Great for T2. But: large tokens (contracts are big), no record that a proposal is pending, hard single-use/revocation without reintroducing state, and can't do T3 (nothing to list/approve in the UI, nowhere to record the approval decision).
  • C. Store-backed proposal (recommended) — reuse the existing drafts machinery. A proposal is a draft in a proposed state; the token is the draft id; commit is a promotion to an immutable contract version. Covers T1+T2+T3; durable audit; enables the UI panel; and adds almost no surface because db/models/draft.py already stores raw+parsed payload, owner/team/visibility, and promotion provenance (promoted_to_kind/ref/ at), and the store already has in-memory + SQLAlchemy (SQLite/Postgres) backends. The suggestion-queue / review-events subsystem (SuggestionManager, accept/dismiss) is the existing precedent for the approval workflow.

Why C reuses, not adds: a "proposed contract version" ≈ a draft + status: proposed + expires_at + approved_by/at. Promotion already means "draft → real artifact, deterministically." We'd add a small status/expiry to the draft flow rather than a bespoke token table, and route approval through the review-events pattern.

Sub-decisions — RESOLVED: - S1 — Where does approval happen? → Out-of-band in scope. Approval may happen in-session (MCP host gates commit) or later in the UI by a different person. This mandates option C. - S2 — Expiry & single-use. → 24h TTL; one-shot commit (token consumed on success); periodic GC of expired proposed drafts. - S3 — Who may approve? → Segregation of duties supported. Config can require a distinct approver role for commit_*; the default collapses proposer==approver so the single-user laptop flow needs no setup. - S4 — Conflict handling. → Stale-diff rejection. If the base version changed between propose and commit, commit refuses and forces a re-propose, so the diff a human approved is guaranteed to still apply.

AG-9 implementation shape (from this decision)

  1. Add to the draft flow: status (draft | proposed | committed | expired | rejected), expires_at, approved_by, approved_at, base_version (the version the diff was computed against).
  2. propose_contract_version → validate + lint the candidate, compute the diff/classification vs base_version, persist a proposed draft, return {draft_id (token), diff, breaking|safe, expires_at}. Side-effect-free beyond writing the pending draft; emits a contract.proposed audit event.
  3. Approval surfaces (both write approved_by/at, policy-checked per S3): the MCP host gating commit_* (in-session), or the UI approval panel (AG-15, out-of-band).
  4. commit_contract_version(token) → re-check status/TTL (S2) and base_version freshness (S4); on pass, promote the draft to a new immutable contract version (reuse existing promotion + immutable-versions), mark the draft committed, emit a contract.committed audit event.
  5. Reuse the SuggestionManager / review-events pattern for the approval workflow; the drafts store's existing in-memory + SQLAlchemy backends give T1/T2/T3 for free.