Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down
2 changes: 1 addition & 1 deletion MISSION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion client/VENDORING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Update the stale below-floor test case

Lowering the default floor to 0.5.168 makes the existing ClientModeTest.version_mismatch_fires_on_a_node_below_the_floor assertion assertTrue(isVersionMismatch("0.5.175", "0.5.176")) return false, because 0.5.175 is now above the floor. The inspected .github/workflows/build.yml runs :shared:desktopTest, which includes this common test, so the required desktop test job will fail until the test uses a node version below 0.5.168 (and updates its stale explanation).

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Record the client changes in the vendoring ledger

This commit changes ClientMode.kt and two test files under client/, but its client/VENDORING.md edit only refreshes the state digest and adds no §3 delta row explaining those departures. That leaves the modified client files without the provenance entry required for every vendored-file change; add the ledger row, or reconcile the applicable repository rule in the same commit if retiring §3 was intended.

AGENTS.md reference: AGENTS.md:L26-L26

Useful? React with 👍 / 👎.


/**
* Compare two `major.minor.patch` versions NUMERICALLY. Negative, zero, positive.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand All @@ -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 -----------------------------------------
Expand Down
93 changes: 92 additions & 1 deletion compat/validate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down Expand Up @@ -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*"([^"]+)"',
Comment on lines +118 to +119

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Strip block comments before matching the floor

When an old declaration is retained inside a normal multiline comment whose interior lines do not begin with *, this negative lookahead treats that declaration as live and captures its value instead of the compiled constant below it. I reproduced this with /* old floor\nconst val MIN_NODE_VERSION: String = "0.5.168"\n*/ followed by a live 9.9.9 declaration; the final regex still returns 0.5.168, so the drift check can report green. This is fresh evidence beyond the earlier // case because the revised regex only examines the matched line and does not track block-comment spans; strip comments before matching or exclude declarations inside those spans.

AGENTS.md reference: AGENTS.md:L52-L52

Useful? React with 👍 / 👎.

re.M,
)
Comment on lines +117 to +121

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Exclude commented-out floor declarations

When ClientMode.kt retains the old declaration in a comment before changing the live constant, MIN_NODE_RE.search captures the commented value and the compatibility check passes despite the compiled MIN_NODE_VERSION disagreeing with the matrix. I reproduced this with // const val MIN_NODE_VERSION: String = "0.5.168" followed by a live 9.9.9 declaration; validate() returned no problems, so this new drift gate can report green while the banner uses the wrong floor. Anchor the match to an uncommented declaration or strip comments before parsing.

AGENTS.md reference: AGENTS.md:L44-L44

Useful? React with 👍 / 👎.

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,
Comment on lines +185 to +188

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Select the matrix row for the graded client's version

When --client-tree grades a consumer copy whose client version belongs to a different matrix row and the floors differ, this still searches using the CIRISClient repository's VERSION; a correct stale consumer can therefore fail against a newer release's floor, while a wrong consumer constant can pass if it happens to equal that newer floor. Fresh evidence after the earlier report is that the final implementation now passes the selected tree but still compares client_version to the root-derived version; derive the selected tree's version using the same two-layout logic as version_alignment before choosing its row.

AGENTS.md reference: AGENTS.md:L49-L50

Useful? React with 👍 / 👎.

)
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


Expand Down
19 changes: 17 additions & 2 deletions readiness/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading