Development record
development-instructions-live-in-the-right-tier-abcd-manages
Summary
spc-23 delivers itd-117: a user-scope rules layer at ~/.abcd/rules.json that
composes between abcd's plugin-bundled default domains and each repo's in-tree
.abcd/rules.json. Machine-wide house conventions are declared once; a repo
override still wins; a machine without the file behaves exactly as today.
Scope
- Core (
internal/core/rules):Loadgains a user-scope read and one more merge step —Merge(Merge(Defaults(), user), repo). The user file passes the same trust guards as the repo file, and its layer of origin is recorded so a surface can render provenance. - Surface:
abcd rulesshows which layer each domain came from. - Not in scope: any directory-designated workspace or repo-grouping layer.
There is no
workspaces.jsonand none is introduced — itd-40's no-grouping stance stands untouched. Duplication detection between layers and migration of harness memory are deferred to follow-up intents (recorded in itd-117's Open Questions).
Approach
The existing loader already has every primitive this needs, so the change is composition rather than new machinery:
readGuarded(path, maxRulesFileBytes)(rules.go) opens read-only withO_NOFOLLOW, refuses non-regular leaves, and caps at 256 KiB in a single open — reused verbatim for the user path, with the.abcdsymlink pre-check applied to~/.abcdexactly as it is to the repo's.Merge(base, over)is per-field with a sticky kill switch (out.Disabled = base.Disabled || over.Disabled). Applying the repo layer last therefore satisfies AC3 (repo wins per field) and AC7 (a repo'sdormantstate or kill switch survives a user layer that declares the domain active) without new merge semantics.- Consequence worth recording: stickiness runs both ways — a user layer
setting
"disabled": truesilences abcd's rules on that machine and no repo can re-enable it. That is the fail-safe direction (a kill switch that can be overridden downward is not a kill switch), and it is documented rather than worked around. - Absence is the default:
os.IsNotExiston the user path yields the bundled defaults unchanged, so AC1's byte-identical guarantee falls out of the same branch the repo path already uses. - The user scope's location comes from the existing user-scope resolution
(
~/.abcd/), which the brief already blesses for machine-local shared state — no new path concept is introduced.
Correction (2026-09-25). The first bullet above says the .abcd symlink
pre-check applies to ~/.abcd exactly as it is to the repo's. It does not,
and deliberately: the repo layer refuses a symlinked .abcd unconditionally,
while the user layer refuses a symlinked ~/.abcd only when a rules.json
sits behind it, and spares a symlinked ~/.abcd with no file, which reads as
absent. An unconditional refusal would change what a machine whose ~/.abcd is
a dotfiles symlink injects, against AC1. The consequence is that a
dotfiles-symlinked ~/.abcd can never host a rules.json. The user file is
read through fsutil.ReadDeclaration rather than the repo's readGuarded, and
a HOME or ~/.abcd this account cannot search reads as absent, as it does for
the other home-scoped declarations.
Acceptance-criteria satisfaction
- AC1 (absence is free) — the
os.IsNotExistbranch returns the prior result; test asserts a deep-equal ruleset againstDefaults()and that no file or directory is created. - AC2 (user overrides bundled) —
Merge(Defaults(), user); test sets one field in a bundled domain and asserts the injected value. - AC3 (repo overrides user) — repo layer applied last; test sets the same field at all three layers and asserts the repo value.
- AC4 (custom user domain) —
Mergeadds unknown domain keys; test declares a domain only in the user file and asserts it recall-matches in a repo that never mentions it. - AC5 (loud refusal) — the
ELOOP/errNotRegular/errTooBig/ invalid-JSON branches are mirrored for the user path with user-scope-specific messages; test asserts each is a hard error, never a silent fallback to defaults. - AC6 (provenance) — each domain records its originating layer through the
merge;
abcd rulesrenders it, and the CLI test asserts the label for a domain overridden at each level. - AC7 (repo suppression wins) — covered by ordering plus kill-switch
stickiness; test asserts a repo
dormantbeats a user-active domain, and that a repo kill switch suppresses everything.
Known limitations
mergeDomain replaces a domain's fields wholesale — a partial rules-array
override is not expressible, and a third layer makes that grain more visible.
Recorded in itd-117's Open Questions as a known limitation, not fixed here.
Frontmatter
| id | spc-23 |
|---|---|
| type | spec |
| lifecycle | closed |
.abcd/development/specs/closed/spc-23-development-instructions-live-in-the.md
open on GitHub ↗ · commit history ↗
References1
- itd-117A Machine's Shared Conventions Are Declared Once In The User Scope — And Every Managed Repo Inherits Themimplements
mentions7
- iss-2609012039215212GHSA-22f8-qf5r-gjgq (CWE-451, CWE-1427): a `.abcd/rules.json` override replaces a bundled domain's rules wholesale (`mergeDomain`, internal/core/rules/rules.go) and nothing downstream records where the words came from — `ResolvedDomain` carries no origin, `renderDomain` emits name and bullets only, the prompt-router diagnostic prints names, and `abcd rules --json` marshals the same origin-less shape — so a hostile or compromised checkout injects attacker-chosen text under `## PII` and the tool's own `# abcd rules` header, byte-indistinguishable from a bundled default in every consumer-visible surface. Reproduced at v0.7.0. The fix must derive provenance at merge time with no rules.json schema change: a `source` field on `ResolvedDomain` valued `bundled` or `repo` (leaving room for `user`, spc-23 AC6), the rendered heading `## NAME (repo override)` for a repo-sourced domain so the marker lands in the agent's context, in `abcd rules` and inside the dedup signature, the source per injected name on the hook's stderr diagnostic, and the field in `rules --json`. The marker moves `Signature` for overridden domains only: a one-time re-injection on the first post-upgrade prompt. Named as out of scope: the guard registry merge (internal/core/guard/config.go) has the same provenance-less shape for `.abcd/guard.json` overrides, but its surface is a block/allow decision, not injected prose. Siblings captured separately: `"rules": []` injecting a heading-only block, and a stop-word recall keyword firing on ordinary prose. iss-174 (an override withholds bundled upgrades) stays open and distinct. Advisory severity medium.
- iss-2609020219198779The rules-root bound from GHSA-vvqc-3mv2-5p49 does not close the user-scope ~/.abcd when the home directory is itself a git working tree (dotfiles-in-home). ResolveRoot bounds the walk at the toplevel, and for a session in a non-repo directory beneath a version-controlled home that toplevel IS the home, so the home-scope rules.json and guard.json still govern the session: injected rules, the kill switch and the guard registry all come from a file outside any project. Cross-UID /tmp is NOT closed either, contrary to what this record first said: git refuses on ownership and the .git-marker fallback then resolves the planted tree's OWN root, which is precisely what a foreign-uid `git init` in a shared directory wants — that residual is recorded separately as iss-2609020259564193 and needs an ownership policy. What is closed is the one-command plant: a bare `.git` file or an empty `.git` directory no longer bounds the fallback, because the marker must now look like a repository (a `.git` directory carrying HEAD, or a `.git` file beginning "gitdir: "). A version-controlled home and a foreign-uid repository both remain open. Evidence: ResolveRoot in internal/core/rules/root.go. The fix needs a decision first — whether a home-directory toplevel is a legitimate configuration scope (spc-23 plans a user layer that would make it one) or must be excluded outright — so nothing is changed here; the doc comment now states the exact scope and names this record.
- 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-3abcd Loads Its Own Rules On Demand
- itd-4Nothing You Notice Gets Lost
- itd-40abcd Knows What Kind of Folder You're In