gage 0.3.6

Hand context between agents — files, prompts and decisions — with a trust boundary that survives the hop.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
//! Local state: what you have read, and what you consider settled.
//!
//! None of this lives in a bundle, and that is the point. A sender must not be
//! able to mark their own message read in your inbox, or declare a thread
//! answered because they would like it to be. It is the same rule that keeps
//! trust out of the manifest: anything the sender controls is a claim, and
//! claims do not get to be your state.
//!
//! Thread state is derived, not stored, wherever it can be: a thread is open
//! if its newest message expects a reply and nothing later answered it. Stored
//! state is only the overrides a person makes by hand, so the common case
//! stays correct without anyone maintaining it.

use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
use std::fs;
use std::io;
use std::path::Path;
use std::time::{Duration, Instant};

#[derive(Debug, Default, Serialize, Deserialize)]
pub struct LocalState {
    /// Bundle ids that have been rendered by `gage open`.
    #[serde(default)]
    pub read: BTreeSet<String>,
    /// thread id -> an explicit override, when someone closes a thread by
    /// hand. Absent means "work it out from the messages".
    #[serde(default)]
    pub closed: BTreeMap<String, String>,
    /// A monotonic counter, bumped once per observation batch.
    ///
    /// Ordering by COUNT rather than by clock, because every timestamp-based
    /// version of this was wrong in a different way. `created` is the signer's
    /// field. A local wall clock has one-second resolution here, so a witness
    /// written in the same second as a retirement fails a strict `<`. And a
    /// string compare over RFC3339 accepted `""`, `"...00.9Z"` and
    /// `"...T01:00:00-14:00"` as earlier than noon. A counter has none of
    /// those properties: it only ever increases, and "before the retirement"
    /// is a total order with no parsing in it.
    #[serde(default)]
    pub seq: u64,
    /// witness -> the `seq` at which this machine first observed it.
    ///
    /// The witness is `sha256(from.key || manifest bytes)`, NOT the bundle id.
    /// `bundle::load` never recomputes the id, so the signer picks it: keying
    /// on the id let a holder of a retired key reuse an id this machine had
    /// already stamped, attach a different payload, and inherit the witness.
    /// The manifest bytes are what the signature covers, and the key is in
    /// there for domain separation.
    #[serde(default)]
    pub witness: BTreeMap<String, u64>,
}

/// A kernel advisory lock around `state.json`.
///
/// Both halves of read-modify-write used to happen on a value loaded earlier,
/// and the whole file was written back whole. The viewer polls every 2.5
/// seconds while the CLI writes, so two processes could each load the old
/// state and overwrite the other's changes -- silently losing a witness,
/// which is the one record that must not be lost.
///
/// The first fix was a lockfile with an owner nonce and an age-based staleness
/// breaker. The codex reviewer took it apart: read-then-unlink is not atomic,
/// so a paused breaker could delete a lock somebody else had since taken, and
/// a crash between creating the file and writing the nonce left an empty lock
/// that the breaker refused to touch -- wedging every later write forever.
/// Age cannot prove abandonment and a userspace protocol cannot fix that.
///
/// `flock` can. The kernel releases it when the file descriptor closes, which
/// includes the process dying for any reason, so there is no staleness to
/// detect, nothing to break, and no unlink to race. The lock file is created
/// once and left in place; it is the descriptor that carries the lock, not
/// the name.
pub struct Lock(fs::File);

#[cfg(unix)]
mod flock {
    unsafe extern "C" {
        fn flock(fd: i32, operation: i32) -> i32;
    }
    pub const EX: i32 = 2;
    pub const NB: i32 = 4;
    pub const UN: i32 = 8;

    pub fn try_lock(fd: i32) -> bool {
        unsafe { flock(fd, EX | NB) == 0 }
    }
    pub fn unlock(fd: i32) {
        unsafe { flock(fd, UN) };
    }
}

#[cfg(unix)]
impl Drop for Lock {
    fn drop(&mut self) {
        use std::os::unix::io::AsRawFd;
        flock::unlock(self.0.as_raw_fd());
    }
}

#[cfg(unix)]
pub fn take_lock(target: &Path) -> io::Result<Lock> {
    use std::os::unix::io::AsRawFd;
    let lp = target.with_extension("json.lock");
    if let Some(d) = lp.parent() {
        fs::create_dir_all(d)?;
    }
    let f = fs::OpenOptions::new()
        .create(true)
        .write(true)
        .truncate(false)
        .open(&lp)?;
    // Generous, because the retirement path holds this across a scan of the
    // inbox. Nothing here waits on a human or on the network, so a hold that
    // outlasts this really is a stuck process rather than a slow one.
    let deadline = Instant::now() + Duration::from_secs(30);
    loop {
        if flock::try_lock(f.as_raw_fd()) {
            return Ok(Lock(f));
        }
        if Instant::now() > deadline {
            return Err(io::Error::new(
                io::ErrorKind::TimedOut,
                format!(
                    "{} stayed locked; another gage process is stuck",
                    lp.display()
                ),
            ));
        }
        std::thread::sleep(Duration::from_millis(20));
    }
}

