Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
---
id: AILOG-2026-08-07-001
title: Two silent failures from adopter reports — an inert refresh heuristic (#416) and duplicate FU ids that misdirected writes (#415)
status: accepted
created: 2026-08-07
agent: claude-opus-5-v1.0
confidence: high
review_required: false
risk_level: medium
eu_ai_act_risk: not_applicable
nist_genai_risks: []
iso_42001_clause: []
lines_changed: 520
files_modified:
- cli/src/commands/charter/mod.rs
- cli/src/commands/charter/close.rs
- cli/src/commands/charter/refresh_suggest.rs
- cli/src/commands/charter/amend.rs
- cli/src/followups.rs
- cli/src/validation.rs
- cli/src/commands/followups/
- cli/tests/duplicate_fu_ids_test.rs
- dist/STRAYMARK.md
- dist/.straymark/00-governance/QUICK-REFERENCE.md
- CHANGELOG.md
observability_scope: none
tags: [charter, telemetry, follow-ups, adopter-feedback, silent-failure, cli, i18n]
related:
- AILOG-2026-08-06-001
- AILOG-2026-08-04-003
---

# AILOG: issues #415 and #416

## Summary

Two adopter reports, both the same shape: the failure produced a message that
looked normal. #416 had a heuristic reporting "not enough Charters yet" in every
repo because the reader and writer disagreed on a filename. #415 had a registry
where a write could land on the wrong entry and answer `already closed`.

Both fixed. In #415 the reported cause turned out to be wrong, and the real one
had been sitting in the adopter's own registry since June.

## #416 — `refresh-suggest` never found telemetry

The report was correct in every particular, including which of the two
filenames was the right one to keep. `close` writes `CHARTER-NN.telemetry.yaml`
with the slug stripped so the name survives a rename; `refresh_suggest` built
`<NN-slug>.telemetry.yaml` from the Charter's own file stem.

Reproduced on a second repo before touching anything: Sentinel has 27 telemetry
files, all canonical, and `refresh-suggest` reported `(missing)` for its Charter
with `Chain length: 0`. Not specific to the reporting adopter.

**Fix**: one `canonical_telemetry_name` in `charter/mod.rs`, used by both sides,
with a fallback to the legacy name for telemetry older CLIs may have written.
The regression test asserts what actually matters — not the string, but that
writer and reader derive it from the same place.

Also corrected: the two thresholds printed as bare numbers (`3` sample size vs
`6` trigger value) read as contradictory; each line now names what it gates.

**Scope correction.** In my first comment on #416 I said the stale convention
lived in five files including `SPECKIT-CHARTER-BRIDGE.md`. It does not — my grep
matched a line where `NN-slug.md` (correct, that is the Charter file) and the
word "telemetry" merely co-occur. Four files: `STRAYMARK.md` and
`QUICK-REFERENCE.md` in three languages, plus the `charter amend` guidance
string. Corrected in the closing comment.

## #415 — duplicate FU ids

**The reported cause does not reproduce.** The issue proposed that ids are
assigned per-AILOG without atomic reservation; `next_n += 1` has been inside the
per-entry loop since before cli-3.41.0, and two AILOGs in one pass produce
`FU-001` and `FU-002` correctly. Suggestion 1 was already implemented.

Two real paths, both reproduced:

1. **Parallel branches.** `next_fu_number` is `max + 1` over the *local* copy,
so two branches extracting different follow-ups both take the same number. A
merge then carries both. Reproduced with the exact reported shape — same id,
different `Origin`, different `Source-hash`. With the #391 merge driver wired
the collision does not survive: it renumbers. It was wired nowhere.
2. **Triage pruning.** Pruning a closed entry to a provenance bullet removes its
heading, `max()` drops, and the next `drift --apply` reissues the number.
This one was documented in Sentinel's own registry on **2026-06-04** —
*"the CLI numbers from the max `### FU-` heading and prune-to-provenance-bullet
hides closed ids … collision reported upstream"* — and had gone unfixed for
two months.

**Fixes**: the high-water mark now scans every `FU-NNN` mention in the registry
body, so a pruned id stays retired; `find_entry_unique` refuses an ambiguous id
and names both entries, replacing first-match resolution in `note`,
`set-status`, `verify`, `promote` and `status`; and `FOLLOWUP-DUPLICATE-ID`
reports the state as an **error** — unlike most registry findings it silently
misdirects writes, and no reading of a duplicate id is intentional.

## Verification

- `cargo test`: **967 passed, 0 failed**.
- `charter/mod.rs`: 4 unit tests, including the writer/reader agreement one.
- `duplicate_fu_ids_test.rs`: 5 tests reproducing the reported sequence — the
refused `note`/`set-status` leave the file byte-identical; `validate` reports
the duplicate; a clean registry stays silent (the rule keys on headings, so a
cited id is not a second entry); a pruned id is not reissued.
- Against real repos: `refresh-suggest` on Sentinel now resolves
`CHARTER-02.telemetry.yaml`, chain length `1`, rolling mean computed — it was
`(missing)` and `0`. `validate` reports no duplicate ids there today (the
operator repaired the FU-345 collision by hand before reporting).

## Risk

| Id | Risk | Handling |
|----|------|----------|
| R1 | `find_entry_unique` makes `status` fail where it used to print something. A read-only command refusing is a behaviour change. | Deliberate: showing one of two entries sharing an id is how an operator concludes the wrong one is the one they are about to write to. `followups list` still shows everything unfiltered. |
| R2 | `FOLLOWUP-DUPLICATE-ID` is an **error**, so a repo that currently has duplicates will start failing `validate` — including in CI. | Intended. The state misdirects writes and the fix is mechanical (renumber, then `recount`); the hint says so. A warning would reproduce the original failure, which is that nothing said anything. |
| R3 (new, not in Charter) | The high-water mark now derives from raw text, so a stray `FU-9999` in prose would inflate every future id. | Accepted: numbers are cheap and a collision is not, so the scan errs toward retiring too much. Worth revisiting only if an adopter reports id inflation. |
| R4 (new, not in Charter) | The legacy telemetry fallback is unverified — no adopter is known to have `<NN-slug>.telemetry.yaml` on disk. It was added because the reporter suggested it and it costs one `exists()` call. | Noted rather than claimed as tested; the unit test covers the resolution order, not a real-world file. |

## Follow-ups

- Wire the merge driver in Sentinel and LNXDrive. It is the mitigation for
#415's parallel-branch path and remains unexercised in both — already tracked
from AILOG-2026-08-06-001, restated here because #415 is the first confirmed
instance of that path causing damage in production.
14 changes: 12 additions & 2 deletions .straymark/follow-ups-backlog.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
---
last_scan: 2026-08-06
last_scan: 2026-08-07
schema_version: v1
total_open: 3
total_open: 4
total_promoted: 0
total_closed_in_session: 2
total_phase_blocked: 0
Expand All @@ -17,6 +17,7 @@ fully_extracted_ailogs:
- AILOG-2026-08-05-003
- AILOG-2026-08-05-004
- AILOG-2026-08-06-001
- AILOG-2026-08-07-001
---

# Follow-ups Backlog
Expand Down Expand Up @@ -88,6 +89,15 @@ Entry shape (v1 — optional fields marked):
- **Cost**: TBD
- **Notes**: Auto-appended by `straymark followups drift --apply` 2026-08-06.

### FU-006 — Wire the merge driver in Sentinel and LNXDrive.
- **Origin**: AILOG-2026-08-07-001 §Follow-ups
- **Source-hash**: 937ac2c96b96
- **Status**: open
- **Trigger**: TBD
- **Destination**: TBD
- **Cost**: TBD
- **Notes**: Auto-appended by `straymark followups drift --apply` 2026-08-07.

## Bucket: time-triggered

## Bucket: charter-triggered
Expand Down
28 changes: 28 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,34 @@ and this project uses [independent versioning](README.md#versioning) for Framewo

---

## Framework 4.43.0 / CLI 3.45.0 — 2026-08-06

Two adopter reports, both silent-failure shaped: a heuristic that had been inert in every repo while reporting a reassuring message, and a registry that let writes land on the wrong entry.

### Fixed (CLI)

- **`charter refresh-suggest` never found telemetry** (#416). `close` writes `CHARTER-NN.telemetry.yaml` (slug stripped, so the name survives a Charter rename); `refresh-suggest` built `<NN-slug>.telemetry.yaml` from the Charter's own filename. The reader never found what the writer wrote, so the refresh heuristic — the mechanism that detects accumulated spec drift across a Charter chain — has been inert in **every repo with closed Charters** since the names diverged. Both sides now derive the name from one helper, with a fallback to the legacy name for telemetry written by older CLIs. Confirmed on a second repo: 27 telemetry files, every Charter reported `(missing)`, chain length `0`.

The failure mode is why this matters more than its size: `Chain shorter than 3 closed Charters with telemetry — heuristic not yet meaningful` is indistinguishable from the normal early state, so a seven-Charter chain ran past it without anyone suspecting a defect.

- **Duplicate FU ids silently misdirected writes** (#415). Commands resolving by id took the **first** match, so with two entries sharing a number `note` annotated the wrong follow-up and `set-status` answered `already closed — nothing to change` — which reads as success while the intended entry stayed open. `note` / `set-status` / `verify` / `promote` / `status` now refuse an ambiguous id and name both entries.

- **Triage pruning released ids for reuse** (#415, reported from Sentinel 2026-06-04). `next_fu_number` was `max(parsed entries) + 1`, so an id stopped being reserved the moment its entry lost its `### FU-NNN` heading — which is exactly what triage does when it prunes a closed entry to a provenance bullet. The high-water mark now comes from every `FU-NNN` mentioned anywhere in the registry body.

The other collision path — parallel branches each computing `max + 1` against their own copy — is handled by the #391 merge driver, which renumbers on merge. Reproduced both.

### Added (CLI)

- **`FOLLOWUP-DUPLICATE-ID`** validation rule (#415): two entries sharing a `### FU-NNN` heading are reported as an **error**. Unlike most registry findings this one silently misdirects writes, and no reading of a duplicate id is intentional.

### Changed (CLI)

- **`refresh-suggest` labels its two thresholds** (#416). `3` (sample size needed to evaluate) and `6` (value the rolling mean must beat) were both printed as bare numbers and read as contradictory. Each line now names what it gates.

### Fixed (Framework)

- **The telemetry filename convention was documented wrong** (#416) in `STRAYMARK.md` (§10 and §15) and `QUICK-REFERENCE.md` (EN/es/zh-CN) — the docs corroborated the reader, not the writer, in every language. Same correction in the `charter amend --merge-into` guidance the CLI prints.

## CLI 3.44.0 — 2026-08-06

### Added (CLI)
Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -278,8 +278,8 @@ StrayMark uses independent version tags for each component:

| Component | Tag prefix | Example | Includes |
| --- | --- | --- | --- |
| Framework | `fw-` | `fw-4.42.0` | Templates (12 types), governance, directives, Charter template + schema |
| CLI | `cli-` | `cli-3.44.0` | The `straymark` binary |
| Framework | `fw-` | `fw-4.43.0` | Templates (12 types), governance, directives, Charter template + schema |
| CLI | `cli-` | `cli-3.45.0` | The `straymark` binary |
| Loom (EXPERIMENTAL) | `loom-` | `loom-0.4.2` | The `straymark-loom` visualization server, downloaded on demand by `straymark loom serve` |

Check installed versions with `straymark status` or `straymark about`.
Expand Down
2 changes: 1 addition & 1 deletion cli/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "straymark-cli"
version = "3.44.0"
version = "3.45.0"
edition = "2021"
description = "CLI for StrayMark — the cognitive discipline your AI-assisted projects need"
license = "MIT"
Expand Down
2 changes: 1 addition & 1 deletion cli/src/commands/charter/amend.rs
Original file line number Diff line number Diff line change
Expand Up @@ -352,7 +352,7 @@ findings_closed: {findings_closed}\n\
\n\
## Telemetry\n\
\n\
This amendment populates `charter_telemetry.post_close_amendment:` in the Charter's `.telemetry.yaml`. Run `straymark charter amend {charter_id} --trigger {trigger} --merge-into .straymark/charters/<NN-slug>.telemetry.yaml` (this command) with the appropriate path to auto-merge, or paste the YAML block printed by the CLI.\n\
This amendment populates `charter_telemetry.post_close_amendment:` in the Charter's `.telemetry.yaml`. Run `straymark charter amend {charter_id} --trigger {trigger} --merge-into .straymark/charters/CHARTER-NN.telemetry.yaml` (this command) with the appropriate path to auto-merge, or paste the YAML block printed by the CLI.\n\
",
amends
.map(|a| format!("- **Amends**: [{a}]({a}.md) (forward pointer; the original gets a `## Historical correction` subsection)\n"))
Expand Down
16 changes: 4 additions & 12 deletions cli/src/commands/charter/close.rs
Original file line number Diff line number Diff line change
Expand Up @@ -305,19 +305,11 @@ fn truncate_desc(s: &str, max: usize) -> String {
}

/// Build the `.straymark/charters/CHARTER-NN.telemetry.yaml` path for a Charter.
///
/// Delegates to the shared helper so the writer and every reader derive the
/// name from one place (GH #416).
fn telemetry_path_for(charters_state_dir: &Path, charter: &Charter) -> PathBuf {
// Strip optional slug suffix to keep the telemetry filename stable across
// Charter renames: CHARTER-01-foo → CHARTER-01.telemetry.yaml.
let id = &charter.frontmatter.charter_id;
let canonical = id
.split_once('-')
.and_then(|(prefix, rest)| {
// CHARTER-NN[-slug] → CHARTER-NN
let nn = rest.split('-').next()?;
Some(format!("{}-{}", prefix, nn))
})
.unwrap_or_else(|| id.clone());
charters_state_dir.join(format!("{}.telemetry.yaml", canonical))
super::canonical_telemetry_path(charters_state_dir, &charter.frontmatter.charter_id)
}

/// Copy the template YAML to the telemetry path. Refuses to overwrite an
Expand Down
108 changes: 108 additions & 0 deletions cli/src/commands/charter/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,111 @@ pub mod list;
pub mod new;
pub mod refresh_suggest;
pub mod status;

use std::path::{Path, PathBuf};

/// Canonical name of a Charter's telemetry sidecar: `CHARTER-NN.telemetry.yaml`.
///
/// Derived from `charter_id` with any slug suffix stripped, so the filename
/// survives a Charter rename (`CHARTER-01-foo` → `CHARTER-01.telemetry.yaml`).
///
/// **This is the single definition on purpose** (GH #416). `close` built this
/// name while `refresh-suggest` built `<NN-slug>.telemetry.yaml` from the
/// Charter's own filename, so the reader never found what the writer wrote and
/// the refresh heuristic was inert in every repo — reported as `(missing)` for
/// every Charter, indistinguishable from "not enough closed Charters yet".
pub fn canonical_telemetry_name(charter_id: &str) -> String {
let canonical = charter_id
.split_once('-')
.and_then(|(prefix, rest)| {
// CHARTER-NN[-slug] → CHARTER-NN
let nn = rest.split('-').next()?;
Some(format!("{prefix}-{nn}"))
})
.unwrap_or_else(|| charter_id.to_string());
format!("{canonical}.telemetry.yaml")
}

/// Where `close` writes a Charter's telemetry.
pub fn canonical_telemetry_path(charters_state_dir: &Path, charter_id: &str) -> PathBuf {
charters_state_dir.join(canonical_telemetry_name(charter_id))
}

/// Legacy sidecar name written before the canonical one: the Charter's own file
/// stem plus `.telemetry.yaml`. Read-only — never written any more, but adopters
/// may still have files under it.
pub fn legacy_telemetry_path(charter_path: &Path) -> Option<PathBuf> {
let stem = charter_path.file_stem()?.to_str()?;
let parent = charter_path.parent()?;
Some(parent.join(format!("{stem}.telemetry.yaml")))
}

/// Locate a Charter's telemetry on disk: canonical name first, legacy second.
///
/// Returns the canonical path even when nothing exists, so callers can report a
/// meaningful "expected here" rather than `None`.
pub fn resolve_telemetry_path(charter_path: &Path, charter_id: &str) -> PathBuf {
let dir = charter_path.parent().unwrap_or_else(|| Path::new("."));
let canonical = canonical_telemetry_path(dir, charter_id);
if canonical.exists() {
return canonical;
}
if let Some(legacy) = legacy_telemetry_path(charter_path) {
if legacy.exists() {
return legacy;
}
}
canonical
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn canonical_name_strips_the_slug() {
assert_eq!(
canonical_telemetry_name("CHARTER-22-fundacion-expedientes"),
"CHARTER-22.telemetry.yaml"
);
assert_eq!(canonical_telemetry_name("CHARTER-02"), "CHARTER-02.telemetry.yaml");
}

#[test]
fn canonical_name_passes_through_unrecognized_shapes() {
assert_eq!(canonical_telemetry_name("WEIRD"), "WEIRD.telemetry.yaml");
}

/// GH #416: the writer and the reader disagreed for two releases and the
/// only visible symptom was a reassuring "not enough Charters yet" message.
/// What must never regress is not the name — it is that both sides derive
/// it from the same place.
#[test]
fn writer_and_reader_agree_on_the_name() {
let dir = std::path::Path::new("/p/.straymark/charters");
let charter_path = dir.join("22-fundacion-expedientes.md");
let id = "CHARTER-22-fundacion-expedientes";

let written = canonical_telemetry_path(dir, id);
let read = resolve_telemetry_path(&charter_path, id);
assert_eq!(written, read);
assert!(written.ends_with("CHARTER-22.telemetry.yaml"));
}

#[test]
fn falls_back_to_the_legacy_name_when_only_that_exists() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
let charter_path = dir.join("07-old.md");
std::fs::write(&charter_path, "x").unwrap();
std::fs::write(dir.join("07-old.telemetry.yaml"), "y").unwrap();

let resolved = resolve_telemetry_path(&charter_path, "CHARTER-07-old");
assert!(resolved.ends_with("07-old.telemetry.yaml"));

// Once the canonical file appears it wins, without touching the legacy one.
std::fs::write(dir.join("CHARTER-07.telemetry.yaml"), "z").unwrap();
let resolved = resolve_telemetry_path(&charter_path, "CHARTER-07-old");
assert!(resolved.ends_with("CHARTER-07.telemetry.yaml"));
}
}
Loading