# Contract v3.2: changes from v3.1

*W-contract-v32, 2026-09-27 23:37 to 2026-09-28 early, Beirut. Brief: `briefs/W-contract-v32.md`. Review: `~/.openclaw/wiki/coordination/inbox/vesper/2026-09-27-bolo-contract-v3-review.md` (Pass12, and the Vesper answer of 09:50). Definitions: `~/.openclaw/wiki/coordination/inbox/bolo/2026-09-27-2335-vesper-ack-accepted-three-definitions-closed-since-seen-baseline-tilde-validity-history-start.md`. Freeze: `DESIGN-FREEZE-2026-09-27.md` §2 and pass 7 NOTES (b). This is not a release. Nothing ships until Bolo re-probes v3.2.*

## How to check this

```
python3 contract/validate.py                # exit 0: 121 passed, 0 failed (contract/VALIDATE.out)
python3 contract/validate.py --v31-compat   # exit 1 by design: 17 of 17 v3.2 negative vectors fail on v3.1
python3 contract/validate.py --v3-compat    # exit 1 by design: 5 of 5 v3.1 negative vectors still fail on v3
```

"Before" means v3.1 exactly as Bolo reviewed it at pass13. `legacy-v31/` holds `projection_v31.py`, `coverage_v31.py`, `companion-v31.schema.json`, `COMPANION-CONTRACT-v31.md` and `state-vectors-v31.json`. Their sha256 values (`legacy-v31/SHA256`) are the same as the hashes in his `PASS13-CONTRACT-REVIEW.json` (projection `f85ab54f…`, coverage `54816b0e…`, schema `877e72f4…`, contract `d41c7ae0…`, vectors `ec3665bb…`). v3.1 had no code for the Now fields, so `legacy-v31/nowat_v31.py` transcribes the v3.1 prose and the pass 7 prototype (quoted in its docstring) to give V47 to V54 a before, the same way v3.1 did with `coverage_v3.py`. A vector counts only if it passes on v3.2 **and** fails on v3.1. V37 to V41 (the v3.1 negatives) are byte-equal to v3.1, still pass on v3.2 and still fail on v3.

**Vector ids.** The brief calls the new vectors V26 to V30 and then V31 onward, but V21 to V41 are taken. The new ones are **V42 to V58**. Each carries `briefId`: V26 to V38 as the brief numbers them, and H1 to H4 for the hardening. That makes 58 in total.

**Bolo's own probe.** I ran `contract-review-pass12.py` against v3.2 as a copy that wrote only to `/tmp/v32probe/`, and left his evidence root untouched. It exits 0. `futureRetention` is `unknown`, R2u, reason `retention_unavailable`, with the cohort listed as inadmissible. `partialPhantomSupport` is `unverified`, nothing removed, phantom denominator 0. `uncoveredNonCandidate` is `unverified`, denominator 0. `inconsistentUnmeasuredHead` is null with `head_unmeasured`. Of the `clockBoundaries`, the naive clock is refused with `evidence clock has no UTC offset; not admitted` (v3.1 raised TypeError), and `+00:60` is refused with `evidence clock offset is not a valid UTC offset; not admitted`. `inconsistentSolConversionAccepted` is still `true`, because that probe asks the JSON Schema, and the schema does not do arithmetic (agreed at 09:50). The adapter refuses the same instance with `conversion_mismatch` (V58). His probe asserts `len(projection.STATES) == 6`, and that still holds: `unknown` is in `projection.OUTCOMES`, not in the six words.

## The four semantic blockers (Bolo pass12)

