Development record
hook-binary-to-persistent-data-dir
Summary
spc-35 delivers itd-132's maintainer-ruled shape: the harness's persistent
per-plugin data directory (${CLAUDE_PLUGIN_DATA}) becomes a download
cache for the checksum-verified release artefact, plugin roots are
provisioned from it by verified copy, and the PATH entry becomes an
abcd-owned regular file instead of a symlink into a harness-owned
directory. Execution stays per-root — the relocate-execution alternative was
rejected in adversarial review for creating a shared mutable binary across
roots. Supersedes spc-21's "update heals by re-fetch" criterion (replacement
contract: an update never re-fetches unless the released binary changed);
every other spc-21 invariant — same-origin checksums, HTTPS pin, -q-first
curl, proxy/CA unset, no environment seam for origins, refuse/notice
message discipline — carries over verbatim.
Design
1. Cache layout (in ${CLAUDE_PLUGIN_DATA})
${CLAUDE_PLUGIN_DATA}/
cache/abcd-<os>-<arch> the verified release artefact (0755)
cache/binary-meta release_tag / release_sha / binary_sha256 /
fetched_at — the spc-21 format minus plugin_sha
path-entry provenance of the abcd-owned PATH copy:
installed path + binary_sha256
.bootstrap.lock relocated concurrency lock (mkdir-atomic)
.bootstrap.tmp.$$ relocated temp dirs (same-filesystem rename)
plugin_sha is dropped from the meta: with one cache serving many roots, a
recorded provisioning-time root is meaningless — the version-skew notice is
computed against the live CLAUDE_PLUGIN_ROOT basename at render time
(40-hex gate unchanged).
2. Provisioning flow (bootstrap.sh rework)
Steady state first, as in spc-21: a regular executable at
$CLAUDE_PLUGIN_ROOT/abcd exits in one file test, no network (AC 3).
On a missing root binary (fresh install or fresh post-update root):
- If
CLAUDE_PLUGIN_DATAis unset or unusable: degrade loudly to the current spc-21 per-root fetch — the loud-staging principle; never a silent fallback (AC 7). Deriving the documented path shape fromCLAUDE_PLUGIN_ROOTis deliberately not attempted: the derivation is documented but not endorsed, and a wrong guess plants a trusted artefact in an untracked location. Revised 2026-09-15 (GHSA-4q78-ccfv-f374, adr-2609151706587280): the data dir is taken from the harness (or, from a terminal, the plugin root's.data-dirstamp) and never derived — but being taken is a route, not trust. The bootstrap, once it has established manifest trust for the cache, writes~/.abcd/cache-attestationnaming the data dir, the manifest-authenticatedbinary_sha256and the trust; the cache → PATH promotion in Design 3 accepts a data dir only when that record names it and the hash itscache/binary-metacarries. An offline run never writes or rewrites the attestation. - Take the relocated lock in the data dir (per-root locks cannot serialise
two roots writing one cache). The per-root
.bootstrap.attemptthrottle in the hook commands stays as-is — it only rate-limits invocation. - Refresh detector (maintainer-ruled, local state + one best-effort
resolve): re-resolve the latest release tag exactly as spc-21 step 4.
- Resolve answers a tag equal to
cache/binary-meta'srelease_tag: the cache is a candidate, but its co-located record does not by itself establish trust (adr-46 decision 3 — a same-UID attacker writes both the artefact and the record that vouches for it). Authenticate the cache against the published manifest: fetchchecksums.txtfor the resolved release (same-origin, HTTPS-pinned,-q-first — the exact spc-21 step 5 manifest fetch), and check the cachedbinary_sha256against the published digest forabcd-<os>-<arch>. On match, provision from cache — re-hashcache/abcd-<os>-<arch>(catching corruption in the file itself), copy into a data-dir temp dir, rename-install into the plugin root (AC 1, AC 5). On mismatch, the cache is tampered or stale: discard it and fall to the different-tag download path. If the manifest fetch fails but the tag resolved, treat as offline (below). - Resolve fails (offline): no published manifest is reachable, so the
cache is trusted at corruption-evidence only — re-hash the artefact
against its recorded
binary_sha256and provision on match; a mismatch refuses loudly and installs nothing. This is the deliberate availability trade (adr-46 decision 3), and the success notice says so: provisioned from an unauthenticated cache while offline, not "verified against the published manifest". - Resolve answers a different tag (or the equal-tag manifest check
rejected the cache): download + verify into the cache under the unchanged
spc-21 steps 5–6, update
cache/binary-metaby rename, then provision the root from the fresh cache (AC 2). - No cache and resolve fails: refuse with the spc-21 no-network message.
The accepted gap: a release cut with no plugin update never triggers —
the skew notice surfaces it and
abcd update(itd-130) is the explicit refresh path.
- Resolve answers a tag equal to
- The hook manifest (
hooks/hooks.json) keeps invoking$r/abcd; only bootstrap.sh learns the cache. SessionEnd never reaches this path at all (iss-2608210934566223, shipped separately).
3. PATH entry: owned regular file (ahoy rework)
installPinnedSymlinkbecomesinstallOwnedCopy: re-verify the cache artefact againstbinary_sha256, copy to the target (default~/.local/bin/abcd) as a regular file 0755, record path + hash in the provenance record (AC 4, AC 5). Revised 2026-09-15: the re-verify is bound first — the data dir must be the one~/.abcd/cache-attestationnames and its recorded hash the attested one, or the cache is refused loudly and install degrades to the pinned symlink as with no cache (Design 2 step 1's revision; adr-2609151706587280 decision 6).- Ownership is recorded provenance, never content-guessing: a regular
file at the target matching the record's hash is owned (idempotent /
refreshable); anything else classifies foreign and is refused exactly as
today. A legacy abcd-owned symlink (target inside a plugin root or
cache dir — today's
classifyBinTargetgeometry) is a heal-able gap: replaced by the owned copy onahoy install(AC 4). - The provenance record is reachable without the harness environment.
ahoy install,ahoy uninstall, andabcd updaterun from a terminal, whereCLAUDE_PLUGIN_DATAis not exported — so the record cannot live in the data dir behind that variable, or ownership is unprovable exactly where those verbs run: uninstall would leave the binary,ahoywould report abcd's own binary as foreign, andabcd updatewould desync the record it just wrote. The record lives in a home-scoped, abcd-owned location derivable from the installed copy without any harness variable (the ahoy user-scope store, alongside the history store, is the natural home); the data dir stays the cache's home only. Any read of the record that finds the recorded path absent still ownership-checks before acting — the absent-path branch never short-circuits to "owned" (closes iss-2608210934566228's sibling comment overclaim). - The record carries the plugin root, so the CLI can resolve it without the
symlink. The old symlink doubled as
resolvePluginRoot's route home (its target sat inside the plugin root); a regular-file copy severs that, leaving every plugin-root verb a no-op from a terminal (iss-2608210934566230).installOwnedEntryrecordsplugin_root=besidepath=/binary_sha256=, andresolvePluginRootgains it as a candidate after the env-var and executable-ancestor routes. Because a plugin root is a commit-stamped cache dir the harness replaces on update, the hook-side bootstrap — which runs on the fresh root withCLAUDE_PLUGIN_ROOTset — rewrites the recordedplugin_root=each time it provisions, so the terminal read tracks the latest root within one hook firing of any update.store_symlink_test.gois extended with the copy layout so the iss-170 contract is pinned by behaviour, not by the symlink mechanism. - Refresh: when bootstrap installs a new release into the cache and the record
exists, it refreshes the PATH copy in the same run (verified copy + rename);
ahoy installheals it on demand.ahoy uninstallremoves the owned copy and the record; the cache itself is left to the harness's uninstall-from-all-scopes deletion (open question 2 resolved: abcd removes what abcd owns, the harness removes what the harness owns). - The
--devshim is untouched: it builds from the source checkout and never writes into the cache or over a verified artefact.
4. Migration (one-way, idempotent)
On the first bootstrap run with an empty cache and a verified binary already
in the plugin root: verify it against the root's existing .binary-meta
binary_sha256; on match, seed the cache (copy + meta rewrite minus
plugin_sha); on mismatch or missing meta, ignore it and fetch fresh (AC
5). Root-local .binary-meta files stop being written; the skew notice
reads the cache meta.
The empty-cache test must use the same non-regular-file refusal the main
install path uses: [ -e "$cache_binary" ] && [ ! -f "$cache_binary" ] →
refuse, matching the plugin-root guard already at the main install site. The
[ ! -f "$cache_binary" ] || exit 0 fast-path form is a directory-shaped
hazard (iss-2608210934566229): a directory planted at $cache_binary is not a
regular file, so that test passes, mv -f moves the seed into the directory,
and a lying binary-meta vouches for a directory — every later root then
downloads and refuses, running UNGUARDED across updates. The same mv-onto-
directory guard the binary install uses (if [ -e … ] && [ ! -f … ]; then refuse) applies to the cache path.
Acceptance-criteria mapping
| itd-132 AC | Delivered by |
|---|---|
| Update → verified copy from cache, no fetch | Design 2 step 3 (equal-tag / offline branch) |
| New release + update → fetch-verify into cache | Design 2 step 3 (different-tag branch) |
| Steady state: one file test, no network | Design 2 fast path (spc-21 step 1 unchanged) |
| PATH regular file survives updates & GC; legacy symlink healed | Design 3 |
| Every promotion re-verifies binary_sha256; loud refusal | Design 2 step 3, Design 3, Design 4 |
| Skew notice vs live plugin root | Design 1 (plugin_sha dropped; render-time comparison) |
| No data dir → loud per-root fallback | Design 2 step 1 |
Testing
Each watched fail first, per the spc-21 script-test pattern (fixture server,
temp CLAUDE_PLUGIN_ROOT and temp CLAUDE_PLUGIN_DATA):
- cache hit, resolve offline → root provisioned by copy, zero artefact downloads (fixture server records no asset request); success notice names the offline/unauthenticated trust.
- cache hit, resolve online → the published
checksums.txtis fetched and the cached hash checked against it before the cache is trusted (fixture server records the manifest request, not an asset request); success notice names the manifest-verified trust. - cache tamper (the case the old test missed): artefact AND
binary-metarewritten together to a self-consistent poisoned pair, resolve online → the published-manifest check rejects the cache and the different-tag download path replaces it; nothing poisoned is ever installed. Watched to fail against the co-located-record-only design. - second plugin root appears (update simulation) → provisioned from cache.
- resolve answers a new tag → one download, cache meta updated, root provisioned from the new artefact.
- corrupted cache artefact (bytes only, meta intact) → loud refusal, nothing installed anywhere.
CLAUDE_PLUGIN_DATAunset → per-root fetch with the loud degradation notice.- migration: verified root binary seeds the cache once; second run no-ops;
hash-mismatched root binary is ignored and fetched fresh; a directory
planted at
$cache_binaryis refused, not seeded into. - ahoy: owned-copy install; original plugin root deleted → PATH entry still executes; legacy owned symlink healed to a copy; foreign regular file refused; uninstall removes copy + record, leaves the cache.
- terminal environment (the regression the harness hid): install the owned
copy with
CLAUDE_PLUGIN_DATAset, then CLEAR it and every harness var, and point the executable at the copy →Detectstill reportspinnedand resolves the plugin root from the record,abcd ahoydoes not report abcd's own binary as foreign, andUninstallstill removes the copy.store_symlink_test.goextended with the copy layout. - skew notice: rendered against a live root differing from
release_sha; silent whenrelease_shaunknown (unchanged).
Documentation surfaces
The PATH entry is now an owned regular file, not a symlink; the plugin
markdown surface and the CLI reference must say so. Update
commands/ahoy.md ("pinned … symlink", "switches back to the pinned
symlink", "Remove the marker block and owned PATH symlink") and
docs/reference/cli/commands.md to describe the owned copy and its
provenance record.
Filed alongside (SPLIT routing)
- ADR + brief invariant from iss-2608210934566226 (adr-46, invariant 12): persistence must not weaken the spc-21 verification posture at fetch time or at rest — every promotion re-verifies, the cache is authenticated against the published manifest when online, and SessionEnd performs no network work. Invariant 12's SessionEnd clause landed with iss-2608210934566223 and is asserted in the register.
- Principle from iss-2608210934566227: store durable state where the platform documents it survives.
- CHANGELOG entry at ship; iss-2608210934566221/222 resolved by the shipping change.
Review-driven revisions (2026-08-21)
Design 2 step 3 (cache authentication), Design 3 (terminal-reachable
provenance + recorded plugin root), and Design 4 (migration directory guard)
were revised after the two adversarial reviews of the first implementation;
the surviving findings are iss-2608210934566228 (cache re-verify was
corruption-only), iss-2608210934566229 (migration mv-onto-directory), and
iss-2608210934566230 (owned copy broke terminal root resolution). adr-46 is
amended to match.
Revision (2026-09-15)
GHSA-4q78-ccfv-f374 (iss-2609012039102770): the Design 3 promotion re-verified the cache only against its co-located record, which whoever chose the data dir through the environment also wrote. Option B was ruled: the bootstrap writes a home-scoped attestation after manifest authentication, and the promotion is bound to it (Design 2 step 1 and Design 3 above, each marked). adr-46 is superseded by adr-2609151706587280, which carries its decisions forward and adds the binding as decision 6.
Frontmatter
| id | spc-35 |
|---|---|
| type | spec |
| lifecycle | closed |
.abcd/development/specs/closed/spc-35-hook-binary-to-persistent-data-dir.md
open on GitHub ↗ · commit history ↗
References1
- itd-132The hook binary survives plugin updatesimplements
mentions26
- adr-46Persisting the hook binary never weakens the verification posture — every promotion re-verifies, and SessionEnd performs no network work
- adr-2609151706587280The cache reaches PATH only through a home-scoped attestation the environment cannot write
- dcn-26071200000000052026-07-12 — autonomous-run surface named /abcd:run, taking itd-29's reserved
- dcn-26082100000000042026-08-21 — Bug-hunt round 6, branch bughunt-b/round-6. Baseline (make preflight
- dcn-26091500000000102026-09-15 — GHSA-4q78-ccfv-f374 (iss-2609012039102770) is closed by OPTION B, ruled by the maintainer at an interactive question: bind the cache to a record the environment does not choose alone. The owned PATH-copy promotion re-verified the cache only against the `binary-meta` beside it, and `CLAUDE_PLUGIN_DATA` is taken from the environment as given, so whoever chose the directory wrote both the bytes and the record that "verified" them — reproduced at v0.7.0 as a one-byte file installed 0755 as `~/.local/bin/abcd` with provenance recorded. Now the bootstrap, the one process holding the harness's real data dir that has just established manifest trust for the cache (an authenticated cache hit, or a fresh download verified against the same-origin manifest), writes `~/.abcd/cache-attestation` — `data_dir`, the manifest-authenticated `binary_sha256`, `cache_trust=manifest`, `attested_at`; 0600, temp-and-rename, beside `path-entry` — and `ahoy install` promotes a cache only when that record names the directory, the co-located record carries the attested hash, and the artefact hashes to it, whichever route (environment or the root's `.data-dir` stamp) named the directory; detection offers the heal on the same predicate. An offline run neither writes nor rewrites the attestation, so a cache provisioned offline waits for a networked session before it reaches PATH, said out loud. Why B: the trust floor moves from a value the environment supplies to a write into the caller's own home, which adr-46 decision 4 already treats as the ownership root, so the attestation grants nothing that authority did not hold and costs `ahoy install` no network (adr-38 stands). Rejected: A (a manifest GET on a disk-only verb, re-fetching what the session already proved); C (a documented residual — weaker than it reads, since a harness honouring a committed settings file's environment block lets a hostile checkout set the variable, and the owned-copy claim is what the hook shims trust); the mechanical partial of cross-checking against the plugin-root binary (breaks dogfood installs whose root binary is a local build); and a terminal rung through the attestation alone (it would heal a dogfood checkout's stable symlink into a release copy). adr-46 is superseded by adr-2609151706587280 (never amended, always superseded; retained because its numbered decisions are cited), spc-35 Design 2 step 1 and Design 3 are revised in place and dated, and brief invariant 12 gains the clause.
- durable-state-lives-where-the-platform-says-it-survivesDurable state lives where the platform says it survives
- iss-170resolvePluginRoot's ancestor-walk fallback defeats the install layout ahoy itself creates: os.Executable() (internal/core/ahoy/store.go:92) is used without filepath.EvalSymlinks, so when abcd is invoked through the pinned PATH symlink the walk scans the symlink's ancestors (~/.local/bin, ~, /) and never finds hooks/ — a permanent phantom 'plugin root not resolvable' gap on exactly the layout ahoy install writes. Neither env var is set in a normal shell (CLAUDE_PLUGIN_ROOT exists only inside harness hook invocations), so the fallback is the path that matters. Fix is EvalSymlinks before the walk, plus a test invoking through a symlink watched failing first.
- iss-2608210934566221Relocate the hook binary and .binary-meta from the plugin cache dir to the harness persistent data dir (CLAUDE_PLUGIN_DATA, ~/.claude/plugins/data/abcd-abcd-marketplace/). The harness re-clones every update into a fresh commit-stamped cache dir and its docs state extra files there are NOT preserved across updates, so the ~11MB binary is re-fetched on every plugin update by design. The data dir survives updates and is deleted only on full uninstall. Fixes in one move: the per-update re-download, the SessionEnd bootstrap-cancellation window, per-version binary disk copies (orphaned cache dirs are GC'd ~14 days after .orphaned_at), and the PATH-symlink rot captured separately. Reworks the itd-105/spc-21 bootstrap fast path and every pluginBinaryPath consumer.
- iss-2608210934566223The SessionEnd hook attempts the blocking bootstrap download before falling back to a PATH binary. After a plugin update lands a fresh binary-less cache dir, an update-then-quit exits through SessionEnd, which starts the ~11MB download, gets cancelled by the harness during shutdown ('Hook cancelled'), and silently drops that session's transcript capture — observed 2026-08-21, session 8db3dbd6 lost. Exit is the one hook with no time budget: reorder to plugin-root binary, then PATH binary, then loud give-up, and never bootstrap at SessionEnd. The persistent-data-dir relocation shrinks the exposure to true first-install-then-quit, but the ordering fix stands on its own.
- iss-2608210934566226ADR to draft at itd-132 planning (SPLIT-routed trust rule): binary persistence must not weaken the spc-21 verification posture. Whatever shape itd-132 takes (relocate-execution or download-cache+copy), the fetch-time invariants carry over verbatim — same-origin checksums.txt, HTTPS pin with -q-first curl, proxy/CA env unset, no environment seam for origins — AND the at-rest window is addressed: under the cache model a tampered binary survived at most one update cycle; a persistent location extends that window unboundedly unless the recorded binary_sha256 is re-verified at adoption/refresh points. Companion brief invariant: SessionEnd performs no network work. Route: ADR + brief invariant, filed with the itd-132 spec.
- iss-2608210934566227Principle to draft at itd-132 planning (SPLIT-routed stance): store durable state where the platform documents it survives — never fight the host lifecycle inside a directory the host re-clones or garbage-collects. Surfaced by the 2026-08-21 plugin-update post-mortem (binary in the re-cloned cache dir). Candidate home: .abcd/development/principles/. File alongside the itd-132 spec.
- iss-2608210934566228spc-35 BLOCK (security review): the cache promotion 're-verify against recorded binary_sha256' is a corruption check, not a tamper check. The cached artefact and its binary-meta (holding the expected hash) both live in ${CLAUDE_PLUGIN_DATA}/cache, equally same-UID-writable. An attacker writing BOTH — payload plus a meta recording its hash and the current release_tag — passes the promotion gate and gets an unverified binary installed at the guard path and onto PATH, with the success notice asserting 'checksum-verified'. Demonstrated end-to-end vs live v0.6.1. This is exactly adr-46's central claim: on main a poisoned plugin-root binary healed at the next update (harness re-clones a fresh root -> bootstrap re-downloads+verifies from GitHub); here the poisoned cache is preferred over the network indefinitely and survives every update. adr-46, invariant 12, and the notice assert a protection the code does not provide. Fix: in cache mode (which already resolves the latest tag over the network) GET that release's checksums.txt and verify cached_sha against the PUBLISHED digest before use_cache=yes — restores tamper-evidence online, offline stays corruption-only (document honestly). Plus the missing test: TestBootstrapCorruptCacheRefusesLoudly rewrites only the artefact, never the meta, so it never exercises the case that succeeds.
- iss-2608210934566229spc-35 BLOCK (security review): the migration seed re-opens the mv-onto-directory hazard the main path already refuses. The seed fast-path test is '[ ! -f "$cache_binary" ] || exit 0', which is TRUE for a directory at that path. So 'mkdir -p .../cache/abcd-<os>-<arch>' (one same-UID command) makes the seed 'mv -f' the file INTO the directory (succeeds) and write a lying binary-meta -> every fresh plugin root then downloads ~11MB and hits refuse, so shell commands run UNGUARDED, every session, until a human removes the directory by hand — and this survives every plugin update. Demonstrated (exit 0, no message). Fix: the migration path needs the '[ -e "$cache_binary" ] && [ ! -f "$cache_binary" ] -> refuse' form already used at the main install site (bootstrap.sh:409).
- iss-2608210934566230spc-35 FIX-FIRST (ruthless review): replacing the PATH symlink with an owned regular-file copy severs the executable-ancestor route back to the plugin root, so every plugin-root-dependent verb no-ops when abcd is invoked BY NAME from a plain terminal — which is where bootstrap.sh itself tells the user to run 'ahoy install', and where 'abcd update' runs. Root cause: CLAUDE_PLUGIN_DATA is exported only to hook processes, not terminals (itd-132 records this). Two failures: (1) resolvePluginRoot() walks the copy's ancestors (~/.local/bin -> ~ -> /Users), none has hooks/, returns ('',false) -> Detect emits plugin.root_missing, Install/version/staleness no-op (store.go:96-108; store_symlink_test.go pins the OLD symlink mechanism and stays green only because it builds the symlink by hand, not via ahoy install). (2) path-entry lives in the data dir resolved solely from CLAUDE_PLUGIN_DATA (data_dir.go:12), so from a terminal isOwnedCopyFile is false -> uninstall leaves the binary ('not a symlink; left untouched'), abcd reports its OWN binary as symlink.foreign, and abcd update's RefreshPathEntryDigest no-ops so path-entry desyncs permanently. Contradicts AC 4 and the CHANGELOG. Fix (ruthless): record plugin_root= in the provenance and add it as a resolvePluginRoot candidate; write the provenance to a home-scoped abcd-owned location derivable from the installed copy, consulted when the var is absent; extend store_symlink_test.go with the copy layout; update commands/ahoy.md + docs/reference/cli/commands.md (still say 'symlink'). Note the recorded plugin_root is itself a GC'd cache dir, so the hook-side bootstrap must rewrite it on each provision.
- iss-2608211849580624README provisioning section describes download-on-every-update, but spc-35 shipped a persistent CLAUDE_PLUGIN_DATA cache that provisions by verified copy; the .binary-meta deletion remedy is also a no-op on cache-provisioned roots
- iss-2608261133091022invariant 12 and spc-35 still withhold the SessionEnd no-network clause pending iss-2608210934566223, which resolved with a pinning test, so the register under-asserts a property the tree holds
- iss-2609012039102770GHSA-4q78-ccfv-f374 (CWE-345, advisory severity low): the owned PATH-copy verification is self-referential under an env-supplied CLAUDE_PLUGIN_DATA. `internal/core/ahoy/data_dir.go:pluginDataDir` is `os.Getenv("CLAUDE_PLUGIN_DATA")` and nothing else; `owned_copy.go:cacheRecordedSHA` reads `binary_sha256` from a `binary-meta` beside the artefact under that same directory; `apply.go:installOwnedEntry` hashes the artefact, compares it to that record, writes the bytes 0755 as the owned PATH entry and records provenance in `~/.abcd/path-entry`. The bootstrap's manifest authentication (`hooks/bootstrap.sh`, cache_trust=manifest, iss-2608210934566228) covers only the cache-to-plugin-root promotion; the cache-to-PATH promotion re-verifies against the co-located record only, which an attacker who writes both satisfies trivially — adr-46 decision 3 states exactly this principle for the bootstrap. Reproduced at v0.7.0: with CLAUDE_PLUGIN_DATA and ABCD_PLUGIN_ROOT pointed at attacker-chosen directories, `ahoy install --yes --adopt` installed a one-byte file as `~/.local/bin/abcd`, recorded its hash and the fake plugin root in path-entry, and `classifyBinTarget` then vouched for it as an owned copy.
- iss-2609012039116200Sub-finding of GHSA-4q78-ccfv-f374 that does not need the design decision: `ahoy install` uses any CLAUDE_PLUGIN_DATA value as the verified cache, including one that is relative, lies inside the repository being installed, or is world-writable. `internal/core/ahoy/data_dir.go:pluginDataDir` returns the env value unexamined and `owned_copy.go:ownedCopySourceReady` plus `apply.go:installOwnedEntry` copy from it. The harness's persistent per-plugin data directory (spc-35) is always absolute, never inside a project checkout, and never world-writable, so refusing those shapes is a strict hardening of the current contract: a relative value resolves against the checkout the verb runs in, an in-checkout value blesses committed bytes as the owned PATH binary, and a world-writable cache lets any local user swap both artefact and record. The fix must establish that such a data dir is ignored with a note naming why, that install then degrades to the existing loud pinned-symlink path exactly as when no cache exists, and that a harness-shaped data dir still installs the owned copy; the display-only readers of the same variable (`vintage.go:readPinnedTag`, the skew notice) make no trust decision and are unchanged.
- iss-2609012111150528itd-130's Decisions section records that 'the persistent-data-dir alternative is rejected, not deferred' (grilled 2026-08-20), and one day later itd-132/spc-35 adopted exactly that shape as the download cache with a copied PATH file, superseding spc-21's re-fetch criterion. itd-130's text was never revised, so the shipped record now carries a decision its successor reversed without a typed link between them. Surfaced by the itd-130 fidelity audit (receipt rcp-264f7b144576) as a diverged item. The fix is a record change, not code: itd-132 should carry a typed 'reverses' link to the itd-130 decision, or itd-130's Decisions should note the supersession with the date, and the reversal is a human's to confirm.
- iss-2609012111160075itd-132 ac-5 promises that every promotion of a cached artefact re-verifies against its recorded binary_sha256 and 'refuses loudly' on a mismatch. Two branches discard silently instead: the migration-seed mismatch is ignored by spec design (spc-35 line 145) with no notice, and the bootstrap's PATH-refresh branch skips a copy whose hash no longer matches the recorded provenance with no notice and no test covering that branch. Both are refusals in effect, neither is loud, and a user whose PATH copy quietly stopped being refreshed has no signal until abcd update or ahoy tells them it is foreign. Surfaced by the itd-132 fidelity audit (receipt rcp-acde3e9ce729). Loud-staging says a stage that no-ops must say so.
- iss-2609012111168716pluginDataDir reads CLAUDE_PLUGIN_DATA from the environment, which the harness exports only to hook processes. The bootstrap's success notice and docs/how-to/install.md direct the user to run '<plugin-root>/abcd ahoy install' from their terminal, where that variable is unset, so ahoy install finds no cache and degrades (loudly) to the spc-21 pinned symlink into the plugin root, the exact shape spc-35 replaced because it dangles after the next plugin update. Every install that follows the documented instruction therefore lands the legacy shape. Surfaced by the itd-132 fidelity audit (receipt rcp-acde3e9ce729) against ac-4. Directions, none adopted: ahoy install resolves the data dir from recorded provisioning metadata (the bootstrap knows the path and could stamp it into .binary-meta or path-entry) rather than the environment; or the notice tells the user to run it via the hook path; or the docs state the degradation.
- iss-2609290448510918The predecessor-store qualifier sweep reaches past the six intents iss-2609261536147903 named: outside drafts/ and those six, 192 citation sites across about forty intents name a spc-N at or below spc-70 that is not the citing intent's own spec, with no '(predecessor store)' on the line. Some are live cross-references (the cold-reading family's intents cite each other's live specs), and some are the predecessor store's (itd-4, itd-6, itd-29, itd-47 and itd-49 describe pre-rebuild work in its terms), so each site needs the same reading against the live spec it collides with; the specs charter's Two spc-N Namespaces section is the rule. Found while sweeping the pattern of iss-2609261536147903 in lane drainDrift3; a census script over intents/{planned,shipped,disciplines} reproduces the count.
- itd-29Domain Experts Run A Spec Autonomously And Recover From Anything Without Touching Git
- itd-130`abcd update` completes a chosen update in one verb
- itd-147The brief's surface chapters are a generated reflection of the shipped surface, so a shape claim cannot drift
- spc-21a-fresh-plugin-install-just-works-the-hook-binary-survives