Pact Protocol v1 — Normative Specification
Status: Frozen · Version: 1 · Date: 2026-06-09 Implements: M1.1 Protocol Freeze Reference implementation:
.pact/bin/pact.shMachine-readable schema:schemas/event.schema.json
1. Overview and Version
The pact protocol is a multi-agent coordination protocol for software development. It enables any number of agents — regardless of vendor, runtime, or tool interface — to collaborate on a shared codebase by reading and writing a small set of plain files under a .pact/ directory inside a git repository. The protocol requires no external services, message brokers, or shared memory: the append-only event log (log.jsonl) is the authoritative source of truth, and all state is a deterministic projection of that log.
Design principles:
- Agent-agnostic: any agent that can read files and run shell commands can participate.
- Append-only: events are never deleted or mutated; the log grows monotonically.
- Self-contained: the entire protocol state lives inside
.pact/and travels with the repository. - Normative rules: two invariants (“the pact”) are enforced by all conformant implementations.
1.1 protocol_version
protocol_version is a single integer. The current value is 1.
It MUST be recorded in two places:
- The
payload.protocol_versionfield of theinitevent (authoritative — travels with the log). - The header of
.pact/PROJECT.md(human-visible annotation).
1.2 Compatibility Rules
These rules are normative and MUST be enforced by all v1 implementations:
- Forward compatibility: A reader MUST ignore unknown fields in a known event envelope and MUST ignore unknown
event_typevalues. Implementations MUST NOT fail on encountering either. - Additive changes are non-breaking: Adding optional fields to the event envelope, adding optional fields to a payload, or introducing new
event_typevalues does NOT bumpprotocol_version. Older readers ignore the additions; newer readers use them. - Breaking changes bump the major: Changing the semantics of an existing field, removing a required field, or altering the state machine constitutes a breaking change and MUST result in a new
protocol_versioninteger (e.g.,2). - Fail closed on higher major: An implementation MUST fail closed — that is, refuse to operate and surface an upgrade prompt — when it encounters a
protocol_versionin aninitevent that is higher than the major it supports. It MUST NOT attempt best-effort processing of an unsupported major. - Backward compatibility within major: A v1 implementation reading a log produced by an earlier v1 revision (e.g., one that pre-dates
event_id) MUST treat absent optional fields as their defined defaults and continue normally.
2. Event Log (log.jsonl)
log.jsonl is the authoritative source of truth for all protocol state. It is an append-only file in which each line is a complete, self-contained JSON object representing one event. No line is ever deleted or modified after it is written.
2.1 Event Envelope
Every event MUST conform to the following frozen envelope. All eight top-level fields are REQUIRED:
{
"event_id": "<opaque string>",
"ts": "2026-06-09T10:00:00Z",
"agent_id": "claude-opus",
"role": "orchestrator",
"event_type": "assign",
"task_id": "T1",
"feature": "CLI-INIT",
"payload": {}
}
Field semantics:
| Field | Type | Constraint | Description |
|---|---|---|---|
event_id | string | non-empty, globally unique | Opaque identifier for this event. MUST be a globally-unique string (UUIDv4 or 128-bit random hex). Used for deduplication and referencing; not a sequence number. |
ts | string | UTC ISO-8601 | Timestamp of event creation in UTC ISO-8601 format (e.g., 2026-06-09T10:00:00Z). |
agent_id | string | ^[a-z0-9][a-z0-9-]*$ | The seat identifier of the agent emitting this event. MUST match the slug pattern. |
role | string | enum | The functional role of the emitter for this event. MUST be one of orchestrator, worker, reviewer. Derived from event_type (see §2.3); implementations MUST NOT trust a self-reported role that contradicts the event_type. |
event_type | string | enum | One of the seven v1 event types (see §2.2). |
task_id | string | The task identifier this event applies to. MUST be "" (empty string) when the event is not task-scoped (e.g., init, join, merge). | |
feature | string | The feature identifier this event applies to. MUST be "" (empty string) when the event is not feature-scoped (e.g., init, join). | |
payload | object | Event-type-specific data. Required fields per event type are specified in §2.4. Additional fields MAY be present and MUST be ignored by readers that do not recognise them. |
The envelope itself MUST NOT have additional top-level fields beyond these eight in a v1 event. Readers MUST ignore any extra top-level fields encountered (forward compatibility).
The machine-readable definition of the full envelope is schemas/event.schema.json.
2.2 event_type Enum (v1)
The following seven values constitute the complete v1 event_type enum:
init | assign | join | checkpoint | accept | changes_requested | merge
Implementations MUST ignore lines whose event_type is not in this list (forward compatibility with future additive event types).
2.3 Role Derivation
role is derived from event_type according to the following mapping. It is a denormalised convenience field; conformant implementations MUST apply these mappings:
| event_type | role |
|---|---|
init | orchestrator |
assign | orchestrator |
merge | orchestrator |
join | worker |
checkpoint | worker |
accept | reviewer |
changes_requested | reviewer |
2.4 Per-event_type Payload Required Fields
Each payload object MUST contain at minimum the fields listed below. Additional fields are permitted and MUST be ignored by readers that do not recognise them.
| event_type | Required payload fields |
|---|---|
init | project (string), protocol_version (integer), seats (array of seat objects; see §6 and schemas/seat.schema.json), base_branch (string) |
assign | owner (string, seat slug), reviewer (string, seat slug), branch (string, git branch name), spec (string, path to task file) |
join | roles (array of strings) |
checkpoint | evidence (string; multi-line allowed; implementations MAY fold newlines when rendering) |
accept | (empty object {} is valid; no required fields) |
changes_requested | reason (string) |
merge | (empty object {} is valid; no required fields) |
2.5 Event Ordering
Events are ordered by ts (UTC ISO-8601 sort) and, within the same timestamp, by append order within the file. There is no sequence number field. event_id provides stable identity for individual events; it is not a monotone counter. This design accommodates git merges that interleave events from multiple branches without requiring coordination on a global counter.
2.6 Additive event_type Extensions (post-v1, non-breaking per §1.2)
The reference implementation emits the following event types beyond the frozen v1
enum. Per §1.2 they do not bump protocol_version; readers that do not recognise
them MUST ignore them (§2.2).
| event_type | role | scope | semantics |
|---|---|---|---|
start | orchestrator | task | The orchestrate driver launched the task’s owner. Projects assigned → in_progress for exactly the named task (unlike join, which is seat-scoped and lifts the joining seat’s first actionable owned task). Payload: owner (string, seat slug). Never rewinds a task that has progressed past assigned. |
cancel | orchestrator | task | Excludes the task from the projection (structured retirement; the log is never hand-edited). |
withdraw | orchestrator | feature | Excludes the entire feature from the projection. |
config_gate | orchestrator | project | Sets the project’s hard verification gate command (see pactify config gate). |
rebaseline | orchestrator | project | Overrides the init-time base_branch (see pactify config base-branch); the latest rebaseline wins. |
3. .pact/ File Contract
A conformant v1 repository MUST maintain the following directory layout inside .pact/:
.pact/
PROJECT.md # Charter + managed seat block (contains protocol_version in header)
STATE.yml # Regenerable projection of log.jsonl (not authoritative)
tasks/<id>.md # One file per task; YAML frontmatter + markdown body
log.jsonl # Authoritative event log (append-only)
bin/ # Implementation-provided tooling (not part of the wire contract)
3.1 log.jsonl
The authoritative source of truth. All programmatic protocol decisions — including enforcement of the two rules, state machine transitions, and roster checks — MUST read and interpret the log directly. STATE.yml MUST NOT be used as the basis for protocol decisions.
3.2 STATE.yml
A human-readable, regenerable projection of the current state derived from log.jsonl. It is produced by replaying the log. The exact rendering format is implementation-defined and is NOT part of the v1 wire contract. Implementations MUST be able to regenerate STATE.yml from log.jsonl at any time (see §8, Recovery). STATE.yml is provided as a convenience for human inspection and for agent cold-start orientation; it MUST NOT be treated as authoritative.
3.3 PROJECT.md
Contains the project charter and the seat roster in a managed block delimited by <!-- pact:begin --> / <!-- pact:end --> markers. The init verb rewrites the managed block idempotently and MUST NOT overwrite content outside the block. The managed block MUST include a header annotation carrying protocol_version (e.g., # <project> — Pact Charter (protocol_version: 1)).
3.4 tasks/<id>.md
One Markdown file per task. It MUST carry a YAML frontmatter block at the top of the file with the following fields (conforming to schemas/task.schema.json):
---
id: T1
feature: CLI-INIT
owner: opencode
reviewer: claude-opus
---
## Spec
...
## Acceptance
- [ ] ...
## Handoff log
...
The frontmatter fields (id, feature, owner, reviewer) are a redundant mirror of the corresponding assign event fields. This allows an agent to orient itself by reading a single task file without parsing the full log. In the event of a conflict between the task file frontmatter and the log, the log is authoritative.
Machine-readable schema: schemas/task.schema.json.
3.5 bin/
Contains the protocol implementation tooling (e.g., pact.sh for the bash reference implementation, or a compiled pactify binary). The contents of bin/ are NOT part of the v1 wire contract and may vary between implementations.
4. State Machines
4.1 Task State Machine
A task MUST progress through the following states. No other transitions are valid:
todo → assigned → in_progress → awaiting_review → accepted
↘
changes_requested → in_progress
todo: task exists in the backlog but has not been assigned via anassignevent.assigned: anassignevent has been emitted for this task; the owner has not yet joined.in_progress: work is underway — the owner has joined (ajoinevent lifts the seat’s first actionable owned task: the firstassignedtask whose dependencies are allaccepted) or the orchestrate driver has launched the owner (via astartevent, §2.6); or the task has been returned fromchanges_requested.awaiting_review: acheckpointevent has been emitted; the reviewer must now act.accepted: anacceptevent has been emitted by the designated reviewer while the task wasawaiting_review. This is a terminal state.changes_requested: achanges_requestedevent has been emitted by the designated reviewer while the task wasawaiting_review. The task returns toin_progressfor the owner to address.
4.2 Feature State Machine
A feature MUST progress through the following states:
planned → in_progress → awaiting_review → accepted → shipped
planned: feature is declared but has no assigned tasks yet.in_progress: one or more tasks have been assigned.awaiting_review: all tasks areawaiting_revieworaccepted.accepted: all tasks areaccepted.shipped: amergeevent has been emitted for this feature (following Rule 2).
5. The Two Rules (The Pact)
These two rules are the normative invariants of the protocol. Every conformant implementation MUST enforce them. Violations MUST cause the offending command to fail immediately (fail-closed), and the log MUST NOT have an event appended for an operation that violates either rule.
Rule 1: A Worker Cannot Self-Accept
A seat MUST NOT self-accept its own work.
Specifically:
- At
assigntime:ownerMUST differ fromreviewer. An implementation MUST reject anassigncommand whereowner == reviewer. - At
acceptorchanges_requestedtime: only the task’s designatedreviewer(the seat recorded in theassignevent payload) MAY emit anacceptorchanges_requestedevent for that task. Any other seat MUST be rejected. - An
acceptorchanges_requestedevent is only valid when the task’s current state isawaiting_review. Attempts to accept or request changes on a task in any other state MUST be rejected.
Rule 2: A Feature Cannot Merge Until All Its Tasks Are Accepted
A merge event for a feature MUST NOT be emitted unless all tasks belonging to that feature have status accepted.
An implementation MUST check the log projection before emitting a merge event and MUST reject the merge command if any task under the feature is not in the accepted state.
6. Seat Identity
6.1 Definition
agent_id is a project-local, self-declared, stable seat identifier. It represents a functional role in the project (e.g., claude-opus, opencode, reviewer-gpt4), not a global identity, a process, or a session. The same seat may be occupied by different agent instances at different times; the seat identifier remains stable.
6.2 Roster
The authoritative seat roster is the seats array in the payload of the init event. Seats are declared at init; a join event does NOT introduce a new seat — it only signals that an already-declared seat has come online (cold-start / resume). An agent_id that appears in the log but is not a declared seat is a validation error. (Adding seats after init is deferred to a future protocol revision.)
Each seat object MUST conform to schemas/seat.schema.json:
{ "id": "claude-opus", "roles": ["reviewer"], "entry": "CLAUDE.md" }
idMUST match the slug pattern^[a-z0-9][a-z0-9-]*$.rolesMUST be a non-empty array; valid values areorchestrator,worker,reviewer.entryis the repo-relative path to the agent’s vendor entry file (e.g.,CLAUDE.md,AGENTS.md).idvalues MUST be unique within the roster. Duplicate seat ids are a validation error.
6.3 Separation of Duties
Separation of duties between worker and reviewer is enforced by requiring distinct seat names — the owner and reviewer of any task MUST be different seat ids. This is a naming constraint, not a cryptographic guarantee. Cryptographic authentication of seat identity is out of scope for v1.
6.4 task_id Uniqueness
task_id MUST be globally unique across all features within a project. Implementations MUST reject an assign command if the task_id already exists in the log, regardless of which feature it belongs to. Conformance checks MUST verify this invariant.
6.5 Handshake
A seat learns its own agent_id from its vendor entry file (e.g., CLAUDE.md or AGENTS.md). This file is baked by init for each seat listed in the roster. The file contains a managed block with the PACT_AGENT_ID export and the join command.
All implementations MUST fail closed — immediately refuse to execute any pact command — if the PACT_AGENT_ID environment variable is unset or empty.
7. Command Verbs
The following verbs constitute the frozen v1 command contract. The verb names and their required-parameter semantics MUST be consistent across all conformant implementations.
Two skins implement the same contract: pact_<verb> (bash, reference implementation) and pactify <verb> (Go CLI, M1.2). Implementations MAY add optional --flags (non-breaking); they MUST NOT rename verbs or change required-parameter semantics.
7.1 Verb Reference
init --project <name> --seat "<id>:<roles>:<entry>" [--seat ...]
Scaffolds .pact/, bakes vendor entry files, emits init event.
Required: --project, at least one --seat.
Seat format: id:comma-separated-roles:entry-path (exactly 3 fields).
join <id> --roles <r1,r2>
Worker cold-start (or resume after crash). Emits join event.
Required: seat id positional arg, --roles.
assign <task_id> --feature <feature_id> --branch <branch_name> \
--owner <seat_id> --reviewer <seat_id> [--spec <path>]
Emits assign event; creates tasks/<task_id>.md.
Required: task_id, --feature, --branch, --owner, --reviewer.
Enforces Rule 1 (owner != reviewer) at call time.
Enforces task_id global uniqueness at call time.
checkpoint <task_id> --evidence "<text>"
Transitions task to awaiting_review. Emits checkpoint event.
Required: task_id, --evidence.
MUST be called by the task owner (PACT_AGENT_ID == task.owner).
accept <task_id>
Transitions task to accepted. Emits accept event.
Required: task_id.
MUST be called by the task reviewer (PACT_AGENT_ID == task.reviewer).
Task MUST be in awaiting_review state.
changes <task_id> --reason "<text>"
Transitions task to changes_requested (then back to in_progress).
Emits changes_requested event.
Required: task_id, --reason.
MUST be called by the task reviewer (PACT_AGENT_ID == task.reviewer).
Task MUST be in awaiting_review state.
merge <feature_id>
Transitions feature to shipped. Emits merge event.
Required: feature_id.
Enforces Rule 2 (all tasks accepted) at call time.
status
Prints the current STATE.yml to stdout. Read-only.
log [--replay]
Without --replay: prints log.jsonl to stdout.
With --replay: rebuilds STATE.yml from log.jsonl (reconciliation).
validate
Checks: (a) STATE.yml matches a fresh render of the log (no drift);
(b) all agent_ids in the log are declared seats;
(c) all seat ids match the slug pattern;
(d) no task has owner == reviewer (Rule 1);
(e) task_ids are globally unique;
(f) protocol_version in the log does not exceed the supported major
(fail-closed if higher);
(g) every event carries a non-empty event_id.
Exits non-zero if any check fails.
help
Prints the verb reference to stdout.
8. Recovery
The log is the source; STATE.yml is a projection. They can diverge only through bugs, manual edits, or aborted writes.
Reconciliation: Running log --replay (or pactify log --replay) rebuilds STATE.yml by replaying the log from the beginning. This MUST produce a deterministic result regardless of how many times it is run.
Crashed worker: A worker that terminates unexpectedly resumes by re-joining the same seat:
export PACT_AGENT_ID=<seat_id>
source .pact/bin/pact.sh && pact_join <seat_id> --roles <roles>
The join event is idempotent at the state-machine level: re-joining a seat that already has in_progress tasks does not change their state. The worker then re-reads STATE.yml to locate its in-progress tasks and continues.
Log integrity: The log MUST NOT be edited manually. If log corruption is suspected, validate MUST be run to surface the inconsistency. Recovery from corrupted log entries is outside the scope of v1.
9. What Is Implementation-Defined
The following aspects of an implementation are explicitly NOT part of the v1 wire contract. They may vary between conformant implementations without affecting interoperability:
-
Git branch and commit choreography: The reference implementation (
pact.sh) performs specific git operations atjoin(branch switch),checkpoint(git commit), andmerge(no-ff merge to base branch). These behaviours are implementation-defined. Alternative implementations may use worktrees, shared trees, cloud-hosted repositories, or no git choreography at all, provided the event log and file contract are honoured. -
STATE.ymlexact render format: The structure ofSTATE.ymlis documented (project, agents, features, tasks, statuses) but the precise YAML layout — field order, indentation, quoting — is implementation-defined. A v1-conformant implementation MUST be able to regenerateSTATE.ymlfrom the log; it need not match the reference rendering byte-for-byte. -
Transport and serve: How the protocol is exposed — MCP server endpoints, web dashboard, cloud relay, webhook — is outside the v1 contract (planned for M1.3 and beyond).
-
Schema validation library: Whether an implementation uses a JSON Schema validation library at runtime (e.g., to validate events against
schemas/event.schema.jsonon write) is implementation-defined. v1 requires only that emitted events conform to the schema in practice. -
event_idgeneration algorithm: Implementations MAY use UUIDv4, 128-bit random hex, or another globally-unique generation strategy. The requirement is uniqueness and non-emptiness; the format is opaque to readers.
Conformance Statement
Conformance: an implementation is v1-conformant if its emitted log.jsonl validates against schemas/event.schema.json, it enforces the two rules and the state machines, and it fails closed on a higher protocol major.
Addendum: task dependencies (additive, v1)
This addendum is additive to frozen v1: it introduces one optional field and one enforced gate. Logs that never use it are bit-for-bit unchanged, and the wire envelope, the two rules, and every state machine are untouched.
The field
An assign event’s payload MAY carry an optional deps array of task ids:
{ "event_type": "assign", "task_id": "T2", "feature": "F",
"payload": { "owner": "w2", "reviewer": "rev", "branch": "feat/x", "spec": ".pact/tasks/T2.md", "deps": ["T1"] } }
deps is emitted only when non-empty. A task with no dependencies carries no deps key, and STATE.yml renders no deps: line for it — preserving byte-parity with the bash reference renderer. In the schema, deps is an optional array of string; an assign payload with deps present but not an array is rejected.
Assign-time validation
When an assign carries deps, the implementation MUST validate, before appending the event:
- Existence — every dep id already exists (was assigned earlier).
- Same feature — every dep belongs to the same feature as the new task. Cross-feature dependencies are rejected.
- No self-dependency — a task may not list itself (the smallest cycle).
- Acyclic — adding the new task’s out-edges introduces no cycle. The reference checks this with a depth-first search over the feature’s existing
depsgraph plus the new node’s edges, flagging any back-edge (gray node) reachable from the new task.
A failed check aborts the assign; nothing is written to the log.
The join gate (enforced; task-level)
deps adds an enforced join gate, not a third global rule. It is evaluated per task, against the pre-join state, when a seat joins:
For each task owned by the joining seat, every dep of that task MUST have status
accepted. If any dep is not yetaccepted, the join is rejected with an error naming the blocking task and dep (task <T> blocked by unaccepted dep <D>).
This is the only behavioural change deps introduces. Workers pull work as before; the gate simply refuses a premature pull while an upstream task is still in flight. Once the upstream task reaches accepted, the same join succeeds.
The gate is enforced at join AND checkpoint: a seat that joined before its dep’d task was assigned cannot bypass the gate by checkpointing — checkpoint re-applies the same per-task dep check and refuses a blocked task with task <T> blocked by unaccepted dep <D>.
Bash reference is not extended
The bash reference implementation (pact.sh) is not extended for deps. It has no deps concept, emits no deps payloads, and renders no deps: lines — which is exactly why a deps-free Go log stays byte-identical to a bash log (proven by the interop suite). Implementations that don’t support deps remain fully v1-conformant; they simply never emit the field.
UI sidecar files are outside the protocol
A .pact/squad/layout.json (or any similar UI sidecar) is not part of the protocol. It carries presentation-only data (e.g. canvas node positions) and is never read by the state machine or validation. validate ignores unknown files under .pact/; their presence or absence does not affect conformance.
Addendum: join client provenance (additive, v1)
This addendum is additive to frozen v1: it introduces one optional field on the join event and changes no rule, state machine, or projection. Logs that never use it are bit-for-bit unchanged.
The field
A join event’s payload MAY carry an optional client object recording the tool that performed the join:
{ "event_type": "join",
"payload": { "roles": ["worker"], "client": { "name": "claude-code", "version": "1.4.0" } } }
client is emitted only when its name is non-empty. A join with no client provenance carries no client key, preserving byte-parity with the bash reference renderer and with pre-feature Go logs (the interop suite stays untouched). In the schema, client is an optional object with optional string name/version; a join payload whose client is present but not an object is rejected.
Who fills it
- The CLI stamps
client: {name: "pactify-cli", version: <build version>}on everyjoin. - The MCP host fills it from the connecting session’s
initializeclientInfo(name/versionas the client self-reported them). When the session has no clientInfo, noclientfield is emitted.
Semantics — advisory, not identity
client is self-reported metadata, not an identity proof. A client names itself; nothing verifies the claim. It is provenance for display and a soft “this seat was last joined by a different tool” warning — never an access-control or authentication signal. Seat identity remains agent_id (§6); the join gate and the two rules are unaffected.
Projection — log only
client is never projected into STATE.yml. Provenance lives in the log; tools that want it (e.g. the ops panel) fold it from the raw join events. This keeps STATE byte-parity trivially intact for every log.
Bash reference is not extended
The bash reference implementation (pact.sh) is not extended for client. It emits no client payloads, which is exactly why a client-free Go log stays byte-identical to a bash log. Implementations that don’t support client provenance remain fully v1-conformant; they simply never emit the field.