| Bolo item | File and line (v3.1 → v3.2) | Vector | Before (v3.1) | After (v3.2) |
|---|---|---|---|---|
| **1. Retention availability gate** | `projection.py:119-138` (v3.1) gated lights and the safety event only. v3.2: `projection.py:178-185`. When holders is reported true, an attention light is lit and a cohort is supplied, the cohort must pass `_unavailable(retention.availableAt, asOf)` (`:112`, now `clocks.admit`). If it fails, the output is `unknown`, rule `R2u`, `reason: retention_unavailable`, and the cohort goes into `inadmissible[]`. `OUTCOMES` is at `:45`. Schema: `SituationState.state` enum + `unknown`, `rule` pattern + `R2u`, `stateReason` (`schemas.py:268-270, 300`). Contract §3 table, §3.1 row R2u, §3.2 template, §3.7. Every earlier retention vector input gained `availableAt` (8 inputs; expectations unchanged). The fixtures' AGRIPPA retention carries the 17:36Z read's `availableAt` (`make.py:607, 621`), and `validate.reproject` passes it. | **V42** (brief V26): Bolo's probe. V39's input with holders and tape true, retention 90 to 20, `availableAt` 2099, a caller lit 10 min ago. Extra checks: `gate:retention-noclock` (a cohort with no clock is `unknown` too) and `gate:retention-admitted` (the same cohort available 11:55Z gives *Early money leaving*). | `early_money_leaving`, R2, "…retention fell 70 points in the last 6 h, by RPC read.", nothing inadmissible. | `unknown`, R2u, "State not computed: the early-cohort retention read is not available at this moment.", `inadmissible: [{retention, 2099-01-01T00:00:00Z, "evidence available after asOf; not admitted"}]`. |
| **2. Phantom absence needs interval coverage** | `coverage.py:56-62, 110-114` (v3.1) checked coverage at one instant but searched swaps ±60 s. v3.2: `coverage.py:92` `support(at)` = `[at − 60 s, at + 60 s)`. `:98` `interval_covered` requires the union of covered half-open intervals to contain the whole window. `:112` `_support_covered`. `:166` is the verdict. `_tape_near` now searches the same half-open window. Point coverage (`_tape_covered`) is used only for misquote, whose reference is a covered swap that exists. Contract §6 phantom row and the V43 note. | **V43** (brief V27): prices 1, 0.4, 1.02, covered only `[12:00:59, 12:01:01)`, no swaps. Extra: `print:interval-union` (two adjacent spans covering the window give `true`; a one-second hole gives `unverified`). | `phantom: true`, removed, phantom denominator 1. | `unverified`, nothing removed, denominator 0, unverified 1. |
| **3. Phantom denominator counts only covered candidates** | `coverage.py:107-109, 125-129` (v3.1) judged the price shape before coverage, so an uncovered non-candidate became a covered `false`. v3.2: `coverage.py:166-171` checks support coverage **first**. Any distinct observation with both neighbours and no covered support is `unverified`, whether or not it is a candidate, and stays outside the denominator. The documented denominator contract is unchanged; v3.2 implements it. Contract §6. | **V44** (brief V28): prices 1, 1.01, 1.02, no covered tape. Extra: `print:covered-noncandidate` (with covered support, the same row is a covered `false`, denominator 1). | `phantom: false`, denominator 1, unverified 0. | `unverified`, denominator 0, unverified 1. |
| **4. Lag needs a measured, consistent head** | `coverage.py:41-52` (v3.1) ignored `measuredAt` and block numbers. v3.2: `coverage.py:49` `pool_lag(row, as_of)`. If `measuredAt` is missing, malformed or after `asOf`, the result is `head_unmeasured` (`:61`). If the head block is below the covered block, it is `head_inconsistent` (`:68`). `:75` `head_age` gives freshness against `asOf`. Schema `PoolRow.headMeasuredAt`, `headAgeSec`, and the new `lagSec` text (`schemas.py:173-175`). `make.py:209` builds both. `validate.check_v31` recomputes lag and head age with `measuredAt` and `asOf`. Extra: `lag:head-after-asOf`. Contract §2.5 table. | **V45** (brief V29): `measuredAt` null. Its second case is Bolo's exact probe (both faults), and `head_unmeasured` is reported first. **V46** (brief V30): covered 101, head 100, measured. | `lagSec` 4 in all three cases. | null with `head_unmeasured` (V45, both cases) and `head_inconsistent` (V46). The fixtures keep their lags (SPRING 2 s) and gain `headAgeSec` (SPRING 1 s). |

## Hardening (Bolo pass12, same pass)

| Item | Where | Vector | Before (v3.1) | After (v3.2) |
|---|---|---|---|---|
| Offset-naive `availableAt` | `clocks.py:27` `parse` (`:35` naive). `projection.py:112` `_unavailable` → `clocks.admit`, and `_t` → `clocks.must`. Contract §0.15, §3.7, §10. | **V55** (H1): V01 plus a safety event at `2026-09-27T11:50:00` | raised `TypeError: can't compare offset-naive and offset-aware datetimes` | *Developing*, R5a, `inadmissible: [{safetyEvent, "evidence clock has no UTC offset; not admitted"}]` |
| Invalid offsets | `clocks.py:41`: minutes over 59, or anything outside `[-14:00, +14:00]`. Extra check `clocks:parse` (11 forms, including `+14:00` accepted and `-14:01` refused). | **V56** (H2): `2026-09-27T12:50:00+00:60`. **V57** (H3): `2026-09-28T02:20:00+14:30` | both normalised to 11:50Z and admitted: `safety_changed`, R1b, "…10 min ago." | *Developing*, R5a, `inadmissible: [{safetyEvent, "evidence clock offset is not a valid UTC offset; not admitted"}]` |
| Native-quote conversion | `adapter.py:22` `admit_curve`: exact decimal `derivedSol == raw / 10^9` when `quoteIsNative`, else `conversion_mismatch` (`:29, :35`). `validate.check_v31` runs it over every fixture curve. Contract §0.16, §2.5. | **V58** (H4): a schema-valid native curve with raw `1000000000` and SOL `999` is refused; `1` and `1.25` (for `1250000000`) are admitted | the v3.1 schema accepts it, and there is no adapter | refused, `conversion_mismatch`; the schema still accepts the shape (by design) |

## The frozen-Now fields (DESIGN-FREEZE §2, rows marked v3.2)

| Freeze row or definition | File and line | Vector | Before (v3.1) | After (v3.2) |
|---|---|---|---|---|
| **Contract §7 `NowRow`**: the frozen row, `now.asOf`, and the rule "Now at t" | Contract §7 rewritten (§7.1 to §7.6). `schemas.py:549` `NowRow`, `:577` `Now {asOf, window, historyFrom, rows[], orderRule, endLine}`. The v3.1 row is renamed `NowRowV31` and lives only in `now.sections` for build-0. `nowat.py:231` `now_at`, `:224` `row_at`, `:147` `state_at`. `make.py:1299` `now_v32`, `:1068` `spring_now_row`, `:1231` `proto_row`. | **V51** (brief V35): a row that surfaced at 11:45Z is absent at 11:30Z. **V52** (brief V36): a read at 11:10:00Z, available at 11:10:06Z, is absent at 11:10:03Z. Also `now-v32`: Now at asOf recomputes every stored row (state, latest, For you, order, cap, change, row order). | Six v2 elements with minutes-before-asOf marks. No t, no `surfacedAt`, no history. | The frozen row: identity, scale with bases, state with descriptor and Why here, sentence, Latest, clock (window, tape start, price reads, marks, share reads, readout), For you, `surfacedAt`, order bucket with reason. `waitingFor` is optional and not rendered. |
| **History start** (definition 3) | `schemas.py:577` `Now.historyFrom`. `nowat.py:44` `history_refusal`, `:231` refusal. Contract §7.1, §10, §12.4.3. | **V47** (brief V31): `historyFrom` 11:00:00Z; 10:59:59Z refuses, 11:00:00Z answers. `now-v32` also checks that 16:26:59Z refuses on the fixture. | Answers any moment. | `history not kept before 2026-09-27T11:00:00Z`. |
| **Contract §2.3 market cap** (definition 2, Bolo 11:20Z validity note) | `schemas.py:424` `Supply`, `:435` `MarketCapV32`. `nowat.py:113` `supply_valid_at`, `:123` `market_cap_at`. Contract §2.3 note, §7.2, §12.4.2. | **V48** (brief V32): a supply read available on 16 Sep is not valid on 14 Sep; `rpc_supply` inside its interval, `assumed_supply` after `validTo`. | Any supply read clears the mark at every moment (the prototype Boolean). | Validity at t only. SPRING is `assumed_supply` with `assumedSupply` 1e9, and its `supply` is null with `supply not read; market cap estimated, supply assumed`. |
| **Contract §2.3 change** (definition 1) | `schemas.py:441` `ChangeV32` (`firstSeenAt`, `firstSeenPrice`, `basis: since_first_seen`). `nowat.py:135` `change_at`. Contract §7.2, §12.4.1. | **V49** (brief V33): first read at 10:00Z (price 1) outside a window that starts 11:00Z; the window's reads are 1.5 and 2. | +33% (first read in the window). | +100% (first read ever). `window.start` and `firstSeenAt` are separate fields. |
| **Contract §2.3 price series** | `schemas.py:447` `PriceRead` (`basis` const `tape`, `source` pattern `^tape:`, `price` a full-precision decimal string). `nowat.py:62` `admit_price_read`, `:109` `reads_at`. Contract §7.2. | **V53** (brief V37): a DexScreener tick relabelled `basis: tape` is refused by the adapter and by the schema; a real tape read is admitted by both. V52 above covers availability. | `basis` is a label that nothing checks. | `provider tick is not a tape read; not admitted`. |
| **Situation store**: `surfacedAt`, state history, material events with `availableAt` | `schemas.py:498` `StateHistoryEntry`, `:503` `MaterialEvent` (anchor required). `nowat.py:79` `admit_material_event`, `:156` `latest_at`. `make.py:988` `derive_material`. Contract §7.2, §7.3. | **V54** (brief V38): a price move without an anchor is refused (adapter and schema); with its anchor it is admitted. | No anchor anywhere. | `material event names no anchor; not admitted`. Every event names `anchor {kind, at}` and `threshold`. Latest is the newest event with `changedWording: true`. |
| **Item-5 pin: entrants** | `schemas.py:516` `Wallets`. `nowat.py:95` `wallets_from_buys`. Contract §7.4. | **V50** (brief V34): five buys by three wallets. | Five entrants; bars 3 and 2. | Three entrants; bars 2 and 1. SPRING's bars (638, 121, 102, 78) and total (939) come from the T5 `buyers` set, which is distinct attributed buyer wallets since tape start (`scripts/T5-holders.py:112`). |
| **Contract §4 checkpoints**: display strings | Contract §4.3 table. `schemas.py:533` `ForYou`. `nowat.py:168` `for_you_at`, `:194` `for_you_text`. | `now-v32`: SPRING at 17:27:30Z reads `In · 2 material since entry · 17:11Z` / `3 since look`; at 16:57:30Z `No material change · since look 16:57Z`; at 16:42:30Z `Before your look` / `looked 16:57Z`; KESTREL reads `New to you`. | Only `changes` (a count, or "you have not looked at this room"). | The five fixed forms. Kinds stay `viewed \| watched \| entered`; the display words are look and entry. |
| **Catalogue §10** | Contract §10: new groups "Availability gate and clocks (v3.2)", "Adapter (v3.2)" and "Now (v3.2)", plus `head_unmeasured` and `head_inconsistent` under pool coverage and one fixture-only line. | `null-reason-catalogue`: 7 new fixture reasons found, all present | | Every string from the brief is there verbatim: `retention_unavailable`, `head_unmeasured`, `head_inconsistent`, `conversion_mismatch`, `history not kept before <clock>`, `supply not read; market cap estimated, supply assumed`, `not on our tape yet`. 14 more new strings are listed there too. |

