Development record
refusal guards with zero coverage: the launch bundle symlink-dereference and scripts-deny guards (internal/core/launch/bundle.go:339), the memory quotation-budget and licence-detection compliance checks (internal/core/memory/lint.go:225), and the memory ask --file-back write path (internal/core/memory/ask.go:354) are all untested. A guard fails silent: when it regresses the system keeps working and simply stops refusing. Detector (per guards-prove-themselves): a convention that every refusal path ships a test presenting the forbidden input and asserting the rejection, its error shape, and the absence of side effects; a pairing lint between declared invariants and named tests is the promotion path. Acceptance corpus: the five guard paths above.
refusal guards with zero coverage: the launch bundle symlink-dereference and scripts-deny guards (internal/core/launch/bundle.go:339), the memory quotation-budget and licence-detection compliance checks (internal/core/memory/lint.go:225), and the memory ask --file-back write path (internal/core/memory/ask.go:354) are all untested. A guard fails silent: when it regresses the system keeps working and simply stops refusing. Detector (per guards-prove-themselves): a convention that every refusal path ships a test presenting the forbidden input and asserting the rejection, its error shape, and the absence of side effects; a pairing lint between declared invariants and named tests is the promotion path. Acceptance corpus: the five guard paths above.
Closed (2026-08-02, v0.5.0 item C8): the five-path acceptance corpus is complete. The audit that opened the round found the entry's "zero coverage" claim accurate for three paths, half-accurate for one, and already satisfied for one:
- Symlink dereference (
bundle.gohandleSymlink/walkSymlinkTarget) — PARTIAL. The reject half was already pinned byTestSymlinkEscapeRejectedandTestSymlinkToRepoRootDoesNotLeakDenied. What was missing is the half a guard whose job is to dereference SAFELY also owes:TestSymlinkDereferenceAcceptsInRepoTargetpins that an accepted link to an in-repo file is included under its own logical path with the TARGET asResolvedPath, that a link to an in-repo directory emits its subtree under the link's prefix, and that the target tree does not additionally ship under its own unrequested name;TestSymlinkDirectoryCycleRejectedpins theancestorscycle guard, whose absence does not merely mislabel a rejection — the resolve never terminates. - Scripts runtime closure (
classifyRegular) — UNCOVERED, nowTestScriptsNotInRuntimeClosureRejected, which walks all three outcomes in one resolution through the injectableClosureFnseam (literal include outside the closure ->rejected(fs_error, scripts_not_in_runtime_closure); swept up by a directory include ->excluded(denied_namespace); matching the closure's own__pycache__/.pycdeny -> likewise excluded, not rejected) and adds a no-closure polarity control so the exclusions are demonstrably the gate's doing. - MQ001 quotation budget (
lint.gocheckQuotation) — UNCOVERED, nowTestLintQuotationBudgetMQ001: both refusals (the contiguous quoted-span ceiling and the per-page share of a source), an under-budget control, and the two properties around the finding — MQ001 is curator-advisory (warn, exit 0), and lint leaves the page byte-identical. - ML001 licence detection (
lint.gocheckLicence) — the single-source branch was ALREADY covered byTestLintRejectsMalformed/external_missing_licence, which presents the forbidden page and asserts the blocker, its code and the exit contract; that half was left alone rather than padded. The multi-source branch (an externalsources[]entry with nolicencekey beside a sibling that has one) had no case and gains one in the same table. ask --file-backwrite path (ask.gofileBack/fileBackSource) — UNCOVERED, nowTestAskFileBackWritesCitedPage(a page filed back without its own source block inherits the cited matches' provenance, validates, cites the ingested hash, and is back-linked in the registry) andTestAskFileBackRefusesWithoutWriting(no cited matches, a declining decision, and cited pages without complete provenance — each asserting the typed*AskErroror the declined status, the specific message, and, against a byte-level snapshot of the whole store taken before and after, that nothing was written, mutated or removed).
Detector-first throughout: each new case was watched fail with its guard
disabled and pass with it restored — the scripts-closure branch short-circuited,
the cycle check disabled, the symlink file dereference dropped, checkQuotation
and checkLicence stubbed out, and the ask-side no-match refusal, decline gate,
provenance requirement and write call each neutralised in turn. No guard was
found broken and no production behaviour diverged from its own documentation, so
the increment is test-only and carries no CHANGELOG entry — the repo's
definition of done ties an entry to a user-facing change, and coverage alone
is not one. go.mod is untouched.
The entry's promotion path — a pairing lint between declared invariants and named tests — is NOT built here; it remains available as a separate convention increment, since the corpus this entry scoped is coverage, not the lint.
Frontmatter
| id | iss-34 |
|---|---|
| type | issue |
| lifecycle | resolved |
| severity | major |
.abcd/work/issues/resolved/iss-34-untested-refusal-guards.md
open on GitHub ↗ · commit history ↗
mentions5
- dcn-26071200000000012026-07-12 — clean-slate hardening run STEP 0 triage. A fresh adversarial
- dcn-26080200000000012026-08-02 — iss-34 (v0.5.0 item C8): the five untested refusal guards now carry detectors, and the audit that opened the round is worth keeping because the ledger's "zero coverage" claim was true for only three of the five. ML001's single-source branch was ALREADY covered by `TestLintRejectsMalformed/external_missing_licence` — forbidden page, blocker code, exit contract — so nothing was added there; padding a satisfied guard with a second test buys no detection and costs a reader's trust in the corpus. The symlink-dereference guard was covered on its REJECT half only (escape, denied target), which is the interesting general point: a guard whose job is to refuse SOME inputs and safely dereference the rest is only half-pinned by refusal tests, because dropping the dereference silently ships nothing rather than shipping something forbidden — a regression that reads as "stricter" and never trips a deny assertion. Both halves are now pinned, and the cycle guard turns out not to mislabel a rejection when removed but to hang the resolve outright. The three genuinely uncovered guards — the scripts runtime closure, the MQ001 quotation budget, and the `ask --file-back` write path — each gained the full shape the convention asks for: forbidden input presented literally, the specific reason code or error type asserted (not merely "an error"), and absence of side effects proved, which for file-back means a byte-level snapshot of the whole store taken before and after each refusal rather than a spot check on one filename. Two design choices are reusable: the scripts test drives the shipped `ClosureFn` seam and adds a no-closure polarity control, so the exclusions are demonstrably the gate's doing rather than an unrelated default-deny; and each case was verified by disabling its guard and watching the test fail, which for already-shipped guards is the only available form of "watched fail before". No guard was found broken, so the increment is test-only, `go.mod` is untouched, and there is no CHANGELOG entry — the repo's definition of done ties an entry to a user-facing change, and coverage alone is not one. The entry's promotion path — a pairing lint between declared invariants and named tests — is deliberately NOT built here; it is a convention increment, not part of the coverage corpus this item scoped.
- dcn-26080200000000032026-08-02 — iss-38 (v0.5.0 item D): hand-maintained indexes are either DELETED or GATED, never corrected and left hand-kept, because a corrected list drifts again on the next commit and nobody notices the second time either (adr-5, derive don't store). The ledger named four stale indexes; three were real and one does not hold. (1) `intents/README.md` transcribed four lifecycle directories as ASCII trees and had fallen sixteen drafts, one promotion (itd-73, listed as a draft while living in `planned/`), two disciplines and two superseded intents behind, with a `shipped/` section still calling the directory empty over thirteen shipped intents — deleted, because the enumeration carried nothing the directory does not carry better, and what a filesystem cannot state (the kind taxonomy, the lifecycle contract, the bundle history) stays. (2) `commands/README.md` (fourteen verbs named, eighteen files present) and (3) `internal/README.md` (core described as "identity/version and the read-only status snapshot" while its own later bullets described twenty packages, and `adapter/scanner` filed under planned seams although it ships with its own pattern set, redactor and tests) keep short, high-value enumerations — so those ship WITH the detector rather than as a promise to remember. The new `index_drift` rule holds a marked region (`<!-- index: <id> -->`) to the directory it enumerates in one of two modes: `exact`, where listing and directory must agree both ways, and `absent`, where every listed path must still be missing — the planned-seams shape, whose drift is a seam that shipped. Scoping is an explicit marker plus a configured entry pattern rather than a markdown-list parser: one generic rule, no bespoke parser per README, and unrelated prose beside a list cannot fire it. It fails closed three ways (region gone while the config entry remains; region parsing to zero entries; malformed spec), so half-deleting an index is a finding rather than a silent disarm. (4) The fourth instance — "the repo README Layout omits `skills/` from the plugin surface" — was investigated and does NOT hold: there is no `skills/` directory, abcd ships zero skills deliberately (the three workflows once shaped as skills became commands, closing iss-61; `brief/05-internals/08-skills.md` states it), so the Layout section is correct by omission and was left untouched. Inventing a `skills/` reference to satisfy a finding would have written a falsehood into the record to close a ticket — the same disposition iss-37 reached on its one non-holding instance. CHANGELOG: an entry, unlike iss-34's test-only pass, and impact `additive` rather than `internal` — the rule lives in the shipped lint engine that `abcd docs lint` runs, so any abcd-managed repo gains it by declaring its own pairs in `.abcd/docs-lint.json`.
- dcn-26080500000000032026-08-05 — iss-80 (v0.5.0): resolved as already-fixed, with the residual verification gap closed rather than the defect re-fixed. The branch-local id-collision class this item tracked — `itd-N`/`spc-N`/`iss-N` allocators scanning only the working tree, so two agents on branches cut from one base each mint the same id and collide at merge — was already closed by iss-115 and iss-120, which introduced `recordid.MaxAcrossRefs` (scan every git ref for the family's highest committed id) and folded it into all three minting paths: `internal/core/spec/store.go`, `internal/core/intent/create.go`, and `internal/core/capture/workflow.go`. What survived was uneven evidence, not an uneven fix: only the `spc-N` family had an end-to-end regression test standing up a real two-branch history (`internal/core/spec/refunion_test.go`), while none of capture's or intent's existing tests stood up a real two-branch history through `Capture`/`CreateFromText` and so never drove the ref scan through their public entry points at all — `MaxAcrossRefs` was exercised only as a primitive in its own package test, never as wiring. That is the shape in which a fix silently un-wires: a refactor that dropped the `MaxAcrossRefs` call from `Capture` or `CreateFromText` would have left every existing test in both packages green. So this round adds `internal/core/capture/refunion_test.go` (`TestCaptureMintsPastACommittedBranch`) and `internal/core/intent/refunion_test.go` (`TestCreateFromTextMintsPastACommittedBranch`), each mirroring the spec test's exact fixture via `internal/gittest.NewRepo` — branch A mints and commits record 1, branch B is cut from before that commit so its working tree carries no record, and B must mint record 2. Both were mutation-checked before being trusted: with `MaxAcrossRefs` stubbed to return an empty `RefScan`, each fails with precisely the collision it names (`iss-1`/`itd-1` re-minted on branch B), and all three families fail together — so the tests are pinned to the ref-union behaviour rather than passing incidentally. No production code was touched, which is the point: the minting paths were verified correct by reading before the coverage was written, and the change is test-and-record only. Impact recorded as `internal` and no CHANGELOG entry, matching the iss-34 precedent for a test-only closure with no user-facing behaviour change.
- dcn-26081900000000032026-08-19 — bughunt round 1 (branch bughunt-b/round-1): captured iss-291..iss-304 and fixed