Skip to main content

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