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/client/VENDORING.md b/client/VENDORING.md index a12ad20..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:** `34d7639263b56a316ea2da485f6ed070bbe6822b502f900228041b443e5186ed` +**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/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/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 ----------------------------------------- diff --git a/compat/validate.py b/compat/validate.py index dc5ee2e..308d263 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,6 +106,97 @@ 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(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'^(?!\s*(?://|\*|/\*))\s*(?:internal\s+|public\s+)?const val MIN_NODE_VERSION' + r':\s*String\s*=\s*"([^"]+)"', + re.M, +) +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`. + + 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 = client_tree / CLIENT_MODE_REL + if not kt.is_file(): + return [f"{kt} is missing — the floor constant cannot be checked"] + # 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). + 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. + graded = _client_version_of(client_tree, version) + row = next( + (r for r in rows + if isinstance(r, dict) and r.get("client_version") == graded), + 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 {declared!r} — same fact, two copies. The " + f"matrix is where it is reasoned about; change it there and follow " + 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",