From 4929dc4d6cb8cf96cbc4b2704d81172c76e75ca3 Mon Sep 17 00:00:00 2001 From: Eric Moore Date: Thu, 27 Aug 2026 22:36:27 -0500 Subject: [PATCH 1/5] fix(compat): the floor was already in the matrix, and I wrote a different number MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `MIN_NODE_VERSION` shipped as 0.5.190 in PR #17. It should be 0.5.168, and the repo has said so since 0.5.185. I took CIRISServer#497's `ciris-client>=0.5.190,<0.6` and used it here. That range answers WHICH CLIENTS THE SERVER SUPPORTS. This constant answers WHICH NODES THIS CLIENT SUPPORTS — the opposite direction — and `compat/matrix.json` has carried that as `node_min: 0.5.168` in every row since 0.5.185, each with a written reason. At 0.5.190 the client would have shown "update recommended" against every node between 0.5.168 and 0.5.190: nodes the matrix calls supported. That is the permanent nag the decoupling existed to remove, moved to a different boundary. I also told this repo's owner the floor was "not earned the way the server earned theirs". That was wrong in a more useful way than it looked. The floor IS earned — stated, reasoned and append-only in the matrix. What was missing was never the evidence; it was anything keeping the Kotlin copy equal to it. I asserted a constant beside a file that already held the answer. `CompatibilityFloorMatchesMatrixTest` reads `compat/matrix.json`, finds the row for `CLIENT_VERSION`, and fails if `node_min` and `MIN_NODE_VERSION` disagree. It reads the matrix rather than restating it: a test hard-coding 0.5.168 would prove the constant equals a literal in a test file, which is not the claim. The release gate already refuses a version with no matrix row, so the row and the constant now move together or the build stops. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01EGE52kPzjGFiPzGcs63ZC1 --- client/VENDORING.md | 2 +- .../ciris/mobile/shared/models/ClientMode.kt | 29 +++++----- .../CompatibilityFloorMatchesMatrixTest.kt | 57 +++++++++++++++++++ 3 files changed, 74 insertions(+), 14 deletions(-) create mode 100644 client/shared/src/desktopTest/kotlin/ai/ciris/mobile/shared/models/CompatibilityFloorMatchesMatrixTest.kt diff --git a/client/VENDORING.md b/client/VENDORING.md index a12ad20..571108e 100644 --- a/client/VENDORING.md +++ b/client/VENDORING.md @@ -40,7 +40,7 @@ source is the pair a bisect wants: The tree's current recorded state — sha256-of-sha256s over every git-tracked file under `client/` except this one: -**state digest:** `34d7639263b56a316ea2da485f6ed070bbe6822b502f900228041b443e5186ed` +**state digest:** `d12c44ebdd77f7487dcc6eaf6cf42912e39c59a185fdb7afa8b819abfcf0882f` `packaging/check_vendoring.py` asserts it on every push, and refuses any tracked file matching a §2 never-vendor class. **Any commit that touches diff --git a/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/models/ClientMode.kt b/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/models/ClientMode.kt index c72af40..331cd29 100644 --- a/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/models/ClientMode.kt +++ b/client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/models/ClientMode.kt @@ -221,21 +221,24 @@ fun clientModeFrom( /** * The oldest node this client can drive. * - * A FLOOR, not a pin. CIRISServer 0.5.192 moves its own dependency to - * `ciris-client>=0.5.190,<0.6` (CIRISServer#497) so the client can ship for the - * agent team without a paired server cut; this is the same relaxation from the - * other side. + * **This is `node_min` from `compat/matrix.json`, and it must stay equal to it.** + * `CompatibilityFloorMatchesMatrixTest` fails if they diverge. * - * THIS NUMBER IS NOT YET EARNED THE WAY THE SERVER EARNED ITS FLOOR. The server - * mutation-tested theirs at both ends — lower it to 0.5.186 and the id gate - * fails, lower it to 0.5.188 and the wheel gate fails — so the bound is measured - * rather than asserted. Ours is the pairing both sides are standardising on and - * nothing here proves this client cannot drive an older node. Until a gate - * installs the floor and exercises the API surface this client actually calls, - * treat it as the server team put it: an untested bound is a guess with a - * version number on it. + * I first wrote 0.5.190 here, which was the SERVER's floor for the opposite + * question. CIRISServer#497 declares `ciris-client>=0.5.190,<0.6` — which + * CLIENT versions the server supports. This constant answers which NODE + * versions the client supports, and the repo already had that answer, recorded + * with a reason in the compatibility matrix and unchanged at 0.5.168 since + * 0.5.185. Taking the server's number for it would have nagged on every node + * between 0.5.168 and 0.5.190 — nodes the matrix says are supported — which is + * the permanent nag the whole change was made to remove, moved to a different + * boundary. + * + * So the floor is not "unearned" as I described it: it is stated and justified + * in the matrix, one row per release, append-only. What was missing was + * anything keeping the two copies equal. That is the test. */ -const val MIN_NODE_VERSION: String = "0.5.190" +const val MIN_NODE_VERSION: String = "0.5.168" /** * Compare two `major.minor.patch` versions NUMERICALLY. Negative, zero, positive. diff --git a/client/shared/src/desktopTest/kotlin/ai/ciris/mobile/shared/models/CompatibilityFloorMatchesMatrixTest.kt b/client/shared/src/desktopTest/kotlin/ai/ciris/mobile/shared/models/CompatibilityFloorMatchesMatrixTest.kt new file mode 100644 index 0000000..81e587a --- /dev/null +++ b/client/shared/src/desktopTest/kotlin/ai/ciris/mobile/shared/models/CompatibilityFloorMatchesMatrixTest.kt @@ -0,0 +1,57 @@ +package ai.ciris.mobile.shared.models + +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * The floor is declared twice. This keeps the copies equal. + * + * `compat/matrix.json` has carried `node_min` since 0.5.185 — one row per + * release, append-only, each with a written reason. `MIN_NODE_VERSION` is the + * same fact in Kotlin, where the banner can read it. Nothing structural held + * them together, and the first version of the constant was a DIFFERENT NUMBER: + * the server's client-floor from CIRISServer#497, which answers the opposite + * question. The client would have nagged on nodes the matrix calls supported. + * + * Reads the matrix rather than restating it. A test that hard-codes 0.5.168 + * proves the constant equals a literal in a test file, which is not the claim. + */ +class CompatibilityFloorMatchesMatrixTest { + + private fun repoRoot(): File { + var dir: File? = File(".").absoluteFile + repeat(6) { + val d = dir ?: return@repeat + if (File(d, "compat/matrix.json").isFile) return d + dir = d.parentFile + } + error("could not find compat/matrix.json above ${File(".").absolutePath}") + } + + /** Minimal extraction — no JSON dependency in this source set. */ + private fun nodeMinFor(version: String): String? { + val text = File(repoRoot(), "compat/matrix.json").readText() + val idx = text.indexOf("\"client_version\": \"$version\"") + if (idx < 0) return null + return Regex("\"node_min\"\\s*:\\s*\"([^\"]+)\"") + .find(text.substring(idx))?.groupValues?.get(1) + } + + @Test + fun the_constant_equals_the_matrix_row_for_this_version() { + val nodeMin = nodeMinFor(CLIENT_VERSION) + assertTrue( + nodeMin != null, + "compat/matrix.json has no row for $CLIENT_VERSION — a release without " + + "its matrix row does not merge, and this is that gate reaching Kotlin", + ) + assertEquals( + nodeMin, MIN_NODE_VERSION, + "MIN_NODE_VERSION and compat/matrix.json disagree about the oldest " + + "supported node. Same fact, two copies; the matrix is where it is " + + "reasoned about, so change it there and follow here.", + ) + } +} From 4dfbf08f81e3d1a03b7304a7e95467519f4f993e Mon Sep 17 00:00:00 2001 From: Eric Moore Date: Thu, 27 Aug 2026 22:42:09 -0500 Subject: [PATCH 2/5] fix(compat): three tests had the wrong floor encoded in them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Correcting MIN_NODE_VERSION to 0.5.168 left three cases asserting the behaviour of the number I removed. Codex named the first; the suite found the other two the moment the constant moved, which is the drift test doing its job on its first run. version_mismatch_fires_on_a_node_below_the_floor 0.5.175 is ABOVE 0.5.168 version_mismatch_ignores_the_v_prefix 0.5.176 likewise a_node_that_declares_nothing_... 0.5.186 likewise Each asserted the client NAGS at a node the compatibility matrix calls supported. Left in place they would have preserved the exact defect the change removes, inside the tests written to prove it gone — a green suite certifying the wrong floor. Below-floor cases now use 0.5.167 and 0.5.100, which are genuinely below 0.5.168, and the supported-but-older versions are asserted SILENT alongside them, so the boundary is pinned from both sides rather than only from beneath. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01EGE52kPzjGFiPzGcs63ZC1 --- client/VENDORING.md | 2 +- .../mobile/shared/models/ClientModeTest.kt | 17 ++++++++++++----- .../shared/models/CompatibilityFloorTest.kt | 9 ++++++--- 3 files changed, 19 insertions(+), 9 deletions(-) diff --git a/client/VENDORING.md b/client/VENDORING.md index 571108e..20cbbb8 100644 --- a/client/VENDORING.md +++ b/client/VENDORING.md @@ -40,7 +40,7 @@ source is the pair a bisect wants: The tree's current recorded state — sha256-of-sha256s over every git-tracked file under `client/` except this one: -**state digest:** `d12c44ebdd77f7487dcc6eaf6cf42912e39c59a185fdb7afa8b819abfcf0882f` +**state digest:** `c99742b440f6d2dfb7cabfb836e5e0b387e9ad25a86e7c9f32e79a0d05f7a536` `packaging/check_vendoring.py` asserts it on every push, and refuses any tracked file matching a §2 never-vendor class. **Any commit that touches diff --git a/client/shared/src/commonTest/kotlin/ai/ciris/mobile/shared/models/ClientModeTest.kt b/client/shared/src/commonTest/kotlin/ai/ciris/mobile/shared/models/ClientModeTest.kt index 8db092c..16c6650 100644 --- a/client/shared/src/commonTest/kotlin/ai/ciris/mobile/shared/models/ClientModeTest.kt +++ b/client/shared/src/commonTest/kotlin/ai/ciris/mobile/shared/models/ClientModeTest.kt @@ -336,17 +336,24 @@ class ClientModeTest { // is genuinely too old and SHOULD flag, so the prefix is now shown to be // ignored at versions where the answer is not about age. assertFalse(isVersionMismatch("v0.5.191", "0.5.191")) - assertTrue(isVersionMismatch("v0.5.176", "0.5.191")) + assertTrue(isVersionMismatch("v0.5.167", "0.5.191")) } @Test fun version_mismatch_fires_on_a_node_below_the_floor() { // RENAMED, because the reason changed. This used to be "fires on a real // difference" — under equality, 0.5.175 vs 0.5.176 flagged because they - // differed. It still flags, but now because 0.5.175 is below - // MIN_NODE_VERSION, and a mere difference no longer flags anything: - // that is the decoupling CIRISServer#497 asked for. - assertTrue(isVersionMismatch("0.5.175", "0.5.176")) + // differed. Now only being BELOW the floor flags, and a mere difference + // flags nothing: the decoupling CIRISServer#497 asked for. + // + // The example moved too, and it had to (Codex, PR #19). While the floor + // was wrongly 0.5.190 this asserted 0.5.175 flags; the floor's real + // value is 0.5.168, which makes 0.5.175 a SUPPORTED node. Correcting the + // constant without correcting the case would have left a test asserting + // the client nags at a node the matrix says is fine — the exact defect + // being fixed, preserved in the test that was supposed to prove it gone. + assertTrue(isVersionMismatch("0.5.167", "0.5.191"), "below the 0.5.168 floor") + assertFalse(isVersionMismatch("0.5.175", "0.5.191"), "above the floor, merely older") assertFalse(isVersionMismatch("0.5.192", "0.5.193")) } diff --git a/client/shared/src/commonTest/kotlin/ai/ciris/mobile/shared/models/CompatibilityFloorTest.kt b/client/shared/src/commonTest/kotlin/ai/ciris/mobile/shared/models/CompatibilityFloorTest.kt index d7a7ba2..d0a921b 100644 --- a/client/shared/src/commonTest/kotlin/ai/ciris/mobile/shared/models/CompatibilityFloorTest.kt +++ b/client/shared/src/commonTest/kotlin/ai/ciris/mobile/shared/models/CompatibilityFloorTest.kt @@ -48,8 +48,11 @@ class CompatibilityFloorTest { @Test fun a_node_below_the_floor_still_says_so() { // The signal the nag exists for. "Never complain" would have deleted it. - assertTrue(isVersionMismatch("0.5.188", clientVersion = "0.5.191")) - assertTrue(isVersionMismatch("0.5.186", clientVersion = "0.5.191")) + assertTrue(isVersionMismatch("0.5.167", clientVersion = "0.5.191")) + assertTrue(isVersionMismatch("0.5.100", clientVersion = "0.5.191")) + // 0.5.188 and 0.5.186 are ABOVE the 0.5.168 floor — supported, silent. + assertFalse(isVersionMismatch("0.5.188", clientVersion = "0.5.191")) + assertFalse(isVersionMismatch("0.5.186", clientVersion = "0.5.191")) // And the lexical trap must not rescue a too-old node: "0.5.9" reads as // greater than "0.5.190" to a string compare. assertTrue(isVersionMismatch("0.5.9", clientVersion = "0.5.191")) @@ -70,7 +73,7 @@ class CompatibilityFloorTest { // Absent means "did not say", not "is happy". Every node today is here, // so this is the live path, and the floor this side holds still applies. assertFalse(isVersionMismatch("0.5.191", clientVersion = "0.5.191", nodeMinClientVersion = null)) - assertTrue(isVersionMismatch("0.5.186", clientVersion = "0.5.191", nodeMinClientVersion = null)) + assertTrue(isVersionMismatch("0.5.167", clientVersion = "0.5.191", nodeMinClientVersion = null)) } // ---- unchanged behaviour ----------------------------------------- From e862bbe37303041fccee66d866329fe9cd3dfc21 Mon Sep 17 00:00:00 2001 From: Eric Moore Date: Thu, 27 Aug 2026 22:48:42 -0500 Subject: [PATCH 3/5] fix(compat): the floor check belongs with the matrix, and my first one could not fail MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two findings on the drift test, and acting on the first produced a better answer than patching it would have. IT DOES NOT BELONG IN THE CLIENT'S TEST SUITE. `client/` builds standalone with `-PclientVersion`, and this tree is vendored into two other repos — none of which are required to have `compat/` above them. A Kotlin test that walks up looking for `compat/matrix.json` fails the whole `:shared:desktopTest` task there, for a reason that has nothing to do with the client. CI hid it, because a full checkout happens to place the matrix above `client/` (Codex, PR #19). The matrix is the thing being compared against, so the comparison now lives beside the matrix in `compat/validate.py`, which already runs as a release gate and where the file is guaranteed to exist. THAT ALSO DISSOLVED THE SECOND FINDING rather than fixing it. The Kotlin version scanned text after the matched `client_version`, so a row that ever placed `node_min` first would send the scan into the NEXT release's floor — and if that value happened to equal the constant, the check would pass WHILE DRIFTING. A gate silently failing to fail. `validate.py` already parses the row as JSON, so key order cannot mean anything. AND THE FIRST VERSION OF THIS GATE COULD NOT FIRE AT ALL. I appended the call next to the wrong `return problems` — it landed inside `check_kotlin_floor` itself, a recursive line nothing ever reached — and referenced a `REPO` constant that exists in `check_pins.py` and not here. Three mutations passed, including deleting `MIN_NODE_VERSION` outright. I only know because I ran them; the gate reported OK on every one, which is precisely the failure this session keeps naming, committed by the person naming it. Mutation-tested after the fix: constant drifts to 0.5.190 FAIL, naming both values and which is source constant deleted FAIL, "parsed no MIN_NODE_VERSION" node_min reordered before PASS — JSON parsing does not care about key client_version in the row order, which was the point Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01EGE52kPzjGFiPzGcs63ZC1 --- client/VENDORING.md | 2 +- .../CompatibilityFloorMatchesMatrixTest.kt | 57 ------------------- compat/validate.py | 53 +++++++++++++++++ 3 files changed, 54 insertions(+), 58 deletions(-) delete mode 100644 client/shared/src/desktopTest/kotlin/ai/ciris/mobile/shared/models/CompatibilityFloorMatchesMatrixTest.kt diff --git a/client/VENDORING.md b/client/VENDORING.md index 20cbbb8..4a4862b 100644 --- a/client/VENDORING.md +++ b/client/VENDORING.md @@ -40,7 +40,7 @@ source is the pair a bisect wants: The tree's current recorded state — sha256-of-sha256s over every git-tracked file under `client/` except this one: -**state digest:** `c99742b440f6d2dfb7cabfb836e5e0b387e9ad25a86e7c9f32e79a0d05f7a536` +**state digest:** `be1f4ba8990a3b289cc30f414e91ea7524fd1444160340308ffb8dc5a25dba68` `packaging/check_vendoring.py` asserts it on every push, and refuses any tracked file matching a §2 never-vendor class. **Any commit that touches diff --git a/client/shared/src/desktopTest/kotlin/ai/ciris/mobile/shared/models/CompatibilityFloorMatchesMatrixTest.kt b/client/shared/src/desktopTest/kotlin/ai/ciris/mobile/shared/models/CompatibilityFloorMatchesMatrixTest.kt deleted file mode 100644 index 81e587a..0000000 --- a/client/shared/src/desktopTest/kotlin/ai/ciris/mobile/shared/models/CompatibilityFloorMatchesMatrixTest.kt +++ /dev/null @@ -1,57 +0,0 @@ -package ai.ciris.mobile.shared.models - -import java.io.File -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertTrue - -/** - * The floor is declared twice. This keeps the copies equal. - * - * `compat/matrix.json` has carried `node_min` since 0.5.185 — one row per - * release, append-only, each with a written reason. `MIN_NODE_VERSION` is the - * same fact in Kotlin, where the banner can read it. Nothing structural held - * them together, and the first version of the constant was a DIFFERENT NUMBER: - * the server's client-floor from CIRISServer#497, which answers the opposite - * question. The client would have nagged on nodes the matrix calls supported. - * - * Reads the matrix rather than restating it. A test that hard-codes 0.5.168 - * proves the constant equals a literal in a test file, which is not the claim. - */ -class CompatibilityFloorMatchesMatrixTest { - - private fun repoRoot(): File { - var dir: File? = File(".").absoluteFile - repeat(6) { - val d = dir ?: return@repeat - if (File(d, "compat/matrix.json").isFile) return d - dir = d.parentFile - } - error("could not find compat/matrix.json above ${File(".").absolutePath}") - } - - /** Minimal extraction — no JSON dependency in this source set. */ - private fun nodeMinFor(version: String): String? { - val text = File(repoRoot(), "compat/matrix.json").readText() - val idx = text.indexOf("\"client_version\": \"$version\"") - if (idx < 0) return null - return Regex("\"node_min\"\\s*:\\s*\"([^\"]+)\"") - .find(text.substring(idx))?.groupValues?.get(1) - } - - @Test - fun the_constant_equals_the_matrix_row_for_this_version() { - val nodeMin = nodeMinFor(CLIENT_VERSION) - assertTrue( - nodeMin != null, - "compat/matrix.json has no row for $CLIENT_VERSION — a release without " + - "its matrix row does not merge, and this is that gate reaching Kotlin", - ) - assertEquals( - nodeMin, MIN_NODE_VERSION, - "MIN_NODE_VERSION and compat/matrix.json disagree about the oldest " + - "supported node. Same fact, two copies; the matrix is where it is " + - "reasoned about, so change it there and follow here.", - ) - } -} diff --git a/compat/validate.py b/compat/validate.py index dc5ee2e..5690123 100644 --- a/compat/validate.py +++ b/compat/validate.py @@ -106,6 +106,59 @@ def validate(repo_root: Path) -> list[str]: f"exactly one row must match VERSION ({version}); found {len(matches)} — " f"a release without its matrix row does not merge (FSD §6)" ) + problems.extend(check_kotlin_floor(repo_root, rows, version)) + return problems + + +MIN_NODE_RE = re.compile( + r'const val MIN_NODE_VERSION:\s*String\s*=\s*"([^"]+)"' +) +CLIENT_MODE = "client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/models/ClientMode.kt" + + +def check_kotlin_floor(repo_root: Path, rows: list, version: str) -> list[str]: + """ + `MIN_NODE_VERSION` in Kotlin must equal this release's `node_min`. + + THE SAME FACT IS WRITTEN TWICE. The matrix is where the floor is reasoned + about, one row per release, append-only; the Kotlin constant is where the + version banner can read it. The first version of that constant was a + DIFFERENT NUMBER — the server's client-floor from CIRISServer#497, which + answers the opposite question — and the client would have nagged on nodes + this file calls supported. + + CHECKED HERE, NOT IN THE CLIENT'S TEST SUITE. `client/` builds standalone + with `-PclientVersion` and this tree is vendored into two other repos, none + of which are required to have `compat/` above them: a Kotlin test that walks + up looking for this file fails the whole `:shared:desktopTest` task there, + for a reason that has nothing to do with the client (Codex, PR #19). The + matrix is the thing being compared against, so the comparison belongs beside + the matrix, where the file is guaranteed to exist. + + Parses the row as JSON rather than scanning text after a match: a row that + ever placed `node_min` before `client_version` would send a text scan into + the NEXT release's floor, and if that value happened to match the constant + the check would pass while drifting — a gate silently failing to fail. + """ + problems: list[str] = [] + kt = repo_root / CLIENT_MODE + if not kt.is_file(): + return [f"{CLIENT_MODE} is missing — the floor constant cannot be checked"] + m = MIN_NODE_RE.search(kt.read_text(encoding="utf-8")) + if not m: + # A parser that finds nothing where the construct plainly exists must + # fail loudly (AGENTS.md, Gate Rules). + return [f"parsed no MIN_NODE_VERSION from {CLIENT_MODE}"] + row = next((r for r in rows if r.get("client_version") == version), None) + if row is None: + return [] # the "exactly one row for VERSION" check already reports this + if m.group(1) != row["node_min"]: + problems.append( + f"MIN_NODE_VERSION is {m.group(1)!r} but the {version} row's " + f"node_min is {row['node_min']!r} — same fact, two copies. The " + f"matrix is where it is reasoned about; change it there and follow " + f"in {CLIENT_MODE}." + ) return problems From 1ffa3a7bbec13afeea231f84c19dd59fccf65b3f Mon Sep 17 00:00:00 2001 From: Eric Moore Date: Thu, 27 Aug 2026 23:04:23 -0500 Subject: [PATCH 4/5] fix(compat): the drift gate could be fooled by a comment, and its contract was unwritten MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five findings on the gate I added, and two of them say the gate lied. A COMMENT DEFEATED IT. `MIN_NODE_RE` matched the first occurrence anywhere in the file, so an old value left commented above a changed live one was what the gate compared — green while the compiled constant disagreed with the matrix. Codex reproduced it. That is the second time in two commits that this gate reported OK on exactly the drift it exists to catch. Now anchored to a live declaration at line start, and mutation-tested with the decoy in place. IT THREW INSTEAD OF REPORTING. A row missing `node_min`, or a non-object row, reached past the loop that had already recorded the problem and raised. Both CI and the imported readiness gate got a traceback instead of the validator's actionable list. Now it defers: the loop's finding is the answer. IT GRADED THE WRONG TREE. `readiness --client-tree /client` points the board at a consumer's vendored copy, and the path was hardcoded to ours — so a drifted constant in the tree actually under grade reported green while the board named that tree as its subject. `validate()` now takes the tree. ITS CONTRACT WAS UNWRITTEN. Adding this check changed what `compat-matrix` enforces, while the decorator question, MISSION.md §3 and the README table still described only the row check. A gate whose stated contract omits a condition it enforces under-reports what it checked, which is the same defect as not checking. All three updated here, in the commit that changed the behaviour. AND THE FIFTH IS A STALE RULE, RECONCILED RATHER THAN OBEYED. Codex asked for a `VENDORING.md` §3 delta row for the changed client files. §3 was RETIRED at the three-way merge — git history is the declaration, the §1 digest is the seal — but AGENTS.md still instructed every file under `client/` to be byte-identical to CIRISAgent@6083bdf or carry a §3 row, and claimed `check_vendoring.py` asserts that. It does not and never has: it asserts the digest and the never-vendor classes. The finding offered reconciliation as the alternative and that is the correct branch — the rule was describing a gate that does not exist, so the instruction is fixed rather than a row added to a retired section. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01EGE52kPzjGFiPzGcs63ZC1 --- AGENTS.md | 4 ++-- MISSION.md | 2 +- README.md | 2 +- compat/validate.py | 42 ++++++++++++++++++++++++++++-------------- readiness/client.py | 19 +++++++++++++++++-- 5 files changed, 49 insertions(+), 20 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 1803a07..5fb74c5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,8 +22,8 @@ - Anchors are EXEMPLARS. `MAX_ANCHOR_CHARS` exists because `prompts.language_guidance` is a real key whose Yoruba value is 31,297 characters, and lexical retrieval loved it: it contains most words, so it out-scored every genuinely similar UI string and cost ~8k tokens a request to teach nothing about button labels. ## Working in `client/` -- Every file under `client/` is byte-identical to CIRISAgent@6083bdf **or** has a row in `client/VENDORING.md` §3 saying why. `packaging/check_vendoring.py` asserts exactly that, and CI runs it. -- Changing a vendored file means: make the change, add the row, and re-record the digest (`python3 packaging/check_vendoring.py --print`) in the same commit. +- The tree is AUTHORED here, not copied. `client/VENDORING.md` §3 — the byte-identity-or-delta-row rule — was RETIRED at the three-way merge: git history is the declaration and the §1 state digest is the seal. `packaging/check_vendoring.py` asserts the digest and the never-vendor classes (§2); it has never asserted byte-identity to a vendor commit, and this file said it did (Codex, PR #19). +- Changing a file under `client/` means: make the change and re-record the digest (`python3 packaging/check_vendoring.py --print`) in the same commit. There is no row to add. - Prefer pushing the change upstream to carrying it. The delta table is deliberately small so that keeping it small stays a decision someone makes, not a thing that erodes. - Do not vendor the substrate. `androidApp/wheels/`, jniLibs, `iosApp/Resources*`, `iosApp/Frameworks/` and `iosApp/app_packages_native/` are other repos' release artifacts (§2). They are excluded on purpose and `.gitignore` does not protect you from `git add -f`. diff --git a/MISSION.md b/MISSION.md index 6991947..ae3fd1e 100644 --- a/MISSION.md +++ b/MISSION.md @@ -90,7 +90,7 @@ the report so a worklist is never mistaken for a verdict. | `nav-gate-registry` | normative | A surface still gated on a closed issue is a capability withheld for no reason | | `substrate-binaries` | code | An artifact missing its substrate is an artifact that fails on a user's device, not in CI | | `toolchain` | code | Fails at the start of the day rather than twenty minutes into a build | -| `compat-matrix` | normative | "Which client works with which node, and what does it do when they mismatch" must be a published, validated record — not support folklore (`compat/matrix.json`, FSD §6) | +| `compat-matrix` | normative | "Which client works with which node, and what does it do when they mismatch" must be a published, validated record — not support folklore (`compat/matrix.json`, FSD §6). The record and the code must AGREE: the matrix's `node_min` and the client's `MIN_NODE_VERSION` are one fact written twice, and a banner reading a different floor than the record publishes is folklore with a version number | ## 4. Dependencies & gating diff --git a/README.md b/README.md index 466ec1b..abfab6c 100644 --- a/README.md +++ b/README.md @@ -273,7 +273,7 @@ not a result about the client. | `spec-drift` | data | Does the committed OpenAPI spec match what the node serves? (needs `--node`) | | `surface-binding` | data | Does every documented endpoint reach a client surface? | | `nav-gate-registry` | normative | Is every `SubstrateGate` pointing at an open issue? | -| `compat-matrix` | normative | Does the compatibility matrix carry this release's row? | +| `compat-matrix` | normative | Does the compatibility matrix carry this release's row, and does the client's `MIN_NODE_VERSION` agree with it? | ### Reading the board diff --git a/compat/validate.py b/compat/validate.py index 5690123..f0256c7 100644 --- a/compat/validate.py +++ b/compat/validate.py @@ -39,7 +39,7 @@ def _vertuple(v: str) -> tuple[int, ...]: return tuple(int(x) for x in v.split(".")) -def validate(repo_root: Path) -> list[str]: +def validate(repo_root: Path, client_tree: Path | None = None) -> list[str]: problems: list[str] = [] path = repo_root / "compat" / "matrix.json" if not path.is_file(): @@ -106,17 +106,23 @@ def validate(repo_root: Path) -> list[str]: f"exactly one row must match VERSION ({version}); found {len(matches)} — " f"a release without its matrix row does not merge (FSD §6)" ) - problems.extend(check_kotlin_floor(repo_root, rows, version)) + problems.extend(check_kotlin_floor(client_tree or (repo_root / 'client'), rows, version)) return problems +# ANCHORED TO A LIVE DECLARATION. A commented-out old value sitting above a +# changed live one made this capture the comment and pass while the compiled +# constant disagreed with the matrix — a drift gate reporting green on the drift +# it exists to catch (Codex, PR #19, reproduced there). MIN_NODE_RE = re.compile( - r'const val MIN_NODE_VERSION:\s*String\s*=\s*"([^"]+)"' + r'^(?!\s*(?://|\*|/\*))\s*(?:internal\s+|public\s+)?const val MIN_NODE_VERSION' + r':\s*String\s*=\s*"([^"]+)"', + re.M, ) -CLIENT_MODE = "client/shared/src/commonMain/kotlin/ai/ciris/mobile/shared/models/ClientMode.kt" +CLIENT_MODE_REL = "shared/src/commonMain/kotlin/ai/ciris/mobile/shared/models/ClientMode.kt" -def check_kotlin_floor(repo_root: Path, rows: list, version: str) -> list[str]: +def check_kotlin_floor(client_tree: Path, rows: list, version: str) -> list[str]: """ `MIN_NODE_VERSION` in Kotlin must equal this release's `node_min`. @@ -141,23 +147,31 @@ def check_kotlin_floor(repo_root: Path, rows: list, version: str) -> list[str]: the check would pass while drifting — a gate silently failing to fail. """ problems: list[str] = [] - kt = repo_root / CLIENT_MODE + kt = client_tree / CLIENT_MODE_REL if not kt.is_file(): - return [f"{CLIENT_MODE} is missing — the floor constant cannot be checked"] + return [f"{kt} is missing — the floor constant cannot be checked"] m = MIN_NODE_RE.search(kt.read_text(encoding="utf-8")) if not m: # A parser that finds nothing where the construct plainly exists must # fail loudly (AGENTS.md, Gate Rules). - return [f"parsed no MIN_NODE_VERSION from {CLIENT_MODE}"] - row = next((r for r in rows if r.get("client_version") == version), None) - if row is None: - return [] # the "exactly one row for VERSION" check already reports this - if m.group(1) != row["node_min"]: + return [f"parsed no MIN_NODE_VERSION from {kt}"] + # The caller's loop already reports a non-object row or a missing + # node_min. Reaching past that to index it turns an actionable failure list + # into a traceback, for both CI and the imported readiness gate. + row = next( + (r for r in rows + if isinstance(r, dict) and r.get("client_version") == version), + None, + ) + declared = row.get("node_min") if row else None + if not isinstance(declared, str): + return [] + if m.group(1) != declared: problems.append( f"MIN_NODE_VERSION is {m.group(1)!r} but the {version} row's " - f"node_min is {row['node_min']!r} — same fact, two copies. The " + f"node_min is {declared!r} — same fact, two copies. The " f"matrix is where it is reasoned about; change it there and follow " - f"in {CLIENT_MODE}." + f"in {kt}." ) return problems diff --git a/readiness/client.py b/readiness/client.py index b9232a1..c90ffc2 100644 --- a/readiness/client.py +++ b/readiness/client.py @@ -406,15 +406,30 @@ def generated_api_drift(ctx: Context) -> Result: ) -@gate("compat-matrix", "Does the compatibility matrix carry this release's row?") +@gate( + "compat-matrix", + "Does the compatibility matrix carry this release's row, and does the " + "client's MIN_NODE_VERSION agree with it?", +) def compat_matrix(ctx: Context) -> Result: """The published client↔node record (FSD §6): one row per release, append-only, exactly one row for the current VERSION. The same validation CI runs (compat/validate.py) — one implementation, two callers. + + ALSO the Kotlin floor. `MIN_NODE_VERSION` states the oldest supported node + in code, and `node_min` states it in the matrix; they are one fact written + twice, and nothing structural kept them equal until this gate. The question + above says so, because a gate whose stated contract omits a condition it + enforces is a board that under-reports what it checked. + + Graded against THE TREE UNDER GRADE, not always this repo's: `--client-tree` + points readiness at CIRISServer's or CIRISAgent's vendored copy, and reading + our own `client/` there would report green for a consumer whose constant had + drifted (Codex, PR #19). """ from compat.validate import validate - problems = validate(Path(__file__).resolve().parents[1]) + problems = validate(Path(__file__).resolve().parents[1], client_tree(ctx)) if problems: return Result( "compat-matrix", From cf2ef99b287d6ee96a5b55e8ffee84790012bb0c Mon Sep 17 00:00:00 2001 From: Eric Moore Date: Thu, 27 Aug 2026 23:09:40 -0500 Subject: [PATCH 5/5] fix(compat): a block comment could still hide the decoy, and the row must match the graded tree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two more on the same gate. The line-anchored exclusion caught `//` and a `*` continuation, but a declaration parked inside `/* ... */` need not start its line with anything. Block comments are stripped before matching now, and the decoy is mutation-tested in that form as well as the line form. And the row was selected by THIS repo's VERSION even when `--client-tree` pointed readiness at a consumer's vendored copy, which can sit at a different release — comparing that tree's constant against a row describing a release it is not. The graded tree's own generated `ClientVersion.kt` answers it; absent one, it falls back to ours rather than inventing a version. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01EGE52kPzjGFiPzGcs63ZC1 --- compat/validate.py | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/compat/validate.py b/compat/validate.py index f0256c7..308d263 100644 --- a/compat/validate.py +++ b/compat/validate.py @@ -122,6 +122,25 @@ def validate(repo_root: Path, client_tree: Path | None = None) -> list[str]: CLIENT_MODE_REL = "shared/src/commonMain/kotlin/ai/ciris/mobile/shared/models/ClientMode.kt" +def _client_version_of(client_tree: Path, fallback: str) -> str: + """ + The version of the tree being graded. + + `--client-tree` points readiness at a consumer's vendored copy, which can be + at a different release than this repo. Selecting the row by OUR VERSION + would compare that tree's constant against a row describing a release it is + not (Codex, PR #19). The generated `ClientVersion.kt` is the tree's own + answer; absent it — a tree that has not run `generateBuildFlavor` — fall + back to ours rather than inventing one. + """ + gen = client_tree / "shared/build/generated/flavor/commonMain/kotlin/ai/ciris/mobile/shared/models/ClientVersion.kt" + if gen.is_file(): + m = re.search(r'CLIENT_VERSION:\s*String\s*=\s*"([^"]+)"', gen.read_text(encoding="utf-8")) + if m: + return m.group(1) + return fallback + + def check_kotlin_floor(client_tree: Path, rows: list, version: str) -> list[str]: """ `MIN_NODE_VERSION` in Kotlin must equal this release's `node_min`. @@ -150,7 +169,11 @@ def check_kotlin_floor(client_tree: Path, rows: list, version: str) -> list[str] kt = client_tree / CLIENT_MODE_REL if not kt.is_file(): return [f"{kt} is missing — the floor constant cannot be checked"] - m = MIN_NODE_RE.search(kt.read_text(encoding="utf-8")) + # Block comments too. The line-anchored exclusion catches `//` and a `*` + # continuation line, but an old declaration parked inside `/* ... */` need + # not start its line with anything (Codex, PR #19). Strip them, then match. + source = re.sub(r"/\*.*?\*/", "", kt.read_text(encoding="utf-8"), flags=re.S) + m = MIN_NODE_RE.search(source) if not m: # A parser that finds nothing where the construct plainly exists must # fail loudly (AGENTS.md, Gate Rules). @@ -158,9 +181,10 @@ def check_kotlin_floor(client_tree: Path, rows: list, version: str) -> list[str] # The caller's loop already reports a non-object row or a missing # node_min. Reaching past that to index it turns an actionable failure list # into a traceback, for both CI and the imported readiness gate. + graded = _client_version_of(client_tree, version) row = next( (r for r in rows - if isinstance(r, dict) and r.get("client_version") == version), + if isinstance(r, dict) and r.get("client_version") == graded), None, ) declared = row.get("node_min") if row else None