Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
fcc3df0
fix(history): stage each agent behind its own lock
REPPL Sep 25, 2026
4c34bb9
fix: decode a URL's userinfo before deciding where the login ends
REPPL Sep 25, 2026
9b96e61
chore: resolve iss-2609090828371674 — per-agent staging locks
REPPL Sep 25, 2026
b08621f
chore: resolve iss-2609020630232658 — decoded userinfo colon test
REPPL Sep 25, 2026
6bd2e84
perf(history): let reconstruct's mode reach the thread loader
REPPL Sep 25, 2026
2351cc9
chore: resolve iss-2609091155497399 — spine mode reaches the loader
REPPL Sep 25, 2026
57acad2
refactor(history): give session ownership one definition
REPPL Sep 25, 2026
1edd3df
chore: resolve iss-2609091911066372 — one session-ownership rule
REPPL Sep 25, 2026
8a61f10
feat(history): capture a whole session with capture --session <id> --all
REPPL Sep 25, 2026
9383582
chore: resolve iss-2609202046145653 — capture --session --all
REPPL Sep 25, 2026
2475b57
fix(memory): read the store through one os.Root handle
REPPL Sep 25, 2026
474f4dd
chore: resolve iss-2608291814572914 — structural memory store contain…
REPPL Sep 25, 2026
cdf5a43
fix(memory): compare the store lock's file type under S_IFMT
REPPL Sep 25, 2026
a88dea0
chore: resolve iss-2608261133210491 — store-lock S_IFMT mask
REPPL Sep 25, 2026
bf6df37
fix(memory): stop a page name re-pairing the code span a render wraps…
REPPL Sep 25, 2026
3fd96ee
chore: capture the lifeboat half of the re-wrapped renderer record
REPPL Sep 25, 2026
9f23bc2
chore: resolve iss-2609020539188868 — memory renderers own no delimiter
REPPL Sep 25, 2026
534c400
fix(memory): hold the page-filename refusal to secret patterns, as ruled
REPPL Sep 25, 2026
b5f5ab9
chore: resolve iss-2609090951282192 — filename bar holds to secrets
REPPL Sep 25, 2026
cb2c4c9
docs(cli): list Cobra's completion and help in the generated reference
REPPL Sep 25, 2026
f85e710
chore: capture the record-lint job-name half of iss-304
REPPL Sep 25, 2026
a7a6f5d
chore: resolve iss-304 — reference lists the builtins; local read bou…
REPPL Sep 25, 2026
c14eaa2
chore: restamp four resolutions after a message-only rebase
REPPL Sep 25, 2026
0c5c3d3
chore: capture the coverage-index write that bypasses the store handle
REPPL Sep 25, 2026
a8652c1
fix(memory): read config, registry and fingerprint through the store …
REPPL Sep 25, 2026
113f8ab
fix(memory): write the coverage index through the store handle
REPPL Sep 25, 2026
0037d20
chore: resolve iss-2609252100150846 — coverage index written through …
REPPL Sep 25, 2026
ff82474
chore: make the store-containment resolution name what it covers
REPPL Sep 25, 2026
36628a4
fix(memory): mark a citation cut at the page-value cap
REPPL Sep 25, 2026
a71e5b1
test(history): hold the staging lock's inode revalidation at the stag…
REPPL Sep 25, 2026
6302a2f
fix(fsutil): name the caller's timeout in a lock-contention error
REPPL Sep 25, 2026
ebdc5e8
chore: capture the kept original written by path after the store lock…
REPPL Sep 26, 2026
27e45c5
fix(memory): keep the original through a store handle opened after th…
REPPL Sep 26, 2026
35d5cf5
chore: resolve iss-2609260908572219 — kept original written through t…
REPPL Sep 26, 2026
883b20b
test(memory): pin the kept original's sources/ symlink refusal
REPPL Sep 26, 2026
ab4d90f
chore(capture): local-tier writes by path follow a symlinked ancestor
REPPL Sep 26, 2026
71895f2
Merge branch 'main' into fix/history-memory-cluster
REPPL Sep 26, 2026
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
15 changes: 14 additions & 1 deletion .abcd/development/brief/04-surfaces/11-history.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,13 @@ ahoy's registry stays under `~/.abcd/history/` and holds no transcripts.
standard input, where there is no filename to read it from. The caller also
says where the transcript came from, a session abcd captured itself or an import of
a prior tool's transcripts, and it defaults to the first.
Asked for a whole session instead, capture stores the named session's main
thread and every sub-agent transcript it spawned in one call — the write-side
twin of listing a session — finding them under the paths given, or the
declared `ingest_roots`, by the session their lines name, and placing them
exactly as ingesting does, so a transcript another repository owns is
reported rather than stored here. It needs the session named; nothing infers
the running one.
- **The staged listing** names transcripts that ended but are not yet redacted into the
store. A non-empty list means unredacted transcript text is on disk.
- **Draining** redacts and stores every staged transcript, then deletes the raw
Expand Down Expand Up @@ -210,7 +217,12 @@ which is what it is. The handshake is locked and keyed on content per
one carrying different bytes replaces the staged copy (the later snapshot of a
session is the one worth keeping), and a drain removes a staged file only while
it still holds the bytes it captured. One `(session, agent)` has one staged
copy, and a fresher copy is never lost (GHSA-xq36-hcgf-9wrj).
copy, and a fresher copy is never lost (GHSA-xq36-hcgf-9wrj). The lock is
per agent, never per repository: each staged file's key (its agent id, or the
session id for a main thread) has its own lock file under `staging/locks/`, so
a burst of simultaneous sub-agent completions stages in parallel instead of
queuing behind one lock whose timeout would refuse the tail of the burst. A
lock file is removed with the staged file it guards.

Staging is also **the outcome record the store never had.** Before it, an absent
record spanned "never ended", "ended before the store existed" and "ended and
Expand Down Expand Up @@ -324,6 +336,7 @@ Sub-verbs: none.

| Flag | Type |
|---|---|
| `--all` | bool |
| `--kind` | string |
| `--session` | string |

Expand Down
9 changes: 8 additions & 1 deletion .abcd/development/release/surface.json
Original file line number Diff line number Diff line change
Expand Up @@ -911,8 +911,15 @@
{
"path": "abcd history capture",
"hidden": false,
"sentence": "Redact and store one raw session transcript from a file or stdin: Writes one record into the store; refuses stdin without --session.",
"sentence": "Redact and store a session transcript, or a whole session with --all: Writes one record per transcript; refuses stdin or --all without --session.",
"flags": [
{
"name": "all",
"shorthand": "",
"type": "bool",
"required": false,
"hidden": false
},
{
"name": "kind",
"shorthand": "",
Expand Down

This file was deleted.

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
schema_version: 1
id: "iss-2609251355497247"
slug: "the-lifeboat-half-of-iss-2609020539188868-is-still-open"
severity: "minor"
category: "security"
source: "review-followup"
found_during: "autonomous run A resumed 2026-09-25"
origin: researcher-authored
production_mode: hand-written
found_at: "internal/core/lifeboat"
---

The lifeboat half of iss-2609020539188868 is still open after the memory renderers were fixed: synthesis_review renders a finding id through termsafe.Sanitize alone, never CleanProse, so it can still carry an HTML comment opener or link syntax, and wraps a severity in its own bracket; the press-release subhead wraps a cleaned value in its own emphasis; synthesis_principles writes a cleaned principle as a bare paragraph with no leading-marker escape. The fix is the one applied to memory: every untrusted field on a markdown line through CleanProse, and no renderer adding delimiters around a cleaned value (termsafe.CodeSpan where a code span is wanted).
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
schema_version: 1
id: "iss-2609251358062952"
slug: "the-ci-job-id-record-lint-in-github-workflows-ci-yml-runs"
severity: "nitpick"
category: "inconsistency"
source: "review-followup"
found_during: "autonomous run A resumed 2026-09-25"
origin: researcher-authored
production_mode: hand-written
found_at: ".github/workflows/ci.yml"
---

The CI job id record-lint in .github/workflows/ci.yml runs scripts/check-reviews-cases.sh and scripts/check-reviews.sh (the reviews-charter gate), while the real record-lint is a step of the check job, so a required status check is named for a gate it does not run. Renaming the job alone breaks the merge gate, because the main-protection ruleset (mirrored in .abcd/work/rulesets/main-protection.json) requires the context record-lint: the rename and the live ruleset edit must land together, which needs the forge ruleset changed by someone holding that permission. Carried over from iss-304 (d-12) when its other two halves were closed.
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
schema_version: 1
id: "iss-2609260948440803"
slug: "local-tier-writes-by-path-memory-lint-writes-its-run-log"
severity: "minor"
category: "security"
source: "review-followup"
found_during: "autonomous run A resumed 2026-09-25: review3-history item 6"
origin: researcher-authored
production_mode: hand-written
found_at: "internal/core/memory/lint.go"
---

Local-tier writes by path: memory lint writes its run-log report by path into the local tier and follows a symlinked ancestor out of the checkout. Lint (internal/core/memory/lint.go, lintReportDir and the write after it) joins .abcd/.work.local/logs/memory/lint-<ts> onto the repo root, os.MkdirAll-s it, and writes report.json and report.md with fsutil.WriteFileAtomic by path. Nothing vets .abcd/.work.local or logs/ first, and a committed symlink beats .gitignore (git add -f), so a checkout that ships .abcd/.work.local as a symlink gets the directory chain and both reports created at the link's target: probed at 35d5cf5f with .abcd/.work.local linked to a directory outside the repo, Lint returned nil and logs/memory/lint-<ts>/report.json and report.md were written in the outside directory. The contained pattern for this same tier already exists: mode.SetAt (internal/core/mode/store.go) opens an os.Root on the checkout, root.Lstat-refuses a .abcd/.work.local that is not a real directory, and writes with fsutil.WriteFileAtomicInRoot. Two sites the review named alongside were checked at 35d5cf5f and are NOT in this class: intent/audit.go's review request and dead-letter writes vet .abcd/.work.local/reviews level by level with fsutil.EnsureRealDirAll, and history/location.go's moveFile writes under a chain Resolve proved real with fsutil.EnsureRealDir, so both refuse a symlinked ancestor; each keeps only a vet-by-path-then-write-by-path swap window.
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
schema_version: 1
id: "iss-2608261133210491"
slug: "memory-storelock-wrong-ifmt-mask"
severity: "nitpick"
category: "tech-debt"
source: "agent-finding"
found_during: "bughunt-round-8"
found_at: "internal/core/memory/writer.go:70"
resolution: "The store-lock fstat guard compares the file type under S_IFMT (lockModeIsRegular), so socket and symlink modes are refused; the flock consolidation itself stays with iss-129."
impact: internal
resolved_by:
commit: "cdf5a434"
---

the memory store-lock guard tests mode AND S_IFREG nonzero instead of masking with S_IFMT, so its regular-file assertion also accepts symlink and socket modes; dead defence shielded by O_NOFOLLOW, fold into the iss-129 flock consolidation

## Grounds

- pursued: the guard admits only S_IFREG under the S_IFMT mask; a socket or symlink mode accepted by lockModeIsRegular would show it wrong
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
schema_version: 1
id: "iss-2608291814572914"
slug: "memory-store-containment-is-per-verb-not-structural"
severity: "minor"
category: "architectural-insight"
source: "impl-review"
found_during: "ultra-v0.6.8-followup"
found_at: "internal/core/memory/writer.go"
resolution: "Every memory read (Bare and its headroom, QueryPages, the Ingest dedup and registry load, fileBack, the Lint crawl, residue and quotation checks, and the coverage crawl with its budget and stored fingerprint) goes through one os.Root store handle opened inside the repository root, and one Lint holds one handle for both passes; fileBack opens it before reading. The crawls moved in 2475b570; the config, registry and fingerprint reads that commit left by path moved in a8652c12. The coverage-index write goes through the handle too (iss-2609252100150846), and so does the --keep-original write of sources/<hash>, through a handle Ingest opens after WritePages returns, since the lock and its walk lapse there (iss-2609260908572219). The locked writer keeps validatedMemoryDir and writes by path, a residual behind the store lock; it is the only write into the store by path."
impact: fix
resolved_by:
commit: "a8652c12"
---

ultra-v0.6.8 altitude 3: the memory store's symlink guard is a per-verb pre-check repeated at five entry points (Bare, QueryPages, Ingest, Lint, runMemoryCoverageLint) rather than a containment mechanism, and fileBack in ask.go reached Dir(root) and existingPageFrontmatter without it. The site package fixed the identical class (gh #487) by opening one os.Root and routing every read through fsutil.ReadGuardedInRoot. Deeper fix: memory holds a store-root handle the same way so containment is structural rather than remembered at each verb.

## Grounds

- pursued: a store swapped for a symlink after the handle opened redirects no read, pinned by TestStoreHandleReadsOnlyTheDirectoryItOpened and by the store_swap_test.go set, which swaps the store at the moment openStore returns and drives Lint, Bare and Ingest over it; fileBack refuses a symlinked store before reading its registry; a memory read by path outside store.go and the locked writer reappearing would show it wrong
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,14 @@ found_during: "autonomous-run-2026-09-01"
origin: researcher-authored
production_mode: hand-written
found_at: "internal/core/memory/schema.go"
resolution: "The memory renderers (RenderIndex, RenderContradictions, Ask's cited and empty renders) wrap cleaned names through termsafe.CodeSpan and clean every untrusted field with CleanProse. The lifeboat half is captured separately as iss-2609251355497247 for a lifeboat lane."
impact: fix
resolved_by:
commit: "bf6df37b"
---

Three markdown renderers re-escape or re-wrap a value termsafe already cleaned, which is the class the intent audit's renderEvidence just had fixed. termsafe's guarantees hold over the exact string CleanProse returned; a caller that adds its own delimiters is parsing a different string than the cleaner reasoned about. memory.RenderIndex and memory.RenderContradictions (internal/core/memory/schema.go, the two backtick-wrapped format strings) wrap a cleaned page filename in their own backticks, so a filename carrying a backtick shifts code-span parity in the committed .abcd/memory/index.md and contradictions.md and can move a sheltered angle bracket out of its span; memory.Ask's match render has the same shape over a value cleaned only by Sanitize; lifeboat's synthesis_review severity bracket and press-release subhead emphasis are the weaker form (neither delimiter affects span parity, and the artefact is not a committed record), and synthesis_review renders f.ID through Sanitize alone, never CleanProse, so it can still carry an HTML comment opener or link syntax; lifeboat's synthesis_principles writes a cleaned principle as a bare paragraph with no leading-marker escape. Out of scope for the code-span fix that found them (a different package, a different caller, and none is a defect that change introduced). What the fix DID close for all of them is the same-line embedding half: no cleaned field can carry an unpaired backtick run any more, so two cleaned values on one line cannot re-pair. The fix must establish that no renderer alters a cleaned value's bytes, and that every untrusted field on a committed markdown line goes through CleanProse rather than Sanitize alone.

## Grounds

- pursued: a page name carrying a balanced backtick pair keeps its sheltered content inside one code span in index.md, contradictions.md and the ask answer; raw HTML or link syntax from a page field appearing live in any of the three would show it wrong
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,14 @@ found_during: "autonomous-run-2026-09-01"
origin: researcher-authored
production_mode: hand-written
found_at: "internal/core/ahoy/remote_userinfo.go"
resolution: "Every userinfo colon test runs on the decoded userinfo: ahoy's scrub (and so the at-rest detector and its heal) and memory ingest's two refusal renderers; undecodable userinfo fails closed."
impact: fix
resolved_by:
commit: "4c34bb9d"
---

scrubRemoteUserinfo and the history.credential_at_rest detector decide that a userinfo carries a password by a literal colon, but git percent-decodes userinfo, so a remote such as ssh://user%3Apw@host/owner/repo.git under a non-http scheme is neither scrubbed at the derivation site nor detected at rest: the encoded password is stored verbatim in index.json and meta.json and the heal never fires. Reachability is thin (no credential helper is known to write this form) so this is a coverage hole in the new detector rather than a demonstrated leak; the fix is to percent-decode the userinfo before the colon test, for every scheme.

## Grounds

- pursued: ssh://user%3Apw@host is scrubbed and detected at rest while a bare or double-encoded login is kept; an encoded password surviving any of the three sites would show it wrong
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,14 @@ production_mode: hand-written
found_at: "internal/fsutil"
deferred_after: "v0.9.0"
deferral_reason: "Ruled by the product thinker at the 2026-09-23 run A interview (M22: sharded per-agent locks, each agent staging behind its own lock; not a timeout or backoff tune and not a lock-free append; a build lane owed, not holding the tag). Earlier deferral: The record states its own position plainly: the remedies are design-shaped and should be chosen rather than assumed. Raising the timeout, lowering the backoff ceiling, sharding the lock per agent, or moving to a lock-free append reconciled at drain are four different bargains between latency, contention and complexity, and the measurement that motivates them is a ceiling rather than a fault. Choosing among them is the maintainer's call and no reading of the evidence makes one of them obviously right."
resolution: "Each staged key (agent id, or session id for a main thread) stages behind its own lock under staging/locks/, per the M22 ruling; fsutil.WithFileLock revalidates the locked inode so a lock is retired with its staged file."
impact: fix
resolved_by:
commit: "fcc3df07"
---

The shared file lock's backoff cap limits staging to roughly ten writers a second, so a burst of simultaneous sub-agent completions loses transcripts. The lock helper backs off exponentially to a hundred-millisecond ceiling, so the rate a contended lock admits is set by that ceiling and not by how short the critical section is. Measured here, sixteen simultaneous stages take 2.12 seconds, consistently across three runs. Extrapolating the same rate, a burst past roughly forty to fifty simultaneous completions begins exceeding the five-second staging lock timeout, and a stage that times out is refused: the transcript it carried is not written anywhere, which is the loss the capture work exists to prevent. Sessions that fan out widely are exactly the sessions whose delegated reasoning is most worth keeping, so the ceiling bites hardest where the value is highest. The condition is pre-existing in the locking helper rather than introduced by sub-agent capture, but nothing reached the lock concurrently before, so it was unreachable in practice until now. The remedies are design-shaped and should be chosen rather than assumed: raise the timeout, lower the backoff ceiling, sharded locks keyed per agent, or a lock-free append with reconciliation at drain.

## Grounds

- pursued: a burst of 64 simultaneous distinct-agent stages all succeed and a held agent lock blocks only that agent; a stage refused with lock contention during a fan-out, or an empty lock file per sub-agent accumulating in staging/locks, would show it wrong
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,14 @@ origin: researcher-authored
production_mode: hand-written
found_at: "internal/core/memory/redact.go"
related_issues: ["iss-2608291444328326"]
resolution: "The page-filename bar (write refusal and MR001 name check alike) selects hard_fail findings that are not identity kinds, so it holds to secret patterns as ruled."
impact: fix
resolved_by:
commit: "534c4005"
---

The memory write boundary refuses a page filename carrying a hard-fail span, and the ruling that ordered it asked for a bar narrow enough that an ordinary slug is not refused: secrets only. The delivered bar selects on the scanner hard-fail severity, and the scanner puts three kinds there rather than one, a secret pattern, a banned real name, and the caller's own local machine account name. The function comment says so plainly, so the width is disclosed rather than hidden, but it is wider than the ruling. The consequence is a refusal the author cannot act on: on a machine whose account name is an ordinary word, an ordinary page whose slug carries that word at a hyphen boundary is refused at ingest with a message telling the author to repair the slug at the source, when what matched was the machine account rather than anything in the page. That collision is already recorded against another surface as iss-2608291444328326, where the same rule turned the release payload gate red on a pristine tree until the home directory was pointed at an alias. It matters because a memory write is the one path here with no workaround: the page cannot be written under the name the distiller chose, and the remedy the message names is not the remedy. Fix direction: hold the filename to the secret patterns alone, as the ruling asked, or keep the identity kinds and make the refusal name which kind matched so an author can tell a collision from a leak. Detector: with the caller account name set to an ordinary word, an ordinary page whose slug contains that word must still write, while a page name carrying a token body is still refused.

## Grounds

- pursued: with the machine account named garden, topic_home_garden-plan.md writes and is indexed, while a ghp_ token slug is still refused; an ordinary slug refused for local_username or real_name would show it wrong
Loading
Loading