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
57 changes: 57 additions & 0 deletions crates/batten/src/contract.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
21 changes: 18 additions & 3 deletions crates/batten/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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, &current));
if !matches!(envelope.event, hook::Event::SessionStart) {
advice.push(contract::unmediated_session());
}
return;
};

Expand Down
82 changes: 74 additions & 8 deletions crates/batten/tests/contract_drift.rs
Original file line number Diff line number Diff line change
Expand Up @@ -116,16 +116,66 @@ fn notice(output: &Output) -> Option<String> {
)
}

/// **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");
Expand Down Expand Up @@ -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());
}
Expand Down
81 changes: 81 additions & 0 deletions mise.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading