Documentation
¶
Overview ¶
Package rules is abcd's transport-agnostic modular rules loader (itd-3). It owns the whole capability behind two front doors: the `abcd rules [domain]` CLI verb and the Claude Code prompt-router hook. Nothing here writes to stdout or knows about a harness event — the front doors under internal/surface and the hook entrypoint marshal these results for their transport.
The model is a small set of binary-bundled default domains (embedded below, plus SHELL, generated from the guard's hazard registry in shell.go, and GRILL, generated from the asking rules in grill.go) merged with two optional override layers, in order: the user scope's ~/.abcd.noindex/rules.json (one per machine, spc-23) and then the per-repo <repoRoot>/.abcd/rules.json, so the repo wins a field both set. Each domain carries recall keywords + aliases and a list of rules; a prompt is recall-matched against the active domains and only the matching rules are rendered for injection. A leading *<DOMAIN> star-command activates a domain unconditionally (overriding a dormant state, but never the top-level kill switch).
Validation is hand-rolled Go (zero new dependencies): a rules.json that fails to parse or validate is a fail-closed error the caller surfaces loudly — it never silently degrades to zero injection.
Index ¶
- Constants
- Variables
- func Label(name, source string) string
- func LayerPath(source string) string
- func LoadBackstop(repoRoot string) int
- func PruneState(maxAge time.Duration)
- func Render(domains []ResolvedDomain) string
- func ResetState(session string) error
- func ResolveRoot(cwd string) string
- func SaveState(session string, st SessionState) error
- func Signature(d ResolvedDomain) string
- func SwapUserHomeForTest(fn func() (string, error)) (restore func())
- func Validate(rs RuleSet) error
- type Domain
- type InjectResult
- type Resolution
- type ResolvedDomain
- type RuleSet
- type SessionState
Constants ¶
const ( StateActive = "active" StateDormant = "dormant" )
Domain state values. An empty string is treated as active.
const ( SourceBundled = "bundled" SourceUser = "user" SourceRepo = "repo" )
Source labels for ResolvedDomain.Source: where a domain's effective content came from. An override that names a domain — replacing its rules, changing its state, or declaring it outright — makes the domain that override's layer, conservatively: the effective behaviour is that layer's choice even when only the state moved. The LAST layer to name a domain labels it, so a domain the user scope and the repo both name is SourceRepo.
const DefaultRefreshBackstop = 15
DefaultRefreshBackstop is the fixed-N full-refresh backstop (D1): the primary refresh is event-driven (a SessionStart/PreCompact reset clears the ledger), and this large counter only catches always-relevant domains that never recall-match. It is deliberately larger than CARL's every-5.
const GrillDomain = "GRILL"
GrillDomain is the bundled domain that carries the asking rules every abcd interview follows (itd-201; spc-2610030944505997, "GRILL generated from one Go source"): one thing at a time, the thing being decided quoted in full, the question's layout and its limits, options that widen and never recommend, and deferral recorded as an answer.
The domain is GENERATED from internal/core/question, never written in defaults/rules.json: its rules are question.AskingRules(question.Default), the same Limits value the question check in the guard hook enforces, and its recall is question.AskingRecall. A limit changed there changes the rule text with no second edit (itd-2610030810350727 criterion A7), and TestGrillDomainIsGeneratedFromTheAskingRules fails if the two ever part. It reaches every repository abcd manages through the binary, as SHELL does, and is an ordinary bundled domain to every loader contract: per-field user and repository overrides, dormant (a repository silences it with {"GRILL": {"state": "dormant"}}), the kill switch, the *GRILL star command, dedup and provenance.
const RepoRelPath = ".abcd/rules.json"
RepoRelPath is the per-repo override file, relative to the repo worktree.
const ShellDomain = "SHELL"
ShellDomain is the bundled domain that carries itd-103's teaching plane (spc-16, "Two planes, one registry"; iss-151, ruling J10): the shell-hazard registry's lessons, injected before shell-heavy work so an agent is taught the safe form up front. A host without hook support gets this plane alone; a host with hooks gets it and the guard.
The domain is GENERATED from the bundled registry the guard reads, never written in defaults/rules.json: one rule per registry entry (the entry's Lesson, in id order) and one recall term per command head the registry matches. An entry added to or removed from the registry changes the domain with no second edit, and TestShellDomainIsGeneratedFromTheGuardRegistry fails if the two ever part. It is an ordinary bundled domain to every loader contract — per-field user and repo overrides, dormant, the kill switch, the *SHELL star command, dedup and provenance — and a guardrail domain to noteWithheld.
The bundled defaults carry the domain generated from the bundled registry. Load regenerates it, by the same generator, from the registry the guard enforces in the repository — the bundled entries merged with the repository's own .abcd/guard.json (ruling CK1) — so a repository's own hazards are taught as the bundled ones are, each such lesson marked guard.RepoMark. The switches stay independent (spc-16, "Config home"): the guard file decides what is refused, and rules.json overrides, silences or kills the teaching of it like any bundled domain's.
const StateTTL = 7 * 24 * time.Hour
StateTTL bounds how long a per-session ledger lives before the reset hook sweeps it (sessions are one-shot; a week is generous headroom).
Variables ¶
var TrustedRootsDisplay = abcdhome.Display("trusted-roots")
TrustedRootsDisplay is how that file is NAMED in a diagnostic: the tilde form, never the expanded path, so a refusal a user pastes into a shell works and no message carries the developer-identity home path (iss-81, fsutil.RedactHome).
var TrustedRootsRelPath = abcdhome.Rel("trusted-roots")
TrustedRootsRelPath is the home-scoped opt-in that re-admits a marker root the caller does not own, relative to the user's home directory.
var UserDisplayPath = abcdhome.Display("rules.json")
UserDisplayPath is how the user-scope file is NAMED in a diagnostic: the tilde form, never the expanded path, so no message carries the developer-identity home path (iss-81, fsutil.RedactHome) and a refusal a user pastes still names the file.
var UserRelPath = abcdhome.Rel("rules.json")
UserRelPath is the user-scope override file, relative to the home directory: the machine's own conventions, layered between the bundled defaults and every repo's override (itd-117, spc-23).
Functions ¶
func Label ¶ added in v0.11.0
Label is a domain name as every provenance surface prints it: bare for a bundled domain, "NAME (user override)" or "NAME (repo override)" for one an override layer named. The rendered heading and the hook's diagnostic both use it, so the two agree byte for byte.
func LayerPath ¶ added in v0.11.0
LayerPath is the display path of the file a layer's source label names: UserDisplayPath for SourceUser, RepoRelPath for SourceRepo, and "" for the bundled defaults, which have no file.
func LoadBackstop ¶
LoadBackstop reads rules.force_refresh_every_n from <repoRoot>/.abcd/config.json — the fixed-N full-refresh backstop (D1). Event-driven reset is the primary refresh, so this only bounds always-relevant domains; a missing file/key or a non-positive value falls back to DefaultRefreshBackstop.
func PruneState ¶
PruneState removes session-state files older than maxAge, bounding the growth of the temp state dir across many sessions. Best-effort housekeeping: a missing dir or a per-entry error is ignored.
func Render ¶
func Render(domains []ResolvedDomain) string
Render is the single renderer both front doors use. It emits a header plus one section per domain. An empty domain list renders to zero bytes (D3: no model-facing tokens on a no-match).
func ResetState ¶
ResetState clears a session's ledger (the event-driven refresh: the next prompt re-injects every matching domain). A missing file is not an error.
func ResolveRoot ¶ added in v0.7.1
ResolveRoot resolves the repo root the per-repo configuration under .abcd/ is read from — rules.json and config.json here, guard.json for the shell guard, which shares this resolver so a session's rules and its guard can never come from two different places.
The resolution is bounded at the git working tree (GHSA-vvqc-3mv2-5p49). The toplevel git reports for cwd is resolved FIRST; the walk then runs from cwd upward but stops at that toplevel inclusive, so the nearest .abcd directory INSIDE the tree wins (a repo that disabled a domain, or the whole loader, stays disabled from any nested directory — iss-66/B12) and a tree with no .abcd resolves to its own toplevel. A .abcd above the working tree is never reached: an unbounded walk let a directory planted in the shared temp dir govern the injected rules and disarm the guard of every repository beneath it. The per-repo file is a repo-scope setting by design (the configuration chapter and itd-3 both reject a global rules.json), so the bound is the documented contract, not a new policy.
The exact scope of what that closes: any ancestor a session's own working tree sits inside; a plant in a shared directory that is not a repository — the one-command `: > /tmp/.git` or `mkdir /tmp/.git` beside a planted /tmp/.abcd, which the fallback below refuses because a marker must look like a repository; and a REAL repository planted in a shared ancestor by another uid (`git init /tmp`, or a repository laid in a root-owned mode-1777 directory), which the fallback refuses on OWNERSHIP — see foreignOwnerRefusal, and TrustedRootsRelPath for the explicit opt-in that re-admits a foreign-uid checkout the caller means to trust.
The home directory is never a repo root (iss-2609020219198779). Its .abcd is the USER layer (spc-23), and a home that is itself a git working tree (dotfiles-in-home) would otherwise hand every non-repo directory beneath it the home's .abcd a second time, as the repo layer — its guard.json with it, which has no user layer at all, and its config.json as the repo layer over itself as the machine layer. So the walk passes over the home, a toplevel that IS the home takes the non-repo route (cwd, no walk) when nothing nearer carries a .abcd, and a toplevel that CONTAINS the home — a hermetic harness pointing HOME inside its checkout — stays the root, because it is a repository git vouched for and not the home. What this does not reach is a session whose working directory IS the home: the root is then cwd, as it is for any non-repo directory, and a .abcd at the working directory is read. That is the working-directory read the refusal below also leaves standing; making it refuse is a posture change, not a bound on the walk (iss-2609251522588539, and its entry of 2026-09-25 in .abcd/work/DECISIONS.md).
"Not a repository" and "a repository git will not answer for" are DIFFERENT outcomes and only the first resolves to cwd with no walk. abcd runs git under an isolated environment (GIT_CONFIG_GLOBAL=/dev/null, GIT_CONFIG_NOSYSTEM=1) which also discards the developer's safe.directory exceptions, so rev-parse fails inside a checkout owned by another uid; it fails outright when the host launches the hook with git off its PATH. Collapsing those onto cwd dropped the repository's own configuration layer with no error anywhere: from a subdirectory the root became the subdirectory, so guard.json's hazards became allows, the rules kill switch stopped applying, and the private banlist store went missing. A repo-shaped tree therefore falls back to the .git marker (gitutil.RepoShapedRoot), and only a directory with no repository above it keeps the no-walk cwd: outside a working tree there is no boundary to stop at, and a walk that stops nowhere is the defect.
The marker walk is a NAME check that runs to the filesystem root, so it is not by itself a bound: an unprivileged `: > /tmp/.git` would otherwise hand every session in a plain directory beneath /tmp to a planted /tmp/.abcd, git having refused to answer for a directory that is not a repository. The fallback root is accepted only when its .git is a PLAUSIBLE repository — a directory carrying HEAD, or a regular file beginning "gitdir: " (plausibleRepository below, which states the shapes exactly) — and anything else takes the non-repo route: cwd, no walk, nothing above it consulted. That discriminator is what git reads, not a trust boundary on its own: laying out a real repository still passes it. The trust boundary is the ownership gate that runs next — a marker root whose owner is not the caller is refused, loudly, unless the caller has declared it in the user-scope home (iss-2609020259564193). Both bounds apply only where git will not answer; where it does, the toplevel it named stands whoever owns it.
A nested repository or submodule resolves its own toplevel either way and does not inherit the superproject's .abcd — correct scoping, visible to submodule workflows.
func SaveState ¶
func SaveState(session string, st SessionState) error
SaveState durably persists the ledger for a session through the canonical fsutil.WriteFileAtomic (temp-write, fsync, rename, parent-dir fsync), so the write is crash-safe and a reader sees either the old file or the complete new one. The state dir is pre-created 0700 (WriteFileAtomic's own MkdirAll is a no-op once it exists); the session file is 0600.
func Signature ¶
func Signature(d ResolvedDomain) string
Signature is the per-domain dedup key: an FNV-1a hash of the rendered block, so identical rendered content (defaults or override) dedups and any content drift invalidates. FNV is sufficient — this is dedup, not security.
func SwapUserHomeForTest ¶ added in v0.11.0
SwapUserHomeForTest substitutes the home lookup Load reads the user layer through and returns the restore. It is exported because the front-door tests that load rules live in another package. Tests only; never called in production code, and never safe to call from a parallel test.
func Validate ¶
Validate checks structural invariants: schema_version == 1, every domain name matches [A-Z][A-Z0-9_]*, every state is active/dormant (or empty), every domain carries at least one rule, and no rule body is empty or whitespace-only. An empty rule body otherwise passes here and renders as a bare contentless "- " bullet in the injected block (iss-2608261550497978), and a domain with no rules at all — an override of {"rules": []}, or a custom domain declared without any — renders as a heading-only "## NAME" block, suppression wearing the domain's name (GHSA-22f8-qf5r-gjgq sibling). Neither silent shape is acceptable; {"state": "dormant"} is the way to silence a domain.
The ruleless-domain rule is a REFUSAL here and a per-domain skip in Load. It refuses here because Validate is what guards the bundled defaults, where a heading-only domain is a build error. Load reaches Validate with the ruleless domains already dropped and a note naming each (see dropRulelessDomains), because failing a repo's whole file — every domain, on an upgrade — is not proportionate to one malformed entry.
Types ¶
type Domain ¶
type Domain struct {
State string `json:"state,omitempty"`
Recall []string `json:"recall,omitempty"`
Aliases []string `json:"aliases,omitempty"`
Rules []string `json:"rules,omitempty"`
}
Domain is one keyed rule domain. The map key in RuleSet.Domains is its name; the name is therefore not a serialized field on the value.
type InjectResult ¶
type InjectResult struct {
Text string
Injected []string
Active []string
Sources map[string]string
State SessionState
}
InjectResult is the outcome of one prompt-router evaluation. Text is empty when nothing new is injected (a healthy no-match renders zero model-facing tokens, per D3). Sources says, per injected name, which layer the domain came from (SourceBundled, SourceUser or SourceRepo): the names alone cannot, and the out-of-band diagnostic must say whose words went into the context.
Active is the FULL set of domain names in force this turn, name-sorted and never nil (ruling J15, iss-2608261550580260): every domain that is not dormant, plus a dormant one this prompt activated with *NAME, and nothing at all under the kill switch. Injected names only what changed; Active is the removal signal a client that snapshots injected rules prunes against — a name it holds that Active omits has stopped, whether it was deleted, renamed or made dormant. It is never rendered into Text, so it costs the model's context nothing; a front door hands it to a machine reader out of band.
func Inject ¶
func Inject(rs RuleSet, prompt string, prev SessionState, backstop int) InjectResult
Inject is the pure heart of the prompt-router hook: it recall-matches prompt, drops any matched domain already injected this session with an unchanged signature, renders the remainder, and returns the updated session state. It performs no I/O and never reflects prompt bytes into the output — the rendered text is abcd's own rule content only.
backstop is the fixed-N full-refresh interval; <= 0 uses DefaultRefreshBackstop. When the (incremented) prompt count is a multiple of the backstop, the ledger is cleared first so always-relevant domains re-inject.
func (InjectResult) Labels ¶ added in v0.7.1
func (r InjectResult) Labels() []string
Labels returns the injected names in order, each labelled the way the rendered heading is — "PII (repo override)" for a repo-sourced domain, "PII (user override)" for a user-scope one, the bare name otherwise — so the diagnostic and the block agree byte for byte.
type Resolution ¶ added in v0.8.0
Resolution is ResolveRoot's answer together with the diagnostics the resolution produced. Notes is empty in the ordinary case; a non-empty entry is a refusal or a downgrade a front door MUST print out of band (stderr, never the injected context), because a resolution that silently declined to read a repository's own configuration is the exact shape the bound exists to prevent (.abcd/development/principles/loud-staging.md).
func Resolve ¶ added in v0.8.0
func Resolve(cwd string) Resolution
Resolve is ResolveRoot with its diagnostics kept. ResolveRoot is the shorthand for the callers that have nowhere to print them.
type ResolvedDomain ¶
ResolvedDomain pairs a domain with its name for ordered rendering and dedup, and with its Source so every consumer can say whose words these are (GHSA-22f8-qf5r-gjgq): the renderer marks a repo-sourced heading, the hook diagnostic labels the name, and `rules --json` carries the field.
type RuleSet ¶
type RuleSet struct {
SchemaVersion int `json:"schema_version"`
Disabled bool `json:"disabled"`
Domains map[string]Domain `json:"domains"`
// contains filtered or unexported fields
}
RuleSet is the merged, validated rule model: the bundled defaults overlaid with the per-repo override.
func Defaults ¶
func Defaults() RuleSet
Defaults returns a deep copy of the binary-bundled default rule set, safe for the caller to mutate.
func Load ¶
Load returns the bundled defaults merged with the user scope's ~/.abcd.noindex/rules.json and then with <repoRoot>/.abcd/rules.json — bundled, then user, then repo, each layer overriding per field (itd-117, spc-23). An absent file contributes nothing, so a machine with neither file gets the defaults unchanged; a present file that cannot be read, parsed or validated is a fail-closed error naming that file, and no partial set is returned with it.
func Merge ¶
Merge overlays over onto base. Domain fields are per-field: a field set on the override wins; an absent field inherits the base (so {"state":"dormant"} on a default domain silences it while keeping its recall and rules). New domain keys are added. The kill switch is sticky (either side can enable it). Every domain the override names is recorded as SourceRepo — the one moment the origin is still known, so the renderer and the diagnostics can say so later.
func (RuleSet) Active ¶
func (rs RuleSet) Active() []ResolvedDomain
Active returns every injectable domain (state != dormant) in name-sorted order — the full set the diagnostic `abcd rules` render shows. The top-level kill switch yields nothing.
func (RuleSet) KillSwitchSources ¶ added in v0.11.0
KillSwitchSources returns the layers whose file set the kill switch, in layer order (SourceUser before SourceRepo); empty when the set is not disabled. The switch is sticky, so every layer named here has to clear it before anything injects again.
func (RuleSet) Lookup ¶
func (rs RuleSet) Lookup(name string) (ResolvedDomain, bool)
Lookup returns one domain by name regardless of its state (a dormant domain is still inspectable); ok is false when the name is absent.
func (RuleSet) Match ¶
func (rs RuleSet) Match(prompt string) []ResolvedDomain
Match returns the domains to inject for prompt, in deterministic (name-sorted) order. The top-level kill switch suppresses everything. Otherwise a domain is injected if a star-command names it (overriding dormant) or — when active — its recall keywords or aliases hit the prompt.
type SessionState ¶
type SessionState struct {
Count int `json:"count"`
Ledger map[string]string `json:"ledger"` // domain name -> last-injected signature
}
SessionState is the per-session dedup ledger plus the prompt counter that drives the fixed-N refresh backstop.
func LoadState ¶
func LoadState(session string) SessionState
LoadState reads the ledger for a session. A missing, oversized, or malformed file yields the zero state (a fresh session), never an error — dedup is a best-effort optimisation, not a correctness gate.