Skip to main content

ljos_cli/
admit.rs

1//! Origin-bound admission.
2//!
3//! Every memory a seat writes is stored at once and labelled with whose it
4//! is. `origin` on a live atom is `user-declared` (a person),
5//! `agent-derived` (this seat's own agent), `tool-echo` (tool output),
6//! `external` or `peer` (another seat), and `source.basis` is `person` or
7//! `agent`. A 400 that names `origin` as unknown is retried once without
8//! the field.
9//!
10//! Packset's memory levels are `explicit` and `derived`. `derived` is L0.
11//! An agent's lesson from `ljos finish`, tool output and another seat are
12//! stored there, and [`accept`] raises one to `explicit` and records the
13//! review. Search reads neither label, so it finds the claim at once.
14//!
15//! A writer that holds agent writes answers 400 `held as proposal <id>`.
16//! `POST /v1/proposals/accept` with `{"workspace","id"}` stores it there
17//! and then, so nothing waits for a person.
18//!
19//! An older ljos kept such lessons as proposals in
20//! `$XDG_STATE_HOME/ljos/proposals.jsonl` and stored one only on `ljos
21//! accept`. [`import_open`] stores each open one once, labelled as it was
22//! filed.
23
24use std::collections::BTreeMap;
25use std::path::PathBuf;
26use std::sync::Mutex;
27
28use crate::quoted_yes::Yes;
29use crate::PacksetClient;
30use anyhow::{bail, Context, Result};
31use serde::{Deserialize, Serialize};
32use serde_json::Value;
33
34pub const ORIGIN_USER: &str = "user-declared";
35pub const ORIGIN_AGENT: &str = "agent-derived";
36pub const ORIGIN_TOOL: &str = "tool-echo";
37pub const ORIGIN_PEER: &str = "peer";
38
39/// Packset's lower memory level (`packset_core::record::LEVELS`). L0.
40pub const LEVEL_L0: &str = "derived";
41/// Packset's level for a claim a person stood behind.
42pub const LEVEL_EXPLICIT: &str = "explicit";
43
44/// The level an origin is stored at.
45///
46/// A person's words are `explicit`. An agent's lesson, tool output and
47/// another seat start at L0 until [`accept`].
48#[must_use]
49pub fn admission_level(origin: &str) -> &'static str {
50    match origin {
51        ORIGIN_USER => LEVEL_EXPLICIT,
52        _ => LEVEL_L0,
53    }
54}
55
56#[derive(Debug, Clone, Serialize, Deserialize)]
57struct Record {
58    id: String,
59    /// `open`, `stored`, `refused`, `accepted` or `satisfied`.
60    status: String,
61    remote: bool,
62    #[serde(default, skip_serializing_if = "Option::is_none")]
63    remote_id: Option<String>,
64    /// The live atom this row was stored as.
65    #[serde(default, skip_serializing_if = "Option::is_none")]
66    stored: Option<String>,
67    text: String,
68    kind: String,
69    /// `user-declared`, `agent-derived`, `tool-echo` or `peer`.
70    #[serde(default, skip_serializing_if = "String::is_empty")]
71    origin: String,
72    /// The person's words this acceptance rests on, where a seat carried
73    /// them instead of the person typing in a terminal of their own.
74    #[serde(default, skip_serializing_if = "Option::is_none")]
75    grant: Option<Yes>,
76    /// The seat that filed the proposal. Empty on a row written before
77    /// seats were recorded.
78    #[serde(default, skip_serializing_if = "String::is_empty")]
79    seat: String,
80    /// When the latest outcome was written, RFC 3339.
81    #[serde(default, skip_serializing_if = "String::is_empty")]
82    at: String,
83    /// The pack's reason, when the outcome is `refused`.
84    #[serde(default, skip_serializing_if = "String::is_empty")]
85    reason: String,
86    /// A revised text. The original `text` stays. Absent until
87    /// `ljos proposals amend`.
88    #[serde(default, skip_serializing_if = "Option::is_none")]
89    amendment: Option<Amendment>,
90    atom: Value,
91}
92
93/// A revised proposal text, who wrote it, and when.
94#[derive(Debug, Clone, Serialize, Deserialize)]
95struct Amendment {
96    text: String,
97    seat: String,
98    at: String,
99}
100
101fn gate() -> std::sync::MutexGuard<'static, ()> {
102    static GATE: Mutex<()> = Mutex::new(());
103    GATE.lock().unwrap_or_else(|e| e.into_inner())
104}
105
106fn origin_cache() -> &'static Mutex<BTreeMap<String, bool>> {
107    static KEPT: std::sync::OnceLock<Mutex<BTreeMap<String, bool>>> = std::sync::OnceLock::new();
108    KEPT.get_or_init(|| Mutex::new(BTreeMap::new()))
109}
110
111fn proposals_path() -> PathBuf {
112    std::env::var_os("XDG_STATE_HOME")
113        .map(PathBuf::from)
114        .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".local/state")))
115        .unwrap_or_else(|| PathBuf::from(".local/state"))
116        .join("ljos")
117        .join("proposals.jsonl")
118}
119
120/// The marker that says no proposal is left open to import.
121fn imported_path() -> PathBuf {
122    proposals_path().with_file_name("proposals-imported")
123}
124
125fn http_timeout() -> std::time::Duration {
126    std::env::var("PACKSET_TIMEOUT_MS")
127        .ok()
128        .and_then(|v| v.trim().parse::<u64>().ok())
129        .filter(|ms| *ms > 0)
130        .map_or(
131            std::time::Duration::from_secs(30),
132            std::time::Duration::from_millis,
133        )
134}
135
136pub fn stamp_origin(atom: &mut Value, origin: &str) {
137    if let Some(map) = atom.as_object_mut() {
138        map.insert("origin".into(), Value::String(origin.to_string()));
139    }
140}
141
142/// Mark `atom` as the person's: origin `user-declared` and `source.basis`
143/// `person`. Only a person-path writer calls this; the readers take the
144/// basis, not the origin alone, as the person standing behind a claim.
145pub fn stamp_person(atom: &mut Value) {
146    stamp_origin(atom, ORIGIN_USER);
147    atom["source"]["basis"] = Value::String(crate::Basis::Person.as_str().into());
148}
149
150fn strip_origin(atom: &mut Value) {
151    if let Some(map) = atom.as_object_mut() {
152        map.remove("origin");
153    }
154}
155
156fn origin_rejected(text: &str) -> bool {
157    let lower = text.to_lowercase();
158    lower.contains("origin")
159        && (lower.contains("unknown")
160            || lower.contains("unexpected")
161            || lower.contains("not a field")
162            || lower.contains("unrecognized"))
163}
164
165/// POST an atom. When this writer refuses `origin`, the field is dropped
166/// and the post is tried once more. The answer is remembered per base URL.
167pub fn post_kept(client: &PacksetClient, atom: &Value) -> Result<Value> {
168    let base = client.base().to_string();
169    let mut body = atom.clone();
170    let known = origin_cache()
171        .lock()
172        .unwrap_or_else(|e| e.into_inner())
173        .get(&base)
174        .copied();
175    if known == Some(false) {
176        strip_origin(&mut body);
177    }
178    match client.post_atom(&body) {
179        Ok(v) => {
180            if body.get("origin").is_some() {
181                origin_cache()
182                    .lock()
183                    .unwrap_or_else(|e| e.into_inner())
184                    .insert(base, true);
185            }
186            Ok(v)
187        }
188        Err(e) => {
189            let text = e.to_string();
190            if body.get("origin").is_some() && origin_rejected(&text) {
191                origin_cache()
192                    .lock()
193                    .unwrap_or_else(|err| err.into_inner())
194                    .insert(base, false);
195                strip_origin(&mut body);
196                return client.post_atom(&body).map_err(anyhow::Error::from);
197            }
198            Err(anyhow::Error::from(e))
199        }
200    }
201}
202
203fn read_latest() -> BTreeMap<String, Record> {
204    let bytes = crate::read_state_file(&proposals_path()).unwrap_or_default();
205    crate::complete_rows::<Record>(&bytes)
206        .into_iter()
207        .map(|rec| (rec.id.clone(), rec))
208        .collect()
209}
210
211fn append(rec: &Record) -> Result<()> {
212    let path = proposals_path();
213    if let Some(dir) = path.parent() {
214        std::fs::create_dir_all(dir)?;
215    }
216    let mut line = serde_json::to_string(rec)?;
217    line.push('\n');
218    crate::append_locked_row(&path, line.as_bytes(), crate::Durability::Durable)
219        .with_context(|| format!("admit: cannot write {}", path.display()))
220}
221
222/// The id in `held as proposal <id>`.
223fn held_proposal_id(text: &str) -> Option<String> {
224    let marker = "held as proposal ";
225    let rest = text.get(text.find(marker)? + marker.len()..)?;
226    let id: String = rest.chars().take_while(|c| c.is_ascii_hexdigit()).collect();
227    (id.len() == 32).then_some(id)
228}
229
230/// Write `atom` as an agent's own memory, live at once, with origin
231/// `agent-derived`. Nothing waits for a person. A text the pack's prose
232/// gate would turn away is stored as typed, with the gate's reason as a
233/// label ([`write_fitted`]).
234///
235/// # Errors
236///
237/// An empty text, or the pack refusing the claim for a reason that is not
238/// the prose gate: a secret, an instruction to the model, an unknown kind.
239pub fn write_agent(client: &PacksetClient, atom: Value) -> Result<Value> {
240    write_fitted(client, atom, true, write_agent_one)
241}
242
243/// Post `atom` with `post`, text unchanged. The pack holds an agent's and
244/// a hook's claim (`held`) to its sentence, grade and adverb limits and
245/// keeps any other with a warning. A limit is a warning, not a rewrite:
246/// the stored text is the text that was typed. When the pack refuses that
247/// prose, one local record keeps it and search shows that one row. A
248/// `note` is not held to the gate.
249fn write_fitted(
250    client: &PacksetClient,
251    atom: Value,
252    held: bool,
253    post: fn(&PacksetClient, Value) -> Result<Value>,
254) -> Result<Value> {
255    let text = atom["text"].as_str().unwrap_or("").trim().to_string();
256    if text.is_empty() {
257        crate::memory_port::keep_empty("lesson");
258        return Err(crate::policy_label::labelled(
259            "empty-input",
260            "remember: empty text is not a claim",
261        ));
262    }
263    let refused = (held && atom["kind"].as_str() != Some(packset_core::record::NOTE_KIND))
264        .then(|| crate::pack_copy::gate_refusal(&text))
265        .flatten();
266    if crate::pack_copy::reject_unsafe(&text).is_err() {
267        return post(client, atom);
268    }
269    if let Some(why) = refused.as_deref() {
270        let kind = atom["kind"].as_str().unwrap_or("lesson");
271        crate::memory_port::keep_prose(&text, kind, why);
272    }
273    match post(client, atom.clone()) {
274        Ok(mut posted) => {
275            label_prose(&mut posted, refused.as_deref());
276            Ok(posted)
277        }
278        Err(e) if refused.is_some() && !crate::note_can_wait(&e) => {
279            crate::verbatim::keep(&atom, refused.as_deref().unwrap_or(""))
280        }
281        Err(e) => Err(e),
282    }
283}
284
285/// Put `warning` on `posted` when the pack did not already name one.
286fn label_prose(posted: &mut Value, warning: Option<&str>) {
287    let Some(why) = warning.filter(|why| !why.is_empty()) else {
288        return;
289    };
290    let Some(map) = posted.as_object_mut() else {
291        return;
292    };
293    let prose = map
294        .entry("prose".to_string())
295        .or_insert_with(|| Value::Object(Default::default()));
296    let Some(fields) = prose.as_object_mut() else {
297        return;
298    };
299    if fields
300        .get("warning")
301        .and_then(Value::as_str)
302        .is_some_and(|have| !have.is_empty())
303    {
304        return;
305    }
306    fields.insert("warning".into(), Value::String(why.to_string()));
307}
308
309/// One agent atom, with no further split.
310///
311/// Always origin `agent-derived` and `source.basis` `agent`, whatever the
312/// prose. The pack keeps a person's long or hard sentence with a warning
313/// and refuses an agent's; filing the agent's as the person's to get it
314/// kept would hand it to every reader as the person's words.
315pub(crate) fn write_agent_one(client: &PacksetClient, mut atom: Value) -> Result<Value> {
316    let text = atom["text"].as_str().unwrap_or("").trim();
317    if text.is_empty() {
318        crate::memory_port::keep_empty("lesson");
319        return Err(crate::policy_label::labelled(
320            "empty-input",
321            "remember: empty text is not a claim",
322        ));
323    }
324    stamp_origin(&mut atom, ORIGIN_AGENT);
325    atom["source"]["basis"] = Value::String(crate::Basis::Agent.as_str().into());
326    if atom["source"]["seat"]
327        .as_str()
328        .unwrap_or("")
329        .trim()
330        .is_empty()
331    {
332        let seat = proposing_seat();
333        if !seat.is_empty() {
334            atom["source"]["seat"] = Value::String(seat);
335        }
336    }
337    post_live(client, atom)
338}
339
340/// Post `atom` as it is labelled. A writer that holds the write answers
341/// `held as proposal ID`; that proposal is accepted on the spot, so the
342/// claim is live there too, and the origin `atom` carries is put back.
343fn post_live(client: &PacksetClient, atom: Value) -> Result<Value> {
344    match post_kept(client, &atom) {
345        Ok(posted) => Ok(posted),
346        Err(e) => {
347            let text = format!("{e:#}");
348            let Some(id) = held_proposal_id(&text) else {
349                return Err(e);
350            };
351            let workspace = atom
352                .get("workspace")
353                .and_then(Value::as_str)
354                .filter(|w| !w.is_empty())
355                .map_or_else(|| client.workspace(), str::to_string);
356            let accepted = accept_on_writer(client, &workspace, &id)?;
357            let origin = atom["origin"].as_str().unwrap_or(ORIGIN_AGENT);
358            relabel(client, &workspace, accepted, origin)
359        }
360    }
361}
362
363/// Store `atom` at once, as its labels say: a known origin is kept, any
364/// other is `agent-derived`, a claim that is not the person's has
365/// `source.basis` `agent`, and the level is [`admission_level`]. `ljos
366/// finish --lesson`, `ljos findings --remember` and the correction hook
367/// write this way. Search finds the claim at once; [`accept`] raises it.
368///
369/// # Errors
370///
371/// An empty text, or the pack refusing the claim for a reason other than
372/// its prose gate.
373pub fn store_labelled(client: &PacksetClient, mut atom: Value) -> Result<Value> {
374    if atom["text"].as_str().unwrap_or("").trim().is_empty() {
375        crate::memory_port::keep_empty("lesson");
376        return Err(crate::policy_label::labelled(
377            "empty-input",
378            "store: empty text is not a claim",
379        ));
380    }
381    if atom
382        .get("workspace")
383        .and_then(Value::as_str)
384        .unwrap_or("")
385        .is_empty()
386    {
387        atom["workspace"] = Value::String(client.workspace());
388    }
389    if !packset_core::atom::known_origin(atom["origin"].as_str().unwrap_or("")) {
390        stamp_origin(&mut atom, ORIGIN_AGENT);
391    }
392    let origin = atom["origin"].as_str().unwrap_or(ORIGIN_AGENT).to_string();
393    if origin != ORIGIN_USER && atom["source"]["basis"].as_str().is_none() {
394        atom["source"]["basis"] = Value::String(crate::Basis::Agent.as_str().into());
395    }
396    atom["level"] = Value::String(admission_level(&origin).to_string());
397    // The pack's `validate` asks with the hold on: its prose gate is for
398    // what an agent or a hook writes, whether the writer holds or not.
399    // Packset used to export this as `needs_accept(origin, hook, hold)`
400    // with the hold forced on. A hook write and an agent-derived write
401    // are the ones the prose gate reads before the post.
402    let held = atom["hook"] == Value::Bool(true) || origin == ORIGIN_AGENT;
403    write_fitted(client, atom, held, post_live)
404}
405
406/// How long one hook call spends storing the proposals an older ljos left
407/// open. The prompt's search after it must keep its fused pool, which
408/// waits up to [`crate::HOOK_RERANK_BUDGET_MS`], or recall is lost. The
409/// rest of [`crate::HOOK_WALL_BUDGET_MS`] is split evenly with the
410/// cross-encoder stage. Each can be cut without harm: the rows left go
411/// on the next call, and a cut rerank answers with the fused pool.
412pub const IMPORT_SLICE_MS: u64 = (crate::HOOK_WALL_BUDGET_MS - crate::HOOK_RERANK_BUDGET_MS) / 2;
413
414/// What one [`import_open`] did.
415#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
416pub struct Imported {
417    /// Rows stored as live memories.
418    pub stored: usize,
419    /// Rows still open for a later call.
420    pub left: usize,
421}
422
423/// Store each proposal an older ljos left open, once, as the labelled
424/// memory its row holds ([`store_labelled`]), and mark the row `stored`
425/// with the atom's id. A row the pack turns away is marked `refused` and
426/// keeps its atom, so `ljos accept` can try it again; no row is dropped.
427/// No post starts once `budget_ms` is spent, and a pack that does not
428/// answer ends the call; the rows left stay open for the next one. Once
429/// none is left, a marker beside the file spares later calls the read.
430pub fn import_open(budget_ms: u64) -> Imported {
431    let _deadline = crate::pack_link::CommandDeadline::enter(crate::IMPORT_ADMISSION);
432    let marker = imported_path();
433    if marker.exists() || !proposals_path().exists() {
434        return Imported::default();
435    }
436    let _g = gate();
437    let open: Vec<Record> = read_latest()
438        .into_values()
439        .filter(|rec| rec.status == "open")
440        .collect();
441    let started = std::time::Instant::now();
442    let budget = std::time::Duration::from_millis(budget_ms);
443    let mut client = crate::pack().ok();
444    let mut out = Imported::default();
445    for mut rec in open {
446        let Some(live) = client.as_ref().filter(|_| started.elapsed() < budget) else {
447            out.left += 1;
448            continue;
449        };
450        let stored = if rec.remote {
451            import_held(live, &rec)
452        } else {
453            store_labelled(live, rec.atom.clone())
454        };
455        match stored {
456            Ok(posted) => {
457                rec.status = "stored".into();
458                rec.stored = posted["id"].as_str().map(str::to_string);
459            }
460            Err(e) if crate::note_can_wait(&e) => {
461                client = None;
462                out.left += 1;
463                continue;
464            }
465            Err(err) => {
466                let text = rec.atom["text"].as_str().unwrap_or("");
467                let kind = rec.atom["kind"].as_str().unwrap_or("lesson");
468                crate::memory_port::keep_prose(text, kind, &format!("legacy proposal: {err:#}"));
469                rec.status = "refused".into();
470            }
471        }
472        match append(&rec) {
473            Ok(()) if rec.status == "stored" => out.stored += 1,
474            Ok(()) => {}
475            Err(_) => out.left += 1,
476        }
477    }
478    if out.left == 0 {
479        let _ = std::fs::write(&marker, format!("{}\n", crate::now_utc()));
480    }
481    out
482}
483
484/// A row whose atom a writer holds is accepted there, then given back the
485/// origin and level it was filed under. A writer that no longer holds it
486/// is posted the row's atom.
487fn import_held(client: &PacksetClient, rec: &Record) -> Result<Value> {
488    let workspace = workspace_of(client, rec);
489    let remote_id = rec.remote_id.as_deref().unwrap_or(&rec.id);
490    let accepted = match accept_on_writer(client, &workspace, remote_id) {
491        Ok(accepted) => accepted,
492        Err(e) if crate::note_can_wait(&e) => return Err(e),
493        Err(_) => return store_labelled(client, rec.atom.clone()),
494    };
495    let origin = if rec.origin.is_empty() {
496        ORIGIN_AGENT
497    } else {
498        rec.origin.as_str()
499    };
500    let Some(id) = accepted["id"].as_str().filter(|id| !id.is_empty()) else {
501        bail!("import: the writer stored {remote_id} without an id");
502    };
503    update_fields(
504        client,
505        &workspace,
506        id,
507        serde_json::json!({"origin": origin, "level": admission_level(origin)}),
508    )
509}
510
511/// Raise a proposal's memory level from L0 to `explicit`.
512fn promote_level(atom: &mut Value) {
513    if let Some(map) = atom.as_object_mut() {
514        map.insert("level".into(), Value::String(LEVEL_EXPLICIT.to_string()));
515    }
516}
517
518/// Make `atom` a standing explicit claim. The origin is left as stored,
519/// so a reader can still see where it came from. Accept is what the
520/// prompt hook reads as promotion: the horizon tag, and the keep verdict
521/// the caller records.
522fn stand_for_prompt(atom: &mut Value) {
523    promote_level(atom);
524    let mut entities = atom
525        .get("entities")
526        .and_then(Value::as_array)
527        .cloned()
528        .unwrap_or_default();
529    let standing = entities
530        .iter()
531        .any(|entity| entity.as_str() == Some("horizon:standing"));
532    entities.retain(|entity| entity.as_str() != Some("horizon:transient"));
533    if !standing {
534        entities.push(Value::String("horizon:standing".to_string()));
535    }
536    atom["entities"] = Value::Array(entities);
537}
538
539/// Fields [`stand_for_prompt`] changes on `before`, or an empty object
540/// when the claim is already standing and explicit.
541fn prompt_fields(before: &Value) -> Value {
542    let mut after = before.clone();
543    stand_for_prompt(&mut after);
544    let mut fields = serde_json::Map::new();
545    if after.get("level") != before.get("level") {
546        if let Some(level) = after.get("level") {
547            fields.insert("level".to_string(), level.clone());
548        }
549    }
550    if after.get("entities") != before.get("entities") {
551        if let Some(entities) = after.get("entities") {
552            fields.insert("entities".to_string(), entities.clone());
553        }
554    }
555    Value::Object(fields)
556}
557
558/// The person's own text wrote the claim. An open proposal of those words
559/// is satisfied, so a later accept does not post a second atom.
560pub fn satisfy_text(text: &str) {
561    let _g = gate();
562    let text = text.trim();
563    if text.is_empty() {
564        return;
565    }
566    let open: Vec<Record> = read_latest()
567        .into_values()
568        .filter(|rec| rec.status == "open" && rec.text.trim() == text)
569        .collect();
570    for mut rec in open {
571        rec.status = "satisfied".into();
572        // A satisfied row that is not written leaves the proposal open, so a
573        // later `ljos accept` reposts it. Name the log and the error, not the
574        // proposal's text, which a row could carry a secret in.
575        if let Err(e) = append(&rec) {
576            eprintln!(
577                "ljos: a satisfied proposal was not recorded in {}: {e:#}",
578                proposals_path().display()
579            );
580        }
581        close_remote(&rec);
582    }
583}
584
585fn workspace_of(client: &PacksetClient, rec: &Record) -> String {
586    let workspace = rec
587        .atom
588        .get("workspace")
589        .and_then(Value::as_str)
590        .unwrap_or("");
591    if workspace.is_empty() {
592        client.workspace()
593    } else {
594        workspace.to_string()
595    }
596}
597
598/// Close a held proposal whose text is already a live claim. The writer's
599/// duplicate rule keeps one atom when the text and kind match.
600fn close_remote(rec: &Record) {
601    if !rec.remote {
602        return;
603    }
604    let Some(remote_id) = rec.remote_id.as_deref() else {
605        return;
606    };
607    let Ok(client) = crate::pack() else {
608        return;
609    };
610    let workspace = workspace_of(&client, rec);
611    let _ = accept_on_writer(&client, &workspace, remote_id);
612}
613
614fn accepted_line(rec: &Record, posted: &Value) -> String {
615    let origin = posted
616        .get("origin")
617        .and_then(Value::as_str)
618        .filter(|origin| !origin.is_empty())
619        .unwrap_or(ORIGIN_AGENT);
620    format!(
621        "accepted {} as {} origin {origin}{}\n{}",
622        rec.id,
623        rec.kind,
624        crate::revision_note(posted),
625        rec.grant.as_ref().map(Yes::line).unwrap_or_default()
626    )
627}
628
629/// Accept on a writer that held the write stores it as `user-declared`.
630/// Put back the origin it was written with, or the memory list and every
631/// reader that goes by origin would take it for the person's. One update,
632/// on an atom just stored.
633fn relabel(
634    client: &PacksetClient,
635    workspace: &str,
636    accepted: Value,
637    origin: &str,
638) -> Result<Value> {
639    let stored = accepted.get("origin").and_then(Value::as_str).unwrap_or("");
640    if stored == origin {
641        return Ok(accepted);
642    }
643    let Some(id) = accepted
644        .get("id")
645        .and_then(Value::as_str)
646        .filter(|id| !id.is_empty())
647    else {
648        bail!("accept stored the claim without an id, so its origin could not be put back to {origin}");
649    };
650    update_fields(client, workspace, id, serde_json::json!({"origin": origin})).with_context(|| {
651        format!("the claim {id} was stored as {stored}; putting the origin back to {origin} failed")
652    })
653}
654
655/// `POST /v1/atoms/update` of `fields` on atom `id`. The pack's answer.
656fn update_fields(
657    client: &PacksetClient,
658    workspace: &str,
659    id: &str,
660    fields: Value,
661) -> Result<Value> {
662    let body = serde_json::json!({"workspace": workspace, "id": id, "fields": fields});
663    let bytes = serde_json::to_vec(&body).unwrap_or_default();
664    match client.roundtrip(
665        "POST",
666        "/v1/atoms/update",
667        &[],
668        Some(&bytes),
669        true,
670        http_timeout(),
671    ) {
672        Ok(answer) if (200..300).contains(&answer.status) => {
673            serde_json::from_str(&answer.body).context("update: the pack's answer was not json")
674        }
675        Ok(answer) => bail!("update: {}: {}", answer.status, answer.body),
676        Err(e) => bail!("update: {e}"),
677    }
678}
679
680fn accept_on_writer(client: &PacksetClient, workspace: &str, id: &str) -> Result<Value> {
681    let body = serde_json::json!({"workspace": workspace, "id": id});
682    let bytes = serde_json::to_vec(&body).unwrap_or_default();
683    match client.roundtrip(
684        "POST",
685        "/v1/proposals/accept",
686        &[],
687        Some(&bytes),
688        true,
689        http_timeout(),
690    ) {
691        Ok(answer) if (200..300).contains(&answer.status) => {
692            serde_json::from_str(&answer.body).context("accept: the pack's answer was not json")
693        }
694        Ok(answer) => {
695            bail!(
696                "accept: POST /v1/proposals/accept failed: {}: {}",
697                answer.status,
698                answer.body
699            )
700        }
701        Err(e) => bail!("accept: POST /v1/proposals/accept failed: {e}"),
702    }
703}
704
705/// Every acceptance that rests on a person's words a seat carried, by
706/// proposal id. An audit reads these to see which lessons went in on a
707/// quoted yes rather than from a terminal of the person's own.
708#[must_use]
709pub fn granted() -> Vec<(String, Yes)> {
710    let mut out: Vec<(String, Yes)> = read_latest()
711        .into_values()
712        .filter(|rec| rec.status == "accepted")
713        .filter_map(|rec| rec.grant.map(|yes| (rec.id, yes)))
714        .collect();
715    out.sort_by(|a, b| a.1.at.cmp(&b.1.at).then_with(|| a.0.cmp(&b.0)));
716    out
717}
718
719/// Accept a stored memory, or a proposal an older ljos filed. See
720/// [`accept_on`].
721pub fn accept(id: &str) -> Result<String> {
722    accept_on(id, None)
723}
724
725/// Accept each id or prefix. One result line per id: `accepted`,
726/// `already in pack`, `not found`, or `refused` with the pack's reason.
727/// A refusal names the measured value and the limit. One id does not
728/// stop the rest.
729#[must_use]
730pub fn accept_ids(ids: &[String], yes: Option<&crate::quoted_yes::Yes>) -> String {
731    let mut out = String::new();
732    for id in ids {
733        let line = match accept_on(id, yes) {
734            Ok(line) => line,
735            Err(err) => format!("refused {}: {err:#}\n", id.trim()),
736        };
737        if line.ends_with('\n') {
738            out.push_str(&line);
739        } else {
740            out.push_str(&line);
741            out.push('\n');
742        }
743    }
744    out
745}
746
747/// File an open proposal from this seat. The row names the seat, so a
748/// later list can count proposals per seat.
749///
750/// # Errors
751///
752/// The text is empty, or the proposal log cannot be written.
753pub fn file_open(text: &str, kind: &str) -> Result<String> {
754    let text = text.trim();
755    if text.is_empty() {
756        bail!("proposals: empty text is not a proposal");
757    }
758    let kind = if kind.trim().is_empty() {
759        "lesson"
760    } else {
761        kind.trim()
762    };
763    let seat = proposing_seat();
764    let id = proposal_id(text, &seat);
765    let rec = Record {
766        id: id.clone(),
767        status: "open".into(),
768        remote: false,
769        remote_id: None,
770        stored: None,
771        text: text.to_string(),
772        kind: kind.to_string(),
773        origin: ORIGIN_AGENT.into(),
774        grant: None,
775        seat: seat.clone(),
776        at: crate::now_utc(),
777        reason: String::new(),
778        amendment: None,
779        atom: serde_json::json!({
780            "kind": kind,
781            "text": text,
782            "origin": ORIGIN_AGENT,
783            "level": LEVEL_L0,
784            "source": {"seat": &seat, "basis": "agent"},
785        }),
786    };
787    append(&rec)?;
788    Ok(format!("{id}\n"))
789}
790
791/// The seat filing this proposal: the one this call resolved, not a
792/// placeholder and not an empty name. Empty when nothing resolved, which
793/// the listing prints as `seat unknown`.
794fn proposing_seat() -> String {
795    let seat = crate::seat_name();
796    if seat.is_empty() || crate::omitted_actor_name(&seat) {
797        String::new()
798    } else {
799        seat
800    }
801}
802
803fn proposal_id(text: &str, seat: &str) -> String {
804    use sha2::{Digest, Sha256};
805    let digest = Sha256::digest(format!("{seat}\n{text}").as_bytes());
806    digest.iter().map(|b| format!("{b:02x}")).take(16).collect()
807}
808
809/// Record a revised text on proposal `id` and run the readability check
810/// accept uses. The original text stays on the row. Nothing is accepted.
811///
812/// # Errors
813///
814/// The text is empty, or the proposal log cannot be written.
815pub fn amend_proposal(id: &str, text: &str) -> Result<String> {
816    let text = text.trim();
817    if text.is_empty() {
818        bail!("proposals: empty text is not an amendment");
819    }
820    let _g = gate();
821    let id = id.trim();
822    if id.is_empty() {
823        bail!("proposals: an id is required");
824    }
825    let rows = read_latest();
826    let known: Vec<String> = rows.keys().cloned().collect();
827    let id = match crate::resolve_short_id("proposals", id, &known) {
828        Ok(id) => id,
829        Err(err) => return Ok(format!("not found {id}: {err:#}\n")),
830    };
831    let id = id.as_str();
832    let Some(mut rec) = rows.get(id).cloned() else {
833        return Ok(format!("not found {id}\n"));
834    };
835    if !matches!(rec.status.as_str(), "open" | "proposed" | "refused") {
836        return Ok(format!("already in pack {id}\n"));
837    }
838    rec.amendment = Some(Amendment {
839        text: text.to_string(),
840        seat: crate::seat_name(),
841        at: crate::now_utc(),
842    });
843    rec.atom["text"] = Value::String(text.to_string());
844    if let Some(why) = crate::pack_copy::gate_refusal(text) {
845        rec.status = "refused".to_string();
846        rec.reason = why.clone();
847        rec.at = crate::now_utc();
848        append(&rec)?;
849        return Ok(amend_refusal(id, text, &why));
850    }
851    rec.status = "open".to_string();
852    rec.reason.clear();
853    rec.at = crate::now_utc();
854    append(&rec)?;
855    Ok(format!("ok {id}: passes (ready for ljos accept)\n"))
856}
857
858/// `refused <id>: <reason>; <value> vs limit <limit>: "<sentence>"`.
859fn amend_refusal(id: &str, text: &str, why: &str) -> String {
860    let quote = trim_quote(&offending_sentence(text, why));
861    let (reason, value, limit) = measured(why);
862    match (value, limit) {
863        (Some(value), Some(limit)) if !quote.is_empty() => {
864            format!("refused {id}: {reason}; {value} vs limit {limit}: \"{quote}\"\n")
865        }
866        _ if !quote.is_empty() => format!("refused {id}: {why}: \"{quote}\"\n"),
867        _ => format!("refused {id}: {why}\n"),
868    }
869}
870
871fn measured(why: &str) -> (String, Option<String>, Option<String>) {
872    if let Some((words, limit)) = word_limit(why) {
873        return (
874            format!("sentence has {words} words"),
875            Some(words.to_string()),
876            Some(limit.to_string()),
877        );
878    }
879    if let Some(rest) = why.split("readability grade ").nth(1) {
880        let mut parts = rest.split_whitespace();
881        let grade = parts.next().unwrap_or("");
882        if parts.next() == Some("exceeds") {
883            let limit = parts
884                .next()
885                .unwrap_or("")
886                .trim_end_matches(|c: char| !c.is_ascii_digit() && c != '.');
887            if !grade.is_empty() && !limit.is_empty() {
888                return (
889                    format!("readability grade {grade} exceeds {limit}"),
890                    Some(grade.to_string()),
891                    Some(limit.to_string()),
892                );
893            }
894        }
895    }
896    if let (Some(n), Some(limit)) = (
897        number_before(why, " sentences"),
898        number_after(why, "at most "),
899    ) {
900        return (
901            format!("atom has {n} sentences"),
902            Some(n.to_string()),
903            Some(limit.to_string()),
904        );
905    }
906    if let Some(rest) = why.split("adverb ratio ").nth(1) {
907        let mut parts = rest.split_whitespace();
908        let ratio = parts.next().unwrap_or("");
909        if parts.next() == Some("exceeds") {
910            let limit = parts
911                .next()
912                .unwrap_or("")
913                .trim_end_matches(|c: char| !c.is_ascii_digit() && c != '.');
914            if !ratio.is_empty() && !limit.is_empty() {
915                return (
916                    format!("adverb ratio {ratio} exceeds {limit}"),
917                    Some(ratio.to_string()),
918                    Some(limit.to_string()),
919                );
920            }
921        }
922    }
923    (why.to_string(), None, None)
924}
925
926/// The latest proposal per id, filtered by seat and status. A row that
927/// names no seat is `seat unknown`. With no status, open rows the pack
928/// does not already hold come first, then refused rows, then one count
929/// line. `--status in-pack` lists the rows the pack already holds.
930#[must_use]
931pub fn list_proposals(seat: Option<&str>, status: Option<&str>) -> String {
932    let held = live_atoms().unwrap_or_default();
933    let want_seat = seat.map(str::trim).filter(|s| !s.is_empty());
934    let want_status = status.map(str::trim).filter(|s| !s.is_empty());
935    let mut rows: Vec<Record> = read_latest().into_values().collect();
936    rows.retain(|rec| want_seat.is_none_or(|seat| seat_matches(rec, seat)));
937    rows.sort_by(|a, b| a.id.cmp(&b.id));
938    if rows.is_empty() {
939        return "no proposals\n".to_string();
940    }
941    if let Some(status) = want_status {
942        let mut out = String::new();
943        for rec in &rows {
944            if !status_selected(rec, status, &held) {
945                continue;
946            }
947            out.push_str(&proposal_line(rec, &held));
948        }
949        if out.is_empty() {
950            out.push_str("no proposals\n");
951        }
952        return out;
953    }
954    let mut fresh = String::new();
955    let mut refused = String::new();
956    let mut open_n = 0usize;
957    let mut new_n = 0usize;
958    let mut pack_n = 0usize;
959    for rec in &rows {
960        let open = matches!(rec.status.as_str(), "open" | "proposed");
961        if open {
962            open_n += 1;
963            if atom_in_pack(rec, &held) {
964                pack_n += 1;
965            } else {
966                new_n += 1;
967                fresh.push_str(&proposal_line(rec, &held));
968            }
969        } else if rec.status == "refused" {
970            refused.push_str(&proposal_line(rec, &held));
971        }
972    }
973    let mut out = fresh;
974    out.push_str(&refused);
975    out.push_str(&format!(
976        "{open_n} open: {new_n} new, {pack_n} already in pack (`--status in-pack` to list them)\n"
977    ));
978    out
979}
980
981/// Write `already-in-pack` and the time on each open row whose atom is
982/// already a pack entry. A row already settled is left as it is. Nothing
983/// is deleted. One line per id that changed, then the count.
984///
985/// # Errors
986///
987/// The proposals log could not be written.
988pub fn settle_proposals(seat: Option<&str>) -> Result<String> {
989    let held = live_atoms().unwrap_or_default();
990    let want_seat = seat.map(str::trim).filter(|s| !s.is_empty());
991    let _g = gate();
992    let mut rows: Vec<Record> = read_latest().into_values().collect();
993    rows.retain(|rec| want_seat.is_none_or(|seat| seat_matches(rec, seat)));
994    rows.sort_by(|a, b| a.id.cmp(&b.id));
995    let mut lines = Vec::new();
996    for mut rec in rows {
997        if !matches!(rec.status.as_str(), "open" | "proposed") || !atom_in_pack(&rec, &held) {
998            continue;
999        }
1000        let id = rec.id.clone();
1001        note_outcome(&mut rec, "already-in-pack", "")?;
1002        lines.push(format!("already in pack {id}"));
1003    }
1004    let n = lines.len();
1005    lines.push(format!("settled {n}"));
1006    Ok(format!("{}\n", lines.join("\n")))
1007}
1008
1009/// Live atoms, when the pack answers. `None` is a pack that does not, so a
1010/// list still prints and does not pretend a row is already held.
1011fn live_atoms() -> Option<Vec<Value>> {
1012    let client = crate::pack().ok()?;
1013    crate::atoms_lean(&client, &client.workspace()).ok()
1014}
1015
1016fn proposal_kind(rec: &Record) -> String {
1017    if !rec.kind.trim().is_empty() {
1018        return rec.kind.trim().to_string();
1019    }
1020    rec.atom["kind"]
1021        .as_str()
1022        .unwrap_or("lesson")
1023        .trim()
1024        .to_string()
1025}
1026
1027fn proposal_text(rec: &Record) -> String {
1028    if let Some(text) = rec
1029        .amendment
1030        .as_ref()
1031        .map(|amendment| amendment.text.trim())
1032        .filter(|text| !text.is_empty())
1033    {
1034        return text.to_string();
1035    }
1036    let text = rec.text.trim();
1037    if !text.is_empty() {
1038        return text.to_string();
1039    }
1040    rec.atom["text"].as_str().unwrap_or("").trim().to_string()
1041}
1042
1043/// Put the text accept will store onto the atom. An amended proposal
1044/// carries `provenance:amended`, which a reader of the pack can see.
1045fn stamp_current(rec: &mut Record) {
1046    let text = proposal_text(rec);
1047    rec.atom["text"] = Value::String(text);
1048    if rec.amendment.is_some() {
1049        crate::add_entities(&mut rec.atom, ["provenance:amended".to_string()]);
1050    }
1051}
1052
1053/// Whether an amendment was written after the refusal. A refusal of that
1054/// same amendment is not a newer one.
1055fn amended_since_refusal(rec: &Record) -> bool {
1056    rec.amendment
1057        .as_ref()
1058        .is_some_and(|amendment| amendment.at > rec.at)
1059}
1060
1061/// The writer's duplicate rule: one atom when the folded text and the kind
1062/// match. Accept reports that row as already in pack, and so does the list.
1063fn atom_in_pack(rec: &Record, held: &[Value]) -> bool {
1064    let kind = proposal_kind(rec);
1065    let text = proposal_text(rec);
1066    if text.is_empty() || kind.is_empty() {
1067        return false;
1068    }
1069    let key = crate::memories::lesson_key(&kind, &text);
1070    held.iter().any(|atom| {
1071        let held_kind = atom["kind"].as_str().unwrap_or("").trim();
1072        if held_kind != kind {
1073            return false;
1074        }
1075        let same = |words: &str| crate::memories::lesson_key(held_kind, words) == key;
1076        same(atom["text"].as_str().unwrap_or(""))
1077            || atom["aliases"]
1078                .as_array()
1079                .is_some_and(|aliases| aliases.iter().filter_map(Value::as_str).any(same))
1080    })
1081}
1082
1083fn seat_matches(rec: &Record, seat: &str) -> bool {
1084    if seat == "unknown" || seat == "seat unknown" {
1085        rec.seat.trim().is_empty()
1086    } else {
1087        rec.seat == seat
1088    }
1089}
1090
1091fn wants_in_pack(status: &str) -> bool {
1092    status == "in-pack" || status == "already in pack"
1093}
1094
1095fn status_selected(rec: &Record, status: &str, held: &[Value]) -> bool {
1096    if wants_in_pack(status) {
1097        return rec.status == "already-in-pack"
1098            || (matches!(rec.status.as_str(), "open" | "proposed") && atom_in_pack(rec, held));
1099    }
1100    rec.status == status
1101}
1102
1103fn display_status(rec: &Record, held: &[Value]) -> String {
1104    if rec.status == "already-in-pack"
1105        || (matches!(rec.status.as_str(), "open" | "proposed") && atom_in_pack(rec, held))
1106    {
1107        "already in pack".to_string()
1108    } else {
1109        rec.status.clone()
1110    }
1111}
1112
1113fn proposal_seat(rec: &Record) -> String {
1114    let filed = rec.seat.trim();
1115    if !filed.is_empty() {
1116        return filed.to_string();
1117    }
1118    rec.atom["source"]["seat"]
1119        .as_str()
1120        .unwrap_or("")
1121        .trim()
1122        .to_string()
1123}
1124
1125fn proposal_line(rec: &Record, held: &[Value]) -> String {
1126    let named = proposal_seat(rec);
1127    let seat = if named.is_empty() {
1128        "seat unknown".to_string()
1129    } else {
1130        format!("seat {named}")
1131    };
1132    let text = if rec.status == "refused" && !rec.reason.is_empty() {
1133        format!(
1134            "{}  amend it with ljos proposals amend",
1135            refusal_clause(&proposal_text(rec), &rec.reason)
1136        )
1137    } else {
1138        proposal_text(rec).replace('\n', " ")
1139    };
1140    format!(
1141        "{}  {}  {seat}  {text}\n",
1142        rec.id,
1143        display_status(rec, held)
1144    )
1145}
1146
1147/// How many characters of the offending sentence a refusal quotes.
1148const QUOTE_CHARS: usize = 80;
1149
1150/// The refusal a person can act on. A sentence past the word limit names
1151/// the count, the limit and the sentence, trimmed to about 80 characters.
1152fn refusal_clause(text: &str, why: &str) -> String {
1153    let quote = trim_quote(&offending_sentence(text, why));
1154    if quote.is_empty() {
1155        return why.to_string();
1156    }
1157    if let Some((words, limit)) = word_limit(why) {
1158        format!("sentence has {words} words; limit {limit}: \"{quote}\"")
1159    } else {
1160        format!("{why}: \"{quote}\"")
1161    }
1162}
1163
1164fn word_limit(why: &str) -> Option<(usize, usize)> {
1165    let words = number_before(why, " words")?;
1166    let limit = number_after(why, "the limit is ")?;
1167    Some((words, limit))
1168}
1169
1170fn offending_sentence(text: &str, why: &str) -> String {
1171    let parts = packset_core::prose::sentences(text);
1172    if let Some(n) = number_after(why, "atom sentence ") {
1173        if let Some(sentence) = n.checked_sub(1).and_then(|i| parts.get(i)) {
1174            return sentence.trim().to_string();
1175        }
1176    }
1177    if parts.len() == 1 {
1178        return parts[0].trim().to_string();
1179    }
1180    text.trim().to_string()
1181}
1182
1183fn trim_quote(text: &str) -> String {
1184    let text = text.trim();
1185    if text.chars().count() <= QUOTE_CHARS {
1186        return text.to_string();
1187    }
1188    let keep = QUOTE_CHARS - 3;
1189    let mut out: String = text.chars().take(keep).collect();
1190    if let Some(space) = out.rfind(' ') {
1191        if space > keep / 2 {
1192            out.truncate(space);
1193        }
1194    }
1195    out.push_str("...");
1196    out
1197}
1198
1199fn number_before(text: &str, marker: &str) -> Option<usize> {
1200    let at = text.find(marker)?;
1201    let head = text[..at].trim_end();
1202    let digits: String = head
1203        .chars()
1204        .rev()
1205        .take_while(|c| c.is_ascii_digit())
1206        .collect();
1207    digits.chars().rev().collect::<String>().parse().ok()
1208}
1209
1210fn number_after(text: &str, marker: &str) -> Option<usize> {
1211    let rest = text.split(marker).nth(1)?;
1212    let digits: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect();
1213    digits.parse().ok()
1214}
1215
1216/// [`accept`], recording the person's words the acceptance rests on.
1217///
1218/// `id` names a live memory, or a proposal row: an open one is stored
1219/// now at `explicit`, a stored one is accepted as the memory it became.
1220/// A satisfied or accepted row is already in the pack.
1221///
1222/// `yes` is a yes a seat carried from chat, see [`crate::quoted_yes`]. It
1223/// is kept on the accepted row, beside the acceptance it stands for, and
1224/// said back on the line the caller prints. `None` is the terminal path,
1225/// where the person ran the accept themselves.
1226///
1227/// # Errors
1228///
1229/// No proposal or memory has that id, a short id starts several, or the
1230/// pack or the state directory refuses the write.
1231pub fn accept_on(id: &str, yes: Option<&crate::quoted_yes::Yes>) -> Result<String> {
1232    let _g = gate();
1233    let id = id.trim();
1234    if id.is_empty() {
1235        bail!("accept: an id is required");
1236    }
1237    let rows = read_latest();
1238    let known: Vec<String> = rows.keys().cloned().collect();
1239    let id = match crate::resolve_short_id("accept", id, &known) {
1240        Ok(id) => id,
1241        Err(err) => return Ok(format!("not found {id}: {err:#}\n")),
1242    };
1243    let id = id.as_str();
1244    let Some(mut rec) = rows.get(id).cloned() else {
1245        return match accept_memory(id, None, yes) {
1246            Ok(line) => Ok(line),
1247            Err(err) if err.to_string().contains("no memory") => Ok(format!("not found {id}\n")),
1248            Err(err) => Err(err),
1249        };
1250    };
1251    rec.grant = yes.cloned();
1252    match rec.status.as_str() {
1253        "satisfied" => {
1254            close_remote(&rec);
1255            note_outcome(&mut rec, "already-in-pack", "")?;
1256            return Ok(format!("already in pack {id}\n"));
1257        }
1258        "accepted" | "already-in-pack" => return Ok(format!("already in pack {id}\n")),
1259        "stored" => {
1260            let stored = rec.stored.clone().unwrap_or_default();
1261            return accept_memory(&stored, Some(rec), yes);
1262        }
1263        "refused" if !amended_since_refusal(&rec) => {
1264            let when = if rec.at.is_empty() {
1265                "unknown"
1266            } else {
1267                rec.at.as_str()
1268            };
1269            return Ok(format!(
1270                "refused {id}: unchanged since last refusal at {when}; amend it with ljos proposals amend\n"
1271            ));
1272        }
1273        "open" | "proposed" | "refused" => {
1274            stamp_current(&mut rec);
1275            if matches!(rec.status.as_str(), "open" | "proposed") {
1276                if let Some(held) = live_atoms() {
1277                    if atom_in_pack(&rec, &held) {
1278                        note_outcome(&mut rec, "already-in-pack", "")?;
1279                        return Ok(format!("already in pack {id}\n"));
1280                    }
1281                }
1282            }
1283        }
1284        _ => return Ok(format!("not found {id}\n")),
1285    }
1286    let text = proposal_text(&rec);
1287    if let Some(why) = crate::pack_copy::gate_refusal(&text) {
1288        if rec.status != "refused" || rec.reason != why {
1289            rec.reason.clone_from(&why);
1290            note_outcome(&mut rec, "refused", &why)?;
1291        }
1292        return Ok(format!("refused {id}: {}\n", refusal_clause(&text, &why)));
1293    }
1294    let client = crate::pack()?;
1295    if rec.remote {
1296        let remote_id = rec.remote_id.clone().unwrap_or_else(|| rec.id.clone());
1297        let workspace = workspace_of(&client, &rec);
1298        let posted = accept_on_writer(&client, &workspace, &remote_id)?;
1299        note_outcome(&mut rec, "accepted", "")?;
1300        return Ok(accepted_line(&rec, &posted));
1301    }
1302    // A person accepting the proposal is the promotion off L0. The origin
1303    // stays, so a reader can still see where the claim came from. The
1304    // horizon becomes standing, which is what the prompt hook reads.
1305    stand_for_prompt(&mut rec.atom);
1306    match post_kept(&client, &rec.atom) {
1307        Ok(posted) => {
1308            rec.stored = posted["id"].as_str().map(str::to_string);
1309            note_outcome(&mut rec, "accepted", "")?;
1310            Ok(accepted_line(&rec, &posted))
1311        }
1312        Err(e) => {
1313            let err_text = e.to_string();
1314            let Some(remote_id) = held_proposal_id(&err_text) else {
1315                if !crate::note_can_wait(&e) {
1316                    let why =
1317                        crate::server_reason(&format!("{e:#}")).unwrap_or_else(|| format!("{e:#}"));
1318                    note_outcome(&mut rec, "refused", &why)?;
1319                    return Ok(format!(
1320                        "refused {id}: {}\n",
1321                        refusal_clause(&proposal_text(&rec), &why)
1322                    ));
1323                }
1324                return Err(e).with_context(|| format!("accept: POST /v1/atoms failed for {id}"));
1325            };
1326            // Filed while this writer was not holding, or the post is the
1327            // filing. Accept that proposal. Do not leave it open.
1328            let workspace = workspace_of(&client, &rec);
1329            let posted = accept_on_writer(&client, &workspace, &remote_id)?;
1330            rec.remote = true;
1331            rec.remote_id = Some(remote_id);
1332            note_outcome(&mut rec, "accepted", "")?;
1333            Ok(accepted_line(&rec, &posted))
1334        }
1335    }
1336}
1337
1338/// Record `status` on `rec` and the time, when it changed. A repeat of the
1339/// same outcome writes nothing.
1340fn note_outcome(rec: &mut Record, status: &str, reason: &str) -> Result<()> {
1341    if rec.status == status && rec.reason == reason && !rec.at.is_empty() {
1342        return Ok(());
1343    }
1344    rec.status = status.to_string();
1345    rec.reason = reason.to_string();
1346    rec.at = crate::now_utc();
1347    append(rec)
1348}
1349
1350/// Accept live memory `id`, a whole id or the start of one: raise its level
1351/// to `explicit`, record a `keep` verdict for `ljos memories review`, and
1352/// keep the acceptance and the yes it rests on as a row. `rec` is the
1353/// proposal row an import stored it from.
1354fn accept_memory(id: &str, rec: Option<Record>, yes: Option<&Yes>) -> Result<String> {
1355    let client = crate::pack()?;
1356    let workspace = client.workspace();
1357    let atoms = crate::atoms_lean(&client, &workspace).context("accept: GET /v1/atoms failed")?;
1358    let all: Vec<crate::memories::Memory> = atoms
1359        .iter()
1360        .filter_map(crate::memories::Memory::of)
1361        .collect();
1362    let full = match crate::memories::resolve(id, &all) {
1363        Ok(full) => full,
1364        Err(err) if err.to_string().contains("no memory starts with") => {
1365            bail!("no memory {id}")
1366        }
1367        Err(err) => return Err(err).context(format!("accept: no open proposal or memory {id}")),
1368    };
1369    let atom = atoms
1370        .iter()
1371        .find(|a| a["id"].as_str() == Some(full.as_str()))
1372        .cloned()
1373        .unwrap_or_default();
1374    let fields = prompt_fields(&atom);
1375    let posted = if fields.as_object().is_some_and(|map| !map.is_empty()) {
1376        update_fields(&client, &workspace, &full, fields)?
1377    } else {
1378        atom.clone()
1379    };
1380    crate::memories::record(&full, "keep", None)?;
1381    let mut rec = rec.unwrap_or_else(|| Record {
1382        id: full.clone(),
1383        status: String::new(),
1384        remote: false,
1385        remote_id: None,
1386        stored: None,
1387        text: atom["text"].as_str().unwrap_or("").to_string(),
1388        kind: atom["kind"].as_str().unwrap_or("lesson").to_string(),
1389        origin: atom["origin"].as_str().unwrap_or("").to_string(),
1390        grant: None,
1391        seat: atom["source"]["seat"]
1392            .as_str()
1393            .unwrap_or("")
1394            .trim()
1395            .to_string(),
1396        at: String::new(),
1397        reason: String::new(),
1398        amendment: None,
1399        atom,
1400    });
1401    rec.stored = Some(full);
1402    rec.grant = yes.cloned();
1403    note_outcome(&mut rec, "accepted", "")?;
1404    Ok(accepted_line(&rec, &posted))
1405}
1406
1407#[cfg(test)]
1408mod tests {
1409    use super::*;
1410    use std::os::unix::fs::PermissionsExt;
1411
1412    fn record(id: String) -> Record {
1413        Record {
1414            id,
1415            status: "proposed".into(),
1416            remote: false,
1417            remote_id: None,
1418            stored: None,
1419            text: "use uv for python envs".into(),
1420            kind: "lesson".into(),
1421            origin: ORIGIN_AGENT.into(),
1422            grant: None,
1423            seat: String::new(),
1424            at: String::new(),
1425            reason: String::new(),
1426            amendment: None,
1427            atom: serde_json::json!({"text": "use uv for python envs"}),
1428        }
1429    }
1430
1431    /// The seat that filed a proposal is on the row and in the listing.
1432    #[test]
1433    fn a_proposal_names_the_seat_that_filed_it() {
1434        let _g = crate::test_env_lock();
1435        let dir = tempfile::tempdir().unwrap();
1436        unsafe {
1437            std::env::set_var("XDG_STATE_HOME", dir.path());
1438            std::env::set_var("LJOS_SEAT", "saddle");
1439            std::env::remove_var("PACKSET_URL");
1440        }
1441        let filed = file_open("Fire the kiln only after the shelf is cool.", "lesson").unwrap();
1442        assert!(!filed.trim().is_empty(), "{filed}");
1443        let listed = list_proposals(None, None);
1444        assert!(listed.contains("seat saddle"), "{listed}");
1445        assert!(!listed.contains("seat unknown"), "{listed}");
1446        let row = read_latest().into_values().next().unwrap();
1447        assert_eq!(row.seat, "saddle");
1448        assert_eq!(row.atom["source"]["seat"], "saddle");
1449    }
1450
1451    /// A held proposal carries the whole atom, so its row is private and
1452    /// written whole, like the other state logs.
1453    #[test]
1454    fn concurrent_proposal_rows_each_parse_and_stay_private() {
1455        let _g = crate::test_env_lock();
1456        let dir = tempfile::tempdir().unwrap();
1457        unsafe { std::env::set_var("XDG_STATE_HOME", dir.path()) };
1458        const THREADS: usize = 16;
1459        const EACH: usize = 8;
1460        let handles: Vec<_> = (0..THREADS)
1461            .map(|thread| {
1462                std::thread::spawn(move || {
1463                    for n in 0..EACH {
1464                        append(&record(format!("{thread}-{n}"))).unwrap();
1465                    }
1466                })
1467            })
1468            .collect();
1469        for handle in handles {
1470            handle.join().expect("proposal writer");
1471        }
1472        let path = proposals_path();
1473        let text = std::fs::read_to_string(&path).unwrap();
1474        for line in text.lines() {
1475            serde_json::from_str::<Record>(line)
1476                .unwrap_or_else(|err| panic!("proposal row does not parse ({err}): {line}"));
1477        }
1478        assert_eq!(read_latest().len(), THREADS * EACH, "{text}");
1479        let mode = std::fs::symlink_metadata(&path)
1480            .unwrap()
1481            .permissions()
1482            .mode()
1483            & 0o777;
1484        assert_eq!(mode, 0o600, "proposals mode {mode:o}");
1485    }
1486
1487    /// A proposal still being appended is not yet held; the proposals
1488    /// before it are.
1489    #[test]
1490    fn a_torn_last_proposal_leaves_the_others() {
1491        let _g = crate::test_env_lock();
1492        let dir = tempfile::tempdir().unwrap();
1493        unsafe { std::env::set_var("XDG_STATE_HOME", dir.path()) };
1494        append(&record("a".into())).unwrap();
1495        let path = proposals_path();
1496        let mut bytes = std::fs::read(&path).unwrap();
1497        bytes.extend_from_slice(b"{\"id\":\"b\",\"text\":\"caf\xc3");
1498        std::fs::write(&path, bytes).unwrap();
1499        let ids: Vec<String> = read_latest().into_keys().collect();
1500        assert_eq!(ids, ["a"]);
1501    }
1502
1503    /// A proposals file swapped for a symlink is not written through.
1504    #[test]
1505    fn a_symlinked_proposals_file_is_refused() {
1506        let _g = crate::test_env_lock();
1507        let dir = tempfile::tempdir().unwrap();
1508        unsafe { std::env::set_var("XDG_STATE_HOME", dir.path()) };
1509        let path = proposals_path();
1510        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1511        let outside = dir.path().join("outside");
1512        std::fs::write(&outside, "kept\n").unwrap();
1513        std::os::unix::fs::symlink(&outside, &path).unwrap();
1514        assert!(append(&record("a".into())).is_err());
1515        assert_eq!(std::fs::read_to_string(&outside).unwrap(), "kept\n");
1516    }
1517}