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
//! Concierge action ledger (Phase D2) — an auditable, append-only record
//! of every consented action the concierge takes (install, set-default,
//! rollback). Two jobs:
//!
//! 1. **Audit** — the user (and a reviewer) can see exactly what the
//! concierge did and when. Every entry is user-consented: it is only
//! written when the user invokes the action.
//! 2. **Reversibility** — each entry captures the prior state
//! (`prior_model_id`), so a default change can be undone. Rollback is
//! what earns the concierge permission to act at all, and it's only
//! possible if "what was there before" is durably recorded.
//!
//! Append-only JSONL at `concierge-actions.jsonl` under the CAR state root
//! (`~/.car/concierge-actions.jsonl`; `CAR_HOME` moves it), owner-only
//! (0600). Carries no prompt/output text.
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::intent::UseCase;
pub const ACTION_LEDGER_FILE: &str = "concierge-actions.jsonl";
/// What the concierge did.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ConciergeActionKind {
/// Acquired (pulled) a model.
Install,
/// Set a lane's default model.
SetDefault,
/// Cleared a lane default.
ClearDefault,
/// Reverted a lane default to its prior value.
Rollback,
}
/// One consented action.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ConciergeActionEntry {
/// Monotonic per-process sequence number, assigned at append time.
/// Used as the canary's anchor identity instead of the whole-second
/// `timestamp` (two actions in the same second would otherwise share
/// an identity). `#[serde(default)]` → pre-upgrade entries load as 0.
#[serde(default)]
pub seq: u64,
pub kind: ConciergeActionKind,
pub model_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub use_case: Option<UseCase>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub project: Option<String>,
/// The lane's default *before* this action — the value a rollback
/// restores. `None` means "no prior default" (rollback clears).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub prior_model_id: Option<String>,
#[serde(default)]
pub detail: String,
pub timestamp: u64,
}
/// Result of a closed-loop `apply` (Phase D3).
#[derive(Debug, Clone, Serialize)]
pub struct ConciergeApplyResult {
pub model_id: String,
pub use_case: UseCase,
pub installed: bool,
pub set_default: bool,
/// The lane's prior default (what a rollback restores).
pub prior_model_id: Option<String>,
}
/// Default path: `concierge-actions.jsonl` under the CAR state root —
/// `~/.car/concierge-actions.jsonl` unless `CAR_HOME` moves the root, in which
/// case the ledger moves with it. The ledger is per-daemon bookkeeping (what
/// *this* install's concierge did, and what a rollback would undo), so a
/// relocated daemon must not read or append to the primary's history.
pub fn default_path() -> PathBuf {
car_home::root_or_relative().join(ACTION_LEDGER_FILE)
}
/// Append one action (owner-only file). Append-only: no rewrite.
pub fn append_action(path: &Path, entry: &ConciergeActionEntry) -> std::io::Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
use std::io::Write;
let mut opts = std::fs::OpenOptions::new();
opts.create(true).append(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
opts.mode(0o600);
}
// On Windows, `mode(0o600)` is ignored; lock the file owner-only via ACL on
// a fresh create (idempotent, but skip re-hardening on every append). No-op
// off Windows.
let existed = path.exists();
let mut f = opts.open(path)?;
if !existed {
car_secrets::harden_owner_only(path);
}
let line = serde_json::to_string(entry).map_err(std::io::Error::other)?;
f.write_all(line.as_bytes())?;
f.write_all(b"\n")
}
/// Read the most recent `limit` actions (0 = all); skips garbage lines.
pub fn read_actions(path: &Path, limit: usize) -> Vec<ConciergeActionEntry> {
let Ok(content) = std::fs::read_to_string(path) else {
return Vec::new();
};
let mut out: Vec<ConciergeActionEntry> = content
.lines()
.filter(|l| !l.trim().is_empty())
.filter_map(|l| serde_json::from_str(l).ok())
.collect();
if limit > 0 && out.len() > limit {
out = out.split_off(out.len() - limit);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn append_and_read_roundtrip() {
let dir = std::env::temp_dir().join("car-action-ledger-test");
let _ = std::fs::remove_dir_all(&dir);
let path = dir.join(ACTION_LEDGER_FILE);
let e = ConciergeActionEntry {
seq: 1,
kind: ConciergeActionKind::SetDefault,
model_id: "big-coder".into(),
use_case: Some(UseCase::Coding),
project: None,
prior_model_id: Some("small-coder".into()),
detail: "concierge apply".into(),
timestamp: 100,
};
append_action(&path, &e).unwrap();
let read = read_actions(&path, 0);
assert_eq!(read.len(), 1);
assert_eq!(read[0].kind, ConciergeActionKind::SetDefault);
assert_eq!(read[0].prior_model_id.as_deref(), Some("small-coder"));
let _ = std::fs::remove_dir_all(&dir);
}
}