Development record
abcd Keeps the Names You Ban Out of Everything You Publish
Press Release
Name a thing once as off-limits; abcd keeps it out of every published surface — and keeps the truly private ones off the machine's commits entirely. A repo abcd configures often must not name certain things in what it publishes: a specific agent harness (so the surface stays host-agnostic), a partner's product, a private project whose very name is confidential, or — most sensitively — the user's own machine identifiers: hostnames, device names, and the IPs or address prefixes of their private network. abcd manages this as a two-layer banlist. The public banlist is enforced deterministically in CI: named tokens in user-facing content (README,
docs/, the shipped artefact) fail the build, with a per-line escape hatch for the rare deliberate mention. The private banlist is enforced by a local, untracked guard — because a name that must never appear anywhere public cannot be written into public CI config to ban it there. Its patterns live only on the developer's machine; a pre-commit guard refuses to stage any content that matches, so the string never enters tracked history in the first place."The names I can't afford to leak are exactly the ones I can't put in a public linter rule," said Kira, a maintainer. "abcd solved that by splitting it: public names get a CI gate, private names get a local guard whose list never leaves my machine. I stopped worrying that a stray paste would ship a name I'd promised to keep quiet."
Why This Matters
Two failure modes share one root. First, a tool that claims to be host-agnostic undermines itself the moment its published docs name a specific harness — the naming dates the content and couples the surface. Second, and worse, a private collaborator's or project's name leaking into a public repo is a confidentiality breach that a history rewrite alone cannot fully undo (merged-PR diffs and cached views persist server-side). Both are cheap to prevent and expensive to remediate. The lesson learned the hard way on abcd's own repo: prevent at authoring time, and never let the sensitive string reach a public artefact — including the linter config meant to catch it.
The design tension is the interesting part: a deterministic CI gate is the right tool for public banned names, but it is the wrong place for a private one, because the rule would have to contain the very string it forbids. Splitting enforcement by sensitivity — public names in CI, private names in a local untracked guard — resolves it without compromise.
What It Looks Like
abcdmanages both layers as first-class config. A public banlist (patterns + per-token severity + the allow-context escape) compiles into the deterministic docs-currency lint family; a private banlist is scaffolded as an untracked, per-machine file plus a committed guard hook that reads it. The literal private strings never enter tracked content or CI config.- Wired into install.
abcd ahoyscaffolds both surfaces for any repo it configures: the public family in the docs-lint config, the guard hook in the repo's committed hooks, and a gitignored banlist stub with instructions. A repo becomes name-safe by being abcd-managed, not by hand-rolling hooks. - Verbs to maintain it. Add, list, and remove banned patterns; the public ones are visible and reviewable, the private ones addressed by reference (never printed into a shared artefact).
- Reports what it cannot enforce. The private guard is local by construction, so abcd states plainly that CI cannot enforce it — the guard protects the machine that opted in, and the public flip is gated on a from-scratch name scan.
Acceptance Criteria
BDD format, per the itd-1 discipline. Confirmed by the maintainer in the 2026-07-29 planning interview.
- Given a repo whose public banlist bans token X, when docs-lint runs over user-facing content (README,
docs/, the shipped artefact) containing X without the allow escape, then a blocker finding names the file and line; given the line carries the escape, then no finding is raised. - Given a private banlist entry with key K and pattern P, when a commit stages content matching P, then the commit is refused and the refusal message names only K — the matched string and the pattern value are never echoed to any output.
- Given a private banlist containing a hostname, an IP or CIDR prefix, or a device name, when staged content matches one, then the commit is refused exactly as for a name entry — machine identifiers are first-class private entries.
- Given a fresh clone where the private banlist file does not exist, when the guard hook runs, then it prints a loud warning that the private layer is inactive on this machine and does not block the commit — never a silent pass that looks like protection.
- Given a repo configured by
abcd ahoy, when scaffolding completes, then the public family is present in the docs-lint config, the guard hook is committed, the private banlist stub exists and is gitignored, and the stub's seeded examples use only reserved documentation values (RFC 5737/3849/2606/7042 ranges, persona-derived hostnames) per theexamples-use-reserved-identifiersprinciple. - Given the banlist maintenance verbs (add, list, remove), when entries are rendered, then public entries render in full and private entries render by key only.
- Given any status or report surface describing the private layer, when it renders, then it states plainly that CI cannot enforce the private list — it protects only machines that have opted in.
Resolved Questions (2026-07-29 planning interview)
- Config shape: two files. The public banlist is committed beside the docs-lint config; the private banlist is a separate gitignored file with its own tooling. A single split file makes the whole file's visibility load-bearing — accidental commit of the private half is the exact failure mode this intent exists to prevent.
- Public-layer home: generalise the docs-lint family. The existing
harness/*banned-token family is lifted into a general banned-names family — one canonical primitive, ratchet not big-bang. A dedicated rule kind with richer reporting is future work, not this intent. - Private-stub seeding: reserved documentation values only. The
examples-use-reserved-identifiersprinciple answers the seeding question — the stub's examples come from RFC-reserved ranges and persona-derived hostnames, so the scaffold can never suggest a plausible real value. - Scope: machine identifiers included. Hostnames, device names, and IP/CIDR entries are first-class private-banlist entries (evidence: iss-158, the 2026-07-29 managed-repo NEXT.md privacy-leak investigation).
Dogfood (already running on this repo)
The concrete prototype this intent generalises is live in abcd-cli itself: a harness/* banned-token family in the docs-currency lint (public agent-harness names, blocker, README + docs/ scanned), a committed .githooks/pre-commit guard that reads an untracked banlist for the private name, and the gitignored banlist that holds it. The feature is to lift that from a hand-wired arrangement into an abcd capability every managed repo inherits. Relates to the host-agnostic documentation principle and to the install surface (ahoy).
Open Questions
None — all three original questions resolved in the 2026-07-29 planning interview (see Resolved Questions).
Audit Notes
Fidelity review — receipt rcp-3ceed52bdb99 (verifier intent-auditor claude-fable-5-1).
Provenance: intent-auditor@claude-fable-5-1 · rubric_hash sha256:effa65b3e9e88ff29433b443ec2be159522a8b0b71cf1434526514aa61edb13e · prompt_hash sha256:adb1f626f42968e7eed5c70958482350603309715288f8683540ecbcd62ef5c7 Input attestations: diff:internal/core/banlist, internal/core/ahoy/banlist_scaffold*.go, internal/surface/cli/banlist.go, .githooks/pre-commit, internal/core/lint/config.go and .abcd/docs-lint.json at chore/audit-run-a-1 5b4a43b6 (git ls-tree -r; spc-20 closed, itd-74 shipped)@sha256:cf94fb55003ac6ae6116bab8ea45d59a0f568e0de0a7e005bb9f2a4472190b8a;
Acceptance rollup: MET 5 · MET_WITH_CONCERNS 2 · NOT_MET 0 · INCONCLUSIVE 0
Per-criterion verdicts:
- ac-1 — MET_WITH_CONCERNS: the banned_tokens family compiles per-token patterns with an allow_context escape and a blocker finding names file and line, as TestBannedTokens (bad.md:1) and TestDocsLintHarnessNameGate (docs/named.md:3, the docs-lint:allow line silent) hold; the concern is the reach: the configured roots are docs and README.md only, the payload render reuses those same roots, and the shipped artefact (commands/, agents/, skills/) is not a scanned root
evidence: internal/core/lint/config.go:47 — "type BannedToken struct {"
evidence: internal/core/lint/config.go:59 — "AllowContext []string
json:"allow_context"" evidence: internal/core/lint/lint.go:279 — "findings = append(findings, tokenChecks.lintLines(rel, lines, mask)...)" evidence: internal/core/lint/lint_test.go:238 — "func TestBannedTokens(t *testing.T) {" evidence: internal/core/lint/lint_test.go:262 — "if !hasFinding(fs, filepath.Join("rec", "bad.md"), "py", 1) {" evidence: internal/core/lint/lint_test.go:289 — "if !hasFinding(fs, filepath.Join("docs", "named.md"), "harness/claude-code", 3) {" evidence: .abcd/docs-lint.json:2 — ""roots": [ "docs", "README.md" ]" evidence: internal/core/launch/gates.go:105 — "// Findings are the docs-lint findings over the configured doc roots." - ac-2 — MET: the committed guard refuses a staged match naming only the entry key and withholds the matched text and the pattern by design; TestPreCommitHook_RefusesByKeyOnly asserts the key is printed and neither the pattern, its upper-case form nor the matched line appears evidence: .githooks/pre-commit:877 — "echo "pre-commit: BLOCKED — staged content matches private banlist entry '$key'." >&2" evidence: .githooks/pre-commit:886 — "(the matched text and the pattern are withheld by design — only the key is named)" evidence: internal/core/banlist/hook_test.go:195 — "func TestPreCommitHook_RefusesByKeyOnly(t *testing.T) {"
- ac-3 — MET: machine identifiers are ordinary keyed patterns in the store format, and the keyed corpus carries a hostname, an IPv4 address, a CIDR prefix, a MAC address and an IPv6 address that TestPreCommitHook_KeyedCorpus proves block exactly as the name entry does, naming the key only evidence: .githooks/pre-commit:27 — "hostnames, IPv4/IPv6 addresses, CIDR prefixes, MAC addresses," evidence: internal/core/banlist/testdata/parse-corpus.txt:19 — "lab-host alice-laptop.example.com" evidence: internal/core/banlist/testdata/parse-corpus.txt:21 — "lab-cidr 203.0.113.0/24" evidence: internal/core/banlist/hook_test.go:215 — "func TestPreCommitHook_KeyedCorpus(t *testing.T) {"
- ac-4 — MET: an absent store prints a starred WARNING that the private name guard is INACTIVE and the commit proceeds; an entryless store warns as loudly; both are pinned by TestPreCommitHook_AbsentBanlistWarnsLoudly and TestPreCommitHook_EntrylessStoreWarnsLoudly evidence: .githooks/pre-commit:462 — "echo "pre-commit: WARNING — the private name guard is INACTIVE on this machine." >&2" evidence: .githooks/pre-commit:681 — "echo "pre-commit: WARNING — the private name guard has NO ENTRIES in this store." >&2" evidence: internal/core/banlist/hook_test.go:155 — "func TestPreCommitHook_AbsentBanlistWarnsLoudly(t *testing.T) {" evidence: internal/core/banlist/hook_test.go:171 — "func TestPreCommitHook_EntrylessStoreWarnsLoudly(t *testing.T) {"
- ac-5 — MET_WITH_CONCERNS: Install writes the executable guard hook, a docs-lint config whose public family ListPublic reports present, and a keyed private stub with only commented examples (TestInstallScaffoldsTheBanlistArtefacts); the stub's values are judged reserved by the repo's own network detector with an armed RFC 1918 control (TestBanlistStubSeedsOnlyReservedIdentifiers) and real git ignores it (TestInstalledStubIsIgnoredByRealGit); the concern is that where the docs-lint config path is gitignored (the public-visibility case) no public family is written and the gap is reported unresolvable, so the scaffold leaves that repo with no CI-enforced family evidence: internal/core/ahoy/banlist_scaffold_test.go:19 — "func TestInstallScaffoldsTheBanlistArtefacts(t *testing.T) {" evidence: internal/core/ahoy/banlist_scaffold_test.go:73 — "func TestBanlistStubSeedsOnlyReservedIdentifiers(t *testing.T) {" evidence: internal/core/ahoy/banlist_scaffold_test.go:346 — "func TestInstalledStubIsIgnoredByRealGit(t *testing.T) {" evidence: internal/core/ahoy/banlist_scaffold.go:164 — "# lab-ipv4 192.0.2.17" evidence: internal/core/ahoy/banlist_scaffold_test.go:495 — "t.Error("install wrote a docs-lint config into a path git ignores, delivering no enforcement")" evidence: internal/core/ahoy/banlist_scaffold_test.go:509 — "t.Error("the gap offers a fix that would produce a config CI never sees")"
- ac-6 — MET: renderPublicLayer prints id, severity, owner and the pattern in full; renderPrivateLayer prints each entry's key and line and states that pattern values never reach any output, and TestAddPrivateCreatesTheStoreAndListsKeysOnly asserts the marshalled private report carries no pattern evidence: internal/surface/cli/banlist.go:478 — "fmt.Fprintf(w, " %-32s %-8s %-13s %s\n", termsafe.Sanitize(e.ID), termsafe.Sanitize(e.Severity), owner, termsafe.Sanitize(e.Pattern))" evidence: internal/surface/cli/banlist.go:269 — "fmt.Fprintf(w, " %s (line %d)\n", termsafe.Sanitize(e.Key), e.Line)" evidence: internal/surface/cli/banlist.go:274 — "keys only: the pattern values never reach any output, by design" evidence: internal/core/banlist/private_test.go:225 — "func TestAddPrivateCreatesTheStoreAndListsKeysOnly(t *testing.T) {"
- ac-7 — MET: one PrivateReachNote constant states that CI cannot enforce the layer and it protects only opted-in machines; the banlist render, the ahoy status board, its JSON envelope and the scaffolded stub all derive from it, with tests on the board and the envelope evidence: internal/core/banlist/banlist.go:92 — "const PrivateReachNote = "CI cannot enforce this layer — it protects only machines that have opted in, " +" evidence: internal/surface/cli/banlist.go:298 — "fmt.Fprintln(w, " reach: "+banlist.PrivateReachNote)" evidence: internal/core/ahoy/banlist_scaffold.go:468 — "Reach: banlist.PrivateReachNote," evidence: internal/surface/cli/ahoy_banlist_reach_test.go:20 — "func TestAhoyStatusStatesThePrivateBanlistReach(t *testing.T) {" evidence: internal/surface/cli/ahoy_banlist_reach_test.go:86 — "func TestAhoyEnvelopeCarriesTheReach(t *testing.T) {"
Gap audit:
- honoured:
- two layers, public in CI config and private in an untracked store the committed guard reads evidence: .githooks/pre-commit:6 — "Refuses to commit any name listed in an UNTRACKED banlist." evidence: internal/core/banlist/banlist.go:157 — "func validPublicPattern(stored string) bool {"
- a refusal names the key alone evidence: .githooks/pre-commit:886 — "only the key is named"
- an absent private layer warns loudly and never impersonates protection evidence: .githooks/pre-commit:462 — "WARNING — the private name guard is INACTIVE on this machine."
- ahoy scaffolds hook, public family and gitignored stub seeded with reserved values evidence: internal/core/ahoy/banlist_scaffold_test.go:19 — "func TestInstallScaffoldsTheBanlistArtefacts(t *testing.T) {"
- every surface states what CI cannot enforce evidence: internal/core/banlist/banlist.go:92 — "CI cannot enforce this layer"
- diverged:
- the public family gates README, docs/ and the shipped artefact evidence: .abcd/docs-lint.json:2 — ""roots": [ "docs", "README.md" ]" evidence: internal/core/launch/gates.go:105 — "the docs-lint findings over the configured doc roots"
- a repo becomes name-safe by being abcd-managed: the public family is present after scaffolding evidence: internal/core/ahoy/banlist_scaffold_test.go:485 — "func TestPublicFamilyUnderPublicVisibility(t *testing.T) {" evidence: internal/core/ahoy/banlist_scaffold_test.go:509 — "the gap offers a fix that would produce a config CI never sees"
- missing: (none)
Frontmatter
| id | itd-74 |
|---|---|
| type | intent |
| lifecycle | shipped |
| kind | standalone |
| severity | minor |
.abcd/development/intents/shipped/itd-74-name-banlist.md
open on GitHub ↗ · commit history ↗
Referenced by4
- adr-41Documents and ledgers never leave the user tier; a public citation requires both gatesrelated
- itd-76abcd Lets You Consult Sources You Cannot Cite — and Remembers Every Debtblocks
- itd-2609151516525843A Repository Can Ban A Name On Its First Commit, And A Machine Can Ban One Everywherebuilt on by
- spc-20name-banlistimplemented by
mentions29
- dcn-26070800000000012026-07-08 — Confidential sources: global user-level corpus (CSL-JSON + grep
- dcn-26072900000000012026-07-29 — privacy-leak follow-up: illustrative machine identifiers in anything committed or published always come from reserved documentation ranges (RFC 5737 IPv4, RFC 3849 IPv6, RFC 2606 domains, RFC 7042 MACs; hostnames derived from the persona registry, e.g. alice-laptop) — the itd-79 persona rule applied to infrastructure. Recorded as principle `examples-use-reserved-identifiers`; enforcement is the iss-154 allowlist-inversion lint (flag any identifier OUTSIDE the reserved ranges), whose shipping promotes the principle to a discipline. No new intent: real private identifiers are itd-74's banlist territory — the incident evidence extends itd-74's scope to machine identifiers (iss-158) rather than founding a second primitive. Incident issue text deliberately names no repo and no values (shape only), so the ledger itself cannot re-leak.
- 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-26073000000000032026-07-30 — itd-74 (round 6), increment 1: the private guard layer end to end plus the maintenance verbs on both layers (AC1–AC4, AC6). Private entry format is `KEY<whitespace>PATTERN` with the key charset `[A-Za-z0-9][A-Za-z0-9._/-]*` — deliberately free of every regex metacharacter, which is what makes legacy compatibility safe rather than best-effort: a line whose first field is really the head of a regex cannot pass for a key, so it falls back to the whole-line reading under the synthetic key `entry-<line-number>`, and even where a split does apply to an old two-word pattern the resulting match is a superset of the old one (over-blocking, never under-blocking). Refusal names the key alone; the matched text and the pattern never reach any output, and a malformed line is a refusal naming its line number, because a banlist that cannot be read must not look like a banlist that found nothing. Two hook-internal fixes fell out of writing the proof: the candidate text moves from a pipe into `grep` to a temp file (under `pipefail`, a matching `grep -q` exits early and the writer left holding a closed pipe reports 141, which the pipeline surfaces instead of grep's verdict — a staged diff larger than the pipe buffer could silently defeat the guard), and grep's own stderr is discarded so an engine error message can never echo a pattern. Public layer: entries are managed IN the existing `banned_tokens` family of `.abcd/docs-lint.json` under a `names/` id prefix, and the prefix is the ownership boundary — `list` renders the whole family (hand-curated harness/present_tense entries included, marked as such), `remove` refuses anything outside the namespace. Config edits are byte surgery on the array located through the standard decoder's input offsets, never a re-marshal: an add is one inserted line plus a separating comma, a remove is one deleted line, and add-then-remove is byte-identical to the original — asserted against this repo's own docs-lint.json rather than a synthetic fixture, because a surgical editor proven only on a two-entry toy is not proven. Redaction is structural, not conventional: the exported private entry type has no pattern field at all, so no future rendering can leak the value, and pattern-validation errors discard the engine's message because Go's regexp errors quote the expression. One format, two readers, one fixture: `testdata/parse-corpus.txt` and one shared probe table drive both the Go parser and the committed shell hook, so their agreement is checked rather than assumed. Residual: the two engines are RE2 and the platform's POSIX ERE, so a pattern valid in one and not the other is possible — the shared corpus is restricted to constructs both accept, and the malformed-line path fails safe on either side. Deferred to later increments: `ahoy` scaffolding of the guard artefacts and the seeded stub (AC5), the honest-reach line on the status/report surfaces (AC7; `abcd banlist` itself states it), and the intent/spec lifecycle moves.
- dcn-26073000000000042026-07-30 — itd-74 (round 6), fix pass after two adversarial reviews (correctness + security) both returned BLOCK. Five decisions, each replacing a mechanism rather than patching an instance. (1) THE STORE DECLARES ITS FORMAT. The private banlist's first line — `# abcd-banlist: keyed` — decides the whole file: keyed means every line must parse as `KEY<space-or-tab>PATTERN`, no declaration means every line is one whole-line pattern under `entry-<line-number>`, and no line is ever split. The previous per-line heuristic ("is the first field key-shaped?") was wrong in two independent ways at once: it printed part of a legacy line as a key, and on this layer a pattern IS the secret, so the guard leaked what it existed to withhold; and it narrowed an old whole-line pattern to the remainder after its first field, so protection did NOT "never weaken because the format grew a column" — the earlier record line and the brief both claimed otherwise and were wrong. A declaration costs the user one line and makes both classes unrepresentable; `add`/`remove` refuse a non-empty legacy store rather than migrate it, because writing a keyed line in would silently reinterpret every other line. Both readers now strip ASCII space and tab only: `strings.TrimSpace` and bash `[[:space:]]` are different sets, so a U+00A0-indented line was keyed by one reader and dead to the other, and a U+000B-separated line the reverse. (2) VALIDATE AGAINST THE ENGINE THAT ENFORCES, not a convenient third one. A private pattern is screened for the constructs POSIX ERE does not implement (a backslash before an alphanumeric, `(?`) and then handed to grep ITSELF on stdin to accept or refuse; RE2 is the fallback only when grep cannot be run, and the refusal says so. Checking under RE2 accepted `\d` and `(?i)` as healthy (grep reads them as something else — inert protection reported live) and accepted `[a-z-.]`, which grep refuses (its fail-safe branch then blocks every commit). The public layer's engine is Go's regexp because `abcd docs lint` enforces it, so a public add compiles the EXACT string it stores through the linter's own compile path, and stores it with the `(?i)` prefix all sixteen hand-curated entries carry — without it a verb-written entry was case-sensitive while the docs promised otherwise. `list --private` reports unusable and inert lines APART: the first stops every commit, the second stops nothing, and one message for both misdirects an incident. (3) THE GUARD READS STAGED BLOBS, not diff text, and fails closed. Four shapes staged a banned name that no diff-text reading could see: a content line beginning `++` becomes `+++` and is dropped with the headers, a NUL-bearing blob has no textual diff at all, a committed `.gitattributes` with `-diff` disables the reading repo-wide in one line, and a rename is status R which the `ACM` filter excluded (as it excluded T). `git show :<path>` over `--name-only -z --diff-filter=ACMRT` asks the question the guard is actually asking and has no shape to route around. Every git step is checked, replacing a `|| true` that turned any failure into a clean pass: a check that could not run must never be indistinguishable from one that passed. (4) CONTAINMENT IS THE WRITE PATH'S JOB. Reads and writes resolve through `os.Root`, so a symlinked `.abcd/.work.local` cannot land the private patterns outside the repo while the verb reports the in-repo path; the verbs resolve the repo root as the rules loader does rather than trusting cwd, which had created a second nested store that the root-anchored gitignore does not match and the guard does not read; `add --private` refuses when git does not ignore the store path, because the layer's whole safety is that the file is untracked and the guard cannot catch its own source; and both stores hold the shared flock across load-modify-write. (5) A PATTERN NEVER TRAVELS IN ARGV. The hook passes it to grep via `-f -` on stdin, the verb accepts `-` to read one line from stdin (the documented form), and a flag-parse failure withholds the offending token instead of quoting it. Accepted trade on that last point: `SetInterspersed(false)` was rejected because the documented `add --private KEY "PATTERN" --json` puts a persistent flag after the positionals; a flag-error surface closes the leak without breaking it, and a test pins the trailing flag. Residual, stated rather than fixed: the guard still trusts an out-of-repo `sync-banlist` executable it neither verifies nor sandboxes, and a garbling refresh is only partly caught (the zero-entry warning); and the empty-`$toplevel` branch is guarded but not test-covered, because git resolves the repo before invoking a hook.
- dcn-26073000000000052026-07-30 — itd-74 (round 6), SECOND fix pass after two fresh adversarial reviews returned BLOCK on the first fix pass's OWN code. Detector-first throughout, each fix reverted in place and watched to fail before it passed. The guard's staged-content read was the cluster: it now reads each staged blob stage-explicitly (`git show ":0:$path"`, closing the `0:README.md` rev-magic bypass), derives staged modes from `git diff --cached --raw -z` so a gitlink (mode 160000, no blob here) is SKIPPED rather than fail-closing every commit forever, scans the staged PATH strings alongside content so a banned name in a filename is refused by key, refuses any staged path under the local tier (the private store must never be committed, and the guard cannot catch its own source), and announces the format and entry count it actually read before the scan so a stripped `# abcd-banlist: keyed` line cannot silently downgrade every keyed entry to a non-matching whole-line pattern. The verbs: `add` now proves the composed `KEY<space>PATTERN` line round-trips (a whitespace-only pattern wedged the store as `key `; leading/trailing whitespace was silently trimmed so the enforced pattern differed from the validated one) and rejects a NUL the two readers disagree on; the stdin path reads all of stdin and refuses trailing data rather than storing the first of a multi-line pattern silently; the verb resolves the git working-tree toplevel — the exact root the guard enforces at — so a repo nested under a parent holding a .abcd/ no longer writes its store into the parent where the guard never reads it; no cobra path echoes an unknown token (a would-be private value); the inert verdict is driven by the grep probe, not a static screen that falsely called `\b`/`\w`/`\s` "matches nothing" (GNU grep implements them); `remove --private` deletes ALL lines for a key (a duplicate no longer survives under a key the report calls gone) and `remove --public` no longer lets a bare hand-curated key shadow the managed `names/<key>` target it owns; the read path reports a store git does not ignore; and a mutation's entry count excludes unparseable lines so it agrees with `list`. Records corrected: the brief and this ledger had the RE2-vs-ERE divergence backwards — Go ACCEPTS `[a-z-.]` while grep -E refuses it (exit 2), the opposite of the earlier wording; engine.go was already right. Residual, recorded not fixed: the fsutil lock files are 0644 and never unlinked (a shared primitive; the banlist lock lives in the 0700 gitignored tier and the file is empty), a public-only add still creates the gitignored local-ephemeral tier to place its lock (nothing leaks; relocating hits the atomic-rename-inode problem), the `.abcd`-symlink asymmetry and a hand-written store's 0644 mode; and the out-of-repo `sync-banlist` trust and the empty-`$toplevel` branch carry over from the first pass. `RemovePrivate` surfaces the not-ignored condition only via the shared read path (list/bare), since `PrivateResult` carries no health field.
- 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-26080200000000092026-08-02 — iss-41 (v0.5.0 item D): the interim delivery-state rule is stated as the one the lifecycle already implies — **delivered capability is represented by the intent leaving `drafts/`, and nothing else says it** — because the alternative on offer (a new tier, or a per-intent "partly shipped" field) invents lifecycle vocabulary to describe a citation problem. The ledger's premise that `shipped/` is empty has aged out: thirteen intents live there now, so the mechanism exists and what was missing was the gate holding the changelog to it. `delivery_state` reads each version entry's delivery sections (`Added`, `Changed`; a repo's configured list is unioned with those, never substituted for them) and blocks an `itd-N` citation whose intent is still in `drafts/`. Scoping by section rather than by whole entry is what keeps it precise: the ids under `Fixed` in this corpus are provenance for a defect — which draft two branches minted at once — and blocking them would force either a falsehood or a deletion of real information. It fails closed three ways: no changelog, no intents store, and a store holding no lifecycle bucket — the last being a root pointed one level too deep, which resolves every citation to nothing and reads exactly like a clean corpus. The corpus had TWO instances, not the one filed: itd-60, whose deterministic layer shipped as `abcd docs lint` while its semantic layer is still five open questions, and itd-85, which shipped WHOLE as `abcd audit` and was simply never promoted. They get different treatments because they are different facts. itd-60 stays in `drafts/` and loses the citation — an intent is delivered whole or not at all (split-the-intent doctrine), so citing a partial delivery reads as a promotion that never happened, and splitting itd-60 to make the citation true is editorial work this issue does not carry. itd-85 loses the citation too, but under protest: the honest fix is promotion, and promotion is blocked on the `shipped/` schema wanting a non-null `spec_id` the audit verb has never had. Filed as iss-180 rather than papered over or forced — writing a retroactive spec to satisfy a lint is the failure mode this repo keeps naming. The out-of-scope half is a REUSE, not a new rule: `index_drift` gained one field, `dir_entry`, the mirror of `entry` on the directory side, which reduces a filename stem to the id a document actually enumerates. Without it a hand-written listing can only agree with a directory by transcribing whole slugs, which is a listing nobody writes, and the alternative was a second rule shaped exactly like the first. The list itself had drifted nineteen entries while claiming in its own prose to be derived and "not hand-counted": itd-47, itd-73 and itd-74 had left `drafts/` for three different buckets, and sixteen captures were missing. Regenerated, wrapped in the marked region, and the sentence promising hand-kept lockstep is replaced by the gate that enforces it. The four later-phase items that never had an intent id move OUT of the derived list — a derivation cannot produce them, so their presence was what made the claim false in the first place.
- dcn-26091500000000032026-09-15 — Banned names get both halves (product thinker, ruling on iss-2609100506269348, choosing "both" once told a home list is invisible to CI): a committed declaration lifts the public visibility fence so a fresh public repository can create its committed banned-names layer on its first commit, and a machine-global private list in the user-level home bans a name in every repository on the machine. CI never reads the home list and nothing from it reaches a committed file. Filed as itd-2609151516525843 (`builds_on` itd-74, `refines` adr-56); whether the home is one macOS user's or shared across users stays the product thinker's separate open question. Rejected: move the banlist config out of the hidden directory; make the public list private-visibility only.
- 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.
- examples-use-reserved-identifiersExamples use reserved identifiers
- iss-803-out-of-scope.md omits current drafts itd-74 and itd-75 while including scoped itd-43; regenerate from the actual draft corpus.
- 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-159hazard registry has no entries for destructive GitHub remote operations: 'gh repo delete' and 'gh api -X/--method DELETE repos/{owner}/{repo}' both verdict allow. Field evidence: the user-level guard-bash.sh hook blocks both (repo deletion is human-only; second layer behind the missing delete_repo scope) — that hand-wired hook is the prior art to lift into the bundled registry defaults, same prototype-to-product path as itd-74. Registry entries should mirror the hook's nuances: quote-tolerant token matching (already native), depth-limited api paths (DELETE on deeper repo subpaths stays allowed), and the block message naming the human-only successor.
- 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-177ahoy install reports every written artefact as an absolute filesystem path, so an install receipt pasted into an issue or a transcript carries the developer's home directory and username
- iss-178Autonomous cloud runs in sibling repos leaked harness attribution footers and a live session URL into public GitHub artifacts: the harness auto-appends a 'Generated with' footer plus a session link when a PR or issue is created, overriding the repos' Assisted-by-only attribution policy (commit messages stayed clean; the leak surface was PR bodies and issue comments, plus GitHub's public edit history retaining the pre-scrub revision). Leak shape only - no session ids reproduced here. Two remedies needed: (a) every autonomous routine prompt must ban session URLs and harness footers in public text AND mandate a post-create re-read-and-strip of every PR/issue/comment the loop creates, because the append happens outside the model's own text; (b) abcd should detect the class - session-URL and harness-footer patterns belong with the shared privacy pattern set (iss-154 family / itd-74 banlist territory) so audit and docs-lint flag them in any committed or posted text.
- 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-2609261457353277itd-74 ac-5 promises the public family present after ahoy scaffolding; where the docs-lint config path is gitignored (TestPublicFamilyUnderPublicVisibility) install writes no public family and reports the gap as unresolvable, leaving that repo with no CI-enforced banned-names family
- iss-2609261457358637itd-74 ac-1 promises the public banlist gates README, docs/ and the shipped artefact; the banned_tokens family walks only the docs-lint roots (docs, README.md) and the payload render reuses those roots, so a banned public token in commands/, agents/ or skills/ ships ungated
- iss-2610021502084298No check meets the retired role word in an intent's press release at the record: the docs-lint rule roles/retired-role-word roots at the docs, the command pages and the rules, so a shipped or planned intent's press release carrying it is refused only when the site composer renders it as the landing page's hero quote, which is how itd-60's first MET audit broke the site-render gate (iss-2610021446271464). After the R5 reword of itd-60, thirteen shipped press releases still carry the word (itd-65, itd-66, itd-67, itd-69, itd-73, itd-74, itd-93, itd-102, itd-111, itd-114, itd-125, itd-2609111003026787, itd-2609212137129937), so the next MET audit ingested for any of them refuses the render again on a branch that only recorded an audit; the product thinker's ruling R5 (2026-10-02) accepted that a future slip breaks the build and did not ask for this check. Related to iss-2610020731591808, which names the brief, the principles and the personas registry as swept but unguarded.
- itd-1Every Intent Has Acceptance Criteria; Every Audit Verifies Them
- itd-77abcd's User-Level Home Lives Where You Want It
- itd-78abcd Knows What to Build First — Even When It's Something Small
- itd-79Personas Come From the Registry; the Role Picks the Name
- itd-152Autonomous cloud runs in sibling repos leaked harness attribution footers and a live session URL into public GitHub artifacts: the harness auto-appends a 'Generated with' footer plus a session link when a PR or issue is created, overriding the repos' Assisted-by-only attribution policy (commit messages stayed clean; the leak surface was PR bodies and issue comments, plus GitHub's public edit history retaining the pre-scrub revision). Leak shape only - no session ids reproduced here. Two remedies needed: (a) every autonomous routine prompt must ban session URLs and harness footers in public text AND mandate a post-create re-read-and-strip of every PR/issue/comment the loop creates, because the append happens outside the model's own text; (b) abcd should detect the class - session-URL and harness-footer patterns belong with the shared privacy pattern set (iss-154 family / itd-74 banlist territory) so audit and docs-lint flag them in any committed or posted text.
- itd-162prepare-this-repo adopt phase is not self-contained in the abcd record: Phase 3-5 reference templates at a machine-local path outside both the abcd repo and the target (pre-commit-config.yaml, prepare-commit-msg, AGENTS.md, DECISIONS.md, NEXT.md). A fresh clone of abcd-cli onboarding a repo would not have them, so the adoption step silently degrades against loud-staging. Detector: an onboarding self-containment check -- every asset the adopt phase applies resolves from within the abcd record or the binary, never an external machine-local path. Acceptance: the Phase 3/4/5 template references in prepare-this-repo.md.
- spc-31source-provenance-ledger