Development record
name-banlist
Summary
Two-layer banned-names enforcement, generalised from the hand-wired prototype
already live on abcd-cli, delivered as a capability every abcd-managed repo
inherits through ahoy. The public layer is a banned-token family in the
deterministic docs-lint (committed config, CI-enforced, per-line escape). The
private layer is a gitignored per-machine banlist read by a committed
pre-commit guard, so a private string never appears in tracked content — not
even in the config that bans it. Private entries include machine identifiers
(hostnames, device names, IP/CIDR prefixes) as first-class citizens.
Scope
In: the two banlist stores and their formats; generalising the existing
harness/* docs-lint banned-token family into a banned-names family; the
committed guard hook and its refuse-by-key behaviour; ahoy scaffolding of
all three surfaces (lint config entry, hook, gitignored stub); banlist
maintenance verbs (add / list / remove) on both layers; honest reporting of
the private layer's local-only reach.
Out: richer dedicated reporting for the public rule (future work per the planning interview); CI enforcement of the private layer (impossible by construction — the point); generic network-identifier pattern detection (iss-154/157, a separate detector); the from-scratch name scan gating a public flip.
Approach
- Two files, per the planning decision. The public banlist lives in the
committed docs-lint config (extending the existing banned-token family
shape: pattern, severity, allow-context escape). The private banlist is a
gitignored file scaffolded with instructions and reserved-value examples
only (
examples-use-reserved-identifiers: RFC 5737/3849/2606/7042 values, persona-derived hostnames). Entry format is key + pattern; the key is the only thing tooling ever prints. - One canonical primitive. The public family generalises the existing
harness/*family in place — same lint engine, same escape hatch — rather than adding a second banned-token mechanism. The guard hook generalises the existing.githooks/pre-commitprototype. - Refuse by key. The guard greps staged content against private patterns; on match it exits non-zero naming the entry key only. The matched string and pattern never reach stdout/stderr/logs. Absent banlist ⇒ loud warning, exit zero (the layer protects machines that opted in; silence must never impersonate protection).
ahoywiring. Scaffolding writes: the public family into the repo's docs-lint config, the committed hook, the gitignore entry, and the stub. A repo becomes name-safe by being abcd-managed.- Verbs.
abcd-level add/list/remove operate on both layers; visibility follows the layer (public entries render fully, private by key).
Acceptance-criteria mapping
- Public-gate criterion → the generalised banned-names docs-lint family (blocker severity, file+line findings, allow escape).
- Refuse-by-key criterion → the guard's output contract; tested with a fixture banlist asserting the pattern value is absent from all output.
- Machine-identifier criterion → entry format treats hostname/IP/CIDR patterns identically to name patterns; fixtures use reserved values.
- Absent-list criterion → guard's no-file branch: warn loudly, never block, never silent-pass.
- Scaffold criterion →
ahoywrites all four artefacts; scaffold test asserts stub content contains only reserved-range values. - Verb-visibility criterion → list rendering redacts private entries to keys; asserted in surface tests.
- Honest-reach criterion → status/report text states CI cannot enforce the private layer; asserted against the rendered surface.
Evidence and lineage
Prototype: abcd-cli's own docs-lint harness/* family, .githooks/pre-commit
guard, and gitignored banlist. Motivating incident: the 2026-07-29
managed-repo NEXT.md privacy-leak investigation (iss-154..iss-158); scope
extension to machine identifiers recorded in iss-158. Related principle:
examples-use-reserved-identifiers (seeding). Related discipline path:
itd-79 (registry + lint promotion pattern).
Acceptance-criteria satisfaction
AC as numbered in itd-74 → covering evidence.
- Public gate — the generalised
banned_tokensfamily in.abcd/docs-lint.jsonwith the per-line allow escape; verb-written entries under thenames/prefix (internal/core/banlist/public_test.go), enforced byabcd docs lint. The layer's reach is bounded by what git tracks, and a config git ignores is now reported as unenforceable rather than claimed (banlist.public_family_ignored; the placement question is iss-176). - Refuse by key — the committed guard's output contract, asserted against a
fixture store in
internal/core/banlist/hook_test.go: the refusal names the key, and the pattern and matched text appear nowhere in any output. - Machine identifiers — hostname, IP, CIDR and MAC entries are ordinary
entries with no special case, on both readers; the shared corpora under
testdata/carry them. - Absent list — the guard's INACTIVE branch (absent store) and NO ENTRIES
branch (present, nothing usable) each warn loudly and exit zero, pinned by
TestPreCommitHook_AbsentBanlistWarnsLoudlyand its entryless sibling. - Scaffold —
abcd ahoywrites the guard hooks, the merge half beside abcd's own guard only, the public family, the EOL pin, and the gitignored stub (internal/core/ahoy/banlist_scaffold_test.go). Every write is create-if-absent and contained; the stub lands only wheregit check-ignorereports the path as ignored.TestBanlistStubSeedsOnlyReservedIdentifiersasserts the stub's examples against the repo's own network-identifier detector, with a control value proving the detector is armed. - Verb visibility — private entries render by key only; the redaction is
structural (the exported entry type carries no pattern field), asserted in
internal/surface/cli/banlist_surface_test.go. - Honest reach —
banlist.PrivateReachNoteis stated by the verb, the ahoy status board and the JSON envelope, and it names both limits: opt-in machines only, and only the commits git runs a hook for.
Residuals, recorded rather than closed.
- Where git runs hooks. The private layer cannot cover a fast-forward
git pull(no commit is created, so nothing is asked), a rebase,git am,git revert, a cherry-pick, or any commit made with--no-verify. That is a property of git, which is why the reach sentence names them and says "e.g.". - The copy refusals are a mistake-net. The first-line and basename tests catch
a
cp, a.bak, a stray duplicate. A copy with the declaration stripped or displaced, a one-byte edit to it, and a renamed legacy store all pass; the rename-source test only fires for a store git already tracks. They are not widened further on purpose — matching a store's key list, or scanning deeper into every staged blob, would put the secret through more code paths to catch fewer accidents. The published# abcd-banlist-examplesecond-line escape exists because they are shape tests and a repo may legitimately commit a store-shaped file. The escape keeps content scanning, so it cannot smuggle a plaintext banned name — but it offers no protection to a copy of a STORE, whose escaped patterns never match their own text, and a live store carrying the marker on its own second line therefore exempts every copy of itself. The marker belongs in fixtures and documents, never in a store holding real patterns. - The marker asserts identity, not integrity. Any file carrying a line equal to
# abcd-name-guard: v<n>is treated as abcd's guard and earns the merge shim that delegates to it — including a file that quotes the template inside a heredoc, and including a hook edited to check nothing. It answers "did abcd put this here" well enough to keep abcd from claiming a maintainer's hook; it is not a signature, and in a repo whose committed hooks a contributor can change, the guard's behaviour is whatever that contributor last committed. - The environment pin narrows, it does not close. It pins
PATH,IFSand xtrace and unsets inherited shell functions shadowing the commands it runs, which covers the common accidental and repo-scoped routes — a direnv file, an editor task, a sourced rc, an exportedBASH_FUNC_*. It is not a boundary: anyone who controls the committer's environment wins,#!/usr/bin/env bashresolves the interpreter through the inheritedPATHbefore any of the hook runs, and two of the pinned directories are user-writable on a typical developer machine.
Frontmatter
| id | spc-20 |
|---|---|
| type | spec |
| lifecycle | closed |
.abcd/development/specs/closed/spc-20-name-banlist.md
open on GitHub ↗ · commit history ↗
References1
- itd-74abcd Keeps the Names You Ban Out of Everything You Publishimplements
mentions13
- adr-3Directory location is the source of truth for lifecycle state
- dcn-26072900000000022026-07-29 — v0.5.0 scoped as "security & consistency" (plan: plans/2026-07-29-v0.5.0-security-and-consistency.md): the security half closes the NEXT.md leak class end-to-end (itd-74/spc-20; the atomic iss-154+157+125+153 detector item; iss-155/156; guard batch iss-159+144+148; boundary fixes iss-30/34), the consistency half retires the record-currency majors (iss-37..44, iss-80) — maintainer added the latter rather than deferring them. Version is derived by launch ship, not declared; 0.5.0 is the prediction given itd-74 additive. Deferred majors listed explicitly in the plan (largest: iss-124); the 2026-07-24 queue's pick-up role is superseded.
- dcn-26072900000000032026-07-29 — v0.5.0 cycle runs as a scheduled cloud loop (one item per 5-hour round, plan order): auto-merge authorised through the strict gate only (CI green + two adversarial MERGE verdicts; security lens mandatory on workstream A/B diffs); itd-74 is in autonomous scope, multi-round via PR resume, bounded by spc-20 and STOP condition 1. Rejected: human-merges-everything (stalls the ordered pipeline at merge cadence) and auto-merge-C/D-only (slows exactly the security half the release is for).
- dcn-26073000000000062026-07-30 — itd-74 (round 7), increment 2: the scaffolding and honest-reach halves (AC5, AC7), closing spc-20's mapping. `ahoy install` writes three artefacts, all create-if-absent: the committed guard hook at `.githooks/pre-commit`, a `.abcd/docs-lint.json` carrying an EMPTY public banned-names family, and the documented private stub in the gitignored local tier. Three decisions worth the record. (1) THE SCAFFOLDED HOOK IS A GENERALISATION, NOT A COPY. It drops this repo's iss-62 identity gate and the itd-76 dogfood refresh and keeps the name guard alone, so a byte-equality drift test between the two files would be wrong; the template is proved BEHAVIOURALLY instead — the scaffold test installs it into a real temp repo and drives three commits through it (loud warning and exit 0 with no entries, refusal naming the key alone with the pattern absent from all output, refusal to stage the store itself), which is the only evidence that distinguishes a guard that works from one that merely parses. Rejected: a symlink from `.githooks/pre-commit` to the embedded default (zero duplication, but breaks a Windows checkout and would have rewired increment 1's own test harness), and copying this repo's hook verbatim (it would scaffold repo-specific gates into every managed repo). (2) THE PUBLIC FAMILY IS SEEDED EMPTY. abcd cannot know which names a repo may not publish, and a ban nobody declared would fail a build over a word the maintainer never chose; the array's PRESENCE is what makes `banlist add --public` usable, which is all AC5 asks for. A docs-lint config that exists but carries no usable array is a NON-resolvable diagnostic gap: the config gates CI and a contributor owns it, so abcd reports the fault and never rewrites a file it cannot read. (3) THE STUB'S GAP IS RESOLVABLE ONLY ONCE THE FENCE COVERS IT. Writing the store into a repo git would track is the exact hazard the layer exists to prevent, so the stub write runs after the visibility step and is gated on the canonical `.gitignore` block being on disk; advertising it as resolvable regardless would leave a repo permanently "partial" on a fix apply refuses to make (the markerSymlink precedent). No new gitignore entry was needed — iss-169's fence already covers `.abcd/.work.local/` under private and `/.abcd/` under public — so AC5's gitignore clause is pinned by a new test against real `git check-ignore`, keyed to `banlist.PrivateRelPath` rather than to a spelling of the tier, and it passed on first run. AC7: the reach sentence lives once as `banlist.PrivateReachNote` and travels INSIDE the reported state (`BanlistHealth.Reach`), not as renderer prose — a machine consumer reading "hook installed" beside a present store would otherwise draw exactly the wrong conclusion, and the JSON envelope is a report surface too. The health pass reads no entry and spawns no subprocess: the store's content is the secret, and a status board is the surface that must not hold it. Stub examples are all commented out (a fresh scaffold parses to zero entries, so the guard warns loudly rather than looking like protection) and every illustrative value is a reserved documentation value or a persona-derived fixture host, judged by the repo's own network-identifier detector — with a control value asserted flagged first, so the assertion cannot pass vacuously. Residual, recorded not fixed: abcd does not set `core.hooksPath`, so a clone arms the hook by hand (the record scopes AC5 to the hook being committed, and silently repointing a repo's hooks path is a git-config change no acceptance criterion asked for); and the scaffolded template and this repo's prototype now share ~250 lines that no mechanism holds in sync. FIX PASS after two fresh adversarial reviews returned BLOCK on this increment's own code; detector-first throughout, each behaviour change watched failing first. (A) THE FENCE IS GIT'S DECISION, NOT A TEXT COMPARISON. Both the stub write and the gap's resolvability were derived from `gitignoreBlockDrifts` — a set comparison of the abcd-managed .gitignore block — which answers a different question from the one that matters. A repo can carry a byte-perfect block and still track the store (a negation after it, a tracked tier), a fully-configured repo never reaches the visibility step at all, and `stepConfigValues` returns nil whenever ANY unrelated config value is missing under a declined ConfigChange, which silently disabled the write while `localTierIgnored` reported the gap as resolvable — a permanent partial. Both now call `gitutil.IsIgnored`, the same primitive increment 1's `requireIgnoredStore` uses, evaluated on disk after the visibility step, and skipped outside a git repo for the same stated reason (a check cannot demand proof no one can supply). Health carries the verdict and a present store git would track is called out on every surface. (B) CONTAINMENT. The three writes used `os.MkdirAll` plus an atomic rename, and a repo that commits a symlink at `.githooks` — which a checkout materialises before abcd runs — took the 0755 hooks OUTSIDE the repo while every surface reported the in-repo path; watched, and confirmed by the reverted code writing `pre-commit` into the link target. They now go through `fsutil.CreateExclusiveIn` under an `os.Root` opened at the repo, which is both guarantees at once (no clobber, no check-then-write window) and refuses symlink traversal at every level. The local-tier case was already contained by increment 1's `os.OpenRoot` read path, which reports such a store as unreadable rather than absent. (C) BOM FAIL-OPEN, IN BOTH READERS. A leading UTF-8 BOM made the first line differ from the format declaration, so a KEYED store read as LEGACY: every keyed entry became a whole-line pattern matching nothing a commit contains, while `entries` stayed >=1 so the zero-entry warning never fired — a store that looked healthy and checked nothing, reproduced as a real commit going through. Fixed in the Go parser, the scaffolded template, and this repo's own prototype; and a first line that is not the declaration but WOULD be after stripping leading blanks is now a DAMAGED declaration that fails closed, because reading it as legacy is the same downgrade by another route. (D) MERGE-COMMIT BYPASS. git runs no pre-commit hook for a merge, so a banned name entered history the moment a branch carrying it was merged; a `pre-merge-commit` half now runs the same guard through one implementation (a shim, not a copy) in both the template and this repo. The reach sentence widened with it: `PrivateReachNote` now names what a hook cannot see — a rebase, `git am`, a cherry-pick, `--no-verify` — because "machines that have opted in" is necessary and not sufficient, and the shorter sentence let a reader believe an opted-in machine was covered. (E) TWO CLAIMS WITHDRAWN. A foreign pre-commit hook was reported as the abcd guard: presence is not identity, so each template carries `# abcd-name-guard: v1`, a hook without it is a non-resolvable diagnostic abcd never replaces, and "installed" became "committed" with the arming instruction beside it (git runs the hook the clone's hooks path selects, which abcd neither sets nor fully observes). And under `visibility: public` the fence ignores the anchored `/.abcd/`, so the "committed, CI-enforced" public family is untracked exactly where public exposure is the risk — detection now reports `banlist.public_family_ignored` and the board reads NOT ENFORCEABLE. The placement question is NOT resolved here (moving the file amends the iss-169 record; carving an exception gives up its one-switch property) and is captured as iss-176 for the maintainer, with three candidate reconciliations. Minors in the same pass: the private layer's shape comes from a new `banlist.SummarisePrivate` — the shared parser without the per-entry grep, so a status pass costs no subprocess per line — the public family's four faults report apart (an unreadable file is not an absent array), the local tier's 0700 is asserted rather than assumed from the creating call, the staged-path refusal is case-folded, the format is announced before the parse loop, and stale scratch files are swept. Residuals the reviews verified as accepted: the scaffolded template and this repo's prototype share ~250 lines that no mechanism holds in sync (byte-equality would be wrong — the template drops the identity gate and the dogfood refresh — and the template is proved behaviourally instead); `--no-verify`, rebase and server-side pushes remain uncoverable by construction; GNU/BSD grep divergence stands as increment 1 recorded it; commit messages, tag names and branch names are outside the guard's scope (it reads staged blobs and paths); a scaffolded repo gets no CI wiring for the public family; abcd still does not set `core.hooksPath`; and `a.note()` renders absolute paths across every apply step, pre-existing and repo-wide, captured as iss-177 rather than half-fixed here. FIX PASS 2 after a second pair of independent BLOCK reviews, both on the fix pass's own code. (F) THE MERGE SHIM NEVER STANDS IN. It was written whenever its own gap was present — including beside a FOREIGN pre-commit hook — so abcd's marker landed in the shim, the board read "pre-merge-commit hook committed", and merges stayed unchecked; worse, the maintainer's hook silently began running on merge commits, which git never did, so apply was taking over exactly the wiring the foreign-hook gap disclaims owning. The shim is now written only beside abcd's own guard (including one the same step just wrote — keyed on the artefact's STATE, not on a gap a fresh repo deliberately does not raise), and `banlist.merge_hook_inert` reports the alternative. The wrong assertion in the increment's own test — which asserted the buggy behaviour — was flipped and watched fail. (G) A COPY OF THE STORE IS A MISTAKE-NET, NOT AN ADVERSARY-NET. The staged-path refusal matched only the local tier, so `cp` of the banlist to notes.txt, or a `git mv` out of the tier, committed every pattern in clear while the guard announced a clean check — the entries cannot catch their own text, because they are escaped regular expressions and `carol-server\.example\.net` does not match itself. Three tests now: the tier path (not escapable, and applied to a rename's SOURCE path too — the loop had been overwriting it with the destination and discarding the one field that identified the file), a staged blob whose FIRST LINE is the format declaration, and a staged path whose basename is the store's filename. Their reach is stated rather than implied, here and in the brief and the spec: a copy with the declaration stripped, altered by a byte or displaced below a preamble passes the first-line test; a LEGACY store declares no format at all and passes it always, and passes the basename test the moment it is renamed; and the rename-source test only fires for a store git already tracks. What they catch is the accident that happens — a `cp`, a `.bak`, a stray duplicate — and they are NOT widened further on purpose: matching a store's key list or scanning deeper into every staged blob would route the secret through more code paths to catch fewer accidents. Round 3 also found the other direction: the first-line test blocked this repo's OWN committed corpus fixtures, and `--no-verify` is an off switch for the whole guard rather than a per-file escape, so the refusals now honour a published second-line `# abcd-banlist-example` marker — exempting a blob from the COPY tests and from nothing else, its content still scanned against every entry — and the two keyed corpora carry it. The tests also moved OUT from behind the absent-store early exit (whether this machine has a store says nothing about whether a commit is carrying one, and that exit was itself how the rename escaped), and the guard no longer creates its local tier in a repo that never opted in: with no store it scratches in a 0700 temp directory instead of leaving an unfenced abcd directory behind on every commit. (H) THE DECLARATION IS LINE 1 OR NOWHERE, in all three readers and for BOTH formats. A blank line or a comment above it, a duplicate below it, or any prefix bytes before it (a UTF-16 byte-order mark, an editor artefact — caught by a suffix test rather than a byte-class check no portable shell has), is a damaged declaration that fails closed. Round 3 found the header scan gated on the store not already being keyed, so a keyed store with the declaration repeated on line 2 read as healthy to Go — "present, keyed, 1 entry" on the status board — while the shell blocked every commit; a store one reader calls healthy and the other refuses is worse than either verdict alone, and a fourth shared corpus now drives both readers over exactly that file. (I) THE ENVIRONMENT IS PINNED FIRST, in all four hooks: xtrace off, IFS, LC_ALL and PATH as the FIRST statements, before anything is read or run — the pin had been landing after `rev-parse`/`cd`, and the merge shim had none at all while resolving its delegate through an external `dirname` (now `${0%/*}`, pure parameter expansion). The pin NARROWS the substitution class and does not close it: two of the pinned directories are user-writable on a typical developer machine, and `#!/usr/bin/env bash` resolves the interpreter through the inherited PATH before any of it runs — the earlier "root-owned" wording in this line was wrong and is corrected here. Every external tool the hooks call is probed after the pin so a missing one blocks loudly instead of surfacing as a mute exit 127, and a nonstandard prefix extends the pin through `git config --local abcd.guardPath` — read through the already-pinned git, and deliberately NOT an environment variable, since a repo-scoped direnv sets the environment and would reopen the exact hole. Minors in the same pass: scaffolded modes are chmod'd explicitly because `CreateExclusiveIn` and `MkdirAll` are umask-masked and a stripped exec bit turns the shim's fail-closed branch into a permanent merge block; the marker is matched by PREFIX so a v2 template cannot reclassify every v1 hook as foreign; `storePathIsSafe` distinguishes "not a repository" (skip, as before) from "repo-shaped and git will not answer" (fail closed) — `InRepo` is false for git-absent-from-PATH too, which the old comment's justification did not cover; `hooksPathArmed` resolves both sides before comparing, so an absolute `core.hooksPath` is no longer read as unarmed and told to downgrade itself; the absent-public-family gap is non-resolvable where git would ignore the path and abcd declines to write a config it would immediately call unenforceable; the detection envelope omits the banlist object for an unmanaged folder rather than serialising undeclared zero states; detection reads now resolve through the same containment root the writes use; the reach note is explicitly non-exhaustive and names `git revert` and a reapplied stash; and `abcd ahoy` scaffolds the `.githooks/* text eol=lf` attribute into managed repos (append-if-missing, never a rewrite) rather than leaving them the CRLF-shebang failure this branch fixed for abcd itself. CORRECTION to this line's earlier claim that the health pass "spawns no subprocess": it always did — it now spawns four (one `rev-parse`, one batched `check-ignore` for both paths, one `config --local`, and one `check-attr` for the hooks' EOL), down from six or seven, and `ahoy.Detect` is on the session-hook path, so that count is a cost paid per prompt. One earlier test was vacuous and is fixed rather than deleted: the local-tier half of the symlink containment test passed with containment reverted, because the escaping store is classified unreadable before any write is attempted, so it now asserts that protecting state by name. FIX PASS 3 (converge). MARKER IDENTITY: `classifyGuardHook` matched the marker as a substring of the whole blob, so a foreign hook that merely MENTIONED it — a comment, a grep for it — classified as abcd's own, the board claimed coverage, and the merge shim was written beside it; identity is now a whole LINE matching `^# abcd-name-guard: v<digits>$`, with the version a group so a v2 template cannot reclassify v1 hooks. The same line-wise fix went to the `.gitattributes` pin, where a commented-out attribute had read as pinned. REACH ACCURACY: a fast-forward `git pull` creates no commit and so runs no hook — the commonest uncovered path of all, now named — and the stash claim was probed FALSE (`git stash pop` then commit runs pre-commit normally) and removed: a bypass list naming a covered path teaches a reader to distrust the rest of it. Minors: the merge-inert gap no longer asserts a delegating shim that may not exist; `createContained` pinned modes on directories it did NOT create, widening a maintainer's deliberate 0700 to 0755, and now pins only what it made; `repoShaped` walked no ancestors, so in a repo SUBDIRECTORY with git unavailable the stub would have been written into a tracked tree; the stale-scratch sweep and the tier creation are conditional on the tier being the scratch home; and the foreign-hook gap's `Required: false` is documented as deliberate (a required gap nothing can resolve is a repo permanently reported incomplete for a state its maintainer chose). Also re-classified: `stepBanlist` now asks disk what occupies the hook paths rather than trusting the detection snapshot taken before the earlier steps ran. FIX PASS 4 (prose currency plus one-liners). The two CHANGELOG bullets, internal/README.md, and both brief surfaces described THREE scaffolded artefacts and a stale reach; there are FIVE (both guard hooks, the .gitattributes LF pin, the public family, the stub), and the reach now names a fast-forward `git pull` — the commonest uncovered path of all — and no longer names a reapplied stash, which probing showed IS covered and which reach_test.go now pins out. Security minors: an inherited shell FUNCTION shadowing `grep` defeated the PATH pin entirely (probed — the guard reported a clean check under `BASH_FUNC_grep%%=() { return 1; }`), so all four hooks now `unset -f` the commands they run as their first statement after the xtrace guard; `abcd.guardPath` refuses a value with an empty PATH element, which every consumer reads as the current directory; the scratch-directory trap is installed before the three inner mktemps that could exit past it; and `mktemp -d` takes an explicit portable template. The `.gitattributes` classification stops pattern-matching abcd's own line and asks `git check-attr eol` instead — git is already this package's authority for the ignore question, and a later `* text eol=crlf` overrides a line a regex still calls pinned; the line-anchored regex survives only to keep an append from duplicating. The marker regex tolerates a trailing CR, so a CRLF-mangled abcd hook classifies as OURS-but-unpinned and install can heal it rather than reporting a foreign hook for ever. Three residuals are widened rather than fixed, in the brief and the spec: the environment pin narrows common accidental and repo-scoped routes and closes nothing (anyone controlling the committer's environment wins); the example escape keeps content scanning so it cannot smuggle a plaintext banned name, but it protects no copy of a STORE — escaped patterns never match their own text — and a live store carrying the marker exempts every copy of itself; and the marker asserts identity, never integrity, so any file quoting it is treated as abcd's guard. Lifecycle: itd-74 sits in shipped/ with its fidelity review OWED (receipt rcp-3ceed52bdb99). That is the designed follow-up, not an omission — `abcd spec close` stamps the receipt and the review is a separate verb (`abcd intent review`), which record-lint and audit accept; no record file was hand-edited to produce or to clear it.
- dcn-26092300000000192026-09-23 — The one sanctioned mention of abcd in an adopted repository (product thinker, ruling F of the run A interview, 14:38Z, on iss-2609231103413459). The 2026-09-11 ruling that `ahoy install` must not write abcd by name into a repository it adopts (carried by iss-2609110944498549) covers the name-guard hooks (`.githooks/pre-commit`, `.githooks/pre-merge-commit`) and the `.gitignore` fence, and they are its sanctioned exception: both keep their markers and their naming, documented as the one allowed mention of abcd in an adopted repository, because the hooks run the binary and the fence tells people not to hand-edit it. No rename and no migration: detection classifies an adopted repository by exactly that hook marker and fence, so a rename would have to migrate every repository already adopted. The product thinker ruled the markers, not abcd's record ids: the hook templates' citations of abcd's own records (itd-74, spc-20, itd-150, iss-370, and a path into the design record) resolve to nothing in an adopter's repository, so they are dropped from the templates and a test keeps them out. iss-2609231103413459 closes as wontfix on this ruling.
- iss-154privacy-hygiene detects only absolute home paths — no network-identifier patterns (IPv4 incl. CGNAT/tailnet 100.64/10, hostnames, device names, ports-in-context). Field incident: an agent committed a Tailscale investigation (tailnet IP, two device hostnames, firewall posture, service port, presence/away patterns) into a public repo's committed tier and audit passed it silently. The v1 deferral note names only emails and private-repo names; network identifiers are not even recorded as deferred.
- iss-158no commit-time gate for user-private identifiers: the canonical home for this is itd-74 (name-banlist, in drafts/) — its private-banlist layer (local untracked file + committed guard hook, refuse by entry-key only, never echoing the value) is exactly the gate this incident needed. This issue is NOT a new primitive: it records the field evidence that itd-74's scope must include machine identifiers (hostnames, tailnet IPs/prefixes, device names), not only harness/project names, and that the incident strengthens the case for promoting itd-74 out of drafts (a maintainer decision). Generic patterns (iss-154/157) catch shapes; only the banlist catches this user's actual names. Prior art at skill level: the confidential-sources sync-banlist guard; dogfood prototype already live per itd-74's own record. Complements, does not replace, iss-154.
- iss-176The public banned-names family lives at .abcd/docs-lint.json, which the installed .gitignore fence ignores under visibility public, so the layer that claims to be committed and CI-enforced is untracked exactly where public exposure is the risk
- iss-2609231103413459A default ahoy install still commits abcd's name, and abcd-internal record references, outside the .abcd/ namespace the adopter accepts: the two name-guard hooks (.githooks/pre-commit and .githooks/pre-merge-commit: the '# abcd-name-guard: v1' marker, prose naming abcd, 'abcd ahoy' and 'abcd banlist', and the itd-74 / spc-20 ids) and the .gitignore fence ('# BEGIN ABCD', '# abcd-managed block — do not hand-edit. Run /abcd:ahoy to refresh.'). Reproduced 2026-09-23 on a fresh git repo with 'abcd ahoy install --yes --adopt --visibility private': grep -ril abcd over the tracked result names both hooks and .gitignore. The 2026-09-11 ruling (iss-2609110944498549) is that ahoy install must not write abcd by name into a repository it adopts, and prepare-this-repo's acceptance promises no abcd-internal content in any committed artefact. The fix for that record removed the conventions-file block from the default (docs target skip); these writes remain. Major: this is the unmet remainder of iss-2609110944498549 (major), whose acceptance 1 (no committed file contains abcd after a default install) this record now holds; it carries that severity rather than a lower one, so the remainder still meets the release-cut guard. The hooks are not the files a repository's contributors read first, and the hooks run the abcd binary, so part of the naming is functional, but that bears on the fix, not on whether the ruling is met. What a fix has to settle: the hook marker and the fence are what detection classifies by (classifyGuardHook, gitignoreBlockDrifts), so a rename is a migration over every already-adopted repo, and the hooks are security-sensitive; whether the name in a hook that invokes the tool is in scope of the ruling at all is the product thinker's call.
- 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.
- iss-2609300025372991Two shipped intents' Implementing specs sections misstate where their predecessor-store spec ids stand: itd-36 says its frontmatter spec_id records spc-38 as the primary delivering spec, while the frontmatter records spc-2609211905174684 and live spc-38 and spc-39 are itd-136's record explorer and itd-137's relationship chart; itd-4 says its spc-20 to spc-23 do not exist in the native spec store, while the live store holds all four as other specs (banlist, fresh install, stale-binary warning, tier placement). Found while qualifying predecessor-store citations for iss-2609290448510918.
- itd-4Nothing You Notice Gets Lost
- itd-79Personas Come From the Registry; the Role Picks the Name