# FableTest Scenario DSL — Specification v1 This document is **normative**. Scenario YAML files under `scenarios/` must conform to it; `fabletest validate` enforces it; `fabletest/model.py` implements it. Where code and this document disagree, this document wins and the code is the bug. ## 1. File layout A scenario file is a YAML document with a single top-level key: ```yaml scenarios: - id: FC-001 ... - id: FC-002 ... ``` One file per taxonomy family by convention (`scenarios/faction-capture.yaml`, etc.), but the harness loads every `*.yaml` under `scenarios/` and treats the corpus as flat. ## 2. Scenario object | Field | Type | Required | Meaning | |---|---|---|---| | `id` | string, `^[A-Z]{2}-\d{3}$` | yes | Globally unique. Two-letter family prefix + sequence. | | `title` | string | yes | One-line human name. | | `family` | enum | yes | One of the seven taxonomy keys (§7). Must match the file's family. | | `severity` | enum | yes | `critical` \| `high` \| `medium` \| `low`. Critical = constitutional capture or irreversible harm. | | `tags` | list[string] | no | Free-form search keys. | | `precedent` | string (multiline) | yes | The historical or game-theoretic event this scenario encodes. Cite the real thing. | | `setup` | object | yes | Initial world state (§3). | | `moves` | list[Move] | yes | The attack, as a sequence of *attempted legal moves* (§4). | | `expected` | object | yes | The verdict the constitution must produce (§5). | | `empathy` | object | yes | Who is worst off and why the floor is set where it is (§6). | ## 3. Setup ```yaml setup: actors: - id: majority # unique within scenario kind: faction # faction | member | office | external members: 51 # voting members represented (kind=member implies 1) objective: capture # capture | drain | entrench | suppress | deadlock | defend | bystander attributes: # optional free map the engine understands holds_office: tallier resources: treasury: 100000 # fungible commons, integer units parameters: {} # overrides applied on top of constitution/parameters.yaml; # empty means "test the constitution as configured" ``` Total membership is the sum of `members` over non-`external` actors. The engine derives quorum denominators, thresholds, and per-capita treasury shares from this. `objective` is metadata for the empathy metric and reporting; it grants no powers. Attackers win or lose strictly by what the rules let their moves do. ## 4. Moves A move is `{actor, action, args}`. Every move is an **attempted** action; the engine adjudicates it as `legal` (state changes) or `refused` (state does not change, the refusing rule is recorded). Scenarios attack with sequences of moves that are each individually plausible — the whole point is that attacks are made of legal-looking steps. ### Action vocabulary | Action | Args | Semantics | |---|---|---| | `propose` | `id` (e.g. `p1`), `kind` (`amendment`\|`motion`\|`spend`\|`admission`\|`expulsion`\|`emergency`\|`interpretation`\|`convention`\|`schedule`), `layer` (`kernel`\|`userland`, amendments only), `target` (rule-id), `summary`, `payload` (kind-specific map), `bundle` (list of targets, if >1 subject), `notice_days` (int), `retroactive` (bool, default false) | Creates a proposal. Notice clocks start. | | `vote` | `proposal`, `yes`, `no`, `abstain` (member counts) | Casts ballots. Engine checks franchise, vesting, delegation caps, one-person-one-vote. | | `tally` | `proposal` | Closes voting; engine applies quorum + threshold and ratifies or rejects. | | `certify` / `refuse_certify` | `proposal` | Certification step by an office-holding actor. Refusal of a valid tally triggers automatic certification under `kernel.A8`. | | `transfer` | `from`, `to`, `amount`, `authorization` (proposal id) | Moves treasury funds. Checked against caps and authorization. | | `admit` | `sponsor`, `count` | Adds members. Checked against admission rate limits and vesting. | | `expel` | `who`, `authorization` | Removes a member. Checked against expulsion threshold, due-process delay, rate limit. | | `declare_emergency` | `scope`, `duration_days` | Opens an emergency. Checked against `kernel.A5` limits. | | `renew_emergency` / `end_emergency` | `emergency` ref | Renewal counts against `max_renewals`; renewal requires its own vote at `renewal_threshold`. | | `delegate` / `revoke_delegation` | `from`, `to`, `count` | Proxy voting. Checked against `params.delegation.max_per_delegate`. | | `schedule_vote` | `proposal`, `notice_days` | Sets the voting window. Checked against minimum notice and accessibility window. | | `boycott` / `return` | — | Actor's members withdraw from / rejoin quorum counting per the kernel's quorum rules. | | `challenge` | `proposal` or `move`, `grounds` (rule-id) | Invokes interpretation/dispute resolution (`kernel.A8`). | | `fork` | — | Actor exercises the exit right (`kernel.I3`): leaves with pro-rata treasury share and a copy of the text. | | `advance_time` | `days` | Moves the clock (notice periods, due process, emergency sunsets, cooling-off windows elapse). | ### Determinism Scenarios are deterministic: same text + same parameters + same moves ⇒ same verdict. There is no randomness in the engine. (Stochastic agent self-play is Milestone #5; its job is to *discover* sequences which then get frozen here, deterministically.) ## 5. Expected verdicts ```yaml expected: verdict: blocked # blocked | succeeds | deadlock | fork blocked_by: kernel.A3.kernel_supermajority # required iff verdict == blocked worst_off_floor: 0.90 # empathy assertion, always required notes: optional free text ``` - `blocked` — the attack's pivotal move is refused; the recorded refusing rule must match `blocked_by` (first rule violated wins; defense-in-depth scenarios assert the *first* layer). - `succeeds` — used for **legitimacy controls**: sequences that look procedurally similar to attacks but are healthy governance and *must be allowed*. A constitution that blocks everything is also broken. - `deadlock` — the final state is a live-locked institution (used by the `procedural_deadlock` family, usually asserting the kernel's deadlock-breaker fired or, in failure cases, must fire). - `fork` — the lawful terminal state is a clean exit under `kernel.I3`. A scenario **passes** when the engine's verdict equals `expected.verdict`, `blocked_by` matches (if applicable), **and** the computed worst-off welfare ≥ `worst_off_floor`. Failing any of the three fails the scenario and blocks the PR. ## 6. Empathy block ```yaml empathy: worst_off: minority # actor id expected to be worst off rationale: > Why this floor: what the worst-off party stands to lose, and what residual harm is tolerated even when the defense holds. ``` See `docs/empathy-metric.md` for the welfare model and the floor calibration table. ## 7. Family keys `faction_capture` (FC), `treasury_drain` (TD), `quorum_manipulation` (QM), `emergency_ratchet` (ER), `definitional_ambiguity` (DA), `minority_suppression` (MS), `procedural_deadlock` (PD). ## 8. Rule-id namespace Verdicts reference rules by stable ids: - `kernel.A1` … `kernel.A10` — kernel articles (membership & franchise; proposals & deliberation; ratification & semver; quorum; emergency powers; treasury & commons; rights floor; interpretation & enforcement; right to fork; entrenchment & meta-rules). Sub-clauses use dotted suffixes, e.g. `kernel.A2.single_subject`, `kernel.A10.cooling_off`. - `kernel.I1` … `kernel.I7` — invariants: **I1** equal franchise (one person, one vote, inalienable); **I2** no retroactivity; **I3** inviolable exit; **I4** no permanent emergency; **I5** due-process floor; **I6** no self-dealing rule changes (rules in force when a vote is scheduled govern that vote; denominators freeze at scheduling); **I7** worst-off floor (the empathy invariant, enforceable in itself). - `params.` — entries in `constitution/parameters.yaml`, e.g. `params.amendment.kernel_threshold`, `params.quorum.floor`, `params.emergency.max_duration_days`, `params.expulsion.due_process_days`, `params.admission.rate_limit`, `params.admission.voting_vesting_days`, `params.delegation.max_per_delegate`, `params.treasury.transfer_cap`, `params.amendment.notice_days`, `params.voting.window_hours`. ## 9. Minimal complete example ```yaml scenarios: - id: FC-000 title: Example — bare majority cannot touch the kernel family: faction_capture severity: critical tags: [example] precedent: > Synthetic baseline. A 51% bloc attempts a kernel change at simple majority. setup: actors: - {id: majority, kind: faction, members: 51, objective: capture} - {id: minority, kind: faction, members: 49, objective: defend} resources: {treasury: 100000} parameters: {} moves: - {actor: majority, action: propose, args: {id: p1, kind: amendment, layer: kernel, target: kernel.A3, summary: "Lower kernel threshold to 0.51", notice_days: 14}} - {actor: majority, action: advance_time, args: {days: 14}} - {actor: majority, action: vote, args: {proposal: p1, yes: 51, no: 49, abstain: 0}} - {actor: majority, action: tally, args: {proposal: p1}} expected: verdict: blocked blocked_by: params.amendment.kernel_threshold worst_off_floor: 0.95 empathy: worst_off: minority rationale: > The minority loses nothing but the stress of the attempt; the defense holds at the threshold check, so welfare stays near baseline. ```