# Probing witnesses: decoys, the catalogue, and the ledger [Decision 0007](decisions/0007-a-witness-is-paid-for-the-watch-never-for-what-it-saw.md) pays a witness on three measurements, and one of them — accuracy — needs a ground truth that no real log can supply. This is the operator's guide to manufacturing it: `service/decoyd.py`, a log that exists to be probed. It is built. What it has not done is run against a witness anybody else operates, and nothing below should be read as though it had. [Decision 0004](decisions/0004-one-author-plays-every-role.md) governs that claim as it governs every other. ## Why the probe is a decoy and not a real log A witness exists to refuse three things: a rollback, an equivocation, a rewritten history. To measure whether it does, something has to present them. A real log cannot — it is append-only, and staging a rollback on it would *be* a rollback; serving one witness a different history from the rest *is* equivocation, performed by the operator. So the log that misbehaves is one that carries nothing, and the witness is never told which that is. A decoy is indistinguishable by construction. Its public listener is `logd.application` over a log — not a copy of the route table, the same function — so there is no route a witness can probe for and no `role` other than `log` in its health answer. Its entries are digests, like every entry; a commitment carries a digest and never a measured value, so there is no content to tell a decoy by. It is fed at production cadence, followed by a witness the same way as every other log, and honest for a warm-up at least as long as would let a witness without durable state forget its earliest checkpoint. Then, once, at a tick drawn from a secret, it does one of the things in the catalogue. ## The catalogue, which is published | event | what the log does | correct response | | --- | --- | --- | | `growth` | extends the tree; the consistency proof from the witness's last size verifies | cosign | | `rollback` | presents a smaller tree than the witness last cosigned | refuse, 409 | | `equivocation` | presents a second, different root at a size the witness already cosigned | refuse, 409 | | `rewrite` | rewrites an entry it already published, then grows | refuse, 409 | Every warm-up tick is `growth`. The three faults are exactly the refusals `scripts/check_witness.py` already tests, so a witness that passes the harness knows precisely what it will be scored on and never when or on which log. A refusal with any status other than 409 is not a correct refusal: the request was well formed, the *state* was the problem, and a witness that fell over is not one that refused. The catalogue carries a version (`decoyd.CATALOGUE_VERSION`), written into every ledger. A new event means a new version, and a ledger line is contested against the catalogue that was in force when it was written — the same discipline `rulesets/v1.py` imposes. ## The schedule, which is not A decoy's schedule — which fault, at which tick — is drawn by HMAC from the decoy's secret and its name. Reproducible from those two things for the disclosure audit; derivable from nothing else. `DecoySpec.warmup` is the honest run; `spread` is how many ticks past it the fault may land in. One fault per warm-up period is the order of magnitude the decision names, so that misbehaviour is too rare to be a signature of a decoy. The secret is a 32-byte file, the same kind a witness key is, named under `secrets` in the deployment description. Outside a demonstration a decoy without one is refused, for the reason a witness without one is: a schedule derived from a public name is a schedule every witness can derive. A demonstration may pin `fault` and `fault_at` instead; a real decoy draws. ## Running one The probe operator adds a decoy to their deployment description: ```json "decoys": [ { "name": "decoy-1", "log": "contubernium.log.7", "warmup": 48, "spread": 48, "cadence_seconds": 3600, "prober_host": "127.0.0.1" } ], "ports": {"decoy-1": 8910, "contubernium.log.7": 8911}, "secrets": {"decoy-1": "/srv/contubernium/decoy-1.key"} ``` and starts it like any node: ``` python -m contubernium.service.run --config deploy.json --node decoy-1 ``` That is **one process with two listeners**. The log answers on `ports["contubernium.log.7"]`, bound to the host the rest of the deployment binds to, and that URL is what goes into the published set. The prober — tick, ledger, disclose — answers on `ports["decoy-1"]`, bound to `prober_host`, loopback by default. An operator endpoint reachable from where the log is reachable would be a tell. Starting the decoy's log by its own name is refused: it starts with its prober or not at all. With `cadence_seconds` set the decoy ticks itself: each tick the synthetic publisher submits one digest and every witness the deployment believes in — its own and the external ones under `external_witnesses` — is asked to cosign, the way a reader asks: by log identifier, for the latest checkpoint. With it at zero, `POST /tick` on the prober does the same by hand, which is what a test wants. The prober's interface: | method | path | what it does | | --- | --- | --- | | `GET` | `/health` | `role: decoy`, the log id, `state` (`warming`, `faulted`, `disclosed`), the tick count | | `GET` | `/catalogue` | the catalogue above, versioned | | `POST` | `/tick` | one production-cadence step; 409 once the fault has fired | | `GET` | `/ledger` | every event so far, scored; the schedule is withheld until disclosure | | `POST` | `/disclose` | publish the ledger and burn the decoy; 409 before the fault has fired | The ledger is on disk — one JSON line per event, fsynced before the tick is acknowledged — and a restarted decoy picks up where it stopped. The log's entries persist beside it. ## What the witness operator does Follow the whole published set. A witness that follows only the logs its readers care about cannot be measured and is not on the roster. In the witness's own description the set is `follows`, log identifier to URL, and nothing in it says which entries are decoys because nothing in it knows: ```json "log": "contubernium.log.v1", "log_url": "https://log.contubernium.io", "follows": { "contubernium.log.7": "https://log7.contubernium.io", "contubernium.log.9": "https://log9.contubernium.io" } ``` The cost is one small process per log. A witness that simply verifies every consistency proof it fetches needs to know nothing else, which is the equilibrium the decision rests on: verification is a hash computation, distinguishing is guesswork against a penalty, and the honest strategy is the cheap one. ## Disclosure, and the score When a decoy has fired its fault, the operator discloses it: ``` curl -X POST http://127.0.0.1:8910/disclose > results/probe-ledger-decoy-1.json ``` The ledger names the decoy, the log, the schedule, the catalogue version, every event with every witness's observed response and latency, and the scores. A witness audits it against its own record — every refusal it made was a signed 409 with a reason, every cosignature is on the log — so the score is checkable rather than asserted. A disclosed decoy is burned: every endpoint of its log answers 410 with a sentence saying why. Scores pool across every ledger in a period: ``` python scripts/probe_score.py results/probe-ledger-*.json python scripts/probe_score.py results/probe-ledger-*.json --roster roster.json > roster-scored.json python scripts/witness_pay.py roster-scored.json ``` Two rates, both reported: **refusal accuracy** — correct refusals over faults presented — and **assent accuracy** — correct cosignatures over honest growth presented. The probe score is the lesser. A witness that refuses everything scores 1.0 and 0.0; a rubber stamp scores 0.0 and 1.0; neither is paid, which is the symmetry the decision requires. A witness that has been presented no fault has an *undefined* refusal accuracy, not a perfect one, and the gate treats undefined as unmet. **Reliability** is fetches answered over fetches made, and **responsiveness** is the mean cosign latency over those answered; both are in the ledger and neither is the availability figure the pay formula gates on, which comes from the fetch log. `tests/test_decoy.py` runs the pipeline against a rubber stamp, a refuse-everything witness, one that refuses with the wrong status, and one that is absent, and asserts each scores as it should. A pipeline that only ever scored 100% would be a formula with a hole in it. ## What this does not settle The decision's open questions stay open, and running this is how they get answered rather than argued: - **Traffic fingerprinting.** A witness sees who asks it for cosignatures. A decoy's prober asks at the cadence a reader would, but whether that is enough, and what the hosting side channel gives away — a prober on loopback is not a prober nobody can find — is to be learned against witnesses that exist. - **The fault rate and the decoy count.** `warmup` and `spread` default to two days of hourly ticks each. Tuned against measured witness behaviour, not here. - **Post-hoc detection on real logs** as a second accuracy signal. Not implemented; the decision leaves whether it enters the score open. - **Staggered ages.** The pipeline keeps decoys at staggered ages so one is always warm. Each decoy is one entry in `decoys`; staggering them is the operator's schedule, and nothing here automates it yet.