#[cfg(not(unix))]
pub fn take_lock(_target: &Path) -> io::Result<Lock> {
    Err(io::Error::new(
        io::ErrorKind::Unsupported,
        "state locking needs a unix host",
    ))
}

impl LocalState {
    fn path() -> std::path::PathBuf {
        crate::identity::home().join("state.json")
    }

    pub fn load() -> Self {
        std::fs::read_to_string(Self::path())
            .ok()
            .and_then(|s| serde_json::from_str(&s).ok())
            .unwrap_or_default()
    }

    /// Apply a change under the lock, against what is on disk right now.
    ///
    /// The closure returns whether anything changed, so a no-op does not
    /// rewrite the file. Callers hold a `LocalState` for reading and come
    /// through here to write, which is why there is no public `save`: a
    /// whole-file save from a stale value is exactly the lost-update bug.
    pub fn mutate<F>(f: F) -> std::io::Result<Self>
    where
        F: FnOnce(&mut Self) -> bool,
    {
        let p = Self::path();
        let _guard = take_lock(&p)?;
        let mut cur = Self::load();
        if f(&mut cur) {
            let tmp = p.with_extension(format!("json.tmp.{}", std::process::id()));
            std::fs::write(&tmp, serde_json::to_vec_pretty(&cur)?)?;
            std::fs::rename(&tmp, &p)?;
        }
        Ok(cur)
    }

    pub fn is_read(&self, id: &str) -> bool {
        self.read.contains(id)
    }

    pub fn mark_read(&mut self, id: &str) -> std::io::Result<()> {
        let owned = id.to_string();
        Self::mutate(move |s| s.read.insert(owned))?;
        self.read.insert(id.to_string());
        Ok(())
    }

    /// The sequence at which a bundle was first observed here, if ever.
    pub fn witnessed(&self, witness: &str) -> Option<u64> {
        self.witness.get(witness).copied()
    }

    /// Record observations and return the state as it now stands on disk.
    ///
    /// One batch, one `seq`. Bundles observed in the same batch are not
    /// ordered against each other, which is correct: nothing distinguishes
    /// them, and inventing an order would be a claim this machine cannot
    /// support.
    pub fn observe(witnesses: &[String]) -> std::io::Result<Self> {
        Self::mutate(|s| {
            let next = s.seq + 1;
            let mut added = false;
            for w in witnesses {
                if !s.witness.contains_key(w) {
                    s.witness.insert(w.clone(), next);
                    added = true;
                }
            }
            if added {
                s.seq = next;
            }
            added
        })
    }

    /// Observe the inbox and take the retirement cut as ONE locked step.
    ///
    /// Doing these separately left a window: the scan finished, a concurrent
    /// pull delivered an old-key bundle and stamped it with the same batch
    /// number, and only then was the cut taken -- so an arrival from after
    /// the decision landed below the line. The scan therefore runs inside the
    /// lock, and the cut is reserved above the batch it records, so anything
    /// observed afterwards is necessarily above it.
    pub fn observe_and_cut<F>(scan: F) -> std::io::Result<u64>
    where
        F: FnOnce(&dyn Fn(&str) -> bool) -> Vec<String>,
    {
        let mut cut = 0;
        Self::mutate(|s| {
            let batch = s.seq + 1;
            cut = s.seq + 2;
            let already: BTreeSet<String> = s.witness.keys().cloned().collect();
            for w in scan(&|x| already.contains(x)) {
                s.witness.entry(w).or_insert(batch);
            }
            s.seq = cut;
            true
        })?;
        Ok(cut)
    }

