Skip to content

Pacts

A pact is a voluntary agreement among a set of players to constrain their harvesting. Pacts are arena-scoped, persistent across turns, and proposed/accepted through the cheap-talk action surface (cheap-talk). Once active, every turn the arena checks whether each party's actual harvest complied with the pact's terms and records any violations.

This page documents the canonical pact lifecycle, term schema, and violation semantics as implemented in platform/pacts.js; any paper or notebook citing a different term type is stale.


Lifecycle

                ┌─────────────────┐
                │   (no pact)     │
                └─────────────────┘
                         │ propose
       ┌────────────► proposed ──────► rejected
       │                 │
       │ accept          │ all parties accepted
       │                 ▼
       └──────────── active ─── checkViolations() every turn

A pact transitions through three states:

State Entered when Behaviour
proposed a player calls propose_pact (or pledge) not yet binding; awaits acceptance
active every party has accepted (proposer accepts implicitly) checked against actual harvests
rejected any party calls reject_pact terminal; binds no one

A unilateral pact (single-party — typically via the pledge shorthand) becomes active on proposal because the proposer's implicit acceptance covers the only party.


Term schema

Terms are a discriminated union on terms.type. v0.9 ships two types; adding a third is additive (extend TERM_VALIDATORS and VIOLATION_CHECKERS in platform/pacts.js), never a rewrite.

terms.type Required fields Optional fields Meaning
harvest_cap maxHarvest > 0 each party caps actual at maxHarvest
no_aggression tolerance ≥ 0 each party stays at or below the group mean (× 1 + tolerance)
// harvest_cap
{ "type": "harvest_cap", "maxHarvest": 2.5 }

// no_aggression (default tolerance = 0 → strict mean)
{ "type": "no_aggression" }
{ "type": "no_aggression", "tolerance": 0.1 }

The platform exports the live list as a frozen array:

import { PACT_TERM_TYPES } from './pacts.js';
// → Object.freeze(['harvest_cap', 'no_aggression'])

Violation checking

After every turn, the arena calls PactRegistry.checkViolations(turnRecord). For each active pact:

  1. The checker for pact.terms.type is invoked with the party's actual harvest, the terms, and a per-turn context object (currently { groupMean }).
  2. If the checker returns a non-null detail string, an event is appended to the pact's violations log and to the returned event array.

Floating-point slack of \(10^{-6}\) means a harvest numerically equal to the cap (or fair share) is not a breach.

harvest_cap violation

actual > maxHarvest + ε

Recorded detail: harvested X.XXX > cap Y.YYY.

no_aggression violation

limit  = groupMean × (1 + tolerance)
actual > limit + ε

Recorded detail: harvested X.XXX > fair share Y.YYY (group mean Z.ZZZ).

Where groupMean is computed once per turn over all decisions in turnRecord.decisions[]. Parties not playing this turn are skipped.


Information discipline (per-player view)

Pacts are not globally observable to players. PactRegistry.viewFor(playerId) returns only the pacts the player is a party to, partitioned into active and proposed (with an awaitingYou: bool flag on each proposal). This is the view threaded into the LLM observation envelope and into the decide(obs, memory) snapshot for code agents — players cannot see pacts they are not bound by.

// obs.pacts (player-scoped)
{
  "active": [
    { "id": "...", "proposerId": "alice", "parties": ["alice", "bob"],
      "terms": { "type": "harvest_cap", "maxHarvest": 2.5 },
      "proposedTurn": 3, "acceptedTurn": 4 }
  ],
  "proposed": [
    { "id": "...", "proposerId": "carol", "parties": ["bob", "carol"],
      "terms": { "type": "no_aggression" },
      "proposedTurn": 7, "awaitingYou": true }
  ]
}

Persistence

Each turn record carries the compact serialised view of the registry under turnRecord.pacts:

{
  "id":             "uuid-...",
  "proposerId":     "alice",
  "parties":        ["alice", "bob"],
  "terms":          { "type": "harvest_cap", "maxHarvest": 2.5 },
  "status":         "active",
  "proposedTurn":   3,
  "acceptedTurn":   4,
  "violationCount": 1
}

The full violations[] array stays on the registry-side Pact object; the turn record carries only the running count (the detailed log is exposed to meta-agents and post-hoc analysis through PactRegistry.get()).


Engine-agnostic by construction

A pact only ever reads turnRecord.decisions[].actual and player IDs. It never touches engine internals — neither commons stock, nor Seneca R/C/P, nor world-level link weights. This is why the same pact registry works unchanged across logistic, seneca, and any future engine.


Downstream consumers

Where What it reads
sanction / sanction_graduated perturbations the violation log to decide who to penalise (see perturbations)
governance_agent meta-agent viewFor(metaId) + violation events to schedule perturbations
sanction_rate metric the count of sanctions fired this turn (see metrics)
World-level Treaty generalises the same lifecycle across nested arenas

Adding a new pact term type is a single-file change to platform/pacts.js. Engines, agents, and perturbation modules need not be modified — this is the engine-agnostic invariant documented in architecture.