2  Approach

This section describes the four platform additions that constitute this paper’s governance MVP. All four are engine-agnostic: they live in platform/, not in any engine/<kind>.js, and adding a new engine remains a single-file change.

2.1 Pact registry

The pact registry (platform/pacts.js) is an arena-side, persistent data structure tracking two classes of declared agreement (PACT_TERM_TYPES):

Term type Semantics
harvest_cap No party harvests more than maxHarvest per turn
no_aggression No party harvests more than the group mean (\(\pm\) tolerance)

Pacts have a two-state lifecycle: proposed \(\to\) active, with the transition firing once every listed party has recorded an acceptance (the proposer counts as an implicit acceptor). A pact that any party rejects transitions instead to rejected and binds no one. Violation detection runs at end-of-turn against each player’s actual harvest and emits a { partyId, pactId, termType } event into the turn-level pacts.violations list. The registry is queryable via tool from the LLM side and is observed by both the metrics layer and the perturbation trigger system.

2.2 Cheap-talk channel

Cheap talk lives on a separate channel from action submission. Six tool primitives are exposed to LLM agents through the BDPD Agent SDK:

Tool Scope Used in pilot?
broadcast(text) all players yes
send_private(target, text) one player rarely
announce_intended_harvest(h) metric channel yes
propose_pact(parties, terms) pact registry optional
accept_pact(pactId) pact registry optional
pledge(maxHarvest) pact registry optional

pledge(maxHarvest) is a shorthand: it registers a unilateral harvest_cap pact binding only the caller, so a single-party self-commitment does not require constructing a full pact spec. Messages are routed by the MessageBus, surfaced into each agent’s prompt context as a structured inbox preceding the action prompt. The system prompt and tool schema preamble — which constitute the bulk of input tokens — are structurally stable across calls within a run, making them eligible for provider-side caching where supported.

2.3 Governance metrics

Five new metrics are registered with the existing METRIC_REGISTRY in engine/metrics.js. Four return fractions in \([0, 1]\); lie_score is in raw harvest units. All are computed per turn from arena history. The canonical formulas are mirrored from the platform documentation (docs/platform/metrics.md) and summarised in Table 2.1.

Table 2.1: Governance metric definitions
Metric Definition
cooperation_index \(\lvert \{i : \text{actual}_i \le \text{share} + \varepsilon \} \rvert / N\)
lie_score \(\tfrac{1}{|A|} \sum_{i \in A} \lvert \text{announced}_i - \text{actual}_i \rvert\)
announce_frequency fraction of (player, turn) pairs with non-null announced
silent_defection fraction with announced = null and \(\text{actual} > \text{share} + \varepsilon\)
sanction_rate sanctionCount / \(N\)

\(\text{share} = \text{regen} / N\) is the sustainable per-player harvest under the current regeneration rate, evaluated each turn; \(\varepsilon = 10^{-6}\) is a numerical tolerance so that a player harvesting exactly at the share counts as cooperating, not a tunable behavioural margin. \(A = \{i : \text{announced}_i \ne \text{null}\}\) is the set of players who announced. Four of the five metrics return a fraction in \([0, 1]\); lie_score is the exception and returns a mean harvest-unit gap, so its scale tracks the harvest cap of the active pact. Each metric returns null rather than \(0\) when its input data is unavailable. Two design choices are non-obvious and worth noting.

  • silent_defection is the diagnostic that ended up doing the empirical work below (see Section 3.2). It captures the failure mode in which an agent simply does not participate in announcement, while continuing to extract — a mode entirely missed by lie_score. The over-harvest threshold is the dynamic share, not a fixed cutoff.
  • sanction_rate uses nPlayers as the denominator. A value of \(1.0\) means every player was sanctioned this turn; typical enforcement values during a violation are \(1/N\) per fired sanction. The choice keeps the metric on the same population-fraction scale as the other four.

2.4 Sanction perturbation

Sanctioning is implemented as a perturbation of type sanction with a pact_violation trigger. Perturbations are arena-level side-effects already used in BDPD for shocks (capacity cap, regen nudge, etc.); we extend the trigger surface with pact_violation which fires per-violation and bypasses the standard fired-once-then-dedup semantics. A perturbation specification has the form:

{
  "id": "auto-sanction",
  "type": "sanction",
  "trigger": { "kind": "pact_violation", "termType": "harvest_cap" },
  "payload": { "amount": 3, "reason": "cap-breach" }
}

When fired, the perturbation deducts amount from the violator’s privateResource and emits a changes.sanction record into the turn log, picked up by the sanction_rate metric. The decision to treat sanction as a perturbation (rather than a new core action class) keeps the engine surface untouched and lets third parties write governance plugins — Yoon-style voluntary-sanctioning agents (Yoon and Armsworth 2025), graduated-sanction ladders, or arena-level governance agents — without modifying any engine file. This is the structural payoff of the engine-agnostic constraint.

A second perturbation type, sanction_graduated, implements Ostrom’s design principle #5 (graduated sanctions) as the same plug-in. Its payload replaces the scalar amount with a vector ladder indexed by violation ordinal:

{
  "id": "auto-sanction-graduated",
  "type": "sanction_graduated",
  "trigger": { "kind": "pact_violation", "termType": "harvest_cap" },
  "payload": { "ladder": [1, 3, 10], "reason": "cap-breach" }
}

At each pact violation the handler counts prior \((player, \text{termType})\) violations from the registry and selects ladder[min(k - 1, |\text{ladder}| - 1)] — the last entry sticks, so a \(k\)-step ladder defines bounded escalation. The constant-amount sanction of Section 3.1 and the graduated sanction_graduated of Section 3.3 are the same plug-in surface with different payloads, and both are engine-agnostic: neither touches engine/*.js.