    pub fn close(&mut self, thread: &str) -> std::io::Result<()> {
        let owned = thread.to_string();
        Self::mutate(move |s| s.closed.insert(owned, "closed".into()).is_none())?;
        self.closed.insert(thread.to_string(), "closed".into());
        Ok(())
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ThreadState {
    /// Newest message expects a reply and none has come.
    AwaitingYou,
    /// You asked; nothing has come back.
    AwaitingThem,
    /// Nothing outstanding.
    Settled,
    /// Closed by hand.
    Closed,
}

impl ThreadState {
    pub fn label(&self) -> &'static str {
        match self {
            ThreadState::AwaitingYou => "awaiting YOU",
            ThreadState::AwaitingThem => "awaiting them",
            ThreadState::Settled => "settled",
            ThreadState::Closed => "closed",
        }
    }
}

pub struct Thread {
    pub id: String,
    pub subject: String,
    pub messages: Vec<crate::manifest::Manifest>,
    pub unread: usize,
    pub state: ThreadState,
    /// Distinct sender keys other than yours, in first-seen order. More than
    /// one means a reply has more than one possible destination.
    pub others: Vec<String>,
    /// Message ids whose claim to belong to this thread could not be checked
    /// against anything held locally.
    pub unverified: Vec<String>,
}

/// Can this message's claim to belong to a thread be checked locally?
///
/// `thread` is a field the SENDER writes, and the grouper used to honour it
/// outright. That let anyone who had seen one message of a conversation graft
/// a bundle onto it: group by the claim, and `gage reply` -- which picks the
/// newest message that is not yours -- would then resolve the recipient to
/// the grafter instead of the person you were talking to. A sender-controlled
/// field decided who your reply went to, in a codebase whose rule is that
/// trust is computed by the receiver and never carried.
///
/// So the claim is checked against what is held locally:
///
/// * no claim, or a claim to be its own root: nothing to forge.
/// * a parent that is held, and really is in the claimed thread: verified.
/// * a parent that is held and is in a DIFFERENT thread: the chain
///   contradicts the claim, so the claim loses and the message starts its own.
/// * a parent that is not held: honoured, because over a relay you may hold
///   only part of a thread, but marked unverified. `resolve_reply` refuses to
///   take a recipient from one of these, and the viewer says so.
fn thread_claim(m: &crate::manifest::Manifest, held: &BTreeMap<String, String>) -> (String, bool) {
    let Some(claim) = m.thread.as_deref() else {
        return (m.id.clone(), true);
    };
    if claim == m.id {
        return (m.id.clone(), true);
    }
    let Some(parent) = m.in_reply_to.as_deref() else {
        // Claims a thread while answering nothing. There is no chain to check,
        // so it does not get to join.
        return (m.id.clone(), false);
    };
    match held.get(parent) {
        Some(pt) if pt == claim => (claim.to_string(), true),
        Some(_) => (m.id.clone(), false),
        None => (claim.to_string(), false),
    }
}

/// Group every bundle you hold into threads, newest activity first.
///
/// `mine` is your own signing key, which is how an outstanding question is
/// told from one you are waiting on: the direction of the newest message.
pub fn threads(
    manifests: Vec<crate::manifest::Manifest>,
    st: &LocalState,
    mine: &str,
) -> Vec<Thread> {
    // First pass: what each held message says its thread is, so a claim can be
    // checked against a parent. Built from the raw claims on purpose -- the
    // check only needs to know whether the parent is in the SAME thread, and
    // a one-level comparison cannot be walked into a loop.
    let held: BTreeMap<String, String> = manifests
        .iter()
        .map(|m| (m.id.clone(), m.thread_id().to_string()))
        .collect();

    let mut by: BTreeMap<String, Vec<crate::manifest::Manifest>> = BTreeMap::new();
    let mut unverified: BTreeMap<String, Vec<String>> = BTreeMap::new();
    for m in manifests {
        let (tid, ok) = thread_claim(&m, &held);
        if !ok {
            unverified
                .entry(tid.clone())
                .or_default()
                .push(m.id.clone());
        }
        by.entry(tid).or_default().push(m);
    }
    let mut out: Vec<Thread> = by
        .into_iter()
        .map(|(id, mut msgs)| {
            // Root first, then by time, then by id so the order is total and
            // stable rather than dependent on directory iteration.
            msgs.sort_by(|a, b| {
                (a.id != id, &a.created, &a.id).cmp(&(b.id != id, &b.created, &b.id))
            });
            // Your own messages are never unread. Counting them makes every
            // thread you started look like it needs attention.
            let unread = msgs
                .iter()
                .filter(|m| m.from.key != mine && !st.is_read(&m.id))
                .count();
            // The root is the message whose own id IS the thread id, not
            // whichever sorted first. `created` has one-second resolution, so
            // a question and its reply sent in the same second can invert and
            // the thread ends up named after the reply ("re: ...").
            let subject = msgs
                .iter()
                .find(|m| m.id == id)
                .or_else(|| msgs.first())
                .map(|m| m.subject.clone())
                .unwrap_or_else(|| "(no subject)".into());
            let state = if st.closed.contains_key(&id) {
                ThreadState::Closed
            } else {
                match msgs.last() {
                    Some(last) if last.msg_type.expects_reply() => {
                        if last.from.key == mine {
                            ThreadState::AwaitingThem
                        } else {
                            ThreadState::AwaitingYou
                        }
                    }
                    _ => ThreadState::Settled,
                }
            };
            // Who a reply could go to. Order is oldest first, which makes the
            // original correspondent first and a late graft last.
            let mut others: Vec<String> = Vec::new();
            for m in &msgs {
                if m.from.key != mine && !others.contains(&m.from.key) {
                    others.push(m.from.key.clone());
                }
            }
            let unverified = unverified.remove(&id).unwrap_or_default();
            Thread {
                id,
                subject,
                messages: msgs,
                unread,
                state,
                others,
                unverified,
            }
        })
        .collect();
    out.sort_by(|a, b| {
        let an = a
            .messages
            .last()
            .map(|m| m.created.clone())
            .unwrap_or_default();
        let bn = b
            .messages
            .last()
            .map(|m| m.created.clone())
            .unwrap_or_default();
        bn.cmp(&an)
    });
    out
}