Development record
The transcript corpus is a sibling store that creates itself, and the per-repo location is an opt-in pull
Context
ADR-29 settled that abcd captures its own session transcripts into a native local store: no external tool, keyed on the repo's root-commit SHA, never committed, redacted at write time through the two-stage sanitise-then-verify model of ADR-6, with an optional private companion as a separate opt-in path. Every one of those properties still holds and none of them is reopened here.
What ADR-29 did not settle is where on the machine the store sits, and the
location it inherited is what made the store fail silently. The corpus was laid
out at ~/.abcd/history/<root-sha>/transcripts/, inside ahoy's registry
namespace: index.json and the per-repo meta.json, written by abcd ahoy install. history.Capture required that directory to exist and deliberately
never created it, because ownedDirsReal treated an absent level and a
symlinked one alike and refused both. On a machine where install had not run,
hook session-end logged one line to stderr, exited 0 as a shutdown hook must,
and staged nothing. Session after session, with no marker anywhere: a store that
reads as wired while the corpus never accrues, which is the exact failure the
transcript work exists to prevent (iss-95).
Creating the directory where it stood would have closed the defect and left the
fault. ~/.abcd/history/ is another package's install-owned substrate, and a
capture hook that lays out a directory inside it is one package writing into
another's namespace. Brief invariant 15 already asks for the opposite: only the
history core package touches the store's path, held by a boundary test. The
location is therefore what has to move, and moving it is what makes the creation
clean.
Decision
The transcript corpus is a sibling of ahoy's registry, not a sub-tree of it.
It lives at ~/.abcd/transcripts/<root-sha>/{records,staging}/, and
~/.abcd/history/ keeps exactly what ahoy owns: the index and the per-repo
metadata. internal/core/history is the only package that lays out or judges
the corpus path, which is invariant 15 satisfied rather than promised.
The store creates itself, through one seam, and keeps the discipline it
replaces. history.Resolve(repoRoot, rootSHA) is the single door every read
and write path goes through, so the store cannot be reached by a route that
skipped the creation, the symlink check or the migration. ensureRealDir walks
the chain top-down, creates each level with os.Mkdir rather than MkdirAll
(which creates a whole chain without judging any of it), and re-verifies every
level as a real directory on every resolve, so a parent swapped between calls is
caught before the leaf is opened. What ownedDirsReal was protecting was never
absence: it was creating or writing through a planted symlink, and that refusal
is kept and made explicit. Creation needs no authority the caller does not
already hold, since every level is under their own home. Directories are 0o700:
records are redacted but still a verbatim account of the caller's sessions, and
staging holds them unredacted.
The key stays the repo's root-commit SHA, as a directory. One user-level
store still has to answer "this repo's transcripts", and the root commit is the
one name that survives what a checkout does: it moves, it is renamed, it is
cloned twice on one machine, and its root commit changes under none of that. The
key being a directory rather than a field means list, show, staged and
drain each read one repo's lane, and no cross-repo filter exists to get wrong.
The per-repo location is an opt-in pull the caller declares in their own
home. ~/.abcd/local-transcript-roots takes one absolute checkout path per
line with # comments, matched both as written and symlink-resolved, and is
honoured only while it is a regular file this uid owns that no one else can
write. A declared checkout keeps its store at
<repo>/.abcd/.work.local/transcripts/<root-sha>/, in the gitignored,
per-worktree local tier, so a pulled-in transcript is never a commit candidate
and never merge-conflicts between concurrent sessions. The declaration is
home-scoped on the ~/.abcd/path-entry and ~/.abcd/trusted-roots idiom, and
for the reason those give. A file inside the checkout would let a cloned repo
redirect the machine's transcripts into its own working tree, where its own
.gitignore decides whether they are committable, which is the tree asserting
where the machine's session record is kept. An environment variable clears the
letter of that bar and not its spirit, because a repo can ship the shell, direnv
or task-runner configuration that sets it, and the tree asserts it one
indirection out. A declaration that is present but not honoured says so on
stderr, since an ignored opt-in and one never written are otherwise the same
silence.
A corpus at the earlier location is moved, loudly. The first resolve moves
every record and staged file out of
~/.abcd/history/<root-sha>/{transcripts,staging}/ into the store, file by file
rather than by directory rename, which is idempotent under a concurrent peer and
correct across filesystems, as the per-repo opt-in can be. Nothing is deleted
except a source whose bytes are already at the destination; a non-regular entry
or a name collision with different content is left where it is and counted. The
move is loud both ways: Resolve returns notes the way rules.Resolve does and
every front door prints them on stderr, and a transcripts.moved tombstone at
the old path names the new one for whoever goes looking there. The tombstone is
withheld while anything is left behind, so the next resolve looks again.
Alternatives Considered
- Create the directory where it stood, under
~/.abcd/history/. The smallest diff, and it closes iss-95 on the machine that never installed. Rejected: it leaves a capture hook laying out a directory inside ahoy's install-owned namespace, which is the arrangement invariant 15 exists to forbid, and it buys the fix by conceding the boundary. - Read both locations, and write to the new one. No migration step and no window in which a record is in flight. Rejected: from the first capture onward there are two stores diverging, and every read path has to merge them forever, so the cost is permanent and grows.
- Refuse, and name the remedy. Honest, loud, and one command from working. Rejected: it reopens iss-95 from the other end, because a machine that had installed would stop capturing until someone ran the remedy. That is the silent-corpus failure wearing a louder hat, and a shutdown hook is exactly where nobody reads the hat.
- Chosen: a sibling store, self-creating through one seam, with a home-declared per-repo opt-in and a move-loudly migration. The corpus accrues on a machine that has done nothing but check the repo out, the symlink discipline survives intact, and one package owns the path.
Consequences
- Capture no longer has an install-shaped precondition. Its only
precondition is a home directory the caller can write, which they already
have, so a machine that has never run
abcd ahoy installaccrues a corpus from its first session. - The
history.transcripts_missinggap is retired, and the SessionStart notice built on it goes with it. A self-creating store makes "absent" the ordinary state of a repo not yet captured, so a required gap there would have every status board assert that transcripts will not be captured, which is false. The test that pinned the notice is inverted: a session starting on a machine with no~/.abcdis silent and leaves a store behind. ahoy installopens the store rather than laying it out. It callshistory.Resolveand pointsmeta.json's corpus block at what comes back, so that block carries the store's home-redacted path rather than a name relative to the<root-sha>/directory.- The read verbs take the repo root.
List,Read,StageandListStagedgain it, because the per-repo opt-in cannot be resolved without it;CaptureandDrainalready had it. - Turning the opt-in on does not move a corpus that already exists. The migration is from the legacy location into the resolved store, not between the two current lanes, so a repo declared after it has been capturing has records in the user-level store and new records in its own tree. This is the accepted cost of a declaration that is a location choice rather than a migration command.
- The store can now sit inside a repository tree, which the cold-reading
exclusion floor has to enforce rather than assume: its transcript-store row
states a
directoryexclusion on.abcd/.work.local/transcriptsinstead of asserting that the store is unreachable, per adr-56. - ADR-29 is superseded rather than corrected. Its native, redacted-on-write, root-SHA-keyed and never-committed properties are carried here unchanged; only the location it inherited does not survive. It is retained rather than pruned, because the brief and the adapter table still cite it.
Status note
Accepted by the maintainer's ruling that transcripts go into
~/.abcd/transcripts/ by default and that the per-repo location becomes an
opt-in pull. It is a new record rather than an edit to ADR-29 by the maintainer's
standing instruction that an ADR is never amended and always superseded, so
ADR-29's decision text stands as written and this record carries the change.
Frontmatter
| id | adr-2609090717039680 |
|---|---|
| type | adr |
| status | superseded |
| date | 2026-09-09 |
.abcd/development/decisions/adrs/2609090717039680-the-transcript-corpus-is-a-sibling-store.md
open on GitHub ↗ · commit history ↗
References4
Referenced by4
- adr-2609091014087993A tool never creates directories in user-owned project space; agent and session scratch is machine-scopedrelated
- adr-2609091248200336A tool never creates directories in user-owned project space; the store's location binds now and its verbs bind when the store shipsrelated
- adr-2609091248201071The transcript corpus is a sibling store that creates itself through the canonical directory primitiverelated
- adr-2609091248201071The transcript corpus is a sibling store that creates itself through the canonical directory primitivesuperseded by
mentions5
- adr-2610031751066232The transcript store is spelled under the renamed home, `~/.abcd.noindex/transcripts/<root-sha>/`, and the rest of its decision is unchanged
- dcn-26090900000000042026-09-09 — adr-2609090717039680 is superseded by adr-2609091248201071, one record for that original alone (maintainer, ruling on iss-2609091155525689 that each record describing the real-dir helper at its pre-consolidation home gets its own successor rather than one record covering both). The mechanism is unchanged and its home moved: `24c2f2e3` consolidated the three `ensureRealDir` copies into `fsutil.EnsureRealDir` / `EnsureRealDirAll`, so `internal/core/history` owns the store's layout and mode and calls the primitive for the create-then-prove step. Rejected: describing the mechanism and naming no symbol, which is the reading under which a fourth copy is a faithful implementation of the record.
- iss-95Wiring the Stop hook does NOT by itself start the transcript clock: history.Capture requires ~/.abcd/history/<root-sha>/transcripts/ to already exist and deliberately never creates it (ownedDirsReal validates the store's dirs are real, not symlinks). That dir is bootstrapped by 'abcd ahoy install'. On a machine where install has not run — including this one, where ~/.abcd/ does not exist at all — 'hook session-end' fails closed, logs to stderr, exits 0, and captures NOTHING. Silently. That is precisely the failure mode itd-89 exists to prevent: a hook that appears wired while the corpus never accrues. Decide: (a) the hook bootstraps the store itself (changes Capture's stated precondition, and a hook creating dirs is a trust-boundary act the ownedDirsReal discipline deliberately avoids), or (b) 'ahoy install' stays the sanctioned bootstrap and the not-installed case is made LOUD rather than a stderr line nobody reads (e.g. ahoy doctor already flags history.bootstrap_missing as a required gap). Until this is settled, itd-89's acceptance is met in code but not on any machine that has not installed.
- iss-2609091128479544internal/fsutil is the declared home for directory validation, and .abcd/development/principles/one-canonical-primitive.md names that primitive in its rule list: a second copy is a flagged consolidation target, a third is forbidden. There are three ensureRealDir implementations at this tip. internal/core/lifeboat/voyage.go:115 and internal/core/history/location.go:175 are near-identical (os.Mkdir at 0o700 followed by fsutil.IsRealDir, differing only in the error each returns), and the history one was added this cycle, so it is the copy that crossed the line the principle draws. internal/core/intent/lifecycle.go:824 is the divergent variant: an Lstat symlink check followed by os.MkdirAll at 0o755, whose own doc comment records that a symlinked ANCESTOR is not caught. The third copy is therefore not mere duplication but the weakening the principle predicts: the intent variant's guarantee is strictly weaker than the other two, the difference is invisible at its five call sites, and a hardening landed in either of the other homes reaches none of them. The principle's Promotion clause asks for a check over private redefinitions of the canonical names, and internal/fsutil/canonical_test.go's TestNoNonCanonicalAtomicWritePrimitives is exactly that check for writeFileAtomic, durableWrite, isRealDir and createExclusiveIn; ensureRealDir is simply absent from its name list, which is how three copies accumulated under an armed detector. Two accepted decision records now bless the divergence rather than flag it: adr-2609090717039680:56-60 presents the local helper as a virtue and adr-2609091014087993:71-73 commits a future worktree store to a fourth copy. Fix direction: one primitive in internal/fsutil that takes the create mode as a parameter, because 0o700 for a private store under the caller's home and 0o755 for a record directory in a shared worktree are both legitimate and must not be silently unified, plus a chain form that verifies every level it creates so the intent variant's documented ancestor gap closes rather than being carried forward; and ensureRealDir joins the canonical-primitive detector's name list so a fourth copy cannot land quietly.
- iss-2609091155525689Two accepted decision records describe the directory primitive as it stood before the consolidation, and this repository supersedes rather than amends, so each owes a correction rather than an edit. adr-2609090717039680 (the transcript corpus is a sibling store that creates itself) names ensureRealDir as a helper of internal/core/history and presents its behaviour — walks the chain top-down, os.Mkdir per level rather than MkdirAll, re-verifies every level on every resolve — as a property of that package. Every one of those behaviours still holds, and the symbol does not: it is fsutil.EnsureRealDir, called in a loop, and the package that used to own the sequence now owns only the layout. A reader following the ADR to the code finds nothing at the name it gives. adr-2609091014087993 (a tool never creates directories in user-owned project space) commits a future machine-scoped worktree store to creating itself one level at a time, never through a symlink, exactly as the transcript store does. That sentence is now satisfied by calling the canonical primitive, and it was the reading of it that would have produced a fourth copy: it describes a mechanism where it could name the thing that implements it. What is owed is one correction record covering both — the mechanism is unchanged, the home moved, and a store that wants this behaviour calls internal/fsutil rather than reproducing it. Detector: an ADR that names a package-local symbol should not survive that symbol's removal, which no gate currently checks; the record-lint rule family has no cross-reference from decision prose to Go identifiers.