## Fixtures

- `make.py` regenerates all five. `fixtures/now.json` `now.value` is the frozen Now at **2026-09-14 17:27:30Z**: `window` is [16:27:30Z, 17:27:30Z), `historyFrom` is 16:27:00Z (SPRING's tape start, the earliest clock the fixture holds). The rows are in this order: MARLIN, AGRIPPA, SPRING, LUMEN, OBOL, KESTREL.
- **SPRING (grade A)** uses only the reads already in the fixture. Prices are 0.0014216742830688294, 0.0010605857797392807, 0.001681516314997832 and 0.0026580796971611676 at 16:32, 16:42, 16:57 and 17:27Z, each with its own `availableAt` (+6 s). `firstSeenAt` is 16:32Z, the look is 16:57Z and the entry 17:11Z, and `marketCap.basis` is `assumed_supply` with `assumedSupply` 1e9. There is no supply read. `latestEntry` is null with `latest entry time not in the T5 extract`, and no history is earlier than the tape start. Derived: `~$2.66M`, `+87% since seen`, readout `$0.00266 · 939 entered · 1 profiled · none seen · top 20: 0.59 · safety not read`, Latest `first profiled trader · 17:08Z`. All of these are byte-equal to pass 7 NOTES (c). Rewinding gives `~$1.68M +18%` at 16:57, `~$1.06M -25%` at 16:42, and `~$1.42M` with no change at 16:32. At 16:57 the page shows no `2.66`, no MARLIN, KESTREL or OBOL, and no event after 16:57:30Z.
- **AGRIPPA, LUMEN, MARLIN, KESTREL, OBOL (grade C, `illustrative: true`)** are transcribed from `design-pass7-2026-09-27/d2-tracks/now.html` (PX, ROWS, the sentence and why functions). Their `situationId` is null with `no Situation page for this row`.
- SPRING's illustrative `entered` event moved from 17:10Z to 17:11Z (contract §12.2 item 18). The situation fixtures otherwise differ only in `contractVersion`, `projectionVersion` (`p2-provisional`), and the pool rows' `headMeasuredAt` and `headAgeSec`.
- build-0: `scripts/build0-fixtures.py` copied the fixtures byte for byte (`cmp` identical, all five). `build0-shots.cjs` exit 0, **ALL OK**, appended to `build-0/CHECKS.md` under "v3.2 fixtures". No page or component was changed. The one visible effect is from data: the v3.1 `sections` rows are now labelled "frozen …" against the new list `asOf`.
- sha256: spring `e5dfe96d36b3f6e7…`, agrippa `b41792017d71569b…`, mossy `59f151975e53d1a0…`, now `724869db98156a1f…`, board `39cf70410b2ea05a…`.

## Choices I made that you may want to overrule (contract §12.2 items 15 to 20)

1. **`unknown` only when holders is true.** The brief says a cohort is admissible only when available and holders is true, "otherwise unknown". When holders is not reported true, I kept the v3.1 refusal (V39 unchanged, *Developing* with "support not reported"). The known and unreported refusals already say more than `unknown` would. `unknown` also needs an attention light, because without one R2 cannot fire whatever the cohort says.
2. **The prototype's example rows break its printed materiality rule.** LUMEN's second and third caller channels and its cohort reductions are flagged material in the prototype but fall outside the six categories. Under the contract, LUMEN reads `1 material since look · 17:02Z` (prototype: 4), its Latest is the 17:03Z state change, and it sorts after SPRING (prototype: second). I kept the rule, left the page alone, and flagged it here (§12.2 item 16).
3. **Two sentences.** The Now row carries the frozen page's sentence and Why here. The Situation state keeps the §3.2 template sentence. They agree on the word, not on the wording (§12.2 item 15).
4. **Minute moments.** A page minute `HH:MM` is `HH:MM:00Z`. SPRING's reads become available 6 s after their block time, so at exactly 16:57:00Z the contract shows the 16:42 read, where the prototype shows 16:57 (§12.2 item 17).
5. **Price-move anchor.** I followed the brief (the previous price move, else `firstSeenAt`), not the 23:35 pin's "previous material event". On SPRING they agree. `anchor.kind` is an enum (§12.2 item 19).
6. **Names.** `assumed_supply` and `now.historyFrom` follow the brief; the 23:35 wording used `estimate` and `history.since` (§12.2 item 20).
7. **`now.sections` kept.** build-0 still reads the v3.1 Now shape, and changing pages was out of scope, so `sections` stays as optional `NowRowV31`. It is removed at admission.

## For the re-probe

Vector ids: **V42** (V26 retention gate), **V43** (V27 interval coverage), **V44** (V28 denominator), **V45** (V29 unmeasured head, includes your exact pass12 probe), **V46** (V30 inconsistent head), **V47 to V54** (V31 to V38: history boundary, supply validity, first-seen baseline, repeat buy, `surfacedAt > t`, `availableAt > t`, provider tick as tape, missing anchor), **V55 to V58** (H1 to H4: naive clock, `+00:60`, `+14:30`, conversion mismatch). V37 to V41 are unchanged.

```
cd /home/botbox/.openclaw/workspace/projects/caverio/companion-2026-09-26
python3 contract/validate.py                 # 121 passed, 0 failed, exit 0
python3 contract/validate.py --v31-compat    # 17 of 17 fail on v3.1 (legacy-v31/, your pass13 hashes), exit 1 by design
python3 contract/validate.py --v3-compat     # 5 of 5 fail on v3, exit 1 by design
```

The same review command shape works: `contract-review-pass12.py` (or a pass14 copy) runs unmodified against v3.2 and exits 0. It still reports `inconsistentSolConversionAccepted: true`, because it asks the schema; the adapter answer is V58. New modules to read: `clocks.py`, `adapter.py`, `nowat.py`. Validator sections 8 and 9 are new.

## Still open (not in this brief)

- History persistence before any live rewind claim: v3.2 defines the boundary and the refusal. Whether the store persists per-tick history is Bolo's item 4 (workspace-gnwh), and no live `historyFrom` exists yet.
- Bolo M2 item 12: only `BondingCurve`, `PriceRead`, `Supply` and `MaterialEvent` have typed economic fields. Other event `values` payloads are still generic.
- build-0 does not render the frozen Now fields. That is the G2 page change.
- The Situation's `market.provider.marketCap` and `changeFromFirstSeen` still use the v3.1 shape. Moving them to `MarketCapV32` and `ChangeV32` is the next additive step.

Status: DONE
