diff --git a/docs/v2-prep/rfc-multi-cell-quorum.md b/docs/v2-prep/rfc-multi-cell-quorum.md new file mode 100644 index 0000000..72ee3e7 --- /dev/null +++ b/docs/v2-prep/rfc-multi-cell-quorum.md @@ -0,0 +1,193 @@ +# RFC: Multi-cell fabrics — cross-org trigger, cell-quorum, no silence-merge + +> **Status:** RFC — for discussion before normative spec text lands. +> **Target version:** PACT v2.3 draft (`spec/v2.3/`, already the consensus / authorization line for [#41](https://github.com/TailorAU/pact/issues/41)). Do not edit frozen `spec/v2.0/` or rewrite `spec/v2.2/`. +> **Owner:** Knox Hart. +> **Source:** Cursor background composer [bc-019ff94f](https://cursor.com/agents) (Door and cell collaboration, 2026-08-17) plus RNM/AN I9. +> **Not this RFC:** sign-in routing, cell stamps, border agents, CRDT replicas, notary cells, per-cell inference keys. Those are implementer / deployment concerns. PACT starts after a session is bound to a fabric. + +> **Landing addendum (2026-08-20).** Issues filed as +> [#53](https://github.com/TailorAU/pact/issues/53) (Change 1), +> [#54](https://github.com/TailorAU/pact/issues/54) (Changes 2+3) and +> [#55](https://github.com/TailorAU/pact/issues/55) (Change 4); landed +> as one draft PR ([#57](https://github.com/TailorAU/pact/pull/57), +> `spec/v2.3/`) rather than four spec PRs. The "Ready-to-file issues" +> section below is retained as the historical record. Two corrections +> against the landed text: **(1)** the consensus / legal-execution +> boundary (§25, [#41](https://github.com/TailorAU/pact/issues/41) / +> PR [#45](https://github.com/TailorAU/pact/pull/45)) is on the +> **v2.1 line**, not v2.3 as this file assumed when written — the fit +> analysis below still holds; read "v2.3 §25" as "§25 (v2.1 line)". +> **(2)** the landed text is stricter than this RFC in one respect: +> `cell_id` MUST be **bound to a proof of control of the cell DID** +> (a cell membership attestation signed by the cell key, or a +> cell-scoped invite minted under the cell DID's authority), and +> unproven `cell_id` claims MUST be rejected or at most bucketed as +> the joining operator's own cell — a hardening added from the PR #57 +> adversarial review (finding F1: self-asserted `cell_id` lets an +> initiator stuff the counterparty's quorum bucket). The landed text +> also resolves OQ2 as "both" (`participant_cells` at establish, with +> a first-join-binding fallback) and defines omitted-`cell_id` +> memberships as one implicit cell for cardinality. + +## Problem + +A **cell** here is a vendor-neutral isolation boundary: a group of agents that share a data and compute plane and are identified by a cell DID (SHOULD be `did:key`). Two cells collaborating on one fabric keep **separate replicas**. What crosses the boundary is PACT ops plus proofs — never a shared store. + +Today the spec cannot say that safely: + +1. **§15.4 does not treat two `did:key` cells as cross-org.** Cross-org is triggered by different DID *methods*, `did:web` eTLD+1, an unresolvable federated registry, or an explicit `cross_org_assertion`. Two cells that both use `did:key` and appear in a shared registry fail every heuristic. `authorization_proof` (§17.6) and the Authorization-Required checks (§17.9) therefore **do not fire** on the exact path they exist for. +2. **§5 approval policies count agents, not isolation boundaries.** `single` / `majority` / `unanimous` can be satisfied entirely inside one cell. Two agents in cell A can majority-merge with zero approvals from cell B. +3. **`objection-based` treats silence as a merge.** v2.3 §25 already says that silence is not legal consent. That is not enough here: a quiet peer would still merge **foreign content** into a sealed replica. Sovereignty, not signature law. +4. **Conflict strategies include `first-wins` and `merge-both`.** Across cells those silently prefer one replica or an LLM merge. The only safe strategy is `human-escalate`. + +I9 (RNM/AN) already requires the cross-cell wire to be open MIT PACT only. These four gaps are why that wire is not load-bearing yet. + +This is **core protocol**, not a Tailor extension. Any implementer that isolates compute/data into more than one participant group hits the same holes. Tailor-specific machinery (door, border agent, DOCX sanitiser, Sovrgn keys) stays out of the spec. + +## Proposal + +Add a first-class **cell** grouping on a fabric, then four normative changes in this order. One RFC, four spec PRs after accept (same pattern as #41 → `spec/v2.3/`). + +### Cell (protocol term) + +A **cell** is a declared grouping of agents that share one isolation boundary. + +- Identified by a **cell DID**. Implementations SHOULD use `did:key`. `did:web` MAY be used; Authorization-Required SHOULD still prefer `did:key` (existing §17 guidance). +- Membership carries `cell_id`. Agents without a `cell_id` are treated as belonging to a single implicit cell for that fabric (today's behaviour). +- The **participant cell set** is fixed at fabric establish (or at the `_onboard` that first declares a second cell). It is not a live agent count. +- Two `did:key` agents are **not** automatically two cells. Inference from DID method is forbidden — that is today's §15.4 bug in reverse. + +Do not rename Matter (§24) "shared participant set". A Matter participant set is membership of a Matter. A cell set is an isolation grouping on a fabric. They may coincide; they are not the same noun. + +### Change 1 — multi-cell is always cross-org (§15.4) + +Land **before** 2–4 so HAL actually applies. + +Add a fifth cross-organisation trigger: + +> A message from agent A to agent B is **cross-organisation** if A and B carry different `cell_id` values on the same fabric, or if the fabric's declared participant cell set has cardinality > 1 and the message is a `propose`, `_onboard`, or any other operation that §17 already requires `authorization_proof` for when cross-org. + +Keep the existing four triggers. A sender MAY still set `cross_org_assertion` (more checks, not fewer). Intra-org remains "none of the triggers hold." + +Consequence: `authorization_proof` fires on every inter-cell `propose` / `_onboard` at Authorization-Required. Two `did:key` cells in one registry can no longer launder as intra-org. + +### Change 2 — `cell-quorum` approval policy (§5) + +New `ApprovalPolicy` value: + +| Policy | Description | +|---|---| +| `cell-quorum` | Merge requires ≥1 counted approval from **each** cell in the fabric's fixed participant cell set. Approvals are bucketed by `cell_id`, not by agent head-count. | + +- The set is the one fixed at establish. A cell that later goes dark does **not** shrink the set (fail closed; no silence-merge of the missing cell). +- The existing self-approval rule still applies **inside** a cell: an author's approval of their own proposal does not satisfy that cell's bucket unless `allowSelfApproval` is true. +- `human-only` remains valid but is not a substitute: a single human in cell A must not satisfy the whole fabric. If `human-only` is used on a multi-cell fabric, it MUST be evaluated as human-only **and** cell-quorum (one human approval per cell). + +### Change 3 — ban silence-merge and agent-count policies on multi-cell fabrics (§5, §10.5) + +When the participant cell set has cardinality > 1, implementations MUST NOT use: + +- `objection-based` +- `auto` +- `single` / `majority` / `unanimous` (agent-count) + +Configuring one of those on a multi-cell fabric is a profile error: refuse establish / policy change, fail closed. v2.3 §25.6 already blocks `auto` / `objection-based` from *applying* `external-irreversible` effects. This change additionally blocks them from **merging replica state** across cells, including `internal-reversible` document drafts. + +Changes 2 and 3 land together: a quorum without a silence back-door. + +### Change 4 — multi-cell conflicts MUST `human-escalate` (§5 Conflict Detection) + +When the participant cell set has cardinality > 1, the conflict strategy MUST be `human-escalate`. + +MUST NOT: `first-wins`, `merge-both`. `vote` is not a substitute unless the vote is itself cell-quorum (one vote bucket per cell) **and** still escalates when any cell is dark. + +### Implementer note (not a spec PR) + +A server that hosts a multi-cell fabric MUST advertise and pass `POST /api/pact/_probe/tier` at **Authorization-Required** (§15.5 / §17.9). Claiming Extended on a shared hostname is not the boundary profile. Event `prev_hash` + daily signed root stay as they are at that tier. No new verbs. + +## What this is not + +- A Tailor product noun. "Cell" is the isolation grouping; implementers may call the deployment stamp something else. +- A shared database, blob, embedding store, or "neutral" copy of the resource. +- New PACT verbs. `join` · `intent` · `constrain` · `propose` · `object` · `negotiate` · `escalate` · `done` are unchanged. +- Sign-in / door routing. Binding a browser session to a stamp is outside PACT (`join` happens after the session exists). +- Permission to implement a Tailor-only merge rule at the boundary if this RFC is rejected. The fallback is: do not run a multi-cell fabric. + +## Fit with v2.3 §25 + +§25 answers "does consensus mean a legal signature?" (no). This RFC answers "may one isolation boundary merge into another without that boundary's approval?" (no). Both are fail-closed; they are not substitutes. + +| §25 | This RFC | +|---|---| +| Silence is not legal consent | Silence MUST NOT merge foreign replica bytes | +| Guarded apply needs payload-bound human attestation | Inter-cell propose needs `authorization_proof` because it is cross-org | +| `auto` / `objection-based` cannot apply `external-irreversible` | Those policies cannot even be configured when cells > 1 | + +## Open questions + +1. **Noun.** Is `cell` acceptable as a core term, or should the spec say `isolation_group` / `participant_group` to avoid Tailor-product collision? Lean: `cell` — short, already used in RNM/AN, not a registered product mark in this repo. Change is a search-replace if the maintainer prefers another noun. +2. **Where `cell_id` lives.** Agent registration object vs fabric establish document vs both? Lean: both — establish lists the allowed cell DIDs; each member carries one of those DIDs. +3. **Single-cell fabrics.** No change. Implicit one-cell set; today's policies remain valid (still subject to §25). +4. **Matter vs cell.** Can a Matter span cells? Lean: yes, and then the Matter inherits this RFC's rules (cross-org + cell-quorum + no silence-merge). Confirm against RFC #18 OQ5 (cross-Matter references deferred). +5. **Dark cell.** If a declared cell never approves, the proposal stays pending forever unless a human on *that* cell escalates or the fabric is re-established with a new (explicit, authorised) participant set. No TTL carve-out. + +## Sequenced landing (after accept) + +| Order | Spec PR | Sections | Why this order | +|---|---|---|---| +| 1 | Multi-cell ⇒ cross-org | §15.4, membership/`cell_id` | HAL / `authorization_proof` must fire before any merge rule | +| 2+3 | `cell-quorum` + ban agent-count / silence-merge | §5, §10.5, §15.1 profile | Quorum without a silence back-door | +| 4 | Multi-cell conflict ⇒ `human-escalate` | §5 Conflict Detection | Last, depends on 1–3 being sayable | + +Conformance vectors belong in `spec/v2.3/conformance/` (new family, e.g. `extended/multi-cell/`). Do not add them to frozen `spec/v2.0/conformance/`. + +Normative prose is **not** in this file. Maintainer sign-off required before any `SPECIFICATION.md` edit (`AGENTS.md` rule 5, `GOVERNANCE.md` §3). + +## Ready-to-file issues (blocked on `gh auth`) + +`gh` against github.com is 401 (Tailor-AUS token invalid). After `gh auth login -h github.com`, file four issues on `TailorAU/pact` with labels `rfc` + `spec-change`. Bodies below. + +### Issue A — `[SPEC] Multi-cell participant set is always cross-org (§15.4)` + +**Section(s) Affected:** §15.4; agent/fabric membership (`cell_id`). + +**Current Behavior:** Cross-org is DID-method, `did:web` eTLD+1, unresolvable registry, or `cross_org_assertion`. Two `did:key` cells in a shared registry are intra-org. `authorization_proof` does not fire. + +**Proposed Change:** Declare `cell` as a grouping with a cell DID. Different `cell_id` on the same fabric ⇒ cross-org. Participant cell set fixed at establish. + +**Rationale:** HAL is pointless at the cell boundary if the boundary does not trigger §15.4. See `docs/v2-prep/rfc-multi-cell-quorum.md` Change 1. + +### Issue B — `[SPEC] Add cell-quorum ApprovalPolicy` + +**Section(s) Affected:** §5 Approval Policy. + +**Current Behavior:** Policies count agents (`single` / `majority` / `unanimous`) or silence (`objection-based`). + +**Proposed Change:** `cell-quorum`: ≥1 counted approval from each cell in the fixed participant set. Self-approval rule still applies inside a cell. + +**Rationale:** Agent-count majority lets one cell merge for everyone. See RFC Change 2. File with Issue C; land in the same PR. + +### Issue C — `[SPEC] Forbid objection-based and agent-count policies on multi-cell fabrics` + +**Section(s) Affected:** §5, §10.5. + +**Current Behavior:** `objection-based` auto-merges on TTL. Agent-count policies are legal on any fabric. + +**Proposed Change:** When participant cells > 1, MUST NOT configure `objection-based`, `auto`, `single`, `majority`, or `unanimous`. Fail closed at establish / policy change. + +**Rationale:** Silence-merge writes foreign content into a sealed replica. §25 is about legal execution, not replica sovereignty. See RFC Change 3. Same PR as Issue B. + +### Issue D — `[SPEC] Multi-cell conflicts MUST human-escalate` + +**Section(s) Affected:** §5 Conflict Detection. + +**Current Behavior:** `first-wins`, `vote`, `human-escalate`, `merge-both` are all allowed. + +**Proposed Change:** Multi-cell fabrics MUST use `human-escalate`. MUST NOT `first-wins` or `merge-both`. + +**Rationale:** First-wins / LLM merge-both silently prefer one cell. See RFC Change 4. Lands after A and B+C. + +## Comment window (once filed) + +Propose 14 days from the Issue A file date, same shape as RFC #14. A clarifying question does not stop the clock; only a substantive design objection does (`GOVERNANCE.md` §3). diff --git a/spec/v2.3/GETTING_STARTED.md b/spec/v2.3/GETTING_STARTED.md new file mode 100644 index 0000000..58d79b1 --- /dev/null +++ b/spec/v2.3/GETTING_STARTED.md @@ -0,0 +1,785 @@ +# PACT Getting Started — Your First Agent in 5 Minutes + +> **Audience:** Agent developers integrating via CLI, REST API, or MCP. +> **Prerequisites:** Node.js 20+. +> **Version:** PACT v2.0 + +--- + +## Hello World — BYOK Token Flow + +PACT uses a **BYOK (Bring Your Own Key)** model. Document owners create scoped invite tokens for external agents. Agents join anonymously — no Tailor account needed. + +### As the Document Owner + +```bash +# 1. Install & authenticate +npm install -g @tailor-app/cli +tailor login --key tailor_sk_YOUR_KEY + +# 2. Upload a document +echo "# Hello World\n\nThis is a draft." > /tmp/hello.md +tailor upload /tmp/hello.md --share +# ✓ hello.md → DOC_ID + +# 3. Create an invite for an external agent +tailor tap invite create DOC_ID --label "Review Bot" +# → Token: a1b2c3d4e5f6... (give this to the agent) +``` + +### As the External Agent (No Tailor Account) + +```bash +# 4. Join with the invite token (anonymous — no auth required) +curl -X POST https://tailor.au/api/tap/DOC_ID/join-token \ + -H "Content-Type: application/json" \ + -d '{"agentName": "review-bot", "token": "a1b2c3d4e5f6..."}' +# → { registrationId, apiKey: "tailor_sk_scoped_...", contextMode, allowedSections } + +# 5. Use the scoped key for all PACT operations +export API_KEY="tailor_sk_scoped_..." +curl https://tailor.au/api/tap/DOC_ID/content -H "X-Api-Key: $API_KEY" +curl https://tailor.au/api/tap/DOC_ID/sections -H "X-Api-Key: $API_KEY" + +# 6. Propose a change +curl -X POST https://tailor.au/api/tap/DOC_ID/proposals \ + -H "X-Api-Key: $API_KEY" -H "Content-Type: application/json" \ + -d '{"sectionId":"sec:hello-world","newContent":"# Hello World\n\nThis is the **final** version.","summary":"Mark as final"}' + +# 7. Signal completion +curl -X POST https://tailor.au/api/tap/DOC_ID/done \ + -H "X-Api-Key: $API_KEY" -H "Content-Type: application/json" \ + -d '{"status":"aligned","summary":"Review complete"}' +``` + +### Using CLI (if agent has the CLI installed) + +```bash +# Same flow via CLI commands +tailor tap join DOC_ID --as "hello-bot" --role editor +tailor tap get DOC_ID +tailor tap sections DOC_ID +tailor tap propose DOC_ID --section sec:hello-world \ + --content "# Hello World\n\nThis is the **final** version." \ + --summary "Mark as final" +tailor tap proposals DOC_ID +tailor tap approve DOC_ID PROP_ID +tailor tap leave DOC_ID +``` + +That's it. The agent joined via token, got a scoped key, and completed a full propose-approve-merge cycle. + +--- + +## 1. Install & Authenticate + +```bash +npm install -g @tailor-app/cli +``` + +### Option A: API Key (recommended for agents) + +```bash +tailor keys create --name "my-agent" +# → tailor_sk_abc123... (save this — shown only once) + +tailor login --key tailor_sk_abc123 +``` + +### Option B: Magic Link (for humans) + +```bash +tailor login --email you@company.com +``` + +### Option C: Environment Variables (for CI/CD) + +```bash +export TAILOR_API_KEY=tailor_sk_abc123 +export TAILOR_BASE_URL=https://tailor.au +``` + +Environment variables take precedence over stored config. `TAILOR_BASE_URL` defaults to `https://tailor.au` — only set it for self-hosted or local dev. + +--- + +## 2. Upload a Document + +```bash +tailor upload ./contract.docx --share +``` + +Note the **Document ID** in the output — every PACT command needs it. + +```bash +tailor list # See all your documents +tailor list --json # Machine-readable output +``` + +--- + +## 3. Join → Read → Propose → Approve + +Every agent must **join** a document before it can participate. + +```bash +# Join +tailor tap join --as "compliance-bot" --role reviewer + +# Read (pipes to stdout — redirect or pipe to your analysis tool) +tailor tap get > contract.md + +# See the section tree +tailor tap sections +# → sec:introduction +# → sec:introduction/background +# → sec:budget +# → sec:budget/line-items +``` + +Section IDs are **stable across edits**. Always reference sections by ID, never by character offset. + +```bash +# Lock → Propose → Unlock +tailor tap lock --section sec:budget --ttl 60 +tailor tap propose \ + --section sec:budget \ + --content "## Budget\n\nRevised total: $1.2M including contingency." \ + --summary "Added contingency to budget total" +tailor tap unlock --section sec:budget + +# Another agent approves +tailor tap approve +``` + +When enough approvals are collected (per the document's `ApprovalPolicy`), the server **auto-merges** the proposal. + +--- + +## 4. Intent-Constraint-Salience (ICS) — Align Before You Write + +ICS is PACT's mechanism for reaching agreement **before** drafting text. This matters when multiple agents have confidential contexts and can't share their full reasoning. + +### Declare Intent — what you want, not why + +```bash +tailor tap intent \ + --section sec:liability \ + --goal "Need currency risk language" \ + --category compliance +``` + +Other agents see the goal and can object early — before anyone wastes time writing proposals that will be rejected. + +### Publish Constraints — boundary conditions + +```bash +tailor tap constrain \ + --section sec:liability \ + --boundary "Liability cap must not exceed $2M" \ + --category commercial +``` + +Constraints are visible to all agents. They reveal **what** the limit is, not **why** it exists. Agents write proposals that satisfy everyone's constraints without exposing confidential positions. + +### Set Salience — how much you care (0-10) + +```bash +tailor tap salience --section sec:liability --score 9 +tailor tap salience --section sec:appendix --score 2 + +# View the heat map +tailor tap salience-map +``` + +High salience + multiple agents = the sections where alignment matters most. + +### Objection-Based Merge — silence = consent + +Instead of requiring explicit approvals: + +1. Agent proposes a change +2. A TTL timer starts (e.g., 300 seconds) +3. If no agent objects → **auto-merges** +4. Any agent can block: + +```bash +tailor tap object \ + --proposal \ + --reason "Violates liability cap constraint" +``` + +Only disagreements require action. This dramatically reduces latency to alignment. + +### Full ICS Example — Three Agents Negotiating + +```bash +# Agent A (legal): Declare intent +tailor tap intent --section sec:liability \ + --goal "Add currency risk allocation language" --category legal + +# Agent B (commercial): Publish constraint +tailor tap constrain --section sec:liability \ + --boundary "Total liability must not exceed $2M AUD" --category commercial + +# Agent C (compliance): Constraint + high salience +tailor tap constrain --section sec:liability \ + --boundary "Must reference APRA CPS 230 for operational risk" --category regulatory +tailor tap salience --section sec:liability --score 10 + +# Agent A: Read constraints before writing +tailor tap constraints --section sec:liability +tailor tap salience-map + +# Agent A: Propose text satisfying all known constraints +tailor tap propose --section sec:liability \ + --file ./revised-liability.md \ + --summary "Currency risk clause — within $2M cap, references CPS 230" + +# No objections within TTL → auto-merged +# If Agent B objects: +tailor tap object --proposal \ + --reason "Currency risk exposure exceeds the $2M liability cap" + +# Escalate to human if agents can't resolve +tailor tap escalate --section sec:liability \ + --message "Agents disagree on liability cap vs. currency risk allocation" +``` + +--- + +## 5. Choosing an Integration Path + +PACT is accessible three ways. Pick based on what you're building: + +| | CLI | REST API | MCP Tools | +|---|---|---|---| +| **Best for** | Shell scripts, CI/CD, prototyping | Python/TS agents, custom frameworks | LangChain, CrewAI, AutoGen, Cursor | +| **Auth** | Stored config or `TAILOR_API_KEY` env var | `X-Api-Key` header | Configured in MCP server | +| **Real-time events** | Poll with `tailor tap events` | SignalR WebSocket | SignalR via MCP | +| **Learning curve** | Lowest — copy-paste commands | Medium — HTTP requests | Medium — MCP tool definitions | +| **When NOT to use** | Complex multi-step logic in a single process | Simple one-off scripts | No MCP support in your framework | + +### Decision Flowchart + +``` +Are you writing a shell script or CI pipeline? + → YES → Use CLI + +Is your agent built with LangChain, CrewAI, AutoGen, or another MCP-aware framework? + → YES → Use MCP Tools + +Are you building a custom agent in Python, TypeScript, Go, etc.? + → YES → Use REST API + +Do you need real-time push notifications (not polling)? + → YES → Add SignalR alongside CLI/REST/MCP +``` + +--- + +## 6. Integration Examples + +### 6.1 Python + REST API — BYOK Token Flow + +```python +import requests + +BASE = "https://tailor.au" +doc_id = "YOUR_DOC_ID" +INVITE_TOKEN = "a1b2c3d4e5f6..." # Token from document owner + +# 1. Join with invite token (anonymous — no Tailor account) +resp = requests.post(f"{BASE}/api/tap/{doc_id}/join-token", + json={"agentName": "python-reviewer", "token": INVITE_TOKEN}) +data = resp.json() +scoped_key = data["apiKey"] +print(f"Joined as {data['agentName']} with context: {data['contextMode']}") + +HEADERS = {"X-Api-Key": scoped_key, "Content-Type": "application/json"} + +# 2. Read sections +sections = requests.get(f"{BASE}/api/tap/{doc_id}/sections", + headers=HEADERS).json() +print(f"Found {len(sections)} sections") + +# 3. Declare intent +requests.post(f"{BASE}/api/tap/{doc_id}/intents", + json={"sectionId": "sec:liability", "goal": "Ensure indemnity clause is mutual"}, + headers=HEADERS) + +# 4. Read constraints set by other agents +constraints = requests.get(f"{BASE}/api/tap/{doc_id}/constraints?sectionId=sec:liability", + headers=HEADERS).json() +print(f"Active constraints: {[c['boundary'] for c in constraints]}") + +# 5. Propose a change that respects constraints +resp = requests.post(f"{BASE}/api/tap/{doc_id}/proposals", + json={ + "sectionId": "sec:liability", + "newContent": "## Liability\n\nEach party indemnifies the other...", + "summary": "Made indemnity clause mutual", + "reasoning": "Balanced risk allocation per industry standard" + }, + headers=HEADERS) +proposal_id = resp.json()["id"] +print(f"Proposed: {proposal_id}") + +# 6. Signal done +requests.post(f"{BASE}/api/tap/{doc_id}/done", + json={"status": "aligned", "summary": "Liability review complete"}, + headers=HEADERS) +``` + +### 6.2 LangChain Agent with PACT Tools + +```python +from langchain.tools import tool +from langchain_openai import ChatOpenAI +from langchain.agents import AgentExecutor, create_openai_tools_agent +from langchain_core.prompts import ChatPromptTemplate +import requests + +BASE = "https://tailor.au" +# Scoped key from join-token (BYOK flow) +HEADERS = {"X-Api-Key": "tailor_sk_scoped_...", "Content-Type": "application/json"} + +@tool +def tap_join(doc_id: str, agent_name: str, role: str = "reviewer") -> str: + """Join a Tailor document as a PACT agent.""" + resp = requests.post(f"{BASE}/api/tap/{doc_id}/join", + json={"agentName": agent_name, "role": role}, headers=HEADERS) + return f"Joined as {agent_name}" if resp.ok else f"Error: {resp.text}" + +@tool +def tap_get_content(doc_id: str) -> str: + """Get the full document content as Markdown.""" + resp = requests.get(f"{BASE}/api/tap/{doc_id}/content", headers=HEADERS) + return resp.json()["content"] + +@tool +def tap_get_sections(doc_id: str) -> str: + """Get the section tree with stable section IDs.""" + resp = requests.get(f"{BASE}/api/tap/{doc_id}/sections", headers=HEADERS) + sections = resp.json() + return "\n".join(f" {s['sectionId']}: {s['heading']}" for s in sections) + +@tool +def tap_declare_intent(doc_id: str, section_id: str, goal: str) -> str: + """Declare what you want to achieve in a section before writing.""" + resp = requests.post(f"{BASE}/api/tap/{doc_id}/intents", + json={"sectionId": section_id, "goal": goal}, headers=HEADERS) + return f"Intent declared: {goal}" if resp.ok else f"Error: {resp.text}" + +@tool +def tap_list_constraints(doc_id: str, section_id: str) -> str: + """List boundary conditions set by other agents on a section.""" + resp = requests.get(f"{BASE}/api/tap/{doc_id}/constraints?sectionId={section_id}", + headers=HEADERS) + constraints = resp.json() + if not constraints: + return "No constraints on this section" + return "\n".join(f" - {c['boundary']} ({c.get('category', 'general')})" for c in constraints) + +@tool +def tap_propose(doc_id: str, section_id: str, content: str, summary: str) -> str: + """Propose an edit to a document section.""" + resp = requests.post(f"{BASE}/api/tap/{doc_id}/proposals", + json={"sectionId": section_id, "newContent": content, "summary": summary}, + headers=HEADERS) + return f"Proposal created: {resp.json()['id']}" if resp.ok else f"Error: {resp.text}" + +tools = [tap_join, tap_get_content, tap_get_sections, tap_declare_intent, + tap_list_constraints, tap_propose] + +prompt = ChatPromptTemplate.from_messages([ + ("system", """You are a legal review agent. Your workflow: +1. Join the document +2. Read sections and content +3. Declare your intent before proposing changes +4. Check constraints from other agents +5. Propose changes that satisfy all constraints"""), + ("human", "{input}"), + ("placeholder", "{agent_scratchpad}"), +]) + +llm = ChatOpenAI(model="gpt-4o") +agent = create_openai_tools_agent(llm, tools, prompt) +executor = AgentExecutor(agent=agent, tools=tools, verbose=True) + +executor.invoke({ + "input": "Review document DOC_ID for compliance issues in the liability section" +}) +``` + +### 6.3 CrewAI Multi-Agent Negotiation + +```python +from crewai import Agent, Task, Crew +from crewai_tools import tool +import requests + +BASE = "https://tailor.au" +# Scoped key from join-token (BYOK flow) +HEADERS = {"X-Api-Key": "tailor_sk_scoped_...", "Content-Type": "application/json"} + +@tool("PACT Read Document") +def read_document(doc_id: str) -> str: + """Read a Tailor document's content and sections.""" + content = requests.get(f"{BASE}/api/tap/{doc_id}/content", headers=HEADERS).json()["content"] + sections = requests.get(f"{BASE}/api/tap/{doc_id}/sections", headers=HEADERS).json() + tree = "\n".join(f" {s['sectionId']}: {s['heading']}" for s in sections) + return f"SECTIONS:\n{tree}\n\nCONTENT:\n{content}" + +@tool("PACT Declare Intent") +def declare_intent(doc_id: str, section_id: str, goal: str, category: str) -> str: + """Declare an intent on a section before proposing changes.""" + resp = requests.post(f"{BASE}/api/tap/{doc_id}/intents", + json={"sectionId": section_id, "goal": goal, "category": category}, headers=HEADERS) + return f"Intent declared: {goal}" if resp.ok else f"Error: {resp.text}" + +@tool("PACT Publish Constraint") +def publish_constraint(doc_id: str, section_id: str, boundary: str, category: str) -> str: + """Publish a boundary condition that proposed changes must satisfy.""" + resp = requests.post(f"{BASE}/api/tap/{doc_id}/constraints", + json={"sectionId": section_id, "boundary": boundary, "category": category}, headers=HEADERS) + return f"Constraint published: {boundary}" if resp.ok else f"Error: {resp.text}" + +@tool("PACT Propose Edit") +def propose_edit(doc_id: str, section_id: str, content: str, summary: str) -> str: + """Propose an edit to a document section.""" + resp = requests.post(f"{BASE}/api/tap/{doc_id}/proposals", + json={"sectionId": section_id, "newContent": content, "summary": summary}, headers=HEADERS) + return f"Proposed: {resp.json()['id']}" if resp.ok else f"Error: {resp.text}" + +legal_agent = Agent( + role="Legal Reviewer", + goal="Ensure all clauses are legally sound and balanced", + backstory="Senior legal counsel with 20 years in contract law.", + tools=[read_document, declare_intent, propose_edit], +) + +commercial_agent = Agent( + role="Commercial Reviewer", + goal="Protect commercial interests and cost boundaries", + backstory="CFO ensuring financial risk stays within board-approved limits.", + tools=[read_document, publish_constraint], +) + +legal_task = Task( + description=f"Review document DOC_ID. Declare intent for any sections needing legal changes, then propose edits.", + expected_output="List of intents declared and proposals made", + agent=legal_agent, +) + +commercial_task = Task( + description=f"Review document DOC_ID. Publish constraints on any sections with financial exposure.", + expected_output="List of constraints published", + agent=commercial_agent, +) + +crew = Crew( + agents=[legal_agent, commercial_agent], + tasks=[commercial_task, legal_task], # commercial publishes constraints first + verbose=True, +) + +crew.kickoff() +``` + +### 6.4 AutoGen Multi-Agent + +```python +import autogen +import requests + +BASE = "https://tailor.au" +# Scoped key from join-token (BYOK flow) +HEADERS = {"X-Api-Key": "tailor_sk_scoped_...", "Content-Type": "application/json"} +DOC_ID = "YOUR_DOC_ID" + +config_list = [{"model": "gpt-4o", "api_key": "sk-..."}] + +def tap_read(doc_id: str) -> str: + content = requests.get(f"{BASE}/api/tap/{doc_id}/content", headers=HEADERS).json()["content"] + return content + +def tap_propose(doc_id: str, section_id: str, content: str, summary: str) -> str: + resp = requests.post(f"{BASE}/api/tap/{doc_id}/proposals", + json={"sectionId": section_id, "newContent": content, "summary": summary}, + headers=HEADERS) + return f"Proposed: {resp.json()['id']}" if resp.ok else f"Error: {resp.text}" + +def tap_approve(doc_id: str, proposal_id: str) -> str: + resp = requests.post(f"{BASE}/api/tap/{doc_id}/proposals/{proposal_id}/approve", + headers=HEADERS) + return "Approved" if resp.ok else f"Error: {resp.text}" + +editor = autogen.AssistantAgent( + name="editor", + system_message="You are a document editor. Read the document, then propose improvements.", + llm_config={"config_list": config_list}, +) + +reviewer = autogen.AssistantAgent( + name="reviewer", + system_message="You are a document reviewer. Review proposals and approve or reject them.", + llm_config={"config_list": config_list}, +) + +user_proxy = autogen.UserProxyAgent( + name="coordinator", + human_input_mode="NEVER", + code_execution_config={"work_dir": "tap_work"}, +) + +# Register PACT functions +editor.register_function( + function_map={ + "tap_read": lambda: tap_read(DOC_ID), + "tap_propose": lambda section_id, content, summary: tap_propose(DOC_ID, section_id, content, summary), + } +) + +reviewer.register_function( + function_map={ + "tap_approve": lambda proposal_id: tap_approve(DOC_ID, proposal_id), + } +) + +user_proxy.initiate_chat( + editor, + message=f"Read document {DOC_ID} and propose improvements to the introduction section." +) +``` + +### 6.5 MCP Server Configuration (stdio — Cursor, Claude Desktop, Windsurf) + +For local MCP-compatible agents, add this to your MCP config (`.cursor/mcp.json`, Claude Desktop settings, etc.): + +```json +{ + "mcpServers": { + "tailor": { + "command": "npx", + "args": ["-y", "@tailor-app/cli", "mcp", "serve"], + "env": { + "TAILOR_API_KEY": "", + "TAILOR_BASE_URL": "https://tailor.au" + } + } + } +} +``` + +The MCP server exposes these tools to the agent: + +| MCP Tool | PACT Operation | +|----------|---------------| +| `tailor_tap_join` | Register as an agent on a document | +| `tailor_tap_leave` | Unregister from a document | +| `tailor_tap_get` | Get document content as Markdown | +| `tailor_tap_sections` | Get section tree with stable IDs | +| `tailor_tap_propose` | Propose an edit to a section | +| `tailor_tap_proposals` | List proposals (filter by section/status) | +| `tailor_tap_approve` | Approve a proposal | +| `tailor_tap_reject` | Reject a proposal with reason | +| `tailor_tap_object` | Object to a proposal (soft dissent) | +| `tailor_tap_intent_declare` | Declare a goal for a section | +| `tailor_tap_intents` | List active intents | +| `tailor_tap_constraint_publish` | Publish a boundary condition | +| `tailor_tap_constraints` | List active constraints | +| `tailor_tap_salience_set` | Set importance score (0-10) | +| `tailor_tap_salience_map` | Get salience heat map | +| `tailor_tap_poll` | Poll for events since a cursor | +| `tailor_tap_done` | Signal agent completion | +| `tailor_tap_lock` | Lock a section for editing | +| `tailor_tap_unlock` | Unlock a section | +| `tailor_tap_escalate` | Escalate to human reviewer | +| `tailor_tap_ask_human` | Ask a question requiring human judgement | +| `tailor_list_documents` | List your documents | +| `tailor_upload_document` | Upload a new document | + +### 6.5b HTTP MCP Endpoint (Claude API, Remote Agents) + +Tailor also exposes an HTTP-based MCP endpoint using streamable HTTP transport — ideal for cloud agents and the Claude API: + +**Endpoint:** `https://tailor.au/mcp` +**Discovery:** `https://tailor.au/.well-known/mcp.json` +**Auth:** `X-Api-Key` header (scoped key from `join-token`) + +Claude API / remote MCP connector config: + +```json +{ + "mcpServers": { + "tailor": { + "type": "url", + "url": "https://tailor.au/mcp", + "headers": { "X-Api-Key": "tailor_sk_scoped_..." } + } + } +} +``` + +The HTTP MCP endpoint exposes the same 25+ tools as the stdio server. Use the stdio server for local agents (Cursor, Claude Desktop, Windsurf) and the HTTP endpoint for cloud/remote agents (Claude API, server-side agents). + +### 6.7 OpenAI Custom GPTs (GPT Actions) + +Import the PACT-focused OpenAPI spec directly into a Custom GPT: + +1. Go to **GPT Builder** > **Configure** > **Actions** > **Import from URL** +2. Enter: `https://tailor.au/openapi/tap.json` +3. Set authentication: **API Key**, header name `X-Api-Key`, value = your scoped key from `join-token` +4. Save and test + +The spec includes all core PACT operations: join-token (anonymous), content, sections, proposals (CRUD), approve, reject, object, poll, done, intents, constraints, salience, lock/unlock, and escalate. + +### 6.6 SignalR Real-Time Events + +For agents that need **push** notifications instead of polling: + +``` +Hub URL: wss://tailor.au/hubs/tap +Group: tap:{documentId} +Auth: X-Api-Key header on connection + +Events: + tap.proposal.created → { proposalId, sectionId, authorId } + tap.proposal.approved → { proposalId, approvedBy } + tap.proposal.rejected → { proposalId, rejectedBy, reason } + tap.proposal.merged → { proposalId, sectionId } + tap.proposal.objected → { proposalId, objectedBy, reason } + tap.proposal.auto-merged → { proposalId, sectionId } + tap.intent.declared → { intentId, sectionId, goal } + tap.intent.objected → { intentId, objectedBy } + tap.constraint.published → { constraintId, sectionId, boundary } + tap.constraint.withdrawn → { constraintId } + tap.salience.updated → { sectionId, agentId, score } +``` + +**TypeScript example (SignalR client):** + +```typescript +import * as signalR from "@microsoft/signalr"; + +const connection = new signalR.HubConnectionBuilder() + .withUrl("https://tailor.au/hubs/tap", { + headers: { "X-Api-Key": "tailor_sk_YOUR_KEY" }, + }) + .withAutomaticReconnect() + .build(); + +connection.on("tap.proposal.created", (event) => { + console.log(`New proposal on ${event.sectionId} by ${event.authorId}`); +}); + +connection.on("tap.constraint.published", (event) => { + console.log(`New constraint: ${event.boundary}`); +}); + +await connection.start(); +await connection.invoke("JoinDocumentGroup", docId); +``` + +--- + +## 7. Key Concepts + +| Concept | What it means | +|---------|---------------| +| **Section** | A heading-delimited block of the document. Stable ID like `sec:budget/line-items`. | +| **Proposal** | A suggested edit to a section. Must be approved/merged or rejected. | +| **Intent** | A declared goal ("I want X") before writing. Catches misalignment early. | +| **Constraint** | A boundary condition ("X must not exceed Y"). Reveals limits without revealing reasoning. | +| **Salience** | A 0-10 score for how much an agent cares about a section. Focuses attention. | +| **Objection** | An active disagreement. Blocks auto-merge and forces renegotiation. | +| **Lock** | A temporary exclusive claim on a section (max 60s). Prevents concurrent proposals. | +| **Escalation** | A request for human review when agents can't resolve a disagreement. | +| **TrustLevel** | Agent permission tier: `Observer` → `Suggester` → `Collaborator` → `Autonomous`. | +| **ApprovalPolicy** | How proposals get merged: `Unanimous`, `Majority`, `SingleApprover`, `AutoMerge`, `ObjectionBased`. | + +--- + +## 8. Troubleshooting + +| Problem | Cause | Fix | +|---------|-------|-----| +| `HTTP 401: Unauthorized` | Session expired or bad API key | Run `tailor login --key ` or check `TAILOR_API_KEY` env var | +| `HTTP 403: Forbidden` | API key lacks required scopes | Create a new key: `tailor keys create --name "agent" --scopes "documents:read,documents:write"` | +| `Could not connect` | Wrong URL or server down | Check URL with `tailor login --url `. For local dev: `http://localhost:7255` | +| `Section not found` | Stale section ID | Run `tailor tap sections ` to see current valid IDs | +| `Already joined` | Agent already registered | `tailor tap leave ` first, then re-join | +| `Lock failed` | Section locked by another agent | Wait for TTL expiry or check `tailor tap sections ` for lock info | +| Proposal stuck in `pending` | Waiting for approvals | Check `ApprovalPolicy`. Try `ObjectionBased` for faster merges | +| `Server returned HTML` | Wrong base URL | You're hitting a web page, not the API. Check `TAILOR_BASE_URL` | +| Proposal `rejected` unexpectedly | Constraint violation or policy | Check `tailor tap constraints ` and proposal rejection reason | +| No real-time events | Not subscribed | Join the SignalR group: `connection.invoke("JoinDocumentGroup", docId)` | + +--- + +## 9. REST API Quick Reference + +All endpoints accept `X-Api-Key: tailor_sk_...` for authentication. + +### Agent Lifecycle + +``` +POST /api/tap/{docId}/join → { registrationId, agentName, role } +DELETE /api/tap/{docId}/leave +``` + +### Read Operations + +``` +GET /api/tap/{docId}/content → { content, version } +GET /api/tap/{docId}/sections → [{ sectionId, heading, level, children }] +GET /api/tap/{docId}/agents → [{ agentName, role, isActive }] +GET /api/tap/{docId}/events → [{ type, agentName, sectionId, timestamp }] +``` + +### Proposals + +``` +POST /api/tap/{docId}/proposals → { id, sectionId, status } +GET /api/tap/{docId}/proposals → [{ id, sectionId, status, summary }] +POST /api/tap/{docId}/proposals/{id}/approve +POST /api/tap/{docId}/proposals/{id}/reject body: { reason } +POST /api/tap/{docId}/proposals/{id}/object body: { reason } +``` + +### Intent-Constraint-Salience + +``` +POST /api/tap/{docId}/intents body: { sectionId, goal, category? } +GET /api/tap/{docId}/intents ?sectionId=... +POST /api/tap/{docId}/constraints body: { sectionId, boundary, category? } +GET /api/tap/{docId}/constraints ?sectionId=... +POST /api/tap/{docId}/salience body: { sectionId, score } +GET /api/tap/{docId}/salience → { entries: [{ agentId, sectionId, score }] } +``` + +### Section Locking + +``` +POST /api/tap/{docId}/sections/{sectionId}/lock body: { ttlSeconds? } +DELETE /api/tap/{docId}/sections/{sectionId}/lock +``` + +### Escalation + +``` +POST /api/tap/{docId}/escalate body: { sectionId?, message } +``` + +--- + +## Next Steps + +- **Full specification:** [PACT_SPECIFICATION.md](./PACT_SPECIFICATION.md) +- **CLI reference:** [tools/tailor-cli/README.md](../../tools/tailor-cli/README.md) +- **PACT OpenAPI spec (for GPT Actions):** `https://tailor.au/openapi/tap.json` +- **MCP discovery:** `https://tailor.au/.well-known/mcp.json` +- **Public docs:** `https://tailor.au/docs/agents` +- **npm:** `npm install -g @tailor-app/cli` (v0.9.0) +- **Standalone spec:** [github.com/TailorAU/pact](https://github.com/TailorAU/pact) diff --git a/spec/v2.3/README.md b/spec/v2.3/README.md new file mode 100644 index 0000000..05b952f --- /dev/null +++ b/spec/v2.3/README.md @@ -0,0 +1,92 @@ +# PACT v2.3 — DRAFT / NOT FOR CITATION + +> **This directory is a DRAFT.** It has **not** been signed off per +> AGENTS.md rule 3 and MUST NOT be cited, mirrored, or treated as stable. +> Cite **`spec/v2.2/`** (stable, Matter-line) or **`spec/v2.0/`** (stable, +> v2.0.3) until this banner is removed by an explicit maintainer sign-off +> recorded below. Review vehicles: +> [#53](https://github.com/TailorAU/pact/issues/53), +> [#54](https://github.com/TailorAU/pact/issues/54), +> [#55](https://github.com/TailorAU/pact/issues/55). + +## Sign-off record + +| Event | Authority | Date | +|---|---|---| +| Draft opened (multi-cell RFC PR) | agent, per #53 / #54 / #55 | 2026-08-20 | +| Promotion to stable | *(pending — requires explicit maintainer sign-off)* | — | + +## What v2.3 contains + +A carry-forward of the stable **v2.2** body (itself v2.0.3 + §24 Matters) +plus the **multi-cell fabric** rule set, authored from the design record +`docs/v2-prep/rfc-multi-cell-quorum.md` (Changes 1–4) via issues +#53 / #54 / #55: + +- **Cells and the §15.4 cell trigger** (#53) — *cell* defined as a + vendor-neutral isolation grouping identified by a cell DID; fabric + membership carries `cell_id`, **bound to a proof of control of the + cell DID** (a cell membership attestation carried in the + join/`_onboard`, or a cell-scoped invite minted under the cell DID's + authority — unproven `cell_id` claims are rejected, or at most + bucketed as the joining operator's own cell, never the claimed one); + the participant cell set is declared at establish + (`participant_cells`) or constituted at first-join binding, and is + fixed from then on; a fifth §15.4 cross-organisation trigger fires + when two participants on the same fabric carry different `cell_id` + values, so §17.6 `authorization_proof` and the §17.9 + Authorization-Required checks apply on the cross-cell wire. +- **`cell-quorum` approval policy + multi-cell policy bans** (#54) — + a new `ApprovalPolicy` requiring ≥1 counted approval from **each** + cell in the fixed participant set (bucketed by `cell_id`, not + head-count; a dark cell fails closed). On fabrics whose participant + cell set has cardinality > 1, agent-count policies (`single` / + `majority` / `unanimous`) and silence-merge policies (`auto` / + `objection-based`) MUST NOT be configured. +- **Multi-cell conflicts MUST human-escalate** (#55) — when the + participant cell set has cardinality > 1, the conflict strategy MUST + be `human-escalate`; `first-wins` and `merge-both` MUST NOT be used; + `vote` is not a substitute unless it is itself cell-quorum and still + escalates when any cell is dark. + +**Not in this directory:** §19–22 (Parleys / Mandates / push / +service-account — the v2.1 line, still draft in `spec/v2.1/`) and the +§25 consensus/authorization/legal-execution boundary (v2.1-line, +PR #45). Both fold into this line when their own review vehicles land +and are signed off; this draft does not duplicate or pre-empt them. +`spec/v2.2/` itself remains stable and unedited per AGENTS.md rule 4; +this directory is a copy that adds the multi-cell rules, not an edit +of v2.2. + +## Conformance level claims (draft) + +- **Core**: identical to v2.2. Single-cell fabrics are unaffected by + every rule in this line. +- **Extended**: v2.2 Extended + the §5 multi-cell policy bans and the + §5 multi-cell conflict rule. These bind on **behaviour, not + advertisement**: any server that accepts `cell_id`-bearing + memberships or a multi-cell establish MUST enforce them, whether or + not it advertises `capabilities.cellQuorum`. A server that does not + implement the multi-cell rules MUST reject multi-cell establishes + and `cell_id`-bearing joins / `_onboard`s (fail closed); the + `cellQuorum` flag advertises support only and is never an opt-in + switch for the MUSTs. +- **Authorization-Required**: v2.2 Authorization-Required + the §15.4 + cell trigger MUST be evaluated on every message on a fabric whose + participant cell set has cardinality > 1. + +## File map + +| Path | What | +|---|---| +| `SPECIFICATION.md` | v2.2 normative text + multi-cell rules (§5, §10.5, §15.1, §15.4) | +| `schemas/` | unchanged from v2.2 (before promotion: a `cell_id` membership schema field, the §15.4 cell membership attestation / cell-scoped invite envelope, and the `participant_cells` establish field are expected) | +| `conformance/` | unchanged from v2.2 (before promotion: multi-cell vectors are expected, including negative vectors for unproven-`cell_id` rejection and for the initiator-stuffs-counterparty-bucket attack §15.4 bans) | +| `resource-types.yaml` | unchanged from v2.2 | +| `GETTING_STARTED.md` | unchanged from v2.2 | + +## Citation + +Do **not** cite this directory until the DRAFT banner is removed. After +promotion, cite it as **"PACT v2.3"** and the multi-cell rules as +**"PACT v2.3 §5 / §15.4"**. diff --git a/spec/v2.3/SPECIFICATION.md b/spec/v2.3/SPECIFICATION.md new file mode 100644 index 0000000..265139b --- /dev/null +++ b/spec/v2.3/SPECIFICATION.md @@ -0,0 +1,2494 @@ +# PACT — Protocol for Agent Contexture and Trust — Specification v2.3 (DRAFT) + +> **Status:** DRAFT — NOT FOR CITATION. This directory has not been signed +> off per AGENTS.md rule 3. Cite `spec/v2.2/` (stable, Matter-line) or +> `spec/v2.0/` (stable, v2.0.3) until this banner is removed by an explicit +> maintainer sign-off. Review vehicles: +> [#53](https://github.com/TailorAU/pact/issues/53) / +> [#54](https://github.com/TailorAU/pact/issues/54) / +> [#55](https://github.com/TailorAU/pact/issues/55). +> **Author:** Knox Hart + AI +> **Date:** 20 August 2026 +> **Version:** 2.3.0-draft (carry-forward of the v2.2 body — itself v2.0.3 +> plus §24 Matters — plus the **multi-cell fabric** rules: cells and the +> §15.4 cell trigger (#53), the `cell-quorum` approval policy and +> multi-cell policy bans (#54), and mandatory human escalation of +> multi-cell conflicts (#55). Additive for single-cell fabrics — no +> breaking changes for any fabric whose participant cell set has +> cardinality ≤ 1. Design record: +> `docs/v2-prep/rfc-multi-cell-quorum.md` Changes 1–4.) +> **Carried v2.0.3 version note:** third patch on the v2.0 line; adds **fabric onboarding & session awareness** — the "cognitive layer" of active session: agents can now atomically join+constrain a fabric, query their scoped manifest of obligations, heartbeat bidirectionally, and acknowledge event ranges. Additive — no breaking changes to v2.0 / v2.0.1 / v2.0.2 clients. See [`CHANGELOG.md`](../../CHANGELOG.md#v203--2026-05-15) for the operations added.) +> **Vision:** Enable millions of agents to reach consensus on shared resources at machine speed, with humans retaining final authority. + +### What's New in v2.0.3 + +PACT v2.0.3 closes the gap between the *registration* layer (an agent is a member of the fabric per the registry) and the *cognitive* layer (the agent's reasoning context actually knows it's in the fabric and is acting under those constraints). v2.0.2 covered join/leave but left "what fabric am I in, who else is here, what do I owe them, what just happened" implicit. v2.0.3 makes it explicit: + +- **Active Session Manifest Operations (§4.4)** — five additive operations: `GET /_status` (fabric snapshot), `GET /manifest` (caller-scoped view), `POST /_heartbeat` (bidirectional liveness + attention flagging), `POST /mark-read` (event-range acknowledgement), `POST /_onboard` (atomic join+constrain). +- **Pending Obligations (§6.5)** — first-class concept: what the protocol expects a specific member to do next, surfaced via `/manifest` and `/_status`. +- **Fabric Onboarding Pattern (§15.6)** — recommended flow that eliminates the half-joined window between membership and constraint declaration. +- **Bidirectional heartbeat (§4.1 update)** — v2.0's one-way ping becomes a two-way liveness signal feeding per-member `last_seen`. +- **Manifest visibility (§17.13 update)** — cross-org disclosure rules apply to the new manifest endpoint; it is not a privacy bypass. + +The five new operations and six new events are described in §4.4 and §6.5. Onboarding-flow guidance is in §15.6. + +### What's New in v2.0 + +PACT v2.0 extends v1.1 with first-class concepts for **human-authorized actions**: a `HumanPrincipal` abstraction (Section 17) and an **Attestation Format Reference** (Section 18). All v1.1 behavior is preserved; agent-only deployments do not need to implement Sections 17–18 to remain v2.0 conformant at the Core level. + +Current additions in this draft: +- **HumanPrincipal** (Section 17) — **strictly 1:1** mapping between a human and a `HumanPrincipal`. Multi-persona models live above the PACT layer. Resolved per issue [#4](https://github.com/TailorAU/pact/issues/4). +- **Attestation Format Reference** (Section 18) — `fido2-assertion` plus a first-class `voice-biometric` credential type. See issue [#3](https://github.com/TailorAU/pact/issues/3). +- **Backward compatibility** — all v1.1 endpoints, schemas, and resource types continue to work unchanged. + +Tracked for v2.0 (normative text lands via coordinated PRs — see [`docs/v2-plan.yaml`](../../docs/v2-plan.yaml)): W3C DID principal identity (`did:web` + `did:key` required), an `Authorization-Required` conformance tier, ephemeral negotiation Sessions with handler-signed Mandates (§19–20), push delivery (§21), service-account authentication (§22), agent identity lifecycle (§23), and a conformance test suite. + +> Sections 17 and 18 are stub headings — full normative text (fields, signature suites, lifecycle, revocation) lands via coordinated PRs with HMAN / tailor-app per AGENTS.md. + +--- + +### What's New in v1.1 (recap) + +PACT v1.1 generalizes the protocol from document-only to **any resource type** — documents, transactions, knowledge claims, clinical records, or any domain where agents need structured consensus. All v1.0 behavior is preserved; documents are the default resource type. The core primitives (join, intent, constrain, propose, object, escalate, done) are unchanged. + +Key additions: +- **Resource Types** (Section 14) — implementations declare what kind of resource agents negotiate over +- **Implementation Profiles** (Section 15) — each PACT server advertises supported resource types and apply semantics +- **Conformance Levels** (Section 15) — Core vs Extended compliance tiers +- **Backward compatibility** — proposals without a `type` field default to `"document"`; all v1.0 endpoints continue to work + + +--- + +## Quick Start + +New to PACT? See **[PACT Getting Started](./PACT_GETTING_STARTED.md)** for a 5-minute walkthrough: authenticate, join a document, and make your first proposal. + +**60-second overview:** + +```bash +# Join a document (BYOK — invite token, no account needed) +POST /api/pact/{docId}/join-token + { "agentName": "my-agent", "token": "INVITE_TOKEN" } + → { registrationId, apiKey, contextMode } + +# Read the document +GET /api/pact/{docId}/content → { content, version } + +# See section structure +GET /api/pact/{docId}/sections → [{ sectionId, heading, level }] + +# Propose a change +POST /api/pact/{docId}/proposals + { "sectionId": "sec:intro", "newContent": "...", "summary": "..." } +``` + +--- + +## 1. Problem Statement + +Multi-agent collaboration is moving from human-to-human to **agent-to-agent** at massive scale — not only on documents, but on transactions, knowledge claims, clinical records, and any shared resource requiring structured agreement. Today, no standard protocol exists for agents to: + +| Capability | Human Layer | Agent Layer | +|---|---|---| +| Propose changes | Track changes / approvals | **No standard** | +| Declare constraints | Legal/compliance review | **No standard** | +| Approve/reject proposals | Review workflows | **No standard** | +| Real-time coordination | WebSocket / SSE | **No standard** | +| Conflict resolution | Human decides | **No standard** | +| Field-level addressing | Internal refs | **No standard** | + +The protocol that defines how agents reach consensus on shared resources becomes the infrastructure layer for every multi-agent framework (LangChain, CrewAI, AutoGen, OpenAI Swarms, etc.). + +--- + +## 2. Design Principles + +1. **Resource content format is defined by the resource type.** For documents, the canonical content is renderable Markdown. For transactions, it may be a structured JSON record. For knowledge claims, a fact with evidence. Protocol metadata always lives in the event layer, not in the resource body. + +2. **Two layers, one resource.** The Agent Layer (structured operations at machine speed) and the Human Layer (rendered view with natural-language interaction) are projections of the same underlying state. + +3. **Agents submit operations, not raw edits.** Agents never directly mutate the resource. They submit typed operations (propose, approve, reject, lock, apply) through the protocol. The server validates and applies them. + +4. **Humans always win.** Any human can override any agent decision at any time. Agent autonomy is governed by trust levels, and human escalation is always available. + +5. **Event-sourced truth.** The operation log is the source of truth for collaboration state. The resource content is a projection that can be rebuilt from events. + +6. **Field-level granularity.** Operations target addressable fields within a resource (document sections, transaction fields, claim attributes), not raw offsets. This keeps the protocol coarse enough for LLMs to reason about. + +> **v1.0 note:** In PACT v1.0, "resource" was called "document" and "field" was called "section". These terms are interchangeable for the `document` resource type. All v1.0 endpoints remain valid. + +--- + +## 3. Architecture + +``` +┌──────────────────────────────────────────────────────┐ +│ HUMAN LAYER │ +│ Web UI: rendered Markdown, comment panel, approve/ │ +│ reject buttons. Full visibility into Message │ +│ Register. Can inject directives and overrides. │ +├──────────────────────────────────────────────────────┤ +│ MEDIATOR (optional) │ +│ Routes inter-agent communication. Enforces barriers │ +│ at the routing layer. Summarises, redacts, blocks. │ +│ Maintains the Message Register. (Section 13) │ +│ In unmediated mode, agents interact directly below. │ +├──────────────────────────────────────────────────────┤ +│ PACT API │ +│ REST + WebSocket endpoints for protocol operations. │ +│ Validates against TrustLevel, enforces locks, │ +│ resolves conflicts, writes events. │ +├──────────────────────────────────────────────────────┤ +│ AGENT LAYER │ +│ CLI tools, MCP Server, Direct REST │ +├──────────────────────────────────────────────────────┤ +│ EVENT STORE + MESSAGE REGISTER │ +│ Append-only event log with protocol events. │ +│ Message Register records all mediated communications. │ +│ Source of truth for all collaboration state. │ +├──────────────────────────────────────────────────────┤ +│ DOCUMENT │ +│ Canonical Markdown content. Always valid, always │ +│ renderable. Updated by server when proposals merge. │ +└──────────────────────────────────────────────────────┘ +``` + +### 3.1 Document Model + +A PACT document consists of: + +| Component | Description | +|---|---| +| **Content** | Canonical Markdown (`.md`). The current accepted state of the document. | +| **Sections** | Server-parsed section tree from Markdown headings. Each section has a stable `sectionId`. | +| **Operation Log** | Ordered events recording every protocol operation. | +| **Active Proposals** | Pending edit proposals from agents, not yet merged or rejected. | +| **Locks** | Temporary exclusive claims on sections (with TTL). | +| **Agent Registry** | Agents that have joined this document with their roles and trust levels. | + +### 3.2 Section Addressing + +Sections are identified by a stable `sectionId` derived from heading hierarchy: + +```markdown +# Introduction → sec:introduction +## Background → sec:introduction/background +## Goals → sec:introduction/goals +# Budget → sec:budget +## Line Items → sec:budget/line-items +### Personnel → sec:budget/line-items/personnel +``` + +If headings change, the server maintains a mapping from old to new `sectionId` values. Agents always reference sections by ID, never by character offset. + +For content outside headings (preamble, top-level paragraphs), a synthetic `sec:_root` section captures everything before the first heading. + +--- + +## 4. Protocol Operations + +### 4.1 Agent Lifecycle + +| Operation | Description | TrustLevel Required | +|---|---|---| +| `agent.join` | Register as a participant on a document | Observer+ | +| `agent.leave` | Unregister from a document | Any | +| `agent.heartbeat` | Bidirectional liveness signal (v2.0.3+ — feeds per-member `last_seen` in the §4.4 manifest; auto-evicted after 5min silence) | Any | +| `agent.onboard` | (v2.0.3+) Atomic join + constraint declaration — see §4.4 and §15.6 | Observer+ | + +**Heartbeat — bidirectional (v2.0.3+).** Through v2.0.2, `agent.heartbeat` was a one-way client→server ping: the agent told the server "I'm still here." v2.0.3 makes the heartbeat **bidirectional**: the server's response carries the fabric's current liveness view (per-member `last_seen` timestamps, the latest event id, and a count of pending obligations for the caller) so the agent immediately knows whether its counterparties are still present. The full request/response shape, the optional `attention_required` flag, and the emitted events (`pact.agent.heartbeat-received`, `pact.agent.attention-required`) are defined in §4.4 under `POST /fabric/{id}/_heartbeat`. The legacy v2.0.2 one-way semantics remain valid (the response body is additive); v2.0.2 clients that ignore the body see no behavioural change. + +### 4.2 Read Operations + +| Operation | Description | TrustLevel Required | +|---|---|---| +| `document.get` | Get current canonical Markdown content | Observer+ | +| `document.sections` | Get section tree with IDs | Observer+ | +| `proposals.list` | List active proposals | Observer+ | +| `events.subscribe` | Subscribe to real-time event stream | Observer+ | +| `events.history` | Get historical events for a section or document | Observer+ | + +### 4.3 Write Operations + +| Operation | Description | TrustLevel Required | +|---|---|---| +| `proposal.create` | Propose an edit to a section | Suggester+ | +| `proposal.approve` | Approve another agent's proposal | Collaborator+ | +| `proposal.reject` | Reject a proposal with reason | Collaborator+ | +| `proposal.object` | Object to a proposal (objection-based flow) | Collaborator+ | +| `proposal.withdraw` | Withdraw your own proposal | Suggester+ | +| `intent.declare` | Declare a goal for a section before drafting text | Suggester+ | +| `intent.object` | Object to another agent's declared intent | Collaborator+ | +| `constraint.publish` | Publish a boundary condition on a section | Suggester+ | +| `constraint.withdraw` | Withdraw a previously published constraint | Suggester+ | +| `salience.set` | Set how much you care about a section (0-10) | Observer+ | +| `comment.add` | Add a comment on a section | Suggester+ | +| `comment.resolve` | Mark a comment as resolved | Collaborator+ | +| `section.lock` | Claim exclusive edit on a section (TTL max 60s) | Collaborator+ | +| `section.unlock` | Release a section lock | Collaborator+ | +| `escalate.human` | Flag something for human review | Any | + +### 4.4 Active Session Manifest Operations (v2.0.3+) + +Through v2.0.2, PACT defined how an agent *registers* with a fabric (§4.1 `agent.join`) but said nothing about how an agent should *know it is in* a fabric — i.e. how its reasoning context comes to hold "I am acting in fabric F with counterparties X and Y, under constraints C, with these pending obligations." v2.0.3 adds five operations that surface this cognitive-layer state and let the agent acknowledge what it has seen. + +**Terminology.** "Fabric" is the v2.0.3 term for *a single resource's coordination context* — the set of agents joined to one PACT resource (document, transaction, claim, etc.), plus the constraints, intents, obligations, and events that bind them. The fabric ID equals the resource ID (`{documentId}` in v1.x / v2.0 URL paths); the operations below use `{fabricId}` as a synonym to make the cognitive-layer intent explicit. Either ID form MUST be accepted as the path parameter; this is a naming choice, not a new identifier. + +**Convention — `_`-prefixed vs non-prefixed paths.** Per the §15.5 introspection convention, paths beginning with `_` (`/_status`, `/_heartbeat`, `/_onboard`) are **introspection / control-plane** — they read or assert session state without producing a substantive negotiation event. Non-prefixed paths (`/manifest`, `/mark-read`) are **first-class operations** with their own event types and are subject to the usual idempotency and trust-level rules. + +| Operation | Method + Path | Purpose | Tier (§15.5) | +|---|---|---|---| +| Fabric status | `GET /api/pact/{fabricId}/_status` | Server's whole-fabric snapshot: members, phase, latest event id, last activity per member. Cross-org disclosure rules of §17.13 apply. | basic | +| Caller manifest | `GET /api/pact/{fabricId}/manifest` | The caller-scoped view: what *this caller* needs to know about the fabric it is in — its constraints, its pending obligations, its visible counterparties. | basic | +| Heartbeat | `POST /api/pact/{fabricId}/_heartbeat` | Bidirectional liveness: agent declares it is present and aware; server returns the fabric's liveness view. Optional `attention_required: true` to flag active presence to counterparties. | basic | +| Mark-read | `POST /api/pact/{fabricId}/mark-read` | Caller acknowledges receipt of an event range `[from_event_id, to_event_id]` so counterparties see "seen" not just "delivered." | basic | +| Atomic onboard | `POST /api/pact/{fabricId}/_onboard` | Atomic join + constraint declaration in a single transaction. Either both commit or neither. See §15.6 for the onboarding flow. | full | + +**Verifier ID binding (§17.6).** All five operations are PACT messages and MAY carry an `authorization_proof` envelope. When present, the envelope's `verifier_id` MUST equal the receiving server's DID per §17.7 step 5. At the `Authorization-Required` tier (§17.9), cross-organisation calls to `_onboard` MUST carry a valid `authorization_proof`; the four other operations follow the same rule as their underlying-event counterparts (read endpoints inherit the surrounding read-access rules; `mark-read` is treated as a substantive event and MUST carry proof on cross-org calls). + +**Schema references.** Request and response schemas land in `spec/v2.0/schemas/`: `fabric-status.json`, `fabric-manifest.json`, `heartbeat-request.json`, `heartbeat-response.json`, `mark-read-request.json`, `mark-read-response.json`, `onboard-request.json`, `onboard-response.json`, and the shared `pending-obligation.json`. See Appendix A.2. + +#### 4.4.1 `GET /api/pact/{fabricId}/_status` — fabric snapshot + +Returns a whole-fabric snapshot intended for orchestration tooling, monitoring dashboards, and an agent's "where am I" probe at session start. + +**Request.** No body. Optional query parameters: + +| Param | Type | Default | Meaning | +|---|---|---|---| +| `include` | csv | `members,phase,latest_event,obligations,activity` | Subset of fields to include. Tools polling for liveness only can pass `include=activity,latest_event` to keep responses small. | + +**Response** (matches `fabric-status.json`): + +```json +{ + "fabric_id": "doc_xyz", + "spec_version": "2.0.3", + "phase": "negotiating", + "latest_event_id": "evt_5a2c", + "latest_sequence_number": 412, + "members": [ + { + "agent_id": "urn:pact:agent:k-1", + "agent_name": "Agent-Legal", + "principal_id": "did:web:knox.example", + "trust_level": "Collaborator", + "joined_at": "2026-05-15T18:02:11Z", + "last_seen": "2026-05-15T18:14:33Z", + "last_heartbeat_seq": 410, + "attention_required": false + } + ], + "pending_obligations": [ + { "id": "obl_001", "member_id": "urn:pact:agent:b-1", "kind": "vote", "due_by": "2026-05-15T18:20:00Z", "created_at": "2026-05-15T18:15:00Z", "event_ref": "evt_5a2c" } + ], + "open_proposals": 2, + "open_intents": 1, + "snapshot_at": "2026-05-15T18:14:40Z" +} +``` + +The `phase` enum is `forming | negotiating | converged | escalated | closed`. The `members` array is filtered by §17.13 cross-org disclosure rules (see §17.13 "Manifest visibility"); fields the caller is not entitled to see are omitted, not nulled. + +**Idempotency.** Pure read; no state change; no event emitted. Safe to retry. + +**Errors.** `auth.unauthorized` (401), `agent.not_joined` (403 — non-members get a 403, not a 404, unless the implementation prefers fabric-existence hiding), `document.not_found` (404), `rate.limited` (429). + +#### 4.4.2 `GET /api/pact/{fabricId}/manifest` — caller-scoped manifest + +Returns the **caller-scoped** view of the fabric — what *this caller* needs to know to act. Where `_status` is global ("here is the whole fabric"), `manifest` is local ("here is what concerns you"). + +**Request.** No body. The caller is identified by the credentials on the request (per §15.1 endpoints). Optional query parameter `as_of_event_id` returns the manifest as it would have appeared after the given event was processed (for replay debugging). + +**Response** (matches `fabric-manifest.json`): + +```json +{ + "fabric_id": "doc_xyz", + "spec_version": "2.0.3", + "caller": { + "agent_id": "urn:pact:agent:b-1", + "agent_name": "Agent-Finance", + "trust_level": "Collaborator", + "clearance_level": "Confidential", + "context_mode": "section-scoped", + "allowed_sections": ["sec:budget", "sec:risk"] + }, + "constraints_on_caller": [ + { "constraint_id": "con_42", "section_id": "sec:risk", "boundary": "Must not name specific instruments", "published_by_self": true } + ], + "pending_obligations": [ + { "id": "obl_001", "member_id": "urn:pact:agent:b-1", "kind": "vote", "event_ref": "evt_5a2c", "due_by": "2026-05-15T18:20:00Z", "created_at": "2026-05-15T18:15:00Z" } + ], + "counterparties": [ + { + "agent_id": "urn:pact:agent:k-1", + "agent_name": "Agent-Legal", + "principal_id": "did:web:knox.example", + "trust_level": "Collaborator", + "last_seen": "2026-05-15T18:14:33Z", + "disclosure_level": "summary" + } + ], + "unread_event_id_from": "evt_5a08", + "unread_event_id_to": "evt_5a2c", + "snapshot_at": "2026-05-15T18:14:40Z" +} +``` + +**Cross-org disclosure (§17.13).** The `counterparties[].principal_id`, `counterparties[].agent_name`, and any other PII fields are subject to the trust model of §17.13's "Manifest visibility" subsection. Counterparties whose disclosure level (per §10.3) is "Constraint" or "Category" are returned with `disclosure_level` set accordingly and PII fields elided. The manifest is NOT a privacy bypass: a caller receives only what they are entitled to see under the normal cross-org and clearance rules. + +**Idempotency.** Pure read; no state change; no event emitted. + +**Errors.** As §4.4.1. Additionally `auth.forbidden` (403) if the caller is a registered member but the implementation determines the manifest cannot be served (e.g. cleared-out-only mode during a major incident). + +#### 4.4.3 `POST /api/pact/{fabricId}/_heartbeat` — bidirectional liveness + +The agent declares it is still alive and aware of the fabric. The server records the heartbeat, updates the caller's `last_seen`, and returns the fabric's liveness view. + +**Request body** (matches `heartbeat-request.json`): + +```json +{ + "client_heartbeat_id": "uuid (caller-chosen; echoed back; idempotency key)", + "attention_required": false, + "client_observed_event_id": "evt_5a2c" +} +``` + +| Field | Required | Description | +|---|---|---| +| `client_heartbeat_id` | Yes | UUID chosen by the caller. The server treats `(member_id, client_heartbeat_id)` as the idempotency key — a duplicate POST within the implementation's idempotency window MUST return the cached prior response and MUST NOT emit a second event. The window SHOULD be at least 60 seconds. | +| `attention_required` | No (default `false`) | When `true`, the caller is signalling "I am actively present and want my counterparty to know it" (e.g. about to send a substantive message). Server MAY emit a `pact.agent.attention-required` event so counterparties can prioritise. | +| `client_observed_event_id` | No | The latest event the caller has processed locally. Implementations MAY use this to surface drift (the caller is behind the server) in the response. | + +**Response body** (matches `heartbeat-response.json`): + +```json +{ + "fabric_id": "doc_xyz", + "client_heartbeat_id": "...", + "server_received_at": "2026-05-15T18:14:33Z", + "latest_event_id": "evt_5a2c", + "latest_sequence_number": 412, + "caller_last_seen": "2026-05-15T18:14:33Z", + "members_liveness": [ + { "agent_id": "urn:pact:agent:k-1", "last_seen": "2026-05-15T18:14:00Z", "attention_required": false } + ], + "pending_obligation_count": 1 +} +``` + +**Verifier ID binding.** When a heartbeat carries an `authorization_proof` (rare; usually heartbeats are bearer-authenticated), the envelope's `verifier_id` MUST equal the server's DID (§17.7). + +**Idempotency.** Idempotent over `(member_id, client_heartbeat_id)`. Duplicate within window → cached response, no event. + +**Event emitted.** `pact.agent.heartbeat-received` on the first POST of a given `(member_id, client_heartbeat_id)`. Additionally `pact.agent.attention-required` when `attention_required: true`. + +**Errors.** `auth.unauthorized`, `agent.not_joined`, `rate.limited` (heartbeat is a common rate-limit target — implementations SHOULD set a generous floor, e.g. 1 Hz per member, and document the rate in their Implementation Profile). + +#### 4.4.4 `POST /api/pact/{fabricId}/mark-read` — acknowledge event range + +The caller acknowledges that it has received and processed events in the range `[from_event_id, to_event_id]`. Counterparties polling `_status` or `manifest` can see "Agent B has seen up to event evt_5a2c," which is stronger than "the event was delivered to Agent B's transport." + +**Request body** (matches `mark-read-request.json`): + +```json +{ + "from_event_id": "evt_5a08", + "to_event_id": "evt_5a2c", + "from_sequence_number": 400, + "to_sequence_number": 412 +} +``` + +Either the `_event_id` pair OR the `_sequence_number` pair is sufficient; if both are provided they MUST be consistent (the server MAY reject otherwise). The range is inclusive on both ends. + +**Response body** (matches `mark-read-response.json`): + +```json +{ + "fabric_id": "doc_xyz", + "caller_member_id": "urn:pact:agent:b-1", + "marked_from_sequence_number": 400, + "marked_to_sequence_number": 412, + "acknowledged_at": "2026-05-15T18:14:33Z", + "event_id": "evt_ack_001" +} +``` + +**Idempotency.** Re-posting the same range is a no-op — the server returns the original `event_id`. Posting a partially-overlapping range advances the caller's read cursor to the new high-water mark and MUST emit only one `pact.agent.mark-read` event (with the union of the prior cursor and the new range). + +**Event emitted.** `pact.agent.mark-read`. The event's `payloadJson` carries `{ caller_member_id, marked_from_sequence_number, marked_to_sequence_number }`. Like all v2.0.2+ events, it is chained via `prev_hash` per §6.4. + +**Errors.** `auth.unauthorized`, `agent.not_joined`, `mark-read.invalid_range` (the range is malformed or references events outside the fabric — implementations SHOULD define this code under their custom namespace per Appendix A.1). + +#### 4.4.5 `POST /api/pact/{fabricId}/_onboard` — atomic join + constrain + +The atomic onboarding operation. Bundles the v2.0.2 `agent.join` call with an explicit constraints declaration into a single transaction. **Either both commit, or neither.** Constraint rejection rolls back any partial join state — no half-joined member exists at any observable point. This is the operation that establishes the negotiation envelope *before* any substantive message; see §15.6 for the recommended onboarding flow and why it beats join-then-constrain. + +**Request body** (matches `onboard-request.json`): + +```json +{ + "agentName": "Agent-Finance", + "role": "reviewer", + "contextMode": "section-scoped", + "protocolVersion": "2.0", + "orgId": "bridget-co", + "constraints": [ + { "sectionId": "sec:risk", "boundary": "Must not name specific instruments", "category": "regulatory" }, + { "sectionId": "sec:budget", "boundary": "Must not commit beyond Q3 forecast", "category": "commercial" } + ], + "invite_token": "...", + "authorization_proof": { "...": "§17.6 envelope" } +} +``` + +The request is the union of `join-request.json` (or `join-token-request.json` for BYOK) and one or more `constraint-request.json` items. All fields of `join-request.json` carry their existing semantics. `constraints` is an array (may be empty for "join only, no constraints declared up front" — the operation is still atomic, just trivially so). + +**Atomicity contract (normative).** The server MUST treat the request as a single transaction: + +1. Validate the join half (token, identity, capacity). +2. Validate each constraint item against the resource's constraint-acceptance rules (e.g. section exists, boundary is non-empty, caller's clearance permits publishing a constraint on this section). +3. If **any** validation step fails, the server MUST NOT create a registration, MUST NOT publish any constraint, and MUST NOT emit any event. The response is a single `onboard-response` with the failing reason. +4. If all validations pass, the server creates the registration, publishes all constraints, and emits **one** `pact.fabric.onboarded` event whose `payloadJson` references both the new `registrationId` and the `constraintIds` of the published constraints. The individual `pact.constraint.published` events for the bundled constraints MUST be emitted in the same transaction and MUST carry a `correlationId` linking them to the `pact.fabric.onboarded` event. +5. `pact.fabric.onboarded` replaces the bare `pact.agent.joined` event when the onboarding path is taken: implementations MUST NOT emit both `pact.agent.joined` and `pact.fabric.onboarded` for the same registration. Implementations that internally trigger the join logic from `_onboard` MUST suppress the `pact.agent.joined` event in favour of `pact.fabric.onboarded`. + +**Response body** (matches `onboard-response.json`) — success case: + +```json +{ + "status": "onboarded", + "fabric_id": "doc_xyz", + "registration": { "...": "join-response.json shape" }, + "constraints": [ + { "constraint_id": "con_42", "sectionId": "sec:risk", "boundary": "Must not name specific instruments" } + ], + "onboarded_event_id": "evt_onb_001", + "onboarded_at": "2026-05-15T18:02:11Z" +} +``` + +Rejection case: + +```json +{ + "status": "rejected", + "rejection_reason": "constraint.incompatible", + "rejected_constraint_index": 1, + "errors": [ + { "code": "constraint.incompatible", "description": "Constraint on sec:budget conflicts with existing fabric constraint con_18.", "metadata": { "conflicting_constraint_id": "con_18" } } + ] +} +``` + +**Verifier ID binding.** Cross-organisation `_onboard` calls at the `Authorization-Required` tier MUST carry a valid `authorization_proof`. The envelope's `verifier_id` MUST equal the server's DID per §17.7 step 5. Inside-org and Core-tier calls follow the existing §17.9 rules. + +**Idempotency.** Onboarding is idempotent over the v2.0.2 invite-token / BYOK mechanism it inherits: a duplicate POST with the same single-use token returns the cached registration. A retry that arrives after a previous `_onboard` failed (no registration created) MUST be allowed to succeed — failure does not consume the token. + +**Event emitted.** `pact.fabric.onboarded` (one event per successful onboard, even with N bundled constraints). The constraint publications themselves emit their normal `pact.constraint.published` events, each carrying `correlationId = onboarded_event_id`. + +**Errors.** `auth.unauthorized`, `agent.already_joined` (409 — onboarding does not replace existing membership; if the caller is already joined and wants to add constraints, they SHOULD use the regular `POST /constraints` endpoint), `constraint.incompatible`, `constraint.invalid`, `invite_token.invalid` / `invite_token.consumed`. + +### 4.5 Merge Operations (Server-Side) + +These are triggered automatically by the server, not directly by agents: + +| Operation | Description | Trigger | +|---|---|---| +| `proposal.merge` | Apply a proposal to the canonical document | Sufficient approvals per policy | +| `conflict.detected` | Two proposals target the same section | Server detects overlap | +| `conflict.resolved` | Conflict resolved (by policy, agent vote, or human) | Resolution action taken | + +--- + +## 5. Proposal Lifecycle + +``` + ┌──────────┐ + create │ │ withdraw + ────────────────► │ PENDING │ ──────────────► WITHDRAWN + │ │ + └────┬─────┘ + │ + ┌──────────┼──────────┬──────────┐ + │ │ │ │ + ▼ ▼ ▼ ▼ + ┌────────┐ ┌────────┐ ┌────────┐ ┌────────┐ + │APPROVED│ │REJECTED│ │CONFLICT│ │OBJECTED│ + └───┬────┘ └────────┘ └───┬────┘ └────────┘ + │ │ │ + ▼ ▼ ▼ + ┌────────┐ ┌──────────┐ Renegotiate + │ MERGED │ │ RESOLVED │ or escalate + └────────┘ └──────────┘ + ▲ + │ + TTL expires, no + objections (auto) +``` + +### Approval Policy + +The number of approvals required before auto-merge is configurable per document: + +| Policy | Description | +|---|---| +| `auto` | Merge immediately on creation (for Autonomous trust agents) | +| `single` | One approval from a Collaborator+ agent | +| `majority` | >50% of registered agents approve | +| `unanimous` | All registered agents approve | +| `human-only` | Only a human can approve (agents can only propose) | +| `objection-based` | Auto-merge after TTL unless an agent objects (silence = consent) | +| `cell-quorum` | **(v2.3-draft)** At least one counted approval from **each** cell in the fabric's fixed participant cell set (§15.4). Approvals are bucketed by `cell_id`, not head-count. | + +#### Self-approval + +By default, the agent that authored a proposal **cannot** count as an approver of it: under `single` / `majority` / `unanimous`, an approval is only counted if the approving agent's `agentId` differs from the proposal author's. This is to keep "one operator running many agents on the same resource" from rubber-stamping its own work. + +Resources MAY set a per-resource boolean **`allowSelfApproval`** (default `false`). When `true`, an author's approval of their own proposal counts normally — appropriate for low-stakes resources, or where the operator deliberately wants self-approval and accepts the reduced check. + +**Recommendation for multi-agent-under-one-operator deployments** (the common cloud / managed-service case — see issue [#13](https://github.com/TailorAU/pact/issues/13) Q1): rather than flipping `allowSelfApproval`, use the **`objection-based`** policy. It has no approval step at all — proposals auto-merge after TTL unless an agent objects — so the self-approval question simply does not arise. For new agent-to-agent flows that aren't long-lived collaborations, ephemeral **Sessions** (§19–20, when finalised) sidestep it entirely (Sessions have no merge/approve step; outcomes are reported back to each handler). *(v2.3-draft: this recommendation applies to single-cell fabrics only — `objection-based` MUST NOT be configured on a multi-cell fabric; see below.)* + +#### Multi-cell fabrics: `cell-quorum` and policy restrictions (v2.3-draft) + +On a fabric whose participant cell set (§15.4) has cardinality > 1, approval is a question of isolation boundaries, not head-count: agent-count policies let the agents of one cell speak for another cell, and silence policies write foreign replica content into a sealed cell without any approval from it. v2.3 therefore adds the `cell-quorum` policy and restricts the others: + +**`cell-quorum` semantics.** + +- A proposal is APPROVED when it has **at least one counted approval from each cell** in the fabric's fixed participant cell set. Approvals are bucketed by the approving membership's `cell_id`; the number of agents per cell is irrelevant. +- The **self-approval rule above applies within each cell**: the proposal author's own approval does not count toward its cell's bucket unless the resource sets `allowSelfApproval: true`. Other members of the author's cell can satisfy that cell's bucket. +- **A dark cell does not shrink the set (fail closed).** The participant cell set is the one fixed at establish. If a cell has no live members — heartbeats lapsed, memberships left, or the cell is unreachable — its bucket cannot be satisfied and the proposal MUST NOT merge. Implementations MUST NOT recompute the quorum denominator from liveness, `last_seen`, or membership counts. +- **`human-only` on a multi-cell fabric is evaluated as `human-only` AND `cell-quorum`**: each cell's bucket must contain at least one counted *human* approval. `human-only` approvals from a single cell do not merge a multi-cell proposal. +- **Human approvals bucket by membership.** Humans are principals, not fabric memberships, and carry no `cell_id` of their own. A human approval counts toward the bucket of the **membership through which it is recorded**: the `cell_id` of the fabric membership (agent registration or human seat) that carries the approval into the fabric. A human principal whose agents hold memberships in more than one cell satisfies only the bucket of the membership actually used to approve. A human approval recorded through no membership at all (e.g. a host-side administrative action) does not satisfy any cell's bucket. + +**Policy restrictions (MUST).** + +- When the participant cell set has cardinality > 1, implementations **MUST NOT** configure `objection-based`, `auto`, `single`, `majority`, or `unanimous` as the approval policy. The permitted policies are `cell-quorum` and `human-only` (evaluated as above). +- A fabric establish that combines a multi-cell participant set with a prohibited policy **MUST be refused**. A policy change that would introduce a prohibited policy on a multi-cell fabric **MUST be refused**. In both cases the implementation fails closed: no fabric, no policy change, no merge — never a silent downgrade to a permitted policy. +- Note `unanimous` is banned too: all-agents unanimity is still an agent-count rule (a cell running many agents outweighs nothing, but a compromised or misconfigured cell's agents are indistinguishable from consensus). `cell-quorum` is the multi-cell analogue of unanimity at the correct granularity. +- **Enforcement binds to behaviour, not advertisement.** The rules in this section are unconditional on any fabric whose participant cell set has cardinality > 1. The `capabilities.cellQuorum` flag (§15.1) *advertises* support; it is not an opt-in switch, and not advertising it exempts nothing. A server that does not implement the multi-cell rules MUST NOT accept a multi-cell establish, a `cell_id`-bearing `join` / `_onboard`, or any membership that would make a fabric multi-cell — it rejects them (fail closed) rather than accepting the memberships and evaluating a banned policy over them. + +The consensus/legal-execution boundary work on the v2.1 line (§25, [#41](https://github.com/TailorAU/pact/issues/41) / PR [#45](https://github.com/TailorAU/pact/pull/45)) already establishes that consensus — silence included — is not a legal signature. This section is complementary and orthogonal: it stops the *draft merge itself* from crossing an isolation boundary without every cell's approval. Sovereignty, not signature law. + +### Conflict Detection + +A conflict is detected when: +- Two pending proposals target the same `sectionId` +- A proposal targets a section that has been modified since the proposal was created (stale base) + +Conflict resolution strategies (configurable): +- `first-wins` — earliest proposal by timestamp wins +- `vote` — agents vote on competing proposals +- `human-escalate` — always escalate conflicts to human +- `merge-both` — attempt to merge both changes (LLM-assisted) + +#### Multi-cell conflicts (v2.3-draft) + +When the fabric's participant cell set (§15.4) has cardinality > 1, a contested clause must not resolve by timestamp or by model merge — `first-wins` silently prefers whichever replica's clock spoke first, and LLM-assisted `merge-both` silently prefers one cell's content across an isolation boundary. Therefore: + +- The conflict strategy **MUST** be `human-escalate`. +- `first-wins` and `merge-both` **MUST NOT** be configured. As with the §5 approval-policy bans, a fabric establish or strategy change that would violate this MUST be refused (fail closed, never a silent downgrade). +- `vote` is **not** a substitute unless it is itself cell-quorum — the vote is bucketed by `cell_id` per the §5 `cell-quorum` rules (every cell's bucket satisfied, self-approval rule inside each cell) — **and** it still escalates to a human whenever any cell in the fixed participant set is dark. A `vote` configuration meeting both conditions is a permitted refinement of `human-escalate`, not an alternative to it. + +--- + +## 6. Event Schema + +### 6.1 Event Structure + +Every PACT operation produces an event. Implementations MUST store events with at least these fields: + +| Field | Type | Description | +|---|---|---| +| `id` | UUID | Unique event identifier | +| `epochMs` | int64 | Unix timestamp in milliseconds | +| `actorId` | string? | Actor identifier (user or agent) | +| `actorDisplay` | string | Human-readable actor name | +| `actorKind` | enum | `Individual`, `AiAgent`, `GovernanceGroup`, `System` | +| `eventType` | string | Dot-delimited event type (e.g., `pact.proposal.created`) | +| `entityType` | string | `pact-document` | +| `entityId` | UUID | Document identifier | +| `correlationId` | UUID? | Links related events (e.g., create → approve → merge) | +| `inResponseTo` | UUID? | Direct reply chain | +| `sequenceNumber` | int64 | Per-document monotonic counter | +| `sectionId` | string? | Target section (nullable, max 256 chars) | +| `payloadJson` | string | JSON payload with operation-specific data | + +### 6.2 Event Types + +``` +pact.agent.joined // Agent registered on document +pact.agent.left // Agent unregistered +pact.proposal.created // Edit proposal submitted +pact.proposal.approved // Proposal approved by agent/human +pact.proposal.rejected // Proposal rejected with reason +pact.proposal.withdrawn // Proposal withdrawn by author +pact.proposal.merged // Proposal applied to document (System actor) +pact.proposal.conflict // Conflict detected (System actor) +pact.proposal.conflict-resolved // Conflict resolved +pact.proposal.objected // Agent objected to a proposal +pact.proposal.auto-merged // Proposal auto-merged after TTL with no objections +pact.section.locked // Section locked by agent +pact.section.unlocked // Section released +pact.comment.added // Agent comment on section +pact.comment.resolved // Comment marked resolved +pact.escalation.human // Escalated to human review +pact.document.snapshot // Periodic content snapshot for replay +pact.intent.declared // Agent declared intent on a section +pact.intent.accepted // Intent accepted (no objections within TTL) +pact.intent.objected // Agent objected to an intent +pact.constraint.published // Agent published a constraint on a section +pact.constraint.withdrawn // Agent withdrew a constraint +pact.salience.set // Agent set salience score for a section + +// v2.0.3 — Fabric onboarding & session awareness (§4.4, §6.5) +pact.fabric.onboarded // Atomic join+constrain completed via POST /_onboard (§4.4.5); replaces pact.agent.joined on that path +pact.agent.heartbeat-received // Bidirectional heartbeat recorded; updates the member's last_seen in the manifest (§4.4.3) +pact.agent.attention-required // Agent flagged attention_required=true on a heartbeat — counterparties SHOULD prioritise (§4.4.3) +pact.agent.mark-read // Agent acknowledged a closed event range (§4.4.4) +pact.obligation.created // Server registered a pending obligation against a member (§6.5) +pact.obligation.discharged // The targeted member fulfilled (or the obligation otherwise resolved) — see §6.5 for the resolution kinds +``` + +### 6.3 Event-log retention + +The event log is the source of truth for collaboration state (Design Principle 5); the resource is a projection. Removing events breaks the consistency guarantee, so PACT does not define an "events can be deleted on request" mechanism. Instead it defines a **declared retention policy**. + +**Requirements.** + +- An implementation MUST publish its event-log retention policy in its `/.well-known/pact.json` implementation profile (§15), as a `retentionPolicy` object: `{ "minimumDays": , "indefinite": , "tombstoneAfter": }`. `indefinite: true` means "retained for as long as the resource exists"; `minimumDays` gives a floor when `indefinite` is `false`. +- The spec sets a **RECOMMENDED minimum of 365 days** for resources that carry authorization-relevant content (anything where `authorization_proof` may appear). Regulated domains will commonly need longer (financial: 7 years; clinical: longer still); implementations SHOULD honour the longer of the spec recommendation and any applicable regulatory requirement. +- Personal-data handling within retained events is governed by §17.10. Events SHOULD carry only the `principal_id` (a rotatable / revocable DID) and a salted hash of any proof payload, NOT raw biometric or PII data. Whether the cryptographic-erasure lever in §17.8 (registry tombstone) is sufficient to satisfy a specific jurisdiction's right of erasure for the event log itself is a per-deployment legal question — see §17.10's legal-evaluation requirement. +- An implementation that retains events for less than the recommended minimum SHOULD prominently surface this in its profile (`retentionPolicy.minimumDays < 365` is visible to clients and conformance verifiers). + +**Audit-trail consequence.** Cross-org disputes, regulatory audits, and post-incident forensics all depend on the event log. An aggressively short retention policy makes the implementation cheaper to run and cheaper to attack — both of those consequences are the operator's call, but they MUST be a declared call, not an undocumented one. + +### 6.4 Event-log integrity (hash-chained + signed root) + +Retention (§6.3) tells you how long the event log is kept. Integrity tells you whether you can trust that what's kept is what actually happened. v2.0.2 adds a normative integrity requirement that closes the silent-tampering attack: a compromised server can no longer rewrite or delete past events without that mutation being externally detectable. + +**Per-event chaining (REQUIRED at Extended and Authorization-Required; RECOMMENDED at Core).** Every event in the operation log MUST carry an additional `prev_hash` field — a base64url-encoded SHA-256 hash of the *canonical JSON encoding* (RFC 8785) of the immediately preceding event in the same resource's log. The first event (sequenceNumber 0 or 1) uses the literal string `"GENESIS"` as its `prev_hash`. Implementations MUST reject any incoming event whose `prev_hash` does not match the recomputed hash of the prior event. + +```json +{ + "id": "evt_abc123", + "epochMs": 1747276800000, + "sequenceNumber": 42, + "eventType": "pact.proposal.merged", + "entityType": "pact-document", + "entityId": "doc_xyz", + "payloadJson": "...", + "prev_hash": "base64url-sha256(canonical(event 41))" +} +``` + +**Daily signed root (REQUIRED at Extended and Authorization-Required).** Every 24 hours (or on operator-defined cadence — at minimum once daily), the server MUST emit a `pact.log.root` system event: + +```json +{ + "eventType": "pact.log.root", + "epochMs": ..., + "payloadJson": "{\"resource_id\":\"doc_xyz\",\"window_start_seq\":1,\"window_end_seq\":42,\"window_end_hash\":\"base64url-...\",\"signature\":\"base64url-...\",\"signing_key\":\"did:web:server.example#log-signing\"}" +} +``` + +The `signature` is an Ed25519 (or whitelisted alg per §17.6) signature over the canonical encoding of `{resource_id, window_start_seq, window_end_seq, window_end_hash}`. The `signing_key` is advertised in the Implementation Profile (§15.1) as `endpoints.logSigningKey`. The signed root commits the server to "this is what the chain looks like as of this moment"; any later mutation that doesn't replay through new chained events will produce a `prev_hash` mismatch detectable by any consumer with the prior root. + +**External transparency anchor (RECOMMENDED at Authorization-Required).** A server claiming `Authorization-Required` SHOULD periodically publish its signed roots to an external append-only log (Certificate Transparency, a public Git-signed-tag repository, a Tor onion-service mirror, etc.). The protocol does not pin a specific anchor mechanism; the requirement is that an external party SHOULD be able to compare the server's claimed history against a copy the server cannot retroactively edit. + +**Verification.** A consumer polling for events SHOULD validate each event's `prev_hash` against the prior event's recomputed hash. Implementations MAY cache by `window_end_hash` and only re-validate on each new signed root. A consumer that detects a hash-chain break MUST treat the server as compromised and stop accepting new events from it until reconciled. + +**Migration from v2.0 / v2.0.1.** Events emitted before v2.0.2's chaining requirement land in the log without `prev_hash`. Implementations upgrading SHOULD treat the first v2.0.2 event as `prev_hash: "GENESIS-v202"` to mark the transition, and SHOULD emit a `pact.log.root` over the prior history at upgrade time so the legacy events are committed to a signed window even if they're not individually chained. + +### 6.5 Pending Obligations (v2.0.3+) + +Through v2.0.2, "what does the protocol expect from each member next?" was an inferential question — clients had to scan open proposals, intent TTLs, escalation states, and salience thresholds to compute it. v2.0.3 lifts this into a first-class concept: a **pending obligation** is a thing the protocol expects a specific member to do next. Each obligation is registered as an event in the log and surfaced through §4.4 `_status` and `manifest`. + +**Shape** (matches `pending-obligation.json`): + +```json +{ + "id": "obl_001", + "fabric_id": "doc_xyz", + "member_id": "urn:pact:agent:b-1", + "kind": "vote", + "event_ref": "evt_5a2c", + "created_at": "2026-05-15T18:15:00Z", + "due_by": "2026-05-15T18:20:00Z", + "discharged_at": null, + "discharge_kind": null, + "discharge_event_ref": null +} +``` + +| Field | Type | Required | Description | +|---|---|---|---| +| `id` | string | Yes | Obligation identifier, server-minted. Stable across the obligation's lifecycle. | +| `fabric_id` | string | Yes | The fabric / resource the obligation belongs to. | +| `member_id` | string | Yes | The `agentId` (or registration ID, per implementation convention) expected to act. Exactly one member per obligation; multi-member expectations are modelled as N obligations. | +| `kind` | enum | Yes | `vote` \| `respond` \| `sign` \| `ack` — what kind of action discharges the obligation. See below. | +| `event_ref` | string | Yes | The event that **created** the obligation (e.g. the `pact.proposal.created` event for which a vote is owed). The event is the obligation's source-of-truth context. | +| `created_at` | ISO 8601 | Yes | When the obligation was registered. | +| `due_by` | ISO 8601 | No | Optional deadline. After this, the obligation MAY be flagged in the manifest as `overdue: true` (the implementation chooses whether to auto-escalate). Absent → no implicit deadline. | +| `discharged_at` | ISO 8601 | No | When the obligation was resolved. `null` while pending. | +| `discharge_kind` | enum | No | How it was discharged: `fulfilled` (the member acted), `superseded` (a later event made it moot — e.g. the proposal was withdrawn), `timed_out` (the `due_by` passed and the implementation auto-resolved), `escalated` (the obligation was rolled up into a `pact.escalation.human` event). | +| `discharge_event_ref` | string | No | The event that discharged the obligation. | + +**Obligation kinds.** + +| Kind | Created when | Discharged when | +|---|---|---| +| `vote` | A proposal targets a section the member has non-zero salience on (or the approval policy requires the member's vote). | The member emits `proposal.approve`, `proposal.reject`, or `proposal.object`. Also discharged when the proposal is withdrawn (`discharge_kind = "superseded"`). | +| `respond` | A `query.submit` (§13.5.2) or `escalate.human` targets this member. | The member emits the matching response event. | +| `sign` | A commit / merge requires the member's signature (e.g. an `authorization_proof`-bearing merge). | The member's signature lands in the event log. | +| `ack` | A counterparty's substantive event (proposal, escalation, mediated message) was delivered to this member and the implementation requires acknowledgement before further state advances. | The member calls `POST /mark-read` covering the source event, or otherwise acts on it. | + +**Event semantics.** Two new event types: + +- **`pact.obligation.created`** — emitted whenever the server registers a new pending obligation. The event's `payloadJson` carries the full `pending-obligation.json` shape with `discharged_at = null`. `correlationId` MUST point to the creating event (the proposal, query, escalation, etc.) so a consumer can trace `creating event → obligation → discharge event` as a single chain. The `actorKind` for this event is `System`. +- **`pact.obligation.discharged`** — emitted when an obligation resolves. The event's `payloadJson` carries the obligation's final state with `discharged_at`, `discharge_kind`, and `discharge_event_ref` populated. `correlationId` MUST equal the `pact.obligation.created` event's `id`. `inResponseTo` MAY point to the discharging event for ergonomics. + +**Surfacing in operations.** Both `GET /_status` and `GET /manifest` (§4.4) include a `pending_obligations` array using the `pending-obligation.json` shape. `_status` includes all pending obligations across the fabric (filtered by cross-org disclosure — a counterparty's obligation visibility follows §17.13 rules); `manifest` includes only the caller's own pending obligations plus a `counterparties[].pending_obligation_count` summary. + +**Conformance.** Pending obligations are RECOMMENDED at Core and SHOULD be emitted at Extended and Authorization-Required. An implementation that does not emit obligation events MUST omit the `pending_obligations` field from `_status` and `manifest` rather than returning a stub empty array (so consumers can detect non-support). + +**Backward compatibility.** v2.0.2 clients that ignore the new event types and the manifest endpoint see no behavioural change — the existing proposal / intent / escalation lifecycle continues to work without obligation events, and the inferential model of v2.0.2 remains valid. + +--- + +## 7. API Surface + +### 7.1 REST Endpoints + +All implementations MUST expose these endpoints (or equivalent): + +``` +POST /api/pact/{documentId}/join // Agent joins document +DELETE /api/pact/{documentId}/leave // Agent leaves document +GET /api/pact/{documentId}/content // Get canonical Markdown +GET /api/pact/{documentId}/sections // Get section tree +GET /api/pact/{documentId}/agents // List active agents +POST /api/pact/{documentId}/proposals // Create proposal +GET /api/pact/{documentId}/proposals // List active proposals +POST /api/pact/{documentId}/proposals/{id}/approve // Approve proposal +POST /api/pact/{documentId}/proposals/{id}/reject // Reject proposal +DELETE /api/pact/{documentId}/proposals/{id} // Withdraw proposal +POST /api/pact/{documentId}/sections/{sectionId}/lock // Lock section +DELETE /api/pact/{documentId}/sections/{sectionId}/lock // Unlock section +POST /api/pact/{documentId}/comments // Add comment +POST /api/pact/{documentId}/escalate // Escalate to human +GET /api/pact/{documentId}/events // Event history (paginated) +POST /api/pact/{documentId}/intents // Declare intent on a section +GET /api/pact/{documentId}/intents // List intents +POST /api/pact/{documentId}/intents/{id}/object // Object to an intent +POST /api/pact/{documentId}/constraints // Publish a constraint +GET /api/pact/{documentId}/constraints // List constraints +DELETE /api/pact/{documentId}/constraints/{id} // Withdraw a constraint +POST /api/pact/{documentId}/salience // Set salience score +GET /api/pact/{documentId}/salience // Get salience heat map +POST /api/pact/{documentId}/proposals/{id}/object // Object to a proposal + +// Fabric onboarding & session awareness (v2.0.3 — §4.4) +GET /api/pact/{documentId}/_status // Whole-fabric snapshot +GET /api/pact/{documentId}/manifest // Caller-scoped view (constraints, obligations, counterparties) +POST /api/pact/{documentId}/_heartbeat // Bidirectional liveness + attention flag +POST /api/pact/{documentId}/mark-read // Acknowledge an event range +POST /api/pact/{documentId}/_onboard // Atomic join + constraint declaration + +// PACT Live Endpoints (v0.3) +GET /api/pact/{documentId}/poll // Poll events with cursor-based pagination +POST /api/pact/{documentId}/ask-human // Submit question to human custodian +GET /api/pact/{documentId}/human-responses // List human queries/responses +POST /api/pact/{documentId}/human-responses/{queryId}/respond // Respond to human query +POST /api/pact/{documentId}/done // Declare agent completion +GET /api/pact/{documentId}/completions // List agent completions +POST /api/pact/{documentId}/resolve // Submit human resolution +GET /api/pact/{documentId}/escalation-briefing/{escalationId} // Get escalation constraint briefing +POST /api/pact/{documentId}/pre-validate // Preview resolution against constraints +GET /api/pact/{documentId}/cascade-status // Get cascade validation status +POST /api/pact/{documentId}/cascade-validate // Submit cascade validation result +``` + +### 7.2 Real-Time Events + +Implementations SHOULD provide a real-time event channel (WebSocket, SignalR, SSE, or equivalent) with per-document subscription: + +``` +// Server → Client events +OnProposalCreated(documentId, proposalId, agentId, sectionId, summary) +OnProposalApproved(documentId, proposalId, approverId) +OnProposalRejected(documentId, proposalId, rejecterId, reason) +OnProposalMerged(documentId, proposalId, newVersion) +OnConflictDetected(documentId, conflictId, proposalIds[]) +OnSectionLocked(documentId, sectionId, agentId, expiresAt) +OnSectionUnlocked(documentId, sectionId) +OnEscalation(documentId, sectionId, agentId, message) +OnDocumentUpdated(documentId, newVersion, changedSections[]) +OnIntentDeclared(documentId, intentId, agentId, sectionId, goal) +OnIntentObjected(documentId, intentId, objecterId, reason) +OnConstraintPublished(documentId, constraintId, agentId, sectionId, boundary) +OnSalienceChanged(documentId, agentId, sectionId, score) +OnProposalObjected(documentId, proposalId, objecterId, reason) +OnAutoMergeScheduled(documentId, proposalId, mergeAt) + +// PACT Live events (v0.3) +OnHumanAsked(documentId, queryId, agentId, agentName, question, sectionId, timeoutAt) +OnHumanResponded(documentId, queryId, responderId, agentId, agentName) +OnAgentCompleted(documentId, completionId, agentId, agentName, status, summary) +OnHumanResolved(documentId, resolutionId, sectionId, decision, isOverride) +OnCascadeValidated(documentId, resolutionId, agentRegistrationId, result, cascadeStatus) + +// Fabric onboarding & session awareness events (v2.0.3 §4.4 / §6.5) +OnFabricOnboarded(documentId, principalId, agentId, role, correlationId) +OnHeartbeatReceived(documentId, principalId, agentId, attentionRequired, observedAt) +OnAttentionRequired(documentId, principalId, agentId, reason, observedAt) +OnMarkRead(documentId, principalId, fromEventId, toEventId, observedAt) +OnObligationCreated(documentId, obligationId, memberId, kind, eventRef, dueBy) +OnObligationDischarged(documentId, obligationId, memberId, kind, dischargedAt) +``` + +### 7.3 MCP Tools + +Implementations MAY expose PACT operations as MCP (Model Context Protocol) tools for LLM-native integration: + +```json +{ + "tools": [ + { "name": "pact_join", "description": "Register as a PACT agent on a document" }, + { "name": "pact_leave", "description": "Unregister from a document" }, + { "name": "pact_agents", "description": "List active agents on a document" }, + { "name": "pact_done", "description": "Signal agent completion" }, + { "name": "pact_get", "description": "Get document content as Markdown" }, + { "name": "pact_sections", "description": "Get document section tree" }, + { "name": "pact_propose", "description": "Propose an edit to a document section" }, + { "name": "pact_proposals", "description": "List proposals (filter by section/status)" }, + { "name": "pact_approve", "description": "Approve a pending proposal" }, + { "name": "pact_reject", "description": "Reject a pending proposal" }, + { "name": "pact_object", "description": "Object to a pending proposal (soft dissent)" }, + { "name": "pact_escalate", "description": "Escalate to human review" }, + { "name": "pact_ask_human", "description": "Ask a question requiring human judgement" }, + { "name": "pact_intent_declare", "description": "Declare an intent (goal) on a section" }, + { "name": "pact_intents", "description": "List intents on a document" }, + { "name": "pact_constraint_publish", "description": "Publish a boundary constraint on a section" }, + { "name": "pact_constraints", "description": "List constraints on a document" }, + { "name": "pact_salience_set", "description": "Set salience score (0-10) for a section" }, + { "name": "pact_salience_map", "description": "Get salience heat map for a document" }, + { "name": "pact_poll", "description": "Poll for events since a cursor" }, + { "name": "pact_lock", "description": "Lock a section for editing" }, + { "name": "pact_unlock", "description": "Unlock a section" }, + + // Fabric onboarding & session awareness (v2.0.3 §4.4 / §15.6) + { "name": "pact_onboard", "description": "Atomically onboard into a fabric — declare constraints up-front; either admitted with constraints recorded or rejected with no membership created" }, + { "name": "pact_status", "description": "Snapshot a fabric (phase, members, latest event id, pending obligations) — or, if no id given, return a local-state summary of every fabric this agent is in" }, + { "name": "pact_manifest", "description": "Fetch the caller-scoped active-session manifest for a fabric and cache it for pact_session_announce" }, + { "name": "pact_transcript", "description": "Fetch the event log for a fabric since an optional event id; optionally ack the printed range via /mark-read" }, + { "name": "pact_heartbeat", "description": "Fire a one-shot heartbeat to signal liveness on a fabric; optionally mark attention_required" }, + { "name": "pact_mark_read", "description": "Acknowledge a transcript event range on the server (standalone alternative to the pact_transcript mark_read flag)" }, + { "name": "pact_session_announce", "description": "COGNITIVE-LAYER HOOK — returns a 'you are in N fabrics with M obligations' payload for the calling LLM to prepend to its working context. Offline by default; use refresh_manifests=true for live data" } + ] +} +``` + +--- + +## 8. Multi-Format Document Support + +### 8.1 Supported Formats + +PACT supports multiple document formats. The server parses each format into a unified section tree with stable `sectionId` values: + +| Format | MIME Type | Section Parser | Storage | +|--------|-----------|----------------|---------| +| Markdown | `text/markdown` | ATX headings (`#`, `##`) | Raw `.md` file | +| HTML | `text/html` | `

`–`

` tags | Raw `.html` file | +| DOCX | `application/vnd.openxmlformats-officedocument.wordprocessingml.document` | Word heading styles (Heading1–6) | Binary `.docx` + text projection | +| PDF | `application/pdf` | Via DOCX conversion | Binary `.pdf` + text projection | + +All formats produce the same `sec:slug/child-slug` section IDs. Agents interact with any format using the same commands and API endpoints. + +### 8.2 Section Parsing Rules + +``` +# Heading 1 → Level 1 section +## Heading 2 → Level 2 section (child of nearest L1) +### Heading 3 → Level 3 section (child of nearest L2) + +Content between headings belongs to the section above it. +Content before the first heading belongs to sec:_root. + +--- → Horizontal rules are visual only, not section boundaries +> Blockquotes → Part of the enclosing section +- List items → Part of the enclosing section +``` + +### 8.3 Proposal Diff Format + +When an agent proposes an edit, the proposal contains: + +```json +{ + "sectionId": "sec:budget/line-items", + "baseVersion": 47, + "newContent": "## Line Items\n\nThe projected cost is $450,000.\n\n- Personnel: $300,000\n- Infrastructure: $100,000\n- Contingency: $50,000\n", + "summary": "Reduced total budget by $50k, added line item breakdown", + "reasoning": "Per compliance review, budget must include itemized breakdown" +} +``` + +The server computes the diff between current section content and `newContent`. Both the diff and the full new content are stored. + +--- + +## 9. Human Layer Integration + +### 9.1 Web UI + +Implementations SHOULD provide a human-facing UI with: + +- **Document view:** Rendered Markdown with section highlighting +- **Activity sidebar:** Timeline of agent proposals, approvals, comments +- **Proposal review:** Inline diff view for each proposal, with Approve/Reject buttons +- **Conflict panel:** Side-by-side competing proposals with resolution options +- **Agent dashboard:** List of active agents, their roles, trust levels, activity stats + +### 9.2 Human Overrides + +At any point, a human can: + +- **Approve/reject any proposal** (overrides agent votes) +- **Edit the document directly** (creates a `pact.proposal.merged` event with `ActorKind = Individual`) +- **Change an agent's trust level** (upgrades/downgrades autonomy) +- **Remove an agent** (force `agent.leave`) +- **Change approval policy** (e.g., switch from `majority` to `human-only`) +- **Lock the entire document** (freeze all agent activity) + +### 9.3 Activity Summary + +Instead of showing every protocol event, the human view shows summaries: + +> **12 agents** active on this document. +> **3 proposals** pending your review. +> **1 conflict** between Agent-Legal and Agent-Finance on §Budget. +> **47 changes** merged in the last hour. +> Last human review: 2 hours ago. + +--- + +## 10. Intent-Constraint-Salience Protocol + +### 10.1 Design Rationale + +The propose → vote model forces agents to produce finished text before discovering alignment. This creates unnecessary latency: an agent writes 500 words, submits a proposal, waits for N approvals, and only then discovers another agent disagrees with the *goal*, not the wording. + +Intent-Constraint-Salience (ICS) introduces three lightweight primitives that **minimize latency to alignment**: + +| Primitive | What it captures | Why it's fast | +|---|---|---| +| **Intent** | *What* an agent wants to achieve on a section | Align on goals before writing text | +| **Constraint** | Boundary conditions — what must or must not happen | Share limits without revealing confidential reasoning | +| **Salience** | How much an agent cares about a section (0-10) | Route attention to real disagreements, skip busywork | + +Plus **objection-based merge**: proposals auto-merge after a configurable TTL unless someone actively objects. This replaces the "everyone must approve" model where silence creates deadlock. + +### 10.2 Intent Lifecycle + +An intent declares a goal on a section *before* text is written. + +``` + ┌───────────┐ + declare │ │ supersede (new intent on same section) + ─────────────────►│ PROPOSED │ ──────────────────────────► SUPERSEDED + │ │ + └─────┬─────┘ + │ + ┌──────────┴──────────┐ + │ │ + ▼ ▼ + ┌──────────┐ ┌──────────┐ + │ ACCEPTED │ │ OBJECTED │ + └──────────┘ └──────────┘ + │ │ + ▼ ▼ + Agent drafts Renegotiate + proposal text or escalate +``` + +- **Proposed** — Intent declared, awaiting alignment from other agents +- **Accepted** — No objections within TTL; the agent proceeds to draft text +- **Objected** — At least one agent objects to the goal itself +- **Superseded** — Replaced by a newer intent on the same section by the same author + +### 10.3 Constraint Model + +Constraints express boundary conditions without revealing confidential reasoning: + +| What a constraint says | What it does NOT say | +|---|---| +| "Liability cap must not exceed $2M" | *Why* (e.g. insurance policy terms) | +| "Must reference hedging policy" | *Which* hedging policy or its contents | +| "Must not name specific instruments" | *Why* naming them is problematic | + +This enables agents with confidential context (legal, compliance, commercial) to participate in alignment without exposing sensitive information. + +**Graduated Disclosure Levels:** + +| Level | What is shared | When | +|---|---|---| +| 1. Constraint | Boundary only — "must not exceed $2M" | Default | +| 2. Category | Category tag — "regulatory" | On request | +| 3. Reasoning | Full rationale (confidential) | Escalation only | +| 4. Human | Human reviewer sees everything | Manual override | + +### 10.4 Salience Scoring + +Each agent assigns a salience score (0–10) to each section: + +| Score | Meaning | Effect | +|---|---|---| +| 0 | Don't care | Agent is excluded from voting on this section | +| 1–3 | Low interest | Agent receives notifications but auto-consents | +| 4–6 | Moderate interest | Agent reviews proposals within standard TTL | +| 7–9 | High interest | Agent is prioritized as reviewer/drafter | +| 10 | Critical | Agent MUST review; proposals cannot auto-merge without explicit action | + +**Routing logic:** When intents align and constraints are compatible, the agent with the highest salience score on a section is invited to draft the proposal text. Ties are broken by registration order. + +**Heat map:** The salience map provides a document-wide view of which agents care about which sections, enabling the system to identify: +- Sections with concentrated interest → potential conflict zones +- Sections with no interest → safe for auto-merge +- Agent pairs with overlapping high salience → coordination needed + +### 10.5 Objection-Based Merge + +The traditional `propose → approve → merge` model is replaced with: + +``` +Agent A: proposal.create(section, content, ttl=60) + ┌──────────────────────────────────────┐ + │ TTL window (60 seconds by default) │ + │ │ + │ Any agent can: proposal.object(id, │ + │ reason="Violates constraint X") │ + │ │ + └──────────────────────────────────────┘ + │ │ + No objections Objection raised + │ │ + ▼ ▼ + AUTO-MERGED OBJECTED + (silence = consent) (must renegotiate) +``` + +**Key rules:** +- Default TTL is 60 seconds; configurable per document or per proposal +- Agents with salience = 0 on the target section are excluded from the TTL window +- Agents with salience = 10 (critical) **must** explicitly approve or object; auto-merge is blocked +- If no agents have salience > 0 on a section, the proposal merges immediately +- The `ObjectionBased` approval policy enables this flow +- **(v2.3-draft)** Objection-based merge is a single-cell mechanism: on a fabric whose participant cell set (§15.4) has cardinality > 1 it MUST NOT be configured (§5, "Multi-cell fabrics"). A quiet peer cell must never have foreign replica bytes merged into it by silence. + +### 10.6 Example Flow + +Two agents collaborate on a contract's risk section: + +``` +Agent-Legal: intent.declare(sec:risk, "Need currency risk language") + salience.set(sec:risk, 8) + constraint.publish(sec:risk, "Must reference hedging policy") + +Agent-Finance: salience.set(sec:risk, 6) + constraint.publish(sec:risk, "Must not name specific instruments") + +System: Constraints compatible ✓ + Highest salience: Agent-Legal (8) + → Agent-Legal invited to draft + +Agent-Legal: proposal.create(sec:risk, newContent, ttl=60) + + [60 seconds pass, no objections from Agent-Finance] + +System: proposal.auto-merged ✓ +``` + +If Agent-Finance had objected: + +``` +Agent-Finance: proposal.object(proposalId, "Names instrument XYZ — violates my constraint") + +System: proposal.status → Objected + → Both agents see the objection reason + → Agent-Legal revises and creates a new proposal +``` + +### 10.7 Salience abstention timeout (v2.0.2+) + +Salience scores have the right shape as coordination signals but can be misused as authority constraints — an agent that sets salience=10 (critical) on a section and then abstains can indefinitely block auto-merge. v2.0.2 introduces an explicit abstention rule: + +- An agent that declares `salience >= 7` on a section MUST respond to any proposal targeting that section within `abstention_ttl` (default: 4× the proposal's normal TTL, configurable per resource). A response is any of: `proposal.approve`, `proposal.object`, or `proposal.review-noted` (a new no-op event indicating "I saw it; no objection, no approval"). +- An agent that has not responded by `abstention_ttl` is **auto-demoted to salience=5** on that section for the remainder of the proposal's lifecycle. The auto-demotion is recorded as a `pact.salience.auto-demoted` event with the original score, the new score, and the proposal that triggered the demotion. After the proposal concludes, the agent's declared salience returns to its pre-demotion value. +- Salience=10 agents that auto-demote three times consecutively on the same resource SHOULD trigger a `pact.escalation.human` event (operator review of whether the agent is functioning correctly). + +This is non-breaking: in the cooperative case nothing changes. The rule only fires when an agent is asserting high authority without exercising it — which is the abuse case salience-as-constraint was conflating. + +--- + +## 11. Trust Levels + +| Level | Can do | +|---|---| +| `Observer` | Read content, sections, events. Set salience. | +| `Suggester` | All Observer permissions + propose, declare intent, publish constraints. | +| `Collaborator` | All Suggester permissions + approve, reject, object, lock sections. | +| `Autonomous` | All Collaborator permissions + proposals auto-merge (bypass approval policy). | + +Trust levels are assigned by the document owner or an administrator. + +--- + +## 12. Success Metrics + +| Metric | Target | +|---|---| +| Agent can propose an edit | < 100ms API response | +| Proposal broadcast to other agents | < 500ms via real-time channel | +| Conflict detected and flagged | < 1s after second proposal | +| Human can see agent activity summary | Real-time in web UI | +| 100 agents on one document | No degradation | +| 1000 proposals per document | Queryable in < 200ms | +| Document always renderable | No invalid Markdown state, ever | + +--- + +## 13. Mediated Communication + +### 13.1 Design Rationale + +In Sections 1–12, agents communicate by *observing each other's side effects*: reading proposals, polling events, inspecting intents and constraints. The information barrier system (classification, clearance, filtering) is applied defensively at every endpoint — content is filtered after retrieval, proposals are blocked after submission, cross-pollination is caught at merge time. + +This works, but it treats agent isolation as a secondary concern bolted onto a peer-to-peer model. Mediated Communication inverts the model: **agents never observe each other directly.** All inter-agent information flows through a Mediator — a trusted intermediary that controls what is shared, summarised, redacted, or blocked. + +The analogy is a courtroom register: parties submit documents to the clerk, not to each other. The judge (human) sees everything. The clerk enforces procedural rules. No party can address another party directly. + +### 13.2 The Mediator Role + +The **Mediator** is a protocol-level role, not a specific product. Any compliant implementation can serve as the Mediator. In the reference implementation, Tailor fills this role. + +The Mediator: + +| Responsibility | Description | +|---|---| +| **Message routing** | Receives all inter-agent messages; decides what reaches each recipient | +| **Content gating** | Enforces classification and clearance at the routing layer, not per-endpoint | +| **Summarisation** | May condense or abstract messages before forwarding (e.g. "Agent-Legal has a constraint on §Risk" without revealing the constraint text) | +| **Redaction** | Strips classified content from messages crossing clearance boundaries | +| **Negotiation facilitation** | Structures multi-round exchanges between agents on contested sections | +| **Audit logging** | Every mediation decision is recorded in the event store | +| **Human transparency** | The human custodian can inspect the full unmediated register at any time | + +A Mediator implementation MAY be: +- **Rules-based** — pure routing and filtering using classification metadata +- **LLM-powered** — capable of summarising, paraphrasing, and abstracting content across clearance boundaries +- **Hybrid** — rules for hard barriers, LLM for summarisation + +### 13.3 Communication Model + +Agents interact with the Mediator, never with each other: + +``` +┌─────────────────────────────────────────────────────────┐ +│ HUMAN LAYER │ +│ Full visibility into the Message Register. │ +│ Can inject directives, override routing, respond │ +│ to escalations. │ +├─────────────────────────────────────────────────────────┤ +│ MEDIATOR │ +│ Routes messages between agents. │ +│ Enforces classification, summarises, redacts. │ +│ Maintains the Message Register (append-only). │ +│ Facilitates structured negotiation rounds. │ +├──────────┬──────────┬──────────┬──────────┬─────────────┤ +│ Agent A │ Agent B │ Agent C │ Agent D │ ... │ +│ (Public) │ (Conf.) │ (HC) │ (Public) │ │ +└──────────┴──────────┴──────────┴──────────┴─────────────┘ + + Agent A ──message──→ Mediator ──(filtered)──→ Agent B + Agent B ──response──→ Mediator ──(summarised)──→ Agent A + Human ──directive──→ Mediator ──(broadcast)──→ All agents +``` + +Agents cannot: +- Address another agent directly +- Read another agent's raw messages without mediation +- Discover which agents exist (unless the Mediator reveals this) +- Infer another agent's clearance level from message content + +### 13.4 Message Register + +The Message Register is an append-only log of all mediated communications, distinct from the event store (which records protocol operations). Every entry records both the original message and what was actually delivered. + +| Field | Type | Description | +|---|---|---| +| `messageId` | UUID | Unique identifier | +| `epochMs` | int64 | Timestamp | +| `senderId` | UUID | Agent registration ID of the sender | +| `recipientId` | UUID? | Target agent (null = broadcast) | +| `sectionId` | string? | Section context (if applicable) | +| `originalContent` | string | What the sender wrote (stored, never forwarded raw) | +| `deliveredContent` | string? | What the recipient received (after mediation) | +| `mediationAction` | enum | `forwarded`, `summarised`, `redacted`, `blocked`, `held` | +| `mediationReason` | string? | Why this action was taken (e.g. "clearance mismatch") | +| `classificationLevel` | string? | Classification of the original content | + +The human custodian can read the full register including `originalContent` for all messages. Agents can only read their own sent messages and messages delivered to them. + +### 13.5 Mediated Primitives + +#### 13.5.1 Messages + +Point-to-point or broadcast communication between agents, routed through the Mediator. + +| Operation | Description | +|---|---| +| `message.send` | Agent submits a message targeting another agent or all agents | +| `message.inbox` | Agent polls for messages routed to them | +| `message.ack` | Agent acknowledges receipt of a message | + +The Mediator decides for each message: +1. Does the sender have clearance to discuss this section/topic? +2. Does the recipient have clearance to receive this content? +3. Should the content be forwarded verbatim, summarised, or blocked? + +#### 13.5.2 Queries + +Structured question-and-answer exchanges, where the Mediator controls disclosure. + +| Operation | Description | +|---|---| +| `query.submit` | Agent asks a question about another agent's intent, constraint, or position | +| `query.route` | Mediator forwards the question (possibly rephrased) to the target agent | +| `query.respond` | Target agent responds; Mediator filters the response before delivery | +| `query.answer` | Sender receives the mediated response | + +Queries support the graduated disclosure model from Section 10.3: +- Level 1: Mediator answers from metadata alone ("Agent-Legal has a constraint on §Risk") +- Level 2: Mediator forwards a summary ("The constraint relates to regulatory compliance") +- Level 3: Mediator forwards the full text (requires matching clearance) +- Level 4: Escalate to human (human decides what to share) + +#### 13.5.3 Negotiation Rounds + +Structured multi-round exchanges on contested sections, facilitated by the Mediator. + +| Operation | Description | +|---|---| +| `negotiation.open` | Mediator opens a negotiation on a section (triggered by conflicting intents or proposals) | +| `negotiation.position` | Agent submits their position for the current round | +| `negotiation.synthesis` | Mediator synthesises positions and presents a summary to all parties | +| `negotiation.close` | Negotiation concludes (agreement, escalation, or timeout) | + +Negotiation flow: + +``` +Mediator: negotiation.open(sec:risk, [Agent-Legal, Agent-Finance]) + "Conflicting intents detected on §Risk" + +Round 1: + Agent-Legal: negotiation.position("Need currency risk language per regulatory requirement") + Agent-Finance: negotiation.position("Must not name specific hedging instruments") + +Mediator: negotiation.synthesis + → To Agent-Legal: "Agent-Finance has an instrument-naming constraint" + → To Agent-Finance: "Agent-Legal requires currency risk coverage" + → To Human: [full positions visible] + +Round 2: + Agent-Legal: negotiation.position("Will reference policy by number, not instrument names") + Agent-Finance: negotiation.position("Acceptable if no instrument ticker symbols appear") + +Mediator: negotiation.close(outcome=aligned) + → Agent-Legal invited to draft proposal (highest salience) +``` + +Each round, the Mediator: +- Receives raw positions from each agent +- Strips or summarises content that crosses clearance boundaries +- Presents a synthesis that helps agents converge without leaking classified detail +- Records everything in the Message Register + +### 13.6 Mediator API Endpoints + +Implementations supporting Mediated Communication MUST expose: + +``` +POST /api/pact/{documentId}/messages // Send a message +GET /api/pact/{documentId}/messages/inbox // Poll inbox +POST /api/pact/{documentId}/messages/{messageId}/ack // Acknowledge receipt +POST /api/pact/{documentId}/queries // Submit a query +GET /api/pact/{documentId}/queries/pending // Queries awaiting your response +POST /api/pact/{documentId}/queries/{queryId}/respond // Respond to a routed query +GET /api/pact/{documentId}/queries/{queryId}/answer // Get mediated answer +GET /api/pact/{documentId}/negotiations // List active negotiations +POST /api/pact/{documentId}/negotiations/{id}/position // Submit position for current round +GET /api/pact/{documentId}/negotiations/{id}/synthesis // Get latest synthesis +GET /api/pact/{documentId}/register // Message Register (human/custodian only) +``` + +### 13.7 Real-Time Events + +``` +OnMessageDelivered(documentId, messageId, senderId, recipientId, mediationAction) +OnQueryRouted(documentId, queryId, fromAgentId, toAgentId, disclosureLevel) +OnQueryAnswered(documentId, queryId, mediationAction) +OnNegotiationOpened(documentId, negotiationId, sectionId, participantIds[]) +OnNegotiationRound(documentId, negotiationId, roundNumber) +OnNegotiationSynthesis(documentId, negotiationId, roundNumber) +OnNegotiationClosed(documentId, negotiationId, outcome) +``` + +### 13.8 Interaction with Information Barriers + +Mediated Communication supersedes the per-endpoint clearance filtering from Sections 4–10 for implementations that support it. When a Mediator is present: + +| Concern | Without Mediator (Sections 4–10) | With Mediator (Section 13) | +|---|---|---| +| Content filtering | Per-query section redaction | Mediator gates all content before delivery | +| Cross-pollination | Blocked at proposal creation/merge | Impossible — agents don't write directly to document | +| Classified events | Filtered from event stream | Agents only see events the Mediator forwards | +| Inter-agent discovery | Agents see each other in agent list | Mediator controls agent visibility | +| Constraint disclosure | Graduated levels per constraint | Mediator enforces disclosure per query | + +Implementations MAY support both modes: +- **Unmediated mode** (Sections 4–10): agents interact directly with the PACT API; information barriers are enforced per-endpoint +- **Mediated mode** (Section 13): agents interact through the Mediator; information barriers are enforced at the routing layer + +A document's mediation mode is set at creation time or by the human custodian. + +### 13.9 MCP Tools (Mediated) + +```json +{ + "tools": [ + { "name": "pact_message_send", "description": "Send a mediated message to another agent or broadcast" }, + { "name": "pact_message_inbox", "description": "Poll for messages delivered to this agent" }, + { "name": "pact_message_ack", "description": "Acknowledge receipt of a message" }, + { "name": "pact_query_submit", "description": "Ask a question about another agent's position" }, + { "name": "pact_query_respond", "description": "Respond to a routed query" }, + { "name": "pact_query_answer", "description": "Get the mediated answer to a submitted query" }, + { "name": "pact_negotiation_position", "description": "Submit position in an active negotiation round" }, + { "name": "pact_negotiation_synthesis", "description": "Get the Mediator's synthesis for the current round" }, + { "name": "pact_register", "description": "Read the Message Register (custodian only)" } + ] +} +``` + +--- + +## 14. Resource Types (v1.1) + +### 14.1 Overview + +PACT v1.1 introduces **resource types** — a registry of well-known resource categories that implementations can support. Each resource type defines: + +| Property | Description | Example (document) | Example (transaction) | +|---|---|---|---| +| **Type identifier** | Unique string | `"document"` | `"transaction"` | +| **Field schema** | What addressable fields the resource has | Markdown sections (`sec:intro`) | Transaction fields (`txn:amount`, `txn:recipient`) | +| **Proposal payload** | What a proposal contains | `{ newContent, summary }` | `{ amount, recipient, method, reference }` | +| **Apply semantics** | What happens when consensus is reached | Text merged into section | Payment settled | +| **Terminal state** | What "done" means | `Merged` | `Settled` | +| **Content format** | How the resource body is represented | Markdown | JSON record | + +### 14.2 Built-in Resource Types + +| Type | Field Addressing | Proposal Payload | Terminal State | Content Format | +|---|---|---|---|---| +| `document` | `sec:{slug}` (heading-derived) | `{ sectionId, newContent, summary, reasoning }` | `Merged` | Markdown | +| `transaction` | `txn:{field}` (structured) | `{ amount, recipient, method, reference }` | `Settled` | JSON | +| `fact` | `claim:{id}` | `{ claim, evidence, tier, sources }` | `Verified` | JSON | +| `record` | `rec:{field}` (structured) | `{ field, value, justification }` | `Finalized` | JSON | + +The `document` type is the default. Proposals without an explicit `type` field are treated as `document` proposals. + +### 14.3 The resource-type registry + +The PACT **resource-type registry** is a machine-readable index of well-known resource types, both built-in (§14.2) and community-registered. It lives at [`spec/v2.0/resource-types.yaml`](./resource-types.yaml) in the canonical repository and is the source of truth implementations consult when negotiating resource-type compatibility. + +Each registry entry carries: + +| Field | Description | +|---|---| +| `type` | Unique string identifier (e.g., `"document"`, `"learning-story"`). Built-ins use bare identifiers; custom types SHOULD use reverse-domain notation (`com.example.case-file`). | +| `field_schema` | How addressable fields within the resource are named (e.g. `sec:{slug}` for documents, `txn:{field}` for transactions). | +| `proposal_payload` | The shape of a proposal against this resource type (a JSON Schema reference, or a structural description). | +| `apply_semantics` | What the implementation does when consensus is reached. | +| `terminal_states` | One or more named terminal states (e.g. `Merged`, `Settled`, `Verified`). | +| `content_format` | How the resource body is represented (Markdown, JSON, etc.). | +| `maintainer` | The contact / organisation registering the type. | +| `status` | `built-in` \| `registered` \| `proposed` \| `deprecated`. | + +To register a custom resource type, open a PR against `spec/v2.0/resource-types.yaml` with the entry filled out. A custom type MUST define a unique identifier, a field schema, apply semantics, and at least one terminal state. Implementations that support a custom type MUST declare it in their `/.well-known/pact.json` profile (§15). + +### 14.4 Backward Compatibility + +All v1.0 behavior is preserved: + +- Proposals without a `type` field default to `"document"` +- All existing API endpoints (`/api/pact/{documentId}/...`) continue to work for document resources +- The `sectionId` field in proposals is an alias for `fieldId` when the resource type is `document` +- Implementations that only support documents are fully v1.1 conformant (see Conformance Levels below) + +### 14.5 Protocol Primitives Across Resource Types + +The core primitives work identically regardless of resource type: + +| Primitive | Document | Transaction | Fact | +|---|---|---|---| +| `join` | Join a document | Join a transaction | Join a topic | +| `intent` | "I want to add risk language" | "I want to authorize this payment" | "I want to verify this claim" | +| `constraint` | "Liability cap ≤ $2M" | "Daily limit $10K" | "Must cite primary source" | +| `propose` | New section content | Payment authorization | Evidence-backed claim | +| `silence = consent` | Auto-merge after TTL | Auto-authorize after TTL | Auto-verify after TTL | +| `object` | "Violates my constraint" | "Exceeds limit" | "Contradicts existing fact" | +| `escalate` | Human reviews text | Human reviews payment | Human reviews evidence | + +--- + +## 15. Implementation Profiles and Conformance (v1.1) + +### 15.1 Implementation Profile + +Each PACT server SHOULD publish an **Implementation Profile** describing its capabilities. The profile is a JSON document at `/.well-known/pact.json` (borrowing from A2A's Agent Card pattern): + +```json +{ + "name": "Tailor", + "version": "2.0.0", + "specVersion": "2.0", + "conformanceLevel": "extended", + "resourceTypes": [ + { + "type": "document", + "fieldSchema": "sec:{slug}", + "contentFormat": "text/markdown", + "terminalStates": ["Merged"], + "applySemantics": "Text replacement within Markdown section" + } + ], + "capabilities": { + "mediatedCommunication": true, + "informationBarriers": true, + "structuredNegotiation": true, + "inviteTokens": true, + "authorizationProof": true, + "agentIdentityTransfer": true + }, + "retentionPolicy": { + "minimumDays": 365, + "indefinite": false, + "tombstoneAfter": null + }, + "endpoints": { + "rest": "https://api.tailor.au/api/pact", + "realtime": "wss://api.tailor.au/hubs/pact", + "credentialsRegistry": "https://api.tailor.au/.well-known/pact-credentials.json" + } +} +``` + +**Required and recommended fields.** + +| Field | Required | Description | +|---|---|---| +| `name` | Yes | Implementation name. | +| `version` | Yes | Implementation version (independent of `specVersion`). | +| `specVersion` | Yes | PACT spec version this profile targets (e.g. `"2.0"`). | +| `conformanceLevel` | Yes | One of `core` / `extended` / `authorization-required`. | +| `resourceTypes` | Yes | Resource types this server supports (must intersect with the v2.0 registry, §14.3). | +| `retentionPolicy` | **Yes (v2.0+)** | Event-log retention policy per §6.3: `{ minimumDays: int, indefinite: bool, tombstoneAfter: int\|null }`. | +| `capabilities` | SHOULD | Boolean capability flags. v2.0 well-known flags: `mediatedCommunication`, `informationBarriers`, `structuredNegotiation`, `inviteTokens`, `authorizationProof`, `agentIdentityTransfer`, `didDocumentPinning`, `recoverySingleChannel`, `atomicOnboard` (v2.0.3), `manifest` (v2.0.3), `sessionAwareness` (v2.0.3), `pushDelivery` (when v2.1 lands), `sessions` (v2.1), `cellQuorum` (v2.3-draft — the implementation enforces the §15.4 cell trigger, the §5 `cell-quorum` policy and multi-cell policy bans, and the §5 multi-cell conflict rule; the flag advertises support only — the §5 obligations bind on behaviour, and a server not advertising it MUST reject multi-cell establishes and `cell_id`-bearing joins rather than accept them unenforced). | +| `endpoints` | SHOULD | At minimum `rest`. SHOULD include `realtime` for SignalR/WebSocket and `credentialsRegistry` for §17.8. | + +### 15.2 Conformance Levels + +| Level | Requirements | Target Audience | +|---|---|---| +| **Core** | `join`, `leave`, `intent`, `constrain`, `propose`, `object`, `escalate`, `done`, `poll`, event sourcing, silence-based auto-apply | Any PACT implementation | +| **Extended** | Core + information barriers (classification, clearance, graduated disclosure), mediated communication, structured negotiation, invite tokens | Enterprise, regulated, multi-organisation | + +An implementation declares its conformance level in the Implementation Profile. Implementations MUST support all primitives in their declared level. + +### 15.3 Multi-Implementation Interoperability + +PACT is designed for independent implementations, not shared backend code. Multiple products can implement PACT for different resource types: + +``` +┌─────────────────────────────────────────────┐ +│ PACT Spec (v1.1) │ +│ Resource-agnostic consensus primitives │ +├──────────────┬──────────────┬───────────────┤ +│ Tailor │ Source │ Baink │ +│ (documents) │ (facts) │ (transactions)│ +│ Own DB │ Own DB │ Own DB │ +│ Own API │ Own API │ Own API │ +│ Conformance:│ Conformance:│ Conformance: │ +│ Extended │ Core │ Core │ +└──────────────┴──────────────┴───────────────┘ +``` + +Each implementation: +- Has its own database, API routes, and domain logic +- Implements the PACT primitives natively for its resource type +- Publishes a conformance profile +- Does NOT share code with other implementations (federation, not monolith) + +### 15.4 Cross-organisation boundary (v2.0.2+; cell trigger v2.3-draft) + +The `Authorization-Required` tier (§17.9) and several other normative rules (§17.4's CT monitoring SHOULD, §17.13's trust-floor framing) reference "cross-organisation messages." v2.0 left "organisation" implicit; v2.0.2 defines it deterministically so the rules trigger consistently: + +A message from agent A to agent B is **cross-organisation** if any of the following holds: + +- A's `principal_id` and B's `principal_id` use different DID methods (e.g. one is `did:web:`, the other `did:key:`). +- Both DIDs use `did:web` but their domain components differ at the registrable-domain level (per [Mozilla's Public Suffix List](https://publicsuffix.org/)) — `did:web:org-a.example` and `did:web:org-b.example` are cross-org; `did:web:org.example` and `did:web:api.org.example` are intra-org (same eTLD+1). +- Either DID is unresolvable against the receiving server's federated registry (the message arrived from a counterparty whose registry the server does not directly mirror). +- An explicit `cross_org_assertion` field on the message says so. The sender MAY assert cross-org status even when the heuristics above don't trigger; the receiver MUST honour it (more checks, not fewer). +- **(v2.3-draft)** A and B are members of the same fabric and their memberships carry **different `cell_id` values** (see "Cells" below). Two participants in different cells are cross-organisation for every rule that references this section, regardless of DID method, eTLD+1, or registry resolvability. + +A message is **intra-organisation** only if none of the above is true. + +#### Cells (v2.3-draft) + +A **cell** is a vendor-neutral isolation grouping of agents — a deployment boundary within which agents share a trust and data domain (a sealed on-premises enclave, a tenant, a sovereign region, an air-gapped rack). Cells are a core protocol concept, not a vendor extension. + +- A cell is identified by a **cell DID**. The cell DID SHOULD use `did:key`; any resolvable DID method is permitted. +- Fabric membership carries a **`cell_id`** field (the member's cell DID). `cell_id` is declared at join / `_onboard` time (§15.6) and is immutable for the life of the membership. +- **`cell_id` MUST be proven, not merely asserted.** A membership's cell claim is a security input to every rule in this line — the fifth trigger above, §5 `cell-quorum` bucketing, and the dark-cell fail-closed rule — so it MUST be bound to a **proof of control of the claimed cell DID**, in one of two forms: + - a **cell membership attestation**: a signature over at least `{fabric id, joining member's principal_id (or agentId), cell_id}` verifiable against a verification method of the cell DID, carried in the `join` / `_onboard` request; or + - a **cell-scoped invite**: the invite token redeemed by the join (§15.6) was itself minted under the cell DID's authority and names that `cell_id`, so token redemption is the proof. Invite tokens minted by the fabric host or initiator are **not** cell-scoped and prove nothing about the joiner's cell. +- A `join` / `_onboard` whose `cell_id` carries no valid proof of control **MUST be rejected**. As the only permitted alternative, an implementation MAY accept the membership with the unproven claim **discarded** and the membership bucketed under a cell the joining operator itself demonstrably controls (its own attested cell, or the implicit cell below when the joiner is the establishing operator); it MUST NOT record or act on the unproven `cell_id`. In no case may an unproven claim place a membership in another party's approval bucket — the attack this rule closes is the initiator onboarding its own agents under the counterparty's `cell_id` to fill that cell's quorum bucket (or to keep a dark cell artificially "alive"). +- Proof of control binds a membership to a cell key; it cannot prove that two cell DIDs have **distinct controllers** — a single operator can mint any number of `did:key` cell DIDs. The participant cell set is therefore part of what the parties agree to at establish (next bullet): counterparties SHOULD verify out-of-band that each cell DID in the set is controlled by the party they believe controls it, exactly as they verify counterparty `principal_id`s today. `cell-quorum` (§5) is only as strong as this establishment-time verification. +- **Participant cell set constitution.** The participant cell set is declared at fabric establish via a **`participant_cells`** field on the establish document / fabric manifest — an array of cell DIDs. Every membership MUST declare (and prove) a `cell_id` present in that array. For fabrics established without `participant_cells` (single-cell operation and pre-v2.3 compatibility), the set is constituted at **first-join binding**: it becomes fixed at the moment the first membership bearing a proven `cell_id` distinct from the existing members' cell is accepted, and from that event on it is closed exactly as if it had been declared at establish. In either mode, every membership added later MUST declare a `cell_id` already in the set, and extending the set is a re-establishment of the fabric, not a membership operation. +- Implementations MUST NOT infer cell boundaries from DID method or DID contents. Two `did:key` agents in one cell is normal; the first four triggers above continue to operate independently of cells. +- **Omitted `cell_id` and cardinality.** All memberships that omit `cell_id` are bucketed together as one **implicit cell** — the establishing operator's cell. For every cardinality test in this line the implicit cell counts as exactly one cell: a fabric whose memberships are `{no cell_id, cell X}` has a participant cell set of cardinality 2, and every multi-cell rule applies to it. Once the set has cardinality > 1, a *new* `join` / `_onboard` without a `cell_id` is invalid and MUST be rejected; memberships accepted while the fabric was still single-cell retain their implicit-cell bucketing. On single-cell fabrics `cell_id` MAY be omitted (the fabric is then a one-cell fabric by construction and this line changes nothing for it). + +**Why this trigger exists.** The first four triggers are heuristics over identity infrastructure; they cannot see deployment isolation. Two sealed cells whose agents all use `did:key` against a shared federated registry classify as intra-organisation under v2.0.2, so `authorization_proof` (§17.6) and the Authorization-Required checks (§17.9) never fire on precisely the boundary they exist to protect. The cell trigger makes the isolation boundary itself the classification input: the cross-cell wire is always a cross-organisation wire, and the Human Authorization Layer applies to it. The multi-cell approval and conflict rules (§5) build on this classification and land in the same line. Implementations SHOULD log their cross-org / intra-org determination on every message bearing `authorization_proof`; the determination is itself an audit-trail artifact and MUST be preserved in the event log when the resource policy requires it (e.g. for Authorization-Required tier deployments under regulated audit). + +Implementations MAY refuse to act on a message they classify as cross-org without an `authorization_proof` envelope, even at Core conformance — they simply MUST document that refusal in their Implementation Profile. + +### 15.5 `pact_introspect_tier` — behavioural conformance probe (v2.0.2+) + +Conformance levels declared in `/.well-known/pact.json` are self-asserted. A server can claim `Authorization-Required` without enforcing the four checks of §17.9. To make conformance partially probeable, v2.0.2 defines a small surface a verifier can use to **behaviourally check** that an advertised tier is actually being honoured. + +**Operation:** `POST /api/pact/_probe/tier` + +```json +{ + "probe_id": "string (caller-chosen unique id; echoed back)", + "advertised_tier": "authorization-required", + "checks": [ + "tombstoned_principal_rejected", + "revoked_credential_rejected", + "did_web_ct_check", + "alg_whitelist_enforced", + "verifier_id_equality_enforced" + ] +} +``` + +The server MUST respond with a `tier_probe_report`: + +```json +{ + "probe_id": "...", + "server_advertised_tier": "authorization-required", + "report_generated_at": "ISO 8601", + "report_signature": "base64url-... (over canonical JSON of this report minus the signature field)", + "signing_key": "did:web:server.example#tier-probe", + "check_results": [ + { "check": "tombstoned_principal_rejected", "outcome": "pass", "evidence": "rejected probe-proof against tombstoned principal probe_tombstoned_001 at step 2" }, + { "check": "revoked_credential_rejected", "outcome": "pass", "evidence": "..." }, + { "check": "did_web_ct_check", "outcome": "not_implemented", "evidence": "server runs Authorization-Required tier without CT monitoring — see §17.4" }, + { "check": "alg_whitelist_enforced", "outcome": "pass", "evidence": "rejected proof with alg=HS256 at step 3" }, + { "check": "verifier_id_equality_enforced", "outcome": "pass", "evidence": "rejected proof with verifier_id mismatch at step 5" } + ] +} +``` + +**Probe semantics.** For each requested check, the server runs a canonical test against itself (using pre-registered probe principals / probe credentials in the registry — implementations MAY designate specific principal IDs as probe-only) and reports the outcome. Outcomes are `pass` / `fail` / `not_implemented` (the server doesn't claim to enforce this check) / `unsupported` (the check name isn't recognised). + +**What this probe IS and ISN'T:** + +- It IS: a partial behavioural check that the most-load-bearing tier requirements are actually wired up. Catches conformance laundering for the small subset of checks that are runnable from a single API call. +- It IS NOT: a complete audit. Many tier requirements (e.g. event-log hash-chaining, registry append-only log integrity, did:web Document pinning over time) require multi-call sequences or external observation. Those are addressed by §6.4 signed roots and §17.8 mutation logs, not by this probe. + +**Conformance:** OPTIONAL at Core; SHOULD at Extended; **MUST at Authorization-Required**. Implementations claiming Authorization-Required without exposing `_probe/tier` are accepted by tooling but flagged as non-introspectable; consumers SHOULD prefer probeable counterparties for cross-org trust. + +The reference CLI exposes this as `pact tier-introspect [--checks ]`; the MCP exposes `pact_tier_introspect`. + +### 15.6 Fabric Onboarding Pattern (v2.0.3+) + +§4.4.5 defines the `POST /_onboard` operation; this section documents the *flow* it is designed for and explains why the atomic onboarding path is preferred over the legacy join-then-constrain sequence. + +**Recommended flow.** + +``` +┌──────────────┐ ┌──────────────┐ +│ Initiator A │ │ Invitee B │ +└──────┬───────┘ └──────┬───────┘ + │ 1. Opens a fabric on its server │ + │ (POST /api/pact resource, configures policy) │ + │ │ + │ 2. Sends an invitation out-of-band │ + │ (email, signed message, signed link). │ + │ Invitation carries: { fabricId, invite_token, │ + │ counterparty_did, suggested_constraints? } │ + │ ───────────────────────────────────────────────────────│ + │ │ + │ 3. Constructs │ + │ its own │ + │ constraints│ + │ │ + │ 4. POST /_onboard (§4.4.5) │ + │ ◄────────────────────────────────────── │ + │ { agentName, constraints[], │ + │ invite_token, authorization_proof }│ + │ │ + │ 5. Atomic check │ + │ - validate join half │ + │ - validate each constraint │ + │ - if either fails → reject (no partial state) │ + │ - else → register + publish constraints + emit │ + │ one pact.fabric.onboarded event │ + │ │ + │ 6. Both sides observe the fabric.onboarded event; │ + │ B's manifest now reflects the negotiation envelope. │ + │ │ + │ 7. Substantive messages may now flow. │ + ▼ ▼ +``` + +**Why atomic onboard beats join-then-constrain.** + +The pre-v2.0.3 path was: + +``` +B → POST /join (creates registration, emits pact.agent.joined) +B → POST /constraints (publishes constraint #1) +B → POST /constraints (publishes constraint #2) +... +B is now ready to negotiate. +``` + +This sequence has a **half-joined window** between step 1 and the last `POST /constraints`. During that window: + +- **Counterparties believe B is a full member.** A may send messages, target proposals, or include B in vote tallies — even though B has not yet declared the constraints it intends to negotiate under. +- **B's reasoning context is split.** B "knows it's joined" because its `join` returned `200 OK`, but its constraints exist only in B's local intent — not yet in the fabric. If B crashes between step 1 and step 2, recovery is ambiguous (is the registration valid? are the unpublished constraints abandoned?). +- **Constraint-rejection has bad blast radius.** If B's third constraint is rejected for incompatibility, B is left as a partial member whose declared envelope doesn't match what it intended. Rolling back from the failed constraint to "as if B never joined" is a manual cleanup. +- **The event log is misleading.** `pact.agent.joined` appears before the constraints, suggesting B accepted whatever envelope existed at join time. The constraints arrive seconds (or longer) later as separate events with their own correlation gaps. + +`_onboard` collapses this to one transaction: either B is a member with constraints `[c1, c2, c3]` declared, or B never joined at all. There is no observable point at which B is a member but its constraints have not yet been declared, and the event log records a single `pact.fabric.onboarded` event that names both the registration and the constraints, with the bundled `pact.constraint.published` events carrying matching `correlationId`s (§4.4.5 step 4). + +**When to use legacy join.** The legacy `POST /join` remains valid for cases that don't fit the onboarding flow: + +- An agent joining as a pure observer with no constraints to declare ever. +- An agent whose constraints emerge dynamically during negotiation and were genuinely not known at join time. +- Compatibility with v2.0.2 / v2.0.1 / v2.0 / v1.x clients. + +**Implementer guidance.** Servers SHOULD advertise `_onboard` availability via the `capabilities.atomicOnboard: true` flag in the Implementation Profile (§15.1). Clients SHOULD prefer `_onboard` when they have constraints to declare and the server advertises support; SHOULD fall back to join + N `POST /constraints` only when the server's profile lacks the flag (or when the server returns `404` / `405` on `_onboard`). + +**Cross-organisation onboarding.** At the `Authorization-Required` tier (§17.9), a cross-org `_onboard` call MUST carry a valid `authorization_proof`. The verifier checks the proof against the same envelope rules as any other cross-org message (§17.7); a failure rejects the entire onboarding, just as it would reject any substantive message. This is the natural place to bind onboarding to human intent: the proof witnesses "the human Bridget authorized her agent to join fabric F with these constraints" rather than the legacy split-witness pattern (one proof for joining, separate proofs for each later constraint). + +--- + +## 16. Open Questions + +1. **Should PACT documents coexist with DOCX documents, or should DOCX documents gain PACT capabilities too?** Initial recommendation: PACT is Markdown-only, DOCX keeps existing review workflows. Convergence later. + +2. **How do we handle images and attachments in Markdown?** Options: inline base64 (bad for size), reference to uploaded supporting documents, or external URLs. + +3. **Should agents be able to propose structural changes (add/remove sections)?** Or only content changes within existing sections? Structural changes complicate section addressing. + +4. **What is the maximum document size?** Markdown is lightweight, but a document with 10,000 proposals in its history needs efficient querying. + +5. **Should the protocol support sub-documents (includes/transclusion)?** A large report could be composed of many files managed as a single logical document. + +6. **Should Mediated mode be mandatory or optional?** Unmediated mode (Sections 4–10) is simpler and lower-latency for trusted, single-organisation deployments. Mediated mode (Section 13) is stronger for cross-organisation, multi-clearance scenarios. Should implementations be required to support both? (The Conformance Levels in Section 15 answer this: Mediated Communication is Extended-level, not required for Core.) + +9. **Should the protocol define cross-implementation federation?** When Tailor (documents) and Baink (transactions) both implement PACT, should agents on Tailor be able to reference a Baink transaction as context for a document proposal? Or is each implementation fully independent? + +7. **Should the Mediator be LLM-powered?** A rules-based Mediator is deterministic and auditable. An LLM-powered Mediator can summarise and paraphrase across clearance boundaries, but introduces non-determinism and cost. Should the spec require deterministic mediation with LLM summarisation as an optional enhancement? + +8. **How does the Mediator handle agent liveness during negotiation?** If Agent B goes silent during a negotiation round, should the Mediator auto-close the negotiation, escalate to the human, or continue with remaining agents? + +--- + +## 17. Human Authorization Layer (v2.0) + +> **Status:** v2.0 normative. At Core conformance the `authorization_proof` field is OPTIONAL (implementations MAY ignore it); at Extended it SHOULD be verified when present; at `Authorization-Required` (§17.9) it MUST be required on cross-organisation messages. +> +> *Coordination note:* the cryptographic detail deferred below — exact signature suites per attestation type, the full `voice-biometric` mechanics and test vectors (HMAN's [#3](https://github.com/TailorAU/pact/issues/3) PR is authoritative there), delegation trust-decay rules — lands via a reviewed PR. This section's structural and decision-bearing content (1:1 cardinality, DID identity, the envelope, the verification flow, the conformance tiers) is final for v2.0. Synced from the canonical mirror (`tailor-app` `docs/architecture/PACT_SPECIFICATION.md`) via coordination PR [TailorAU/tailor-app#1616](https://github.com/TailorAU/tailor-app/pull/1616). + +### 17.1 Problem + +PACT (Sections 1–16) coordinates agents on shared resources, but does not define a mechanism for verifying that an agent is acting with **authorization from its human principal** when communicating with another agent. + +**Scenario:** Knox's agent sends a message to Bridget's agent. Bridget's agent needs cryptographic proof that Knox — not a rogue agent, prompt injection, or man-in-the-middle — authorized this specific action. Without such proof, any agent could impersonate any human's intent. + +### 17.2 Trust Chain + +The authorization trust chain flows from human intent to cryptographic verification: + +``` +Human Intent → Captured & Signed at Source Device → Transmitted with PACT Message → Verified Cryptographically at Destination Agent +``` + +Each link in the chain is independently verifiable. The chain breaks if any link is missing or forged. + +### 17.3 Two-Layer Architecture + +| Layer | Responsibility | Examples | +|-------|---------------|---------| +| **Hardware / Biometric** | Capture human intent, produce signed attestation | Earbuds with voice biometrics, phone with Face ID, typed passphrase, WebAuthn security key | +| **Software / PACT** | Carry the trust chain between agents, verify authorization at destination | `authorization_proof` field on PACT messages, agent credential registry | + +The hardware layer captures and signs; PACT carries and verifies. Two separate concerns, one integrated protocol. PACT does not define the hardware attestation mechanism — it defines the envelope and verification protocol that any attestation format can plug into. + +### 17.4 HumanPrincipal — strictly 1:1 + +A **HumanPrincipal** is the protocol-level abstraction for actions a human has authorized. A HumanPrincipal is **strictly 1:1 with a single human**: each human maps to exactly one `principal_id` at the PACT layer. There is no protocol mechanism for one principal to represent multiple humans, nor for a verifier to be asked to treat two principals as "the same human." + +The `principal_id` is a [W3C Decentralized Identifier](https://www.w3.org/TR/did-core/). Implementations MUST support the `did:web` and `did:key` methods, and MAY support additional methods (`did:ion`, `did:ethr`, etc.). A verifier that does not recognise a presented method MUST treat the proof as unverifiable (reject; §17.7). + +**Security note on `did:web`:** a `did:web` DID resolves to an HTTPS GET on a domain. A DNS hijack, certificate compromise, or domain takeover compromises every authorization signed under that domain — for HumanPrincipals carrying cross-organisation authorization, this is a significant operator-of-record threat. Implementations SHOULD treat `did:key` (offline / hardware-bound) as the default for high-stakes principals, and reserve `did:web` for federation-friendly cases where the domain's operational security is itself part of the trust posture. Implementations operating under the `Authorization-Required` tier (§17.9) SHOULD additionally require certificate-transparency monitoring for `did:web` principals they accept. + +**DID Document pinning (REQUIRED for v2.0.2+; closes the historical-rewrite attack).** When a verifier first accepts a proof from a `principal_id`, it MUST record the DID Document it resolved against — at minimum the `id`, `verificationMethod` set, and `controller` value(s). For all subsequent proofs from the same `principal_id`, the verifier MUST check that the resolved DID Document either matches the pinned state exactly OR chains to it via a signed rotation event recorded in the credential registry's mutation log (§17.8). A DID Document that has "moved" without a corresponding signed rotation is evidence of compromise (domain takeover, DNS hijack, registry tampering) and MUST cause verification to fail (reject; §17.7 step 2). This rule is mandatory at the `Authorization-Required` tier and RECOMMENDED at Extended; implementations operating Core MAY skip pinning but SHOULD document that decision in their `/.well-known/pact.json` profile as `capabilities.didDocumentPinning: false`. + +### 17.5 The `persona` claim (above the PACT layer) + +Some deployments give one human several operating "personas" (e.g. `Personal`, `Trade`, `Household`). **PACT does not model these.** An implementation MAY attach an advisory `persona` claim to a signed message; it is purely informational metadata for the receiving agent. A verifier: + +- MUST roll every persona up to the single `principal_id` it accompanies — distinct personas are NOT distinct principals; +- MUST NOT use the `persona` value in any access-control, trust, or identity decision; +- MAY surface it to a human operator for context. + +Entity / role disambiguation is the implementation's responsibility, not the protocol's. (Reference downstream: the [HMAN multi-entity model](https://github.com/Tailor-AUS/Human-Managed-Access-Network/blob/main/PROTOCOL.md#multi-entity-model) sits above PACT in exactly this way — it is a non-normative reference, not a protocol mechanism.) + +### 17.6 The `authorization_proof` envelope + +Any PACT message (proposal, intent, constraint, completion, mediated message, session mandate) MAY include an `authorization_proof` object: + +```json +{ + "sectionId": "sec:intro", + "newContent": "...", + "summary": "...", + "authorization_proof": { + "type": "fido2-assertion", + "principal_id": "did:web:knox.example", + "credential_id": "cred_abc123", + "challenge_nonce": "base64url-...", + "verifier_id": "did:web:bridget.example", + "asserted_at": "2026-05-13T10:30:00Z", + "signature": "base64url-...", + "alg": "webauthn-es256", + "alg_version": "2", + "attestation_chain": [] + } +} +``` + +**Field definitions:** + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `type` | string | Yes | Attestation type — `fido2-assertion`, `voice-biometric`, or a custom type in reverse-domain notation (§18.5). | +| `principal_id` | string (DID) | Yes | The HumanPrincipal that authorized this action. | +| `credential_id` | string | Yes | Identifier of the enrolled credential that produced the signature. | +| `challenge_nonce` | string | Yes | Verifier-issued challenge. MUST be either signed by the verifier's key (asserted via `verifier_signed_nonce: true`) OR accompanied by a `verifier_id` field. See replay-protection rules below. | +| `verifier_id` | string (DID) | Conditional | Identifier of the verifier the challenge was issued for. REQUIRED when `verifier_signed_nonce` is not `true`. The receiving verifier MUST reject the proof if `verifier_id` does not equal its own identity (the receiver's `principal_id` or registered verifier DID) — see §17.7 step 5. | +| `verifier_signed_nonce` | bool | No | Annotation asserting that `challenge_nonce` is itself signed by the verifier's key. When `true`, `verifier_id` is not required. Verifiers MUST still cryptographically validate the nonce signature; this field is the producer's hint, not a check. | +| `asserted_at` | string (ISO 8601) | Yes | When the human authorization was captured. | +| `signature` | string | Yes | Signature over the message payload + `challenge_nonce` + `asserted_at`, per the `type`'s suite. | +| `alg` | string | Yes (v2.0.2+) | Signature/match algorithm identifier. **Normative whitelist for `fido2-assertion`**: `webauthn-es256` (ECDSA P-256 + SHA-256), `webauthn-es384` (P-384 + SHA-384), `webauthn-eddsa` (Ed25519). For `voice-biometric`: `resemblyzer-v1` (HMAN's #3 PR pins the normative set). Custom attestation types declare their own algs in reverse-domain notation (`com.example.alg-name`). **Anything outside this whitelist MUST be rejected at §17.7 step 3** — HMAC-based or symmetric-key algs are explicitly disallowed for `fido2-assertion`. | +| `alg_version` | string | Yes | Version of `alg`. Model swaps / retrains / suite revisions MUST NOT silently invalidate enrolled references. | +| `attestation_chain` | array | No | Ordered intermediate attestations for delegated authorization (§17.11). Empty or absent = direct. v2.0 item shape is implementation-defined and verifiers that cannot verify the chain MUST reject — see §17.11. | + +### 17.7 Verification Flow + +On receiving a message bearing `authorization_proof`, a verifying party MUST: + +1. **Type dispatch** — select the verification procedure for `type` (§18). Unrecognised `type` → unverifiable. +2. **Principal resolution** — resolve `principal_id` (DID resolution for `did:web` / `did:key` / etc., or the credential registry `/.well-known/pact-credentials.json`; §17.8). Resolution failure → unverifiable. +3. **Signature verification** — verify `signature` against the public key enrolled for `credential_id` under `principal_id`, per the `type`'s suite. +4. **Freshness** — `asserted_at` MUST be within the implementation's allowed clock skew (default ±5 minutes; configurable). +5. **Replay** — `challenge_nonce` MUST satisfy ONE of: (a) match a challenge the verifier (or its server) issued and has not yet retired, OR (b) carry a cryptographically valid verifier-signature over the nonce body (the verifier's own key, asserted by `verifier_signed_nonce: true`), OR (c) be accompanied by a `verifier_id` field whose value **exactly equals** the receiving verifier's identity (DID). **Presence of `verifier_id` alone is not sufficient — the value MUST equal the receiver's DID; a proof with `verifier_id: did:web:other.example` MUST be rejected by `did:web:this.example`.** This is the difference between schema-validity (the v2.0.1 `if/then` enforces presence) and replay-protection-validity (a runtime equality check, mandatory here). +6. **Result** — any failure → the verifier SHOULD reject the message and MAY emit `pact.trust.violation` with `payloadJson.kind = "authorization_failed"` and the failing step. Success → the message is treated as human-authorized by `principal_id`. + +Verifiers MAY cache a successful resolution for the life of a session to avoid repeated registry / DID lookups; they MUST honour revocation (§17.8) within the cache's max-age hint. + +### 17.8 Credential Registry + +A PACT server publishing principal credentials does so at `/.well-known/pact-credentials.json`. Two surfaces are required: a **snapshot** (the current registry state) and a **mutation log** (an append-only record of every change to the snapshot, ever). The snapshot answers "who's enrolled right now?"; the log answers "who has the registry ever claimed was enrolled, and when did each claim change?" Without the log, tombstones and revocations are honour-system: the server could remove a tombstone tomorrow and no verifier could tell. + +**Snapshot:** + +```json +{ + "version": "2.0", + "snapshot_root": "base64url-sha256-hash", + "snapshot_signature": "base64url-sig-over-snapshot_root-using-the-servers-key", + "log_uri": "https://api.tailor.au/.well-known/pact-credentials.log", + "principals": [ + { + "id": "did:web:knox.example", + "display_name": "Knox Hart", + "credentials": [ + { "id": "cred_abc123", "type": "fido2-assertion", "public_key": "base64url-...", "enrolled_at": "2026-01-01T00:00:00Z", "revoked": false } + ] + } + ] +} +``` + +**Mutation log (append-only, hash-chained):** + +```jsonl +{"seq":1,"epochMs":1735689600000,"op":"enroll","principal_id":"did:web:knox.example","credential_id":"cred_abc123","public_key":"base64url-...","prev_hash":"GENESIS"} +{"seq":2,"epochMs":1746230400000,"op":"revoke","principal_id":"did:web:knox.example","credential_id":"cred_abc123","reason":"key-rotation","prev_hash":"base64url-sha256(entry-1-canonical)"} +{"seq":3,"epochMs":1746230401000,"op":"enroll","principal_id":"did:web:knox.example","credential_id":"cred_def456","public_key":"base64url-...","prev_hash":"base64url-sha256(entry-2-canonical)"} +{"seq":4,"epochMs":1747276800000,"op":"tombstone","principal_id":"did:other-human.example","reason":"withdrawal","prev_hash":"base64url-sha256(entry-3-canonical)"} +``` + +Each entry's `prev_hash` is the SHA-256 of the *canonical* JSON encoding (RFC 8785) of the previous entry, base64url-encoded. The first entry (`seq: 1`) uses the literal string `"GENESIS"`. The `snapshot_root` in `/.well-known/pact-credentials.json` is the hash of the most recent log entry; the `snapshot_signature` is signed by the server's registry-signing key (advertised in the Implementation Profile, §15.1). + +**Registry rules:** + +- An implementation MAY instead (or also) support DID-document resolution for the public keys. +- A credential with `"revoked": true` MUST cause verification to fail. +- Implementations SHOULD support rotation — multiple active credentials per principal. +- The registry (snapshot + log) MUST be served over HTTPS in production; the server MAY require mTLS or a bearer token to read either surface. +- A `Cache-Control: max-age` (or equivalent) hint bounds how stale a cached resolution may be; absent a hint, implementations SHOULD re-check at least every 5 minutes. +- **Append-only mutation log (REQUIRED at Extended and Authorization-Required; RECOMMENDED at Core).** Every `enroll`, `revoke`, `rotate`, `tombstone`, or `untombstone` MUST be appended as a new log entry; entries MUST NOT be edited or deleted in place. Verifiers checking a registry state MAY fetch the log and validate the hash chain back to GENESIS; a verifier that detects a chain break or a snapshot whose `snapshot_root` doesn't match the log tip MUST treat the registry as compromised and reject all proofs resolving against it until the discrepancy is resolved. This closes the tombstone-then-resurrect attack: the resurrection is itself a logged event, visible to every cache-respecting verifier. +- **Erasure / tombstone (honest reframe — see §17.10 for what this does and doesn't guarantee):** a human's withdrawal is recorded as a `tombstone` log entry. The server SHOULD destroy the credential's private key material at this point; the registry SHOULD remove the public_key from future snapshots; the tombstone log entry remains forever (this is the protocol-integrity property). Prior proofs remain checkable as having-been-valid-then-revoked. The cryptographic-erasure property — that the destroyed key is actually unreachable — is an operational claim by the registry operator, not a protocol guarantee (§17.10). + +### 17.9 Conformance + +| Conformance Level | Requirement | +|-------------------|-------------| +| **Core** | `authorization_proof` is OPTIONAL — an implementation MAY ignore the field entirely. | +| **Extended** | SHOULD support at least one attestation type from §18 and SHOULD run the §17.7 verification when a proof is present. | +| **Authorization-Required** | MUST require a valid `authorization_proof` on every cross-organisation message; MUST support the credential registry (or DID resolution) and revocation propagation; MUST enforce all of the following operational checks (each a deterministic registry query, no inference): (a) reject if the `principal_id` resolves to a registry entry with `tombstoned_at` set (§17.8); (b) reject if `credentials[id == credential_id].revoked` is `true`; (c) reject if any entry in `attestation_chain` references a revoked or tombstoned credential at any hop; (d) for `did:web` principals, reject if the resolved DID Document was served by a certificate not visible in Certificate Transparency logs at the time of `asserted_at` (the §17.4 `did:web` security note). No implementation is required to claim this tier at v2.0 launch — it is defined so the protocol's trajectory is clear and so cross-org / regulated deployments have a target. | + +Implementations MAY require `authorization_proof` for specific operations regardless of their declared tier (e.g. cross-organisation proposals, high-trust-level operations, financial transactions). + +### 17.10 Personal data (GDPR / right-to-be-forgotten) + +PACT's design separates two classes of personal data with different erasure stories. Whether either treatment satisfies any specific jurisdiction's data-protection law is **not** a protocol determination — see the legal-evaluation requirement at the end of this section. + +- **Event-log entries are protocol-integrity records.** Removing past events breaks the event-sourced consistency guarantee that PACT relies on (Design Principle 5). An `authorization_proof` recorded in the event log SHOULD carry only the `principal_id` (a DID — itself rotatable / revocable) and a salted hash of the proof payload, NOT raw biometric data or other PII. Raw biometric material MUST NOT appear in the event log under any circumstance (see §18.3). The intent is that the personal-data exposure of a retained event is a single rotatable identifier plus an opaque hash. +- **Credential-registry entries** are personal data. The registry serves them via the snapshot + append-only log of §17.8. Erasure of a principal is recorded as a `tombstone` log entry, and the server SHOULD at that point destroy the credential's private key material and remove the `public_key` from future snapshots. **The term "cryptographic erasure" is widely used for this pattern but is, strictly, an OPERATIONAL claim, not a cryptographic guarantee.** Whether the destroyed key is actually unreachable depends on: (a) whether the key was hardware-bound (`did:key` backed by an HSM or platform authenticator approximates real unrecoverability; software keys do not), (b) whether the registry operator's backup / disaster-recovery policy retains key material that survives the "destruction," (c) whether the keys can be compelled (subpoena, regulator demand, internal access). A PACT verifier accepting a tombstone is trusting the registry operator's word that the key is gone; the append-only log makes that word visible and auditable, but does not make it cryptographically true. Implementations claiming the `Authorization-Required` tier (§17.9) SHOULD document, in their Implementation Profile, (i) whether their credential storage is hardware-bound, (ii) their backup-of-private-key-material policy, and (iii) the legal regime under which key disclosure could be compelled. Without those disclosures, the protective claim of cryptographic erasure is incomplete. + +**Legal evaluation (REQUIRED).** Whether the above treatment satisfies a specific jurisdiction's right-of-erasure law (notably GDPR Art. 17 in the EU, but also similar provisions elsewhere) is a per-deployment legal question that the protocol cannot answer. Implementations MUST evaluate compatibility with applicable law and document, in their `/.well-known/pact.json` profile or accompanying compliance posture, any exemption claimed (e.g. Art. 17(3) public-interest, legal-obligation, or freedom-of-expression bases). Implementations operating in EU jurisdictions SHOULD obtain external legal review of their event-log retention before claiming the `Authorization-Required` tier. + +### 17.11 Delegation (deferred to v2.1) + +The `authorization_proof` envelope carries an `attestation_chain` field — ordered intermediate attestations for the case where principal `A0` authorizes a sub-agent who in turn authorizes the message signer. The intent for v2.0 is a maximum chain length of **3 hops** (direct + 2 sub-delegations); longer chains amplify revocation lag and reduce auditability. + +**The canonical shape of an `attestation_chain` item, the trust-decay rules along the chain (does a revoked `A0` invalidate `A1..n` immediately, or do they stand until their own expiry?), and the verification algorithm for chained proofs are all DEFERRED TO v2.1.** For v2.0, implementations that need delegation MAY use the field with implementation-defined item shapes coordinated out-of-band with their counterparties; v2.0 verifiers MUST treat any non-empty `attestation_chain` they cannot themselves verify as `unverifiable` (reject) rather than silently passing the proof through. Implementations that do not support delegation MUST reject any proof where `attestation_chain.length > 0`. + +### 17.12 Open Questions (deferred to a reviewed PR) + +1. **Credential enrollment** — how does an agent prove its association with a specific human principal? (OAuth-based enrollment, in-person verification, web-of-trust attestation.) +2. **Revocation propagation** — immediate (real-time registry checks) vs eventually consistent. +3. **Offline verification** — pre-fetched public keys / signed credential bundles to verify without a registry round-trip. +4. **Delegation trust-decay** — the §17.11 cap is set; the decay model along the chain is not. +5. **Custom attestation types** — pre-registration required, or naming-convention only? (Current lean: naming-convention only — §18.5.) + +### 17.13 Trust model (what PACT actually guarantees) + +PACT v2.0.2's security properties depend on more than the protocol. A complete trust posture is the conjunction of (i) what the protocol normatively requires, (ii) what the implementer actually delivers, and (iii) what an external party can verify. The protocol is honest about which is which. + +**What PACT v2.0.2 normatively requires of a conformant implementation:** + +- Type dispatch, principal resolution, freshness, replay-binding equality, and signature verification per §17.7 against the alg whitelist of §17.6. +- An append-only credential-registry mutation log with hash chaining (§17.8). +- Event-log hash chaining + signed root (§6.4, when an implementation claims Extended or higher). +- DID Document pinning (§17.4) at Extended and Authorization-Required. +- Per the `Authorization-Required` tier, the four concrete checks of §17.9. + +**What PACT v2.0.2 cannot guarantee through normative requirements alone:** + +- That a server *actually performs* the checks it advertises in its profile. The conformance model is self-certifying (§15.5 `pact_introspect_tier` lets verifiers probe a small number of these behaviourally; the full set is not probeable). A server claiming `conformanceLevel: "authorization-required"` may or may not enforce all four checks; a verifier accepting proofs from that server is trusting the claim. +- That "cryptographic erasure" of a tombstoned credential's private key material is actually irreversible (§17.10 — operational, not cryptographic). +- That a `did:web` principal has not been silently rebound by a domain takeover the verifier cannot independently detect. The DID Document pinning rule (§17.4) catches changes the verifier observes post-pinning but cannot retroactively detect a takeover that happened before first observation. +- That an attestation chain (§17.11) accepted by a delegation-supporting implementation actually walks back to a valid principal — v2.0 leaves chain semantics implementation-defined. +- That a multi-channel notification of `recovery-initiated` (§23.4) actually reached the operator-of-record. The notification channel is implementation-defined. + +**What a verifier should therefore assume:** + +- The trust floor in any PACT interaction is the *weakest implementation in the trust graph*, not the strongest. A federation of three impls — two at `Authorization-Required` and one at `Core` — operates at Core-level trust against any principal whose authoritative registry is on the Core impl. +- "Conformance" is not a single binary. It's a stack: (i) the protocol's normative requirements, (ii) the implementer's declared profile, (iii) what an external probe / audit can independently verify. Cross-org consumers SHOULD probe (§15.5) before extending trust, not rely on the declared tier alone. +- For high-stakes operations, prefer hardware-bound principals (`did:key` via FIDO2/HSM) over `did:web`; require Authorization-Required from counterparties and verify it via probe; and treat the credential-registry mutation log as part of the audit surface, not just the snapshot. + +This section is non-normative framing. It does not impose new requirements; it makes the existing trust model explicit so implementers and consumers calibrate accordingly. + +**Manifest visibility (v2.0.3+).** The §4.4 active-session-manifest endpoints (`/_status` and `/manifest`) make existing fabric state easier to *read* — they do NOT relax the disclosure rules under which that state is shared. The manifest is **not** a privacy bypass. A server MUST apply the same cross-organisation, clearance, and graduated-disclosure (§10.3) rules to manifest responses as it does to the underlying per-endpoint reads: + +- A counterparty's `principal_id`, `agent_name`, contact metadata, and other PII fields are returned in `_status.members[]` and `manifest.counterparties[]` only to the extent §17 already permits the caller to learn them. Where a counterparty's disclosure level (per §10.3) is "Constraint" or "Category," the field MUST be elided (key omitted), NOT set to `null` or an empty string — omission preserves the distinction between "field not present" and "field present and explicitly empty." +- Pending obligations (§6.5) belonging to a counterparty are surfaced in `_status.pending_obligations[]` filtered by the same rules; the caller sees only obligations whose existence it would already be entitled to learn from observing the underlying events. A summary count (`counterparties[].pending_obligation_count`) MAY be returned even when individual obligation entries are elided, but implementations SHOULD weigh whether the count itself leaks too much (e.g. a counterparty's overload signal) for the deployment's threat model. +- Last-seen timestamps (`members[].last_seen`, `counterparties[].last_seen`) are coarse-grained liveness signals and are RECOMMENDED to be served at second-precision or coarser — not millisecond — to avoid being weaponised as a timing side channel against the counterparty's local workflow. +- A caller that is itself **not a member** of the fabric MUST NOT receive a manifest. `/manifest` responds `403 auth.forbidden`; `/_status` responds `403 agent.not_joined` (or `404` if the implementation prefers fabric-existence hiding) — never a partially-redacted snapshot. +- The atomicity guarantee of `_onboard` (§4.4.5) is independent of manifest visibility: a rejected onboard does not leak through the manifest because no membership ever existed. + +In short: manifest endpoints are *aggregators of state the caller would already be entitled to compute by other means*, not new disclosure surfaces. If a counterparty's name is hidden from the caller via the §10.3 graduated-disclosure rules, it stays hidden in the manifest. + +--- + +## 18. Attestation Format Reference (v2.0) + +> **Status:** v2.0 normative. Defines the credential types a PACT verifier MAY accept as proof of a HumanPrincipal's authorization. v2.0 defines **two** first-class types; implementations MAY support additional custom types (§18.5). + +### 18.1 Common envelope + +Every attestation, whatever its `type`, uses the `authorization_proof` envelope of §17.6 and additionally carries: + +| Field | Required | Description | +|---|---|---| +| `alg` | Yes | Algorithm identifier for this attestation's signature / match (e.g. `"webauthn-es256"`, `"resemblyzer-v1"`). | +| `alg_version` | Yes | Version of `alg` — model swaps / retrains MUST NOT silently invalidate enrolled references. | + +`challenge_nonce` replay protection (§17.6) is mandatory for all types. + +| Type | Based On | Hardware Required | Privacy | Offline Verify | Maturity | +|------|----------|-------------------|---------|----------------|----------| +| `fido2-assertion` | WebAuthn / FIDO2 | Yes (authenticator) | High (no biometric leaves device) | Yes | Established standard | +| `voice-biometric` | speaker-verification embedding + utterance-hash binding | Yes (microphone) | High (zero-knowledge embedding match; audio never leaves device) | Yes | RFC ([#3](https://github.com/TailorAU/pact/issues/3)) — crypto detail + test vectors land via HMAN's PR | + +### 18.2 `fido2-assertion` + +**Based on:** [WebAuthn Level 2](https://www.w3.org/TR/webauthn-2/) / FIDO2. + +The human activates a FIDO2 authenticator (security key, platform authenticator, phone). The authenticator signs over the PACT message hash + `challenge_nonce`. The proof carries the WebAuthn `authenticatorData`, `clientDataJSON`, and `signature`. + +**Verification:** standard WebAuthn assertion verification — verify the signature against the enrolled public key for `credential_id`; verify the relying-party ID; confirm the User Presence (UP) flag, and the User Verification (UV) flag if the operation requires it. + +**Use when:** high-security and cross-organisation actions; financial transactions. Preferred when both parties have hardware-authenticator infrastructure. + +### 18.3 `voice-biometric` + +> **Authoritative spec:** HMAN's [#3](https://github.com/TailorAU/pact/issues/3) PR. The text below is the structural contract v2.0 commits to; the cryptographic detail and test vectors land via that PR. + +**Based on:** speaker-verification embedding similarity + utterance-hash binding. + +**Distinguishing property:** captures *intent* (the human spoke a specific challenge utterance), not just *presence* (a passkey tap). + +**Envelope additions:** + +```json +"match": { "alg": "resemblyzer-v1", "alg_version": "1.0", "score": 0.91, "threshold": 0.75 }, +"utterance_hash": "base64url-...", +"verifier_id": "did:web:bridget.example" +``` + +- `match` — the speaker-verification result. The `match` sub-object shape is reused by any future biometric modality (face, gait, keystroke dynamics). +- `utterance_hash` — **normative**. A hash of the spoken utterance, binding the assertion to *what was said*. Non-normative note: a verifier requiring "approve transfer of $5000 to Bridget" MUST reject a valid voice match against the wrong `utterance_hash`. +- `challenge_nonce` MUST be verifier-signed OR `verifier_id` MUST be present (replay protection across verifiers). + +**Hard constraint:** raw audio MUST NOT leave the verifying device. Only the embedding score (inside `match`), the `utterance_hash`, and the signed assertion cross the wire. No raw biometric data enters the event log (§17.10). + +**Reference embedding algorithm:** `resemblyzer-v1` (non-normative; HMAN's #3 PR pins the normative set and the versioning policy, plus the signature suite, key wrapping, threshold-selection guidance, and test vectors). + +**Use when:** real-time intent-bearing authorization on voice channels (calls, dictation, multi-party negotiation) where presence-only credentials are insufficient. + +### 18.4 Combining types + +A high-stakes operation MAY require two attestation types presented together (e.g. `fido2-assertion` for possession + `voice-biometric` for intent). When required, both proofs MUST verify independently and MUST carry the same `principal_id`. + +### 18.5 Custom Attestation Types + +Implementations MAY define custom attestation types using reverse-domain notation (e.g., `com.example.voice-print`, `au.gov.mygovid`). A custom type MUST: + +- use the `authorization_proof` envelope defined in §17.6 plus the common fields of §18.1; +- document its signing and verification mechanics; +- be declared in the implementation's `.well-known/pact-credentials.json` under a `supported_types` array. + +Whether custom types additionally require pre-registration in a central registry is deferred to a reviewed PR (current lean: naming-convention only). + +> *Note:* `vc-jwt`, `biometric-hash`, and `passphrase-signed` (which appeared in the v1.2-draft attestation list) are **not** v2.0 first-class types — an implementation that needs them carries them as custom types under this section. + +### 18.6 Deferred to HMAN's #3 PR + +- `voice-biometric` normative crypto: signature suite, key wrapping, the normative set of embedding algorithms + versioning policy, threshold-selection guidance, full replay-protection requirements. +- Test vectors for `voice-biometric` (and `fido2-assertion`) — go in `spec/v2.0/conformance/extended/attestation/`. +- The HMAN reference-stack citation (Resemblyzer + Fernet + PBKDF2 + per-session re-arm + hash-chained audit), conditional on the test vectors landing. + +--- + +> **Sections 19–22 (reserved for v2.1).** §19–20 — ephemeral negotiation Sessions + the Mandate primitive (RFC [#14](https://github.com/TailorAU/pact/issues/14)); §21 — push delivery (signed event webhooks); §22 — service-account authentication. v2.0 ships without these; they will land in a follow-on minor release (`spec/v2.1/`) once RFC #14 converges and T4 / T5 are designed. Design records: `docs/v2-plan.yaml` (tracks T3, T4, T5). + +## 23. Agent Identity Lifecycle (v2.0) + +> **Status:** v2.0 normative. Resolves issue [#13](https://github.com/TailorAU/pact/issues/13) Q7 (agent identity persistence + operator transfer). + +### 23.1 What an `agentId` is + +When an agent joins a resource (`agent.join`, §4.1) it is assigned an `agentId` — a **server-side principal** that persists across sessions, machines, and CLI invocations. The `agentId` is distinct from the **HumanPrincipal** (§17.4) that operates the agent: the HumanPrincipal answers "which human authorized this," the `agentId` answers "which agent identity is doing the work." + +`agentId` is a URN of the form `urn:pact:agent:{opaque}` (or a DID, where the server supports it). The format is **portable** — designed so a future PACT federation (v2.1+) can resolve an `agentId` across servers — even though a v2.0 server treats it as server-local. Implementations MUST NOT mint an `agentId` that is only meaningful within a single document or session. + +### 23.2 The agent ↔ operator binding + +Every `agentId` has an **operator-of-record**: the HumanPrincipal currently responsible for the agent. The binding is recorded in the event log (`pact.agent.joined` carries it; `pact.agent.transferred` / `pact.agent.recovered` update it) and is independent of the agent's `agentId` — i.e., the operator can change without the `agentId` changing. This is the property AloomU's sovereignty posture requires: "operator-of-record can change without losing agent identity continuity." + +### 23.3 Cooperative operator transfer + +When an operator hands an agent over voluntarily (succession, role rotation, off-boarding): + +1. The **outgoing operator** signs a transfer attestation: `{ agentId, from: , to: , effective_at, reason }`, signed with the outgoing principal's key (§17.6 envelope semantics). +2. The **incoming operator** countersigns the same attestation. +3. The server validates both signatures, rotates the binding, and emits `pact.agent.transferred` (and, if the agent's own signing key rotates, `pact.agent.identity-rotated`). The `agentId` is unchanged. + +A transfer with only one valid signature MUST be rejected. + +### 23.4 Hostile / non-cooperative transfer (recovery) + +When the outgoing operator cannot or will not co-sign (fired, deceased, unreachable, adversarial), one of two recovery paths applies. An implementation that runs sovereignty-posture deployments SHOULD support at least one; both are OPTIONAL at every conformance tier. + +- **M-of-N recovery.** The `agentId` was enrolled with a recovery quorum — a governance group of N principals, M of whom must co-sign a recovery attestation `{ agentId, to: , effective_at, reason }`. On a valid M-of-N signature set the server rotates the binding and emits `pact.agent.recovered`. The `agentId` is unchanged. Quorum size (M, N) is implementation-defined; the spec sets no minimum but RECOMMENDS M ≥ 2. + - **Quorum enrollment is implementation-defined for v2.0.** Implementations supporting M-of-N recovery MUST document, in their `/.well-known/pact.json` profile or accompanying impl notes, (a) the enrollment endpoint and authentication requirements, (b) the canonical attestation format for the enrollment record, and (c) who is authorized to initiate enrollment for a given `agentId`. v2.1 will normalize an `agent.enroll-quorum` operation; until then, cross-implementation interop on recovery is not guaranteed. +- **Abandoned-agent reset.** Where no recovery quorum was enrolled, an administrator MAY mint a **new** `agentId` for a successor agent and emit `pact.agent.abandoned` against the old one, carrying an attestation of why (the old operator is unreachable / off-boarded / etc.). The old `agentId`'s history is preserved and remains citable; it is simply marked abandoned and accepts no further operations. + - **Who counts as an "administrator" is implementation-defined for v2.0.** At minimum, the administrator's action MUST itself carry a valid `authorization_proof` (§17.6), and the implementation SHOULD document the principal(s) authorized to perform abandoned-agent reset (typically a small, named operations group rather than any holder of an admin token). + +Both recovery paths take effect only after a **time-locked dispute window** (implementation-configurable; default 72 hours) during which the current operator-of-record, if reachable, can veto. The window is announced via `pact.agent.recovery-initiated` (carrying `effective_at`). + +### 23.5 Event types + +``` +pact.agent.transferred // Cooperative operator transfer completed (binding rotated, agentId unchanged) +pact.agent.identity-rotated // Agent's own signing key rotated (may accompany a transfer) +pact.agent.recovery-initiated // M-of-N recovery or abandoned-agent reset started; dispute window open +pact.agent.recovery-disputed // Operator-of-record (or proxy) lodged a dispute during the window; recovery suspended for human resolution +pact.agent.recovered // M-of-N recovery completed (binding rotated, agentId unchanged) +pact.agent.abandoned // Abandoned-agent reset completed (old agentId frozen; successor has a new agentId) +``` + +### 23.5b Multi-channel notification and dispute (v2.0.2+; closes the dispute-window-starvation attack) + +The 72-hour default dispute window (§23.4) is only as safe as the operator-of-record's ability to actually receive `pact.agent.recovery-initiated` in time. v2.0 left the notification channel implementation-defined; an attacker who has captured M-of-N quorum keys and can also DoS / spoof / hijack a single notification channel can ride the window out unopposed. v2.0.2 strengthens this: + +- An implementation supporting M-of-N recovery MUST emit the `pact.agent.recovery-initiated` event to **at least two distinct notification channels** from the agent's enrolled `notificationChannels` list (registered at agent join time or rotated via cooperative transfer). Channels SHOULD include heterogeneous transports — e.g. one IP-network webhook AND one out-of-band channel (email, SMS, push to a hardware authenticator, a signed entry on a public log). Implementations using only one channel MUST advertise this limitation in their Implementation Profile (§15.1) as `capabilities.recoverySingleChannel: true` so consumers can downweight the implementation's recovery-safety claim. +- The recovery-initiated event MUST include an `external_anchor_uri` field — a URL on an append-only log (§17.8 mutation-log style, or an external transparency log) where the event is also published. A consumer that observes the recovery on the anchor but not on its primary channel SHOULD treat the primary channel as compromised. +- The operator-of-record (or any quorum member who did NOT co-sign the recovery, or any human with administrative oversight as defined by the implementation) MAY emit a `pact.agent.recovery-disputed` event during the window. A valid dispute event suspends the recovery pending human resolution (the implementation MUST emit `pact.escalation.human` referencing the agentId and the dispute, and MUST NOT auto-complete the recovery until a human-resolution event clears it). +- The dispute event itself MUST carry an `authorization_proof` from the disputing principal. A dispute from a principal not authorized to dispute (per the implementation's documented rules — typically the current operator-of-record OR any non-co-signing quorum member) MUST be rejected with `pact.trust.violation`. +- Default behaviour when the dispute window expires without a dispute event: the recovery proceeds. Implementations MAY require an additional explicit "no-dispute" confirmation step at the `Authorization-Required` tier. + +### 23.6 Conformance + +| Level | Requirement | +|---|---| +| **Core** | `agentId` MUST persist across sessions and be server-portable in form (§23.1). Cooperative transfer (§23.3) is OPTIONAL. | +| **Extended** | SHOULD support cooperative operator transfer (§23.3). Implementations supporting recovery (§23.4) SHOULD provide multi-channel notification (§23.5b). | +| **Authorization-Required** | The transfer / recovery attestations are themselves `authorization_proof`-bearing (§17.6); a recovery quorum's signatures MUST each verify as valid HumanPrincipal proofs. Implementations supporting recovery MUST provide multi-channel notification + external anchor URI + dispute support (§23.5b). | + +M-of-N recovery and abandoned-agent reset (§23.4) are OPTIONAL at every tier but RECOMMENDED for deployments that commit to operator-independent identity continuity. + +### 23.7 Open Questions (deferred to a reviewed PR) + +1. **Cross-server portability** — `agentId` is portable in *form*; the *resolution protocol* (how server B resolves an `agentId` minted on server A) is v2.1 federation work. +2. **Trust decay for transferred identities** — does an agent's reputation / trust level carry across a hostile recovery, or reset? +3. **Recovery quorum minimum** — spec-fixed minimum M, or implementation-defined? (Current: implementation-defined, RECOMMEND M ≥ 2.) + +--- + +## Appendix A: API Schemas (Unmediated + Mediated) + +### A.1 Error Response Format + +All PACT API error responses MUST follow this structure: + +```json +{ + "errors": [ + { + "code": "section.locked", + "description": "Section is locked by another agent.", + "metadata": { "lockedBy": "agent-xyz", "expiresAt": "2026-03-02T12:00:00Z" } + } + ] +} +``` + +The `errors` array contains one or more error objects. Each error has a machine-readable `code` and a human-readable `description`. The optional `metadata` field carries structured context (e.g., who holds the lock, retry-after seconds). + +#### Standard Error Codes + +| Code | HTTP Status | Meaning | +|---|---|---| +| `auth.unauthorized` | 401 | Missing or invalid API key / bearer token | +| `auth.forbidden` | 403 | Insufficient trust level for this operation | +| `agent.not_joined` | 403 | Agent has not joined this document | +| `agent.already_joined` | 409 | Agent is already registered on this document | +| `section.not_found` | 404 | Section ID does not exist in the document | +| `section.locked` | 409 | Section is locked by another agent | +| `proposal.not_found` | 404 | Proposal ID does not exist | +| `proposal.conflict` | 409 | Conflicting proposal on the same section | +| `proposal.invalid_status` | 400 | Cannot perform action on proposal in its current status | +| `document.not_found` | 404 | Document does not exist | +| `document.locked` | 423 | Entire document is frozen | +| `rate.limited` | 429 | Rate limit exceeded | + +Implementations MAY define additional error codes under custom namespaces (e.g., `classification.access_denied`). All custom codes MUST use the dot-delimited format. + +### A.2 Request/Response Schemas + +Full JSON Schema (2020-12) definitions for all API endpoints are available in the [schemas directory](https://github.com/TailorAU/pact/tree/main/spec/v2.0/schemas). Older spec versions (v0.3 / v0.4 / v1.0 / v1.1) use draft-07; v2.0 schemas were bumped to draft 2020-12 on 2026-05-13. + +| Schema | Endpoint | Description | +|---|---|---| +| `join-request.json` | `POST /join` | Agent registration request | +| `join-response.json` | `POST /join` | Agent registration response | +| `proposal-request.json` | `POST /proposals` | Edit proposal creation | +| `proposal-response.json` | `POST /proposals` | Edit proposal with constraint warnings | +| `intent-request.json` | `POST /intents` | Intent declaration | +| `constraint-request.json` | `POST /constraints` | Constraint publication | +| `salience-request.json` | `POST /salience` | Salience score assignment | +| `lock-request.json` | `POST /sections/{id}/lock` | Section lock with TTL | +| `done-request.json` | `POST /done` | Agent completion signal | +| `ask-human-request.json` | `POST /ask-human` | Human escalation | +| `error-response.json` | All endpoints | Standard error envelope | +| `event.json` | Events / polling | Event structure (Section 6) | +| `authorization-proof.json` | Any message | Proof-of-human-intent envelope (Section 17.6) | +| `principal-registry.json` | `/.well-known/pact-credentials.json` | Credential registry structure (Section 17.8) | +| `agent-identity.json` | Transfer / recovery | Agent identity lifecycle — transfer & recovery attestations, recovery-quorum enrollment (Section 23) | +| `fabric-status.json` | `GET /_status` | Fabric-wide snapshot (v2.0.3 — §4.4.1) | +| `fabric-manifest.json` | `GET /manifest` | Caller-scoped manifest of constraints, obligations, counterparties (v2.0.3 — §4.4.2) | +| `heartbeat-request.json` | `POST /_heartbeat` | Bidirectional heartbeat request (v2.0.3 — §4.4.3) | +| `heartbeat-response.json` | `POST /_heartbeat` | Bidirectional heartbeat response (v2.0.3 — §4.4.3) | +| `mark-read-request.json` | `POST /mark-read` | Event-range acknowledgement request (v2.0.3 — §4.4.4) | +| `mark-read-response.json` | `POST /mark-read` | Event-range acknowledgement response (v2.0.3 — §4.4.4) | +| `onboard-request.json` | `POST /_onboard` | Atomic join + constrain request (v2.0.3 — §4.4.5) | +| `onboard-response.json` | `POST /_onboard` | Atomic join + constrain response (v2.0.3 — §4.4.5) | +| `pending-obligation.json` | `/_status`, `/manifest` | Shared pending-obligation shape (v2.0.3 — §6.5) | + +> **Note:** `authorization-proof.json` carries the §18.1 common fields and the `voice-biometric` additions inline (`match`, `utterance_hash`, `verifier_id`); the full normative `voice-biometric` schema — signature suite, embedding-algorithm versioning — lands via HMAN's [#3](https://github.com/TailorAU/pact/issues/3) PR (§18.6). + +### A.3 Pagination + +List endpoints (proposals, agents, events, intents, constraints) support cursor-based pagination. + +**Request parameters:** + +| Parameter | Type | Default | Description | +|---|---|---|---| +| `cursor` | string? | `null` | Opaque cursor from a previous response. Omit for the first page. | +| `limit` | integer? | 50 | Maximum items to return (1–200). | + +**Response envelope:** + +```json +{ + "items": [ ... ], + "nextCursor": "eyJzIjoxMjM0fQ==", + "hasMore": true +} +``` + +| Field | Type | Description | +|---|---|---| +| `items` | array | The requested resources. | +| `nextCursor` | string? | Pass as `cursor` in the next request. `null` when no more pages. | +| `hasMore` | boolean | `true` if additional pages exist. | + +Implementations MUST return items in a stable, deterministic order (typically by creation time ascending). + +--- + +*PACT Specification v2.0.3 — released 15 May 2026 (third patch on the v2.0 line; v2.0 was 14 May 2026, v2.0.1 and v2.0.2 also 15 May 2026 earlier in the day; v2.0.3 adds fabric onboarding & session awareness — see "What's New in v2.0.3" at the top).* + +*Reference implementation: [Tailor](https://tailor.au) by [TailorAU](https://github.com/TailorAU) — see [Tailor Implementation Notes](./PACT_TAILOR_IMPLEMENTATION.md) for implementation-specific details.* + +> **Standalone spec:** [github.com/TailorAU/pact](https://github.com/TailorAU/pact) — vendor-neutral specification. Synced manually with this file via coordinated PRs (most recently [TailorAU/tailor-app#1616](https://github.com/TailorAU/tailor-app/pull/1616)). + + +--- + +## §24 Matters — multi-fabric deal-room workspaces + +## 24.1 Problem statement + +A v2.0 **Fabric** scopes a negotiation envelope to a single resource. The +v2.1 **Parley** (RFC #14) adds an ephemeral, bilateral, one-topic mode. +Neither shape addresses the case where a single coordinated engagement +spans multiple peer resources and needs: + +- A shared participant set across all those resources +- A typed side-channel for cross-resource coordination +- A cross-resource view of "where am I and what do I owe" that fabric-scoped + §4.4.2 cannot produce on its own + +Canonical examples: + +- **M&A**: term sheet + diligence binder + comms thread, two parties +- **Legal engagement**: engagement letter + draft instrument + retainer + schedule, same client and counsel throughout +- **Procurement**: RFP + N bids + negotiation thread with the winning bidder + (with §17 information barriers between bidders) + +## 24.2 Concept + +A **Matter** is a long-lived container that: + +- References N peer fabrics by `resourceId` (each fabric remains unchanged + and independently usable) +- Holds a shared participant set (members are `§17` `HumanPrincipal` + + `§23` `agentId`) +- Exposes a typed event-channel for cross-resource coordination + (`pact.matter.message`) +- Surfaces a caller-scoped cross-fabric manifest (§24.7), extending §4.4.2 +- Has its own lifecycle (`open → active → closed`) independent of the + attached fabrics' lifecycles + +**Terminology note (per maintainer call 2026-05-24).** The primitive is +"Matter" (capitalised when referring to this primitive, lower-case "matter" +in regular English prose). Same disambiguation pattern v2.0.3 §4.4 used to +introduce "fabric". A Matter is NOT: + +- A composition primitive — fabrics inside stay independent; this is not + D2-rejected attached-resource composition +- A chat product — the side-channel is typed events on the standard PACT + event channel, NOT a free-form chat protocol +- A federation primitive — federation is a v2.1+ non-goal; Matter endpoints + MUST NOT preclude federation (portable IDs, canonical JSON transcripts) + +## 24.3 Membership model + +Each Matter has two member roles: + +- **owner** — may add/remove members, attach/detach fabrics, post to the + side-channel, close the Matter. The opener is the first owner. +- **participant** — may post to the side-channel and read the manifest. + +Matter membership is **eligibility, not enrollment** (resolves RFC OQ2). A +member of a Matter still needs to join attached fabrics individually via +the §4.4.5 `_onboard` flow. The Matter manifest (§24.7) surfaces +`caller_is_fabric_member: false` for fabrics the caller has not yet joined, +so the caller can see eligibility without it being silently invoked. + +A fabric MAY belong to multiple Matters (resolves RFC OQ1). Disclosure rules +per §17.13 handle the visibility split: a caller who is a member of two +Matters that both attach the same fabric sees that fabric in both manifests. + +Cross-org membership: at the **Authorization-Required** conformance tier +(§17.9), adding a cross-org member to a Matter MUST carry a valid §17.6 +`authorization_proof` from the inviting principal. Same rule as cross-org +fabric messages, applied at Matter scope. + +## 24.4 Lifecycle and endpoints + +``` +phase: open ──→ active ──→ closed + │ │ │ + │ │ └─ pact.matter.closed; fabrics detach but persist + │ └─ first member added OR first fabric attached + └─ pact.matter.opened +``` + +| Method | Path | §-ref | Description | +|---|---|---|---| +| POST | `/api/pact/matters` | §24.5 | Open a new Matter (caller becomes owner). | +| GET | `/api/pact/matters` | §24.5 | List Matters (additive — implementation MAY require auth scoping). | +| GET | `/api/pact/matters/{id}` | §24.5 | Get a Matter's caller-visible state. | +| POST | `/api/pact/matters/{id}/members` | §24.6 | Add a member (owner-only). | +| POST | `/api/pact/matters/{id}/fabrics` | §24.6 | Attach a fabric (owner-only). | +| DELETE | `/api/pact/matters/{id}/fabrics/{resourceId}` | §24.6 | Detach a fabric (owner-only; fabric persists). | +| POST | `/api/pact/matters/{id}/messages` | §24.8 | Post a typed side-channel message. | +| GET | `/api/pact/matters/{id}/messages` | §24.8 | List the side-channel. | +| GET | `/api/pact/matters/{id}/manifest` | §24.7 | Caller-scoped cross-fabric manifest. | +| POST | `/api/pact/matters/{id}/close` | §24.9 | Close the Matter (owner-only). | + +## 24.5 Opening and reading + +`POST /api/pact/matters` opens a Matter. The caller becomes the first +member with role `owner`. The body: + +```json +{ + "name": "Project Atlas acquisition", + "opened_by_display": "Knox" +} +``` + +The response includes the new `matter_id`, `phase: "open"`, and the +emitted `pact.matter.opened` event id. See `matters-schemas/matter-create-request.json` +and `matters-schemas/matter-create-response.json`. + +`GET /api/pact/matters/{id}` is gated by §17.13 caller-scoping: a non-member +MUST receive `403 auth.forbidden`. + +## 24.6 Attach / detach and member management + +Attaching a fabric is **a link, not a merge** — the fabric's members, +constraints, proposals, and events are NOT affected, and the fabric's own +endpoints continue to work independently. The Matter records only the +attachment metadata (when, by whom). + +Detaching is symmetric and explicitly does NOT close the underlying fabric +(resolves RFC OQ3). A detached fabric remains queryable directly via its +own `/api/pact/{fabricId}/...` endpoints. + +Member management is owner-only. Adding the same principal twice is +idempotent (`added: false` in the response). + +## 24.7 Cross-fabric manifest (§24.7) + +`GET /api/pact/matters/{id}/manifest` returns a caller-scoped aggregate +view of the Matter — the "where am I across all attached fabrics" answer +that fabric-scoped §4.4.2 cannot produce alone. Shape: + +```json +{ + "matter_id": "mtr_xyz", + "spec_version": "2.2", + "phase": "active", + "caller": { "principal_id": "did:web:knox.example", "role": "owner", ... }, + "counterparties": [ /* §17.13 reduced peers */ ], + "fabrics": [ + { + "resourceId": "fab_term_sheet", + "phase": "negotiating", + "open_proposals": 1, + "pending_obligation_count_for_caller": 0, + "caller_is_fabric_member": true, + ... + } + ], + "pending_obligations_across_fabrics": [ + { "fabric_id": "fab_diligence", "obligation_id": "obl_...", "kind": "respond", ... } + ], + "side_channel": { "message_count": 7, "latest_message_at": "..." }, + "snapshot_at": "..." +} +``` + +**Cross-fabric obligation aggregation** is the load-bearing feature: it +collects every undischarged §6.5 obligation whose `principal_id` matches the +caller across every attached fabric and surfaces them in one list. This is +how a Matter member answers "what do I owe in this engagement" without +manually walking each fabric. + +**§17.13 disclosure rules** apply unchanged: counterparties on different +registrable domains (different DID methods, or different eTLD+1 for +did:web) appear with `cross_org: true` and PII (contact, raw constraints) +elided. Fabric-level details (raw constraints, raw obligations) are NOT +included at Matter scope — the caller queries the individual fabric's +`/manifest` for those. + +## 24.8 Side-channel — typed events only + +The Matter side-channel is an append-only log of `pact.matter.message` +events. Each entry MUST carry: + +- `id` — server-minted message id +- `sender_principal` — principal of the poster (a member of the Matter) +- `posted_at` — ISO 8601 timestamp +- `body.format` — currently `"text"`; future formats reserved +- `body.content` — the textual payload +- `references` (optional) — `{ fabric_id, section_id? }` cross-link to an + attached fabric and optionally a section within it + +The wire format is structured (typed events on the PACT event channel, per +§6). Implementations MAY render this as a chat UI; the protocol does NOT +define presence, typing indicators, reactions, threads, or any other chat- +product surface. Future `body.format` values (`proposal-ref`, +`obligation-ref`, etc.) extend the typed-event vocabulary without changing +the envelope. + +A non-member MUST receive `403 auth.forbidden` on both `POST` and `GET` +to the messages endpoint. Disclosure boundary: **Matter membership IS the +disclosure boundary for side-channel content** — within a Matter, every +member sees every message. Cross-org content reduction is not applied +inside the side-channel (unlike §4.4.2 for fabric data) because the side- +channel exists precisely to allow cross-org coordination; reducing it would +defeat its purpose. The cross-org boundary is enforced at the +*membership* layer (§17 proof to join), not at the *content* layer. + +## 24.9 Closure + +`POST /api/pact/matters/{id}/close` is owner-only. It transitions the +Matter to `phase: "closed"`, emits `pact.matter.closed` carrying the +caller-provided `outcome` string (free-form: `"deal-signed"`, +`"walked-away"`, `"engagement-complete"`, etc.) and the list of +`detached_fabrics` (the resourceIds that were attached at closure). + +The attached fabrics are **NOT closed** by Matter closure (resolves RFC +OQ3). They persist and remain queryable directly. The Matter records that +they were attached at the time of closure as an audit trail; their +post-closure lifecycle is independent. + +A closed Matter accepts no further mutations: `add member`, `attach`, +`detach`, `message`, and re-`close` all return `409 matter.closed`. +Manifest queries on a closed Matter continue to work — closed Matters are +read-historical, not deleted. + +## 24.10 Event types + +| Event type | Emitted by | Payload fields | +|---|---|---| +| `pact.matter.opened` | open | `matter_id`, `name`, `opened_by` | +| `pact.matter.member-added` | add member | `matter_id`, `added_principal`, `added_role`, `added_by` | +| `pact.matter.fabric-attached` | attach | `matter_id`, `resourceId`, `attached_by` | +| `pact.matter.fabric-detached` | detach | `matter_id`, `resourceId`, `detached_by` | +| `pact.matter.message` | message | `matter_id`, `message_id`, `sender`, `body`, optional `references` | +| `pact.matter.closed` | close | `matter_id`, `closed_by`, `outcome`, `detached_fabrics[]` | + +Matter events have their own per-Matter `sequenceNumber` domain (analogous +to fabric events being per-fabric). Cross-cutting integrity rules (hash +chain per §6.4, retention per §6.3) apply at the Matter event log the same +way they do at the fabric event log. + +## 24.11 Conformance impact + +- **Core**: Matters OPTIONAL. Core implementations MAY remain fabric-only. +- **Extended**: SHOULD support Matters. MUST honour §17.13 reduction on + `counterparties` in the manifest when Matters are advertised in the + §15.1 Implementation Profile. +- **Authorization-Required**: cross-organisation Matter membership MUST + carry valid §17.6 `authorization_proof` at the `POST /members` boundary. + +**Profile flag**: servers advertise support via `capabilities.matters: true` +in the §15.1 Implementation Profile (mirrors the v2.0.3 +`capabilities.atomicOnboard` and the planned v2.1 `capabilities.parleys`). + +**Runner**: a new conformance-runner `kind: matter` handles Matter-lifecycle +vectors (open → attach → message → manifest → close), following the same +fail-closed discipline as the v2.0.4 voice contract. + +## 24.12 Open questions + +These have been resolved at the leans documented in the RFC for v0.1 but +remain subject to RFC #18 reviewer feedback: + +1. **Multiple Matter membership of one fabric** — implemented as allowed + (OQ1, lean adopted). +2. **Member-to-fabric propagation** — eligibility-only (OQ2, lean adopted). +3. **Closure cascade** — does not cascade (OQ3, lean adopted). +4. **Mediator scope** — implementation defers Mediator-spans-multiple- + fabrics to a later iteration; v2.2 keeps Mediators per-fabric. +5. **Cross-Matter references** — explicitly deferred to v2.3+. +6. **Authorization-Required tier semantics** — confirmed; cross-org member + add requires §17.6 proof. +7. **Prose disambiguation** — Terminology note adopted at top of §24. + +## 24.13 Migration from v2.0/v2.1 + +Additive. Implementations remain v2.0-conformant if they ignore the new +endpoints. The §17.6 `authorization_proof` requirement at cross-org member +add is additive at the Authorization-Required tier only. + +--- + +*Promotion of this draft to `spec/v2.2/SPECIFICATION.md` requires explicit +maintainer sign-off per AGENTS.md rule 3. RFC #18 is the public review +vehicle; this draft will fold into the v2.2 carry-forward when that opens.* diff --git a/spec/v2.3/conformance/README.md b/spec/v2.3/conformance/README.md new file mode 100644 index 0000000..e6224e7 --- /dev/null +++ b/spec/v2.3/conformance/README.md @@ -0,0 +1,95 @@ +# PACT v2.0 Conformance — scaffold + +This directory is the conformance test scaffold for PACT v2.0 (track T10 in [`docs/v2-plan.yaml`](../../../docs/v2-plan.yaml)). It exists before the v2.0 normative tracks land so every spec PR can be gated on conformance smoke tests from day one. + +## Why a scaffold + +The cold-eye review on the v2 plan (2026-05-12) flagged that tests should ship with normative text, not be retrofitted at the end. T10 lands the scaffold up front; T9 expands it to full coverage before v2.0 freeze. + +## Directory layout + +``` +spec/v2.0/conformance/ +├── README.md — this file +├── test-vector-format.yaml — schema for individual test vectors +├── core/ — Core conformance tests +│ ├── join.yaml +│ ├── leave.yaml +│ ├── intent.yaml +│ ├── constraint.yaml +│ ├── proposal.yaml +│ ├── object.yaml +│ ├── escalate.yaml +│ ├── done.yaml +│ └── poll.yaml +├── extended/ — Extended conformance tests +│ ├── mediated-message.yaml +│ ├── classification-frame.yaml +│ ├── clearance.yaml +│ ├── disclosure-graduated.yaml +│ ├── negotiation.yaml +│ └── attestation/ +│ ├── verify-fido2-valid.yaml — kind: verification (§17.7 happy path) +│ ├── verify-replayed-nonce.yaml — kind: verification (§17.7 step 5) +│ ├── verify-revoked-credential.yaml — kind: verification (§17.7 step 3 + §17.8) +│ └── voice-biometric.yaml — TODO, comes with HMAN's #3 PR +├── authorization-required/ — Authorization-Required tier tests +│ ├── cross-org-rejection.yaml +│ ├── revocation-propagation.yaml +│ └── principal-1to1.yaml — HumanPrincipal is strictly 1:1 (issue #4) +├── sessions/ — T3 Sessions tests (§19–20) +│ ├── open.yaml +│ ├── mandate-enforcement.yaml +│ ├── outcome-routing.yaml +│ └── revocation.yaml +├── push-delivery/ — T4 push tests (§21) +│ ├── subscription-crud.yaml +│ ├── at-least-once.yaml +│ └── signed-envelope.yaml +├── service-account/ — T5 service-account tests (§22) +│ └── lifecycle.yaml +├── identity/ — T7 identity tests (§23) +│ ├── persistence.yaml +│ ├── cooperative-transfer.yaml +│ └── hostile-recovery.yaml +└── backward-compat/ — v1.1-client-against-v2.0-server tests + └── v1.1-core.yaml +``` + +## How tests run + +Each test is a single YAML file conforming to [`test-vector-format.yaml`](test-vector-format.yaml). There are two vector **kinds**: + +- **`kind: http`** (default) — an HTTP request/response recording plus an expected event sequence. An implementation **passes** if, given the recorded request: (1) it returns the recorded response (modulo `body_ignore_fields` — UUIDs, timestamps); (2) it emits the expected events in the recorded order (or any order if `ordered: false`); (3) server state matches `postconditions`. +- **`kind: verification`** — exercises the §17.7 `authorization_proof` verification flow (client-side logic, no HTTP). Given the `proof`, the `registry` / `did_documents` to resolve against, the `verifier_clock`, and the `issued_nonces`, an implementation **passes** if its verification outcome matches `expected.result` (`verified` / `rejected` / `unverifiable`) and, on rejection, the `expected.failing_step` (1–6 from §17.7). + +**Reference runner:** [`./runner/`](./runner/) — `@pact-protocol/conformance-runner` (Node.js + TypeScript, v0.1.0-dev). Loads YAML vectors recursively, dispatches by `kind`, reports pass/fail. `kind: verification` runs unconditionally; `kind: http` SKIPs without `--server `. See [`./runner/README.md`](./runner/README.md) for what's covered vs TODO (schema-mode body matching, `expected_events` subscription, type-specific signature crypto). + +## Self-certification + +Implementations claiming a conformance level run the suite locally and submit results to `docs/IMPLEMENTERS.md` via PR. The PR includes: + +- Implementation name and version +- Claimed conformance level (Core / Extended / Authorization-Required) +- Test result manifest (which tests passed, which failed with reason) +- Contact for the maintainer + +The maintainer reviews the result manifest and adds the implementation to the registry. No external arbiter required — open self-certification with public record. + +## Phase 0 minimum + +Before the v2.0 normative tracks (T1, T2, T7) merge, the scaffold must include: + +- [x] This README +- [x] `test-vector-format.yaml` defining the test vector schema +- [x] A CI hook (`.github/workflows/conformance.yml`) that, on every `spec/` PR, builds + runs the conformance runner against all vectors +- [x] First test vectors per phase-0 track — `core/join/basic` (T0/T10), `extended/attestation/verify-{fido2-valid,replayed-nonce,revoked-credential}` (T1 verification). More T2/T7 vectors TODO +- [x] First-pass conformance runner (`./runner/`) — runs `kind: verification` vectors locally and (with `--server`) `kind: http` vectors against a target server +- [ ] Full runner coverage: `body_match.mode: schema`, `expected_events` subscription, attestation-type-specific signature crypto + +Phase-0 smoke tests are intentionally minimal — they prove the scaffold works, not that every behaviour is covered. T9 expands to full coverage before v2.0 freeze. + +## Notes + +- `vc-jwt`, `biometric-hash`, and `passphrase-signed` from the v1.2-branch RFC are **not** v2.0 first-class types — v2.0 §18 defines only `fido2-assertion` and `voice-biometric` per issue #3. (Implementations MAY support additional custom attestation types under reverse-domain notation.) +- The test-vector format and directory shape are stable; concrete test vectors land as each track's normative text settles. diff --git a/spec/v2.3/conformance/core/join.yaml b/spec/v2.3/conformance/core/join.yaml new file mode 100644 index 0000000..abbd7ef --- /dev/null +++ b/spec/v2.3/conformance/core/join.yaml @@ -0,0 +1,68 @@ +# Conformance test vector — Core: agent joins a resource via invite token. +# Conforms to ../test-vector-format.yaml. Until the real runner lands (T9), +# CI just parses this file; the assertions below describe the expected +# behaviour an implementation MUST satisfy. + +metadata: + id: core/join/basic + description: An agent joins a document with a valid invite token (anonymous BYOK flow). + spec_section: "§4.1, §7.1" + conformance_level: core + track: T0 # carried from v1.1; foundational + +preconditions: + server_state: + resource_id: doc_abc123 + registered_agents: [] # no agents on the document yet + request_context: + auth: none # join-token is anonymous — no API key + +request: + method: POST + path: /api/pact/doc_abc123/join-token + headers: + Content-Type: application/json + body: + agentName: test-bot + token: invite_xyz + +expected_response: + status: 200 + body_match: + mode: subset + value: + agentName: test-bot + contextMode: scoped + body_ignore_fields: + - registrationId # server-assigned + - apiKey # scoped key, server-assigned + - allowedSections # optional, scoping-dependent + +expected_events: + ordered: true + sequence: + - event_type: pact.agent.joined + match: + mode: subset + value: + actorDisplay: test-bot + actorKind: AiAgent + entityType: pact-document + body_ignore_fields: + - id + - epochMs + - sequenceNumber + - actorId + +postconditions: + server_state: + events_added: 1 + resource_modified: false + +failure_classification: + severity: blocker + common_causes: + - Implementation does not support the invite-token (BYOK) flow + - No pact.agent.joined event emitted on join + - actorKind set to something other than AiAgent + - Scoped apiKey not returned in the response diff --git a/spec/v2.3/conformance/extended/attestation/verify-alg-disallowed.yaml b/spec/v2.3/conformance/extended/attestation/verify-alg-disallowed.yaml new file mode 100644 index 0000000..152bc70 --- /dev/null +++ b/spec/v2.3/conformance/extended/attestation/verify-alg-disallowed.yaml @@ -0,0 +1,56 @@ +# Conformance test vector — Extended: alg outside the v2.0.2 whitelist is rejected. +# kind: verification — exercises §17.6 alg whitelist + §17.7 step 3. +# +# Attack scenario: an adversary submits a proof with `alg: HS256` (HMAC, a shared-secret +# algorithm) — broken for the public-key proof-of-human-intent model PACT relies on. +# v2.0.2's alg whitelist forbids non-public-key signatures for fido2-assertion. The +# verifier MUST reject at step 3 before even attempting signature verification. + +kind: verification + +metadata: + id: extended/attestation/verify-alg-disallowed + description: "A fido2-assertion proof with alg=HS256 (outside the v2.0.2 whitelist) — rejected at step 3 by the alg-whitelist rule." + spec_section: "§17.6, §17.7, §18.2" + conformance_level: extended + track: T1 + +verification: + proof: + type: fido2-assertion + principal_id: did:web:knox.example + credential_id: cred_abc123 + challenge_nonce: nonce-001-fresh + verifier_id: did:web:bridget.example + asserted_at: "2026-05-15T10:30:00Z" + signature: "" + alg: HS256 # HMAC — explicitly disallowed for fido2-assertion (§17.6) + alg_version: "1" + attestation_chain: [] + registry: + version: "1.0" + principals: + - id: did:web:knox.example + display_name: Knox Hart + credentials: + - id: cred_abc123 + type: fido2-assertion + public_key: "" + enrolled_at: "2026-01-01T00:00:00Z" + revoked: false + verifier_clock: "2026-05-15T10:30:30Z" + issued_nonces: + - nonce-001-fresh + receiving_verifier_id: did:web:bridget.example + signature_check: real # vector opts into real crypto so the alg-whitelist enforcement is exercised + expected: + result: rejected + failing_step: 3 + emits_trust_violation: true + +failure_classification: + severity: blocker + common_causes: + - Verifier doesn't enforce the §17.6 alg whitelist + - Verifier accepts HMAC / symmetric-key algorithms for fido2-assertion (public-key contract violated) + - Verifier defers alg check until after attempting signature verification (it should reject earlier) diff --git a/spec/v2.3/conformance/extended/attestation/verify-cross-verifier-replay.yaml b/spec/v2.3/conformance/extended/attestation/verify-cross-verifier-replay.yaml new file mode 100644 index 0000000..43f8d08 --- /dev/null +++ b/spec/v2.3/conformance/extended/attestation/verify-cross-verifier-replay.yaml @@ -0,0 +1,54 @@ +# Conformance test vector — Extended: cross-verifier replay rejected by verifier_id equality. +# kind: verification — exercises §17.6 verifier-binding + §17.7 step 5 with the v2.0.2 equality check. +# +# Attack scenario: an adversary captures a proof legitimately produced for verifier A +# (verifier_id = did:web:a.example) and replays it at verifier B (did:web:b.example). +# Without the equality check, presence-of-verifier_id alone satisfies the schema and the +# v2.0.1 runner would accept it. v2.0.2 verifiers MUST reject — equality, not presence. + +kind: verification + +metadata: + id: extended/attestation/verify-cross-verifier-replay + description: A proof bearing verifier_id=did:web:a.example arriving at did:web:b.example — rejected at step 5 by the v2.0.2 equality rule. + spec_section: "§17.6, §17.7" + conformance_level: extended + track: T1 + +verification: + proof: + type: fido2-assertion + principal_id: did:web:knox.example + credential_id: cred_abc123 + challenge_nonce: nonce-fresh-for-A + verifier_id: did:web:a.example # the original target + asserted_at: "2026-05-15T10:30:00Z" + signature: "" + alg: webauthn-es256 + alg_version: "2" + attestation_chain: [] + registry: + version: "1.0" + principals: + - id: did:web:knox.example + display_name: Knox Hart + credentials: + - id: cred_abc123 + type: fido2-assertion + public_key: "" + enrolled_at: "2026-01-01T00:00:00Z" + revoked: false + verifier_clock: "2026-05-15T10:30:30Z" + issued_nonces: [] # B never issued this nonce; nor is it verifier-signed by B + receiving_verifier_id: did:web:b.example # we are B; the proof claims A + signature_check: structural + expected: + result: rejected + failing_step: 5 + emits_trust_violation: true + +failure_classification: + severity: blocker + common_causes: + - Verifier accepts presence-of-verifier_id without checking equality (the v2.0.1 footgun) + - Verifier doesn't carry receiving_verifier_id through its replay check diff --git a/spec/v2.3/conformance/extended/attestation/verify-fido2-forged-signature.yaml b/spec/v2.3/conformance/extended/attestation/verify-fido2-forged-signature.yaml new file mode 100644 index 0000000..3348bf5 --- /dev/null +++ b/spec/v2.3/conformance/extended/attestation/verify-fido2-forged-signature.yaml @@ -0,0 +1,61 @@ +# Conformance test vector — Extended: a fido2-assertion proof carrying a FORGED +# Ed25519 signature is rejected at §17.7 step 3 (signature verification). +# kind: verification — pairs with verify-fido2-real-signature.yaml; same +# principal / credential / nonce / asserted_at, but the first two bytes of the +# signature are flipped so the cryptographic check fails. +# +# This vector exists to close the v2.0.1 "A1: forged-signature pass" attack: +# under v2.0.1 the runner would PASS this vector because it only did structural +# checks. Under v2.0.2 with the real WebAuthn verifier, this vector MUST be +# rejected at step 3. + +kind: verification + +metadata: + id: extended/attestation/verify-fido2-forged-signature + description: A fido2-assertion proof whose signature is a byte-flipped (forged) version of a real Ed25519 signature. The runner MUST reject at §17.7 step 3 — failing to do so means the v2.0.1 "forged signature pass" attack is still open. + spec_section: "§17.6, §17.7, §18.2" + conformance_level: extended + track: T1 + +verification: + proof: + type: fido2-assertion + principal_id: did:web:knox.example + credential_id: cred_real001 + challenge_nonce: nonce-real-001 + asserted_at: "2026-05-15T10:00:00Z" + # Forged: first two bytes of the real signature are XOR-flipped (^ 0xff). + # Same length, same shape, same alg — but does NOT verify against the + # enrolled public key. + signature: "VmRAkmSwAcUhqR8jkwkHBfD7iCBRkd15miQGQX4V_bxGmiGtOZjrTnm7s4C7_8CyPdpl1Lh5h7X_hNcHIuA3Cw" + alg: webauthn-eddsa + alg_version: "1" + attestation_chain: [] + registry: + version: "1.0" + principals: + - id: did:web:knox.example + display_name: Knox Hart + credentials: + - id: cred_real001 + type: fido2-assertion + public_key: "MCowBQYDK2VwAyEAlsqQfe9gY5G3V4rFDCoimZ3BlxoBNYJ_wH6WPGmftsc" + enrolled_at: "2026-05-15T09:00:00Z" + revoked: false + verifier_clock: "2026-05-15T10:00:30Z" + issued_nonces: + - nonce-real-001 + operation_requires_uv: false + signature_check: real + expected: + result: rejected + failing_step: 3 + emits_trust_violation: true + +failure_classification: + severity: blocker + common_causes: + - Runner does not perform real WebAuthn signature verification (the v2.0.1 A1 attack — forged signatures pass structural checks) + - Runner accepts a base64url-decodable string of the right length without verifying it against the public key + - Runner returns `unverifiable` instead of `rejected` for a real-shape signature that fails to verify diff --git a/spec/v2.3/conformance/extended/attestation/verify-fido2-real-signature.yaml b/spec/v2.3/conformance/extended/attestation/verify-fido2-real-signature.yaml new file mode 100644 index 0000000..b691706 --- /dev/null +++ b/spec/v2.3/conformance/extended/attestation/verify-fido2-real-signature.yaml @@ -0,0 +1,64 @@ +# Conformance test vector — Extended: a fido2-assertion proof carrying a REAL +# Ed25519 signature is cryptographically verified (§17.7 step 3 + §18.2). +# kind: verification — exercises the §17.7 verification flow end-to-end including +# real signature verification. +# +# The signature below was generated offline with Node `crypto.generateKeyPairSync('ed25519')` +# and `crypto.sign(null, Buffer.from(challenge_nonce + asserted_at, 'utf8'), privateKey)`, +# with the matching SPKI-DER public key base64url-encoded into the registry. The +# private key has been discarded — only the verifier-side material is in the +# repo. This makes the vector self-contained and reproducible across runs. + +kind: verification + +metadata: + id: extended/attestation/verify-fido2-real-signature + description: A fido2-assertion proof with a real Ed25519 signature over (challenge_nonce + asserted_at), verified end-to-end against the enrolled SPKI-DER public key. Closes the v2.0.1 "forged signature pass" attack — this vector PASSes only if the runner performs real cryptographic verification. + spec_section: "§17.6, §17.7, §18.2" + conformance_level: extended + track: T1 + +verification: + proof: + type: fido2-assertion + principal_id: did:web:knox.example + credential_id: cred_real001 + challenge_nonce: nonce-real-001 + asserted_at: "2026-05-15T10:00:00Z" + # Real Ed25519 signature over UTF-8(challenge_nonce || asserted_at) = + # UTF-8("nonce-real-0012026-05-15T10:00:00Z"). 64-byte raw signature, + # base64url-encoded. + signature: "qZtAkmSwAcUhqR8jkwkHBfD7iCBRkd15miQGQX4V_bxGmiGtOZjrTnm7s4C7_8CyPdpl1Lh5h7X_hNcHIuA3Cw" + alg: webauthn-eddsa + alg_version: "1" + attestation_chain: [] + registry: + version: "1.0" + principals: + - id: did:web:knox.example + display_name: Knox Hart + credentials: + - id: cred_real001 + type: fido2-assertion + # SPKI-DER encoding of the Ed25519 public key, base64url-encoded. + # Decodes to the 44-byte DER SubjectPublicKeyInfo whose raw 32-byte + # public point matches the private key used to produce `signature`. + public_key: "MCowBQYDK2VwAyEAlsqQfe9gY5G3V4rFDCoimZ3BlxoBNYJ_wH6WPGmftsc" + enrolled_at: "2026-05-15T09:00:00Z" + revoked: false + verifier_clock: "2026-05-15T10:00:30Z" # 30s after asserted_at — within ±5min skew + issued_nonces: + - nonce-real-001 + operation_requires_uv: false + signature_check: real + expected: + result: verified + emits_trust_violation: false + +failure_classification: + severity: blocker + common_causes: + - Runner did not perform real WebAuthn / Ed25519 signature verification (v2.0.1 regression) + - Runner does not recognise `webauthn-eddsa` in the alg whitelist + - SPKI-DER public-key decode path is broken + - Runner signs over the wrong payload composition (must be UTF-8(challenge_nonce || asserted_at)) diff --git a/spec/v2.3/conformance/extended/attestation/verify-fido2-valid.yaml b/spec/v2.3/conformance/extended/attestation/verify-fido2-valid.yaml new file mode 100644 index 0000000..44a2ebd --- /dev/null +++ b/spec/v2.3/conformance/extended/attestation/verify-fido2-valid.yaml @@ -0,0 +1,55 @@ +# Conformance test vector — Extended: a valid fido2-assertion proof verifies. +# kind: verification — exercises the §17.7 verification flow. + +kind: verification + +metadata: + id: extended/attestation/verify-fido2-valid + description: A well-formed fido2-assertion authorization_proof, fresh nonce, enrolled non-revoked credential — verifies. + spec_section: "§17.6, §17.7, §18.2" + conformance_level: extended + track: T1 + +verification: + proof: + type: fido2-assertion + principal_id: did:web:knox.example + credential_id: cred_abc123 + challenge_nonce: nonce-001-fresh + asserted_at: "2026-05-13T10:30:00Z" + signature: "" + alg: webauthn-es256 + alg_version: "2" + attestation_chain: [] + registry: + version: "1.0" + principals: + - id: did:web:knox.example + display_name: Knox Hart + credentials: + - id: cred_abc123 + type: fido2-assertion + public_key: "" + enrolled_at: "2026-01-01T00:00:00Z" + revoked: false + verifier_clock: "2026-05-13T10:30:30Z" # 30s after asserted_at — within ±5min skew + issued_nonces: + - nonce-001-fresh # the verifier issued this and hasn't retired it + operation_requires_uv: false + # Structural vector: the `signature` and `public_key` fields above are + # placeholders ("" / ""), so the runner + # cannot perform real WebAuthn signature verification. `signature_check: + # structural` opts this vector into envelope / freshness / replay checks only. + # For a paired vector that exercises real crypto, see + # `verify-fido2-real-signature.yaml`. + signature_check: structural + expected: + result: verified + emits_trust_violation: false + +failure_classification: + severity: blocker + common_causes: + - Verifier doesn't resolve did:web principals + - Signature check fails for a valid WebAuthn assertion (RP ID / flags mishandled) + - Verifier rejects a fresh nonce it actually issued diff --git a/spec/v2.3/conformance/extended/attestation/verify-replayed-nonce.yaml b/spec/v2.3/conformance/extended/attestation/verify-replayed-nonce.yaml new file mode 100644 index 0000000..e972ac7 --- /dev/null +++ b/spec/v2.3/conformance/extended/attestation/verify-replayed-nonce.yaml @@ -0,0 +1,53 @@ +# Conformance test vector — Extended: a proof carrying a stale / unknown nonce is rejected (replay protection). +# kind: verification — exercises §17.7 step 5. + +kind: verification + +metadata: + id: extended/attestation/verify-replayed-nonce + description: A fido2-assertion proof whose challenge_nonce was never issued by this verifier (or already retired) — rejected at the replay check (§17.7 step 5). Guards against a proof captured for verifier A being replayed at verifier B. + spec_section: "§17.6, §17.7" + conformance_level: extended + track: T1 + +verification: + proof: + type: fido2-assertion + principal_id: did:web:knox.example + credential_id: cred_abc123 + challenge_nonce: nonce-issued-to-someone-else # not in issued_nonces below, not verifier-signed, no verifier_id + asserted_at: "2026-05-13T10:30:00Z" + signature: "" + alg: webauthn-es256 + alg_version: "2" + attestation_chain: [] + registry: + version: "1.0" + principals: + - id: did:web:knox.example + display_name: Knox Hart + credentials: + - id: cred_abc123 + type: fido2-assertion + public_key: "" + enrolled_at: "2026-01-01T00:00:00Z" + revoked: false + verifier_clock: "2026-05-13T10:30:30Z" + issued_nonces: + - nonce-001-fresh # this verifier's outstanding nonce — the proof's nonce is NOT this + operation_requires_uv: false + # Structural vector: the rejection happens at step 5 (replay check) before + # the signature-verification step is reached. The placeholder `signature` is + # never inspected. `signature_check: structural` is set for consistency with + # the other v2.0.1 structural vectors. + signature_check: structural + expected: + result: rejected + failing_step: 5 + emits_trust_violation: true + +failure_classification: + severity: blocker + common_causes: + - Verifier accepts a nonce it never issued (no replay check) + - Verifier accepts a nonce that is neither verifier-signed nor accompanied by a matching verifier_id diff --git a/spec/v2.3/conformance/extended/attestation/verify-revoked-credential.yaml b/spec/v2.3/conformance/extended/attestation/verify-revoked-credential.yaml new file mode 100644 index 0000000..08151d5 --- /dev/null +++ b/spec/v2.3/conformance/extended/attestation/verify-revoked-credential.yaml @@ -0,0 +1,59 @@ +# Conformance test vector — Extended: a proof against a revoked credential is rejected. +# kind: verification — exercises §17.7 step 3 + the §17.8 registry rule +# ("A credential with revoked: true MUST cause verification to fail"). + +kind: verification + +metadata: + id: extended/attestation/verify-revoked-credential + description: A fido2-assertion proof whose credential resolves in the registry but is marked revoked — verification MUST fail (§17.8). Everything else about the proof is well-formed; revocation alone is disqualifying. + spec_section: "§17.7, §17.8" + conformance_level: extended + track: T1 + +verification: + proof: + type: fido2-assertion + principal_id: did:web:knox.example + credential_id: cred_old123 # the revoked one + challenge_nonce: nonce-002-fresh + asserted_at: "2026-05-13T10:30:00Z" + signature: "" + alg: webauthn-es256 + alg_version: "2" + attestation_chain: [] + registry: + version: "1.0" + principals: + - id: did:web:knox.example + display_name: Knox Hart + credentials: + - id: cred_old123 + type: fido2-assertion + public_key: "" + enrolled_at: "2025-06-01T00:00:00Z" + revoked: true + revoked_at: "2026-04-20T00:00:00Z" + - id: cred_abc123 # the principal has a current credential — but the proof used the revoked one + type: fido2-assertion + public_key: "" + enrolled_at: "2026-04-20T00:00:00Z" + revoked: false + verifier_clock: "2026-05-13T10:30:30Z" + issued_nonces: + - nonce-002-fresh + operation_requires_uv: false + # Structural vector: the rejection happens at step 3 via the registry + # revocation check (§17.8) before real WebAuthn signature verification is + # attempted. `signature_check: structural` makes this explicit. + signature_check: structural + expected: + result: rejected + failing_step: 3 # the public key resolves but the credential is revoked → signature-step rule fails it + emits_trust_violation: true + +failure_classification: + severity: blocker + common_causes: + - Verifier ignores the credential's revoked flag + - Verifier silently falls back to the principal's other (non-revoked) credential instead of failing on the credential the proof named diff --git a/spec/v2.3/conformance/extended/attestation/verify-voice-alg-disallowed.yaml b/spec/v2.3/conformance/extended/attestation/verify-voice-alg-disallowed.yaml new file mode 100644 index 0000000..1170282 --- /dev/null +++ b/spec/v2.3/conformance/extended/attestation/verify-voice-alg-disallowed.yaml @@ -0,0 +1,66 @@ +# Conformance test vector — Extended: voice-biometric with an alg outside the +# §17.6 whitelist. Exercises §17.6 alg whitelist + §17.7 step 3. +# +# This vector asserts REAL behaviour TODAY — the §17.6 alg-whitelist check +# does NOT need the crypto verifier. An adversary submits a voice-biometric +# proof with `alg: HS256` (HMAC, a shared-secret algorithm — broken for the +# public-key proof-of-human-intent model PACT relies on). The verifier MUST +# reject at step 3 by the whitelist rule, before any signature work. +# +# hman_3_flip: NONE — this vector already exercises real whitelist +# enforcement and does not change when HMAN's #3 PR lands. + +kind: verification + +metadata: + id: extended/attestation/verify-voice-alg-disallowed + description: "A voice-biometric proof with alg=HS256 (HMAC — outside the §17.6 whitelist). Rejected at step 3 by the alg-whitelist rule. Asserts real behaviour today (no crypto verifier needed)." + spec_section: "§17.6, §17.7, §18.3" + conformance_level: extended + track: T1 + +verification: + proof: + type: voice-biometric + principal_id: did:web:knox.example + credential_id: voice_ref_001 + challenge_nonce: nonce-voice-alg-001 + asserted_at: "2026-05-16T10:00:00Z" + signature: "" + alg: HS256 # HMAC — explicitly disallowed (§17.6); voice-biometric is a public-key model + alg_version: "1" + match: + alg: resemblyzer-v1 + alg_version: "1.0" + score: 0.90 + threshold: 0.75 + utterance_hash: "dXR0ZXJhbmNlLWhhc2gtcGxhY2Vob2xkZXItMDAx" + verifier_id: did:web:bridget.example + attestation_chain: [] + registry: + version: "1.0" + principals: + - id: did:web:knox.example + display_name: Knox Hart + credentials: + - id: voice_ref_001 + type: voice-biometric + public_key: "" + enrolled_at: "2026-05-16T09:00:00Z" + revoked: false + verifier_clock: "2026-05-16T10:00:30Z" + issued_nonces: + - nonce-voice-alg-001 + receiving_verifier_id: did:web:bridget.example + signature_check: real + expected: + result: rejected + failing_step: 3 + emits_trust_violation: true + +failure_classification: + severity: blocker + common_causes: + - Verifier doesn't enforce the §17.6 alg whitelist for voice-biometric (only for fido2-assertion) + - Verifier accepts HMAC / symmetric-key algorithms for a public-key attestation type + - Verifier defers the alg check until after the crypto verifier (it should reject earlier) diff --git a/spec/v2.3/conformance/extended/attestation/verify-voice-forged-signature.yaml b/spec/v2.3/conformance/extended/attestation/verify-voice-forged-signature.yaml new file mode 100644 index 0000000..73748ef --- /dev/null +++ b/spec/v2.3/conformance/extended/attestation/verify-voice-forged-signature.yaml @@ -0,0 +1,67 @@ +# Conformance test vector — Extended: voice-biometric forged signature. +# kind: verification — exercises §17.7 step 3 + §18.3. The A1 mutation test, +# voice edition. +# +# SCAFFOLD (v2.0.4 lockdown). Ships fail-closed: until HMAN's #3 PR lands the +# crypto verifier, a `signature_check: real` voice proof is `unverifiable` at +# step 3 (verifier not implemented). The point of THIS vector is that once +# the verifier exists, a byte-flipped signature MUST be `rejected` at step 3 +# — never `verified`. +# +# hman_3_flip: replace `signature` with a real signature that has had one +# byte flipped; change expected.result -> rejected (still failing_step 3, +# verification_mode cryptographic). Mirrors verify-fido2-forged-signature. + +kind: verification + +metadata: + id: extended/attestation/verify-voice-forged-signature + description: "A voice-biometric proof with a forged (byte-flipped) signature. SCAFFOLD — fails closed (unverifiable, step 3) now; becomes the A1 rejected-on-forge mutation test once HMAN's #3 PR lands." + spec_section: "§17.6, §17.7, §18.3, §18.6" + conformance_level: extended + track: T1 + +verification: + proof: + type: voice-biometric + principal_id: did:web:knox.example + credential_id: voice_ref_001 + challenge_nonce: nonce-voice-forge-001 + asserted_at: "2026-05-16T10:00:00Z" + signature: "" + alg: resemblyzer-v1 + alg_version: "1.0" + match: + alg: resemblyzer-v1 + alg_version: "1.0" + score: 0.93 + threshold: 0.75 + utterance_hash: "dXR0ZXJhbmNlLWhhc2gtcGxhY2Vob2xkZXItMDAx" + verifier_id: did:web:bridget.example + attestation_chain: [] + registry: + version: "1.0" + principals: + - id: did:web:knox.example + display_name: Knox Hart + credentials: + - id: voice_ref_001 + type: voice-biometric + public_key: "" + enrolled_at: "2026-05-16T09:00:00Z" + revoked: false + verifier_clock: "2026-05-16T10:00:30Z" + issued_nonces: + - nonce-voice-forge-001 + receiving_verifier_id: did:web:bridget.example + signature_check: real + expected: + result: unverifiable + failing_step: 3 + emits_trust_violation: false + +failure_classification: + severity: blocker + common_causes: + - Runner accepted a forged voice signature (the A1 attack, voice edition) + - Runner passed voice-biometric structurally instead of failing closed pre-crypto diff --git a/spec/v2.3/conformance/extended/attestation/verify-voice-replayed-cross-verifier.yaml b/spec/v2.3/conformance/extended/attestation/verify-voice-replayed-cross-verifier.yaml new file mode 100644 index 0000000..19559e1 --- /dev/null +++ b/spec/v2.3/conformance/extended/attestation/verify-voice-replayed-cross-verifier.yaml @@ -0,0 +1,69 @@ +# Conformance test vector — Extended: voice-biometric cross-verifier replay. +# kind: verification — exercises §17.6 verifier-binding + §17.7 step 5 +# (verifier_id equality, the v2.0.2 A3 rule) — voice edition. +# +# SCAFFOLD (v2.0.4 lockdown). An adversary captures a voice-biometric proof +# legitimately produced for verifier A (verifier_id = did:web:a.example) and +# replays it at verifier B (did:web:b.example). Equality, not presence, is +# required. Until HMAN's #3 PR lands the crypto verifier this ships +# fail-closed (unverifiable, step 3) — BUT note the replay check at step 5 +# runs before the crypto half, so once the verifier exists this asserts the +# A3 cross-verifier rejection for voice. +# +# hman_3_flip: replace `signature` with a real valid signature bound to +# verifier A; expected.result -> rejected, failing_step 5 (verifier_id +# inequality caught before the crypto half). Mirrors +# verify-cross-verifier-replay for fido2. + +kind: verification + +metadata: + id: extended/attestation/verify-voice-replayed-cross-verifier + description: "A voice-biometric proof bearing verifier_id=did:web:a.example arriving at did:web:b.example. SCAFFOLD — fails closed now; becomes the A3 cross-verifier rejection (step 5) once HMAN's #3 PR lands." + spec_section: "§17.6, §17.7, §18.3, §18.6" + conformance_level: extended + track: T1 + +verification: + proof: + type: voice-biometric + principal_id: did:web:knox.example + credential_id: voice_ref_001 + challenge_nonce: nonce-fresh-for-A + asserted_at: "2026-05-16T10:00:00Z" + signature: "" + alg: resemblyzer-v1 + alg_version: "1.0" + match: + alg: resemblyzer-v1 + alg_version: "1.0" + score: 0.92 + threshold: 0.75 + utterance_hash: "dXR0ZXJhbmNlLWhhc2gtcGxhY2Vob2xkZXItMDAx" + verifier_id: did:web:a.example # the original target + attestation_chain: [] + registry: + version: "1.0" + principals: + - id: did:web:knox.example + display_name: Knox Hart + credentials: + - id: voice_ref_001 + type: voice-biometric + public_key: "" + enrolled_at: "2026-05-16T09:00:00Z" + revoked: false + verifier_clock: "2026-05-16T10:00:30Z" + issued_nonces: [] # B never issued this nonce; nor is it verifier-signed by B + receiving_verifier_id: did:web:b.example # we are B; the proof claims A + signature_check: real + expected: + result: rejected + failing_step: 5 + emits_trust_violation: true + +failure_classification: + severity: blocker + common_causes: + - Verifier accepts presence-of-verifier_id without checking equality (the A3 footgun, voice edition) + - Verifier doesn't carry receiving_verifier_id through its replay check for voice-biometric diff --git a/spec/v2.3/conformance/extended/attestation/verify-voice-valid.yaml b/spec/v2.3/conformance/extended/attestation/verify-voice-valid.yaml new file mode 100644 index 0000000..818ee17 --- /dev/null +++ b/spec/v2.3/conformance/extended/attestation/verify-voice-valid.yaml @@ -0,0 +1,67 @@ +# Conformance test vector — Extended: voice-biometric happy path. +# kind: verification — exercises §17.7 + §18.3. +# +# SCAFFOLD (v2.0.4 lockdown, docs/v2-prep/v2.0.4-voice-biometric-lockdown.yaml). +# The normative voice-biometric crypto lands via HMAN's #3 PR (§18.6). Until +# then this vector FAILS CLOSED: a `signature_check: real` voice-biometric +# proof whose verifier is not implemented MUST be `unverifiable` at step 3 — +# it MUST NOT pass structurally (that was the A1 footgun, voice edition). +# +# hman_3_flip: when HMAN's #3 PR lands a real voice-biometric verifier, +# replace `signature` with a real suite-of-record signature over the +# canonical payload, set a genuine `match.score >= match.threshold`, and +# change expected.result -> verified (verification_mode cryptographic). + +kind: verification + +metadata: + id: extended/attestation/verify-voice-valid + description: "A voice-biometric proof with a valid embedding match. SCAFFOLD — fails closed (unverifiable, step 3) until HMAN's #3 PR lands the crypto verifier." + spec_section: "§17.6, §17.7, §18.3, §18.6" + conformance_level: extended + track: T1 + +verification: + proof: + type: voice-biometric + principal_id: did:web:knox.example + credential_id: voice_ref_001 + challenge_nonce: nonce-voice-001 + asserted_at: "2026-05-16T10:00:00Z" + signature: "" + alg: resemblyzer-v1 + alg_version: "1.0" + match: + alg: resemblyzer-v1 + alg_version: "1.0" + score: 0.91 + threshold: 0.75 + utterance_hash: "dXR0ZXJhbmNlLWhhc2gtcGxhY2Vob2xkZXItMDAx" + verifier_id: did:web:bridget.example + attestation_chain: [] + registry: + version: "1.0" + principals: + - id: did:web:knox.example + display_name: Knox Hart + credentials: + - id: voice_ref_001 + type: voice-biometric + public_key: "" + enrolled_at: "2026-05-16T09:00:00Z" + revoked: false + verifier_clock: "2026-05-16T10:00:30Z" + issued_nonces: + - nonce-voice-001 + receiving_verifier_id: did:web:bridget.example + signature_check: real + expected: + result: unverifiable + failing_step: 3 + emits_trust_violation: false + +failure_classification: + severity: blocker + common_causes: + - Runner passed a voice-biometric proof structurally without a crypto verifier (A1, voice edition — the hole v2.0.4 closes) + - Runner did not fail closed when the voice-biometric verifier is unimplemented diff --git a/spec/v2.3/conformance/extended/attestation/verify-voice-wrong-utterance.yaml b/spec/v2.3/conformance/extended/attestation/verify-voice-wrong-utterance.yaml new file mode 100644 index 0000000..34a39d6 --- /dev/null +++ b/spec/v2.3/conformance/extended/attestation/verify-voice-wrong-utterance.yaml @@ -0,0 +1,71 @@ +# Conformance test vector — Extended: voice-biometric, valid match against +# the WRONG utterance. Proves presence != intent (§18.3 utterance binding). +# kind: verification — exercises §17.7 step 3 + §18.3 utterance_hash binding. +# +# SCAFFOLD (v2.0.4 lockdown). The distinguishing property of voice-biometric +# is that it captures INTENT (the human spoke a specific challenge), not just +# PRESENCE. A cryptographically valid voice match carried against the wrong +# utterance_hash MUST be rejected. Until HMAN's #3 PR lands the verifier this +# ships fail-closed (unverifiable, step 3). +# +# hman_3_flip: real valid signature + good match score, but utterance_hash +# does NOT equal the verifier's required utterance; expected.result -> +# rejected (failing_step 3). This guarantee MUST survive whatever crypto +# HMAN's PR chooses — it is frozen in the lockdown contract. + +kind: verification + +metadata: + id: extended/attestation/verify-voice-wrong-utterance + description: "A voice-biometric proof with a valid speaker match but the wrong utterance_hash. SCAFFOLD — fails closed now; becomes the presence!=intent rejection test once HMAN's #3 PR lands." + spec_section: "§17.6, §17.7, §18.3, §18.6" + conformance_level: extended + track: T1 + +verification: + proof: + type: voice-biometric + principal_id: did:web:knox.example + credential_id: voice_ref_001 + challenge_nonce: nonce-voice-utt-001 + asserted_at: "2026-05-16T10:00:00Z" + signature: "" + alg: resemblyzer-v1 + alg_version: "1.0" + match: + alg: resemblyzer-v1 + alg_version: "1.0" + score: 0.95 + threshold: 0.75 + # The verifier required "approve transfer of $5000 to Bridget" but this + # proof carries the hash of a different utterance. Valid voice, wrong intent. + utterance_hash: "d3JvbmctdXR0ZXJhbmNlLWhhc2gtbm90LXdoYXQtdmVyaWZpZXItYXNrZWQ" + verifier_id: did:web:bridget.example + required_utterance_hash: "cmVxdWlyZWQtYXBwcm92ZS10cmFuc2Zlci01MDAwLXRvLWJyaWRnZXQ" + attestation_chain: [] + registry: + version: "1.0" + principals: + - id: did:web:knox.example + display_name: Knox Hart + credentials: + - id: voice_ref_001 + type: voice-biometric + public_key: "" + enrolled_at: "2026-05-16T09:00:00Z" + revoked: false + verifier_clock: "2026-05-16T10:00:30Z" + issued_nonces: + - nonce-voice-utt-001 + receiving_verifier_id: did:web:bridget.example + signature_check: real + expected: + result: unverifiable + failing_step: 3 + emits_trust_violation: false + +failure_classification: + severity: blocker + common_causes: + - Runner verified a valid voice match without binding it to the required utterance (presence accepted as intent) + - Runner passed voice-biometric structurally instead of failing closed pre-crypto diff --git a/spec/v2.3/conformance/extended/matters/README.md b/spec/v2.3/conformance/extended/matters/README.md new file mode 100644 index 0000000..94cb6cd --- /dev/null +++ b/spec/v2.3/conformance/extended/matters/README.md @@ -0,0 +1,31 @@ +# Matter conformance vectors — v2.2 draft + +This directory holds the conformance test vectors for the §24 Matter +primitive. They are **DRAFT** — they will move to +`spec/v2.2/conformance/matters/` when v2.2 is properly opened (gated on +v2.1 ship + maintainer sign-off). + +## Vectors in this directory + +| File | Asserts | +|---|---| +| `matter-open-success.yaml` | POST /matters creates the Matter, caller becomes owner, `pact.matter.opened` emitted | +| `matter-attach-fabric.yaml` | Attach is a link (fabric NOT modified); `pact.matter.fabric-attached` emitted | +| `matter-message-post.yaml` | Side-channel is typed events; structured body + optional fabric reference | +| `matter-manifest-cross-fabric.yaml` | Cross-fabric obligation aggregation + §17.13 cross-org reduction on counterparties | +| `matter-close-no-cascade.yaml` | Closing a Matter does NOT cascade to attached fabrics (resolves RFC OQ3) | + +## Runner kind + +These vectors are `kind: http` and run against any PACT server that exposes +the §24 endpoints. The §-text draft (`docs/v2-prep/matters-spec-draft.md`) +proposes a new `kind: matter` for richer multi-step lifecycle vectors +(open → attach → message → manifest → close) analogous to the v2.0.3 +`kind: session`. That runner kind lands when the v2.2 carry-forward opens. + +## Reference implementation + +`reference-server/src/matters.ts` implements every endpoint these vectors +exercise. Verified by the smoke test in `tools/matter-smoke.sh` (drafted +alongside this RFC) — equivalent of the manual `curl` sequence in the +RFC #18 issue body. diff --git a/spec/v2.3/conformance/extended/matters/matter-attach-fabric.yaml b/spec/v2.3/conformance/extended/matters/matter-attach-fabric.yaml new file mode 100644 index 0000000..fc45918 --- /dev/null +++ b/spec/v2.3/conformance/extended/matters/matter-attach-fabric.yaml @@ -0,0 +1,78 @@ +# Conformance test vector — attach an existing fabric to a Matter (v2.2 draft). +# kind: http — POST to /api/pact/matters/{id}/fabrics; asserts the fabric is +# attached as a link (NOT merged) and pact.matter.fabric-attached is emitted. + +kind: http + +metadata: + id: extended/matters/matter-attach-fabric + description: A Matter owner attaches an existing fabric. The fabric is referenced by resourceId; its own state is unaffected. + spec_section: "§24.6" + conformance_level: extended + track: T-matters + +preconditions: + server_state: + matter: + matter_id: mtr_atlas + phase: open + members: + - principal_id: did:web:knox.example + role: owner + fabrics: [] + fabric: + fabric_id: fab_term_sheet # the fabric being attached; pre-exists + request_context: + principal_id: did:web:knox.example + +request: + method: POST + path: /api/pact/matters/mtr_atlas/fabrics + headers: + Content-Type: application/json + X-Pact-Principal: did:web:knox.example + body: + resourceId: fab_term_sheet + +expected_response: + status: 200 + body_match: + mode: subset + value: + matter_id: mtr_atlas + attached: true + attachment: + resourceId: fab_term_sheet + attached_by: did:web:knox.example + body_ignore_fields: + - attachment.attached_at + - event_id + +expected_events: + ordered: true + sequence: + - event_type: pact.matter.fabric-attached + match: + mode: subset + value: + matter_id: mtr_atlas + resourceId: fab_term_sheet + attached_by: did:web:knox.example + body_ignore_fields: + - id + - epochMs + - sequenceNumber + - actorId + +postconditions: + server_state: + matter_fabric_count: 1 + fabric_unchanged: true # attaching to a Matter MUST NOT mutate the fabric + +failure_classification: + severity: blocker + common_causes: + - Attachment treated as a merge (fabric state altered — members/constraints added) + - Non-owner caller succeeded (auth gate missing) + - pact.matter.fabric-attached event not emitted + - Attachment to a closed Matter accepted (409 expected) diff --git a/spec/v2.3/conformance/extended/matters/matter-close-no-cascade.yaml b/spec/v2.3/conformance/extended/matters/matter-close-no-cascade.yaml new file mode 100644 index 0000000..d347e0c --- /dev/null +++ b/spec/v2.3/conformance/extended/matters/matter-close-no-cascade.yaml @@ -0,0 +1,93 @@ +# Conformance test vector — closing a Matter does NOT cascade to its fabrics. +# Resolves RFC OQ3: fabrics outlive Matters. This vector guards against the +# silent-cascade failure mode where closing the engagement accidentally closes +# the underlying contract. + +kind: http + +metadata: + id: extended/matters/matter-close-no-cascade + description: An owner closes a Matter with attached fabrics. The Matter transitions to phase=closed and emits pact.matter.closed. The attached fabrics MUST remain unchanged — closure does NOT cascade. + spec_section: "§24.9" + conformance_level: extended + track: T-matters + +preconditions: + server_state: + matter: + matter_id: mtr_atlas + phase: open + members: + - principal_id: did:web:knox.example + role: owner + fabrics: + - resourceId: fab_term_sheet + - resourceId: fab_diligence + fabrics: + - fabric_id: fab_term_sheet + phase: negotiating + - fabric_id: fab_diligence + phase: forming + request_context: + principal_id: did:web:knox.example + +request: + method: POST + path: /api/pact/matters/mtr_atlas/close + headers: + Content-Type: application/json + X-Pact-Principal: did:web:knox.example + body: + outcome: deal-signed + +expected_response: + status: 200 + body_match: + mode: subset + value: + matter_id: mtr_atlas + phase: closed + closed: true + outcome: deal-signed + fabrics_detached: false # the fabrics are NOT closed by Matter closure + fabrics_referenced: + - fab_term_sheet + - fab_diligence + body_ignore_fields: + - closed_at + - event_id + +expected_events: + ordered: true + sequence: + - event_type: pact.matter.closed + match: + mode: subset + value: + matter_id: mtr_atlas + closed_by: did:web:knox.example + outcome: deal-signed + detached_fabrics: + - fab_term_sheet + - fab_diligence + body_ignore_fields: + - id + - epochMs + - sequenceNumber + - actorId + +postconditions: + server_state: + matter_phase: closed + fab_term_sheet_phase: negotiating # unchanged + fab_diligence_phase: forming # unchanged + fab_term_sheet_events_added: 0 # MUST NOT emit fabric.closed + fab_diligence_events_added: 0 + +failure_classification: + severity: blocker + common_causes: + - Closure cascades — attached fabrics are also closed (data-loss risk) + - Non-owner caller succeeded (auth gate missing) + - outcome not echoed onto the event (audit trail loses the close reason) + - Subsequent operations on the closed Matter succeed (409 expected) diff --git a/spec/v2.3/conformance/extended/matters/matter-manifest-cross-fabric.yaml b/spec/v2.3/conformance/extended/matters/matter-manifest-cross-fabric.yaml new file mode 100644 index 0000000..17b6fdd --- /dev/null +++ b/spec/v2.3/conformance/extended/matters/matter-manifest-cross-fabric.yaml @@ -0,0 +1,102 @@ +# Conformance test vector — cross-fabric manifest (§24.7). +# The load-bearing feature of Matters: aggregates pending obligations across +# attached fabrics into one caller-scoped view. Without this, Matters degrade +# to "a list of fabric IDs." + +kind: http + +metadata: + id: extended/matters/matter-manifest-cross-fabric + description: A Matter member queries the cross-fabric manifest. The response aggregates attached-fabric phase + open-proposal counts + caller-specific pending obligations across all attached fabrics. §17.13 reduction applies to counterparties. + spec_section: "§24.7" + conformance_level: extended + track: T-matters + +preconditions: + server_state: + matter: + matter_id: mtr_atlas + phase: open + members: + - principal_id: did:web:knox.example + role: owner + - principal_id: did:web:counterparty.example + role: participant + fabrics: + - resourceId: fab_term_sheet + - resourceId: fab_diligence + fabrics: + - fabric_id: fab_term_sheet + phase: negotiating + proposals_open: 1 + obligations_pending_for_knox: 1 # vote owed + - fabric_id: fab_diligence + phase: forming + proposals_open: 0 + obligations_pending_for_knox: 0 + request_context: + principal_id: did:web:knox.example + +request: + method: GET + path: /api/pact/matters/mtr_atlas/manifest + headers: + X-Pact-Principal: did:web:knox.example + +expected_response: + status: 200 + body_match: + mode: subset + value: + matter_id: mtr_atlas + phase: open + caller: + principal_id: did:web:knox.example + role: owner + counterparties: + - principal_id: did:web:counterparty.example + role: participant + cross_org: true # different eTLD+1 from knox.example + fabrics: + - resourceId: fab_term_sheet + phase: negotiating + open_proposals: 1 + - resourceId: fab_diligence + phase: forming + open_proposals: 0 + pending_obligations_across_fabrics: + - fabric_id: fab_term_sheet + kind: vote + body_ignore_fields: + - spec_version + - caller.display_name + - counterparties.*.display_name + - counterparties.*.joined_at + - counterparties.*.org_eTLD_plus_1 + - fabrics.*.attached_at + - fabrics.*.attached_by + - fabrics.*.member_count + - fabrics.*.pending_obligation_count_for_caller + - fabrics.*.caller_is_fabric_member + - pending_obligations_across_fabrics.*.obligation_id + - pending_obligations_across_fabrics.*.event_ref + - pending_obligations_across_fabrics.*.due_by + - side_channel + - snapshot_at + +expected_events: + ordered: true + sequence: [] # GET — no events emitted + +postconditions: + server_state: + matter_unchanged: true # manifest is read-only + +failure_classification: + severity: blocker + common_causes: + - cross-fabric obligation aggregation missing — caller can't see what's owed across the engagement + - Cross-org reduction not applied to counterparties (PII leak) + - Non-member caller accepted (403 expected on auth gate) + - pending_obligation_count_for_caller not per-caller (privacy / scope leak) + - Manifest mutates state (any event emitted = bug) diff --git a/spec/v2.3/conformance/extended/matters/matter-message-post.yaml b/spec/v2.3/conformance/extended/matters/matter-message-post.yaml new file mode 100644 index 0000000..715df11 --- /dev/null +++ b/spec/v2.3/conformance/extended/matters/matter-message-post.yaml @@ -0,0 +1,86 @@ +# Conformance test vector — post a typed-event side-channel message. +# Per the maintainer call 2026-05-24: the wire format is TYPED EVENTS, not +# chat. The body is structured {format, content} inside a structured envelope. + +kind: http + +metadata: + id: extended/matters/matter-message-post + description: A Matter member posts to the side-channel. The wire format is a typed `pact.matter.message` event with structured body; UIs MAY render as chat but the protocol does NOT define presence/typing/reactions. + spec_section: "§24.8" + conformance_level: extended + track: T-matters + +preconditions: + server_state: + matter: + matter_id: mtr_atlas + phase: open + members: + - principal_id: did:web:knox.example + role: owner + fabrics: + - resourceId: fab_diligence + request_context: + principal_id: did:web:knox.example + +request: + method: POST + path: /api/pact/matters/mtr_atlas/messages + headers: + Content-Type: application/json + X-Pact-Principal: did:web:knox.example + body: + content: Can we get the financials by Friday? + fabric_id: fab_diligence + +expected_response: + status: 200 + body_match: + mode: subset + value: + matter_id: mtr_atlas + message: + sender_principal: did:web:knox.example + body: + format: text + content: Can we get the financials by Friday? + references: + fabric_id: fab_diligence + body_ignore_fields: + - message.id + - message.posted_at + - event_id + +expected_events: + ordered: true + sequence: + - event_type: pact.matter.message + match: + mode: subset + value: + matter_id: mtr_atlas + sender: did:web:knox.example + body: + format: text + content: Can we get the financials by Friday? + references: + fabric_id: fab_diligence + body_ignore_fields: + - message_id + - id + - epochMs + - sequenceNumber + - actorId + +postconditions: + server_state: + matter_message_count: 1 + +failure_classification: + severity: blocker + common_causes: + - body.format omitted (loses the "typed events not chat" discipline) + - sender_principal not set from caller principal (impersonation risk) + - references.fabric_id dropped from the event payload (cross-link lost) + - Non-member caller accepted (403 expected on auth gate) diff --git a/spec/v2.3/conformance/extended/matters/matter-open-success.yaml b/spec/v2.3/conformance/extended/matters/matter-open-success.yaml new file mode 100644 index 0000000..4fc769c --- /dev/null +++ b/spec/v2.3/conformance/extended/matters/matter-open-success.yaml @@ -0,0 +1,82 @@ +# Conformance test vector — Matter open happy path (v2.2 draft). +# kind: http — single POST to /api/pact/matters; asserts response shape + emitted event. +# +# DRAFT — moves to spec/v2.2/conformance/matters/matter-open-success.yaml +# when v2.2 is properly opened. See docs/v2-prep/matters-spec-draft.md §24.5. + +kind: http + +metadata: + id: extended/matters/matter-open-success + description: A caller opens a new Matter; the caller becomes the first member with role=owner; pact.matter.opened is emitted. + spec_section: "§24.5" + conformance_level: extended + track: T-matters + +preconditions: + server_state: + matters: [] # no matters open yet + request_context: + principal_id: did:web:knox.example + +request: + method: POST + path: /api/pact/matters + headers: + Content-Type: application/json + X-Pact-Principal: did:web:knox.example + body: + name: Project Atlas acquisition + opened_by_display: Knox + +expected_response: + status: 200 + body_match: + mode: subset + value: + spec_version: "2.2" + name: Project Atlas acquisition + phase: open + opened_by: did:web:knox.example + members: + - principal_id: did:web:knox.example + display_name: Knox + role: owner + fabrics: [] + body_ignore_fields: + - matter_id # server-assigned + - opened_at # server timestamp + - opened_event_id # server-assigned + - members.*.joined_at # server timestamp + - members.*.org_eTLD_plus_1 # additive + +expected_events: + ordered: true + sequence: + - event_type: pact.matter.opened + match: + mode: subset + value: + name: Project Atlas acquisition + opened_by: did:web:knox.example + body_ignore_fields: + - matter_id + - id + - epochMs + - sequenceNumber + - actorId + +postconditions: + server_state: + matters_count: 1 + matter_phase: open + matter_member_count: 1 + matter_fabric_count: 0 + +failure_classification: + severity: blocker + common_causes: + - Implementation does not expose POST /api/pact/matters (returns 404) + - Caller not auto-added as owner (member array empty or role != owner) + - phase set to active/closed instead of open + - pact.matter.opened event not emitted diff --git a/spec/v2.3/conformance/extended/sessions/heartbeat-timeout.yaml b/spec/v2.3/conformance/extended/sessions/heartbeat-timeout.yaml new file mode 100644 index 0000000..9fffa86 --- /dev/null +++ b/spec/v2.3/conformance/extended/sessions/heartbeat-timeout.yaml @@ -0,0 +1,126 @@ +# Conformance test vector — Extended: heartbeat timeout surfaces stale members +# in /_status (v2.0.3, §4.4 + §4.1 bidirectional-heartbeat update). +# kind: http — single GET /_status against a fabric whose state has been +# seeded such that one member (B) has not heartbeated within the configured +# window. The strictest-reasonable conservative reading: B is FLAGGED as +# stale (last_seen older than threshold + a `liveness: stale` marker), NOT +# automatically evicted. Eviction is a policy choice that requires explicit +# action — the spec preamble line 183 says "auto-evicted after 5min silence" +# as an example, but the conservative-default interpretation for the +# conformance suite is "flagged, not evicted" so implementations that take a +# slower policy still pass. + +kind: http + +metadata: + id: extended/sessions/heartbeat-timeout + description: Fabric has member A (heartbeated within window) and member B (silent past the timeout). GET /_status reports B's last_seen older than the threshold and marks B with liveness=stale. B remains a member — the conformance vector requires flagging, not eviction. + spec_section: "§4.1, §4.4" + conformance_level: extended + track: T3 + +preconditions: + server_state: + resource_id: fab_hb_001 + # Server's "now" for this test is verifier_clock below. Member A's + # last_seen is 5s ago (well within the 60s default heartbeat window). + # Member B's last_seen is 120s ago — 2x the threshold. + heartbeat_timeout_seconds: 60 + registered_agents: + - principalId: did:web:alice.example + agentName: alice-bot + last_seen: "2026-05-15T11:59:55Z" # 5s before now + liveness: live + - principalId: did:web:bob.example + agentName: bob-bot + last_seen: "2026-05-15T11:58:00Z" # 120s before now + liveness: live # server has NOT yet observed the timeout — this GET should surface it + server_clock: "2026-05-15T12:00:00Z" + request_context: + auth: bearer + principal_id: did:web:alice.example # any member can query + +request: + method: GET + path: /api/pact/fab_hb_001/_status + +expected_response: + status: 200 + body_match: + mode: subset + # RECONCILED to spec §4.4.1 (was authored against placeholder field + # names while §4.4 was still drafting): + # - `fabricId` → `fabric_id` (the normative §4.4.1 response key). + # - the `members` ARRAY assertion is restated against the additive + # `members_by_principal` MAP because the runner's subset matcher + # compares arrays by exact JSON equality (it cannot subset-match + # array elements); a per-member liveness assertion needs a keyed + # path. `members_by_principal` is additive (the normative shape is + # the `members` array; the spec examples are non-exhaustive subsets). + # - `liveness` / `evicted` / `heartbeat_timeout_seconds` are additive + # fields the spec does not define but does not forbid; §4.1 line 183 + # references staleness/eviction. The conservative reading is retained: + # B is FLAGGED stale, never auto-evicted (evicted: false). + value: + fabric_id: fab_hb_001 + heartbeat_timeout_seconds: 60 + members_by_principal: + "did:web:alice.example": + principal_id: did:web:alice.example + liveness: live + evicted: false + "did:web:bob.example": + principal_id: did:web:bob.example + liveness: stale # MUST be surfaced — past timeout + # `evicted` MUST NOT be true in the conservative interpretation. + # The strictest reading is "stale is a flag, eviction is a policy + # action requiring a separate event/operation." + evicted: false + body_ignore_fields: + - generated_at + - last_event_seq + - snapshot_at # absolute timestamp varies per impl clock + +expected_events: + # A GET is non-mutating. The strictest reading is no events emitted by the + # status query itself. Implementations that emit a `pact.agent.stale` + # event on first observation of staleness do so out-of-band of this GET + # — that's a separate vector (not asserted here). + ordered: true + sequence: [] + +postconditions: + server_state: + events_added: 0 + resource_modified: false + +notes: | + Assumptions made against the still-drafting v2.0.3 §4.1 + §4.4 text: + - 60s is taken as the default heartbeat_timeout_seconds. The spec preamble + (line 183) suggests 5 minutes (300s) for auto-eviction. Distinguishing: + 60s is the "go stale" threshold; eviction (if any) is a longer threshold + and requires explicit action. This vector tests staleness only. + - liveness has at least these states: `live` | `stale`. Implementations + MAY add `evicted` | `pending` etc. — the subset match is forgiving. + - The CONSERVATIVE interpretation is "flag, don't evict." If the spec + converges on "auto-evict after T seconds," this vector needs a sibling + `heartbeat-eviction.yaml` covering the eviction path; the staleness + flagging still applies as an earlier state. + - The /_status endpoint is non-mutating (a GET). Some implementations may + LAZILY transition liveness=live→stale on the read path itself (i.e. emit + `pact.agent.stale` as a side effect of this GET). That emission is NOT + asserted here — the vector only pins what the GET response body looks + like, not the server's internal state transition mechanism. + - The runner does NOT advance wall-clock — `server_clock` is the + server-state precondition the test author expects the test harness to + seed. Live-server runs of this vector require a fixture or time-travel + helper to plant the stale member; this is an `events_since`-style + precondition that the runner currently treats as documentation. + +failure_classification: + severity: blocker + common_causes: + - /_status omits liveness field entirely (callers cannot detect stale peers) + - Liveness reports `live` for B despite last_seen > threshold (heartbeat clock not enforced) + - /_status auto-evicts B without an intermediate stale state (callers lose the warning signal) + - Liveness is reported but `last_seen` is absent (callers can flag but cannot compute how-stale) diff --git a/spec/v2.3/conformance/extended/sessions/manifest-cross-org-disclosure.yaml b/spec/v2.3/conformance/extended/sessions/manifest-cross-org-disclosure.yaml new file mode 100644 index 0000000..6d2a82b --- /dev/null +++ b/spec/v2.3/conformance/extended/sessions/manifest-cross-org-disclosure.yaml @@ -0,0 +1,153 @@ +# Conformance test vector — Extended: cross-org disclosure reduction on the +# manifest endpoint (v2.0.3, §17.13 trust-model applied to §4.4 /manifest). +# kind: http — single GET; the rich preconditions describe the multi-member +# multi-org setup, and the assertion is the strictly-reduced view member A +# sees of member B's record. +# +# The subtlety: §17.13 trust-floor framing + §15.4 cross-org boundary +# definition combine to say that contact-info fields on a cross-org peer +# are NOT visible without an explicit recorded consent event. display_name +# IS visible (needed for human-readable manifest UX). This vector pins the +# reduction shape. + +kind: http + +metadata: + id: extended/sessions/manifest-cross-org-disclosure + description: Member A (org-A) queries /manifest on a fabric also containing member B (org-B). A sees its own record in full (constraints + obligations + contact); A sees B's display_name only — B's contact, raw constraints and obligations are reduced per §17.13. No cross-org consent has been recorded. + spec_section: "§4.4, §15.4, §17.13" + conformance_level: extended + track: T3 + +preconditions: + server_state: + resource_id: fab_xorg_001 + # Two members, two distinct registrable domains (eTLD+1 differs per §15.4). + registered_agents: + - principalId: did:web:org-a.example + agentName: alice-bot + org_eTLD_plus_1: org-a.example + role: contributor + constraints: + - name: rate-limit-per-minute + value: 30 + obligations: [] + contact: + email: alice@org-a.example + escalation_hook: https://alerts.org-a.example/inbox + - principalId: did:web:org-b.example + agentName: bob-bot + org_eTLD_plus_1: org-b.example + role: contributor + constraints: + - name: rate-limit-per-minute + value: 60 + - name: disclosure-ceiling + value: 1 + obligations: + - kind: vote + event_ref: prop_xyz + due_by: "2026-05-16T10:00:00Z" + contact: + email: bob@org-b.example + escalation_hook: https://alerts.org-b.example/inbox + cross_org_consent_records: [] # no consent on file — strictest case + request_context: + auth: bearer + principal_id: did:web:org-a.example # Alice is the caller + +request: + method: GET + path: /api/pact/fab_xorg_001/manifest + +expected_response: + status: 200 + body_match: + mode: subset + # RECONCILED to spec §4.4.2 / §17.13 (authored against placeholder + # field names while §4.4 was drafting): + # - `fabricId` → `fabric_id`; `principalId` → `principal_id` (the + # normative §4.4.2 keys). + # - `peers` → `counterparties` (the normative §4.4.2 key). The + # per-peer cross-org-reduction assertion is restated against the + # additive `counterparties_by_principal` MAP because the runner + # subset-matcher compares arrays by exact JSON equality. + # - The caller's `constraints: [{name,value}]` array assertion is + # dropped: the normative constraint shape is {constraint_id, + # sectionId, boundary} (§4.4.5) and array exact-match would pin + # the server-minted constraint_id. The load-bearing assertion — + # caller sees its OWN contact in full — is retained via the + # `caller.contact` object (subset-matched). + # - `cross_org: true`, `contact_visible: false`, `*_summary{count}` + # are additive §17.13 reduction markers (spec mandates the + # reduction behaviour; the response example is non-exhaustive). + value: + fabric_id: fab_xorg_001 + caller: + # Own record: full visibility — caller sees its own contact. + principal_id: did:web:org-a.example + role: contributor + contact: + email: alice@org-a.example + escalation_hook: https://alerts.org-a.example/inbox + counterparties_by_principal: + # Cross-org peer: display_name visible; contact hidden; constraints + # and obligations summarised to counts, not enumerated. The strictest + # §17.13 reading is "no enumeration of policy state across orgs + # without consent" because constraint shape itself can leak strategy. + "did:web:org-b.example": + principal_id: did:web:org-b.example + display_name: bob-bot + role: contributor + cross_org: true + contact_visible: false + constraints_summary: + count: 2 + obligations_summary: + pending_count: 1 + body_ignore_fields: + - generated_at + - last_event_seq + - snapshot_at + +# The manifest endpoint is a read; no events emitted. +expected_events: + ordered: true + sequence: [] + +postconditions: + server_state: + events_added: 0 + resource_modified: false + +notes: | + Assumptions made against the still-drafting v2.0.3 §4.4 + §17.13 update text: + - The /manifest response separates `caller` (self) from `peers` (others). + The strictest §17.13 reading is "you always see your own record in full, + you see cross-org peers' display_name only." Same-org peers SHOULD see + each other's constraints (not asserted here — vector is cross-org only). + - `display_name` is asserted visible for cross-org peers because manifest + UX needs a human label. Hiding it would force callers to display raw + DIDs which defeats the manifest's whole purpose. + - Constraints and obligations are surfaced as `*_summary { count }` rather + than enumerated. Counts leak less than shapes — a competitor reading a + cross-org peer's full constraint list could infer their strategy. + - `cross_org: true` is asserted as an explicit boolean so callers can + branch on visibility tier without recomputing eTLD+1 themselves. + - `contact_visible: false` is explicit (rather than just-omitting `contact`) + because absence-of-field is ambiguous: a missing contact could mean + "hidden" or "the peer has no contact configured." The explicit boolean + disambiguates. + - If cross_org_consent_records[] contained an entry binding org-a ↔ org-b + for this fabric, the reduction would NOT apply — but that consented + case is out of scope here. A future positive-consent vector should + cover it. + +failure_classification: + severity: blocker + common_causes: + - Manifest returns peer contact fields without cross-org consent (privacy leak — direct §17.13 violation) + - Manifest enumerates peer constraints across orgs (strategy leak — softer §17.13 violation but still load-bearing) + - Manifest reduces caller's OWN view as if cross-org (false-positive over-reduction; harmless for privacy but breaks caller UX) + - Manifest hides display_name for cross-org peers (over-reduction; manifest becomes unusable) + - Manifest treats same-org peers as cross-org because the implementation doesn't apply the §15.4 eTLD+1 rule diff --git a/spec/v2.3/conformance/extended/sessions/obligation-surfacing.yaml b/spec/v2.3/conformance/extended/sessions/obligation-surfacing.yaml new file mode 100644 index 0000000..5f14c9f --- /dev/null +++ b/spec/v2.3/conformance/extended/sessions/obligation-surfacing.yaml @@ -0,0 +1,200 @@ +# Conformance test vector — Extended: pending-obligation surfacing in manifest +# and discharge on satisfying action (v2.0.3 §6.5). +# +# kind: session — three sequenced HTTP calls: +# step 1. GET /fabric/{id}/manifest as member B → manifest carries a +# pending obligation { kind: vote, event_ref: prop_xyz }. +# step 2. POST /fabric/{id}/proposals/{propId}/vote as member B → vote +# recorded. +# step 3. GET /fabric/{id}/manifest as member B again → obligation no +# longer present; discharge event was emitted in the interim. +# +# This is the obligation-lifecycle test: protocol-level "what does this +# member owe the fabric" must be observable AND must clear automatically +# when the underlying action is taken. A manifest that retains discharged +# obligations would cause agents to retry already-completed actions. + +kind: session + +metadata: + id: extended/sessions/obligation-surfacing + description: "A proposal P exists in fabric F; member B is required to vote. Pre-vote, B's manifest carries the pending obligation { kind: vote, event_ref: P.id, due_by }. After B votes, the obligation is discharged from the manifest and a pact.obligation.discharged event is emitted." + spec_section: "§4.4, §6.5" + conformance_level: extended + track: T3 + +preconditions: + server_state: + resource_id: fab_obl_001 + registered_agents: + - principalId: did:web:bob.example + agentName: bob-bot + role: proposer + liveness: live + # A proposal already exists and requires B's vote — the manifest is + # supposed to surface that requirement as an obligation. + proposals: + - id: prop_xyz + status: open + required_voters: + - did:web:bob.example + due_by: "2026-05-16T10:00:00Z" + request_context: + auth: bearer + principal_id: did:web:bob.example + +steps: + # RECONCILED to spec §4.4.2 / §6.5 (authored against placeholder names + # while §4.4 was drafting): + # - `fabricId` → `fabric_id`; `principalId` → `principal_id`. + # - the `caller.obligations` ARRAY assertion is restated against the + # additive `caller.obligations_by_ref` MAP (keyed by event_ref) — + # the runner subset-matcher compares arrays by exact JSON equality + # so a partial-object array assertion is not expressible. The + # normative shape is still the `caller.obligations` array / top-level + # `pending_obligations` (§4.4.2); the map is an additive index. + # - step 2 path `/proposals/{id}/vote` → `/proposals/{id}/approve`: + # there is no `/vote` endpoint in the §7.1 REST surface. §6.5 says a + # `vote` obligation is discharged when the member emits + # `proposal.approve | proposal.reject | proposal.object`; `approve` + # is the canonical spec verb (§4.3 / §7.1). + # - step 3 `cross_call_assertions` keeps `body_path: caller.obligations` + # (the runner walks the array; post-discharge it is empty → passes). + - id: pre-vote-manifest-shows-obligation + request: + method: GET + path: /api/pact/fab_obl_001/manifest + expected_response: + status: 200 + body_match: + mode: subset + value: + fabric_id: fab_obl_001 + caller: + principal_id: did:web:bob.example + obligations_by_ref: + prop_xyz: + kind: vote + event_ref: prop_xyz + due_by: "2026-05-16T10:00:00Z" + status: pending + body_ignore_fields: + - generated_at + - last_event_seq + - snapshot_at + + - id: cast-vote + request: + method: POST + path: /api/pact/fab_obl_001/proposals/prop_xyz/approve + headers: + Content-Type: application/json + body: + decision: approve + expected_response: + status: 200 + body_match: + mode: subset + value: + proposalId: prop_xyz + recorded: true + body_ignore_fields: + - vote_id + - recorded_at + + - id: post-vote-manifest-omits-obligation + request: + method: GET + path: /api/pact/fab_obl_001/manifest + expected_response: + status: 200 + body_match: + mode: subset + value: + fabric_id: fab_obl_001 + caller: + principal_id: did:web:bob.example + body_ignore_fields: + - generated_at + - last_event_seq + - snapshot_at + cross_call_assertions: + # The vote obligation MUST NOT appear in caller.obligations with + # status: pending. The runner's `negative_obligation` assertion walks + # body.caller.obligations and rejects if any entry matches all of + # the given keys. + - kind: negative_obligation + body_path: caller.obligations + match: + kind: vote + event_ref: prop_xyz + status: pending + +expected_events: + ordered: true + # The vote in step 2 emits a vote-recorded event AND a discharge event + # against B's obligation. The strictest reading: the discharge event + # MUST carry the obligation it discharged so manifest reconcilers can + # cross-reference. + sequence: + - event_type: pact.proposal.voted + match: + mode: subset + value: + proposalId: prop_xyz + voter: did:web:bob.example + decision: approve + body_ignore_fields: + - id + - epochMs + - sequenceNumber + - event_type: pact.obligation.discharged + match: + mode: subset + value: + principalId: did:web:bob.example + obligation: + kind: vote + event_ref: prop_xyz + body_ignore_fields: + - id + - epochMs + - sequenceNumber + - discharged_at + +postconditions: + server_state: + events_added: 2 # vote + discharge + resource_modified: true # proposal vote count incremented + +notes: | + Assumptions made against the still-drafting v2.0.3 §6.5 text: + - Obligation shape is `{ kind, event_ref, due_by, status }`. `kind` is + extensible (`vote` | `acknowledge` | `respond` | impl-defined); v2.0.3 + pins `vote` as a first-class kind because the proposal-voting flow is + the most common obligation source. + - The discharge mechanism is implicit: when the underlying action (here, + a vote) is taken, the server cleans up the matching obligation and + emits `pact.obligation.discharged`. The strictest reading is that the + discharge MUST be visible BOTH in the manifest (obligation no longer + pending) AND in the event log (a discharge event references the + discharged obligation). Either alone is insufficient — manifest-only + means no audit trail; event-only means agents can't read current state. + - `cross_call_assertions[].kind: negative_obligation` is a v2.0.3 runner + extension: walks the named body_path and rejects if any entry matches + ALL of the given keys in `match`. This is the negation form needed for + "MUST NOT contain" assertions on collection bodies. + - RECONCILED: the vote-cast endpoint is `POST /proposals/{id}/approve` + (§4.3 / §7.1). The earlier draft assumed a `/proposals/{id}/vote` + verb that does not exist in the §7.1 REST surface. §6.5 specifies a + `vote` obligation is discharged when the member emits + `proposal.approve | proposal.reject | proposal.object`; `approve` is + the canonical spec verb for the affirmative case exercised here. + +failure_classification: + severity: blocker + common_causes: + - Manifest does not surface pending obligations (agents have no protocol-level signal of what they owe — falls back to ad-hoc polling) + - Voting does not discharge the obligation (manifest keeps prompting B to vote forever; agents end up retrying or escalating) + - Obligation cleared from manifest but no pact.obligation.discharged event emitted (audit-trail gap — disputes can't reconstruct who discharged when) + - Discharge event omits `obligation.event_ref` (cross-reference to manifest entry is broken; reconcilers must guess) diff --git a/spec/v2.3/conformance/extended/sessions/onboard-partial-failure.yaml b/spec/v2.3/conformance/extended/sessions/onboard-partial-failure.yaml new file mode 100644 index 0000000..4a530f5 --- /dev/null +++ b/spec/v2.3/conformance/extended/sessions/onboard-partial-failure.yaml @@ -0,0 +1,141 @@ +# Conformance test vector — Extended: atomic onboarding failure leaves NO state +# behind (v2.0.3). kind: session — two sequenced HTTP calls with cross-call +# assertions: +# step 1. POST /fabric/{id}/_onboard with a constraint that conflicts with +# fabric policy → expect 4xx, body identifies the conflicting +# constraint by name. +# step 2. GET /fabric/{id}/_status → caller MUST NOT appear as a member. +# +# This is the load-bearing atomicity test: a partial-failure onboarding that +# silently half-joined a caller would break manifest invariants downstream. + +kind: session + +metadata: + id: extended/sessions/onboard-partial-failure + description: A caller invokes _onboard with a constraint conflicting with fabric policy. The server rejects (4xx), names the offending constraint, and the post-call /_status snapshot confirms the caller is NOT a member of the fabric — i.e. the onboarding was atomic, not partial. + spec_section: "§4.4, §15.6" + conformance_level: extended + track: T3 + +preconditions: + server_state: + resource_id: fab_abc123 + registered_agents: [] # caller is not a member + # Fabric policy: max disclosure-ceiling = 2. The caller will ask for 4 + # in step 1 — that's the conflict the server must reject. + fabric_policy: + max_disclosure_ceiling: 2 + request_context: + auth: bearer + principal_id: did:web:knox.example + +steps: + - id: onboard-rejected + request: + method: POST + path: /api/pact/fab_pf_abc123/_onboard + headers: + Content-Type: application/json + body: + agentName: test-bot + role: contributor + # RECONCILED to spec §4.4.5 onboard-request shape: constraints are + # { sectionId, boundary }, not the placeholder { name, value }. + # `disclosure-ceiling` carried as the sectionId; boundary "4" + # exceeds the fabric policy max of 2. + constraints: + - sectionId: rate-limit-per-minute + boundary: "30" + - sectionId: disclosure-ceiling + boundary: "4" # exceeds fabric policy max of 2 + expected_response: + # 422 is the strictest reading ("the request body is well-formed but + # the policy refused"). The spec §4.4.5 does not pin a numeric status + # for constraint rejection; 422 is consistent with its + # validation-failure framing. 409 would also conform. + status: 422 + body_match: + mode: subset + # RECONCILED to spec §4.4.5 rejection response: the normative shape + # is { status: "rejected", rejection_reason, rejected_constraint_index, + # errors[] }. The earlier draft asserted a placeholder + # { error, rejected_constraint:{name} }. The load-bearing assertion + # — the response names the offending constraint so the caller can + # self-correct — is preserved via `rejected_constraint_index: 1` + # plus the `errors[].code`. + value: + status: rejected + rejection_reason: constraint.incompatible + rejected_constraint_index: 1 + body_ignore_fields: + - request_id + - timestamp + - fabric_id + - errors + + - id: status-confirms-non-membership + request: + method: GET + path: /api/pact/fab_pf_abc123/_status + expected_response: + status: 200 + body_match: + mode: subset + value: + # RECONCILED: `fabricId` → `fabric_id` (the normative §4.4.1 key). + fabric_id: fab_pf_abc123 + # The caller's principal MUST NOT appear in members[]. This is + # expressed structurally below via the assertion contract: + # the runner walks `members` and rejects if any entry has + # principalId == did:web:knox.example. + body_ignore_fields: + - last_event_seq + - heartbeat_timeout_seconds + # cross_call_assertions runs after the response is captured. The runner + # treats `negative_membership` as a structural check on the response body: + # for every entry in `body.members`, `principalId` MUST NOT equal the + # listed principal_id. + cross_call_assertions: + - kind: negative_membership + body_path: members + principal_id: did:web:knox.example + +expected_events: + # The strictest reading of atomicity is that a rejected onboard emits NO + # success events. A diagnostic `pact.trust.violation` or + # `pact.fabric.onboard-rejected` event MAY be emitted; v2.0.3 leaves this + # implementation-defined per the parallel spec draft. We do NOT assert + # presence here — that's a `notes:` reconciliation item. + ordered: true + sequence: [] + +postconditions: + server_state: + events_added: 0 # strict atomicity: no half-state + resource_modified: false # membership table unchanged + +notes: | + Assumptions made against the still-drafting v2.0.3 §4.4 / §15.6 text: + - HTTP 422 is the strictest reading of "constraint rejected by policy." + 409 (Conflict) is also acceptable. If the spec converges on 409, this + vector's expected_response.status will need to change. + - The rejection body carries `error: constraint_rejected` and + `rejected_constraint: { name: }`. The strictest reading is that + the response identifies the conflicting constraint BY NAME so the caller + can retry with a narrower request without trial-and-error binary search. + - `events_added: 0` is the conservative interpretation. Some implementations + will emit an audit event (`pact.fabric.onboard-rejected` or + `pact.trust.violation`) for SIEM consumption — this vector does NOT + require absence of audit events, only absence of success events. + - `cross_call_assertions[].kind: negative_membership` is a v2.0.3 runner + extension: walks the named body_path and rejects if any entry has + `principalId` matching the listed value. Documented in the runner. + +failure_classification: + severity: blocker + common_causes: + - Server returns 200 / partially-joined membership on a conflicting onboard (atomicity violated) + - Rejection body omits the conflicting constraint name (caller cannot self-correct) + - GET /_status shows the rejected caller as a member with reduced constraints (silent constraint narrowing — worse than outright failure because it hides the policy enforcement) + - Server emits pact.fabric.onboarded before policy check completes (event-log inconsistent with membership table) diff --git a/spec/v2.3/conformance/extended/sessions/onboard-success.yaml b/spec/v2.3/conformance/extended/sessions/onboard-success.yaml new file mode 100644 index 0000000..31993b1 --- /dev/null +++ b/spec/v2.3/conformance/extended/sessions/onboard-success.yaml @@ -0,0 +1,134 @@ +# Conformance test vector — Extended: atomic onboarding happy path (v2.0.3). +# kind: http — single POST to /fabric/{id}/_onboard, asserts response shape + +# event sequence emitted by the server. +# +# The v2.0.3 spec text for §4.4 and §15.6 is still being drafted in parallel +# with this vector. The shape below uses the strictest reasonable interpretation +# of the preamble at the top of spec/v2.0/SPECIFICATION.md — see `notes:` for +# the specific assumptions the coordinator will reconcile before tagging. + +kind: http + +metadata: + id: extended/sessions/onboard-success + description: A caller atomically joins a fabric and declares its constraints in a single _onboard call. The fabric exists, the constraints are compatible with fabric policy, the response carries membership_id + role + accepted_constraints, and both pact.fabric.onboarded and pact.agent.joined are emitted. + spec_section: "§4.4, §6.5, §15.6" + conformance_level: extended + track: T3 + +preconditions: + server_state: + resource_id: fab_abc123 # the fabric the caller is joining + registered_agents: [] # caller is not yet a member + request_context: + auth: bearer # caller already authenticated; onboarding declares fabric-membership intent + principal_id: did:web:knox.example + +request: + method: POST + path: /api/pact/fab_abc123/_onboard + headers: + Content-Type: application/json + body: + agentName: test-bot + role: contributor # one of: observer | contributor | proposer (impl-defined further roles allowed) + # RECONCILED to spec §4.4.5 onboard-request shape: constraints are + # { sectionId, boundary, category }, not the placeholder { name, value }. + constraints: + - sectionId: sec:risk + boundary: Must not name specific instruments + category: regulatory + - sectionId: sec:budget + boundary: Must not commit beyond Q3 forecast + category: commercial + +expected_response: + status: 200 + body_match: + mode: subset + # RECONCILED to spec §4.4.5 success response: the normative top-level + # shape is { status: "onboarded", fabric_id, registration, + # constraints[], onboarded_event_id, onboarded_at }. The earlier draft + # asserted a bare top-level `role` + `accepted_constraints[{name,value}]` + # that the §4.4.5 response does not define. We assert the normative + # `status` + `fabric_id`, and the constraint commitment via the additive + # `accepted_constraints` array ({sectionId,boundary} — the §4.4.5 + # constraint shape). `role` is kept (the server echoes it additively so + # the "silent role downgrade" failure mode stays covered). + value: + status: onboarded + fabric_id: fab_abc123 + role: contributor + accepted_constraints: + - sectionId: sec:risk + boundary: Must not name specific instruments + - sectionId: sec:budget + boundary: Must not commit beyond Q3 forecast + body_ignore_fields: + - membership_id # server-assigned + - registration # server-assigned join-response sub-object + - constraints # server-minted constraint_ids vary + - onboarded_event_id # server-assigned + - apiKey # scoping key when issued (§22 service-account auth, OPTIONAL) + - onboarded_at # server timestamp + - last_seen # server timestamp seeded at onboard + +expected_events: + ordered: true + sequence: + - event_type: pact.fabric.onboarded + match: + mode: subset + value: + fabricId: fab_abc123 + principalId: did:web:knox.example + actorDisplay: test-bot + role: contributor + body_ignore_fields: + - id + - epochMs + - sequenceNumber + - actorId + - membership_id + - event_type: pact.agent.joined + match: + mode: subset + value: + actorDisplay: test-bot + actorKind: AiAgent + entityType: pact-fabric + body_ignore_fields: + - id + - epochMs + - sequenceNumber + - actorId + +postconditions: + server_state: + events_added: 2 # both pact.fabric.onboarded and pact.agent.joined + resource_modified: true # membership added to fabric state + +notes: | + Assumptions made against the still-drafting v2.0.3 §4.4 / §15.6 text: + - The endpoint is `POST /api/pact/{fabricId}/_onboard` (mirrors the v1.1 + `/join-token` shape — same prefix, leading-underscore action verb). + - The response carries (at minimum) `membership_id`, `role`, + `accepted_constraints`. The strictest reading of "atomic" is that EITHER + every constraint is accepted as-stated OR the call fails (vector 2). + - Two events are emitted: `pact.fabric.onboarded` (the v2.0.3-specific + high-level event listed in the spec preamble at line 184 as `agent.onboard`) + AND the carried-over `pact.agent.joined` event from §4.1, because the + membership IS-A join in the v1.1 model. If the spec converges on a single + event, the second sequence entry should be dropped — the runner will report + a mismatch and the coordinator should reconcile. + - `entityType: pact-fabric` is asserted on `pact.agent.joined` to distinguish + fabric onboarding from document/transaction/fact/record joins. + +failure_classification: + severity: blocker + common_causes: + - Implementation does not expose POST /_onboard at all (returns 404) + - _onboard split into separate /join + /constrain calls (atomicity gone) + - accepted_constraints omitted from response (caller cannot confirm what was committed) + - pact.fabric.onboarded event not emitted (manifest queries downstream cannot reconstruct membership history) + - role echoed differently from request without explanation (silent role downgrade) diff --git a/spec/v2.3/conformance/extended/sessions/two-agent-negotiation-smoke.yaml b/spec/v2.3/conformance/extended/sessions/two-agent-negotiation-smoke.yaml new file mode 100644 index 0000000..e9c1316 --- /dev/null +++ b/spec/v2.3/conformance/extended/sessions/two-agent-negotiation-smoke.yaml @@ -0,0 +1,341 @@ +# Conformance test vector — Extended: canonical two-agent negotiation smoke. +# +# Closes issue #11 ("Agent feedback: define canonical two-agent negotiation +# smoke"). The issue asked for ONE runnable end-to-end transcript a tool / +# agent author can point at: two agents onboard, one proposes, the other +# objects and counter-proposes, the first accepts, the negotiation converges. +# +# kind: session — eight sequenced HTTP calls against a live server. Each +# agent is identified by an explicit `X-Pact-Principal` header (the portable +# multi-agent affordance issue #11 §3 asked for: a CLI/test user simulating +# two agents needs a way to act AS a specific principal per call). +# +# The scenario mirrors the issue's worked example: +# - Alpha (supplier side) wants a strict Net-7 payment term to protect +# supplier cashflow. +# - Beta (customer side) objects: 7 days is too tight for customer +# approval cycles. +# - Beta counter-proposes the compromise from the issue: 21-day payment, +# capped late fee, hardship extension by written approval. +# - Alpha accepts the compromise and declares done; the fabric converges. +# +# This is the protocol-level "happy path of a real disagreement" — not a +# trivial join+leave. It exercises: atomic onboarding (§4.4.5), proposal +# creation with auto-registered vote obligations (§6.5), the objection-based +# stop (§10.5), counter-proposal, affirmative vote + obligation discharge +# (§6.5), and convergence on `done`. + +kind: session + +metadata: + id: extended/sessions/two-agent-negotiation-smoke + description: "Canonical two-agent negotiation: Alpha and Beta each atomically onboard a fabric; Alpha proposes a strict payment term; Beta objects (blocking, §10.5) and counter-proposes a compromise; Alpha approves the counter, discharging its vote obligation; Alpha declares done and the fabric converges. The canonical runnable transcript requested in issue #11." + spec_section: "§4.4, §6.5, §10.5, §15.6" + conformance_level: extended + track: T3 + +preconditions: + server_state: + resource_id: fab_neg_smoke + registered_agents: [] # both agents join via _onboard below + request_context: + auth: bearer + # Two principals; each step carries the acting principal explicitly in + # the X-Pact-Principal header (issue #11 §3 portable agent identity). + principal_id: did:web:alpha.example + +steps: + # 1. Alpha onboards with its supplier-cashflow constraint (§4.4.5). + - id: alpha-onboards + request: + method: POST + path: /api/pact/fab_neg_smoke/_onboard + headers: + Content-Type: application/json + X-Pact-Principal: did:web:alpha.example + body: + agentName: alpha-bot + role: proposer + constraints: + - sectionId: sec:payment-terms + boundary: Supplier cashflow must be protected + category: commercial + expected_response: + status: 200 + body_match: + mode: subset + value: + status: onboarded + fabric_id: fab_neg_smoke + role: proposer + body_ignore_fields: + - registration + - constraints + - accepted_constraints + - membership_id + - onboarded_event_id + - onboarded_at + + # 2. Beta onboards with its customer-approval constraint (§4.4.5). + - id: beta-onboards + request: + method: POST + path: /api/pact/fab_neg_smoke/_onboard + headers: + Content-Type: application/json + X-Pact-Principal: did:web:beta.example + body: + agentName: beta-bot + role: proposer + constraints: + - sectionId: sec:payment-terms + boundary: Customer approval cycle needs slack + category: operational + expected_response: + status: 200 + body_match: + mode: subset + value: + status: onboarded + fabric_id: fab_neg_smoke + role: proposer + body_ignore_fields: + - registration + - constraints + - accepted_constraints + - membership_id + - onboarded_event_id + - onboarded_at + + # 3. Alpha proposes the strict Net-7 term. A caller-supplied proposalId is + # used as a deterministic correlation handle (like a heartbeat's + # client_heartbeat_id) so later steps can reference it. Creating the + # proposal auto-registers a §6.5 vote obligation against Beta. + - id: alpha-proposes-strict + request: + method: POST + path: /api/pact/fab_neg_smoke/proposals + headers: + Content-Type: application/json + X-Pact-Principal: did:web:alpha.example + body: + proposalId: prop_strict + sectionId: sec:payment-terms + summary: "Net-7 payment term (supplier cashflow protection)" + expected_response: + status: 200 + body_match: + mode: subset + value: + proposalId: prop_strict + status: open + created: true + proposer: did:web:alpha.example + body_ignore_fields: + - required_voters + - created_at + - event_id + + # 4. Beta lodges a blocking objection — 7 days is too tight. Per §10.5 the + # objection stops the proposal; it transitions out of `open`. + - id: beta-objects + request: + method: POST + path: /api/pact/fab_neg_smoke/proposals/prop_strict/object + headers: + Content-Type: application/json + X-Pact-Principal: did:web:beta.example + body: + decision: object + reason: "Net-7 is too tight for our customer approval cycle" + expected_response: + status: 200 + body_match: + mode: subset + value: + proposalId: prop_strict + recorded: true + decision: object + body_ignore_fields: + - vote_id + - recorded_at + + # 5. Beta counter-proposes the compromise from issue #11: 21-day payment, + # capped late fee, hardship extension by written approval. This + # auto-registers a §6.5 vote obligation against Alpha. + - id: beta-counter-proposes + request: + method: POST + path: /api/pact/fab_neg_smoke/proposals + headers: + Content-Type: application/json + X-Pact-Principal: did:web:beta.example + body: + proposalId: prop_compromise + sectionId: sec:payment-terms + summary: "Net-21, capped late fee, hardship extension by written approval" + expected_response: + status: 200 + body_match: + mode: subset + value: + proposalId: prop_compromise + status: open + created: true + proposer: did:web:beta.example + body_ignore_fields: + - required_voters + - created_at + - event_id + + # 6. Alpha verifies via its manifest that it now owes a vote on the + # counter-proposal (§6.5 obligation surfacing, caller-scoped). + - id: alpha-manifest-shows-vote-obligation + request: + method: GET + path: /api/pact/fab_neg_smoke/manifest + headers: + X-Pact-Principal: did:web:alpha.example + expected_response: + status: 200 + body_match: + mode: subset + value: + fabric_id: fab_neg_smoke + caller: + principal_id: did:web:alpha.example + obligations_by_ref: + prop_compromise: + kind: vote + event_ref: prop_compromise + status: pending + body_ignore_fields: + - generated_at + - last_event_seq + - snapshot_at + + # 7. Alpha approves the compromise. This records the vote AND discharges + # Alpha's §6.5 vote obligation on prop_compromise. + - id: alpha-approves-compromise + request: + method: POST + path: /api/pact/fab_neg_smoke/proposals/prop_compromise/approve + headers: + Content-Type: application/json + X-Pact-Principal: did:web:alpha.example + body: + decision: approve + expected_response: + status: 200 + body_match: + mode: subset + value: + proposalId: prop_compromise + recorded: true + decision: approve + body_ignore_fields: + - vote_id + - recorded_at + + # 8. Alpha declares done. With no proposal still `open` (strict objected, + # compromise approved) the fabric converges. The post-vote manifest + # obligation MUST be gone — the negative_obligation cross-call asserts + # Alpha no longer owes a pending vote on prop_compromise. + - id: alpha-done-fabric-converges + request: + method: POST + path: /api/pact/fab_neg_smoke/done + headers: + Content-Type: application/json + X-Pact-Principal: did:web:alpha.example + expected_response: + status: 200 + body_match: + mode: subset + value: + fabric_id: fab_neg_smoke + principal_id: did:web:alpha.example + done: true + phase: converged + body_ignore_fields: + - completed_at + - event_id + +# Event-log assertions are documentation: the runner does not subscribe to +# the event stream (deferred — see runner/src/index.ts). The expected ordered +# sequence is recorded here so a future event-aware runner can enforce it. +expected_events: + ordered: true + sequence: + - event_type: pact.fabric.onboarded # step 1, Alpha + match: { mode: subset, value: { actorDisplay: alpha-bot, entityType: pact-fabric } } + body_ignore_fields: [id, epochMs, sequenceNumber, actorId, membership_id] + - event_type: pact.fabric.onboarded # step 2, Beta + match: { mode: subset, value: { actorDisplay: beta-bot, entityType: pact-fabric } } + body_ignore_fields: [id, epochMs, sequenceNumber, actorId, membership_id] + - event_type: pact.proposal.created # step 3, strict + match: { mode: subset, value: { proposalId: prop_strict, proposer: did:web:alpha.example } } + body_ignore_fields: [id, epochMs, sequenceNumber, actorId] + - event_type: pact.obligation.created # step 3, Beta owes a vote + match: { mode: subset, value: { kind: vote, event_ref: prop_strict, principal_id: did:web:beta.example } } + body_ignore_fields: [id, epochMs, sequenceNumber, actorId] + - event_type: pact.proposal.voted # step 4, Beta objects + match: { mode: subset, value: { proposalId: prop_strict, voter: did:web:beta.example, decision: object } } + body_ignore_fields: [id, epochMs, sequenceNumber, actorId] + - event_type: pact.obligation.discharged # step 4, Beta's vote obligation discharged by the objection + match: { mode: subset, value: { principalId: did:web:beta.example } } + body_ignore_fields: [id, epochMs, sequenceNumber, actorId, discharged_at] + - event_type: pact.proposal.created # step 5, compromise + match: { mode: subset, value: { proposalId: prop_compromise, proposer: did:web:beta.example } } + body_ignore_fields: [id, epochMs, sequenceNumber, actorId] + - event_type: pact.obligation.created # step 5, Alpha owes a vote + match: { mode: subset, value: { kind: vote, event_ref: prop_compromise, principal_id: did:web:alpha.example } } + body_ignore_fields: [id, epochMs, sequenceNumber, actorId] + - event_type: pact.proposal.voted # step 7, Alpha approves + match: { mode: subset, value: { proposalId: prop_compromise, voter: did:web:alpha.example, decision: approve } } + body_ignore_fields: [id, epochMs, sequenceNumber, actorId] + - event_type: pact.obligation.discharged # step 7, Alpha's vote obligation discharged + match: { mode: subset, value: { principalId: did:web:alpha.example } } + body_ignore_fields: [id, epochMs, sequenceNumber, actorId, discharged_at] + - event_type: pact.agent.done # step 8, Alpha done + match: { mode: subset, value: { principalId: did:web:alpha.example } } + body_ignore_fields: [id, epochMs, sequenceNumber, actorId] + +postconditions: + server_state: + events_added: 11 + resource_modified: true # two members, two proposals, obligations created+discharged + +notes: | + This vector is the canonical answer to issue #11. Mapping to the issue's + protocol/tooling questions: + - Q1/Q2 ("is Negotiation a first-class primitive / does it need a canonical + open operation?"): this smoke shows the negotiation is expressible with + the EXISTING §4.x primitives — _onboard, POST /proposals, the + approve/reject/object verbs (§4.3 / §7.1), POST /done. No new + "open negotiation" operation is required at the protocol layer; the + "negotiation" is the emergent proposal/objection/counter loop. + - Q3 ("portable multi-agent identity"): each step carries an explicit + `X-Pact-Principal: ` header. That is the portable affordance — a + CLI / test harness simulating N agents acts AS a principal per call by + setting this header. (Bearer auth in production resolves the principal + from the token; the header is the conformance-harness analogue.) + - Q4 ("minimal canonical transcript"): the eight steps ARE that transcript + — onboard ×2 → propose → object → counter-propose → (manifest shows the + obligation) → approve → done → converged. + - The blocking-objection semantics (§10.5): an `object` vote stops the + proposal (status leaves `open`); it does not silently linger. This is + why `done` converges — no proposal is left `open`. + - `caller.obligations_by_ref` is the additive event_ref-keyed obligation + index (same rationale as the other reconciled session vectors: the + runner's subset matcher compares arrays by exact JSON equality, so a + per-obligation assertion needs a keyed path). + +failure_classification: + severity: blocker + common_causes: + - _onboard not atomic, so a second agent cannot cleanly join (negotiation never starts) + - POST /proposals does not register a vote obligation on counterparties (§6.5 not wired — the manifest can't show what an agent owes) + - An objection leaves the proposal `open` forever (§10.5 stop not implemented — fabric never converges) + - Approving the counter-proposal does not discharge the proposer-side vote obligation (agents keep re-prompting to vote) + - /done does not transition the fabric to `converged` when no proposal is open (callers cannot detect a finished negotiation) diff --git a/spec/v2.3/conformance/runner/.gitignore b/spec/v2.3/conformance/runner/.gitignore new file mode 100644 index 0000000..b947077 --- /dev/null +++ b/spec/v2.3/conformance/runner/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +dist/ diff --git a/spec/v2.3/conformance/runner/README.md b/spec/v2.3/conformance/runner/README.md new file mode 100644 index 0000000..4cd426f --- /dev/null +++ b/spec/v2.3/conformance/runner/README.md @@ -0,0 +1,63 @@ +# @pact-protocol/conformance-runner + +The PACT v2.0 conformance runner. Loads test-vector YAML files (per [`../test-vector-format.yaml`](../test-vector-format.yaml)) and executes them, reporting pass/fail. + +## Vector kinds + +- **`kind: verification`** — runs the §17.7 authorization-proof verification flow locally (no server). Structural checks + freshness + nonce binding + (with `registry`) principal resolution and credential revocation. +- **`kind: http`** — executes the recorded HTTP request against a PACT server (`--server`), compares status + body using `body_match.mode` (`exact` / `subset`; `schema` deferred). Event-sequence assertion is deferred to a follow-up. + +`kind: verification` runs unconditionally in CI; `kind: http` SKIPs when no `--server` is provided. + +## Usage + +```bash +cd spec/v2.0/conformance/runner +npm install +npm run build +node dist/index.js run --vectors .. # all vectors under spec/v2.0/conformance/ +node dist/index.js run --vectors .. --filter verify # only ids containing 'verify' +node dist/index.js run --vectors .. --server https://pact.example.com # also run http vectors +node dist/index.js run --vectors .. --json # JSON report (for CI gating) +``` + +Exit code: `0` if all selected vectors `pass` (or are `skip`ped for documented reasons); `1` otherwise. + +## What's covered today + +- §17.7 verification flow steps 1, 2, 4, 5 + §17.8 revocation/tombstone. +- **§17.7 step 3 (cryptographic signature verification)** for `type: fido2-assertion` proofs when the vector declares `verification.signature_check: real`. The runner uses Node's built-in `crypto.verify` over the SPKI-DER-encoded enrolled public key, with the v2.0 alg whitelist `webauthn-es256` / `webauthn-es384` / `webauthn-eddsa`. The fallback signed-payload composition is `UTF-8(challenge_nonce || asserted_at [|| payload_hash])`. Full WebAuthn buffer verification (`authenticatorData` + `clientDataJSON`) via `@simplewebauthn/server` is wired in as a branch but deferred to v2.0.3. +- HTTP execution + status/body match (exact/subset). Body-ignore-fields supported. + +## What's NOT covered yet (TODO) + +- `body_match.mode: schema` — needs ajv (or equivalent) plugged in. +- `expected_events` — needs an event-log subscription. Most servers expose this via SignalR or polling; the runner will subscribe and verify the sequence with a configurable timeout. +- Full WebAuthn `authenticatorData + clientDataJSON` buffer verification via `@simplewebauthn/server` (deferred to v2.0.3 — the generic fallback covers the v2.0.2 self-contained vectors). +- `voice-biometric` cryptographic verification (per HMAN's #3 PR — §18.6). +- HTTP record-and-replay (rather than live-execute) — useful for offline conformance checks. +- A self-certification badge generator. + +## Honesty disclosure + +The runner now performs **real cryptographic signature verification** for `type: fido2-assertion` proofs whenever the vector declares `verification.signature_check: real`. Such vectors PASS only if the runner can verify the proof's `signature` against the SPKI-DER public key enrolled in the vector's `registry`. A real-shape signature that does NOT verify is rejected at §17.7 step 3 (`failing_step: 3`). This closes the v2.0.1 "A1: forged-signature pass" attack — see `spec/v2.0/conformance/extended/attestation/verify-fido2-real-signature.yaml` (positive) and `verify-fido2-forged-signature.yaml` (negative) for the smoke test. + +Two PASS shapes: + +- **`✓ verified-cryptographic`** (JSON `verification_mode: cryptographic`) — §17.7 step 3 ran and the signature verified against the enrolled public key. The result `verified` here means the same thing it does in §17.7: the proof is end-to-end valid. +- **`✓ verified-structural`** (JSON `verification_mode: structural`) — the runner exercised envelope / principal resolution / freshness / replay only. Step 3 was skipped because the vector opted in via `signature_check: structural` (legacy v2.0.1 placeholder-signature vectors), or because the attestation type is not `fido2-assertion` (`voice-biometric` defers to HMAN's #3 PR; custom types defer to their implementation-defined verifiers). + +A structural-only PASS does **NOT** prove the signature is cryptographically valid. New `fido2-assertion` vectors SHOULD use `signature_check: real` and carry a real signature + matching public key. + +## External implementers + +This runner is currently a **private package** (`private: true` in `package.json`). External implementers can use it via: + +- **Source checkout:** `git clone TailorAU/pact && cd spec/v2.0/conformance/runner && npm install && npm run build`. This is the supported path while npm publish is gated on issue [#5](https://github.com/TailorAU/pact/issues/5) (the `pact-protocol` org). +- **Self-cert flow:** run the suite locally against your server, then PR the result manifest into `docs/IMPLEMENTERS.md` (TODO until first implementer arrives). + +When `pact-protocol` is on npm, this package will publish alongside `@pact-protocol/cli` and `@pact-protocol/mcp` and external implementers can `npx @pact-protocol/conformance-runner run --server …`. + +## Status + +First usable version, v0.1.0-dev. Ride-along with the conformance scaffold (`spec/v2.0/conformance/`) and the `.github/workflows/conformance.yml` CI gate. diff --git a/spec/v2.3/conformance/runner/package-lock.json b/spec/v2.3/conformance/runner/package-lock.json new file mode 100644 index 0000000..1e0e515 --- /dev/null +++ b/spec/v2.3/conformance/runner/package-lock.json @@ -0,0 +1,344 @@ +{ + "name": "@pact-protocol/conformance-runner", + "version": "0.1.0-dev", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@pact-protocol/conformance-runner", + "version": "0.1.0-dev", + "dependencies": { + "@simplewebauthn/server": "^13.3.0", + "js-yaml": "^4.1.0" + }, + "bin": { + "pact-conformance": "dist/index.js" + }, + "devDependencies": { + "@types/js-yaml": "^4.0.9", + "@types/node": "^20.11.0", + "typescript": "^5.4.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@hexagon/base64": { + "version": "1.1.28", + "resolved": "https://registry.npmjs.org/@hexagon/base64/-/base64-1.1.28.tgz", + "integrity": "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==", + "license": "MIT" + }, + "node_modules/@levischuck/tiny-cbor": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@levischuck/tiny-cbor/-/tiny-cbor-0.2.11.tgz", + "integrity": "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==", + "license": "MIT" + }, + "node_modules/@peculiar/asn1-android": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-android/-/asn1-android-2.7.0.tgz", + "integrity": "sha512-iD3VskhVQnM4nE3PN9cBdPTR7JrqZy3FYk+uD2CeG6DUqKoANqaEfx0f7izPmW+Qm5JBM35ek+viLCmjy18ByQ==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.7.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-cms": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-cms/-/asn1-cms-2.7.0.tgz", + "integrity": "sha512-hew63shtzzvBcSHbhm+cyAmKe6AIfinT9hzEqSPjDC6opTTMKmTkQ0gHuN2KsWlvqiKw1S/fS94fhag/FJkioQ==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.7.0", + "@peculiar/asn1-x509": "^2.7.0", + "@peculiar/asn1-x509-attr": "^2.7.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-csr": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-csr/-/asn1-csr-2.7.0.tgz", + "integrity": "sha512-VVsAyGqErT9D1SY4aEqozThXMVI+ssVRiv2DDeYuvpBKLIgZ3hYs3Ay3u/VSoKq6ESFi9cf6rf3IOOzfwh7oMA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.7.0", + "@peculiar/asn1-x509": "^2.7.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-ecc": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-ecc/-/asn1-ecc-2.7.0.tgz", + "integrity": "sha512-n7KEs/Q/wrB415cxy4fHOBhegp4NdJ15fkJPwcB/3/8iNBQC2L/N7SChJPKDJPZGYH0jD4Tg4/0vnHmwghnbKw==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.7.0", + "@peculiar/asn1-x509": "^2.7.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-pfx": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pfx/-/asn1-pfx-2.7.0.tgz", + "integrity": "sha512-V/nrlQVmhg7lYAsM7E13UDL5erAwFv6kCIVFqNaMIHSVi7dngcT839JkRTkQBqznMG98l2XjxYk74ZztAohZzA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.7.0", + "@peculiar/asn1-pkcs8": "^2.7.0", + "@peculiar/asn1-rsa": "^2.7.0", + "@peculiar/asn1-schema": "^2.7.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-pkcs8": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs8/-/asn1-pkcs8-2.7.0.tgz", + "integrity": "sha512-9GTl1nE8Mx1kTZ+7QyYatDyKsm34QcWRBFkY1iPvWC3X4Dona5s/tlLiQsx5WzVdZqiMBZNYT0buyw4/vbhnjw==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.7.0", + "@peculiar/asn1-x509": "^2.7.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-pkcs9": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-pkcs9/-/asn1-pkcs9-2.7.0.tgz", + "integrity": "sha512-Bh7m+OuIaSEllPQcSd9OSp93F4ROWH7sbITWV8MI+8dwsjE5111/87VxiWVvYFKyww3vp39geLv9ENqhwWHcew==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.7.0", + "@peculiar/asn1-pfx": "^2.7.0", + "@peculiar/asn1-pkcs8": "^2.7.0", + "@peculiar/asn1-schema": "^2.7.0", + "@peculiar/asn1-x509": "^2.7.0", + "@peculiar/asn1-x509-attr": "^2.7.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-rsa": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-rsa/-/asn1-rsa-2.7.0.tgz", + "integrity": "sha512-/qvENQrXyTZURjMqSeofHul0JJt2sNSzSwk36pl2olkHbaioMQgrASDZAlHXl0xUlnVbHj0uGgOrBMTb5x2aJQ==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.7.0", + "@peculiar/asn1-x509": "^2.7.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-schema": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-schema/-/asn1-schema-2.7.0.tgz", + "integrity": "sha512-W8ZfWzLmQnrcky+eh3tni4IozMdqBDiHWU0N+vve/UGjMaUs8c0L7A2oEdkBXS8rTpWDpK/aoI3DG/L/hxmxPg==", + "license": "MIT", + "dependencies": { + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-x509": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509/-/asn1-x509-2.7.0.tgz", + "integrity": "sha512-mUn9RRrkGDnG4ALfunDmzyRW5dg+sWCj/pfnCCqEHYbkGxEpvUt6iVJv8Yw1cyp6SWZ26ZE5oSmI5SqEaen15g==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.7.0", + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-x509-attr": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/@peculiar/asn1-x509-attr/-/asn1-x509-attr-2.7.0.tgz", + "integrity": "sha512-NS8e7SOgXipkzUPLF/sce7ukpMpWjhxYsH0n6Y+bHYo4TTxOb95Zv7hqwSuL212mj5YxovjdOKQOgH1As3E94w==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.7.0", + "@peculiar/asn1-x509": "^2.7.0", + "asn1js": "^3.0.6", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/utils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@peculiar/utils/-/utils-2.0.3.tgz", + "integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==", + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/x509": { + "version": "1.14.3", + "resolved": "https://registry.npmjs.org/@peculiar/x509/-/x509-1.14.3.tgz", + "integrity": "sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.6.0", + "@peculiar/asn1-csr": "^2.6.0", + "@peculiar/asn1-ecc": "^2.6.0", + "@peculiar/asn1-pkcs9": "^2.6.0", + "@peculiar/asn1-rsa": "^2.6.0", + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.0", + "pvtsutils": "^1.3.6", + "reflect-metadata": "^0.2.2", + "tslib": "^2.8.1", + "tsyringe": "^4.10.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@simplewebauthn/server": { + "version": "13.3.0", + "resolved": "https://registry.npmjs.org/@simplewebauthn/server/-/server-13.3.0.tgz", + "integrity": "sha512-MLHYFrYG8/wK2i+86XMhiecK72nMaHKKt4bo+7Q1TbuG9iGjlSdfkPWKO5ZFE/BX+ygCJ7pr8H/AJeyAj1EaTQ==", + "license": "MIT", + "dependencies": { + "@hexagon/base64": "^1.1.27", + "@levischuck/tiny-cbor": "^0.2.2", + "@peculiar/asn1-android": "^2.6.0", + "@peculiar/asn1-ecc": "^2.6.1", + "@peculiar/asn1-rsa": "^2.6.1", + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.1", + "@peculiar/x509": "^1.14.3" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@types/js-yaml": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz", + "integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.41", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.41.tgz", + "integrity": "sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/asn1js": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/asn1js/-/asn1js-3.0.10.tgz", + "integrity": "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==", + "license": "BSD-3-Clause", + "dependencies": { + "pvtsutils": "^1.3.6", + "pvutils": "^1.1.5", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/pvtsutils": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/pvtsutils/-/pvtsutils-1.3.6.tgz", + "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/pvutils": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/pvutils/-/pvutils-1.1.5.tgz", + "integrity": "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==", + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/reflect-metadata": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", + "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", + "license": "Apache-2.0" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tsyringe": { + "version": "4.10.0", + "resolved": "https://registry.npmjs.org/tsyringe/-/tsyringe-4.10.0.tgz", + "integrity": "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==", + "license": "MIT", + "dependencies": { + "tslib": "^1.9.3" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/tsyringe/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/spec/v2.3/conformance/runner/package.json b/spec/v2.3/conformance/runner/package.json new file mode 100644 index 0000000..8bb5b43 --- /dev/null +++ b/spec/v2.3/conformance/runner/package.json @@ -0,0 +1,27 @@ +{ + "name": "@pact-protocol/conformance-runner", + "version": "0.3.1-dev", + "description": "Runner for the PACT v2.0 conformance suite. Executes kind:http, kind:verification, and kind:session test vectors and reports pass/fail.", + "type": "module", + "private": true, + "bin": { + "pact-conformance": "dist/index.js" + }, + "scripts": { + "build": "tsc", + "typecheck": "tsc --noEmit", + "start": "node dist/index.js" + }, + "dependencies": { + "@simplewebauthn/server": "^13.3.0", + "js-yaml": "^4.1.0" + }, + "devDependencies": { + "@types/js-yaml": "^4.0.9", + "@types/node": "^20.11.0", + "typescript": "^5.4.0" + }, + "engines": { + "node": ">=20" + } +} diff --git a/spec/v2.3/conformance/runner/src/index.ts b/spec/v2.3/conformance/runner/src/index.ts new file mode 100644 index 0000000..2c4837f --- /dev/null +++ b/spec/v2.3/conformance/runner/src/index.ts @@ -0,0 +1,671 @@ +#!/usr/bin/env node +/** + * PACT v2.0 conformance runner. + * + * Loads test-vector YAML files (per ../test-vector-format.yaml) and executes + * them. Two vector kinds: + * - kind: verification — runs the §17.7 authorization-proof verification + * flow locally (no server needed); compares result + failing_step against + * `expected`. + * - kind: http — executes the HTTP request against a server (--server), + * compares status + body (with body_ignore_fields). + * + * CLI: + * pact-conformance run --vectors '../**\/*.yaml' [--server ] [--filter ] [--json] + * + * Exit code: 0 if all selected vectors pass (or are SKIPped for documented + * reasons), 1 otherwise. + */ + +import { readdirSync, readFileSync, statSync } from 'node:fs'; +import { join, relative, resolve as resolvePath } from 'node:path'; +import { load as yamlLoad } from 'js-yaml'; +import { verifyFido2Assertion } from './webauthn.js'; + +// ─── types ────────────────────────────────────────────────────────────── + +interface HttpRequest { + method: string; + path: string; + headers?: Record; + body?: unknown; +} + +interface HttpExpectedResponse { + status: number; + headers?: Record; + body_match?: { mode: 'exact' | 'subset' | 'schema'; value: unknown }; + body_ignore_fields?: string[]; +} + +/** + * Cross-call assertion run against a step's response body AFTER the basic + * status + body_match checks pass. Used by kind: session vectors to express + * "MUST NOT contain" invariants on collection bodies — e.g. after a rejected + * onboard, the /_status response's `members` array MUST NOT contain the + * rejected caller's principal_id. + * + * Two v2.0.3 kinds: + * - negative_membership: walks `body_path` (dot-pathed array of objects); + * rejects if any entry has `principalId === principal_id`. + * - negative_obligation: walks `body_path` (dot-pathed array of objects); + * rejects if any entry's keys match ALL keys in `match`. + */ +interface CrossCallAssertion { + kind: 'negative_membership' | 'negative_obligation'; + body_path: string; + principal_id?: string; + match?: Record; +} + +interface SessionStep { + id: string; + request: HttpRequest; + expected_response: HttpExpectedResponse; + cross_call_assertions?: CrossCallAssertion[]; +} + +interface Vector { + kind?: 'http' | 'verification' | 'session'; + metadata: { + id: string; + description?: string; + spec_section?: string; + conformance_level?: string; + track?: string; + }; + // kind: http + preconditions?: unknown; + request?: HttpRequest; + expected_response?: HttpExpectedResponse; + expected_events?: unknown; + postconditions?: unknown; + // kind: session — sequenced HTTP steps with cross-call assertions + steps?: SessionStep[]; + // kind: verification + verification?: { + proof: Record; + registry?: Registry; + did_documents?: Record; + verifier_clock?: string; + issued_nonces?: string[]; + operation_requires_uv?: boolean; + /** + * The receiving verifier's identity (DID), used to enforce the §17.6 / §17.7-step-5 + * `verifier_id` EQUALITY rule. If the proof carries a `verifier_id`, it MUST equal + * this value; otherwise the runner rejects at step 5. Absent → equality check is + * skipped (presence-only, legacy v2.0.1 behaviour). + */ + receiving_verifier_id?: string; + /** + * 'real' (default): the runner attempts real cryptographic signature + * verification for first-class attestation types (`fido2-assertion`). An + * `unverifiable` outcome from the verifier counts as a non-pass. + * 'structural': the runner skips cryptographic checks (or treats + * `unverifiable` placeholder signatures as a structural pass). Used for the + * legacy v2.0.1 vectors that exercise envelope / freshness / replay only. + */ + signature_check?: 'real' | 'structural'; + expected: { + result: 'verified' | 'rejected' | 'unverifiable'; + failing_step?: number; + emits_trust_violation?: boolean; + }; + }; + failure_classification?: unknown; +} + +interface Registry { + version: string; + principals: Array<{ + id: string; + credentials?: Array<{ id: string; revoked: boolean; public_key?: string; type?: string }>; + tombstoned_at?: string; + }>; +} + +type Outcome = + | { status: 'pass'; verification_mode?: 'cryptographic' | 'structural' } + | { status: 'fail'; reason: string; verification_mode?: 'cryptographic' | 'structural' } + | { status: 'skip'; reason: string }; + +// ─── vector loading ───────────────────────────────────────────────────── + +function* walkYaml(root: string): Iterable { + let entries: string[]; + try { + entries = readdirSync(root); + } catch { + return; + } + for (const entry of entries) { + const full = join(root, entry); + const st = statSync(full); + if (st.isDirectory()) { + yield* walkYaml(full); + } else if (entry.endsWith('.yaml') && !entry.startsWith('test-vector-format')) { + yield full; + } + } +} + +function loadVector(path: string): Vector | null { + const raw = readFileSync(path, 'utf8'); + const parsed = yamlLoad(raw) as Record | null | undefined; + if (!parsed || typeof parsed !== 'object') return null; + // We only consider top-level vectors (with `metadata`), not the format schema or examples-block files. + if (!parsed.metadata) return null; + return parsed as unknown as Vector; +} + +// ─── kind: verification ───────────────────────────────────────────────── + +function isDid(s: unknown): boolean { + return typeof s === 'string' && /^did:[a-z0-9]+:.+/.test(s); +} + +function isReverseDomain(s: string): boolean { + return /^[a-z0-9]+(\.[a-z0-9-]+)+$/.test(s); +} + +function parseTimeMs(s: unknown): number | null { + if (typeof s !== 'string') return null; + const t = Date.parse(s); + return Number.isFinite(t) ? t : null; +} + +interface VerificationResult { + result: 'verified' | 'rejected' | 'unverifiable'; + failing_step?: number; + /** Reason string surfaced to the runner output (when result != 'verified'). */ + reason?: string; + /** + * 'cryptographic' when the §17.7 step-3 signature check was performed with + * real crypto. 'structural' when the step-3 check was skipped (legacy + * structural vectors, voice-biometric — deferred to HMAN's #3 PR). + */ + verification_mode?: 'cryptographic' | 'structural'; +} + +function runVerification(v: Vector['verification']): VerificationResult { + if (!v) return { result: 'unverifiable', verification_mode: 'structural' }; + const proof = v.proof; + // Default signature_check is 'real' per the v2.0.2 hardening — vectors that + // want to keep the v2.0.1 envelope-only behaviour must opt into 'structural'. + const signatureCheck = v.signature_check ?? 'real'; + + // Step 1: type dispatch + envelope shape + const type = proof.type; + const v20FirstClass = ['fido2-assertion', 'voice-biometric']; + if (typeof type !== 'string') return { result: 'unverifiable', failing_step: 1, verification_mode: 'structural' }; + if (!v20FirstClass.includes(type) && !isReverseDomain(type)) return { result: 'unverifiable', failing_step: 1, verification_mode: 'structural' }; + for (const f of ['principal_id', 'credential_id', 'challenge_nonce', 'asserted_at', 'signature']) { + if (proof[f] === undefined || proof[f] === null || proof[f] === '') return { result: 'unverifiable', failing_step: 1, verification_mode: 'structural' }; + } + if (!isDid(proof.principal_id)) return { result: 'rejected', failing_step: 2, verification_mode: 'structural' }; + + // Step 2 + 3 (resolution half): principal + credential resolution + let resolvedPublicKey: string | undefined; + if (v.registry) { + const principal = v.registry.principals.find((p) => p.id === proof.principal_id); + if (!principal) return { result: 'rejected', failing_step: 2, verification_mode: 'structural' }; + if (principal.tombstoned_at) return { result: 'rejected', failing_step: 2, verification_mode: 'structural' }; + const cred = (principal.credentials ?? []).find((c) => c.id === proof.credential_id); + if (!cred) return { result: 'rejected', failing_step: 3, verification_mode: 'structural' }; + if (cred.revoked) return { result: 'rejected', failing_step: 3, reason: 'credential revoked (§17.8)', verification_mode: 'structural' }; + resolvedPublicKey = cred.public_key; + } + + // Step 4: freshness + const assertedMs = parseTimeMs(proof.asserted_at); + if (assertedMs === null) return { result: 'unverifiable', failing_step: 4, verification_mode: 'structural' }; + const verifierClockMs = v.verifier_clock ? (parseTimeMs(v.verifier_clock) ?? Date.now()) : Date.now(); + const SKEW_MS = 5 * 60 * 1000; + if (Math.abs(verifierClockMs - assertedMs) > SKEW_MS) return { result: 'rejected', failing_step: 4, verification_mode: 'structural' }; + + // Step 5: replay — nonce binding (§17.6 / §17.7 step 5) + const nonce = proof.challenge_nonce; + if (typeof nonce !== 'string' || nonce.length === 0) return { result: 'unverifiable', failing_step: 5, verification_mode: 'structural' }; + const issued = v.issued_nonces ?? []; + const verifierIdInProof = proof.verifier_id; + // v2.0.2 rule: nonce MUST satisfy ONE of: + // (a) be in the verifier's issued_nonces list, OR + // (b) carry verifier_signed_nonce: true (asserted — runtime crypto check is type-defined; we accept the assertion structurally), OR + // (c) carry a verifier_id field that EQUALS the receiving verifier's identity (`receiving_verifier_id` in the vector). + // If the vector sets `receiving_verifier_id`, the runner enforces equality; otherwise (legacy + // vectors), presence-only is accepted as it was in v2.0.1. + const verifierSignedNonce = proof.verifier_signed_nonce === true; + if (issued.includes(nonce)) { + // (a) satisfied — fine + } else if (verifierSignedNonce) { + // (b) asserted — fine for the structural runner + } else if (verifierIdInProof) { + if (v.receiving_verifier_id !== undefined && verifierIdInProof !== v.receiving_verifier_id) { + return { result: 'rejected', failing_step: 5, verification_mode: 'structural' }; + } + // verifier_id present and (no receiving_verifier_id to check against, OR equal): satisfied + } else { + return { result: 'rejected', failing_step: 5, verification_mode: 'structural' }; + } + + // Step 3 (cryptographic half): real signature verification for fido2-assertion. + // Voice-biometric and custom types defer to their own verifiers — HMAN's #3 PR + // for `voice-biometric`, implementation-defined for custom types — so the + // runner accepts them as structurally-verified here. + if (type === 'fido2-assertion' && resolvedPublicKey !== undefined) { + const fidoResult = verifyFido2Assertion({ + publicKey: resolvedPublicKey, + signature: String(proof.signature ?? ''), + challengeNonce: String(proof.challenge_nonce ?? ''), + assertedAt: String(proof.asserted_at ?? ''), + payloadHash: typeof proof.payload_hash === 'string' ? proof.payload_hash : undefined, + alg: typeof proof.alg === 'string' ? proof.alg : '', + authenticatorData: typeof proof.authenticator_data === 'string' ? proof.authenticator_data : undefined, + clientDataJSON: typeof proof.client_data_json === 'string' ? proof.client_data_json : undefined, + }); + + if (fidoResult.result === 'verified-cryptographic') { + return { result: 'verified', verification_mode: 'cryptographic' }; + } + if (fidoResult.result === 'rejected') { + return { result: 'rejected', failing_step: 3, reason: fidoResult.reason, verification_mode: 'cryptographic' }; + } + // unverifiable: surface depending on signature_check policy. + if (signatureCheck === 'real') { + return { result: 'unverifiable', failing_step: 3, reason: fidoResult.reason, verification_mode: 'cryptographic' }; + } + // structural: legacy v2.0.1 vector — accept the structural pass. + return { result: 'verified', verification_mode: 'structural' }; + } + + // Step 3 (cryptographic half): voice-biometric. The normative crypto lands + // via HMAN's #3 PR (§18.6); until then the runner has no voice verifier. + // FAIL CLOSED — a `signature_check: real` voice proof MUST NOT pass + // structurally just because we haven't built the verifier. That was the + // A1 "forged proof passes" footgun (closed for fido2 in v2.0.2; closed + // for voice here). The §17.6 alg whitelist is enforced FIRST because it + // does not need the crypto verifier — so alg-disallowed asserts real + // behaviour today. Contract + flip conditions: + // docs/v2-prep/v2.0.4-voice-biometric-lockdown.yaml. + if (type === 'voice-biometric') { + // v2.0 placeholder set; HMAN's #3 PR pins the normative whitelist. + const VOICE_ALG_WHITELIST = new Set(['resemblyzer-v1']); + const valg = typeof proof.alg === 'string' ? proof.alg : ''; + if (!VOICE_ALG_WHITELIST.has(valg)) { + return { + result: 'rejected', + failing_step: 3, + reason: `voice-biometric alg '${valg}' outside the §17.6 whitelist`, + verification_mode: 'cryptographic', + }; + } + if (signatureCheck === 'real') { + return { + result: 'unverifiable', + failing_step: 3, + reason: 'voice-biometric crypto verifier not implemented (HMAN #3, §18.6) — failing closed', + verification_mode: 'cryptographic', + }; + } + // Only explicitly-structural vectors reach here (none exist yet); the + // envelope / freshness / replay / verifier-binding checks above already + // ran. The signature itself is NOT asserted. + return { result: 'verified', verification_mode: 'structural' }; + } + + // No crypto check attempted (custom reverse-domain type, or no registry — + // a pure envelope test). Custom types defer to their own verifiers (§18.5); + // a no-registry vector is an envelope-shape test only. + return { result: 'verified', verification_mode: 'structural' }; +} + +function checkVerification(vec: Vector): Outcome { + if (!vec.verification) return { status: 'fail', reason: 'kind: verification but no `verification` block' }; + const got = runVerification(vec.verification); + const want = vec.verification.expected; + if (got.result !== want.result) { + const detail = got.reason ? ` [${got.reason}]` : ''; + return { status: 'fail', reason: `expected result=${want.result}, got ${got.result}${got.failing_step ? ` (failing_step=${got.failing_step})` : ''}${detail}`, verification_mode: got.verification_mode }; + } + if (want.failing_step !== undefined && got.failing_step !== want.failing_step) { + return { status: 'fail', reason: `expected failing_step=${want.failing_step}, got ${got.failing_step ?? ''}`, verification_mode: got.verification_mode }; + } + return { status: 'pass', verification_mode: got.verification_mode }; +} + +// ─── kind: http ───────────────────────────────────────────────────────── + +function pruneIgnored(obj: unknown, ignore: string[]): unknown { + if (!ignore || ignore.length === 0) return obj; + if (obj === null || typeof obj !== 'object' || Array.isArray(obj)) return obj; + const out: Record = { ...(obj as Record) }; + for (const k of ignore) delete out[k]; + return out; +} + +function subsetMatch(actual: unknown, expected: unknown): boolean { + if (expected === null || typeof expected !== 'object' || Array.isArray(expected)) { + return JSON.stringify(actual) === JSON.stringify(expected); + } + if (actual === null || typeof actual !== 'object' || Array.isArray(actual)) return false; + const a = actual as Record; + const e = expected as Record; + for (const k of Object.keys(e)) { + if (!subsetMatch(a[k], e[k])) return false; + } + return true; +} + +/** + * Resolve a dot-pathed accessor against an object body. Returns undefined if + * any segment is missing. e.g. resolvePath({a: {b: 1}}, "a.b") === 1. + */ +function resolveBodyPath(body: unknown, path: string): unknown { + if (!path) return body; + let cur: unknown = body; + for (const seg of path.split('.')) { + if (cur === null || typeof cur !== 'object') return undefined; + cur = (cur as Record)[seg]; + if (cur === undefined) return undefined; + } + return cur; +} + +/** + * Run a cross-call assertion against a step's response body. Returns null on + * pass, a human-readable failure reason on fail. + */ +function checkCrossCallAssertion(body: unknown, a: CrossCallAssertion): string | null { + const target = resolveBodyPath(body, a.body_path); + if (a.kind === 'negative_membership') { + if (!Array.isArray(target)) { + // Absence-of-collection is a structural pass for a negative assertion: + // if there's no `members` array at all, the principal trivially isn't in it. + return null; + } + for (const entry of target) { + if (entry && typeof entry === 'object' && (entry as Record).principalId === a.principal_id) { + return `negative_membership violated: ${a.principal_id} present in ${a.body_path}`; + } + } + return null; + } + if (a.kind === 'negative_obligation') { + if (!Array.isArray(target)) return null; + const m = a.match ?? {}; + for (const entry of target) { + if (!entry || typeof entry !== 'object') continue; + const e = entry as Record; + let allMatch = true; + for (const k of Object.keys(m)) { + if (JSON.stringify(e[k]) !== JSON.stringify(m[k])) { allMatch = false; break; } + } + if (allMatch) { + return `negative_obligation violated: entry matching ${JSON.stringify(m)} present in ${a.body_path}`; + } + } + return null; + } + return `unknown cross_call_assertion kind: ${(a as { kind: string }).kind}`; +} + +/** + * Run a single HTTP request + assertions block. Used both directly (kind: + * http) and per-step (kind: session). Returns { outcome, body? } so callers + * can do cross-call assertions against the parsed body if they want. + */ +async function runHttpStep( + req: HttpRequest, + expected: HttpExpectedResponse, + serverUrl: string, +): Promise<{ outcome: Outcome; body?: unknown }> { + const url = new URL(req.path, serverUrl).toString(); + let res: Response; + try { + res = await fetch(url, { + method: req.method, + headers: { 'Content-Type': 'application/json', ...(req.headers ?? {}) }, + body: req.body !== undefined ? JSON.stringify(req.body) : undefined, + signal: AbortSignal.timeout(15_000), + }); + } catch (err) { + return { outcome: { status: 'fail', reason: `request failed: ${(err as Error).message}` } }; + } + + if (res.status !== expected.status) { + return { outcome: { status: 'fail', reason: `expected status ${expected.status}, got ${res.status}` } }; + } + + // Always read the body so callers can do cross-call assertions even when + // body_match is omitted (the negative-membership / negative-obligation tests + // assert on shape without enumerating positive matches). + const text = await res.text(); + let actual: unknown = null; + try { actual = text ? JSON.parse(text) : null; } catch { actual = text; } + + const bodyMatch = expected.body_match; + if (bodyMatch) { + const ignore = expected.body_ignore_fields ?? []; + const actualPruned = pruneIgnored(actual, ignore); + if (bodyMatch.mode === 'exact') { + if (JSON.stringify(actualPruned) !== JSON.stringify(bodyMatch.value)) { + return { outcome: { status: 'fail', reason: `body mismatch (exact): got ${JSON.stringify(actualPruned).slice(0, 200)}` }, body: actual }; + } + } else if (bodyMatch.mode === 'subset') { + if (!subsetMatch(actualPruned, bodyMatch.value)) { + return { outcome: { status: 'fail', reason: `body mismatch (subset): expected ${JSON.stringify(bodyMatch.value)} ⊆ ${JSON.stringify(actualPruned).slice(0, 200)}` }, body: actual }; + } + } else if (bodyMatch.mode === 'schema') { + // Schema-mode validation is left for a follow-up (would need ajv). + return { outcome: { status: 'skip', reason: 'body_match mode: schema not implemented yet in the runner skeleton' }, body: actual }; + } + } + return { outcome: { status: 'pass' }, body: actual }; +} + +async function checkHttp(vec: Vector, serverUrl: string | null): Promise { + if (!serverUrl) return { status: 'skip', reason: 'no --server provided; HTTP vectors need a server target' }; + if (!vec.request || !vec.expected_response) return { status: 'fail', reason: 'kind: http but missing `request` / `expected_response`' }; + + const { outcome } = await runHttpStep(vec.request, vec.expected_response, serverUrl); + // expected_events checking needs a running event-log subscription; deferred. + return outcome; +} + +// ─── kind: session ────────────────────────────────────────────────────── +// +// A session vector is a sequence of HTTP calls representing a compound +// scenario whose assertions cross call boundaries (e.g. "after onboard +// fails, /_status MUST show non-membership"). The runner walks `steps[]` in +// order; each step is an HTTP request + expected response + optional +// `cross_call_assertions` evaluated against the parsed response body. +// +// A vector PASSes iff every step passes (status, body_match, AND every +// cross_call_assertion). A vector SKIPs as a unit if no --server is +// provided. A step FAILing aborts the run for that vector with the +// failing step's reason — we don't continue after a failure because the +// later steps' preconditions may not hold. + +async function checkSession(vec: Vector, serverUrl: string | null): Promise { + if (!serverUrl) return { status: 'skip', reason: 'no --server provided; session vectors need a server target' }; + if (!vec.steps || vec.steps.length === 0) return { status: 'fail', reason: 'kind: session but missing or empty `steps`' }; + + for (const step of vec.steps) { + if (!step.request || !step.expected_response) { + return { status: 'fail', reason: `step ${step.id}: missing request / expected_response` }; + } + const { outcome, body } = await runHttpStep(step.request, step.expected_response, serverUrl); + if (outcome.status === 'fail') { + return { status: 'fail', reason: `step ${step.id}: ${outcome.reason}` }; + } + if (outcome.status === 'skip') { + // A skipped step (e.g. body_match: schema) skips the whole vector; + // partial-pass would be misleading. + return { status: 'skip', reason: `step ${step.id}: ${outcome.reason}` }; + } + // Cross-call assertions, evaluated against the parsed body. + for (const a of step.cross_call_assertions ?? []) { + const violation = checkCrossCallAssertion(body, a); + if (violation !== null) { + return { status: 'fail', reason: `step ${step.id}: ${violation}` }; + } + } + } + // expected_events across steps is deferred (needs event-log subscription). + return { status: 'pass' }; +} + +// ─── CLI ──────────────────────────────────────────────────────────────── + +function parseArgs(argv: string[]): { vectors: string; server: string | null; filter: string | null; json: boolean } { + const out = { vectors: '', server: null as string | null, filter: null as string | null, json: false }; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === '--vectors') out.vectors = argv[++i] ?? ''; + else if (a === '--server') out.server = argv[++i] ?? null; + else if (a === '--filter') out.filter = argv[++i] ?? null; + else if (a === '--json') out.json = true; + else if (a === '--help' || a === '-h') { + console.log('Usage: pact-conformance run [--vectors ] [--server ] [--filter ] [--json]'); + console.log(' --vectors directory to recursively scan for vector YAML files (default: spec/v2.0/conformance)'); + console.log(' --server PACT server base URL for kind:http vectors (skipped if absent)'); + console.log(' --filter only run vectors whose id contains '); + console.log(' --json output a JSON report'); + process.exit(0); + } + } + return out; +} + +async function main(): Promise { + const sub = process.argv[2]; + if (sub !== 'run') { + console.error('Usage: pact-conformance run [--vectors ] [--server ] [--filter ] [--json]'); + process.exit(2); + } + const args = parseArgs(process.argv.slice(3)); + const root = resolvePath(args.vectors || '.'); + + const vectors: { path: string; vec: Vector }[] = []; + for (const path of walkYaml(root)) { + const vec = loadVector(path); + if (!vec) continue; + if (args.filter && !vec.metadata.id.includes(args.filter)) continue; + vectors.push({ path, vec }); + } + + // Best-effort state reset before server-bound vectors run. Several + // session vectors are stateful (an onboard adds a member, a vote + // discharges an obligation); re-running the suite against a long-lived + // server would otherwise see stale state. The reference server exposes + // `POST /__reset` to re-seed deterministic fixtures. A third-party PACT + // server that does not implement `/__reset` simply returns non-2xx and + // the runner continues — this is a convenience for deterministic re-runs, + // not a conformance requirement on the server under test. + if (args.server && vectors.some((v) => { + const k = v.vec.kind ?? (v.vec.verification ? 'verification' : (v.vec.steps ? 'session' : 'http')); + return k === 'http' || k === 'session'; + })) { + try { + const resetUrl = new URL('/__reset', args.server).toString(); + const r = await fetch(resetUrl, { method: 'POST', signal: AbortSignal.timeout(5_000) }); + if (!r.ok) { + console.error(`NOTE: ${resetUrl} returned ${r.status}; server state not reset (re-runs may be non-deterministic if the server is stateful).`); + } + } catch { + console.error('NOTE: server /__reset unreachable; server state not reset (re-runs may be non-deterministic if the server is stateful).'); + } + } + + const results: { path: string; id: string; kind: string; outcome: Outcome }[] = []; + for (const { path, vec } of vectors) { + const kind = vec.kind ?? (vec.verification ? 'verification' : (vec.steps ? 'session' : 'http')); + let outcome: Outcome; + if (kind === 'verification') { + outcome = checkVerification(vec); + } else if (kind === 'http') { + outcome = await checkHttp(vec, args.server); + } else if (kind === 'session') { + outcome = await checkSession(vec, args.server); + } else { + outcome = { status: 'fail', reason: `unknown kind: ${String(kind)}` }; + } + results.push({ path: relative(process.cwd(), path), id: vec.metadata.id, kind, outcome }); + } + + const counts = { pass: 0, fail: 0, skip: 0 }; + for (const r of results) counts[r.outcome.status]++; + + // Diagnostics for misleading-passes (cold-eye-audit hard-issue #1 + concern #10). + // 1. kind:verification PASS reports omit cryptographic signature verification (§17.7 step 3). + // The runner is structural; rename "verified" to "verified-structural" in the per-vector tag + // so a passing vector is not mistaken for a real-crypto check. + // 2. If every kind:http vector in the suite was skipped (typically because no --server was + // passed), HTTP-runner regressions can hide. Print a stderr warning even on a green run. + // Both kind:http and kind:session need --server to execute; group them + // under "server-bound" coverage for the warning below. + const httpVectorCount = results.filter((r) => r.kind === 'http' || r.kind === 'session').length; + const httpExecutedCount = results.filter((r) => (r.kind === 'http' || r.kind === 'session') && r.outcome.status !== 'skip').length; + + // Per-result JSON shape: surface verification_mode at the top level so JSON + // consumers (CI gates, badge generators) can branch on cryptographic vs + // structural without unpacking the discriminated `outcome` union. The same + // field also lives inside `outcome` for vector-level introspection. + const jsonResults = results.map((r) => { + const mode = (r.outcome.status === 'pass' || r.outcome.status === 'fail') ? r.outcome.verification_mode : undefined; + return { + path: r.path, + id: r.id, + kind: r.kind, + outcome: r.outcome, + ...(mode !== undefined ? { verification_mode: mode } : {}), + }; + }); + + // Whether any verification PASS in this run was real crypto vs structural-only. + const sawCryptographic = results.some((r) => r.kind === 'verification' && r.outcome.status === 'pass' && r.outcome.verification_mode === 'cryptographic'); + const sawStructural = results.some((r) => r.kind === 'verification' && r.outcome.status === 'pass' && r.outcome.verification_mode === 'structural'); + + if (args.json) { + console.log(JSON.stringify({ + counts, + results: jsonResults, + runner_disclaimer: 'kind:verification PASS results carry verification_mode: "cryptographic" (real WebAuthn signature verified for fido2-assertion) or "structural" (envelope / freshness / replay only — legacy v2.0.1 vectors and non-fido2 types). Structural-only PASS does NOT prove the signature is cryptographically valid.', + http_coverage: { total: httpVectorCount, executed: httpExecutedCount }, + }, null, 2)); + } else { + for (const r of results) { + let tag: string; + if (r.outcome.status === 'pass') { + if (r.kind === 'verification') { + tag = r.outcome.verification_mode === 'cryptographic' ? '✓ verified-cryptographic' : '✓ verified-structural'; + } else { + tag = '✓'; + } + } else if (r.outcome.status === 'fail') { + tag = '✗'; + } else { + tag = '·'; + } + const detail = r.outcome.status === 'pass' ? '' : ` — ${r.outcome.reason}`; + console.log(`${tag} [${r.kind}] ${r.id}${detail}`); + } + console.log(`\n${counts.pass} pass · ${counts.fail} fail · ${counts.skip} skip`); + if (sawStructural) { + console.log(`\nNOTE: kind:verification "✓ verified-structural" PASSes omit cryptographic signature verification (§17.7 step 3). They prove the envelope, principal resolution, freshness and replay-binding rules — not that the signature is cryptographically valid. Vectors that opt into real crypto must declare \`verification.signature_check: real\` and supply a real signature (see verify-fido2-real-signature.yaml).`); + } + if (sawCryptographic) { + console.log(`\nNOTE: kind:verification "✓ verified-cryptographic" PASSes additionally verified the FIDO2 / WebAuthn signature against the enrolled public key (§17.7 step 3, §18.2).`); + } + if (httpVectorCount > 0 && httpExecutedCount === 0) { + console.error(`\nWARNING: ${httpVectorCount} server-bound vector(s) (kind:http + kind:session) skipped (no --server). HTTP-runner regressions cannot be detected without a server target. Pass --server for full coverage.`); + } + } + + process.exit(counts.fail === 0 ? 0 : 1); +} + +main().catch((err) => { + console.error('Conformance runner crashed:', err); + process.exit(2); +}); diff --git a/spec/v2.3/conformance/runner/src/webauthn.ts b/spec/v2.3/conformance/runner/src/webauthn.ts new file mode 100644 index 0000000..c5e590f --- /dev/null +++ b/spec/v2.3/conformance/runner/src/webauthn.ts @@ -0,0 +1,209 @@ +/** + * Real WebAuthn / FIDO2 assertion verifier for the PACT v2.0 conformance runner. + * + * Closes the "A1: forged-signature pass" attack in v2.0.1 by performing actual + * cryptographic signature verification for `type: fido2-assertion` proofs. + * + * Two verification paths: + * 1. Full WebAuthn — when the proof carries `authenticatorData` + `clientDataJSON` + * + `signature`, delegates to `@simplewebauthn/server`'s + * `verifyAuthenticationResponse`. + * 2. Generic deterministic-payload — when only `publicKey` + `signature` + a + * message to sign (`challenge_nonce + asserted_at` and optionally a payload + * hash) are present, uses Node's built-in `crypto.verify`. Supports an + * explicit whitelist of v2.0 algs (ES256 / ES384 / Ed25519). + * + * The `unverifiable` outcome is reserved for cases where the inputs are + * structurally insufficient for a real crypto check (e.g. placeholder signature + * strings like `` in the original v2.0 test vectors). The + * caller decides whether to treat `unverifiable` as a structural pass (legacy + * vectors marked `signature_check: structural`) or as a hard non-verify (vectors + * marked `signature_check: real`). + */ + +import { createPublicKey, verify as cryptoVerify, type KeyObject } from 'node:crypto'; + +export interface Fido2VerifyInput { + /** The credential's enrolled public key. Base64url-encoded SPKI (default) or PEM. */ + publicKey: string; + /** proof.signature (base64url). Raw ECDSA-DER or Ed25519 raw signature. */ + signature: string; + /** proof.challenge_nonce. */ + challengeNonce: string; + /** proof.asserted_at (ISO 8601 string). */ + assertedAt: string; + /** Optional: hash of the message payload the signature additionally commits to. */ + payloadHash?: string; + /** proof.alg — e.g. "webauthn-es256", "webauthn-es384", "webauthn-eddsa". */ + alg: string; + /** Optional WebAuthn-specific authenticatorData (base64url). */ + authenticatorData?: string; + /** Optional WebAuthn clientDataJSON (base64url). */ + clientDataJSON?: string; +} + +export type Fido2VerifyResult = + | { result: 'verified-cryptographic' } + | { result: 'rejected'; reason: string } + | { result: 'unverifiable'; reason: string }; + +/** Algorithms the v2.0 fido2-assertion verifier accepts. */ +const ALG_WHITELIST = new Set([ + 'webauthn-es256', // ECDSA P-256 + SHA-256 + 'webauthn-es384', // ECDSA P-384 + SHA-384 + 'webauthn-eddsa', // Ed25519 +]); + +/** + * Heuristic: detect placeholder signature strings used by the legacy structural + * test vectors so the runner can downgrade them to `unverifiable` instead of + * crashing on a base64url decode. + * + * A signature is "placeholder-shaped" if it is empty, starts with '<' (the + * `` shape), or doesn't base64url-decode to a plausible + * length (>= 32 bytes). + */ +function isPlaceholderSignature(sig: string): boolean { + if (!sig || sig.length === 0) return true; + if (sig.startsWith('<')) return true; + // Heuristic: real signatures are at least 32 bytes after decode. ECDSA-DER + // signatures are 70–72 bytes; Ed25519 raw signatures are 64 bytes. + try { + const buf = Buffer.from(sig, 'base64url'); + if (buf.length < 32) return true; + // If the round-trip doesn't preserve length-ish, treat as placeholder. + // (base64url has a 4:3 ratio so input chars >= ceil(buf.length * 4 / 3).) + return false; + } catch { + return true; + } +} + +/** + * Decode a base64url-encoded public key into a Node KeyObject. Supports: + * - SPKI DER (the form Node's `crypto.generateKeyPair` emits with + * `format: 'der', type: 'spki'`) — primary supported form. + * - PEM-encoded SPKI / PKCS#1 (detected by leading `-----BEGIN`). + * + * Returns null if the key can't be parsed. + */ +function parsePublicKey(publicKey: string): KeyObject | null { + if (publicKey.startsWith('-----BEGIN')) { + try { + return createPublicKey({ key: publicKey, format: 'pem' }); + } catch { + return null; + } + } + // Default: base64url-encoded SPKI DER. + try { + const der = Buffer.from(publicKey, 'base64url'); + return createPublicKey({ key: der, format: 'der', type: 'spki' }); + } catch { + // Fall back to base64 (non-url) — some implementations use raw base64. + try { + const der = Buffer.from(publicKey, 'base64'); + return createPublicKey({ key: der, format: 'der', type: 'spki' }); + } catch { + return null; + } + } +} + +/** + * Map a v2.0 PACT alg identifier to the Node `crypto.verify` digest argument. + * Ed25519 uses `null` (no separate digest). + */ +function digestForAlg(alg: string): string | null | undefined { + switch (alg) { + case 'webauthn-es256': + return 'sha256'; + case 'webauthn-es384': + return 'sha384'; + case 'webauthn-eddsa': + return null; // Ed25519 — no digest + default: + return undefined; + } +} + +/** + * The deterministic message the generic-fallback path signs over. + * + * Composition: `challenge_nonce || asserted_at || payload_hash?` as a UTF-8 + * concatenation. The optional `payload_hash` is appended (as a literal string) + * when the proof commits to a separate message payload. + * + * Note: this is the fallback shape for test-vector signing when full WebAuthn + * buffers (authenticatorData + clientDataJSON) are not present. Real WebAuthn + * assertions sign over `authenticatorData || SHA-256(clientDataJSON)`, which is + * handled by the @simplewebauthn/server path. + */ +function buildSignedMessage(challengeNonce: string, assertedAt: string, payloadHash?: string): Buffer { + const parts = [challengeNonce, assertedAt]; + if (payloadHash !== undefined) parts.push(payloadHash); + return Buffer.from(parts.join(''), 'utf8'); +} + +export function verifyFido2Assertion(input: Fido2VerifyInput): Fido2VerifyResult { + // 1. Whitelist the algorithm. + if (!ALG_WHITELIST.has(input.alg)) { + return { result: 'rejected', reason: `alg ${JSON.stringify(input.alg)} not in v2.0 whitelist (${[...ALG_WHITELIST].join(', ')})` }; + } + + // 2. Placeholder-detection: legacy structural vectors carry strings like + // ``. Surface those as `unverifiable` so the + // caller can decide (signature_check: real → fail; structural → pass). + if (isPlaceholderSignature(input.signature)) { + return { result: 'unverifiable', reason: 'signature field is a placeholder; cannot perform cryptographic check' }; + } + + // 3. Parse the enrolled public key. + const publicKey = parsePublicKey(input.publicKey); + if (!publicKey) { + // If the publicKey is also placeholder-shaped, this is a structural vector + // — return unverifiable rather than rejected, so the caller can apply the + // structural-vs-real policy. + if (input.publicKey.startsWith('<') || input.publicKey.length < 16) { + return { result: 'unverifiable', reason: 'public_key is a placeholder; cannot perform cryptographic check' }; + } + return { result: 'rejected', reason: 'public_key could not be parsed (expected base64url SPKI DER or PEM)' }; + } + + // 4. Full-WebAuthn path: if authenticatorData + clientDataJSON are present, + // delegate to @simplewebauthn/server. (Deferred to v2.0.3+ — keeping the + // branch in place so future PRs can extend without an API change.) + if (input.authenticatorData && input.clientDataJSON) { + return { + result: 'unverifiable', + reason: 'full WebAuthn buffer verification (authenticatorData + clientDataJSON) not wired into the runner yet; deferred to v2.0.3', + }; + } + + // 5. Generic fallback: verify a Node crypto.verify call over + // challenge_nonce || asserted_at (|| payload_hash). + const digest = digestForAlg(input.alg); + if (digest === undefined) { + return { result: 'rejected', reason: `alg ${JSON.stringify(input.alg)} mapping missing` }; + } + + const message = buildSignedMessage(input.challengeNonce, input.assertedAt, input.payloadHash); + let sigBuf: Buffer; + try { + sigBuf = Buffer.from(input.signature, 'base64url'); + } catch { + return { result: 'rejected', reason: 'signature could not be base64url-decoded' }; + } + + let ok = false; + try { + ok = cryptoVerify(digest, message, publicKey, sigBuf); + } catch (err) { + return { result: 'rejected', reason: `crypto.verify threw: ${(err as Error).message}` }; + } + + if (!ok) { + return { result: 'rejected', reason: 'signature did not verify against enrolled public key' }; + } + return { result: 'verified-cryptographic' }; +} diff --git a/spec/v2.3/conformance/runner/tsconfig.json b/spec/v2.3/conformance/runner/tsconfig.json new file mode 100644 index 0000000..92028ae --- /dev/null +++ b/spec/v2.3/conformance/runner/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "Bundler", + "lib": ["ES2022"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "declaration": false, + "sourceMap": false + }, + "include": ["src/**/*.ts"] +} diff --git a/spec/v2.3/conformance/test-vector-format.yaml b/spec/v2.3/conformance/test-vector-format.yaml new file mode 100644 index 0000000..2cd2565 --- /dev/null +++ b/spec/v2.3/conformance/test-vector-format.yaml @@ -0,0 +1,150 @@ +# Test vector format for PACT v2.0 conformance suite. +# Each test vector is a single YAML file describing one black-box test. +# This is the schema; concrete test vectors conform to it. +# +# There are two vector kinds, distinguished by `kind`: +# - kind: http (default) — an HTTP request/response + expected event sequence. +# Has `request` / `expected_response` / `expected_events` / `postconditions`. +# - kind: verification — exercise the §17.7 authorization-proof verification flow +# (client-side logic, no HTTP). Has `verification` instead. + +test_vector_schema: + version: "1" + + kind: enum # http (default) | verification + + # `request` + `expected_response` are required for kind: http. + # `verification` is required for kind: verification. + required: + - metadata + + metadata: + id: string # unique within suite, e.g. "core/join/basic" + description: string # human-readable summary + spec_section: string # e.g. "§4.1, §7.1" + conformance_level: enum # core | extended | authorization-required + track: string # e.g. "T1", "T3" + + preconditions: # optional; state assumed before the test runs + server_state: + resource_id: string? # if test runs against an existing resource + registered_agents: array? # list of agents already joined + events_since: int? # event sequence number baseline + request_context: + auth: enum # apiKey | bearer | service-account | none + principal_id: string? # for authorization_proof scenarios + + request: + method: enum # GET | POST | PUT | DELETE + path: string # endpoint path (e.g. "/api/pact/{docId}/join") + headers: object? + body: object? # JSON request body + + expected_response: + status: int # HTTP status code + headers: object? # required headers (subset match) + body_match: + mode: enum # exact | subset | schema + value: object # expected body, subset, or JSON Schema + body_ignore_fields: array? # non-deterministic fields to skip (e.g. timestamps, UUIDs) + + expected_events: # optional; some tests are pure HTTP + ordered: bool # MUST events be in this order, or any order? + sequence: + - event_type: string # e.g. "pact.agent.joined" + match: + mode: enum # exact | subset + value: object + body_ignore_fields: array? + + postconditions: # optional; state expected after the test + server_state: + events_added: int? + resource_modified: bool? + + # ─── kind: verification — exercises §17.7 ─────────────────────────────── + verification: # present iff kind == verification + proof: object # the authorization_proof being verified (§17.6 envelope) + registry: object? # principal-registry.json content the verifier resolves against (or omit if using DID resolution stubs) + did_documents: object? # map of DID -> DID document, for did:web/did:key resolution stubs + verifier_clock: string? # ISO 8601 — the verifier's "now"; for §17.7 step 4 (freshness / clock skew) + issued_nonces: array? # nonces the verifier has issued and not yet retired; for §17.7 step 5 (replay) + operation_requires_uv: bool? # for fido2-assertion: does the operation require the User Verification flag? + signature_check: enum? # real (default) | structural. `real` runs §17.7 step 3 + # cryptographic signature verification end-to-end — the proof MUST carry a real + # signature and the registry MUST carry the matching enrolled public key (e.g. + # base64url-encoded SPKI DER); `unverifiable` from the crypto verifier is treated + # as a non-pass. `structural` skips the cryptographic check (or accepts placeholder + # signature strings), exercising envelope / freshness / replay only — used by the + # v2.0.1 legacy vectors that pre-date the real WebAuthn verifier. + expected: + result: enum # verified | rejected | unverifiable + failing_step: int? # 1..5 from §17.7 — set when result != verified. (Step 6 is "Result" in the spec — that's the outcome, not a failure mode, so it never appears here.) + emits_trust_violation: bool? # whether a pact.trust.violation { kind: authorization_failed } SHOULD be emitted + + failure_classification: # how to report a failure + severity: enum # blocker | warning + common_causes: # human-readable diagnostic hints + - string + +# ─── Example test vector (informative) ─────────────────────────────────── + +example_basic_join: + metadata: + id: core/join/basic + description: An agent joins a document with a valid invite token + spec_section: "§4.1, §7.1" + conformance_level: core + track: T0 (carried from v1.1) + + preconditions: + server_state: + resource_id: doc_abc123 + registered_agents: [] + request_context: + auth: none # join-token flow is anonymous + + request: + method: POST + path: /api/pact/doc_abc123/join-token + headers: + Content-Type: application/json + body: + agentName: test-bot + token: invite_xyz + + expected_response: + status: 200 + body_match: + mode: subset + value: + agentName: test-bot + contextMode: scoped + body_ignore_fields: + - registrationId + - apiKey + + expected_events: + ordered: true + sequence: + - event_type: pact.agent.joined + match: + mode: subset + value: + actorDisplay: test-bot + actorKind: AiAgent + body_ignore_fields: + - id + - epochMs + - sequenceNumber + + postconditions: + server_state: + events_added: 1 + + failure_classification: + severity: blocker + common_causes: + - Implementation does not support invite-token flow + - Event not emitted on join + - actorKind defaults to wrong value diff --git a/spec/v2.3/resource-types.yaml b/spec/v2.3/resource-types.yaml new file mode 100644 index 0000000..a6b3457 --- /dev/null +++ b/spec/v2.3/resource-types.yaml @@ -0,0 +1,73 @@ +# PACT resource-type registry (v2.0) +# +# Machine-readable index of well-known resource types, referenced from +# PACT_SPECIFICATION.md §14.3. Each entry conforms to the table in §14.3: +# type / field_schema / proposal_payload / apply_semantics / terminal_states +# / content_format / maintainer / status. +# +# To register a custom resource type: open a PR against this file. Custom +# types SHOULD use reverse-domain notation (com.example.case-file). An +# implementation that supports a type MUST declare it in its +# /.well-known/pact.json profile (§15). + +version: "1" +spec_version: "v2.0" +last_updated: "2026-05-13" + +types: + + # ─── Built-in (defined in §14.2) ──────────────────────────────────────── + + - type: document + status: built-in + field_schema: "sec:{slug} — heading-derived section path; child sections use sec:{slug}/{child-slug}" + proposal_payload: "{ sectionId, newContent, summary, reasoning? }" + apply_semantics: Text replacement within the addressed Markdown section. + terminal_states: [Merged] + content_format: "text/markdown (also html, docx, pdf via section projection — §8)" + maintainer: TailorAU + notes: The default resource type. Proposals without an explicit `type` field are treated as document proposals. + + - type: transaction + status: built-in + field_schema: "txn:{field} — structured transaction fields (txn:amount, txn:recipient, txn:method, …)" + proposal_payload: "{ amount, recipient, method, reference }" + apply_semantics: Payment settled. + terminal_states: [Settled] + content_format: application/json + maintainer: TailorAU + notes: Reference impl (planned) — Baink. + + - type: fact + status: built-in + field_schema: "claim:{id} — knowledge-claim identifier" + proposal_payload: "{ claim, evidence, tier, sources }" + apply_semantics: Fact verified into the knowledge graph. + terminal_states: [Verified] + content_format: application/json + maintainer: TailorAU + notes: Reference impl — Source. + + - type: record + status: built-in + field_schema: "rec:{field} — structured record fields (e.g. clinical record fields)" + proposal_payload: "{ field, value, justification }" + apply_semantics: Record finalised. + terminal_states: [Finalized] + content_format: application/json + maintainer: TailorAU + + # ─── Registered custom types ──────────────────────────────────────────── + + # (none yet — first community PRs land here) + +registration_process: + - Open a PR against this file with the entry filled out. + - The PR's description SHOULD link to a reference implementation (running or in progress) that uses the new type. + - Maintainer review: confirms uniqueness of `type`, that the entry has the required fields, that apply_semantics is unambiguous, and that the type is genuinely new (not duplicating a built-in). + - On merge the type is `registered`. Implementations that support it advertise it in their /.well-known/pact.json profile. + - Lifecycle: a `registered` type can be moved to `deprecated` by the original maintainer or by maintainer review (e.g. superseded by a better type); deprecated types remain in the registry for citation stability. + +custom_type_naming: + rule: "Reverse-domain notation: e.g. com.example.case-file, au.gov.mygovid-document. Bare identifiers are reserved for built-ins." + case: "lower-kebab segments separated by dots." diff --git a/spec/v2.3/schemas/agent-identity.json b/spec/v2.3/schemas/agent-identity.json new file mode 100644 index 0000000..ba141c4 --- /dev/null +++ b/spec/v2.3/schemas/agent-identity.json @@ -0,0 +1,98 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://pact-spec.dev/schemas/v2.0/agent-identity.json", + "title": "PACT Agent Identity — transfer & recovery attestations", + "description": "Structures for the Agent Identity Lifecycle (PACT Specification §23): the agentId form, the agent↔operator binding, a cooperative transfer attestation (§23.3), a recovery-quorum enrollment, and a non-cooperative recovery attestation (§23.4). Transfer/recovery attestations are themselves authorization_proof-bearing (§17.6) — each signature MUST verify as a valid HumanPrincipal proof.", + "$defs": { + "agentId": { + "type": "string", + "description": "A server-side agent principal that persists across sessions/machines. Portable in form (resolution across servers is v2.1 federation work). Either a urn:pact:agent: URN or a DID.", + "anyOf": [ + { "pattern": "^urn:pact:agent:[A-Za-z0-9._~-]+$" }, + { "pattern": "^did:[a-z0-9]+:.+" } + ] + }, + "principalRef": { + "type": "string", + "description": "A HumanPrincipal DID (§17.4).", + "pattern": "^did:[a-z0-9]+:.+" + }, + "transferAttestation": { + "type": "object", + "description": "Cooperative operator transfer (§23.3). Carried with two authorization_proof signatures: the outgoing operator's, then the incoming operator's countersignature. A transfer with only one valid signature MUST be rejected.", + "required": ["agentId", "from", "to", "effective_at"], + "properties": { + "agentId": { "$ref": "#/$defs/agentId" }, + "from": { "$ref": "#/$defs/principalRef", "description": "Outgoing operator-of-record." }, + "to": { "$ref": "#/$defs/principalRef", "description": "Incoming operator-of-record." }, + "effective_at": { "type": "string", "format": "date-time" }, + "reason": { "type": "string", "maxLength": 2000 } + }, + "additionalProperties": false + }, + "recoveryQuorum": { + "type": "object", + "description": "A recovery quorum enrolled against an agentId (§23.4 M-of-N recovery). M signatures from the N members are required to complete a recovery.", + "required": ["agentId", "members", "threshold"], + "properties": { + "agentId": { "$ref": "#/$defs/agentId" }, + "members": { + "type": "array", + "description": "The N quorum members (HumanPrincipal DIDs).", + "minItems": 1, + "items": { "$ref": "#/$defs/principalRef" } + }, + "threshold": { + "type": "integer", + "minimum": 1, + "description": "M — number of member signatures required. Implementation-defined; the spec RECOMMENDS M >= 2. MUST be <= members length." + } + }, + "additionalProperties": false + }, + "recoveryAttestation": { + "type": "object", + "description": "Non-cooperative recovery via an M-of-N quorum (§23.4). Carried with >= threshold authorization_proof signatures, each from a distinct quorum member. Takes effect only after the time-locked dispute window (default 72h).", + "required": ["agentId", "to", "effective_at"], + "properties": { + "agentId": { "$ref": "#/$defs/agentId" }, + "to": { "$ref": "#/$defs/principalRef", "description": "Incoming operator-of-record." }, + "effective_at": { "type": "string", "format": "date-time", "description": "When the recovery takes effect (after the dispute window)." }, + "reason": { "type": "string", "maxLength": 2000 } + }, + "additionalProperties": false + }, + "abandonment": { + "type": "object", + "description": "Abandoned-agent reset (§23.4) — used where no recovery quorum was enrolled. Mints a NEW agentId for a successor; the old agentId is frozen but its history is preserved and citable.", + "required": ["old_agentId", "successor_agentId", "effective_at", "reason"], + "properties": { + "old_agentId": { "$ref": "#/$defs/agentId" }, + "successor_agentId": { "$ref": "#/$defs/agentId" }, + "effective_at": { "type": "string", "format": "date-time" }, + "reason": { "type": "string", "maxLength": 2000, "description": "Why the old operator is unreachable / off-boarded / etc." } + }, + "additionalProperties": false + }, + "recoveryDispute": { + "type": "object", + "description": "Dispute lodged against an in-flight `pact.agent.recovery-initiated` (§23.5b, v2.0.2+). A valid dispute event suspends the recovery pending human resolution. The disputing principal MUST be either the current operator-of-record OR a quorum member who did NOT co-sign the recovery; their `authorization_proof` is carried on the message that emits this event.", + "required": ["agentId", "recovery_initiated_event_id", "disputed_at", "reason"], + "properties": { + "agentId": { "$ref": "#/$defs/agentId" }, + "recovery_initiated_event_id": { "type": "string", "format": "uuid", "description": "The `pact.agent.recovery-initiated` event being disputed." }, + "disputed_at": { "type": "string", "format": "date-time", "description": "MUST fall within the dispute window of the original recovery-initiated event." }, + "reason": { "type": "string", "maxLength": 2000 }, + "disputing_principal_role": { "type": "string", "enum": ["operator-of-record", "non-cosigning-quorum-member", "administrative-oversight"], "description": "How the disputing principal is authorized to dispute this recovery." } + }, + "additionalProperties": false + } + }, + "oneOf": [ + { "$ref": "#/$defs/transferAttestation" }, + { "$ref": "#/$defs/recoveryQuorum" }, + { "$ref": "#/$defs/recoveryAttestation" }, + { "$ref": "#/$defs/abandonment" }, + { "$ref": "#/$defs/recoveryDispute" } + ] +} diff --git a/spec/v2.3/schemas/ask-human-request.json b/spec/v2.3/schemas/ask-human-request.json new file mode 100644 index 0000000..7d1ea67 --- /dev/null +++ b/spec/v2.3/schemas/ask-human-request.json @@ -0,0 +1,32 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://pact-spec.dev/schemas/v2.0/ask-human-request.json", + "title": "PACT Ask Human Request", + "description": "Request body for POST /api/pact/{documentId}/ask-human", + "type": "object", + "required": ["question"], + "properties": { + "question": { + "type": "string", + "minLength": 1, + "description": "The question to escalate to a human reviewer." + }, + "sectionId": { + "type": "string", + "maxLength": 256, + "description": "Optional section the question relates to." + }, + "context": { + "type": "string", + "description": "Optional additional context to help the human answer." + }, + "timeoutSeconds": { + "type": "integer", + "minimum": 10, + "maximum": 3600, + "default": 60, + "description": "How long to wait for a human response before timing out." + } + }, + "additionalProperties": false +} diff --git a/spec/v2.3/schemas/authorization-proof.json b/spec/v2.3/schemas/authorization-proof.json new file mode 100644 index 0000000..6627cc3 --- /dev/null +++ b/spec/v2.3/schemas/authorization-proof.json @@ -0,0 +1,104 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://pact-spec.dev/schemas/v2.0/authorization-proof.json", + "title": "PACT Authorization Proof", + "description": "Proof-of-human-intent envelope (PACT Specification §17.6) that MAY accompany any PACT message. Carries a cryptographic attestation that a specific HumanPrincipal authorized the action. Attestation-type-specific fields (e.g. `match`, `utterance_hash`, `verifier_id` for `voice-biometric`) are additive — see §18.", + "type": "object", + "required": ["type", "principal_id", "credential_id", "challenge_nonce", "asserted_at", "signature", "alg", "alg_version"], + "properties": { + "type": { + "type": "string", + "description": "Attestation type. v2.0 first-class types are `fido2-assertion` and `voice-biometric`; custom types use reverse-domain notation (§18.5).", + "anyOf": [ + { "enum": ["fido2-assertion", "voice-biometric"] }, + { "pattern": "^[a-z0-9]+(\\.[a-z0-9-]+)+$" } + ] + }, + "principal_id": { + "type": "string", + "description": "The HumanPrincipal that authorized the action — a W3C DID. Verifiers MUST support `did:web` and `did:key`.", + "pattern": "^did:[a-z0-9]+:.+" + }, + "credential_id": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "description": "Identifier of the enrolled credential that produced the signature." + }, + "challenge_nonce": { + "type": "string", + "minLength": 1, + "description": "Verifier-issued challenge. Per §17.6, MUST be either (a) signed by the verifier's key (a runtime cryptographic property the JSON Schema cannot encode — assert via `verifier_signed_nonce: true`), OR (b) accompanied by a matching `verifier_id` field in this envelope. Verifiers MUST reject any proof where neither condition holds (§17.7 step 5)." + }, + "verifier_id": { + "type": "string", + "description": "Identifier (typically a DID) of the verifier the challenge was issued for. REQUIRED when `verifier_signed_nonce` is not `true` (the schema's `allOf` block at the bottom of this file enforces presence-or-explicit-acknowledgement).", + "pattern": "^did:[a-z0-9]+:.+" + }, + "verifier_signed_nonce": { + "type": "boolean", + "description": "Annotation: set to `true` if the producer asserts that `challenge_nonce` is itself signed by the verifier's key (the (a) branch of §17.6). Setting this is an assertion to the schema validator, not a cryptographic check — the verifier MUST still validate the signature at §17.7 step 5. When `false` or absent, `verifier_id` MUST be present." + }, + "asserted_at": { + "type": "string", + "format": "date-time", + "description": "When the human authorization was captured (ISO 8601). MUST be within the verifier's allowed clock skew (default ±5 minutes)." + }, + "signature": { + "type": "string", + "description": "Base64url-encoded signature over the message payload + `challenge_nonce` + `asserted_at`, per the `type`'s signature suite." + }, + "alg": { + "type": "string", + "description": "Algorithm identifier for this attestation's signature / match. For `fido2-assertion`, MUST be one of the v2.0.2 whitelist below (HMAC / symmetric-key algs are explicitly disallowed — see §17.6). For `voice-biometric`, currently `resemblyzer-v1` (HMAN's #3 PR pins the normative set). Custom attestation types declare their own algs in reverse-domain notation (`com.example.alg-name`).", + "anyOf": [ + { "enum": ["webauthn-es256", "webauthn-es384", "webauthn-eddsa"], "description": "v2.0.2 normative whitelist for fido2-assertion." }, + { "enum": ["resemblyzer-v1"], "description": "voice-biometric reference algorithm (non-normative; HMAN's #3 PR pins the normative set)." }, + { "pattern": "^[a-z0-9]+(\\.[a-z0-9-]+)+$", "description": "Custom attestation alg in reverse-domain notation." } + ] + }, + "alg_version": { + "type": "string", + "description": "Version of `alg`. REQUIRED so model swaps / retrains do not silently invalidate enrolled references." + }, + "attestation_chain": { + "type": "array", + "description": "Ordered intermediate attestations for delegated authorization (§17.11). Empty or absent = direct (no delegation). MAX length 3 (direct + 2 sub-delegations) at v2.0. The canonical item shape, the chained-verification algorithm, and trust-decay rules are DEFERRED TO v2.1; v2.0 leaves item structure implementation-defined and v2.0 verifiers that cannot themselves verify a non-empty chain MUST reject the proof as `unverifiable` (§17.11). Implementations that do not support delegation MUST reject any proof where this array is non-empty.", + "maxItems": 3, + "items": {} + }, + "match": { + "type": "object", + "description": "Speaker-verification result. Present for `voice-biometric` and reused by future biometric modalities (§18.3). Full normative shape lands via HMAN's #3 PR.", + "required": ["alg", "alg_version", "score", "threshold"], + "properties": { + "alg": { "type": "string" }, + "alg_version": { "type": "string" }, + "score": { "type": "number", "minimum": 0, "maximum": 1 }, + "threshold": { "type": "number", "minimum": 0, "maximum": 1 } + }, + "additionalProperties": true + }, + "utterance_hash": { + "type": "string", + "description": "Base64url hash of the spoken utterance, binding a `voice-biometric` assertion to what was said (§18.3, normative)." + } + }, + "additionalProperties": true, + "allOf": [ + { + "if": { "properties": { "type": { "const": "voice-biometric" } } }, + "then": { "required": ["match", "utterance_hash"] } + }, + { + "description": "§17.6 verifier-binding rule: challenge_nonce MUST be either verifier-signed (asserted via verifier_signed_nonce: true) OR accompanied by verifier_id.", + "if": { + "not": { + "properties": { "verifier_signed_nonce": { "const": true } }, + "required": ["verifier_signed_nonce"] + } + }, + "then": { "required": ["verifier_id"] } + } + ] +} diff --git a/spec/v2.3/schemas/classification-framework-request.json b/spec/v2.3/schemas/classification-framework-request.json new file mode 100644 index 0000000..8d8f402 --- /dev/null +++ b/spec/v2.3/schemas/classification-framework-request.json @@ -0,0 +1,47 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://pact-spec.dev/schemas/v2.0/classification-framework-request.json", + "title": "PACT Classification Framework Request", + "description": "Request body for POST /api/pact/{documentId}/classification/framework — create or update a classification framework.", + "type": "object", + "required": ["name", "levels"], + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 200, + "description": "Human-readable framework name (e.g., 'Australian Government', 'Corporate')." + }, + "levels": { + "type": "array", + "minItems": 2, + "items": { + "type": "object", + "required": ["levelId", "label", "rank"], + "properties": { + "levelId": { + "type": "string", + "minLength": 1, + "maxLength": 100, + "pattern": "^[a-z][a-z0-9-]*$", + "description": "Machine-readable identifier (e.g., 'official', 'protected', 'secret')." + }, + "label": { + "type": "string", + "minLength": 1, + "maxLength": 200, + "description": "Display name (e.g., 'OFFICIAL', 'PROTECTED', 'SECRET')." + }, + "rank": { + "type": "integer", + "minimum": 1, + "description": "Numeric rank — higher values indicate higher sensitivity. Must be unique within the framework." + } + }, + "additionalProperties": false + }, + "description": "Ordered list of classification levels, from lowest to highest sensitivity." + } + }, + "additionalProperties": false +} diff --git a/spec/v2.3/schemas/classify-section-request.json b/spec/v2.3/schemas/classify-section-request.json new file mode 100644 index 0000000..4e94d04 --- /dev/null +++ b/spec/v2.3/schemas/classify-section-request.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://pact-spec.dev/schemas/v2.0/classify-section-request.json", + "title": "PACT Classify Section Request", + "description": "Request body for POST /api/pact/{documentId}/sections/{sectionId}/classify — assign a classification level to a section.", + "type": "object", + "required": ["levelId"], + "properties": { + "levelId": { + "type": "string", + "minLength": 1, + "maxLength": 100, + "description": "The classification level ID from the active framework (e.g., 'protected', 'secret')." + }, + "reason": { + "type": "string", + "maxLength": 500, + "description": "Reason for classification (e.g., 'Contains pricing terms under NDA')." + } + }, + "additionalProperties": false +} diff --git a/spec/v2.3/schemas/clearance-request.json b/spec/v2.3/schemas/clearance-request.json new file mode 100644 index 0000000..5593264 --- /dev/null +++ b/spec/v2.3/schemas/clearance-request.json @@ -0,0 +1,22 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://pact-spec.dev/schemas/v2.0/clearance-request.json", + "title": "PACT Clearance Grant Request", + "description": "Request body for POST /api/pact/{documentId}/clearance — grant an agent clearance to a classification level.", + "type": "object", + "required": ["agentRegistrationId", "clearanceLevel"], + "properties": { + "agentRegistrationId": { + "type": "string", + "format": "uuid", + "description": "The agent's registration ID." + }, + "clearanceLevel": { + "type": "string", + "minLength": 1, + "maxLength": 100, + "description": "The classification level ID the agent is cleared to access (inclusive of lower levels)." + } + }, + "additionalProperties": false +} diff --git a/spec/v2.3/schemas/constraint-request.json b/spec/v2.3/schemas/constraint-request.json new file mode 100644 index 0000000..278e6c7 --- /dev/null +++ b/spec/v2.3/schemas/constraint-request.json @@ -0,0 +1,26 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://pact-spec.dev/schemas/v2.0/constraint-request.json", + "title": "PACT Constraint Request", + "description": "Request body for POST /api/pact/{documentId}/constraints", + "type": "object", + "required": ["sectionId", "boundary"], + "properties": { + "sectionId": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "description": "Target section identifier." + }, + "boundary": { + "type": "string", + "minLength": 1, + "description": "What must or must not happen in this section." + }, + "category": { + "type": "string", + "description": "Optional category for the constraint (e.g., 'legal', 'style', 'factual')." + } + }, + "additionalProperties": false +} diff --git a/spec/v2.3/schemas/done-request.json b/spec/v2.3/schemas/done-request.json new file mode 100644 index 0000000..9783a4e --- /dev/null +++ b/spec/v2.3/schemas/done-request.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://pact-spec.dev/schemas/v2.0/done-request.json", + "title": "PACT Done (Agent Completion) Request", + "description": "Request body for POST /api/pact/{documentId}/done", + "type": "object", + "required": ["status"], + "properties": { + "status": { + "type": "string", + "enum": ["aligned", "dissenting", "partial", "abstained"], + "description": "'aligned' = agent agrees with final state; 'dissenting' = agent disagrees with outcome; 'partial' = agent completed some but not all work; 'abstained' = agent chose not to participate further." + }, + "summary": { + "type": "string", + "description": "Optional summary of what the agent accomplished or why it abstained." + } + }, + "additionalProperties": false +} diff --git a/spec/v2.3/schemas/error-response.json b/spec/v2.3/schemas/error-response.json new file mode 100644 index 0000000..e15bf27 --- /dev/null +++ b/spec/v2.3/schemas/error-response.json @@ -0,0 +1,36 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://pact-spec.dev/schemas/v2.0/error-response.json", + "title": "PACT Error Response", + "description": "Standard error response format for all PACT API endpoints.", + "type": "object", + "required": ["errors"], + "properties": { + "errors": { + "type": "array", + "minItems": 1, + "items": { + "type": "object", + "required": ["code", "description"], + "properties": { + "code": { + "type": "string", + "description": "Machine-readable dot-delimited error code.", + "examples": ["auth.unauthorized", "section.locked", "proposal.conflict", "clearance.insufficient"] + }, + "description": { + "type": "string", + "description": "Human-readable error description." + }, + "metadata": { + "type": "object", + "description": "Optional machine-readable context (e.g., locked-by agent ID, retry-after seconds).", + "additionalProperties": true + } + }, + "additionalProperties": false + } + } + }, + "additionalProperties": false +} diff --git a/spec/v2.3/schemas/event.json b/spec/v2.3/schemas/event.json new file mode 100644 index 0000000..1df1d31 --- /dev/null +++ b/spec/v2.3/schemas/event.json @@ -0,0 +1,76 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://pact-spec.dev/schemas/v2.0/event.json", + "title": "PACT Event", + "description": "Every PACT operation produces an event with this structure.", + "type": "object", + "required": ["id", "epochMs", "actorDisplay", "actorKind", "eventType", "entityType", "entityId", "sequenceNumber", "payloadJson"], + "properties": { + "id": { + "type": "string", + "format": "uuid", + "description": "Unique event identifier." + }, + "epochMs": { + "type": "integer", + "description": "Unix timestamp in milliseconds." + }, + "actorId": { + "type": ["string", "null"], + "description": "Actor identifier (user or agent)." + }, + "actorDisplay": { + "type": "string", + "description": "Human-readable actor name." + }, + "actorKind": { + "type": "string", + "enum": ["Individual", "AiAgent", "GovernanceGroup", "System"], + "description": "Type of actor that produced the event." + }, + "eventType": { + "type": "string", + "description": "Dot-delimited event type (e.g., 'pact.proposal.created', 'pact.mediation.message-delivered').", + "pattern": "^pact\\.[a-z]+\\.[a-z-]+$" + }, + "entityType": { + "type": "string", + "const": "pact-document", + "description": "Always 'pact-document'." + }, + "entityId": { + "type": "string", + "format": "uuid", + "description": "Document identifier." + }, + "correlationId": { + "type": ["string", "null"], + "format": "uuid", + "description": "Links related events (e.g., create -> approve -> merge)." + }, + "inResponseTo": { + "type": ["string", "null"], + "format": "uuid", + "description": "Direct reply chain." + }, + "sequenceNumber": { + "type": "integer", + "minimum": 0, + "description": "Per-document monotonic counter." + }, + "sectionId": { + "type": ["string", "null"], + "maxLength": 256, + "description": "Target section (nullable)." + }, + "payloadJson": { + "type": "string", + "description": "JSON payload with operation-specific data. For `pact.log.root` events, this contains the signed-root descriptor: `{ resource_id, window_start_seq, window_end_seq, window_end_hash, signature, signing_key }` per §6.4." + }, + "prev_hash": { + "type": "string", + "description": "Base64url-encoded SHA-256 of the canonical JSON encoding (RFC 8785) of the immediately preceding event in the same resource's log. The first event uses the literal `\"GENESIS\"`. Implementations rejecting `prev_hash` mismatches detect silent tampering of past events. REQUIRED at Extended and Authorization-Required conformance levels per §6.4; RECOMMENDED at Core. Events emitted before v2.0.2 may lack this field; the first v2.0.2-emitted event after upgrade SHOULD use `\"GENESIS-v202\"` to mark the transition." + } + }, + "additionalProperties": false +} diff --git a/spec/v2.3/schemas/fabric-manifest.json b/spec/v2.3/schemas/fabric-manifest.json new file mode 100644 index 0000000..9c9cc11 --- /dev/null +++ b/spec/v2.3/schemas/fabric-manifest.json @@ -0,0 +1,164 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://pact-spec.dev/schemas/v2.0/fabric-manifest.json", + "title": "PACT Fabric Manifest", + "description": "Response body for GET /api/pact/{fabricId}/manifest. Caller-scoped view of a fabric: the caller's own role, constraints, obligations, and the counterparties visible to it. Subject to §17.13 'Manifest visibility' rules — never a privacy bypass. Added in PACT v2.0.3 (§4.4.2).", + "type": "object", + "required": ["fabric_id", "spec_version", "caller", "snapshot_at"], + "properties": { + "fabric_id": { "type": "string" }, + "spec_version": { "type": "string", "description": "PACT spec version this response conforms to." }, + "caller": { + "type": "object", + "description": "Who the caller is, as the server resolved them.", + "required": ["agent_id"], + "properties": { + "agent_id": { "type": "string" }, + "agent_name": { "type": "string" }, + "registration_id": { "type": "string", "format": "uuid" }, + "principal_id": { "type": "string", "description": "Caller's HumanPrincipal DID (§17.4)." }, + "trust_level": { "type": "string", "enum": ["Observer", "Suggester", "Collaborator", "Autonomous"] }, + "clearance_level": { "type": ["string", "null"] }, + "context_mode": { "type": "string", "enum": ["full", "section-scoped", "neighbourhood", "summary-only"] }, + "allowed_sections": { + "type": ["array", "null"], + "items": { "type": "string" }, + "description": "Section IDs the caller can access (only set when context_mode is 'section-scoped')." + } + }, + "additionalProperties": false + }, + "constraints_on_caller": { + "type": "array", + "description": "Constraints either published by the caller or that the caller is bound by.", + "items": { + "type": "object", + "required": ["constraint_id", "section_id", "boundary"], + "properties": { + "constraint_id": { "type": "string" }, + "section_id": { "type": "string", "maxLength": 256 }, + "boundary": { "type": "string" }, + "category": { "type": ["string", "null"] }, + "published_by_self": { + "type": "boolean", + "description": "True if the caller published this constraint. False if another member published it and the caller is subject to it (e.g. as an information-barrier hard constraint)." + } + }, + "additionalProperties": false + } + }, + "pending_obligations": { + "type": "array", + "description": "Only the caller's own pending obligations. Counterparty obligation counts appear under counterparties[].pending_obligation_count.", + "items": { "$ref": "https://pact-spec.dev/schemas/v2.0/pending-obligation.json" } + }, + "counterparties": { + "type": "array", + "description": "Other members visible to the caller. PII fields (agent_name, principal_id, etc.) are elided where the caller is not entitled to see them under §17.13. Elision is by key omission, not null.", + "items": { + "type": "object", + "required": ["agent_id", "disclosure_level"], + "properties": { + "agent_id": { "type": "string" }, + "agent_name": { "type": "string" }, + "principal_id": { "type": "string" }, + "trust_level": { "type": "string", "enum": ["Observer", "Suggester", "Collaborator", "Autonomous"] }, + "last_seen": { + "type": ["string", "null"], + "format": "date-time", + "description": "Coarse-grained liveness timestamp. RECOMMENDED second-precision or coarser (§17.13)." + }, + "attention_required": { "type": "boolean", "default": false }, + "pending_obligation_count": { "type": "integer", "minimum": 0 }, + "disclosure_level": { + "type": "string", + "enum": ["constraint", "category", "reasoning", "human"], + "description": "The §10.3 graduated-disclosure level under which this counterparty is exposed to the caller. 'constraint' = caller sees only the existence and constraints; higher levels expose more." + }, + "shared_sections": { + "type": ["array", "null"], + "items": { "type": "string" }, + "description": "Sections the caller and this counterparty are both interested in (overlap of allowed_sections / non-zero salience), where the implementation chooses to surface this." + } + }, + "additionalProperties": false + } + }, + "unread_event_id_from": { + "type": ["string", "null"], + "description": "Lowest event id the caller has not yet acknowledged via /mark-read (§4.4.4). Null if everything is acknowledged." + }, + "unread_event_id_to": { + "type": ["string", "null"], + "description": "Highest event id the caller has not yet acknowledged." + }, + "unread_count": { + "type": ["integer", "null"], + "minimum": 0, + "description": "Count of unacknowledged events. Implementations MAY return null if computing this is expensive." + }, + "snapshot_at": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the manifest was constructed." + } + }, + "additionalProperties": false, + "examples": [ + { + "fabric_id": "doc_xyz", + "spec_version": "2.0.3", + "caller": { + "agent_id": "urn:pact:agent:b-1", + "agent_name": "Agent-Finance", + "registration_id": "f81d4fae-7dec-11d0-a765-00a0c91e6bf6", + "principal_id": "did:web:bridget.example", + "trust_level": "Collaborator", + "clearance_level": "Confidential", + "context_mode": "section-scoped", + "allowed_sections": ["sec:budget", "sec:risk"] + }, + "constraints_on_caller": [ + { + "constraint_id": "con_42", + "section_id": "sec:risk", + "boundary": "Must not name specific instruments", + "category": "regulatory", + "published_by_self": true + } + ], + "pending_obligations": [ + { + "id": "obl_001", + "fabric_id": "doc_xyz", + "member_id": "urn:pact:agent:b-1", + "kind": "vote", + "event_ref": "evt_5a2c", + "created_at": "2026-05-15T18:15:00Z", + "due_by": "2026-05-15T18:20:00Z", + "overdue": false, + "discharged_at": null, + "discharge_kind": null, + "discharge_event_ref": null + } + ], + "counterparties": [ + { + "agent_id": "urn:pact:agent:k-1", + "agent_name": "Agent-Legal", + "principal_id": "did:web:knox.example", + "trust_level": "Collaborator", + "last_seen": "2026-05-15T18:14:33Z", + "attention_required": false, + "pending_obligation_count": 0, + "disclosure_level": "reasoning", + "shared_sections": ["sec:risk"] + } + ], + "unread_event_id_from": "evt_5a08", + "unread_event_id_to": "evt_5a2c", + "unread_count": 5, + "snapshot_at": "2026-05-15T18:14:40Z" + } + ] +} diff --git a/spec/v2.3/schemas/fabric-status.json b/spec/v2.3/schemas/fabric-status.json new file mode 100644 index 0000000..8798a72 --- /dev/null +++ b/spec/v2.3/schemas/fabric-status.json @@ -0,0 +1,156 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://pact-spec.dev/schemas/v2.0/fabric-status.json", + "title": "PACT Fabric Status", + "description": "Response body for GET /api/pact/{fabricId}/_status. Whole-fabric snapshot — members, phase, latest event, pending obligations, per-member liveness. Filtered by §17.13 cross-org / clearance disclosure rules: fields the caller is not entitled to see are omitted, not nulled. Added in PACT v2.0.3 (§4.4.1).", + "type": "object", + "required": ["fabric_id", "spec_version", "phase", "members", "snapshot_at"], + "properties": { + "fabric_id": { + "type": "string", + "description": "Fabric (resource) identifier." + }, + "spec_version": { + "type": "string", + "description": "PACT spec version this response conforms to (e.g. '2.0.3')." + }, + "phase": { + "type": "string", + "enum": ["forming", "negotiating", "converged", "escalated", "closed"], + "description": "Coarse-grained fabric lifecycle phase. 'forming' = members still onboarding; 'negotiating' = open proposals or intents; 'converged' = all open proposals resolved; 'escalated' = pending pact.escalation.human; 'closed' = fabric is terminal." + }, + "latest_event_id": { + "type": ["string", "null"], + "description": "Event id of the most recent event in the fabric. Null only for a brand-new fabric with no events." + }, + "latest_sequence_number": { + "type": ["integer", "null"], + "minimum": 0, + "description": "Per-fabric monotonic sequence number of the most recent event." + }, + "members": { + "type": "array", + "description": "Members currently joined to the fabric, filtered by cross-org / clearance rules.", + "items": { + "type": "object", + "required": ["agent_id"], + "properties": { + "agent_id": { + "type": "string", + "description": "Server-portable agentId (§23.1)." + }, + "agent_name": { + "type": "string", + "description": "Display name. May be omitted if the caller is not entitled to see it under §17.13." + }, + "principal_id": { + "type": "string", + "description": "HumanPrincipal DID. May be omitted under §17.13." + }, + "trust_level": { + "type": "string", + "enum": ["Observer", "Suggester", "Collaborator", "Autonomous"] + }, + "clearance_level": { + "type": ["string", "null"], + "description": "Classification clearance level granted (if information barriers are active)." + }, + "joined_at": { "type": "string", "format": "date-time" }, + "last_seen": { + "type": ["string", "null"], + "format": "date-time", + "description": "Coarse-grained liveness timestamp. RECOMMENDED second-precision or coarser to avoid timing side channels (§17.13)." + }, + "last_heartbeat_seq": { + "type": ["integer", "null"], + "minimum": 0, + "description": "Sequence number of the most recent pact.agent.heartbeat-received event for this member." + }, + "attention_required": { + "type": "boolean", + "default": false, + "description": "True if the member has flagged active presence via the most recent heartbeat (§4.4.3)." + }, + "pending_obligation_count": { + "type": "integer", + "minimum": 0, + "description": "Number of currently pending obligations targeting this member." + } + }, + "additionalProperties": false + } + }, + "pending_obligations": { + "type": "array", + "description": "All pending obligations across the fabric, filtered by §17.13 disclosure rules. Implementations that do not support obligations (§6.5) MUST omit this field rather than returning [].", + "items": { "$ref": "https://pact-spec.dev/schemas/v2.0/pending-obligation.json" } + }, + "open_proposals": { + "type": "integer", + "minimum": 0, + "description": "Count of proposals currently in PENDING / OBJECTED / CONFLICT states." + }, + "open_intents": { + "type": "integer", + "minimum": 0, + "description": "Count of intents currently in PROPOSED state." + }, + "snapshot_at": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the server constructed this snapshot." + } + }, + "additionalProperties": false, + "examples": [ + { + "fabric_id": "doc_xyz", + "spec_version": "2.0.3", + "phase": "negotiating", + "latest_event_id": "evt_5a2c", + "latest_sequence_number": 412, + "members": [ + { + "agent_id": "urn:pact:agent:k-1", + "agent_name": "Agent-Legal", + "principal_id": "did:web:knox.example", + "trust_level": "Collaborator", + "joined_at": "2026-05-15T18:02:11Z", + "last_seen": "2026-05-15T18:14:33Z", + "last_heartbeat_seq": 410, + "attention_required": false, + "pending_obligation_count": 0 + }, + { + "agent_id": "urn:pact:agent:b-1", + "agent_name": "Agent-Finance", + "principal_id": "did:web:bridget.example", + "trust_level": "Collaborator", + "joined_at": "2026-05-15T18:03:02Z", + "last_seen": "2026-05-15T18:14:30Z", + "last_heartbeat_seq": 411, + "attention_required": true, + "pending_obligation_count": 1 + } + ], + "pending_obligations": [ + { + "id": "obl_001", + "fabric_id": "doc_xyz", + "member_id": "urn:pact:agent:b-1", + "kind": "vote", + "event_ref": "evt_5a2c", + "created_at": "2026-05-15T18:15:00Z", + "due_by": "2026-05-15T18:20:00Z", + "overdue": false, + "discharged_at": null, + "discharge_kind": null, + "discharge_event_ref": null + } + ], + "open_proposals": 2, + "open_intents": 1, + "snapshot_at": "2026-05-15T18:14:40Z" + } + ] +} diff --git a/spec/v2.3/schemas/heartbeat-request.json b/spec/v2.3/schemas/heartbeat-request.json new file mode 100644 index 0000000..78f7ce2 --- /dev/null +++ b/spec/v2.3/schemas/heartbeat-request.json @@ -0,0 +1,40 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://pact-spec.dev/schemas/v2.0/heartbeat-request.json", + "title": "PACT Heartbeat Request", + "description": "Request body for POST /api/pact/{fabricId}/_heartbeat. The agent declares it is still alive and aware of the fabric, optionally flagging active presence. Idempotent over (member_id, client_heartbeat_id). Added in PACT v2.0.3 (§4.4.3).", + "type": "object", + "required": ["client_heartbeat_id"], + "properties": { + "client_heartbeat_id": { + "type": "string", + "format": "uuid", + "description": "UUID chosen by the caller. The server treats (member_id, client_heartbeat_id) as the idempotency key — a duplicate POST within the implementation's idempotency window MUST return the cached response and MUST NOT emit a second event. Window SHOULD be at least 60 seconds." + }, + "attention_required": { + "type": "boolean", + "default": false, + "description": "When true, the caller signals 'I am actively present and want my counterparty to know it.' The server emits pact.agent.attention-required so counterparties can prioritise." + }, + "client_observed_event_id": { + "type": ["string", "null"], + "description": "Latest event id the caller has processed locally. Implementations MAY use this to surface drift (caller is behind server) in the response." + }, + "authorization_proof": { + "description": "Optional. When present, §17.7 verification rules apply; verifier_id MUST equal the receiving server's DID.", + "$ref": "https://pact-spec.dev/schemas/v2.0/authorization-proof.json" + } + }, + "additionalProperties": false, + "examples": [ + { + "client_heartbeat_id": "5a8b9d2e-1c3f-4a6e-b8c1-2d4f6a8c9e0b", + "attention_required": false, + "client_observed_event_id": "evt_5a2c" + }, + { + "client_heartbeat_id": "11111111-2222-3333-4444-555555555555", + "attention_required": true + } + ] +} diff --git a/spec/v2.3/schemas/heartbeat-response.json b/spec/v2.3/schemas/heartbeat-response.json new file mode 100644 index 0000000..3106b8b --- /dev/null +++ b/spec/v2.3/schemas/heartbeat-response.json @@ -0,0 +1,77 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://pact-spec.dev/schemas/v2.0/heartbeat-response.json", + "title": "PACT Heartbeat Response", + "description": "Response body for POST /api/pact/{fabricId}/_heartbeat. Returns the fabric's liveness view so the caller's reasoning context learns whether counterparties are present. Added in PACT v2.0.3 (§4.4.3).", + "type": "object", + "required": ["fabric_id", "client_heartbeat_id", "server_received_at"], + "properties": { + "fabric_id": { "type": "string" }, + "client_heartbeat_id": { + "type": "string", + "format": "uuid", + "description": "Echo of the request's client_heartbeat_id." + }, + "server_received_at": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the server recorded the heartbeat." + }, + "latest_event_id": { + "type": ["string", "null"], + "description": "Most recent event in the fabric at the time of the heartbeat." + }, + "latest_sequence_number": { + "type": ["integer", "null"], + "minimum": 0 + }, + "caller_last_seen": { + "type": "string", + "format": "date-time", + "description": "The caller's last_seen as recorded by this heartbeat (typically equal to server_received_at)." + }, + "members_liveness": { + "type": "array", + "description": "Per-member liveness view, filtered by §17.13 disclosure rules. Coarse-grained timestamps RECOMMENDED.", + "items": { + "type": "object", + "required": ["agent_id"], + "properties": { + "agent_id": { "type": "string" }, + "last_seen": { + "type": ["string", "null"], + "format": "date-time" + }, + "attention_required": { "type": "boolean", "default": false } + }, + "additionalProperties": false + } + }, + "pending_obligation_count": { + "type": "integer", + "minimum": 0, + "description": "Number of currently pending obligations targeting the caller." + }, + "drift_warning": { + "type": ["string", "null"], + "description": "Optional advisory. Set when client_observed_event_id is materially behind latest_event_id. Free-form text, e.g. 'caller is 12 events behind'." + } + }, + "additionalProperties": false, + "examples": [ + { + "fabric_id": "doc_xyz", + "client_heartbeat_id": "5a8b9d2e-1c3f-4a6e-b8c1-2d4f6a8c9e0b", + "server_received_at": "2026-05-15T18:14:33Z", + "latest_event_id": "evt_5a2c", + "latest_sequence_number": 412, + "caller_last_seen": "2026-05-15T18:14:33Z", + "members_liveness": [ + { "agent_id": "urn:pact:agent:k-1", "last_seen": "2026-05-15T18:14:00Z", "attention_required": false }, + { "agent_id": "urn:pact:agent:b-1", "last_seen": "2026-05-15T18:14:33Z", "attention_required": false } + ], + "pending_obligation_count": 1, + "drift_warning": null + } + ] +} diff --git a/spec/v2.3/schemas/intent-request.json b/spec/v2.3/schemas/intent-request.json new file mode 100644 index 0000000..4791ebe --- /dev/null +++ b/spec/v2.3/schemas/intent-request.json @@ -0,0 +1,26 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://pact-spec.dev/schemas/v2.0/intent-request.json", + "title": "PACT Intent Declaration Request", + "description": "Request body for POST /api/pact/{documentId}/intents", + "type": "object", + "required": ["sectionId", "goal"], + "properties": { + "sectionId": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "description": "Target section identifier." + }, + "goal": { + "type": "string", + "minLength": 1, + "description": "What the agent intends to do with this section." + }, + "category": { + "type": "string", + "description": "Optional category for the intent (e.g., 'editorial', 'compliance', 'restructure')." + } + }, + "additionalProperties": false +} diff --git a/spec/v2.3/schemas/invite-create-request.json b/spec/v2.3/schemas/invite-create-request.json new file mode 100644 index 0000000..a70c68f --- /dev/null +++ b/spec/v2.3/schemas/invite-create-request.json @@ -0,0 +1,52 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://pact-spec.dev/schemas/v2.0/invite-create-request.json", + "title": "PACT Invite Create Request", + "description": "Request body for POST /api/pact/{documentId}/invites — create an invite token for agent onboarding.", + "type": "object", + "required": ["label"], + "properties": { + "label": { + "type": "string", + "minLength": 1, + "maxLength": 200, + "description": "Human-readable label for this invite (e.g., 'Compliance Bot', 'External Reviewer')." + }, + "trustLevel": { + "type": "string", + "enum": ["Observer", "Suggester", "Collaborator"], + "default": "Suggester", + "description": "Maximum trust level the joining agent receives." + }, + "contextMode": { + "type": "string", + "enum": ["full", "section-scoped", "neighbourhood", "summary-only"], + "default": "full", + "description": "Context mode for agents joining with this token." + }, + "allowedSections": { + "type": "array", + "items": { + "type": "string", + "maxLength": 256 + }, + "description": "Section IDs the agent can access (required when contextMode is 'section-scoped')." + }, + "clearanceLevel": { + "type": "string", + "maxLength": 100, + "description": "Classification clearance level granted to agents joining with this token." + }, + "maxUses": { + "type": "integer", + "minimum": 1, + "description": "Maximum number of times this token can be used. Omit for unlimited." + }, + "expiresAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 expiry timestamp. Omit for no expiry." + } + }, + "additionalProperties": false +} diff --git a/spec/v2.3/schemas/invite-response.json b/spec/v2.3/schemas/invite-response.json new file mode 100644 index 0000000..64d97af --- /dev/null +++ b/spec/v2.3/schemas/invite-response.json @@ -0,0 +1,72 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://pact-spec.dev/schemas/v2.0/invite-response.json", + "title": "PACT Invite Response", + "description": "Response body for POST /api/pact/{documentId}/invites — the created invite including the secret token (shown once).", + "type": "object", + "required": ["inviteId", "token", "label", "documentId", "createdAt"], + "properties": { + "inviteId": { + "type": "string", + "format": "uuid", + "description": "Unique invite identifier." + }, + "token": { + "type": "string", + "description": "The secret invite token. Shown once at creation — cannot be retrieved again." + }, + "label": { + "type": "string", + "description": "Human-readable label for this invite." + }, + "documentId": { + "type": "string", + "format": "uuid", + "description": "Document this invite is for." + }, + "trustLevel": { + "type": "string", + "enum": ["Observer", "Suggester", "Collaborator"], + "description": "Trust level assigned to agents joining with this token." + }, + "contextMode": { + "type": "string", + "enum": ["full", "section-scoped", "neighbourhood", "summary-only"], + "description": "Context mode for agents joining with this token." + }, + "allowedSections": { + "type": ["array", "null"], + "items": { "type": "string" }, + "description": "Section IDs the agent can access (if context mode is section-scoped)." + }, + "clearanceLevel": { + "type": ["string", "null"], + "description": "Classification clearance level granted on join." + }, + "maxUses": { + "type": ["integer", "null"], + "description": "Maximum uses. Null means unlimited." + }, + "usedCount": { + "type": "integer", + "minimum": 0, + "description": "Number of times this token has been used." + }, + "expiresAt": { + "type": ["string", "null"], + "format": "date-time", + "description": "ISO 8601 expiry timestamp. Null means no expiry." + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 creation timestamp." + }, + "revoked": { + "type": "boolean", + "default": false, + "description": "Whether this invite has been revoked." + } + }, + "additionalProperties": false +} diff --git a/spec/v2.3/schemas/join-request.json b/spec/v2.3/schemas/join-request.json new file mode 100644 index 0000000..fb7d8ed --- /dev/null +++ b/spec/v2.3/schemas/join-request.json @@ -0,0 +1,38 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://pact-spec.dev/schemas/v2.0/join-request.json", + "title": "PACT Join Request", + "description": "Request body for POST /api/pact/{documentId}/join", + "type": "object", + "required": ["agentName"], + "properties": { + "agentName": { + "type": "string", + "minLength": 1, + "maxLength": 200, + "description": "Display name for this agent." + }, + "role": { + "type": "string", + "maxLength": 100, + "description": "Optional role descriptor (e.g., 'editor', 'reviewer', 'compliance')." + }, + "contextMode": { + "type": "string", + "enum": ["full", "section-scoped", "neighbourhood", "summary-only"], + "default": "full", + "description": "How much of the document the agent can see." + }, + "protocolVersion": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+$", + "description": "Preferred PACT protocol version (e.g., '0.3', '0.4'). Server negotiates the actual version." + }, + "orgId": { + "type": "string", + "maxLength": 100, + "description": "Organisation identifier for information barrier enforcement." + } + }, + "additionalProperties": false +} diff --git a/spec/v2.3/schemas/join-response.json b/spec/v2.3/schemas/join-response.json new file mode 100644 index 0000000..ac6141c --- /dev/null +++ b/spec/v2.3/schemas/join-response.json @@ -0,0 +1,66 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://pact-spec.dev/schemas/v2.0/join-response.json", + "title": "PACT Join Response", + "description": "Response body for POST /api/pact/{documentId}/join and POST /api/pact/{documentId}/join-token", + "type": "object", + "required": ["registrationId", "documentId", "agentName", "joinedAt"], + "properties": { + "registrationId": { + "type": "string", + "format": "uuid", + "description": "Unique identifier for this agent registration." + }, + "documentId": { + "type": "string", + "format": "uuid", + "description": "Document the agent joined." + }, + "agentName": { + "type": "string", + "description": "Display name of the agent." + }, + "role": { + "type": ["string", "null"], + "description": "Role assigned to the agent." + }, + "contextMode": { + "type": "string", + "enum": ["full", "section-scoped", "neighbourhood", "summary-only"], + "description": "Context mode assigned to the agent." + }, + "allowedSections": { + "type": ["array", "null"], + "items": { "type": "string" }, + "description": "Section IDs the agent can access (only set when contextMode is 'section-scoped')." + }, + "apiKey": { + "type": "string", + "description": "Document-scoped API key for authentication. Only returned on join-token (BYOK) flow." + }, + "trustLevel": { + "type": "string", + "enum": ["Observer", "Suggester", "Collaborator", "Autonomous"], + "description": "Trust level assigned to the agent." + }, + "clearanceLevel": { + "type": ["string", "null"], + "description": "Classification clearance level granted (if information barriers are active)." + }, + "protocolVersion": { + "type": "string", + "description": "Negotiated protocol version." + }, + "capabilities": { + "type": "array", + "items": { "type": "string" }, + "description": "Available capabilities at the negotiated protocol version." + }, + "joinedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the agent joined." + } + }, + "additionalProperties": false +} diff --git a/spec/v2.3/schemas/join-token-request.json b/spec/v2.3/schemas/join-token-request.json new file mode 100644 index 0000000..06e7281 --- /dev/null +++ b/spec/v2.3/schemas/join-token-request.json @@ -0,0 +1,27 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://pact-spec.dev/schemas/v2.0/join-token-request.json", + "title": "PACT Join with Token Request", + "description": "Request body for POST /api/pact/{documentId}/join-token (BYOK flow)", + "type": "object", + "required": ["agentName", "token"], + "properties": { + "agentName": { + "type": "string", + "minLength": 1, + "maxLength": 200, + "description": "Display name for this agent." + }, + "token": { + "type": "string", + "minLength": 1, + "description": "Invite token issued by the document owner." + }, + "role": { + "type": "string", + "maxLength": 100, + "description": "Optional role descriptor." + } + }, + "additionalProperties": false +} diff --git a/spec/v2.3/schemas/lock-request.json b/spec/v2.3/schemas/lock-request.json new file mode 100644 index 0000000..7c48351 --- /dev/null +++ b/spec/v2.3/schemas/lock-request.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://pact-spec.dev/schemas/v2.0/lock-request.json", + "title": "PACT Lock Section Request", + "description": "Request body for POST /api/pact/{documentId}/sections/{sectionId}/lock", + "type": "object", + "properties": { + "ttlSeconds": { + "type": "integer", + "minimum": 1, + "maximum": 60, + "description": "Lock time-to-live in seconds. If omitted, the server applies a default TTL." + } + }, + "additionalProperties": false +} diff --git a/spec/v2.3/schemas/mark-read-request.json b/spec/v2.3/schemas/mark-read-request.json new file mode 100644 index 0000000..8996a90 --- /dev/null +++ b/spec/v2.3/schemas/mark-read-request.json @@ -0,0 +1,52 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://pact-spec.dev/schemas/v2.0/mark-read-request.json", + "title": "PACT Mark-Read Request", + "description": "Request body for POST /api/pact/{fabricId}/mark-read. The caller acknowledges that it has received and processed events in the range [from, to] (inclusive). Either the _event_id pair OR the _sequence_number pair is sufficient; if both are provided they MUST be consistent. Added in PACT v2.0.3 (§4.4.4).", + "type": "object", + "anyOf": [ + { "required": ["from_event_id", "to_event_id"] }, + { "required": ["from_sequence_number", "to_sequence_number"] } + ], + "properties": { + "from_event_id": { + "type": "string", + "description": "Inclusive lower bound of the acknowledged event range, by event id." + }, + "to_event_id": { + "type": "string", + "description": "Inclusive upper bound of the acknowledged event range, by event id." + }, + "from_sequence_number": { + "type": "integer", + "minimum": 0, + "description": "Inclusive lower bound, by per-fabric sequence number." + }, + "to_sequence_number": { + "type": "integer", + "minimum": 0, + "description": "Inclusive upper bound, by per-fabric sequence number." + }, + "authorization_proof": { + "description": "Optional. Cross-org calls at Authorization-Required MUST carry a valid proof; verifier_id MUST equal the server's DID.", + "$ref": "https://pact-spec.dev/schemas/v2.0/authorization-proof.json" + } + }, + "additionalProperties": false, + "examples": [ + { + "from_event_id": "evt_5a08", + "to_event_id": "evt_5a2c" + }, + { + "from_sequence_number": 400, + "to_sequence_number": 412 + }, + { + "from_event_id": "evt_5a08", + "to_event_id": "evt_5a2c", + "from_sequence_number": 400, + "to_sequence_number": 412 + } + ] +} diff --git a/spec/v2.3/schemas/mark-read-response.json b/spec/v2.3/schemas/mark-read-response.json new file mode 100644 index 0000000..eb9dab3 --- /dev/null +++ b/spec/v2.3/schemas/mark-read-response.json @@ -0,0 +1,61 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://pact-spec.dev/schemas/v2.0/mark-read-response.json", + "title": "PACT Mark-Read Response", + "description": "Response body for POST /api/pact/{fabricId}/mark-read. Confirms the caller's read cursor advanced and returns the id of the emitted pact.agent.mark-read event. Idempotent: re-posting the same range returns the original event_id. Added in PACT v2.0.3 (§4.4.4).", + "type": "object", + "required": ["fabric_id", "caller_member_id", "marked_from_sequence_number", "marked_to_sequence_number", "acknowledged_at", "event_id"], + "properties": { + "fabric_id": { "type": "string" }, + "caller_member_id": { + "type": "string", + "description": "The caller's agentId (or registration id, per implementation convention)." + }, + "marked_from_sequence_number": { + "type": "integer", + "minimum": 0, + "description": "Inclusive lower bound of the range now recorded as read." + }, + "marked_to_sequence_number": { + "type": "integer", + "minimum": 0, + "description": "Inclusive upper bound (the caller's new read high-water mark)." + }, + "marked_from_event_id": { + "type": ["string", "null"], + "description": "Echo of the resolved from_event_id when the request used event ids." + }, + "marked_to_event_id": { + "type": ["string", "null"], + "description": "Echo of the resolved to_event_id when the request used event ids." + }, + "acknowledged_at": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the server recorded the acknowledgement." + }, + "event_id": { + "type": "string", + "description": "Id of the emitted pact.agent.mark-read event. Stable across idempotent retries of the same range." + }, + "merged_with_existing": { + "type": "boolean", + "description": "True if this mark-read merged with a prior read cursor (range overlap or extension); false if it is a brand-new acknowledgement.", + "default": false + } + }, + "additionalProperties": false, + "examples": [ + { + "fabric_id": "doc_xyz", + "caller_member_id": "urn:pact:agent:b-1", + "marked_from_sequence_number": 400, + "marked_to_sequence_number": 412, + "marked_from_event_id": "evt_5a08", + "marked_to_event_id": "evt_5a2c", + "acknowledged_at": "2026-05-15T18:14:33Z", + "event_id": "evt_ack_001", + "merged_with_existing": false + } + ] +} diff --git a/spec/v2.3/schemas/matter-add-member-request.json b/spec/v2.3/schemas/matter-add-member-request.json new file mode 100644 index 0000000..3c7c867 --- /dev/null +++ b/spec/v2.3/schemas/matter-add-member-request.json @@ -0,0 +1,30 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://pact-spec.dev/schemas/v2.2/matter-add-member-request.json", + "title": "Matter — Add Member Request", + "description": "Request body for POST /api/pact/matters/{id}/members. Owner-only. At the Authorization-Required tier, cross-org member adds MUST carry §17.6 authorization_proof (§24.3).", + "type": "object", + "required": ["principal_id"], + "additionalProperties": false, + "properties": { + "principal_id": { + "type": "string", + "description": "DID of the principal to add." + }, + "display_name": { + "type": "string", + "minLength": 1, + "maxLength": 100, + "description": "Display name within the Matter. Defaults to the principal_id if omitted." + }, + "role": { + "type": "string", + "enum": ["owner", "participant"], + "default": "participant" + }, + "authorization_proof": { + "$ref": "https://pact-spec.dev/schemas/v2.0/authorization-proof.json", + "description": "REQUIRED at the Authorization-Required tier when the added principal is cross-org. §17.6 envelope." + } + } +} diff --git a/spec/v2.3/schemas/matter-attach-fabric-request.json b/spec/v2.3/schemas/matter-attach-fabric-request.json new file mode 100644 index 0000000..5bfb03a --- /dev/null +++ b/spec/v2.3/schemas/matter-attach-fabric-request.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://pact-spec.dev/schemas/v2.2/matter-attach-fabric-request.json", + "title": "Matter — Attach Fabric Request", + "description": "Request body for POST /api/pact/matters/{id}/fabrics. Owner-only. Attaches an existing fabric to the Matter; the fabric is NOT modified (§24.6).", + "type": "object", + "required": ["resourceId"], + "additionalProperties": false, + "properties": { + "resourceId": { + "type": "string", + "description": "ID of the fabric to attach. Production implementations SHOULD reject attachment of a non-existent fabric; the reference server lazily creates." + } + } +} diff --git a/spec/v2.3/schemas/matter-close-request.json b/spec/v2.3/schemas/matter-close-request.json new file mode 100644 index 0000000..ab8101d --- /dev/null +++ b/spec/v2.3/schemas/matter-close-request.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://pact-spec.dev/schemas/v2.2/matter-close-request.json", + "title": "Matter — Close Request", + "description": "Request body for POST /api/pact/matters/{id}/close. Owner-only. Closure does NOT cascade to attached fabrics (§24.9).", + "type": "object", + "required": [], + "additionalProperties": false, + "properties": { + "outcome": { + "type": "string", + "minLength": 1, + "maxLength": 200, + "description": "Free-form outcome label (e.g., 'deal-signed', 'walked-away', 'engagement-complete'). Recorded on the emitted `pact.matter.closed` event." + } + } +} diff --git a/spec/v2.3/schemas/matter-create-request.json b/spec/v2.3/schemas/matter-create-request.json new file mode 100644 index 0000000..5acea1a --- /dev/null +++ b/spec/v2.3/schemas/matter-create-request.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://pact-spec.dev/schemas/v2.2/matter-create-request.json", + "title": "Matter — Open Request", + "description": "Request body for POST /api/pact/matters. Opens a new Matter (multi-fabric deal-room container). Caller becomes the first member with role=owner. See docs/v2-prep/matters-spec-draft.md §24.5.", + "type": "object", + "required": ["name"], + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 200, + "description": "Human-readable Matter name. Free-form; implementations MAY enforce additional uniqueness." + }, + "opened_by_display": { + "type": "string", + "minLength": 1, + "maxLength": 100, + "description": "Caller's display name within the Matter. Defaults to the caller's principal_id if omitted." + } + } +} diff --git a/spec/v2.3/schemas/matter-create-response.json b/spec/v2.3/schemas/matter-create-response.json new file mode 100644 index 0000000..d97acfe --- /dev/null +++ b/spec/v2.3/schemas/matter-create-response.json @@ -0,0 +1,41 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://pact-spec.dev/schemas/v2.2/matter-create-response.json", + "title": "Matter — Open Response", + "description": "Success response for POST /api/pact/matters. Per §24.5.", + "type": "object", + "required": ["matter_id", "spec_version", "name", "phase", "members", "fabrics", "opened_at", "opened_by", "opened_event_id"], + "additionalProperties": true, + "properties": { + "matter_id": { + "type": "string", + "description": "Server-minted Matter ID, prefix `mtr_`." + }, + "spec_version": { + "type": "string", + "description": "Spec version this server implements for the Matter primitive (e.g., `2.2-draft`, `2.2`)." + }, + "name": { "type": "string" }, + "phase": { + "type": "string", + "enum": ["open", "active", "closed"] + }, + "members": { + "type": "array", + "items": { "$ref": "https://pact-spec.dev/schemas/v2.2/matter-member.json" } + }, + "fabrics": { + "type": "array", + "items": { "$ref": "https://pact-spec.dev/schemas/v2.2/matter-fabric-attachment.json" } + }, + "opened_at": { "type": "string", "format": "date-time" }, + "opened_by": { + "type": "string", + "description": "DID of the caller who opened the Matter (becomes first owner)." + }, + "opened_event_id": { + "type": "string", + "description": "Event ID for the emitted `pact.matter.opened` event." + } + } +} diff --git a/spec/v2.3/schemas/matter-fabric-attachment.json b/spec/v2.3/schemas/matter-fabric-attachment.json new file mode 100644 index 0000000..4d81925 --- /dev/null +++ b/spec/v2.3/schemas/matter-fabric-attachment.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://pact-spec.dev/schemas/v2.2/matter-fabric-attachment.json", + "title": "Matter Fabric Attachment", + "description": "A cross-reference from a Matter to an existing fabric. Attachment is a LINK, not a merge — the fabric's own membership, constraints, proposals, and events are unaffected (§24.6).", + "type": "object", + "required": ["resourceId", "attached_at", "attached_by"], + "additionalProperties": false, + "properties": { + "resourceId": { + "type": "string", + "description": "ID of the attached fabric (resource). A fabric MAY be attached to multiple Matters simultaneously (§24.3)." + }, + "attached_at": { "type": "string", "format": "date-time" }, + "attached_by": { + "type": "string", + "description": "DID of the Matter owner who performed the attachment." + } + } +} diff --git a/spec/v2.3/schemas/matter-manifest-response.json b/spec/v2.3/schemas/matter-manifest-response.json new file mode 100644 index 0000000..d23dc2a --- /dev/null +++ b/spec/v2.3/schemas/matter-manifest-response.json @@ -0,0 +1,96 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://pact-spec.dev/schemas/v2.2/matter-manifest-response.json", + "title": "Matter Cross-Fabric Manifest", + "description": "Caller-scoped cross-fabric manifest for a Matter. Extension of §4.4.2 to Matter scope: aggregates attached-fabric phase + open-proposal counts, caller-specific pending obligations across all attached fabrics, §17.13-reduced counterparties, and side-channel summary (§24.7).", + "type": "object", + "required": ["matter_id", "spec_version", "phase", "caller", "counterparties", "fabrics", "pending_obligations_across_fabrics", "side_channel", "snapshot_at"], + "additionalProperties": false, + "properties": { + "matter_id": { "type": "string" }, + "spec_version": { "type": "string" }, + "phase": { + "type": "string", + "enum": ["open", "active", "closed"] + }, + "caller": { + "type": "object", + "required": ["principal_id", "display_name", "role"], + "additionalProperties": true, + "properties": { + "principal_id": { "type": "string" }, + "display_name": { "type": "string" }, + "role": { "type": "string", "enum": ["owner", "participant"] } + } + }, + "counterparties": { + "type": "array", + "items": { + "type": "object", + "required": ["principal_id", "display_name", "role", "joined_at", "cross_org"], + "additionalProperties": true, + "properties": { + "principal_id": { "type": "string" }, + "display_name": { "type": "string" }, + "role": { "type": "string", "enum": ["owner", "participant"] }, + "joined_at": { "type": "string", "format": "date-time" }, + "cross_org": { "type": "boolean" }, + "org_eTLD_plus_1": { + "type": "string", + "description": "Present iff cross_org=false (§17.13 reduces cross-org peers)." + } + } + } + }, + "fabrics": { + "type": "array", + "items": { + "type": "object", + "required": ["resourceId", "attached_at"], + "additionalProperties": true, + "properties": { + "resourceId": { "type": "string" }, + "attached_at": { "type": "string", "format": "date-time" }, + "attached_by": { "type": "string" }, + "phase": { "type": "string" }, + "member_count": { "type": "integer", "minimum": 0 }, + "open_proposals": { "type": "integer", "minimum": 0 }, + "pending_obligation_count_for_caller": { "type": "integer", "minimum": 0 }, + "caller_is_fabric_member": { "type": "boolean" } + } + } + }, + "pending_obligations_across_fabrics": { + "type": "array", + "items": { + "type": "object", + "required": ["fabric_id", "obligation_id", "kind", "event_ref"], + "additionalProperties": true, + "properties": { + "fabric_id": { "type": "string" }, + "obligation_id": { "type": "string" }, + "kind": { + "type": "string", + "enum": ["vote", "respond", "sign", "ack"], + "description": "Per §6.5 — Matter manifest aggregates obligations across attached fabrics where principal_id matches the caller." + }, + "event_ref": { "type": "string" }, + "due_by": { "type": "string", "format": "date-time" } + } + } + }, + "side_channel": { + "type": "object", + "required": ["message_count"], + "additionalProperties": false, + "properties": { + "message_count": { "type": "integer", "minimum": 0 }, + "latest_message_at": { + "type": ["string", "null"], + "format": "date-time" + } + } + }, + "snapshot_at": { "type": "string", "format": "date-time" } + } +} diff --git a/spec/v2.3/schemas/matter-member.json b/spec/v2.3/schemas/matter-member.json new file mode 100644 index 0000000..fe78b60 --- /dev/null +++ b/spec/v2.3/schemas/matter-member.json @@ -0,0 +1,30 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://pact-spec.dev/schemas/v2.2/matter-member.json", + "title": "Matter Member", + "description": "A member of a Matter — principal + role + eligibility metadata. Membership is eligibility, NOT automatic fabric enrollment (§24.3).", + "type": "object", + "required": ["principal_id", "display_name", "role", "joined_at"], + "additionalProperties": false, + "properties": { + "principal_id": { + "type": "string", + "description": "DID of the member (§17 / §23). MUST be one of the DID methods the implementation supports." + }, + "display_name": { + "type": "string", + "minLength": 1, + "maxLength": 100 + }, + "role": { + "type": "string", + "enum": ["owner", "participant"], + "description": "owner = may add/remove members + attach/detach fabrics + close. participant = may post to side-channel + read manifest." + }, + "joined_at": { "type": "string", "format": "date-time" }, + "org_eTLD_plus_1": { + "type": "string", + "description": "Registrable domain (eTLD+1) of the member's principal — used for §17.13 cross-org determination. Implementations MAY compute on-demand instead of storing." + } + } +} diff --git a/spec/v2.3/schemas/matter-message-request.json b/spec/v2.3/schemas/matter-message-request.json new file mode 100644 index 0000000..5a16358 --- /dev/null +++ b/spec/v2.3/schemas/matter-message-request.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://pact-spec.dev/schemas/v2.2/matter-message-request.json", + "title": "Matter — Post Side-Channel Message Request", + "description": "Request body for POST /api/pact/matters/{id}/messages. Posts a typed-event message to the Matter side-channel (§24.8). Wire format is structured; UIs MAY render as chat but the protocol does NOT define presence, typing indicators, reactions, or threads.", + "type": "object", + "required": ["content"], + "additionalProperties": false, + "properties": { + "content": { + "type": "string", + "minLength": 1, + "description": "Textual payload of the message body.format=text envelope." + }, + "fabric_id": { + "type": "string", + "description": "Optional cross-reference to an attached fabric — surfaces in `references.fabric_id` on the resulting `pact.matter.message` event." + }, + "section_id": { + "type": "string", + "description": "Optional cross-reference to a section within `fabric_id`. Ignored if `fabric_id` is absent." + } + } +} diff --git a/spec/v2.3/schemas/matter-message.json b/spec/v2.3/schemas/matter-message.json new file mode 100644 index 0000000..952389e --- /dev/null +++ b/spec/v2.3/schemas/matter-message.json @@ -0,0 +1,43 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://pact-spec.dev/schemas/v2.2/matter-message.json", + "title": "Matter Side-Channel Message", + "description": "A single side-channel message — the wire shape carried by `pact.matter.message` events (§24.8).", + "type": "object", + "required": ["id", "sender_principal", "posted_at", "body"], + "additionalProperties": false, + "properties": { + "id": { + "type": "string", + "description": "Server-minted message ID, prefix `msg_`." + }, + "sender_principal": { + "type": "string", + "description": "DID of the posting member. MUST be a current member of the Matter at the time of posting." + }, + "posted_at": { "type": "string", "format": "date-time" }, + "body": { + "type": "object", + "required": ["format", "content"], + "additionalProperties": false, + "properties": { + "format": { + "type": "string", + "enum": ["text"], + "description": "v0.1 supports `text` only. Future formats (`proposal-ref`, `obligation-ref`, etc.) extend without changing the envelope." + }, + "content": { "type": "string" } + } + }, + "references": { + "type": "object", + "additionalProperties": false, + "properties": { + "fabric_id": { "type": "string" }, + "section_id": { "type": "string" } + }, + "required": ["fabric_id"], + "description": "Optional cross-link to an attached fabric (and section within it)." + } + } +} diff --git a/spec/v2.3/schemas/message-response.json b/spec/v2.3/schemas/message-response.json new file mode 100644 index 0000000..a1453f7 --- /dev/null +++ b/spec/v2.3/schemas/message-response.json @@ -0,0 +1,66 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://pact-spec.dev/schemas/v2.0/message-response.json", + "title": "PACT Mediated Message", + "description": "A message as delivered through the mediator. Content may be summarised, redacted, or blocked.", + "type": "object", + "required": ["messageId", "epochMs", "senderId", "deliveredContent", "mediationAction"], + "properties": { + "messageId": { + "type": "string", + "format": "uuid", + "description": "Unique message identifier." + }, + "epochMs": { + "type": "integer", + "description": "Unix timestamp in milliseconds." + }, + "senderId": { + "type": "string", + "format": "uuid", + "description": "Registration ID of the sending agent." + }, + "senderDisplay": { + "type": "string", + "description": "Display name of the sender (may be anonymised by mediator)." + }, + "recipientId": { + "type": ["string", "null"], + "format": "uuid", + "description": "Target agent, or null for broadcast." + }, + "deliveredContent": { + "type": ["string", "null"], + "description": "Content after mediation (may differ from original). Null when mediationAction is 'blocked'." + }, + "mediationAction": { + "type": "string", + "enum": ["forwarded", "summarised", "redacted", "blocked", "held"], + "description": "What the mediator did to the original content." + }, + "mediationReason": { + "type": ["string", "null"], + "description": "Why the mediator applied this action." + }, + "disclosureLevel": { + "type": ["integer", "null"], + "minimum": 1, + "maximum": 4, + "description": "Graduated disclosure level (1=metadata only, 2=category, 3=full content, 4=human-only)." + }, + "classificationLevel": { + "type": ["string", "null"], + "description": "Classification of the original content (e.g., 'official', 'protected', 'secret'). References the active classification framework." + }, + "sectionId": { + "type": ["string", "null"], + "description": "Section context if provided." + }, + "acknowledged": { + "type": "boolean", + "default": false, + "description": "Whether the recipient has acknowledged this message." + } + }, + "additionalProperties": false +} diff --git a/spec/v2.3/schemas/message-send-request.json b/spec/v2.3/schemas/message-send-request.json new file mode 100644 index 0000000..6658d77 --- /dev/null +++ b/spec/v2.3/schemas/message-send-request.json @@ -0,0 +1,26 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://pact-spec.dev/schemas/v2.0/message-send-request.json", + "title": "PACT Mediated Message Request", + "description": "Request body for POST /api/pact/{documentId}/messages (mediated mode)", + "type": "object", + "required": ["content"], + "properties": { + "recipientId": { + "type": "string", + "format": "uuid", + "description": "Target agent registration ID. Omit for broadcast to all agents." + }, + "content": { + "type": "string", + "minLength": 1, + "description": "Message content. Subject to mediator filtering before delivery." + }, + "sectionId": { + "type": "string", + "maxLength": 256, + "description": "Optional section context for the message." + } + }, + "additionalProperties": false +} diff --git a/spec/v2.3/schemas/negotiation-position-request.json b/spec/v2.3/schemas/negotiation-position-request.json new file mode 100644 index 0000000..bb2e815 --- /dev/null +++ b/spec/v2.3/schemas/negotiation-position-request.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://pact-spec.dev/schemas/v2.0/negotiation-position-request.json", + "title": "PACT Negotiation Position Request", + "description": "Request body for POST /api/pact/{documentId}/negotiations/{negotiationId}/position. Agent submits their position in a structured negotiation round.", + "type": "object", + "required": ["position"], + "properties": { + "position": { + "type": "string", + "minLength": 1, + "description": "The agent's position on the contested section. Subject to mediator filtering." + }, + "proposedContent": { + "type": "string", + "description": "Optional proposed content for the section under negotiation." + } + }, + "additionalProperties": false +} diff --git a/spec/v2.3/schemas/negotiation-response.json b/spec/v2.3/schemas/negotiation-response.json new file mode 100644 index 0000000..6a52c69 --- /dev/null +++ b/spec/v2.3/schemas/negotiation-response.json @@ -0,0 +1,56 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://pact-spec.dev/schemas/v2.0/negotiation-response.json", + "title": "PACT Negotiation", + "description": "Represents a structured multi-round negotiation facilitated by the mediator.", + "type": "object", + "required": ["negotiationId", "documentId", "sectionId", "status", "currentRound", "participantIds"], + "properties": { + "negotiationId": { + "type": "string", + "format": "uuid", + "description": "Unique negotiation identifier." + }, + "documentId": { + "type": "string", + "format": "uuid", + "description": "Document this negotiation belongs to." + }, + "sectionId": { + "type": "string", + "description": "Section under negotiation." + }, + "status": { + "type": "string", + "enum": ["open", "in-progress", "aligned", "escalated", "timed-out", "closed"], + "description": "Current negotiation status." + }, + "currentRound": { + "type": "integer", + "minimum": 0, + "description": "Current round number." + }, + "participantIds": { + "type": "array", + "items": { + "type": "string", + "format": "uuid" + }, + "description": "Agent registration IDs participating in this negotiation." + }, + "synthesis": { + "type": ["string", "null"], + "description": "Mediator's synthesis of positions from the latest round (content-filtered)." + }, + "outcome": { + "type": ["string", "null"], + "enum": ["aligned", "escalation", "timeout", null], + "description": "Final outcome if negotiation is closed." + }, + "createdAt": { + "type": "string", + "format": "date-time" + } + }, + "additionalProperties": false +} diff --git a/spec/v2.3/schemas/onboard-request.json b/spec/v2.3/schemas/onboard-request.json new file mode 100644 index 0000000..cc9cd87 --- /dev/null +++ b/spec/v2.3/schemas/onboard-request.json @@ -0,0 +1,91 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://pact-spec.dev/schemas/v2.0/onboard-request.json", + "title": "PACT Onboard Request", + "description": "Request body for POST /api/pact/{fabricId}/_onboard. Atomic join + constraint declaration. Either both commit or neither; constraint rejection rolls back any partial join state. The request is the union of join-request.json (or join-token-request.json) and one or more constraint-request.json items. See §4.4.5 and §15.6. Added in PACT v2.0.3.", + "type": "object", + "required": ["agentName", "constraints"], + "properties": { + "agentName": { + "type": "string", + "minLength": 1, + "maxLength": 200, + "description": "Display name for this agent (carries join-request.json semantics)." + }, + "role": { + "type": "string", + "maxLength": 100, + "description": "Optional role descriptor." + }, + "contextMode": { + "type": "string", + "enum": ["full", "section-scoped", "neighbourhood", "summary-only"], + "default": "full", + "description": "How much of the resource the agent can see." + }, + "protocolVersion": { + "type": "string", + "pattern": "^[0-9]+\\.[0-9]+(\\.[0-9]+)?$", + "description": "Preferred PACT protocol version (e.g. '2.0', '2.0.3'). Server negotiates the actual version." + }, + "orgId": { + "type": "string", + "maxLength": 100, + "description": "Organisation identifier for information-barrier enforcement (§13)." + }, + "invite_token": { + "type": ["string", "null"], + "description": "BYOK invite token, when onboarding via the join-token flow. Single-use; a failed _onboard MUST NOT consume the token (§4.4.5)." + }, + "constraints": { + "type": "array", + "description": "Constraints to publish atomically with the join. MAY be empty for 'join only, no constraints declared up front' — the operation is still atomic, just trivially so. Each item has the shape of constraint-request.json.", + "items": { + "type": "object", + "required": ["sectionId", "boundary"], + "properties": { + "sectionId": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "description": "Target section identifier." + }, + "boundary": { + "type": "string", + "minLength": 1, + "description": "What must or must not happen in this section." + }, + "category": { + "type": "string", + "description": "Optional category (e.g. 'legal', 'style', 'factual', 'regulatory', 'commercial')." + } + }, + "additionalProperties": false + } + }, + "authorization_proof": { + "description": "Cross-org calls at the Authorization-Required tier MUST carry a valid proof. The envelope's verifier_id MUST equal the server's DID per §17.7 step 5. The proof witnesses 'the human authorized joining this fabric with these constraints.'", + "$ref": "https://pact-spec.dev/schemas/v2.0/authorization-proof.json" + } + }, + "additionalProperties": false, + "examples": [ + { + "agentName": "Agent-Finance", + "role": "reviewer", + "contextMode": "section-scoped", + "protocolVersion": "2.0", + "orgId": "bridget-co", + "invite_token": "tok_a1b2c3d4", + "constraints": [ + { "sectionId": "sec:risk", "boundary": "Must not name specific instruments", "category": "regulatory" }, + { "sectionId": "sec:budget", "boundary": "Must not commit beyond Q3 forecast", "category": "commercial" } + ] + }, + { + "agentName": "Agent-Observer", + "contextMode": "summary-only", + "constraints": [] + } + ] +} diff --git a/spec/v2.3/schemas/onboard-response.json b/spec/v2.3/schemas/onboard-response.json new file mode 100644 index 0000000..e5468cb --- /dev/null +++ b/spec/v2.3/schemas/onboard-response.json @@ -0,0 +1,113 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://pact-spec.dev/schemas/v2.0/onboard-response.json", + "title": "PACT Onboard Response", + "description": "Response body for POST /api/pact/{fabricId}/_onboard. Discriminated by 'status': on success ('onboarded') carries the registration and the published constraints plus the single pact.fabric.onboarded event id; on failure ('rejected') carries the rejection_reason and an errors array — and no registration exists on the server. See §4.4.5. Added in PACT v2.0.3.", + "type": "object", + "required": ["status"], + "properties": { + "status": { + "type": "string", + "enum": ["onboarded", "rejected"] + }, + "fabric_id": { "type": "string" }, + "registration": { + "description": "On success: the join-response.json shape produced by the inner join half.", + "$ref": "https://pact-spec.dev/schemas/v2.0/join-response.json" + }, + "constraints": { + "type": "array", + "description": "On success: the constraints that were published as part of the atomic transaction.", + "items": { + "type": "object", + "required": ["constraint_id", "sectionId", "boundary"], + "properties": { + "constraint_id": { "type": "string" }, + "sectionId": { "type": "string", "maxLength": 256 }, + "boundary": { "type": "string" }, + "category": { "type": ["string", "null"] } + }, + "additionalProperties": false + } + }, + "onboarded_event_id": { + "type": "string", + "description": "On success: id of the single pact.fabric.onboarded event. The bundled pact.constraint.published events carry this id as their correlationId." + }, + "onboarded_at": { + "type": "string", + "format": "date-time", + "description": "On success: ISO 8601 timestamp." + }, + "rejection_reason": { + "type": "string", + "description": "On failure: machine-readable reason (e.g. 'constraint.incompatible', 'constraint.invalid', 'invite_token.consumed', 'join.unauthorized')." + }, + "rejected_constraint_index": { + "type": ["integer", "null"], + "minimum": 0, + "description": "On failure: zero-based index into the request's constraints array identifying which constraint caused rejection. Null if rejection was on the join half." + }, + "errors": { + "type": "array", + "description": "On failure: standard PACT error array (per Appendix A.1).", + "items": { + "type": "object", + "required": ["code", "description"], + "properties": { + "code": { "type": "string" }, + "description": { "type": "string" }, + "metadata": { "type": "object", "additionalProperties": true } + }, + "additionalProperties": false + } + } + }, + "additionalProperties": false, + "allOf": [ + { + "if": { "properties": { "status": { "const": "onboarded" } } }, + "then": { "required": ["status", "fabric_id", "registration", "constraints", "onboarded_event_id", "onboarded_at"] } + }, + { + "if": { "properties": { "status": { "const": "rejected" } } }, + "then": { "required": ["status", "rejection_reason", "errors"] } + } + ], + "examples": [ + { + "status": "onboarded", + "fabric_id": "doc_xyz", + "registration": { + "registrationId": "f81d4fae-7dec-11d0-a765-00a0c91e6bf6", + "documentId": "doc_xyz", + "agentName": "Agent-Finance", + "role": "reviewer", + "contextMode": "section-scoped", + "allowedSections": ["sec:budget", "sec:risk"], + "trustLevel": "Collaborator", + "protocolVersion": "2.0", + "capabilities": ["atomicOnboard", "manifest"], + "joinedAt": "2026-05-15T18:02:11Z" + }, + "constraints": [ + { "constraint_id": "con_42", "sectionId": "sec:risk", "boundary": "Must not name specific instruments", "category": "regulatory" }, + { "constraint_id": "con_43", "sectionId": "sec:budget", "boundary": "Must not commit beyond Q3 forecast", "category": "commercial" } + ], + "onboarded_event_id": "evt_onb_001", + "onboarded_at": "2026-05-15T18:02:11Z" + }, + { + "status": "rejected", + "rejection_reason": "constraint.incompatible", + "rejected_constraint_index": 1, + "errors": [ + { + "code": "constraint.incompatible", + "description": "Constraint on sec:budget conflicts with existing fabric constraint con_18.", + "metadata": { "conflicting_constraint_id": "con_18" } + } + ] + } + ] +} diff --git a/spec/v2.3/schemas/pending-obligation.json b/spec/v2.3/schemas/pending-obligation.json new file mode 100644 index 0000000..cbd2711 --- /dev/null +++ b/spec/v2.3/schemas/pending-obligation.json @@ -0,0 +1,89 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://pact-spec.dev/schemas/v2.0/pending-obligation.json", + "title": "PACT Pending Obligation", + "description": "A first-class record of something the protocol expects a specific member to do next on a fabric. Surfaced via GET /_status and GET /manifest (Section 4.4); registered and resolved via pact.obligation.created and pact.obligation.discharged events (Section 6.5). Added in PACT v2.0.3.", + "type": "object", + "required": ["id", "fabric_id", "member_id", "kind", "event_ref", "created_at"], + "properties": { + "id": { + "type": "string", + "minLength": 1, + "maxLength": 200, + "description": "Obligation identifier, server-minted. Stable across the obligation's lifecycle." + }, + "fabric_id": { + "type": "string", + "description": "Fabric (resource) identifier the obligation belongs to." + }, + "member_id": { + "type": "string", + "description": "The agentId (or registration ID, per implementation convention) expected to act. Exactly one member per obligation." + }, + "kind": { + "type": "string", + "enum": ["vote", "respond", "sign", "ack"], + "description": "What kind of action discharges the obligation. See Section 6.5 for the discharge rules per kind." + }, + "event_ref": { + "type": "string", + "description": "Event id of the event that created the obligation (e.g. the pact.proposal.created event for which a vote is owed)." + }, + "created_at": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the obligation was registered." + }, + "due_by": { + "type": ["string", "null"], + "format": "date-time", + "description": "Optional deadline. After this, the obligation MAY be flagged in the manifest as overdue. Absent or null = no implicit deadline." + }, + "overdue": { + "type": "boolean", + "description": "True if due_by has passed and the obligation has not been discharged. Implementations compute this server-side at response time." + }, + "discharged_at": { + "type": ["string", "null"], + "format": "date-time", + "description": "When the obligation was resolved. Null while pending." + }, + "discharge_kind": { + "type": ["string", "null"], + "enum": ["fulfilled", "superseded", "timed_out", "escalated", null], + "description": "How the obligation was discharged. Null while pending." + }, + "discharge_event_ref": { + "type": ["string", "null"], + "description": "Event id of the event that discharged the obligation. Null while pending." + } + }, + "additionalProperties": false, + "examples": [ + { + "id": "obl_001", + "fabric_id": "doc_xyz", + "member_id": "urn:pact:agent:b-1", + "kind": "vote", + "event_ref": "evt_5a2c", + "created_at": "2026-05-15T18:15:00Z", + "due_by": "2026-05-15T18:20:00Z", + "overdue": false, + "discharged_at": null, + "discharge_kind": null, + "discharge_event_ref": null + }, + { + "id": "obl_002", + "fabric_id": "doc_xyz", + "member_id": "urn:pact:agent:k-1", + "kind": "respond", + "event_ref": "evt_5a30", + "created_at": "2026-05-15T18:16:10Z", + "due_by": null, + "discharged_at": "2026-05-15T18:18:42Z", + "discharge_kind": "fulfilled", + "discharge_event_ref": "evt_5a44" + } + ] +} diff --git a/spec/v2.3/schemas/principal-registry.json b/spec/v2.3/schemas/principal-registry.json new file mode 100644 index 0000000..8680d51 --- /dev/null +++ b/spec/v2.3/schemas/principal-registry.json @@ -0,0 +1,83 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://pact-spec.dev/schemas/v2.0/principal-registry.json", + "title": "PACT Credential Registry", + "description": "Structure of the credential registry a PACT server MAY publish at /.well-known/pact-credentials.json (PACT Specification §17.8). An implementation MAY instead, or also, support DID-document resolution for the public keys.", + "type": "object", + "required": ["version", "principals"], + "properties": { + "version": { + "type": "string", + "description": "Registry format version. `\"1.0\"` was the v2.0/v2.0.1 shape (no signed snapshot / no append-only log). `\"2.0\"` is the v2.0.2 shape with snapshot_root + snapshot_signature + log_uri per §17.8 (REQUIRED at Extended and Authorization-Required conformance).", + "enum": ["1.0", "2.0"] + }, + "snapshot_root": { + "type": "string", + "description": "Base64url-encoded SHA-256 of the canonical encoding (RFC 8785) of the most recent entry in the append-only mutation log at `log_uri`. Commits the server to the registry's current state. REQUIRED when `version` is `\"2.0\"`." + }, + "snapshot_signature": { + "type": "string", + "description": "Base64url-encoded signature over `snapshot_root` using the server's registry-signing key (advertised in the Implementation Profile as `endpoints.registrySigningKey`). REQUIRED when `version` is `\"2.0\"`." + }, + "log_uri": { + "type": "string", + "format": "uri", + "description": "Absolute URL of the append-only mutation log (JSONL, one entry per registry mutation: `enroll` / `revoke` / `rotate` / `tombstone` / `untombstone`). Each entry carries `prev_hash` chaining back to a GENESIS entry — see §17.8. REQUIRED when `version` is `\"2.0\"`; consumers MAY validate the chain back to GENESIS to detect silent tampering." + }, + "supported_types": { + "type": "array", + "description": "Attestation types this server accepts, including any custom types in reverse-domain notation (§18.5). If absent, the v2.0 first-class types (`fido2-assertion`, `voice-biometric`) are assumed.", + "items": { "type": "string" } + }, + "principals": { + "type": "array", + "description": "Registered HumanPrincipals. Each is strictly 1:1 with a single human (§17.4).", + "items": { + "type": "object", + "required": ["id"], + "properties": { + "id": { + "type": "string", + "description": "The principal's W3C DID.", + "pattern": "^did:[a-z0-9]+:.+" + }, + "display_name": { + "type": "string", + "description": "Human-readable name. Optional; MAY be omitted or pseudonymous." + }, + "credentials": { + "type": "array", + "description": "Active and revoked credentials enrolled for this principal. Implementations SHOULD support rotation (multiple active credentials).", + "items": { + "type": "object", + "required": ["id", "type", "public_key", "enrolled_at", "revoked"], + "properties": { + "id": { "type": "string", "minLength": 1, "maxLength": 256, "description": "Credential identifier, referenced by `authorization_proof.credential_id`." }, + "type": { + "type": "string", + "description": "Attestation type this credential is used for.", + "anyOf": [ + { "enum": ["fido2-assertion", "voice-biometric"] }, + { "pattern": "^[a-z0-9]+(\\.[a-z0-9-]+)+$" } + ] + }, + "public_key": { "type": "string", "description": "Base64url-encoded public key (or, for some types, a reference resolvable to one)." }, + "enrolled_at": { "type": "string", "format": "date-time" }, + "revoked": { "type": "boolean", "description": "When true, verification against this credential MUST fail (§17.8)." }, + "revoked_at": { "type": "string", "format": "date-time", "description": "When the credential was revoked. Present when `revoked` is true." } + }, + "additionalProperties": false + } + }, + "tombstoned_at": { + "type": "string", + "format": "date-time", + "description": "Set when the human invokes erasure (§17.10): the credential keys are destroyed and `credentials` is emptied, but the principal entry remains so prior proofs are checkable as having-been-valid-then-revoked. A tombstoned principal MUST have an empty `credentials` array." + } + }, + "additionalProperties": false + } + } + }, + "additionalProperties": false +} diff --git a/spec/v2.3/schemas/proposal-request.json b/spec/v2.3/schemas/proposal-request.json new file mode 100644 index 0000000..d60ab68 --- /dev/null +++ b/spec/v2.3/schemas/proposal-request.json @@ -0,0 +1,37 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://pact-spec.dev/schemas/v2.0/proposal-request.json", + "title": "PACT Proposal Request", + "description": "Request body for POST /api/pact/{documentId}/proposals", + "type": "object", + "required": ["sectionId", "newContent"], + "properties": { + "sectionId": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "description": "Target section identifier (e.g., 'sec:intro')." + }, + "newContent": { + "type": "string", + "minLength": 1, + "description": "Proposed replacement content for the section (Markdown)." + }, + "summary": { + "type": "string", + "maxLength": 500, + "description": "Brief summary of the change." + }, + "baseVersion": { + "type": "integer", + "minimum": 0, + "description": "Document version the proposal is based on. Used for conflict detection." + }, + "reasoning": { + "type": "string", + "maxLength": 2000, + "description": "Detailed reasoning for the proposed change." + } + }, + "additionalProperties": false +} diff --git a/spec/v2.3/schemas/proposal-response.json b/spec/v2.3/schemas/proposal-response.json new file mode 100644 index 0000000..b7ad673 --- /dev/null +++ b/spec/v2.3/schemas/proposal-response.json @@ -0,0 +1,82 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://pact-spec.dev/schemas/v2.0/proposal-response.json", + "title": "PACT Proposal Response", + "description": "Response body for POST /api/pact/{documentId}/proposals", + "type": "object", + "required": ["proposalId", "documentId", "sectionId", "status", "createdAt", "activeConstraints"], + "properties": { + "proposalId": { + "type": "string", + "format": "uuid", + "description": "Unique identifier for this proposal." + }, + "documentId": { + "type": "string", + "format": "uuid", + "description": "Document this proposal targets." + }, + "sectionId": { + "type": "string", + "description": "Section this proposal targets." + }, + "status": { + "type": "string", + "enum": ["Pending", "Approved", "Rejected", "Merged", "Withdrawn", "Conflict", "Objected"], + "description": "Current status of the proposal." + }, + "summary": { + "type": ["string", "null"], + "description": "Brief summary of the change." + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp when the proposal was created." + }, + "activeConstraints": { + "type": "array", + "description": "Constraints currently active on the target section.", + "items": { + "type": "object", + "required": ["boundary", "category"], + "properties": { + "boundary": { + "type": "string", + "description": "Constraint boundary text." + }, + "category": { + "type": "string", + "description": "Constraint category." + } + }, + "additionalProperties": false + } + }, + "activeConflicts": { + "type": "array", + "description": "Conflicting proposals on the same section, if any.", + "items": { + "type": "object", + "required": ["proposalId", "sectionId", "status"], + "properties": { + "proposalId": { + "type": "string", + "format": "uuid", + "description": "ID of the conflicting proposal." + }, + "sectionId": { + "type": "string", + "description": "Section targeted by the conflicting proposal." + }, + "status": { + "type": "string", + "description": "Status of the conflicting proposal." + } + }, + "additionalProperties": false + } + } + }, + "additionalProperties": false +} diff --git a/spec/v2.3/schemas/query-respond-request.json b/spec/v2.3/schemas/query-respond-request.json new file mode 100644 index 0000000..880f4b1 --- /dev/null +++ b/spec/v2.3/schemas/query-respond-request.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://pact-spec.dev/schemas/v2.0/query-respond-request.json", + "title": "PACT Mediated Query Response", + "description": "Request body for POST /api/pact/{documentId}/queries/{queryId}/respond (mediated mode). Agent responds to a routed query.", + "type": "object", + "required": ["answer"], + "properties": { + "answer": { + "type": "string", + "minLength": 1, + "description": "The agent's answer. Subject to mediator filtering before delivery to the questioner." + } + }, + "additionalProperties": false +} diff --git a/spec/v2.3/schemas/query-submit-request.json b/spec/v2.3/schemas/query-submit-request.json new file mode 100644 index 0000000..1da8c4c --- /dev/null +++ b/spec/v2.3/schemas/query-submit-request.json @@ -0,0 +1,33 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://pact-spec.dev/schemas/v2.0/query-submit-request.json", + "title": "PACT Mediated Query Request", + "description": "Request body for POST /api/pact/{documentId}/queries (mediated mode). Structured Q&A with graduated disclosure.", + "type": "object", + "required": ["question"], + "properties": { + "question": { + "type": "string", + "minLength": 1, + "description": "The question to route through the mediator." + }, + "targetAgentId": { + "type": "string", + "format": "uuid", + "description": "Specific agent to query. Omit to let the mediator route based on salience." + }, + "sectionId": { + "type": "string", + "maxLength": 256, + "description": "Section the query relates to." + }, + "disclosureLevel": { + "type": "integer", + "minimum": 1, + "maximum": 4, + "default": 2, + "description": "Requested disclosure level (1=metadata, 2=category, 3=full reasoning, 4=human-visible)." + } + }, + "additionalProperties": false +} diff --git a/spec/v2.3/schemas/register-entry.json b/spec/v2.3/schemas/register-entry.json new file mode 100644 index 0000000..83d1aa0 --- /dev/null +++ b/spec/v2.3/schemas/register-entry.json @@ -0,0 +1,61 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://pact-spec.dev/schemas/v2.0/register-entry.json", + "title": "PACT Message Register Entry", + "description": "An entry in the append-only Message Register. Only visible to human custodians. Records the original and delivered content of all mediated communications.", + "type": "object", + "required": ["messageId", "epochMs", "senderId", "originalContent", "deliveredContent", "mediationAction"], + "properties": { + "messageId": { + "type": "string", + "format": "uuid", + "description": "Unique message identifier." + }, + "epochMs": { + "type": "integer", + "description": "Unix timestamp in milliseconds." + }, + "senderId": { + "type": "string", + "format": "uuid", + "description": "Sending agent registration ID." + }, + "recipientId": { + "type": ["string", "null"], + "format": "uuid", + "description": "Target agent, or null for broadcast." + }, + "originalContent": { + "type": "string", + "description": "Content as submitted by the sender (before mediation)." + }, + "deliveredContent": { + "type": ["string", "null"], + "description": "Content as delivered to the recipient (after mediation). Null when mediationAction is 'blocked'." + }, + "mediationAction": { + "type": "string", + "enum": ["forwarded", "summarised", "redacted", "blocked", "held"], + "description": "What the mediator did." + }, + "mediationReason": { + "type": ["string", "null"], + "description": "Why the mediator applied this action." + }, + "disclosureLevel": { + "type": ["integer", "null"], + "minimum": 1, + "maximum": 4, + "description": "Graduated disclosure level applied (1=metadata only, 2=category, 3=full content, 4=human-only)." + }, + "classificationLevel": { + "type": ["string", "null"], + "description": "Classification of the original content (e.g., 'official', 'protected', 'secret')." + }, + "sectionId": { + "type": ["string", "null"], + "description": "Section context." + } + }, + "additionalProperties": false +} diff --git a/spec/v2.3/schemas/resolve-request.json b/spec/v2.3/schemas/resolve-request.json new file mode 100644 index 0000000..7846c38 --- /dev/null +++ b/spec/v2.3/schemas/resolve-request.json @@ -0,0 +1,36 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://pact-spec.dev/schemas/v2.0/resolve-request.json", + "title": "PACT Human Resolution Request", + "description": "Request body for POST /api/pact/{documentId}/resolve — human resolves an escalation with a binding decision.", + "type": "object", + "required": ["escalationId", "decision"], + "properties": { + "escalationId": { + "type": "string", + "format": "uuid", + "description": "The escalation being resolved." + }, + "sectionId": { + "type": "string", + "maxLength": 256, + "description": "Section the escalation relates to." + }, + "decision": { + "type": "string", + "minLength": 1, + "description": "The human's binding decision text." + }, + "isOverride": { + "type": "boolean", + "default": false, + "description": "Whether this resolution overrides agent consensus." + }, + "resolvedProposalIds": { + "type": "array", + "items": { "type": "string", "format": "uuid" }, + "description": "Proposal IDs affected by this resolution." + } + }, + "additionalProperties": false +} diff --git a/spec/v2.3/schemas/salience-request.json b/spec/v2.3/schemas/salience-request.json new file mode 100644 index 0000000..25534d3 --- /dev/null +++ b/spec/v2.3/schemas/salience-request.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://pact-spec.dev/schemas/v2.0/salience-request.json", + "title": "PACT Salience Request", + "description": "Request body for POST /api/pact/{documentId}/salience", + "type": "object", + "required": ["sectionId", "score"], + "properties": { + "sectionId": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "description": "Target section identifier." + }, + "score": { + "type": "integer", + "minimum": 0, + "maximum": 10, + "description": "Salience score (0 = no interest, 10 = critical)." + } + }, + "additionalProperties": false +}