diff --git a/.straymark/07-ai-audit/agent-logs/AILOG-2026-09-17-004-baton-batch-inheritance.md b/.straymark/07-ai-audit/agent-logs/AILOG-2026-09-17-004-baton-batch-inheritance.md new file mode 100644 index 0000000..a2d58f1 --- /dev/null +++ b/.straymark/07-ai-audit/agent-logs/AILOG-2026-09-17-004-baton-batch-inheritance.md @@ -0,0 +1,87 @@ +--- +id: AILOG-2026-09-17-004 +title: Baton batch inheritance — nearest declaration (#428) +status: accepted +created: 2026-09-17 +agent: claude-opus-5-1m +confidence: high +review_required: false +risk_level: medium +eu_ai_act_risk: not_applicable +nist_genai_risks: [human_ai_config, information_integrity] +iso_42001_clause: [] +files_modified: + - experiment-baton/src/units.rs + - experiment-baton/src/main.rs + - experiment-baton/tests/batch_inheritance.rs + - experiment-baton/tests/task_inheritance.rs + - experiment-baton/06-work-verb-schema-ratification.md + - experiment-baton/07-track-c-adopter-kit.md +observability_scope: none +tags: [baton, adopter-feedback, inheritance, track-c] +related: + - 07-ai-audit/decisions/AIDEC-2026-09-17-001-baton-batch-parent-nearest-declaration.md +--- + +# AILOG: Baton batch inheritance (#428) + +## Summary + +Baton now reads batch declarations. A batch takes the nearest declaration: its own ledger line, +else its AILOG's frontmatter, else the Charters whose ledger that AILOG is, when they agree. Before, +every batch was `undeclared`, and the ratification and the Track C kit disagreed on where a batch +declares. + +## Context + +Estoa's #428. The ratification placed batch verbs on a ledger line, while the framework's AILOG +template and the Track C kit used the AILOG frontmatter, and `read_batches` read neither. This +follows the task inheritance of #430 and the inventory hygiene of #435, and reuses both: the +Charter parent index and the comment-aware scanning. + +## Actions Performed + +1. The parent-declaration code moves into its own section of `units.rs`, shared by tasks and + batches: + - `CharterOrigin` gains `ledger_ailog`, resolved as `charter batch-complete` does + (`originating_ailogs[0]`, else `execution_ailogs[0]`); a link field that is not a list of ids + counts as unreadable; + - `ailog_key` compares ids on the CLI's five-segment key; + - `agreed` is the all-parents-agree rule, now used by both granularities. +2. `read_batches` scans live ledger text only (HTML comments, fences and other sections are + ignored) and collects each batch's own `Work verb` / `Design provenance` lines. An override + replaces the parent's declaration as a whole. `batch_parent` resolves the AILOG frontmatter, + else the agreeing ledger Charters; an unreadable AILOG frontmatter blocks inheritance. +3. `task_inheritance_blockers` → `inheritance_blockers`. The CLI note now covers `--granularity + batch` and says "inheritance from Charters disabled"; the #430 test assertion follows. +4. `tests/batch_inheritance.rs` has 9 cases: the #428 reproduction, both ledger links, non-ledger + and `…-001b` links, agreement, AILOG-over-Charter precedence, whole-declaration overrides, + examples and other sections, an unreadable AILOG and an unreadable Charter. 8 of them fail + before the change; the ninth is a negative case. +5. Ratification §3 note and kit §2 table describe the one contract. + +## Decisions Made + +`AIDEC-2026-09-17-001`: the maintainer chose "nearest declaration" over ledger-only (which would +leave the AILOG slot inert) and over any Charter mentioning the AILOG (which would make motivating +Charters into parents). + +## Impact + +Measured read-only (git status unchanged in each corpus). Batches classified, previously all +`undeclared`: + +| Corpus | Classified batches | Source of the declaration | +|---|---|---| +| Sentinel | 22 | Charters 54 (`operate`) and 55 (`implement/new`), plus two others, through their ledger AILOGs | +| LNXDrive | 7 | Charter 02 through its ledger AILOG | +| Estoa | 8 | AILOG frontmatter | + +Spot-checked against the source AILOGs and Charters. **Performance / Security / Privacy / +Environmental**: N/A; still read-only. + +## Verification + +- [x] `cargo test -p straymark-baton --locked`: 112 passed +- [x] `cargo clippy -p straymark-baton --all-targets --locked -- -D warnings` +- [x] Read-only measurement on three adopter corpora diff --git a/.straymark/07-ai-audit/decisions/AIDEC-2026-09-17-001-baton-batch-parent-nearest-declaration.md b/.straymark/07-ai-audit/decisions/AIDEC-2026-09-17-001-baton-batch-parent-nearest-declaration.md new file mode 100644 index 0000000..f5914cd --- /dev/null +++ b/.straymark/07-ai-audit/decisions/AIDEC-2026-09-17-001-baton-batch-parent-nearest-declaration.md @@ -0,0 +1,83 @@ +--- +id: AIDEC-2026-09-17-001 +title: Baton batch parent — nearest declaration (ledger line → AILOG → Charter) +status: accepted +created: 2026-09-17 +agent: claude-opus-5-1m +confidence: high +review_required: false +risk_level: medium +eu_ai_act_risk: not_applicable +nist_genai_risks: [human_ai_config] +iso_42001_clause: [] +tags: [baton, inheritance, adopter-feedback] +related: + - 07-ai-audit/decisions/AIDEC-2026-09-13-001-baton-task-parent.md +--- + +# AIDEC: Baton batch parent — nearest declaration + +## Context + +The work-verb ratification (#332 §3) places a batch's declaration on a `- **Work verb**:` line in +its AILOG ledger entry and says batches inherit from their parent by default. The framework's AILOG +template, however, ships `work_verb` / `design_provenance` in the AILOG **frontmatter**, and the +Track C kit told adopters to use that. Baton read neither, so every batch was `undeclared` (#428, +Estoa). + +## Problem + +What is a batch's parent, and how do the two existing slots relate, without inventing a new slot +and without guessing ownership? + +## Alternatives Considered + +1. **Ledger line only, no inheritance.** Minimal, but it leaves the AILOG frontmatter slot inert + and forces a line on every batch of a homogeneous ledger. That is the artificial + fragmentation the ratification rules out. +2. **Nearest declaration: ledger line → AILOG frontmatter → Charter.** Each existing slot has a + role. The AILOG frontmatter is the homogeneous default for its batches, and a ledger line is the + override for a heterogeneous one. When the AILOG declares nothing, the batches inherit from the + Charters whose ledger the AILOG is. +3. **Inherit from any Charter that mentions the AILOG** (`originating_ailogs` anywhere, or + `execution_ailogs`). More coverage, but a Charter that was merely *motivated* by an AILOG + would become its batches' parent. + +## Decision + +Option 2, chosen by the maintainer on 2026-09-17. + +- **Charter link.** It is the one `straymark charter batch-complete` writes through: + `originating_ailogs[0]`, else `execution_ailogs[0]`. AILOG ids are compared on their first five + `-` segments, as the CLI resolves them, so `…-028` and `…-028b` never match. +- **Several such Charters must agree.** This is the rule of AIDEC-2026-09-13-001 for tasks. +- **An override replaces the whole declaration.** A verb line without provenance does not inherit + the parent's provenance, and a provenance line alone declares nothing. The same unit semantics + apply to `followups declare`. +- **Unreadable sources.** If an AILOG's frontmatter cannot be read, its batches do not inherit from + anywhere, because it may declare something unseen. Their own lines still apply. An unreadable + Charter disables Charter-level inheritance project-wide, as for tasks, but not AILOG-level + declarations. +- **Scope of the scan.** Only live ledger text counts. HTML comments, fenced blocks and any line + after another heading are ignored. + +## Consequences + +- The Track C kit and the ratification now describe one contract instead of two. +- Adopters who already declared on the AILOG frontmatter see their batches classified without + touching anything. Measured read-only: Sentinel 0 → 22 batches, LNXDrive 0 → 7, Estoa 0 → 8. +- A Charter chain sharing one ledger AILOG with disagreeing declarations leaves its batches + `undeclared`. That is correct, since ownership is then ambiguous. Such batches can be declared + on their own lines. +- Not decided here: follow-up parent links and declarations on specs. + +## Implementation + +`experiment-baton/src/units.rs` (`read_batches`, `batch_parent`, `ledger_ailog`, `agreed`), +covered by `experiment-baton/tests/batch_inheritance.rs`. + +## References + +- `experiment-baton/06-work-verb-schema-ratification.md` §3 +- `experiment-baton/07-track-c-adopter-kit.md` §2 +- Issues #332, #428 diff --git a/experiment-baton/06-work-verb-schema-ratification.md b/experiment-baton/06-work-verb-schema-ratification.md index db736bf..ad51f71 100644 --- a/experiment-baton/06-work-verb-schema-ratification.md +++ b/experiment-baton/06-work-verb-schema-ratification.md @@ -114,10 +114,25 @@ incumple el esquema tipado sigue contando como padre. No se deduce pertenencia d títulos ni desde el orden de archivos. Un verbo inválido conserva el comportamiento conservador del clasificador. El vínculo `context_spec` no establece parentesco. -Batch, overrides por ledger, vínculos de follow-ups y declaraciones directas en -spec continúan pendientes. El kit Track C y el placement de batches tienen una -discrepancia documentada en #428; esta corrección de tareas no redefine ese contrato. -La herencia es una regla ratificada; estos límites describen su implementación +La herencia de Batch también está implementada (#428). Un batch toma la **declaración +más cercana**: + +1. su propia línea `- **Work verb**:` en la entrada del ledger (el override por + heterogeneidad); +2. si no la tiene, el frontmatter del AILOG que contiene el ledger (el slot que ya trae + la plantilla de AILOG, y que actúa como el "padre" homogéneo de sus batches); +3. si tampoco, los Charters cuyo AILOG de ledger es ese AILOG, con la misma resolución + que usa `straymark charter batch-complete` (`originating_ailogs[0]`, si no + `execution_ailogs[0]`), siempre que todos declaren lo mismo. + +Un override reemplaza la declaración completa: una línea de verbo sin procedencia no +hereda la procedencia del padre, y una línea de procedencia sola no declara nada. Si el +frontmatter del AILOG es ilegible, sus batches no heredan: podría declarar algo que no +se ve. Las líneas dentro de comentarios, bloques de código u otras secciones no +declaran nada. + +Siguen pendientes los vínculos de follow-ups con un padre y las declaraciones directas +en spec. La herencia es una regla ratificada; estos límites describen su implementación parcial en [`src/units.rs`](src/units.rs), no una validación humana de las declaraciones. ## 4. Undeclared = estado honesto diff --git a/experiment-baton/07-track-c-adopter-kit.md b/experiment-baton/07-track-c-adopter-kit.md index 63fc14e..e5ee14b 100644 --- a/experiment-baton/07-track-c-adopter-kit.md +++ b/experiment-baton/07-track-c-adopter-kit.md @@ -60,7 +60,7 @@ descomentar y llenar: | Unidad | Slot | |---|---| | Charter | Frontmatter: `work_verb:` / `design_provenance:` | -| Batch (AILOG ledger) | Línea `- **Work verb**:` en la entrada del batch; placement ratificado, aún pendiente en el inventario (#428) | +| Batch (AILOG ledger) | Línea `- **Work verb**:` (y `- **Design provenance**:`) en la entrada del batch **solo si difiere** de su padre. Si no, hereda del frontmatter del AILOG y, en su defecto, de los Charters cuyo ledger es ese AILOG, si coinciden (#428) | | Follow-up | Líneas `- **Work verb**:` / `- **Design provenance**:`, escritas con `straymark followups declare FU-NNN --work-verb …` o con `followups new --work-verb …` (cli-3.49.0+; nunca a mano ni en `Notes`) | | Task (SpecKit) | Sin slot propio — hereda de los Charters cuyo `originating_spec` resuelve al `spec.md` hermano, si todos declaran lo mismo; vínculo ausente o discrepante → `undeclared` | diff --git a/experiment-baton/src/main.rs b/experiment-baton/src/main.rs index 1a6049e..7e9dad2 100644 --- a/experiment-baton/src/main.rs +++ b/experiment-baton/src/main.rs @@ -17,7 +17,7 @@ use straymark_baton::signals::signals_for; use straymark_baton::speckit; use straymark_baton::telemetry::{build_report, EconomicTelemetry, UnitRouting}; use straymark_baton::tiers::Policy; -use straymark_baton::units::{inventory, task_inheritance_blockers, Granularity}; +use straymark_baton::units::{inheritance_blockers, inventory, Granularity}; #[derive(Parser)] #[command( @@ -174,16 +174,17 @@ fn parse_granularity(s: &str) -> anyhow::Result> { }) } -/// Say why tasks stay undeclared when an unreadable Charter disables task -/// inheritance (#427) — otherwise the only hint is "declare the verb". +/// Say why tasks and batches stay undeclared when an unreadable Charter +/// disables inheritance (#427, #428) — otherwise the only hint is "declare the +/// verb". fn note_inheritance_blockers(root: &Path, only: Option) { - if only.is_some_and(|g| g != Granularity::Task) { + if only.is_some_and(|g| !matches!(g, Granularity::Task | Granularity::Batch)) { return; } - let blockers = task_inheritance_blockers(root); + let blockers = inheritance_blockers(root); if !blockers.is_empty() { eprintln!( - "{} task inheritance disabled — unreadable Charter frontmatter could hide a competing parent: {}", + "{} inheritance from Charters disabled — unreadable Charter frontmatter could hide a competing parent: {}", "note:".yellow(), blockers.join(", ") ); diff --git a/experiment-baton/src/units.rs b/experiment-baton/src/units.rs index 27acc91..42b1741 100644 --- a/experiment-baton/src/units.rs +++ b/experiment-baton/src/units.rs @@ -162,10 +162,119 @@ fn read_charters(root: &Path) -> Vec { .collect() } +// ---- Parent declarations (#332 inheritance) -------------------------------- + +/// `(work_verb, design_provenance)` as declared in a frontmatter or ledger line. +type Declaration = (Option, Option); + +/// A Charter as a candidate parent: where its `originating_spec` resolves +/// (canonical, `None` if absent or unresolvable), which AILOG holds its batch +/// ledger, and what it declares. +struct CharterOrigin { + spec: Option, + /// Key of the AILOG `straymark charter batch-complete` writes this Charter's + /// ledger into: `originating_ailogs[0]`, else `execution_ailogs[0]`. + ledger_ailog: Option, + declaration: Declaration, +} + +/// Every Charter's origin, read from the raw frontmatter so a typed-schema +/// problem (a bad enum, a missing required field) does not hide a parent. +/// `Err` lists the Charters whose parent links cannot be read at all: any of +/// them could be a competing parent, so the parent inventory is incomplete. +fn charter_origins(root: &Path) -> Result, Vec> { + let mut origins = Vec::new(); + let mut unreadable = Vec::new(); + for path in discover_charters(root) { + let Ok(yaml) = read_frontmatter_yaml(&path) else { + unreadable.push(rel(root, &path)); + continue; + }; + let spec = match yaml.get("originating_spec") { + None => None, + Some(v) => match v.as_str() { + Some(origin) => root.join(origin).canonicalize().ok(), + None => { + unreadable.push(rel(root, &path)); + continue; + } + }, + }; + let Ok(ledger_ailog) = ledger_ailog(&yaml) else { + unreadable.push(rel(root, &path)); + continue; + }; + origins.push(CharterOrigin { + spec, + ledger_ailog, + declaration: ( + yaml_str(&yaml, "work_verb"), + yaml_str(&yaml, "design_provenance"), + ), + }); + } + if unreadable.is_empty() { + Ok(origins) + } else { + Err(unreadable) + } +} + +/// The Charter's ledger AILOG, resolved exactly as `charter batch-complete` +/// does. `Err` = a link field present but not a list of ids. +fn ledger_ailog(yaml: &serde_yaml::Value) -> Result, ()> { + for key in ["originating_ailogs", "execution_ailogs"] { + let Some(value) = yaml.get(key) else { + continue; + }; + let ids = value + .as_sequence() + .ok_or(())? + .iter() + .map(|id| id.as_str().ok_or(())) + .collect::, _>>()?; + if let Some(first) = ids.first() { + return Ok(ailog_key(first.trim())); + } + } + Ok(None) +} + +/// `AILOG-YYYY-MM-DD-NNN[x]`: the first five `-` segments, which is how +/// `straymark` resolves an AILOG id to its file. A slugged id and a filename +/// stem therefore compare equal, while `…-028` and `…-028b` do not. +fn ailog_key(s: &str) -> Option { + s.starts_with("AILOG-") + .then(|| s.split('-').take(5).collect::>().join("-")) +} + +/// Charters whose unreadable frontmatter disables inheritance from Charters +/// for the whole inventory (empty = inheritance active). Lets the CLI say *why* +/// tasks and batches stay undeclared instead of only nudging to declare a verb. +pub fn inheritance_blockers(root: &Path) -> Vec { + charter_origins(root).err().unwrap_or_default() +} + +/// The declaration every candidate parent agrees on. No artifact says which +/// units each parent owns, so several parents must all declare the same thing: +/// one that differs, or declares nothing, makes the unit ambiguous. +fn agreed<'a>(mut parents: impl Iterator) -> Option { + let first = parents.next()?; + if parents.any(|d| d != first) { + return None; + } + Some(first.clone()) +} + // ---- Batch (from AILOG `## Batch Ledger`) --------------------------------- +/// The ratified placement (#332 §3, #428): a batch declares on its own ledger +/// line only when it differs from its parent — the AILOG's frontmatter, else the +/// Charter(s) whose ledger this AILOG is. fn read_batches(root: &Path) -> Vec { let mut out = Vec::new(); + // An unreadable Charter could be a competing parent (see `read_tasks`). + let origins = charter_origins(root).unwrap_or_default(); for path in find_files(root, |p| { ext_is(p, "md") && file_name(p).starts_with("AILOG-") }) { @@ -174,36 +283,114 @@ fn read_batches(root: &Path) -> Vec { }; let stem = file_stem(&path); let rel_path = rel(root, &path); + let parent = batch_parent(&path, &stem, &origins); + let first = out.len(); + // Per-batch `Work verb` / `Design provenance` lines, in batch order. + let mut own: Vec = Vec::new(); + let mut in_comment = false; + let mut in_fence = false; + let mut in_batch = false; for line in content.lines() { - // `### Batch 1 — B1: crate scaffold + SpecKit adapter (T1.1–T1.5)` - let Some(rest) = line.trim().strip_prefix("### Batch ") else { + let visible = outside_html_comments(line, &mut in_comment); + let t = visible.trim(); + if t.starts_with("```") || t.starts_with("~~~") { + in_fence = !in_fence; continue; - }; - let (num, title) = split_on_dash(rest); - let num = num.split_whitespace().next().unwrap_or("").trim(); - if num.is_empty() { + } + if in_fence { continue; } - out.push(RoutableUnit { - id: format!("{stem}#batch-{num}"), - granularity: Granularity::Batch, - source: SourceRef { - file: rel_path.clone(), - symbol: Some(format!("Batch {num}")), - }, - title: if title.is_empty() { rest.trim().to_string() } else { title }, - effort_estimate: None, - followup_bucket: None, - followup_severity: None, - work_verb: None, - design_provenance: None, - scope_globs: Vec::new(), - }); + if is_heading(t) { + in_batch = false; + // `### Batch 1 — B1: crate scaffold + SpecKit adapter (T1.1–T1.5)` + let Some(rest) = t.strip_prefix("### Batch ") else { + continue; + }; + let (num, title) = split_on_dash(rest); + let num = num.split_whitespace().next().unwrap_or("").trim(); + if num.is_empty() { + continue; + } + out.push(RoutableUnit { + id: format!("{stem}#batch-{num}"), + granularity: Granularity::Batch, + source: SourceRef { + file: rel_path.clone(), + symbol: Some(format!("Batch {num}")), + }, + title: if title.is_empty() { rest.trim().to_string() } else { title }, + effort_estimate: None, + followup_bucket: None, + followup_severity: None, + work_verb: None, + design_provenance: None, + scope_globs: Vec::new(), + }); + own.push((None, None)); + in_batch = true; + continue; + } + if !in_batch { + continue; + } + let Some(declared) = own.last_mut() else { + continue; + }; + if let Some(v) = field_value(t, "**Work verb**") { + declared.0.get_or_insert(v); + } else if let Some(v) = field_value(t, "**Design provenance**") { + declared.1.get_or_insert(v); + } + } + // A batch's own `Work verb` line overrides its parent as a whole + // declaration; a provenance line alone qualifies nothing. + for (unit, declared) in out[first..].iter_mut().zip(own) { + let (verb, provenance) = if declared.0.is_some() { + declared + } else { + parent.clone() + }; + unit.work_verb = verb; + unit.design_provenance = provenance; } } out } +/// What a ledger's batches inherit: the AILOG's own frontmatter declaration, +/// else the Charter(s) whose ledger this AILOG is, when they all agree. +fn batch_parent(path: &Path, stem: &str, origins: &[CharterOrigin]) -> Declaration { + match read_frontmatter_yaml(path) { + Ok(yaml) => { + let own = ( + yaml_str(&yaml, "work_verb"), + yaml_str(&yaml, "design_provenance"), + ); + if own.0.is_some() { + return own; + } + } + // A frontmatter we cannot read may declare something we cannot see; + // inheriting past it could route cheaper than the AILOG says. + Err(_) => return (None, None), + } + let Some(key) = ailog_key(stem) else { + return (None, None); + }; + agreed( + origins + .iter() + .filter(|o| o.ledger_ailog.as_deref() == Some(key.as_str())) + .map(|o| &o.declaration), + ) + .unwrap_or((None, None)) +} + +/// A markdown ATX heading (`#`…`######` followed by a space). +fn is_heading(line: &str) -> bool { + line.starts_with('#') && line.trim_start_matches('#').starts_with(' ') +} + // ---- Follow-up (the registry) --------------------------------------------- fn read_followups(root: &Path) -> Vec { @@ -341,60 +528,6 @@ fn yaml_str(y: &serde_yaml::Value, key: &str) -> Option { // ---- Task (from `specs/**/tasks.md`) -------------------------------------- -/// `(work_verb, design_provenance)` as declared in a frontmatter. -type Declaration = (Option, Option); - -/// A Charter as a candidate task parent: where its `originating_spec` resolves -/// (canonical, `None` if absent or unresolvable) and what it declares. -struct CharterOrigin { - spec: Option, - declaration: Declaration, -} - -/// Every Charter's origin, read from the raw frontmatter so a typed-schema -/// problem (a bad enum, a missing required field) does not hide a parent. -/// `Err` lists the Charters whose frontmatter cannot be read at all: any of them -/// could be a competing parent, so the parent inventory is incomplete. -fn charter_origins(root: &Path) -> Result, Vec> { - let mut origins = Vec::new(); - let mut unreadable = Vec::new(); - for path in discover_charters(root) { - let Ok(yaml) = read_frontmatter_yaml(&path) else { - unreadable.push(rel(root, &path)); - continue; - }; - let spec = match yaml.get("originating_spec") { - None => None, - Some(v) => match v.as_str() { - Some(origin) => root.join(origin).canonicalize().ok(), - None => { - unreadable.push(rel(root, &path)); - continue; - } - }, - }; - origins.push(CharterOrigin { - spec, - declaration: ( - yaml_str(&yaml, "work_verb"), - yaml_str(&yaml, "design_provenance"), - ), - }); - } - if unreadable.is_empty() { - Ok(origins) - } else { - Err(unreadable) - } -} - -/// Charters whose unreadable frontmatter disables task inheritance for the -/// whole inventory (empty = inheritance active). Lets the CLI say *why* tasks -/// stay undeclared instead of only nudging to declare a verb. -pub fn task_inheritance_blockers(root: &Path) -> Vec { - charter_origins(root).err().unwrap_or_default() -} - /// A task has no declaration slot (#332). Inherit only through explicit /// Charter origins resolving to its sibling spec, never from titles or nearest /// directories. Several Charters can cover one spec and none says which tasks it @@ -408,15 +541,12 @@ fn task_declaration(root: &Path, tasks: &Path, origins: &[CharterOrigin]) -> Dec if !spec.is_file() || !spec.starts_with(&root) { return None; } - let mut parents = origins - .iter() - .filter(|o| o.spec.as_ref() == Some(&spec)) - .map(|o| &o.declaration); - let first = parents.next()?; - if parents.any(|d| d != first) { - return None; - } - Some(first.clone()) + agreed( + origins + .iter() + .filter(|o| o.spec.as_ref() == Some(&spec)) + .map(|o| &o.declaration), + ) }; resolve().unwrap_or((None, None)) } diff --git a/experiment-baton/tests/batch_inheritance.rs b/experiment-baton/tests/batch_inheritance.rs new file mode 100644 index 0000000..3c21338 --- /dev/null +++ b/experiment-baton/tests/batch_inheritance.rs @@ -0,0 +1,231 @@ +//! Ratified #332 batch placement (#428): a batch's own ledger line overrides +//! its parent — the AILOG's frontmatter, else the Charter(s) whose ledger the +//! AILOG is (the link `charter batch-complete` writes through), when they agree. +use std::path::PathBuf; +use std::sync::atomic::{AtomicU64, Ordering}; + +use straymark_baton::classify::TaskClass; +use straymark_baton::telemetry::build_report; +use straymark_baton::tiers::{Policy, Tier}; +use straymark_baton::units::{inventory, Granularity}; + +static NEXT: AtomicU64 = AtomicU64::new(0); + +const AILOG: &str = "AILOG-2026-09-13-001"; + +struct Project(PathBuf); +impl Project { + fn new() -> Self { + let root = std::env::temp_dir().join(format!( + "baton-batch-inheritance-{}-{}", + std::process::id(), + NEXT.fetch_add(1, Ordering::Relaxed) + )); + std::fs::create_dir(&root).unwrap(); + Self(root) + } + + fn write(&self, path: &str, content: &str) { + let path = self.0.join(path); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, content).unwrap(); + } + + /// A ledger AILOG whose frontmatter carries `frontmatter` and whose ledger + /// is `ledger` (the `## Batch Ledger` body). + fn ailog(&self, file: &str, frontmatter: &str, ledger: &str) { + let id = file.split('-').take(5).collect::>().join("-"); + self.write( + &format!(".straymark/07-ai-audit/agent-logs/{file}.md"), + &format!("---\nid: {id}\ntitle: Synthetic\n{frontmatter}---\n\n# AILOG\n\n## Batch Ledger\n\n{ledger}"), + ); + } + + fn charter(&self, number: u8, links: &str, declaration: &str) { + self.write( + &format!(".straymark/charters/{number:02}-example.md"), + &format!("---\ncharter_id: CHARTER-{number:02}-example\nstatus: in-progress\neffort_estimate: M\ntrigger: synthetic\n{links}{declaration}---\n# Charter\n"), + ); + } + + fn batches(&self) -> Vec<(String, Option, Tier)> { + let units = inventory(&self.0, Some(Granularity::Batch)); + let (routes, _) = build_report(&units, &Policy::default()); + routes + .into_iter() + .map(|r| (r.id.rsplit('#').next().unwrap().to_string(), r.class, r.tier)) + .collect() + } +} +impl Drop for Project { + fn drop(&mut self) { + // Never panic in Drop: a failing test is already unwinding. + let _ = std::fs::remove_dir_all(&self.0); + } +} + +fn b(n: u8, class: Option, tier: Tier) -> (String, Option, Tier) { + (format!("batch-{n}"), class, tier) +} + +#[test] +fn issue_428_reproduction_ledger_line_overrides_ailog_frontmatter() { + let p = Project::new(); + p.ailog( + AILOG, + "work_verb: operate\n", + "### Batch 1 — Synthetic\n- **Work verb**: design\n\n### Batch 2 — Homogeneous\n(pending)\n", + ); + assert_eq!( + p.batches(), + [ + b(1, Some(TaskClass::Planner), Tier::Frontier), + b(2, Some(TaskClass::Operator), Tier::Local), + ] + ); +} + +#[test] +fn batches_inherit_from_the_charter_whose_ledger_the_ailog_is() { + for links in [ + format!("originating_ailogs: [{AILOG}]\n"), + // Spec-originated Charter: batch-complete writes to execution_ailogs[0]. + format!("originating_spec: specs/001/spec.md\nexecution_ailogs: [{AILOG}-slug]\n"), + ] { + let p = Project::new(); + p.charter(1, &links, "work_verb: implement\ndesign_provenance: new\n"); + p.ailog(&format!("{AILOG}-ledger"), "", "### Batch 1 — One\n"); + assert_eq!( + p.batches(), + [b(1, Some(TaskClass::Implementer), Tier::Economic)], + "{links}" + ); + } +} + +#[test] +fn only_the_ledger_link_counts() { + let p = Project::new(); + // Listed, but not first: batch-complete would not write this Charter's + // ledger here. And `…-001b` is a different AILOG from `…-001`. + p.charter( + 1, + "originating_ailogs: [AILOG-2026-01-01-001, AILOG-2026-09-13-001]\n", + "work_verb: operate\n", + ); + p.charter(2, "originating_ailogs: [AILOG-2026-09-13-001b]\n", "work_verb: operate\n"); + p.ailog(AILOG, "", "### Batch 1 — One\n"); + assert_eq!(p.batches(), [b(1, None, Tier::Frontier)]); +} + +#[test] +fn charter_parents_must_agree() { + for (second, expected) in [ + ("work_verb: audit\n", b(1, Some(TaskClass::Auditor), Tier::Economic)), + ("work_verb: design\n", b(1, None, Tier::Frontier)), + ("", b(1, None, Tier::Frontier)), + ] { + let p = Project::new(); + p.charter(1, &format!("originating_ailogs: [{AILOG}]\n"), "work_verb: audit\n"); + p.charter(2, &format!("execution_ailogs: [{AILOG}]\n"), second); + p.ailog(AILOG, "", "### Batch 1 — One\n"); + assert_eq!(p.batches(), [expected], "{second:?}"); + } +} + +#[test] +fn the_ailog_frontmatter_is_nearer_than_the_charter() { + let p = Project::new(); + p.charter(1, &format!("originating_ailogs: [{AILOG}]\n"), "work_verb: design\n"); + p.ailog(AILOG, "work_verb: audit\n", "### Batch 1 — One\n"); + assert_eq!(p.batches(), [b(1, Some(TaskClass::Auditor), Tier::Economic)]); +} + +#[test] +fn an_override_replaces_the_whole_declaration() { + let p = Project::new(); + p.ailog( + AILOG, + "work_verb: implement\ndesign_provenance: upstream\n", + "\ +### Batch 1 — Own verb, no provenance: not the parent's `upstream` +- **Work verb**: implement + +### Batch 2 — A provenance line alone qualifies nothing +- **Design provenance**: new + +### Batch 3 — Both, in either order +- **Design provenance**: upstream +- **Work verb**: implement +", + ); + assert_eq!( + p.batches(), + [ + b(1, Some(TaskClass::Implementer), Tier::Economic), + b(2, Some(TaskClass::Operator), Tier::Local), + b(3, Some(TaskClass::Operator), Tier::Local), + ] + ); +} + +#[test] +fn examples_and_other_sections_never_declare_a_batch() { + let p = Project::new(); + p.ailog( + AILOG, + "", + "\ + +### Batch 1 — One +Notes. + +#### Details +- **Work verb**: operate + +```markdown +### Batch 9 — Quoted +- **Work verb**: audit +``` + +## Modified Files +- **Work verb**: operate +", + ); + assert_eq!(p.batches(), [b(1, None, Tier::Frontier)]); +} + +#[test] +fn an_unreadable_ailog_frontmatter_blocks_inheritance_but_not_overrides() { + let p = Project::new(); + p.charter(1, &format!("originating_ailogs: [{AILOG}]\n"), "work_verb: operate\n"); + p.write( + &format!(".straymark/07-ai-audit/agent-logs/{AILOG}.md"), + "---\nwork_verb: [broken\n---\n### Batch 1 — One\n\n### Batch 2 — Two\n- **Work verb**: audit\n", + ); + assert_eq!( + p.batches(), + [ + b(1, None, Tier::Frontier), + b(2, Some(TaskClass::Auditor), Tier::Economic), + ] + ); +} + +#[test] +fn an_unreadable_charter_blocks_only_charter_inheritance() { + let p = Project::new(); + p.charter(1, &format!("originating_ailogs: [{AILOG}]\n"), "work_verb: operate\n"); + p.write(".straymark/charters/02-broken.md", "---\ncharter_id: [invalid\n---\n"); + p.ailog(AILOG, "", "### Batch 1 — One\n"); + p.ailog( + "AILOG-2026-09-13-002", + "work_verb: audit\n", + "### Batch 1 — AILOG-level declaration still applies\n", + ); + let units = inventory(&p.0, Some(Granularity::Batch)); + let verbs: Vec<_> = units.iter().map(|u| u.work_verb.as_deref()).collect(); + assert_eq!(verbs, [None, Some("audit")]); +} diff --git a/experiment-baton/tests/task_inheritance.rs b/experiment-baton/tests/task_inheritance.rs index 5f45886..92b8017 100644 --- a/experiment-baton/tests/task_inheritance.rs +++ b/experiment-baton/tests/task_inheritance.rs @@ -233,7 +233,7 @@ fn cli_says_why_an_unreadable_charter_disables_inheritance() { .unwrap(); assert!(run.status.success()); let stderr = String::from_utf8_lossy(&run.stderr); - assert!(stderr.contains("task inheritance disabled"), "{stderr}"); + assert!(stderr.contains("inheritance from Charters disabled"), "{stderr}"); assert!( stderr.contains(".straymark/charters/02-broken.md"), "{stderr}"