# Contract v3.9: changes from v3.8

*W-contract-v39, 2026-09-28 21:00 Beirut onward. Brief: `briefs/W-contract-v39-exact-product.md` (Sprint V21b of `SPRINT-CHAIN-2026-09-27.md`, chained after V21, contract v3.8, and before V22, build-8, resumes). Found by the build-8 worker, attempt 1, at 20:57 (`build-8/CHECKS.md` §1): `nowat.py`'s `admit_price_read` took choice 23's product in Python's default 28-digit decimal context while `js/nowat.js` took the exact BigInt product, so for any product wider than 28 significant digits the two readers admitted opposite values. Every one of the 44 STONK reads the pinned CoinGecko point covers is that wide, so no stored value was admitted by both and build-8 could not assemble. v3.9 changes one line and adds the vectors that would have caught it. No record type, no key, no rename, no schema change, no stamp move. This is not a release.*

## How to check this

```
cd /home/botbox/.openclaw/workspace/projects/caverio/companion-2026-09-26
python3 contract/validate.py                     # exit 0: 182 passed, 0 failed (contract/VALIDATE.out; v3.8: 177)
python3 contract/validate.py --v38-compat        # exit 1 by design: V100 and V101 fail on v3.8; the pin V102 passes on both
python3 contract/validate.py --v37-compat        # exit 1 by design, byte-identical to v3.8's output (and --v36, --v34, --v33, --v32, --v31, --v3)
python3 contract/validate.py --doc build-7/data/real/solana-now.json   # exit 0, document: OK (output byte-identical to v3.8's)
python3 contract/validate.py --doc build-7/data/real/bsc-now.json      # exit 0, document: OK (output byte-identical to v3.8's)
node contract/js/parity-v39.mjs                  # exit 0: 33 of 33 cases, 12 of 12 now_at runs, 4 of 4 STONK sections; writes contract/PARITY-v39.out
node contract/js/parity-v38.mjs                  # exit 0, PARITY-v38.out byte-identical after the rerun
node contract/js/parity-v37.mjs                  # exit 0 (it rewrites PARITY-v37.out; see Verification)
python3 build-8/scripts/defect-exact-product.py && node build-8/scripts/defect-exact-product.mjs
                                                 # exact: admitted in both; prec28: conversion_mismatch in both
(cd contract/legacy-v38 && sha256sum -c SHA256)  # 16 of 16 OK
find build-* design-pass* -newer contract/legacy-v38/SHA256 -type f   # no output
```

## Freeze and hashes

