# Contract v3.1: changes from v3

*W-contract-v31, 2026-09-27 morning. Brief: `briefs/W-contract-v31.md`. Review: `~/.openclaw/wiki/coordination/inbox/vesper/2026-09-27-bolo-contract-v3-review.md`. This is not a release. Nothing ships until Bolo reruns his adversarial probes against v3.1.*

## How to check this

```
python3 contract/validate.py               # exit 0: 92 passed, 0 failed (contract/VALIDATE.out)
python3 contract/validate.py --v3-compat   # exit 1 by design: 5 of 5 negative vectors fail on v3 (recorded at the end of VALIDATE.out)
```

"Before" means v3 exactly as Bolo reviewed it. `legacy-v3/` holds `projection_v3.py`, `companion-v3.schema.json` and `COMPANION-CONTRACT-v3.md`, and their sha256 values (`legacy-v3/SHA256`) are the same as the hashes in his `CONTRACT-REVIEW.json`. v3 had no code for pool lag or suspect prints, so `legacy-v3/coverage_v3.py` transcribes the v3 prose (quoted in its docstring) to give V40 and V41 a before. The validator runs every negative vector on both versions. A vector counts only if it passes on v3.1 **and** fails on v3.

**Vector ids.** The brief calls the new vectors V21 to V25, but v3 already uses V21 to V36. The 36 v3 vectors are unchanged (the first 36 entries are byte-equal to v3's), and the new ones are **V37 to V41**. Each carries `briefId` V21 to V25. That makes 41 in total.

I also ran Bolo's own probe (`contract-review-pass6.py`) against v3.1, as a copy that wrote only to `/tmp/v31probe/` and left his evidence root untouched. Result: `futureSafetyEvent` is *developing*, R5a, "No light is on now.", with one inadmissible note. `malformedReserveSchemaAccepted` is false. `missingCapabilitiesProjection` is *developing*, R5b, with both "support not reported" refusals. His probe uses the v3 curve names, which v3.1 also rejects because of `additionalProperties: false`. V38 therefore also runs the nonsense values under the v3.1 names.

## The five blocking findings

| Bolo item | File and line (v3 → v3.1) | Vector | Before (v3) | After (v3.1) |
|---|---|---|---|---|
| **1. Future or unavailable evidence** | `projection.py:107` (v3) checked only `age <= 60 min`. v3.1: `projection.py:102` `_unavailable`, `:119` gate over lit lights and the safety event, `:168` R1b also needs `age >= 0`. Output gains `inadmissible[]`. Schema `SituationState.inadmissible` at `schemas.py:286`. Contract §0.13, §3.7, §10 ("Availability gate"). | **V37** (brief V21): Bolo's probe, which is V01 plus a safety event dated 2099-01-01 against asOf 2026-09-27. | `safety_changed`, R1b, "Safety event: synthetic future event, -38006640 min ago." | `developing`, R5a, "No light is on now.", `inadmissible: [{safetyEvent, 2099-01-01T00:00:00Z, "evidence available after asOf; not admitted"}]`. Extra check `gate:future-light`: a lit light with a 2099 `litAt` is not admitted either. |
| **2. BondingCurve schema** | `schemas.py:155-161` (v3): six unconstrained `Field`s named `realSolReserve` and so on. v3.1: `schemas.py:187` `RawU64` (canonical u64 decimal string, regex tested against 20,000 random values and the boundaries, `unit` and `basis` required, basis `rpc`). `schemas.py:193` `BondingCurve` uses the IDL names (`virtualTokenReserves`, `virtualQuoteReserves`, `realTokenReserves`, `realQuoteReserves`, `tokenTotalSupply`, `complete`, `quoteMint`), plus `quoteIsNative`, `tokenDecimals`, `quoteDecimals`, `realQuoteReservesSol`, `decodedFrom`. Units are pinned per field, `complete` is required and boolean, and the SOL value is forced null unless `quoteIsNative` is true, which also pins `quoteMint` to wSOL and `quoteDecimals` to 9. The description cites `pump-idl.json` sha256 `ffe966c42f1af41652ee753fe2f1e3f7cd4077d7e6f49faf3138959c8b56064b` in Bolo's evidence root. `tokensLeftBeforeMigration` is removed (Bolo M2 item 5: the pre-migration count is `realTokenReserves`). `make.py` `no_curve`. Contract §1.1(e), §2.5. | **V38** (brief V22): the `nonsense-usd` case has every field set to `{value: {nonsense: true}, unit: usd}`. Also rejected: `complete-missing`, `sol-without-native-quote`, and `reserve-as-number` (a JSON `0` with `complete: true`). Accepted: `decoded-native`, and `decoded-quote-unverified` with the SOL value null and its reason. | The v3 schema accepts Bolo's nonsense object for every reserve and for `complete`. | All four bad cases are rejected and both good cases are accepted. No fixture has a decoded curve: Bolo's census is 0 of 161, so SPRING and AGRIPPA read `not a pump.fun pool` and MOSSY reads `pump.fun curve decode not live yet`, on all 12 fields. |
| **3. Missing capability is not true** | `projection.py:88, 95, 114, 125` (v3): `inp.get("capabilities", {...True})` and `caps.get(cap, True)`. v3.1: `projection.py:140-150`. `computable[cap] = v is True`. False keeps the known refusal (basis `chain_cannot_show`). Anything else, including absent, null or a non-boolean, refuses with basis `support_unreported` and "Holders/Tape: source support not reported; not computed." R2 at `:175` and R3 at `:186` read `computable`. Schema: `refusals[].basis` at `schemas.py:283`. Contract §3.1 (R2 and R3 now say "capability reported true"), §3.4, §10. | **V39** (brief V23): Bolo's probe. Capabilities removed, retention 90 to 20, callers lit 10 min ago. | `early_money_leaving`, R2, "…retention fell 70 points in the last 6 h, by RPC read." | `developing`, R5b, "One light on: a caller call, 10 min ago. Holders: source support not reported; not computed. Tape: source support not reported; not computed." Refused: early_money_leaving and crowd_arriving. The known-false rule is unchanged (V14, V17 and V18 still pass). |
| **4. PoolRow lag** | `schemas.py:151` (v3): `lagSec` = "Seconds between the chain head and our last row". v3.1: `schemas.py:160-185` `PoolRow` gains `window {from, to, bounds: "[from,to)"}`, `coveredBlock`, `sourceHead`, `lagSec` (head block time minus covered-cursor block time, never negative), `lastTradeAt` (kept separately), `seekCursor` (never counted as coverage), and `missingSpans[]` (dates, blocks and a reason; history is not healed by a seek). Code: `coverage.py:37` `pool_lag`. `make.py` `pool_row` builds every fixture pool row and computes `lagSec` through `pool_lag`. `validate.py:343` `check_v31` recomputes it from each row's cursors. SPRING carries `lagSec` 2 s (cursor 17:27:26Z, head 17:27:28Z) and `lastTradeAt` 17:26:58Z, all marked illustrative because block numbers are not in the T5 extract. Contract §2.5, §10 ("Pool coverage"). | **V40** (brief V24): (a) quiet pool, last trade 1 h before, cursor 4 s behind the head; (b) seek cursor after an archive refusal with no covered cursor. | (a) 3629 s (time since the last trade); (b) 119 s (a number, although nothing is covered). | (a) 4 s, with `lastTradeAt` separate; (b) null with "no covered cursor: reading resumed at a seek cursor after an archive refusal". |
| **5. §12.2.5 print flags** | Contract §6 "Suspect prints" and §12.2 item 5 (v3) used the repeat rate ("29% repeats on ticks") as phantom motivation and had one flag. v3.1: `coverage.py:69` `print_flags` has four flags, `unchanged`, `providerStale`, `phantom` (`true`/`false`/`unverified`/null) and `misquote`, each with its own denominator. A phantom needs both distinct neighbours **and** covered tape with no swap at that price within 60 s. With no covered tape the value is `unverified`, and only `phantom: true` removes a row. Contract §6 "Provider print flags" (table with the denominators), §12.2 item 5 rewritten. | **V41** (brief V25): prints 1.00, 0.40, 0.40 (same provider clock, a repeat), 1.02; tape covered only until 12:00:30Z. | The repeated 0.40 row is flagged phantom and removed. There is one flag, and no denominators. | Both 0.40 rows are `unverified` and nothing is removed. The repeat is `unchanged` and `providerStale` (1 of 3 each), the phantom denominator is 0, and 1 is unverified. Extra check `print:covered-phantom-removed`: the same prints with tape covered and no swap at 0.40 are phantom and removed, with a phantom denominator of 1. |

## Additive items (build-0 CHECKS.md §2, and Bolo M2 item 7)

| Item | Where | What |
|---|---|---|
| `SituationState.hero {field, reference}` | `schemas.py:291`; `make.py` `hero()`; contract §3.6 | The default table is build-0's `heroFor`. SPRING uses `holders.walletsBeforeReference` against `market.walletsIn1h`. AGRIPPA uses `holders.earlyCohortRetention` against `diffs[0].checkpoint.snapshot.measures.earlyCohortRetention`. MOSSY uses lights on against the lights this chain can show. The validator resolves the paths. |
| `state.lagReason` | `schemas.py:295`; contract §3 table | A Field. It holds the relationship id when there is a bracket (SPRING `rel-sp-lag`, AGRIPPA `rel-ag-lag`). Otherwise it is null with a reason: MOSSY has `no lag bracket: only one kind of event`. `relationships[]` stays an array, so the pages keep working. |
| `diff.materialityRule` | `schemas.py:308`; `make.py` `MATERIALITY_RULE`; contract §4 | The §4.2 rule in words, on every diff, next to `materialityVersion`. |
| `trader_thesis` on the money lane | `schemas.py` `EVENT_TYPES` (money gains it, attention loses it); `make.py` `ev-sp-f2207`, `ev-ag-f4418`, SPRING mini clock; contract §1.1(c), §6 | Decided: a thesis re-lights `traders` (a money light) and does **not** close an open attention bracket. SPRING's bracket stays open ("no attention event seen yet"), and the diamond now sits on the money lane. That is checked in the build-0 shot and by the validator (`trader_thesis off the money lane`). |
| `asOf` precision | contract §0.12, §12.2 item 13 | `asOf` is to the second and never rounded forward. SPRING was already 17:27:30Z in the fixture, as §12.2 item 4 says; build-0 had read it as minute precision. |
| Stable source-event discriminator (Bolo M2 item 7) | `schemas.py:125` `Event.sourceKey` (required), `:332` `situationId` pattern `…#openedAt~[0-9a-f]{8}`, `lifecycle.openingKey`; `make.py` `situation_id`; contract §0.14 | `sourceKey` = `source|sourceId`, or `source|sha256` when the source gives no id. The situation id suffix is sha256(opening `sourceKey`)[:8], so two reopenings in the same second get different ids. The validator checks the suffix, the `openingKey`, and that no two events in a situation share a `sourceKey`. SPRING's id was built from `providerAt` (16:28:59Z) in v3; it now uses `openedAt` = `availableAt` (16:29:05Z), as the contract always said (§12.2 item 14). |

## Other changes

- `meta.contractVersion` changed from `companion-v3` to `companion-v3.1`, and the schema `$id`s moved to `/contract/v3.1/`. `projectionVersion` changed from `p0-provisional` to `p1-provisional`, because the rule changed at the gate and at the capability default. None of the numbers in `PARAMS` changed. `materialityVersion` is unchanged.
- Bolo's answers to §12.1 are recorded as binding in contract §12.3.
- §10 gains 15 reason strings (refusals for unreported support, the gate, curve quote identity, pool coverage, hero). The catalogue check still passes.
- `validate.py` is extended as follows: `--v3-compat`, the negative-vector runners (projection, schema, pool_lag, print_flags), `check_v31`, `not` in the fallback checker, and a before and after block at the end of the output.
- build-0: the fixtures are copied byte for byte, and the shots were rerun with ALL OK. See `build-0/CHECKS.md`, section "v3.1 fixtures". No page or component was changed.

## Still open (not in this brief)

- Bolo M2 item 12: v3.1 constrains only the economic fields of `BondingCurve`. Every other event's `values` payload is still a generic `Field` map, so those are the next producer-side schemas.
- build-0 still uses its own hero table and does not print `materialityRule`. Reading the new fields is a page change.
- Fixture sha256 (v3.1): spring `00ce217e…`, agrippa `fdc7f2f2…`, mossy `4c28041b…`, now `83820d74…`, board `61f5e402…`.
