Skip to main content

ljos_cli/
admit.rs

1//! Origin-bound admission.
2//!
3//! An agent lesson is filed by posting the atom. A writer that admits by
4//! origin answers 400 `held as proposal <id>` and keeps that proposal,
5//! including the atom. `POST /v1/proposals/accept` with `{"workspace","id"}`
6//! writes the atom and records the acceptance. The same text held again is
7//! that proposal, so a retry does not leave another one.
8//!
9//! `POST /v1/proposals` is the miner. This path does not call it.
10//!
11//! A 400 that names `origin` as unknown, or a 404, means this writer does
12//! not hold. The proposal stays in `$XDG_STATE_HOME/ljos/proposals.jsonl`
13//! and `ljos accept` posts the atom. A writer that does not answer keeps
14//! the proposal local for this call.
15//!
16//! `origin` on a live atom is `user-declared` (a person), `agent-derived`
17//! (this seat's own agent), `tool-echo` (tool output), `external` or `peer`
18//! (another seat). A 400 that names `origin` as unknown is retried once
19//! without the field.
20//!
21//! Packset's memory levels are `explicit` and `derived`. `derived` is L0.
22//! An agent's own lesson starts there. Tool output and another seat cannot
23//! be stored above it until [`accept`] promotes the proposal to `explicit`.
24
25use std::collections::BTreeMap;
26use std::path::PathBuf;
27use std::sync::Mutex;
28
29use crate::quoted_yes::Yes;
30use crate::PacksetClient;
31use anyhow::{bail, Context, Result};
32use serde::{Deserialize, Serialize};
33use serde_json::Value;
34
35pub const ORIGIN_USER: &str = "user-declared";
36pub const ORIGIN_AGENT: &str = "agent-derived";
37pub const ORIGIN_TOOL: &str = "tool-echo";
38pub const ORIGIN_PEER: &str = "peer";
39
40/// Packset's lower memory level (`packset_core::record::LEVELS`). L0.
41pub const LEVEL_L0: &str = "derived";
42/// Packset's level for a claim a person stood behind.
43pub const LEVEL_EXPLICIT: &str = "explicit";
44
45/// The level an origin may be filed at.
46///
47/// A person's words are `explicit`. An agent's own lesson starts at L0.
48/// Tool output and another seat are capped there until [`accept`].
49#[must_use]
50pub fn admission_level(origin: &str) -> &'static str {
51    match origin {
52        ORIGIN_USER => LEVEL_EXPLICIT,
53        _ => LEVEL_L0,
54    }
55}
56
57#[derive(Debug, Clone, PartialEq, Eq)]
58pub struct Filed {
59    pub id: String,
60    pub remote: bool,
61}
62
63#[derive(Debug, Clone, Serialize, Deserialize)]
64struct Record {
65    id: String,
66    status: String,
67    remote: bool,
68    #[serde(default, skip_serializing_if = "Option::is_none")]
69    remote_id: Option<String>,
70    text: String,
71    kind: String,
72    /// `user-declared`, `agent-derived`, `tool-echo` or `peer`.
73    #[serde(default, skip_serializing_if = "String::is_empty")]
74    origin: String,
75    /// The person's words this acceptance rests on, where a seat carried
76    /// them instead of the person typing in a terminal of their own.
77    #[serde(default, skip_serializing_if = "Option::is_none")]
78    grant: Option<Yes>,
79    atom: Value,
80}
81
82fn gate() -> std::sync::MutexGuard<'static, ()> {
83    static GATE: Mutex<()> = Mutex::new(());
84    GATE.lock().unwrap_or_else(|e| e.into_inner())
85}
86
87fn origin_cache() -> &'static Mutex<BTreeMap<String, bool>> {
88    static KEPT: std::sync::OnceLock<Mutex<BTreeMap<String, bool>>> = std::sync::OnceLock::new();
89    KEPT.get_or_init(|| Mutex::new(BTreeMap::new()))
90}
91
92fn proposal_cache() -> &'static Mutex<BTreeMap<String, bool>> {
93    static MODE: std::sync::OnceLock<Mutex<BTreeMap<String, bool>>> = std::sync::OnceLock::new();
94    MODE.get_or_init(|| Mutex::new(BTreeMap::new()))
95}
96
97fn proposals_path() -> PathBuf {
98    std::env::var_os("XDG_STATE_HOME")
99        .map(PathBuf::from)
100        .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".local/state")))
101        .unwrap_or_else(|| PathBuf::from(".local/state"))
102        .join("ljos")
103        .join("proposals.jsonl")
104}
105
106fn http_timeout() -> std::time::Duration {
107    std::env::var("PACKSET_TIMEOUT_MS")
108        .ok()
109        .and_then(|v| v.trim().parse::<u64>().ok())
110        .filter(|ms| *ms > 0)
111        .map_or(
112            std::time::Duration::from_secs(30),
113            std::time::Duration::from_millis,
114        )
115}
116
117pub fn stamp_origin(atom: &mut Value, origin: &str) {
118    if let Some(map) = atom.as_object_mut() {
119        map.insert("origin".into(), Value::String(origin.to_string()));
120    }
121}
122
123/// Mark `atom` as the person's: origin `user-declared` and `source.basis`
124/// `person`. Only a person-path writer calls this; the readers take the
125/// basis, not the origin alone, as the person standing behind a claim.
126pub fn stamp_person(atom: &mut Value) {
127    stamp_origin(atom, ORIGIN_USER);
128    atom["source"]["basis"] = Value::String(crate::Basis::Person.as_str().into());
129}
130
131fn strip_origin(atom: &mut Value) {
132    if let Some(map) = atom.as_object_mut() {
133        map.remove("origin");
134    }
135}
136
137fn origin_rejected(text: &str) -> bool {
138    let lower = text.to_lowercase();
139    lower.contains("origin")
140        && (lower.contains("unknown")
141            || lower.contains("unexpected")
142            || lower.contains("not a field")
143            || lower.contains("unrecognized"))
144}
145
146/// POST an atom. When this writer refuses `origin`, the field is dropped
147/// and the post is tried once more. The answer is remembered per base URL.
148pub fn post_kept(client: &PacksetClient, atom: &Value) -> Result<Value> {
149    let base = client.base().to_string();
150    let mut body = atom.clone();
151    let known = origin_cache()
152        .lock()
153        .unwrap_or_else(|e| e.into_inner())
154        .get(&base)
155        .copied();
156    if known == Some(false) {
157        strip_origin(&mut body);
158    }
159    match client.post_atom(&body) {
160        Ok(v) => {
161            if body.get("origin").is_some() {
162                origin_cache()
163                    .lock()
164                    .unwrap_or_else(|e| e.into_inner())
165                    .insert(base, true);
166            }
167            Ok(v)
168        }
169        Err(e) => {
170            let text = e.to_string();
171            if body.get("origin").is_some() && origin_rejected(&text) {
172                origin_cache()
173                    .lock()
174                    .unwrap_or_else(|err| err.into_inner())
175                    .insert(base, false);
176                strip_origin(&mut body);
177                return client.post_atom(&body).map_err(anyhow::Error::from);
178            }
179            Err(anyhow::Error::from(e))
180        }
181    }
182}
183
184fn read_latest() -> BTreeMap<String, Record> {
185    let bytes = crate::read_state_file(&proposals_path()).unwrap_or_default();
186    crate::complete_rows::<Record>(&bytes)
187        .into_iter()
188        .map(|rec| (rec.id.clone(), rec))
189        .collect()
190}
191
192fn append(rec: &Record) -> Result<()> {
193    let path = proposals_path();
194    if let Some(dir) = path.parent() {
195        std::fs::create_dir_all(dir)?;
196    }
197    let mut line = serde_json::to_string(rec)?;
198    line.push('\n');
199    crate::append_locked_row(&path, line.as_bytes(), crate::Durability::Durable)
200        .with_context(|| format!("admit: cannot write {}", path.display()))
201}
202
203fn proposal_is_local(base: &str) -> bool {
204    proposal_cache()
205        .lock()
206        .unwrap_or_else(|e| e.into_inner())
207        .get(base)
208        .copied()
209        == Some(false)
210}
211
212fn remember_proposal_mode(base: &str, remote: bool) {
213    proposal_cache()
214        .lock()
215        .unwrap_or_else(|e| e.into_inner())
216        .insert(base.to_string(), remote);
217}
218
219/// The id in `held as proposal <id>`.
220fn held_proposal_id(text: &str) -> Option<String> {
221    let marker = "held as proposal ";
222    let rest = text.get(text.find(marker)? + marker.len()..)?;
223    let id: String = rest.chars().take_while(|c| c.is_ascii_hexdigit()).collect();
224    (id.len() == 32).then_some(id)
225}
226
227/// A writer that will not hold the atom, and did not store it.
228fn filing_is_local(text: &str) -> bool {
229    let lower = text.to_lowercase();
230    origin_rejected(text)
231        || lower.contains(": 404:")
232        || lower.contains("status code 404")
233        || (lower.contains("403") && lower.contains("not allowed"))
234}
235
236/// `Ok(None)` is a writer that does not hold. `Ok(Some(id))` is its proposal id.
237fn post_direct(client: &PacksetClient, atom: &Value) -> Result<Option<String>> {
238    let base = client.base();
239    if proposal_is_local(base) {
240        return Ok(None);
241    }
242    match client.post_atom(atom) {
243        Ok(_) => {
244            // This writer stored the atom. It does not hold proposals.
245            remember_proposal_mode(base, false);
246            Ok(None)
247        }
248        Err(e) => {
249            let text = e.to_string();
250            if let Some(id) = held_proposal_id(&text) {
251                remember_proposal_mode(base, true);
252                return Ok(Some(id));
253            }
254            if filing_is_local(&text) {
255                remember_proposal_mode(base, false);
256                return Ok(None);
257            }
258            // A writer that did not answer keeps this proposal local. The
259            // next filing tries the writer again.
260            if !text.contains("bad response") {
261                return Ok(None);
262            }
263            bail!("propose: POST /v1/atoms failed: {text}");
264        }
265    }
266}
267
268/// Write `atom` as an agent's own memory, live at once.
269///
270/// It carries origin `agent-derived`. A writer from before agent writes
271/// were live answers `held as proposal ID`; that proposal is accepted on
272/// the spot, so the memory is live there too. Nothing waits for a person.
273/// A lesson past the two-sentence cap or the character cap is posted as the
274/// shorter copies [`crate::pack_copy::pack_copies`] cuts. A preference is
275/// posted as written.
276///
277/// # Errors
278///
279/// An empty text, or the pack refusing the claim for a reason other than
280/// holding it (a secret, the size cap, a third sentence, or the word
281/// limit, grade or adverb ratio the pack holds an agent's claim to).
282pub fn write_agent(client: &PacksetClient, atom: Value) -> Result<Value> {
283    let text = atom["text"].as_str().unwrap_or("").trim().to_string();
284    if text.is_empty() {
285        bail!("remember: empty text is not a claim");
286    }
287    if atom["kind"].as_str() == Some("lesson") && past_hard_cap(&text) {
288        let copies = crate::pack_copy::pack_copies(&text);
289        let unchanged = copies.is_empty() || (copies.len() == 1 && copies[0] == text);
290        if !unchanged {
291            let mut last = None;
292            let mut first_err = None;
293            for copy in &copies {
294                let mut one = atom.clone();
295                one["text"] = Value::String(copy.clone());
296                match write_agent_one(client, one) {
297                    Ok(posted) => last = Some(posted),
298                    Err(e) if crate::note_can_wait(&e) => return Err(e),
299                    Err(e) => {
300                        if first_err.is_none() {
301                            first_err = Some(e);
302                        }
303                    }
304                }
305            }
306            if let Some(posted) = last {
307                return Ok(posted);
308            }
309            return Err(
310                first_err.unwrap_or_else(|| anyhow::anyhow!("remember: empty text is not a claim"))
311            );
312        }
313    }
314    write_agent_one(client, atom)
315}
316
317/// The two-sentence cap and the character cap. Word count, grade and
318/// adverb ratio are the checks `prefer` stores with a warning.
319fn past_hard_cap(text: &str) -> bool {
320    packset_core::prose::sentences(text).len() > packset_core::prose::MAX_ATOM_SENTENCES
321        || text.chars().count() > packset_core::record::TEXT_SOFT_CAP
322}
323
324/// One agent atom, with no further split.
325///
326/// Always origin `agent-derived` and `source.basis` `agent`, whatever the
327/// prose. The pack keeps a person's long or hard sentence with a warning
328/// and refuses an agent's; filing the agent's as the person's to get it
329/// kept would hand it to every reader as the person's words.
330pub(crate) fn write_agent_one(client: &PacksetClient, mut atom: Value) -> Result<Value> {
331    let text = atom["text"].as_str().unwrap_or("").trim();
332    if text.is_empty() {
333        bail!("remember: empty text is not a claim");
334    }
335    stamp_origin(&mut atom, ORIGIN_AGENT);
336    atom["source"]["basis"] = Value::String(crate::Basis::Agent.as_str().into());
337    match post_kept(client, &atom) {
338        Ok(posted) => Ok(posted),
339        Err(e) => {
340            let text = format!("{e:#}");
341            let Some(id) = held_proposal_id(&text) else {
342                return Err(e);
343            };
344            let workspace = atom
345                .get("workspace")
346                .and_then(Value::as_str)
347                .filter(|w| !w.is_empty())
348                .map_or_else(|| client.workspace(), str::to_string);
349            let accepted = accept_on_writer(client, &workspace, &id)?;
350            // Accept stores the atom as user-declared, and `ljos memories
351            // list` then hides it. Put the origin back so the review list
352            // the CLI reads is the store this write just landed in.
353            relabel_agent(client, &workspace, accepted)
354        }
355    }
356}
357
358/// File `atom` as an agent proposal by posting it.
359///
360/// A held answer is the proposal. The id is stable for one kind and one
361/// text, so the same lesson is one open proposal. An open, accepted or
362/// satisfied row is returned as it stands. Origin is `agent-derived`.
363pub fn propose_atom(client: &PacksetClient, atom: Value) -> Result<Filed> {
364    file_proposal(client, atom, true)
365}
366
367/// File `atom` as a proposal and do not post it.
368///
369/// `ljos finish --lesson`, `ljos findings --remember` and the correction
370/// hook use this. The claim stays out of the pack until [`accept`] promotes
371/// it. A known origin on the atom is kept. The level is [`admission_level`]:
372/// L0 (`derived`) for an agent, a tool echo and another seat, `explicit`
373/// for the person's own words.
374pub fn propose_admission(client: &PacksetClient, atom: Value) -> Result<Filed> {
375    file_proposal(client, atom, false)
376}
377
378fn file_proposal(client: &PacksetClient, mut atom: Value, post: bool) -> Result<Filed> {
379    let _g = gate();
380    let text = atom["text"].as_str().unwrap_or("").trim().to_string();
381    let kind = atom["kind"].as_str().unwrap_or("lesson").to_string();
382    if text.is_empty() {
383        bail!("propose: empty text is not a proposal");
384    }
385    if atom
386        .get("workspace")
387        .and_then(Value::as_str)
388        .unwrap_or("")
389        .is_empty()
390    {
391        if let Some(map) = atom.as_object_mut() {
392            map.insert("workspace".into(), Value::String(client.workspace()));
393        }
394    }
395    if post {
396        stamp_origin(&mut atom, ORIGIN_AGENT);
397    } else {
398        let named = atom["origin"].as_str().unwrap_or("");
399        if !packset_core::atom::known_origin(named) {
400            stamp_origin(&mut atom, ORIGIN_AGENT);
401        }
402        let origin = atom["origin"].as_str().unwrap_or(ORIGIN_AGENT).to_string();
403        if let Some(map) = atom.as_object_mut() {
404            map.insert(
405                "level".into(),
406                Value::String(admission_level(&origin).to_string()),
407            );
408        }
409    }
410    let origin = atom["origin"].as_str().unwrap_or(ORIGIN_AGENT).to_string();
411    let id = crate::work_id(&format!("{kind}\n{text}"));
412    if let Some(existing) = read_latest().get(&id) {
413        if matches!(existing.status.as_str(), "open" | "accepted" | "satisfied") {
414            return Ok(Filed {
415                id,
416                remote: existing.remote,
417            });
418        }
419    }
420    let remote_id = if post {
421        post_direct(client, &atom)?
422    } else {
423        None
424    };
425    let rec = Record {
426        id: id.clone(),
427        status: "open".into(),
428        remote: remote_id.is_some(),
429        remote_id,
430        text,
431        kind,
432        origin,
433        grant: None,
434        atom,
435    };
436    let remote = rec.remote;
437    append(&rec)?;
438    Ok(Filed { id, remote })
439}
440
441/// Raise a proposal's memory level from L0 to `explicit`.
442fn promote_level(atom: &mut Value) {
443    if let Some(map) = atom.as_object_mut() {
444        map.insert("level".into(), Value::String(LEVEL_EXPLICIT.to_string()));
445    }
446}
447
448/// The person's own text wrote the claim. An open proposal of those words
449/// is satisfied, so a later accept does not post a second atom.
450pub fn satisfy_text(text: &str) {
451    let _g = gate();
452    let text = text.trim();
453    if text.is_empty() {
454        return;
455    }
456    let open: Vec<Record> = read_latest()
457        .into_values()
458        .filter(|rec| rec.status == "open" && rec.text.trim() == text)
459        .collect();
460    for mut rec in open {
461        rec.status = "satisfied".into();
462        // A satisfied row that is not written leaves the proposal open, so a
463        // later `ljos accept` reposts it. Name the log and the error, not the
464        // proposal's text, which a row could carry a secret in.
465        if let Err(e) = append(&rec) {
466            eprintln!(
467                "ljos: a satisfied proposal was not recorded in {}: {e:#}",
468                proposals_path().display()
469            );
470        }
471        close_remote(&rec);
472    }
473}
474
475fn workspace_of(client: &PacksetClient, rec: &Record) -> String {
476    let workspace = rec
477        .atom
478        .get("workspace")
479        .and_then(Value::as_str)
480        .unwrap_or("");
481    if workspace.is_empty() {
482        client.workspace()
483    } else {
484        workspace.to_string()
485    }
486}
487
488/// Close a held proposal whose text is already a live claim. The writer's
489/// duplicate rule keeps one atom when the text and kind match.
490fn close_remote(rec: &Record) {
491    if !rec.remote {
492        return;
493    }
494    let Some(remote_id) = rec.remote_id.as_deref() else {
495        return;
496    };
497    let Ok(client) = crate::pack() else {
498        return;
499    };
500    let workspace = workspace_of(&client, rec);
501    let _ = accept_on_writer(&client, &workspace, remote_id);
502}
503
504fn accepted_line(rec: &Record, posted: &Value) -> String {
505    let origin = posted
506        .get("origin")
507        .and_then(Value::as_str)
508        .filter(|origin| !origin.is_empty())
509        .unwrap_or(ORIGIN_AGENT);
510    format!(
511        "accepted {} as {} origin {origin}{}\n{}",
512        rec.id,
513        rec.kind,
514        crate::revision_note(posted),
515        rec.grant.as_ref().map(Yes::line).unwrap_or_default()
516    )
517}
518
519/// Accept rises the origin to `user-declared`. An agent write is reviewed
520/// afterwards, so the live atom has to stay `agent-derived` or the default
521/// memory list never shows it. One update, on an atom just stored.
522fn relabel_agent(client: &PacksetClient, workspace: &str, accepted: Value) -> Result<Value> {
523    let origin = accepted.get("origin").and_then(Value::as_str).unwrap_or("");
524    if origin == ORIGIN_AGENT {
525        return Ok(accepted);
526    }
527    let Some(id) = accepted
528        .get("id")
529        .and_then(Value::as_str)
530        .filter(|id| !id.is_empty())
531    else {
532        bail!(
533            "accept stored the lesson without an id, so its origin could not be put back to agent-derived"
534        );
535    };
536    let body = serde_json::json!({
537        "workspace": workspace,
538        "id": id,
539        "fields": {"origin": ORIGIN_AGENT},
540    });
541    let bytes = serde_json::to_vec(&body).unwrap_or_default();
542    match client.roundtrip(
543        "POST",
544        "/v1/atoms/update",
545        &[],
546        Some(&bytes),
547        true,
548        http_timeout(),
549    ) {
550        Ok(answer) if (200..300).contains(&answer.status) => serde_json::from_str(&answer.body)
551            .context("update: the pack's answer was not json"),
552        Ok(answer) => bail!(
553            "the lesson {id} was stored as {origin}; putting the origin back to agent-derived failed: {}: {}",
554            answer.status,
555            answer.body
556        ),
557        Err(e) => bail!(
558            "the lesson {id} was stored as {origin}; putting the origin back to agent-derived failed: {e}"
559        ),
560    }
561}
562
563fn accept_on_writer(client: &PacksetClient, workspace: &str, id: &str) -> Result<Value> {
564    let body = serde_json::json!({"workspace": workspace, "id": id});
565    let bytes = serde_json::to_vec(&body).unwrap_or_default();
566    match client.roundtrip(
567        "POST",
568        "/v1/proposals/accept",
569        &[],
570        Some(&bytes),
571        true,
572        http_timeout(),
573    ) {
574        Ok(answer) if (200..300).contains(&answer.status) => {
575            serde_json::from_str(&answer.body).context("accept: the pack's answer was not json")
576        }
577        Ok(answer) => {
578            bail!(
579                "accept: POST /v1/proposals/accept failed: {}: {}",
580                answer.status,
581                answer.body
582            )
583        }
584        Err(e) => bail!("accept: POST /v1/proposals/accept failed: {e}"),
585    }
586}
587
588/// The full id of an open proposal a short id names; see
589/// [`crate::resolve_short_id`].
590///
591/// # Errors
592///
593/// The short id starts more than one open proposal.
594pub fn resolve_id(id: &str) -> Result<String> {
595    let open: Vec<String> = read_latest()
596        .into_values()
597        .filter(|r| r.status == "open")
598        .map(|r| r.id)
599        .collect();
600    crate::resolve_short_id("accept", id, &open)
601}
602
603/// Every acceptance that rests on a person's words a seat carried, by
604/// proposal id. An audit reads these to see which lessons went in on a
605/// quoted yes rather than from a terminal of the person's own.
606#[must_use]
607pub fn granted() -> Vec<(String, Yes)> {
608    let mut out: Vec<(String, Yes)> = read_latest()
609        .into_values()
610        .filter(|rec| rec.status == "accepted")
611        .filter_map(|rec| rec.grant.map(|yes| (rec.id, yes)))
612        .collect();
613    out.sort_by(|a, b| a.1.at.cmp(&b.1.at).then_with(|| a.0.cmp(&b.0)));
614    out
615}
616
617/// The conversation that filed an open proposal: the holder its atom's
618/// source names. `None` when the id is not on file or the atom names none.
619#[must_use]
620pub fn proposed_by(id: &str) -> Option<String> {
621    let rec = read_latest().get(id.trim()).cloned()?;
622    rec.atom["source"]["session"]
623        .as_str()
624        .map(str::trim)
625        .filter(|s| !s.is_empty())
626        .map(str::to_string)
627}
628
629/// Write one open proposal into the pack. A satisfied proposal was already
630/// written by `remember` or `prefer`. An accepted one is already there.
631pub fn accept(id: &str) -> Result<String> {
632    accept_on(id, None)
633}
634
635/// [`accept`], recording the person's words the acceptance rests on.
636///
637/// `yes` is a yes a seat carried from chat, see [`crate::quoted_yes`]. It
638/// is kept on the proposal's accepted row, beside the acceptance it
639/// stands for, and said back on the line the caller prints. `None` is the
640/// terminal path, where the person ran the accept themselves.
641pub fn accept_on(id: &str, yes: Option<&crate::quoted_yes::Yes>) -> Result<String> {
642    let _g = gate();
643    let id = id.trim();
644    if id.is_empty() {
645        bail!("accept: a proposal id is required");
646    }
647    let Some(mut rec) = read_latest().get(id).cloned() else {
648        bail!("accept: no open proposal {id}");
649    };
650    rec.grant = yes.cloned();
651    match rec.status.as_str() {
652        "satisfied" => {
653            close_remote(&rec);
654            return Ok(format!("{id} was already written by remember or prefer\n"));
655        }
656        "accepted" => return Ok(format!("{id} is already in the pack\n")),
657        "open" => {}
658        _ => bail!("accept: no open proposal {id}"),
659    }
660    let client = crate::pack()?;
661    if rec.remote {
662        let remote_id = rec.remote_id.clone().unwrap_or_else(|| rec.id.clone());
663        let workspace = workspace_of(&client, &rec);
664        let posted = accept_on_writer(&client, &workspace, &remote_id)?;
665        rec.status = "accepted".into();
666        append(&rec)?;
667        return Ok(accepted_line(&rec, &posted));
668    }
669    // A person accepting the proposal is the promotion off L0. Tool output
670    // and another seat stay at `derived` until this line. The origin stays,
671    // so a reader can still see where the claim came from.
672    promote_level(&mut rec.atom);
673    match post_kept(&client, &rec.atom) {
674        Ok(posted) => {
675            rec.status = "accepted".into();
676            append(&rec)?;
677            Ok(accepted_line(&rec, &posted))
678        }
679        Err(e) => {
680            let text = e.to_string();
681            let Some(remote_id) = held_proposal_id(&text) else {
682                return Err(e).with_context(|| format!("accept: POST /v1/atoms failed for {id}"));
683            };
684            // Filed while this writer was not holding, or the post is the
685            // filing. Accept that proposal. Do not leave it open.
686            let workspace = workspace_of(&client, &rec);
687            let posted = accept_on_writer(&client, &workspace, &remote_id)?;
688            rec.remote = true;
689            rec.remote_id = Some(remote_id);
690            rec.status = "accepted".into();
691            append(&rec)?;
692            Ok(accepted_line(&rec, &posted))
693        }
694    }
695}
696
697#[cfg(test)]
698mod tests {
699    use super::*;
700    use std::os::unix::fs::PermissionsExt;
701
702    fn record(id: String) -> Record {
703        Record {
704            id,
705            status: "proposed".into(),
706            remote: false,
707            remote_id: None,
708            text: "use uv for python envs".into(),
709            kind: "lesson".into(),
710            origin: ORIGIN_AGENT.into(),
711            grant: None,
712            atom: serde_json::json!({"text": "use uv for python envs"}),
713        }
714    }
715
716    /// A held proposal carries the whole atom, so its row is private and
717    /// written whole, like the other state logs.
718    #[test]
719    fn concurrent_proposal_rows_each_parse_and_stay_private() {
720        let _g = crate::test_env_lock();
721        let dir = tempfile::tempdir().unwrap();
722        unsafe { std::env::set_var("XDG_STATE_HOME", dir.path()) };
723        const THREADS: usize = 16;
724        const EACH: usize = 8;
725        let handles: Vec<_> = (0..THREADS)
726            .map(|thread| {
727                std::thread::spawn(move || {
728                    for n in 0..EACH {
729                        append(&record(format!("{thread}-{n}"))).unwrap();
730                    }
731                })
732            })
733            .collect();
734        for handle in handles {
735            handle.join().expect("proposal writer");
736        }
737        let path = proposals_path();
738        let text = std::fs::read_to_string(&path).unwrap();
739        for line in text.lines() {
740            serde_json::from_str::<Record>(line)
741                .unwrap_or_else(|err| panic!("proposal row does not parse ({err}): {line}"));
742        }
743        assert_eq!(read_latest().len(), THREADS * EACH, "{text}");
744        let mode = std::fs::symlink_metadata(&path)
745            .unwrap()
746            .permissions()
747            .mode()
748            & 0o777;
749        assert_eq!(mode, 0o600, "proposals mode {mode:o}");
750    }
751
752    /// A proposal still being appended is not yet held; the proposals
753    /// before it are.
754    #[test]
755    fn a_torn_last_proposal_leaves_the_others() {
756        let _g = crate::test_env_lock();
757        let dir = tempfile::tempdir().unwrap();
758        unsafe { std::env::set_var("XDG_STATE_HOME", dir.path()) };
759        append(&record("a".into())).unwrap();
760        let path = proposals_path();
761        let mut bytes = std::fs::read(&path).unwrap();
762        bytes.extend_from_slice(b"{\"id\":\"b\",\"text\":\"caf\xc3");
763        std::fs::write(&path, bytes).unwrap();
764        let ids: Vec<String> = read_latest().into_keys().collect();
765        assert_eq!(ids, ["a"]);
766    }
767
768    /// A proposals file swapped for a symlink is not written through.
769    #[test]
770    fn a_symlinked_proposals_file_is_refused() {
771        let _g = crate::test_env_lock();
772        let dir = tempfile::tempdir().unwrap();
773        unsafe { std::env::set_var("XDG_STATE_HOME", dir.path()) };
774        let path = proposals_path();
775        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
776        let outside = dir.path().join("outside");
777        std::fs::write(&outside, "kept\n").unwrap();
778        std::os::unix::fs::symlink(&outside, &path).unwrap();
779        assert!(append(&record("a".into())).is_err());
780        assert_eq!(std::fs::read_to_string(&outside).unwrap(), "kept\n");
781    }
782}