"Before" means v3.8 exactly as it stood at 21:02, after the precondition check (`CHANGES-v3.9.md` absent; `pgrep -af 'run-worker[.]sh' | grep -c -E 'build7|build8|contract-v38'` printed 0) and before the first edit. `legacy-v38/` holds the files `legacy-v37/` holds, in the same naming: `nowat_v38.py`, `clocks_v38.py`, `adapter_v38.py`, `schemas_v38.py`, `projection_v38.py`, `coverage_v38.py`, `board_v38.py`, `validate_v38.py`, `vectors_v38.py`, `companion-v38.schema.json`, `now-v38.schema.json`, `COMPANION-CONTRACT-v38.md`, `state-vectors-v38.json`, `now-v38.json` (the v3.8 `fixtures/now.json`), `VALIDATE-v38.out` (the v3.8 `VALIDATE.out`) and `nowat-v38.js` (the v3.8 `js/nowat.js`). `legacy-v38/SHA256` lists all 16, relative to that folder. Key hashes: nowat `80f64245…` (the hash `build-8/CHECKS.md` names), clocks `d56ef6b8…` (unchanged since v3.4), JS reader `998665dd…`, contract `f6951965…`, vectors `5e4578fe…`, VALIDATE `23f8971c…`, schemas `aeefae9c…` and `7ebe24dd…` (v3.7's, unchanged).

A v3.9 vector counts only if it passes on v3.9 **and** fails on v3.8, except the pin (V102), which restates a kept rule and must pass on both. V01 to V99 are byte-equal to v3.8 (`v38-vectors-unchanged`).

## Item 1. The fix (`admit_price_read`, §12.2 choice 23)

| | File and line (v3.9) | Before (v3.8) | After (v3.9) |
|---|---|---|---|
| **The product** | `nowat.py:265` | `return None if Decimal(amount) * Decimal(conv["quote"]) == Decimal(value) else R_CONVERSION`: the multiplication runs in the default context (28 significant digits, ROUND_HALF_EVEN), so a product wider than 28 digits is rounded before the comparison | `return None if _EXACT.multiply(Decimal(amount), Decimal(conv["quote"])) == Decimal(value) else R_CONVERSION`: `_EXACT` is `Context(prec=200)` (`nowat.py:147`), the context `market_cap_at` already multiplies under. Decimal comparison is exact, so nothing else on the line changes |
| **JavaScript** | `js/nowat.js:265` | `decEq(decMul(dec(amount), dec(conv.quote)), dec(value))`, the exact BigInt product | unchanged; `js/nowat.js` is byte-equal to `legacy-v38/nowat-v38.js`, so build-8 copies the same file |
| **The contract sentence** | §12.2 choice 23, **v3.9** | "requires `usd.value == native.amount x conversion.quote` as exact decimals" | adds: the product is computed without rounding in both readers (Python under the 200-digit context `_EXACT`, JavaScript as a BigInt product); a value that equals the product only after rounding is `conversion_mismatch` (V100 to V102) |
| **The header** | `nowat.py` docstring; CONTRACT title line and header note; §11 | v3.8 | a v3.9 paragraph in each. `nowat.py` differs from `legacy-v38/nowat_v38.py` in the header docstring and line 265 only (`exact-v39` checks it with a diff) |

The STONK read at `2026-09-26T10:00:18Z`: native `0.0003515628686154278` (19 significant digits) x quote `119.94072152604973` (17) = `0.042166704123502234168615068024494`. The brief and `build-8/CHECKS.md` call that 35 significant digits; it is 32 significant digits (33 decimal places). The conclusion is the same: wider than 28.

| `usd.value` stored for that read | v3.8 Python | v3.8 JS | v3.9 Python | v3.9 JS |
|---|---|---|---|---|
| exact, `0.042166704123502234168615068024494` | `conversion_mismatch` | admitted | admitted | admitted |
| 28-digit rounding, `0.04216670412350223416861506802` | admitted | `conversion_mismatch` | `conversion_mismatch` | `conversion_mismatch` |

## Item 2. The audit: every default-context expression with a JavaScript mirror

Python's default context rounds the result of `+`, `-`, `*`, `/` and `create_decimal` to 28 significant digits; the `Decimal()` constructor from a string and every comparison are exact. Each row below is an arithmetic expression in the contract's Python readers, what JavaScript does, whether the two readers can refuse or admit differently (an admission) or print a different figure, and the verdict. Only an admission is fixed; a figure that could differ only past the digits `float()` or the cent keeps is noted and left, since no page prints more than those.

| # | Python (v3.9 line) | JavaScript | Can the readers disagree? | Verdict |
|---|---|---|---|---|
| 1 | `admit_price_read` `native_x_provider` product, `nowat.py:265` | `js/nowat.js:265`, exact BigInt | **Yes, on an admission** (v3.8): any product wider than 28 digits; all 44 covered STONK reads | **Fixed** (item 1; V100, V101, V102) |
| 2 | `admit_price_read` `tape_usd` equality `Decimal(amount) != Decimal(value)`, `nowat.py:252` | `js/nowat.js:256`, `decEq(dec(amount), dec(value))` | No. No arithmetic: both sides are constructed from strings, exactly, and compared exactly | Leave |
| 3 | `flow_from_swaps` totals `sum(Decimal(quoteAmount) * Decimal(priceUsd))` then `_cents`, `nowat.py:391` | none: the writer's side of §7.7; JavaScript reads the stored cents (`flowAt`) | No, between readers: only Python writes the figure and both read the same stored cents. The written cent can differ from the exact sum's cent only when a product or partial sum is wider than 28 digits **and** lies within one part in 10^28 of a half cent | Leave; noted under Still open for B10's writer |
| 4 | `flow_net` `Decimal(str(buyUsd)) - Decimal(str(sellUsd))` then `float`, `nowat.py:439` | `js/nowat.js:427`, `decFloat(decSub(...))`, exact then `Number` | Not at any real size: two cent values subtract exactly while the result has 28 or fewer digits (up to about 10^26 usd). Past that, only the last binary digit of a printed float | Leave |
| 5 | `market_cap_at` rpc_supply `_EXACT.multiply(supply_tokens(s), p)`, `nowat.py:568` | `js/nowat.js:368`, exact BigInt then `Number` | No: exact on both sides since v3.7 | Leave |
| 6 | `market_cap_at` estimate `p * Decimal(str(assumedSupply))` then `float`, `nowat.py:569` | `js/nowat.js:369`, exact then `Number` | Not on an admission, and never printed: §7.6a prints `cap not read` for every basis other than `rpc_supply` (only the legacy `mc_text` would print it, as a tilde figure of 3 to 4 digits). Python rounds to 28 digits before `float`, JavaScript rounds once, so the two floats can differ in the last binary digit when the product is wider than 28 digits and the 28-digit value falls on a binary midpoint (values of about 10^10 and up) | Leave; see also Still open 1 (a null `assumedSupply` raises in both) |
| 7 | `change_at` `(ur[-1] / base - 1) * 100` then `float`, `round`, `nowat.py:586` | `js/nowat.js:383-385`: `decDiv` (28 digits, half-even, sticky remainder), `decRound(..., 28)` after the subtraction and after `* 100`, then `Number`, `pyRound` | No: JavaScript reproduces the 28-digit context step by step (each Python operation is one correctly rounded result; each JS step is the same rounding of the same exact value). The output is a whole percent | Leave (emulated on purpose) |
| 8 | `price_text` `Context(prec=3, ROUND_HALF_UP).create_decimal(us[-1])`, `nowat.py:810` | `js/nowat.js:575`, `decRound(d, 3, 'up')` | No: both round the exact stored value once to 3 digits, half up. `us[-1]` is `Decimal(value)`, exact | Leave |
| 9 | `price_moves` `_usd_static(r) / base_p - 1` against 0.25, `nowat.py:488-489` | none: `price_moves` is the writer's (make.py's) rule; JavaScript reads stored material events | No, between readers. Python alone can put a move within one part in 10^28 of the 25% threshold on either side | Leave |
| 10 | `board.entry_cap_at` `p * Decimal(amount) / 10^decimals` and `p * Decimal(str(sup))`, `board.py:67`, `69` | none: Board's JS read model is not written; `entryCap` is admitted for `board.json` only when it is | No, between readers. Its USD comes from `nowat.usd_at` behind `reads_at`, so item 1 fixes which reads it sees. The product itself rounds to 28 digits before `float` (not `_EXACT`, unlike `market_cap_at` since v3.7): a last-binary-digit difference from `market_cap_at` at most | Leave; noted under Still open for Board's read model |
| 11 | `adapter.admit_curve` `Decimal(raw) / LAMPORTS_PER_SOL`, `adapter.py:49` | none | No: a u64 over 10^9 has at most 20 digits and divides exactly | Leave |

No other expression in `nowat.py` runs Decimal arithmetic: `_cents` quantizes an exact sum; `supply_tokens` scales under `_EXACT`; `usd_at` and `_usd_static` return `Decimal(value)`; clocks are integers. So item 1 is the only fix.

## Item 3. Vectors (V100 to V102)

`vectors.py` `neg39` (after `neg38`; helpers `_stonk_row`, constants `_SN`, `_SQ`, `_SX`, `_S28`, `_SCV`, `_T39`), `state-vectors.json` (102 vectors; its `contract` field stays `companion-v3.7`), `validate.py`: `N38` (legacy-v38 bound to its own clocks), `NEG39`, `--v38-compat`, `negative-set-v39`, `v38-vectors-unchanged`, `check_v39` (`exact-v39`), and op `usd_fields` now also returns `usd`, the last read's USD at `t` as written (absent keys are not compared, so V01 to V99 read the same). Each carries `briefId` and `since: v3.9`; V102 also `pin: true`.

The read: STONK at `T` = `2026-09-26T10:00:18Z` (available `T + 2 s`), native `0.0003515628686154278` SOL; conversion `provider:coingecko`, quote `119.94072152604973`, `at` `2026-09-26T10:00:00Z`, `availableAt` `2026-09-28T17:14:43.362Z` (build-8's pinned point and fetch). The supply: raw `814418335339229805`, 9 decimals, block 451332349 (the STONK read), observed, available and valid from `2026-09-28T17:15:00Z` to `18:15:00Z` so that it is valid at `t` = `2026-09-28T17:20:00Z` (build-7's own read is valid only 13:27:37.514Z to 13:27:45.474Z).

| Id | briefId | What it pins | v3.9 | v3.8 (legacy-v38/) |
|---|---|---|---|---|
| V100 | I3-stonk-exact | `usd.value` the exact product `0.042166704123502234168615068024494`: `admit_price_read` None; at `t`: `usd_at` the stored value, `market_cap_at` 34,341,336.98 usd basis `rpc_supply`, `cap_display_at` `$34.34M`, clock `2026-09-26T10:00:00Z`, mark `coingecko`, no gap | pass | `conversion_mismatch`; the read is off the tape; `cap not read`, `not on our tape yet` |
| V101 | I3-stonk-28-digit | `usd.value` the 28-digit rounding `0.04216670412350223416861506802`: `conversion_mismatch`; at `t` the read is off the tape, `cap not read`, `not on our tape yet`, clock and mark null. **A real v3.9 vector, not a pin** (`since: v3.9`, no `pin`): v3.8 admits it | pass | admitted; prints `$34.34M` from the rounded price |
| V102 | I3-28-digit-pin (pin) | native `0.000351562868615` (the STONK amount cut to 15 digits) x the same quote = `0.04216670412345092352794622395`, exactly 28 significant digits, which the old context did not round: admitted; one unit lower in the last place, `conversion_mismatch` | pass | pass |

