| 1 | //! Schema table for the compaction survival contract. |
| 2 | //! |
| 3 | //! Compiled only under `cfg(test)`: this is compile-time documentation of |
| 4 | //! the field table, not the runtime enforcement path (`last_round.rs`). |
| 5 | //! Field names and locations are language-invariant protocol data. The B1 |
| 6 | //! strategy (Rust compaction path) enforces them over the session-tree |
| 7 | //! journal / API transcript. See [`SURVIVAL_CONTRACT.md`](./SURVIVAL_CONTRACT.md). |
| 8 | |
| 9 | /// Documented schema version. Bump when a survive/summarize/prune rule |
| 10 | /// changes; TS/Go strategies pin this number. |
| 11 | pub const SURVIVAL_CONTRACT_VERSION: u32 = 2; |
| 12 | |
| 13 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 14 | pub enum SurvivalRule { |
| 15 | Always, |
| 16 | LastRoundVerbatim, |
| 17 | LastRoundBounded, |
| 18 | LatestOnly, |
| 19 | RestateFromLiveState, |
| 20 | Summarize, |
| 21 | Prune, |
| 22 | NeverDurable, |
| 23 | } |
| 24 | |
| 25 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 26 | pub struct SurvivalField { |
| 27 | pub name: &'static str, |
| 28 | pub location: &'static str, |
| 29 | pub rule: SurvivalRule, |
| 30 | } |
| 31 | |
| 32 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 33 | pub struct EntrySurvival { |
| 34 | pub kind: &'static str, |
| 35 | pub fields: &'static [SurvivalField], |
| 36 | } |
| 37 | |
| 38 | /// Protocol journal kinds plus session-tree `message` / `system` carriers. |
| 39 | pub const ENTRY_SURVIVAL: &[EntrySurvival] = &[ |
| 40 | EntrySurvival { |
| 41 | kind: "header", |
| 42 | fields: &[ |
| 43 | SurvivalField { |
| 44 | name: "id", |
| 45 | location: "JournalEntry.id", |
| 46 | rule: SurvivalRule::Always, |
| 47 | }, |
| 48 | SurvivalField { |
| 49 | name: "created_at", |
| 50 | location: "JournalEntry.created_at", |
| 51 | rule: SurvivalRule::Always, |
| 52 | }, |
| 53 | SurvivalField { |
| 54 | name: "secrets", |
| 55 | location: "payload", |
| 56 | rule: SurvivalRule::NeverDurable, |
| 57 | }, |
| 58 | ], |
| 59 | }, |
| 60 | EntrySurvival { |
| 61 | kind: "user", |
| 62 | fields: &[ |
| 63 | SurvivalField { |
| 64 | name: "text", |
| 65 | location: "payload / Message.content[type=text].text", |
| 66 | rule: SurvivalRule::LastRoundVerbatim, |
| 67 | }, |
| 68 | SurvivalField { |
| 69 | name: "older_turns", |
| 70 | location: "payload / Message.content[type=text].text", |
| 71 | rule: SurvivalRule::Summarize, |
| 72 | }, |
| 73 | ], |
| 74 | }, |
| 75 | EntrySurvival { |
| 76 | kind: "assistant", |
| 77 | fields: &[ |
| 78 | SurvivalField { |
| 79 | name: "text", |
| 80 | location: "payload / Message.content[type=text].text", |
| 81 | rule: SurvivalRule::LastRoundVerbatim, |
| 82 | }, |
| 83 | SurvivalField { |
| 84 | name: "tool_use.id", |
| 85 | location: "Message.content[type=tool_use].id", |
| 86 | rule: SurvivalRule::LastRoundVerbatim, |
| 87 | }, |
| 88 | ], |
| 89 | }, |
| 90 | EntrySurvival { |
| 91 | kind: "tool_result", |
| 92 | fields: &[ |
| 93 | SurvivalField { |
| 94 | name: "tool_use_id", |
| 95 | location: "Message.content[type=tool_result].tool_use_id", |
| 96 | rule: SurvivalRule::LastRoundVerbatim, |
| 97 | }, |
| 98 | SurvivalField { |
| 99 | name: "content", |
| 100 | location: "Message.content[type=tool_result].content", |
| 101 | rule: SurvivalRule::LastRoundBounded, |
| 102 | }, |
| 103 | SurvivalField { |
| 104 | name: "is_error", |
| 105 | location: "Message.content[type=tool_result].is_error", |
| 106 | rule: SurvivalRule::LastRoundVerbatim, |
| 107 | }, |
| 108 | SurvivalField { |
| 109 | name: "older_results", |
| 110 | location: "Message.content[type=tool_result].content", |
| 111 | rule: SurvivalRule::Prune, |
| 112 | }, |
| 113 | ], |
| 114 | }, |
| 115 | EntrySurvival { |
| 116 | kind: "compaction", |
| 117 | fields: &[ |
| 118 | SurvivalField { |
| 119 | name: "summary", |
| 120 | location: "payload.summary / checkpoint user text", |
| 121 | rule: SurvivalRule::LatestOnly, |
| 122 | }, |
| 123 | SurvivalField { |
| 124 | name: "coverage", |
| 125 | location: "CompactionCoverage / /context inspector", |
| 126 | rule: SurvivalRule::LatestOnly, |
| 127 | }, |
| 128 | ], |
| 129 | }, |
| 130 | EntrySurvival { |
| 131 | kind: "branch_summary", |
| 132 | fields: &[ |
| 133 | SurvivalField { |
| 134 | name: "branch_id", |
| 135 | location: "payload.branch_id", |
| 136 | rule: SurvivalRule::Always, |
| 137 | }, |
| 138 | SurvivalField { |
| 139 | name: "summary", |
| 140 | location: "payload.summary", |
| 141 | rule: SurvivalRule::Always, |
| 142 | }, |
| 143 | ], |
| 144 | }, |
| 145 | EntrySurvival { |
| 146 | kind: "message", |
| 147 | fields: &[ |
| 148 | SurvivalField { |
| 149 | name: "role", |
| 150 | location: "SessionEntryKind::Message.message.role", |
| 151 | rule: SurvivalRule::LastRoundVerbatim, |
| 152 | }, |
| 153 | SurvivalField { |
| 154 | name: "content", |
| 155 | location: "SessionEntryKind::Message.message.content", |
| 156 | rule: SurvivalRule::LastRoundBounded, |
| 157 | }, |
| 158 | ], |
| 159 | }, |
| 160 | EntrySurvival { |
| 161 | kind: "system", |
| 162 | fields: &[SurvivalField { |
| 163 | name: "content", |
| 164 | location: "SessionEntryKind::System.content", |
| 165 | rule: SurvivalRule::Always, |
| 166 | }], |
| 167 | }, |
| 168 | ]; |
| 169 | |
| 170 | pub const LIVE_STATE_FIELDS: &[SurvivalField] = &[ |
| 171 | SurvivalField { |
| 172 | name: "anchors", |
| 173 | location: ".codewhale/anchors.md restated on the checkpoint", |
| 174 | rule: SurvivalRule::RestateFromLiveState, |
| 175 | }, |
| 176 | SurvivalField { |
| 177 | name: "branch_worktree", |
| 178 | location: "live git / workspace", |
| 179 | rule: SurvivalRule::RestateFromLiveState, |
| 180 | }, |
| 181 | SurvivalField { |
| 182 | name: "background_work_handles", |
| 183 | location: "session + worker registry", |
| 184 | rule: SurvivalRule::RestateFromLiveState, |
| 185 | }, |
| 186 | SurvivalField { |
| 187 | name: "billed_parent_prompt_tokens", |
| 188 | location: "TurnContext.latest_parent_input_tokens", |
| 189 | rule: SurvivalRule::LatestOnly, |
| 190 | }, |
| 191 | SurvivalField { |
| 192 | name: "compaction_receipt", |
| 193 | location: "checkpoint user message + HistoryCell::System", |
| 194 | rule: SurvivalRule::LatestOnly, |
| 195 | }, |
| 196 | ]; |
| 197 | |
| 198 | #[cfg(test)] |
| 199 | mod tests { |
| 200 | use super::*; |
| 201 | |
| 202 | const SCHEMA_DOC: &str = include_str!("SURVIVAL_CONTRACT.md"); |
| 203 | |
| 204 | #[test] |
| 205 | fn schema_doc_lists_every_entry_kind_and_survive_field() { |
| 206 | let kinds: Vec<_> = ENTRY_SURVIVAL.iter().map(|entry| entry.kind).collect(); |
| 207 | assert!(kinds.contains(&"user")); |
| 208 | assert!(kinds.contains(&"tool_result")); |
| 209 | assert!(kinds.contains(&"compaction")); |
| 210 | for entry in ENTRY_SURVIVAL { |
| 211 | assert!( |
| 212 | SCHEMA_DOC.contains(&format!("`{}`", entry.kind)), |
| 213 | "SURVIVAL_CONTRACT.md must document kind {}", |
| 214 | entry.kind |
| 215 | ); |
| 216 | for field in entry.fields.iter().filter(|field| { |
| 217 | matches!( |
| 218 | field.rule, |
| 219 | SurvivalRule::Always |
| 220 | | SurvivalRule::LastRoundVerbatim |
| 221 | | SurvivalRule::LastRoundBounded |
| 222 | | SurvivalRule::LatestOnly |
| 223 | ) |
| 224 | }) { |
| 225 | let token = field.name.split('.').next().unwrap_or(field.name); |
| 226 | assert!( |
| 227 | SCHEMA_DOC.contains(token) || SCHEMA_DOC.contains(field.name), |
| 228 | "SURVIVAL_CONTRACT.md must locate {}.{}", |
| 229 | entry.kind, |
| 230 | field.name |
| 231 | ); |
| 232 | } |
| 233 | } |
| 234 | for field in LIVE_STATE_FIELDS { |
| 235 | assert!( |
| 236 | SCHEMA_DOC.to_ascii_lowercase().contains("anchor") |
| 237 | && SCHEMA_DOC.contains("receipt"), |
| 238 | "live-state field {} must stay in the schema doc", |
| 239 | field.name |
| 240 | ); |
| 241 | } |
| 242 | assert!(SCHEMA_DOC.contains("Failed compact must not replace live history")); |
| 243 | assert!( |
| 244 | SCHEMA_DOC.contains(&format!("Schema version {SURVIVAL_CONTRACT_VERSION}")), |
| 245 | "SURVIVAL_CONTRACT.md must pin schema version {SURVIVAL_CONTRACT_VERSION}" |
| 246 | ); |
| 247 | } |
| 248 | |
| 249 | #[test] |
| 250 | fn last_round_kinds_have_verbatim_or_bounded_rules() { |
| 251 | for kind in ["user", "assistant", "tool_result", "message"] { |
| 252 | let entry = ENTRY_SURVIVAL |
| 253 | .iter() |
| 254 | .find(|entry| entry.kind == kind) |
| 255 | .unwrap_or_else(|| panic!("missing {kind}")); |
| 256 | assert!( |
| 257 | entry.fields.iter().any(|field| matches!( |
| 258 | field.rule, |
| 259 | SurvivalRule::LastRoundVerbatim | SurvivalRule::LastRoundBounded |
| 260 | )), |
| 261 | "{kind} must declare a last-round survival rule" |
| 262 | ); |
| 263 | } |
| 264 | } |
| 265 | |
| 266 | #[test] |
| 267 | fn compaction_kind_keeps_latest_receipt_only() { |
| 268 | let entry = ENTRY_SURVIVAL |
| 269 | .iter() |
| 270 | .find(|entry| entry.kind == "compaction") |
| 271 | .expect("compaction kind"); |
| 272 | assert!( |
| 273 | entry |
| 274 | .fields |
| 275 | .iter() |
| 276 | .any(|field| field.name == "summary" && field.rule == SurvivalRule::LatestOnly) |
| 277 | ); |
| 278 | assert!( |
| 279 | LIVE_STATE_FIELDS |
| 280 | .iter() |
| 281 | .any(|field| field.name == "compaction_receipt") |
| 282 | ); |
| 283 | assert!( |
| 284 | LIVE_STATE_FIELDS |
| 285 | .iter() |
| 286 | .any(|field| field.name == "anchors") |
| 287 | ); |
| 288 | } |
| 289 | } |
| 290 |