diff --git a/crates/batten/src/contract.rs b/crates/batten/src/contract.rs index 2cef3d4da..170434f9e 100644 --- a/crates/batten/src/contract.rs +++ b/crates/batten/src/contract.rs @@ -308,6 +308,63 @@ pub fn render(change: &ChangeSet, wiring: &[String]) -> String { out } +/// The advisory for a session whose `SessionStart` registration never ran +/// (CLOUD-1085). +/// +/// # Why a seed at a later event is news, and one at `SessionStart` is not +/// +/// [`previous`] returns [`Look::CouldNotLook`] for the first batch of a session, +/// and the caller seeds it silently — correctly, because a session that started +/// after a change has already read the new files and nudging it is the noise that +/// gets an advisory channel ignored. +/// +/// **That reasoning holds only at `SessionStart`.** The reporter serves exactly +/// two events, and the snapshot is seeded at the first one to arrive. So a seed +/// happening at `PostToolBatch` means `SessionStart` did not reach this code — +/// and since the host registers the engine by bare name on every event, the +/// overwhelmingly likely cause is that no `batten` resolved when that event +/// fired. Measured on the container that produced CLOUD-1085: the `SessionStart` +/// receipt was written at 04:37:21, the binary appeared at 04:39:58, and the +/// first snapshot landed at 04:40:48 — at `PostToolBatch`, three and a half +/// minutes late, with every mediated call in between failing open in silence. +/// +/// **An absent reference monitor and a passing one are indistinguishable from +/// outside**, which is the whole defect: nothing in that session reported +/// anything. This is the one place the difference is observable, and it costs +/// nothing to observe — the per-session snapshot the drift predicate already +/// keeps is the entire mechanism. +/// +/// # What it does NOT claim +/// +/// Not that the calls before it were unsafe, and not which ones they were: this +/// process cannot see them. It reports the one fact it holds — the engine did not +/// run at this session's start — and names the provisioning step, because a +/// binary that is absent when the first hook fires is a provisioning failure +/// rather than a policy one (CLOUD-824's posture: report it where it can still be +/// fixed). +/// +/// Pointer-only by construction: no path, no session id, no count of anything +/// read off the disk. The session id is a host token and would be a poor pointer +/// anyway — the reader has exactly one session. +/// +/// Rate-limited by the same write as the drift notice. The caller records the +/// snapshot in the same branch, so this is emitted once per session and never +/// again, which is what keeps it credible rather than a line everybody learns to +/// scroll past. +#[must_use] +pub fn unmediated_session() -> String { + "contract-drift: this session's SessionStart registration did not run\n\n\ + The per-session snapshot is being seeded at a later event, which means the engine\n\ + was not invoked when this session started. The hosts register it by BARE NAME, so\n\ + the usual cause is that no `batten` resolved on PATH at that moment — in which case\n\ + every mediated call until it appeared failed open and said nothing.\n\n\ + This is a provisioning failure rather than a policy one: `mise run deps-install`\n\ + installs the released binary, and `mise run deps` reports which one PATH finds.\n\n\ + Which calls preceded this is not answerable from here, and is not claimed.\n\ + Reported once per session; silence otherwise.\n" + .to_owned() +} + #[cfg(test)] #[allow(clippy::unwrap_used, clippy::expect_used)] mod tests { diff --git a/crates/batten/src/lib.rs b/crates/batten/src/lib.rs index d5807f9df..b393e0ffa 100644 --- a/crates/batten/src/lib.rs +++ b/crates/batten/src/lib.rs @@ -5452,11 +5452,26 @@ fn report_contract_drift( }; let session = envelope.session.as_deref(); - // No snapshot is the FIRST batch of this session, seeded silently. A session - // that started after a change has already read the new files at start, and - // nudging it about them is the noise that gets an advisory channel ignored. + // No snapshot is the FIRST batch of this session. A session that started + // after a change has already read the new files at start, and nudging it + // about them is the noise that gets an advisory channel ignored — so the + // seed is SILENT at `SessionStart`. + // + // AT ANY LATER EVENT THE SAME SEED IS NEWS (CLOUD-1085). This reporter serves + // exactly two events and seeds at whichever arrives first, so seeding at + // `PostToolBatch` means `SessionStart` never reached here — and the hosts + // register the engine by bare name, so the usual cause is that no binary + // resolved when that event fired. Every mediated call until one did failed + // open in silence, and this is the only place that difference is observable. + // + // Recorded before the emit, for the reason the drift notice below gives: the + // write is the rate limit, and erring toward one missed notice beats erring + // toward an unbounded repeat of the same one. let facts::Look::Is(previous) = contract::previous(&git_dir, session) else { drop(contract::record(&git_dir, session, ¤t)); + if !matches!(envelope.event, hook::Event::SessionStart) { + advice.push(contract::unmediated_session()); + } return; }; diff --git a/crates/batten/tests/contract_drift.rs b/crates/batten/tests/contract_drift.rs index a6739fc70..6ae92ef9f 100644 --- a/crates/batten/tests/contract_drift.rs +++ b/crates/batten/tests/contract_drift.rs @@ -116,16 +116,66 @@ fn notice(output: &Output) -> Option { ) } +/// **RE-DECIDED BY CLOUD-1085**, and the previous decision is quoted rather than +/// deleted because it was right about the case it named and wrong about the case +/// it covered. +/// +/// This case read: *"A session that started AFTER a change has already read the +/// new files, so nudging it about them is the noise that gets an advisory channel +/// ignored"* — and asserted silence on the first `PostToolBatch` of a session. +/// The reasoning holds at `SessionStart`, which is the event that argument is +/// about. It does not hold here, and the mirror case below is where it now lives. +/// +/// The reporter serves exactly two events and seeds at whichever arrives first, +/// so a seed at `PostToolBatch` means `SessionStart` never reached the engine. +/// Measured on the container that produced CLOUD-1085: `SessionStart` receipt at +/// 04:37:21, binary at 04:39:58, first snapshot at 04:40:48 — every mediated call +/// in that window failed open in silence, and nothing said so. This case is what +/// makes that observable. #[test] -fn the_first_batch_of_a_session_seeds_the_snapshot_silently() { - // A session that started AFTER a change has already read the new files, so - // nudging it about them is the noise that gets an advisory channel ignored. +fn a_seed_at_a_later_event_reports_the_unmediated_start() { let dir = fixture("contract-seed"); - assert_eq!(drift(&dir, "s1").pipe_notice(), None); - // And a surface that has not moved stays quiet on every later batch. + let told = drift(&dir, "s1") + .pipe_notice() + .expect("a seed at PostToolBatch means SessionStart never ran"); + assert!( + told.contains("SessionStart registration did not run"), + "the notice names the condition rather than the symptom: {told}" + ); + assert!( + told.contains("deps-install"), + "a missing binary is a PROVISIONING failure and the notice names the step: {told}" + ); + // Once per session. The write is the rate limit here exactly as it is for a + // change-set, so the very next batch is silent — without this a session with + // no binary would carry the same line on every batch it ever ran. assert_eq!(drift(&dir, "s1").pipe_notice(), None); } +/// The anti-vacuity mirror, and the home of the reasoning the case above quotes. +/// +/// A session whose `SessionStart` DID reach the engine seeds there, silently, and +/// stays silent on every later batch. Without this the case above would pass over +/// a reporter that simply nags on every seed, which is the noise the whole +/// channel is rate-limited to avoid. +/// +/// Fails by: dropping the `SessionStart` arm of the event test, so the advisory +/// fires on the seeding event itself. +#[test] +fn a_session_seeded_at_session_start_is_silent_and_stays_silent() { + let dir = fixture("contract-seeded-at-start"); + assert_eq!( + drift_on(&dir, "s1", "SessionStart", &[]).pipe_notice(), + None, + "the seeding event is the one the silence argument is about" + ); + assert_eq!( + drift(&dir, "s1").pipe_notice(), + None, + "and a surface that has not moved stays quiet on every later batch" + ); +} + #[test] fn a_moved_contract_file_is_reported_in_band() { let dir = fixture("contract-moved"); @@ -209,9 +259,25 @@ fn each_session_is_told_about_what_moved_under_it_and_not_about_the_rest() { let dir = fixture("contract-sessions"); drift(&dir, "early"); std::fs::write(dir.join("AGENTS.md"), "# the contract\nmid\n").unwrap(); - // A session whose first batch is now: it reads the CURRENT files at start, - // so its snapshot is seeded with them and it is told nothing. - assert_eq!(drift(&dir, "late").pipe_notice(), None); + // A session whose first batch is now: it reads the CURRENT files at start, so + // its snapshot is seeded with them and it is told nothing ABOUT THE DRIFT. + // + // Asserted over what the notice CLAIMS rather than over its presence, since + // CLOUD-1085. This fixture drives `PostToolBatch` only, which is by + // construction the "SessionStart never ran" condition, so `late` does now + // receive that advisory — a different notice about a different fact. The + // property this case owns is isolation: `late` never hears about a change-set + // that predates it. Asserting silence would couple this case to the presence + // of every other advisory the channel ever carries. + let late = drift(&dir, "late").pipe_notice().unwrap_or_default(); + assert!( + !late.contains("AGENTS.md"), + "a session seeded now is not told about drift that predates it: {late}" + ); + assert!( + !late.contains("changed or added"), + "and is told of no change-set at all: {late}" + ); // The session that was already running is told. assert!(drift(&dir, "early").pipe_notice().is_some()); } diff --git a/mise.toml b/mise.toml index daa57114b..7f4afe530 100644 --- a/mise.toml +++ b/mise.toml @@ -1396,6 +1396,87 @@ install -m 0755 target/release/batten "$dest/batten" echo "install:local: batten -> $dest/batten" """ +# The provisioning seam the environment's setup script ALREADY CALLS, and which +# this repository never defined (CLOUD-1085). +# +# THE CALL SITE IS NOT OURS AND IT IS NOT NEW. The container's setup script runs +# `mise run deps-install` on every provision, before any session starts, and +# soft-fails with a stderr line when the task is absent — which is what it did for +# the whole life of this defect. So the bootstrap seam was never missing; it was a +# DANGLING CALL, and defining the task is the entire fix. Nothing outside this +# repository changes, which is exactly what §1 asks for: the change is in the +# environment configuration that provisions a container, and that configuration +# already makes the call. +# +# `install.sh`, NEVER `install:local`, and the distinction is this row's own. +# The local task builds the WORKING TREE's binary — "a dev-clone convenience that +# supersedes a release build, not a provisioning path". A provisioning path cannot +# assume a Rust toolchain, a 141-second compile (measured, CLOUD-1085), or that the +# checkout builds at all. One install interface (§1); this is the caller it lacked. +# +# WHY ROUTING THROUGH `mise run` IS LOAD-BEARING rather than incidental. The setup +# script shadows `mise` with a wrapper that prepends the GitHub API and asset hosts +# to NO_PROXY and sets MISE_GITHUB_TOKEN from the session PAT, because the agent +# proxy answers 403 for third-party release repos. A task body inherits that +# wrapper's environment, so the release API is reachable here and would NOT be from +# a bare `./install.sh` in the setup script. The seam and the proxy fix are the same +# mechanism. +# +# LOUD ON FAILURE, because the defect this closes is a SILENT absence: an absent +# reference monitor and a passing one are indistinguishable from outside, and every +# mediated call in a session without a binary fails open with nothing said. The +# caller appends its own line; this supplies the `::error::` and the non-zero exit +# that make it a report rather than a shrug — CLOUD-824's posture of moving the +# missing-binary report to provisioning time, where it can still be fixed. +# +# `2` STAYS DISTINCT FROM `1` (house style §7). `install.sh` answers 1 for a refusal +# it decided — a bad digest, an unwritable destination — and 2 for could-not-look: +# no curl, or an unreachable release API. Collapsing them sends a reader with a +# network problem hunting a corrupted asset. +# +# A ONE-LINE SHIM, NOT AN INLINE BODY, and the ratchet is what corrected the first +# draft rather than merely refusing it. `inline-task-bodies-not-growing` counts +# triple-quoted `run` bodies in this file and is `non_increasing`; the first +# version of this task was a nine-line body and took the count 31 -> 33. +# +# THIS COMMENT MAY NOT QUOTE THE PATTERN IT DESCRIBES. The row matches a literal +# substring with no anchoring and no `regex` column (CLOUD-1058), so it reads +# prose as readily as code -- spelling the pattern out here incremented the count +# by one all over again, with no task added. Name the shape in words instead. +# +# The right reading of that refusal is not "waive it". `install.sh` already emits +# the diagnosis — it names the destination, whether the digest verified, and why it +# refused — and it already answers `0/1/2` on the house-style table. The body that +# was here caught that status only to re-word it, which is a verdict REPLACED +# rather than propagated (CLOUD-1090's shape, one file over). Deleting it keeps +# `install.sh`'s own words and its own exit code, and the ratchet's block records +# that a one-line `run = "…"` shim is the campaign succeeding rather than a hole. +# +# The consequence the deleted body narrated belongs here, where it costs nothing: +# when this task fails, `batten` does not resolve by name, and until one does every +# mediated call fails open in silence. The caller already appends its own line, and +# mise prints the non-zero exit. +[tasks."deps-install"] +description = "Provision the host dependencies mise does not: the released `batten`, before any hook fires (CLOUD-1085)" +run = "./install.sh" + +# The report half of the same seam, and the setup script calls this one too. +# +# A REPORT, NOT A GATE: the caller discards the status (`mise run deps || true`), +# so this exists to put the answer in the provisioning log where a human reads it, +# rather than leaving the absence to surface as an unrelated failure at first use. +# Same reason the setup script ends with `mise ls --current`. +# +# `command -v` FIRST, because it is the whole question. "A batten exists" and "the +# batten the hook registrations resolve" are different claims — +# `.claude/settings.json` registers `batten hook` by BARE NAME on every event — so +# the path PATH actually finds is the answer, and printing it is the report. It +# also supplies the failure for free: `command -v` exits non-zero when the name +# does not resolve, and `&&` carries that out without a branch to write. +[tasks.deps] +description = "Report the host dependencies `deps-install` provisions, so an absent one is visible in the setup log and not at first use" +run = "command -v batten && batten --version" + [tasks.batten-check] description = "Consumer #1: evaluate the committed batten.toml with batten's own engine against this repository" # `cargo run` rather than an installed binary, so the gate always judges the