No item 2 row needed a fix, so there are no further vectors. `--v38-compat`: exit 1, `2 of 2 negative vectors fail on v3.8; 1 pins pass on both, as they must`.

## Item 4. Schemas, version, stamp

No key is added or renamed, so the schemas' `$id` and `meta.contractVersion` stay `companion-v3.7`, exactly as v3.8 kept them: `companion.schema.json` `aeefae9c…` and `now.schema.json` `7ebe24dd…` are byte-equal to `legacy-v38/` (checked in `check_v39`), and every v3.8 document is a v3.9 document. Contract §11 says so. The version strings moved to v3.9: the CONTRACT title line and header note, `validate.py` docstring (a `--v38-compat` entry and check 15) and labels (`v3.9 pass`, `fails on v3.9`), `README.md`, `js/README.md`, the `nowat.py` header. `js/nowat.js` is not touched, so its header still names v3.8 and build-8 copies the same bytes. No migration script.

## Item 5. Parity

`contract/js/parity-v39.mjs` and `parity-v39.py`: copies of the v3.8 pair with V100 to V102 added (and `usd` in `usd_fields`). Section 3 is new: build-7's `real/solana-now.json`, read into memory on each side (Python from stdin; nothing written, not even to `/tmp`), where every read the pinned point covers (`|at - 10:00:00Z| <= 60 min`: **44 reads, 09:12:58Z to 10:00:18Z**) carries the conversion, with `usd.value` the exact product (Python under `_EXACT`, JavaScript `decMul`), then again with the 28-digit rounding (Python's default context, JavaScript `decRound(..., 28)`).

| STONK section | Clock | Both readers |
|---|---|---|
| a, exact product, held 17:14:43.362Z | `asOf` 13:27:42Z | 44 of 44 admitted, all 44 wider than 28 digits; not held yet: `cap not read`, `no USD price at this moment` |
| | 17:20:00Z | USD at `t` on all 44, price `$0.0422`; supply read expired, so `market_cap_at` takes the estimate path and **raises** on the null `assumedSupply` in both readers (Still open 1) |
| a, 28-digit value | both | 0 of 44 admitted; `no USD price at this moment` at 17:20:00Z, cap `supply not read at 17:20:00Z` |
| b, exact product, held 13:27:40Z (illustrative clock, real quote) | `asOf` | `$34.34M`, 34,341,336.979004525 usd, clock `2026-09-26T10:00:00Z`, mark `coingecko`, price `$0.0422` |
| b, 28-digit value | `asOf` | 0 of 44 admitted; `cap not read`, `no USD price at this moment` |

`PARITY-v39.out`: **33 of 33 cases byte-equal**, **12 of 12 `now_at` runs** (build-3's nine, the contract fixture, build-7's two real documents), **4 of 4 STONK sections EQUAL**. `parity-v38.mjs` exits 0 and its `PARITY-v38.out` is byte-identical after the rerun. `parity-v37.mjs` exits 0; its rerun rewrites `PARITY-v37.out` with v3.8's `clock` and `mark` keys (as v3.8 noted), so the file was put back to its pre-run bytes.

## Verification

- `python3 contract/validate.py`: exit 0, **182 passed, 0 failed** (v3.8: 177). New: `negative:V100`, `V101`, `V102` (pin), `v38-vectors-unchanged`, and `exact-v39`: `nowat.py` differs from `legacy-v38/nowat_v38.py` in its header and the one line; `js/nowat.js` and both schemas byte-equal to `legacy-v38/`, and the JS line compares the BigInt product; 6 price reads in V100 to V102 schema-valid; 50,726 price reads on disk (the fixtures, build-3's supply fixture, build-7's two real documents) admitted or refused exactly as v3.8 did; the 44 covered STONK reads, each wider than 28 significant digits with its exact product: admitted by v3.9 on 44 (v3.8 refused 44), and the 28-digit roundings refused by v3.9 on 44 (v3.8 admitted 44). `contract/VALIDATE.out` is this run (`6d9345ed…`).
- `--v38-compat`: exit 1, V100 and V101 fail on v3.8, V102 passes on both. `--v37-compat`, `--v36-compat`, `--v34-compat`, `--v33-compat`, `--v32-compat`, `--v31-compat`, `--v3-compat`: exit 1 each, **each output byte-identical** to v3.8's validator run on v3.8's files (a copy of `contract/` restored from `legacy-v38/` in `/tmp/w39`, compared with `diff`), new ids included, since no compat group takes `since: v3.9`.
- `validate.py --doc build-7/data/real/solana-now.json` and `--doc build-7/data/real/bsc-now.json`: exit 0, `document: OK`, output byte-identical to v3.8's validator on the same file. Both documents unchanged.
- `python3 build-8/scripts/defect-exact-product.py`: `exact … admitted`, `prec28 … conversion_mismatch`; `node build-8/scripts/defect-exact-product.mjs`: the same. The two scripts were read, not edited.
- **build-3 parity guard** (`/tmp/v39/parity-guard.py`): `build-3/scripts/dump-nowat.py` on the v3.9 `nowat.py` is **9 of 9 byte-equal to the same script on legacy-v38's `nowat.py`**, and reproduces build-3's `parity-*.py.json` byte-equal on 5 of 9 raw and 9 of 9 once `wallets.reason` (null on every row, added by v3.6) is taken out: exactly v3.7's and v3.8's result, for v3.6's reason.
- `migrate_v36_to_v37.py --check`, `migrate_v34_to_v35.py --check`, `migrate_v32_to_v33.py --check`: exit 0.
- **Generators:** `make.py` and `vectors.py` rerun: every schema, fixture and `staging/` file byte-identical (`sha256sum -c` against a snapshot taken before the rerun); `state-vectors.json` V01 to V99 byte-equal to `legacy-v38/state-vectors-v38.json`. Hashes: `nowat.py` `04596d6d…`, `js/nowat.js` `998665dd…` (unchanged), `state-vectors.json` `d53b10e1…`, `COMPANION-CONTRACT-v3.md` `bfbc83fb…`, `validate.py` `4689eac4…`, `vectors.py` `2f174f34…`, `PARITY-v39.out` `11a4ce74…`.
- `(cd contract/legacy-v38 && sha256sum -c SHA256)`: 16 of 16 OK.
- No file under any `build-*` or `design-pass*` directory changed: `find build-* design-pass* -newer contract/legacy-v38/SHA256 -type f` returns nothing. build-8's verify pins `contract/` byte for byte, so it re-pins to these bytes when it resumes. No deploy. Nothing sent. No network call. No em dashes in any file written here.

## Still open

1. **New, found by this sprint's parity run (not fixed: the same in both readers, so not this sprint's class).** `market_cap_at`'s estimate path (`nowat.py:569`, `js/nowat.js:369`) raises when `market.marketCap.assumedSupply.value` is null, which both real documents carry (`No supply assumed.`): Python `decimal.InvalidOperation` on `Decimal("None")`, JavaScript `not a decimal: null`. It is reached at any `t` where the last read has USD and no supply read is valid. Before v3.9 no real read had USD, so it never ran; with build-8's conversion it will: in `PARITY-v39.out` section a at 17:20:00Z (conversion held, build-7's supply read expired at 13:27:45.474Z) both readers raise in `market_cap_at` and `cap_display_at`. **build-8 must not read a `t` where the conversion is held and the supply read is not valid** (for example the moment slider's minutes before the supply's `validFrom`, if its `asOf` is after the fetch), or the contract needs a rule: the obvious one is `value` null with `assumedSupply.reason` and basis `estimate`, which prints `cap not read` exactly as the display rule already would. That needs a ruling and a vector, so it is listed, not done.
2. Audit rows 3 and 10: `flow_from_swaps` totals and `board.entry_cap_at` still multiply in the default context. Neither has a JavaScript mirror today, so the readers cannot split; when B10's writer or Board's read model gets one, take the product under `_EXACT` there too, as `market_cap_at` does.
3. From v3.8, unchanged: build-8 resumes from `build-8/CHECKS.md` item 2 as written (one SOL/USD reference read stored as a `Conversion` on the STONK read, `usd.value` the exact product, now admitted by both readers; `asOf` at or after the fetch clock with a supply read valid there; the cap printed with its clock and the CoinGecko mark through `capDisplayAt`; provenance pinned in SOURCES; BSC unchanged, `cap not read`, `no USD price at this moment`; re-pin build-8's verify to v3.9's `contract/` bytes, schema pin `7ebe24dd…` unchanged). Everything in CHANGES-v3.7 "Still open" that build-7 did not close stands.

Status: DONE
