flint_lean/control.rs
1//! The `.flint/` control namespace (boundary-verbs plan §2.0, D0/D11):
2//! the agent's side of the file protocol.
3//!
4//! The app container ships UNCHANGED — no HTTP client, no CLI binary,
5//! no library can be assumed in its image. The only interface every
6//! agent is guaranteed to have is the shared workspace mount, so the
7//! boundary verbs are *files*. This module owns everything the syncer
8//! writes there:
9//!
10//! - `capabilities.json` — the marker an agent MUST check before
11//! touching a sentinel. Absence ⇒ an old syncer ⇒ sentinels are live
12//! ammunition (they would be scanned and published, §2.0).
13//! - `remote.seq` — the news ticker (D5), fed from information the
14//! barrier already has: zero added bucket requests.
15//! - the pre-existing-`.flint/` pre-flight (D0.4), which disables
16//! sentinel consumption for a workspace that was already using the
17//! namespace as data.
18//!
19//! **Trust statement (plan §1.2):** every file here is writable by
20//! every process sharing the mount. Acks are advisory coordination
21//! signals, NOT attestations — an in-pod process can forge one. The
22//! authoritative durability signal is a remote read the pod cannot
23//! forge (gateway `GET /status`, or the manifest itself).
24
25use std::path::{Path, PathBuf};
26
27use serde::{Deserialize, Serialize};
28
29use super::{now_unix, LeanResult, Syncer, SENTINEL_PROTOCOL};
30
31pub const CAPABILITIES: &str = "capabilities.json";
32pub const REMOTE_SEQ: &str = "remote.seq";
33pub const PUBLISH: &str = "publish";
34pub const PUBLISH_ACK: &str = "publish.ack";
35pub const SYNC: &str = "sync";
36pub const SYNC_ACK: &str = "sync.ack";
37
38/// State-dir file recording the sticky pre-flight verdict. Sticky
39/// matters: the marker's own absence is one of the pre-flight's inputs,
40/// so a second startup would otherwise see the marker it just wrote and
41/// silently re-enable the verbs it disabled.
42const POSTURE: &str = "sentinels.json";
43
44#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct BootStamp {
46 pub holder_id: String,
47 pub boot_unix: u64,
48}
49
50/// `.flint/capabilities.json` (D11). Written atomically at EVERY `run`
51/// startup — after claim succeeds, before the first poll — and again
52/// during first checkout, so it exists the instant the agent may start.
53///
54/// The startup write is load-bearing on the live-tree restart row:
55/// `checkout()` returns at `marker_present()` without materializing
56/// anything, so pinning the marker write inside checkout would upgrade
57/// a fleet whose live workspaces never get it — sentinels dead on
58/// exactly the pods the upgrade targeted.
59#[derive(Debug, Clone, Serialize, Deserialize)]
60pub struct Capabilities {
61 pub protocol: u32,
62 pub verbs: Vec<String>,
63 pub boundary_mode: String,
64 /// "live" | "fenced" (D2). A fenced syncer advertises no verbs, so
65 /// agents stop touching sentinels on a zombie.
66 pub state: String,
67 #[serde(skip_serializing_if = "Option::is_none")]
68 pub reason: Option<String>,
69 pub sentinel_min_interval_secs: u64,
70 pub sentinel_hourly_budget: u64,
71 pub syncer_version: String,
72 /// The rollback tell (D11): an agent that observes a syncer
73 /// restart while this stamp is UNCHANGED is looking at a stale
74 /// marker left by a downgrade — the safety catch painted green.
75 pub boot: BootStamp,
76}
77
78/// `.flint/remote.seq` — the news ticker (D5).
79///
80/// `observed_seq > integrated_seq` means "there is news you have not
81/// integrated — consider dropping a `.flint/sync` at your next safe
82/// point". The agent polls one LOCAL file: no network, no client, no
83/// credentials, and N agents cost zero bucket requests rather than
84/// N×HEAD.
85#[derive(Debug, Clone, Default, Serialize, Deserialize)]
86pub struct RemoteSeq {
87 pub observed_seq: u64,
88 #[serde(skip_serializing_if = "Option::is_none")]
89 pub observed_etag: Option<String>,
90 pub integrated_seq: u64,
91 /// Refreshed on EVERY successful HEAD tick — a local rename, zero
92 /// bucket cost. Without the heartbeat an agent cannot distinguish
93 /// "no news" from "syncer dead" on an idle-but-healthy workspace.
94 /// Contract: older than 3×floor ⇒ syncer or proxy problem.
95 pub updated_unix: u64,
96 /// Advisory news from the gateway (D14): a HITL/CI party asked this
97 /// workspace to pull. The syncer performs NO tree mutation on
98 /// receipt — it moves the ticker and stops.
99 #[serde(skip_serializing_if = "Option::is_none")]
100 pub sync_requested_unix: Option<u64>,
101 #[serde(skip_serializing_if = "Option::is_none")]
102 pub sync_requested_by: Option<String>,
103 /// `integrated_seq` at the moment the request was carried. Once the
104 /// workspace integrates past it the request is stale and clears
105 /// itself — the agent pulled, whether or not it did so on account
106 /// of the request.
107 #[serde(skip_serializing_if = "Option::is_none")]
108 pub sync_requested_at_seq: Option<u64>,
109}
110
111/// The sticky pre-flight verdict (D0.4).
112#[derive(Debug, Clone, Serialize, Deserialize)]
113pub struct SentinelPosture {
114 pub enabled: bool,
115 /// Fleet-visible reason, mirrored into `capabilities.json` and the
116 /// `SentinelVerbsActive` condition.
117 #[serde(skip_serializing_if = "Option::is_none")]
118 pub reason: Option<String>,
119}
120
121pub fn write_atomic(path: &Path, bytes: &[u8]) -> LeanResult<()> {
122 // `.flint/` is app-writable by construction — the agent drops its
123 // sentinels there — so both the parent and the temp name are
124 // attacker-reachable, and `remote.seq` is rewritten every tick.
125 super::safefs::check_parent(path)?;
126 if let Some(parent) = path.parent() {
127 std::fs::create_dir_all(parent)?;
128 }
129 let tmp = path.with_file_name(format!(
130 "{}.tmp",
131 path.file_name().map(|n| n.to_string_lossy()).unwrap_or_default()
132 ));
133 super::safefs::write_via_tmp(path, &tmp, bytes, None).map(|_| ())
134}
135
136fn write_json<T: Serialize>(path: &Path, v: &T) -> LeanResult<()> {
137 let bytes = serde_json::to_vec_pretty(v)
138 .map_err(|e| super::LeanError::State(format!("{}: {e}", path.display())))?;
139 write_atomic(path, &bytes)
140}
141
142impl Syncer {
143 pub fn control_path(&self, name: &str) -> PathBuf {
144 self.cfg.control_dir().join(name)
145 }
146
147 fn posture_path(&self) -> PathBuf {
148 self.cfg.state_dir().join(POSTURE)
149 }
150
151 pub fn load_posture(&self) -> LeanResult<Option<SentinelPosture>> {
152 let p = self.posture_path();
153 if !p.exists() {
154 return Ok(None);
155 }
156 let bytes = std::fs::read(&p)?;
157 serde_json::from_slice(&bytes)
158 .map(Some)
159 .map_err(|e| super::LeanError::State(format!("posture: {e}")))
160 }
161
162 /// The pre-existing-`.flint/` pre-flight (D0.4).
163 ///
164 /// Reserving `.flint/` is a BREAKING change for a workspace already
165 /// using it as data: its writes silently stop publishing and —
166 /// worse — files literally named `.flint/publish` or `.flint/sync`
167 /// would be *consumed* (renamed away) by the sentinel poll: a data
168 /// grab from a non-participating workspace. When either signature
169 /// is present, sentinel consumption is disabled for the workspace:
170 /// the poll arm never arms, `capabilities.json` reports no verbs
171 /// with the reason, and the operator surfaces it as
172 /// `SentinelVerbsActive=False`.
173 ///
174 /// The verdict is STICKY once written — see `POSTURE`.
175 pub fn sentinel_preflight(&self) -> LeanResult<SentinelPosture> {
176 if self.cfg.sentinel_mode == super::SentinelMode::Off {
177 let posture =
178 SentinelPosture { enabled: false, reason: Some("sentinels-off".into()) };
179 write_json(&self.posture_path(), &posture)?;
180 return Ok(posture);
181 }
182 if let Some(prior) = self.load_posture()? {
183 if !prior.enabled && self.cfg.sentinel_mode != super::SentinelMode::Force {
184 return Ok(prior);
185 }
186 }
187
188 // (a) the baseline cites a path under the reserved namespace.
189 let baseline = self.state.load_baseline()?;
190 let legacy_cited = baseline.entries.keys().any(|p| super::scan::is_control_path(p))
191 || baseline.inst_base.keys().any(|p| super::scan::is_control_path(p));
192
193 // (b) a sentinel-named file exists before this tree has ever
194 // been given a capability marker (i.e. before any syncer
195 // that understands the protocol ran here).
196 let never_marked = !self.control_path(CAPABILITIES).exists();
197 let sentinel_named = self.control_path(PUBLISH).exists() || self.control_path(SYNC).exists();
198 let preexisting = legacy_cited || (never_marked && sentinel_named);
199
200 let posture = if preexisting && self.cfg.sentinel_mode != super::SentinelMode::Force {
201 SentinelPosture { enabled: false, reason: Some("preexisting-flint-paths".into()) }
202 } else if preexisting {
203 // `force` requires the operator of record to have accepted
204 // the consumption behavior explicitly.
205 SentinelPosture { enabled: true, reason: Some("forced-over-preexisting".into()) }
206 } else {
207 SentinelPosture { enabled: true, reason: None }
208 };
209 write_json(&self.posture_path(), &posture)?;
210 Ok(posture)
211 }
212
213 /// Write `.flint/capabilities.json` for the current posture.
214 pub fn write_capabilities(&self, posture: &SentinelPosture, fenced: bool) -> LeanResult<()> {
215 let verbs: Vec<String> = if fenced || !posture.enabled {
216 vec![]
217 } else {
218 ["publish", "sync", "remote-seq"].iter().map(|s| s.to_string()).collect()
219 };
220 let holder_id = self
221 .lease
222 .as_ref()
223 .map(|l| l.holder_id.clone())
224 .or_else(|| {
225 self.state.load_incarnation().ok().flatten().map(|i| i.holder_id)
226 })
227 .unwrap_or_else(|| "unclaimed".into());
228 let caps = Capabilities {
229 protocol: SENTINEL_PROTOCOL,
230 verbs,
231 boundary_mode: self.cfg.boundary_mode.as_str().to_string(),
232 state: if fenced { "fenced".into() } else { "live".into() },
233 reason: posture.reason.clone(),
234 sentinel_min_interval_secs: self.cfg.sentinel_min_interval_secs,
235 sentinel_hourly_budget: self.cfg.sentinel_hourly_budget,
236 syncer_version: super::SYNCER_VERSION.to_string(),
237 boot: BootStamp { holder_id, boot_unix: now_unix() },
238 };
239 write_json(&self.control_path(CAPABILITIES), &caps)
240 }
241
242 pub fn read_capabilities(&self) -> Option<Capabilities> {
243 let bytes = std::fs::read(self.control_path(CAPABILITIES)).ok()?;
244 serde_json::from_slice(&bytes).ok()
245 }
246
247 pub fn load_remote_seq(&self) -> RemoteSeq {
248 std::fs::read(self.control_path(REMOTE_SEQ))
249 .ok()
250 .and_then(|b| serde_json::from_slice(&b).ok())
251 .unwrap_or_default()
252 }
253
254 /// Move the ticker (D5). `observed` is what the barrier's existing
255 /// manifest HEAD (or its completed install) already told us — this
256 /// never issues a request of its own.
257 ///
258 /// `updated_unix` refreshes on every call (the liveness heartbeat);
259 /// `observed_seq`/`observed_etag` change only when they change.
260 pub fn touch_remote_seq(
261 &self,
262 observed_seq: Option<u64>,
263 observed_etag: Option<String>,
264 integrated_seq: u64,
265 ) -> LeanResult<()> {
266 let mut t = self.load_remote_seq();
267 if let Some(seq) = observed_seq {
268 t.observed_seq = seq;
269 }
270 if observed_etag.is_some() {
271 t.observed_etag = observed_etag;
272 }
273 t.integrated_seq = integrated_seq;
274 if t.observed_seq < integrated_seq {
275 // Our own install is the newest news we know of.
276 t.observed_seq = integrated_seq;
277 }
278 // A request the workspace has already integrated past is stale
279 // news and self-clears (D14).
280 if t.sync_requested_at_seq.map(|at| integrated_seq > at).unwrap_or(false) {
281 t.sync_requested_unix = None;
282 t.sync_requested_by = None;
283 t.sync_requested_at_seq = None;
284 }
285 t.updated_unix = now_unix();
286 write_json(&self.control_path(REMOTE_SEQ), &t)
287 }
288
289 /// Carry an advisory sync request into the ticker (D14). The
290 /// asymmetry with `boundary_request` is about blast radius, not
291 /// principle: a boundary publishes what is already on disk and
292 /// touches no local file, whereas `sync` re-derives the tree
293 /// against the current remote manifest and DELETES local files for
294 /// remotely-deleted paths. Performing that on a remote's say-so
295 /// would upgrade what a leaked gateway bearer can do from "publish,
296 /// plus hand over these N named objects" to "rewrite and delete
297 /// across a running agent's tree, at my timing, under a scope I
298 /// choose".
299 pub fn carry_sync_request(&self, requested_unix: u64, requestor: &str) -> LeanResult<()> {
300 let mut t = self.load_remote_seq();
301 let newer = t.sync_requested_unix.map(|p| requested_unix > p).unwrap_or(true);
302 if !newer {
303 return Ok(());
304 }
305 t.sync_requested_unix = Some(requested_unix);
306 t.sync_requested_by = Some(requestor.to_string());
307 t.sync_requested_at_seq = Some(t.integrated_seq);
308 t.updated_unix = now_unix();
309 write_json(&self.control_path(REMOTE_SEQ), &t)
310 }
311}