Skip to content

feat: implement itd-150..162 against specs spc-43..54 - #555

Merged
REPPL merged 44 commits into
mainfrom
feat/implement-itd-150-162
Aug 28, 2026
Merged

feat: implement itd-150..162 against specs spc-43..54#555
REPPL merged 44 commits into
mainfrom
feat/implement-itd-150-162

Conversation

@REPPL

@REPPL REPPL commented Aug 28, 2026

Copy link
Copy Markdown
Collaborator

Implements the twelve planned intents itd-150..158 and itd-160..162 against their
specs spc-43..54. itd-159 stays held: abcd intent ready itd-159 refuses it, and
the gate was run for all thirteen before any implementation began.

Built by four agents in parallel, each in its own worktree on file-disjoint
territory, each working test-first against its spec's Approach section and each
running a ruthless and a security adversarial pass over its own diff. The four
branches were then merged here and put through a second pair of adversarial
passes over the combined diff, which is where the two blockers below were caught.

What lands

Intent Spec Change
itd-150 spc-43 The private name guard follows the developer across git worktrees: a linked worktree inherits the primary checkout's store beneath its own.
itd-151 spc-44 agent_contract brings the agents/ tree under record-lint and enforces the itd-5 trust contract.
itd-152 spc-45 A harness-leak privacy class (live session URL, tool attribution footer) in the canonical pattern set, enforced on three wired surfaces.
itd-153 spc-46 abcd ahoy remote apply enables GitHub's native secret scanning and push protection, confirmed and idempotent.
itd-154 spc-47 The bootstrap says what it is doing and how it ended, and the payload structurally denies platform binaries.
itd-155 spc-48 A galloping adjacency probe replaces the scanner's fixed 512-byte window, under a per-line budget.
itd-156 spc-49 The command guard refuses an unquoted brace group it cannot expand, fail-closed.
itd-157 spc-50 The by-links arrangement settles, and the overlap gate measures it: 1,245 overlapping pairs to 0.
itd-158 spc-51 A lifeboat declares a Pass-B exemption in its provenance rather than leaving a silent gap.
itd-160 spc-52 The dangling-reference ratchet is proven by five tests, each watched fail against a mutant.
itd-161 spc-53 cross_store_id_claim sees a decision-shaped document filed outside the record stores.
itd-162 spc-54 The prepare-this-repo adopt phase resolves every asset from the record or the binary, never a machine-local path.

Fourteen issues resolved, each with a Resolves: trailer and a ledger move in the
same diff.

What the integration review caught

Two findings blocked the merge until fixed. Both were branch-local, and both
survived their own branch's security pass.

A worktree of one repository read and enforced another's private name store.
The "is this a real working tree" test was a bare .git existence check, which a
bare mirror placed inside another checkout satisfies. The guard then read the
neighbour's store, enforced its patterns, and named its keys in the refusal: a
match/no-match oracle over exactly the data whose contract is that its values
never reach output, plus cross-repo denial of service from one malformed line.
Primary-worktree resolution now asks git and requires the candidate to confirm the
relationship from its own side. The shell half additionally unsets GIT_DIR and
its siblings around the probe, because git exports GIT_DIR into a hook and
GIT_DIR overrides -C, which made the check pass vacuously.

The new harness-leak fixtures were a live session identifier, committed as a
literal, in the change that adds the detector for that class.
It was carried
past the detector by three separate escapes in the same commit: a waiver, a
string split so the source line no longer matched, and a reserved-host skip.
This repository scans full history and main cannot be force-pushed, so it would
have been unremovable. Every such fixture is now generated at runtime and asserted
opaque by the detector's own predicate, and all seven waivers are gone.

Three further findings were fixed rather than deferred: an unguarded os.ReadFile
that a fork pull request could turn into CI memory exhaustion, present in two
rules that the same CI step reads; the new render printing the primary checkout's
absolute path, which the shell half of the same change already forbids and pins
with a test; and a claimed fourth mitigation surface that no code reaches.

Deliberate divergences from the specs

Each was taken in favour of the intent's acceptance criteria, and each is stated
here rather than left to be discovered.

  • spc-43's Approach asked for key-level override when merging the two stores; a
    union shipped instead, because an override fails open twice. The review
    reproduced the collision independently.
  • spc-53's Approach asked the rule to honour contentExempt; it does not, because
    that exemption covers the corpus which motivated the rule.
  • itd-154's second criterion asked for an eager provisioning line; it was built,
    both reviewers flagged it as a regression because it consumes the one line the
    transcript renders and displaces a refusal's cause, and it was reverted.
    Captured with the design that would satisfy both.
  • scanner.ScrubOutbound ships as a tested primitive with no front door, because
    spc-45 scopes a forge client out. AGENTS.md and the ledger say three wired
    surfaces and name the gap.

Still open

The intents remain in planned/ and the specs in open/. Promoting them wants an
independent audit of delivered code against each acceptance criterion, which is a
separate step and a maintainer decision.

Seven findings were captured rather than guessed at, including one worth
flagging on its own: the guard now refuses every unquoted brace group, so an
everyday mkdir -p foo/{a,b} is blocked. That is the intent's chosen posture,
and it is cheaper to revisit now than later.

Assisted-by: Claude:claude-opus-5[1m]

REPPL added 30 commits August 28, 2026 20:05
…fact

The secret/PII scanner ran every adjacency probe against a fixed 512-byte
slice of the line, so the slice's end was indistinguishable from the end of
the line to the regexp engine. Two failures followed from that one artefact.
A probe's own trailing \b was satisfied by the artificial edge, reporting a
LAN hostname the full line does not contain and over-redacting hundreds of
bytes of legitimate content (iss-189). And an open-ended token longer than
the window was recorded with an artificial end, so the next probe and the
next junction search both started mid-token and the chain that unwinds a run
of abutting tokens broke — the third token vanished entirely rather than
being truncated (iss-190).

gallopingFind replaces the single fixed slice with an exponential-doubling
probe. The window grows only while a match is still running into its own
edge, and stops the moment the answer can no longer change: the match ended
short of the edge, the window already reaches the real end of the line, or
nothing matched inside a bounded window. That last stop is the cost bound the
fixed window was introduced for and is deliberately kept — a FAILING attempt
is what two bundled patterns' unbounded internal quantifiers make expensive.
Growing the window for a match that is genuinely still running does not cost
a class: doubling sums to under twice the final window and the final window
is under twice the match, so one probe costs a constant multiple of that
match's OWN length, the class the top-level unbounded match already pays.

No boundary classifier and no clipped-so-skip branch: removing the artificial
edge dissolves both questions instead of adjudicating them. maxAdjacencyBacktrack,
the BACKWARD bound, is untouched; only the forward extent gallops, in probeAt
and in the junction search's forward reach.

The cost class is asserted, not hoped for: gallopingFind takes a matcher
interface so the guard can hand it a counting stand-in and pin the schedule
exactly — one fixed-window attempt for a short match and for a non-match, a
logarithmic number of attempts totalling under 4x the match for a long one —
alongside a wall-clock guard over the shapes purpose-built to make it grow.

Assisted-by: Claude:claude-opus-5[1m]
…nance

Resolves: iss-229
Assisted-by: Claude:claude-opus-5[1m]
agents/ holds the prompts a model reads as instruction, and it sat outside
both lint roots — so agents/README.md documented the itd-5 contract and named
its linter as unbuilt, while five prompts that read the most
attacker-influenceable input in the tree acquired the contract by hand and
nothing checked that the sixth would.

agent_contract is that detector. It walks agents/ directly (a prompt is not a
record, so the tree is not added to cfg.Roots and judged by the record stores'
schema) and holds each prompt to three things: the trust-contract frontmatter,
the injection-canary fixture an untrusted-input prompt must ship, and — over a
diff — the per-agent CHANGELOG entry.

The reads_untrusted_input DECLARATION is required of every prompt, not only of
the ones that admit to reading untrusted input: a rule that fires on `true`
alone is a rule the next prompt opts out of by deleting a line, and an
undeclared prompt is exactly the class this exists to catch.

The changelog sub-check is diff-scoped because it asks whether a CHANGE
announced itself. The range is armed by the caller (`record-lint -agent-diff`,
ArmAgentDiff), never read from the in-tree config — the ArmReceiptGate
reasoning: a gate a committer can point at an empty range is a gate a committer
can disarm. Unarmed, it is a no-op and the tree-shaped halves still run.

Resolves: iss-278

Assisted-by: Claude:claude-opus-5[1m]
record_schema reasons across the stores but only INSIDE them, so a markdown
file outside every store is not a malformed record to the engine — it is not a
record at all. A note could head itself `# ADR-23`, declare itself accepted,
reuse an id a real ADR already holds, and pass the whole gate at exit 0 with
zero findings. That was measured by probe, and the known instance survived a
full architecture change.

cross_store_id_claim walks the markdown outside the configured stores and fires
on a PAIR of signals: an H1 that OPENS with a record handle (a claim on the id,
not a mention of it) and a body declaring a record LIFECYCLE status, against an
id the record graph shows is taken. The graph is the same scan record_schema
trusts for its own high-water marks, so the detector cannot drift from the
stores' view of which ids are taken.

The pair is what grandfathers the undated Phase 0 notes without enumerating
them: a filename that reads like an ordinal claims nothing, a reading note that
names a decision in prose claims nothing, and a design plan headed with the
record it plans FOR carries a status of its own ("SIGNED OFF", "design
recorded") that is not a lifecycle state. An allowlist of spared files is a list
that grows silently; a fire condition is not.

The rule is structural, so it does not consult contentExempt, for the reason
record_schema does not: an id collision is not a question of how a document is
WRITTEN, and the known instance lives in exactly the tree the exemption covers.

Resolves: iss-2608230752354926

Assisted-by: Claude:claude-opus-5[1m]
The local-ephemeral tier is per-worktree and gitignored, so a checkout made
with `git worktree add` starts with no private store at all: every commit
from an isolated agent worktree ran with the banlist absent — warned, per
design, but unprotected, which turned the isolated-worktree pattern into a
systematic bypass of a protection the main checkout has.

The committed guard now tells the two shapes apart the way git does
(--git-dir differs from --git-common-dir only in a linked worktree), reads
the primary checkout's store as a fallback layer beneath the worktree's own,
and names the inherited origin in the refusal so the remedy is not invisible.
Resolution never fails the commit: an unanswerable rev-parse leaves the hook
exactly as it was, loud warning included.

The two stores are a UNION, not a key-level override. Letting a local key
replace an inherited one fails open twice — two legacy stores collide on
their synthetic `entry-<line>` keys, and a local re-declaration could quietly
narrow a ban the primary made — and a union satisfies the rule the intent
actually states, that the fallback never overrides a worktree-local entry.

The CLI read paths render the same inherited layer, so the status board and
the guard cannot disagree about which entries are in force. The WRITE root
stays the worktree's own: `add` never reaches into a checkout nobody named.

Assisted-by: Claude:claude-opus-5[1m]
Phase 3 reached for a pre-commit config and a prepare-commit-msg hook under a
maintainer-local templates directory, both "if present" — so on every machine
but that one the adopt step did nothing at all and the adoption silently
degraded against loud-staging. A fresh clone of this repo could not perform
the onboarding this record describes.

Both steps now resolve from the binary. The commit gates come from
`ahoy install`, whose hooks are already embedded; the attribution prompt is a
net-new embedded `defaults/prepare-commit-msg`, scaffolded by
`ahoy install --attribution` and recorded in config so a later plain install
keeps it and restores a hand-deleted one. The hook writes no value — which
tool assisted is a fact only the committer has — and it appends its prompt
only for the two message sources git strips comments from, because a prompt
appended to a `-m` message goes into history verbatim.

An onboarding self-containment check refuses any home-relative path in the
adopt-phase record, with its own must-fail half: a gate nobody has watched
fire is an assertion, not a check.

classifyGuardHook becomes classifyHook(root, rel, marker) so the two hook
families share one containment-respecting classifier rather than drifting
apart in a second copy.

Assisted-by: Claude:claude-opus-5[1m]
An autonomous run in a sibling repository posted pull-request bodies and issue
comments carrying a live session URL and a tool's default attribution footer.
The commit messages stayed clean, because the trailer is the one surface this
repo's convention reached; the footer and the link were appended when the forge
artefact was created, outside the model's own text, and the forge kept the
pre-scrub revision. The same class was later found here, model-authored — an
agent reasoning from a generic default. A detector cannot tell the two apart
after the fact and does not need to: the shape is the finding.

The class is defined once, in the scanner's canonical set, and read from there
by every surface that judges text — the four store-before-commit redactors get
it by being folded into DefaultPatterns; `abcd lint`'s privacy rule adds
exactly these two to the network set (not the whole secret set, which is a
different change with its own false-positive budget); the harness_leak rule
carries it into the record and docs lint; and ScrubOutbound is the primitive an
autonomous routine calls before posting. Two copies of a regex is how one of
them comes to mean something weaker.

Three things make it survivable in a repo that has to document its own ban. The
footer's line-start requirement lives in SkipAt rather than in a `^` anchor,
because the adjacency probe re-compiles a pattern body inside `\A(?:...)` over a
WINDOW of the line, where `^` would match mid-line. A reserved documentation
host (RFC 2606/6761) spares a worked example. And a session id must be OPAQUE —
a UUID, a base62 token, or a long hex run — because a documentation slug such
as .../blog/using-agent-session-management-and-1m satisfies every structural
test a regex can state and sits in this repo's own research notes.

ScrubOutbound takes the whole LINE for a harness-leak finding rather than
masking in place: masking is right for a secret embedded in the artefact's own
content, but a footer IS the line the harness added, and a masked stub still
posts the shape the policy bans. It fails closed on the residual rescan.

Resolves: iss-178

Assisted-by: Claude:claude-opus-5[1m]
`git push {--force,} origin main` expands in bash to byte-identical `--force`
argv, but the guard's tokenizer performs no brace expansion: it read the
literal token `{--force,}`, no blocker matched it, and a Tier-1 hazard was a
silent allow — the same mutate-the-flag-token shape the redirection branch
already closes.

The guard does not expand the group either. A correct bounded expander (the
Cartesian product of the alternatives, nested groups, `{a..z}` ranges) is
larger than this fix, and it is not what closes the bypass: a word whose argv
the guard cannot compute is a word it cannot check, so it is REFUSED. The
tokenizer records the group on the segment and Check folds it into a real
VerdictBlock, deliberately not an ErrUnparsableCommand — the `guard check`
verb maps a tokenize error to a blocking exit but the pre-tool-use hook maps
it to fail-OPEN, so the error route would have left the bypass standing on
the one surface that matters.

The three shapes bash does not expand keep their verdicts exactly, each for a
reason rather than by luck. Quoted bytes never reach a structural branch, so
`'{--force,}'` was already an inert literal. `${…}` is parameter expansion,
exempted on the raw byte before the brace — bash's own test — read off the
RAW line so a quoted dollar cannot buy the exemption. And a group needs an
alternative and lives inside one word, so `{a}`, a lone `{`, `awk {print}`
and the reserved-word group command `{ git push --force; }` all fall through
unchanged; the last still puts its inner command in command position, where
the blocker for it fires as before.

Everything else is read fail-closed: a nested group counts even though its
comma is not at the outer group's own level, and an alternative inside quotes
counts too, because a comma the scan cannot rule out is one it must assume
bash will act on.

Assisted-by: Claude:claude-opus-5[1m]
Resolves: iss-2608221457227161
Assisted-by: Claude:claude-opus-5[1m]
The Cut A §4 gate — a fresh plugin install on a machine with no Go — failed
on its third and fourth assertions and blocked the release. No bootstrap line
appeared at any of three session starts, the plugin root held a source
checkout and no binary, and every UserPromptSubmit and PreToolUse hook errored
"No such file or directory" for the whole evening. The download path was not
the defect: it worked. What failed was that a degraded environment produced
SILENCE, so the one thing a reader could act on was the one thing missing.

The script now owes a contract it can keep. It announces provisioning before
the work that can hang — a directory sweep of a plugin root full of source on
a cloud-synced filesystem, then three network fetches — so a run killed in
there still leaves evidence of what it was doing. And an EXIT trap closes the
contract: reaching the end with the announcement made and no terminal line
means the run died somewhere it does not know about, and that is reported as
the same loud refusal, with the same three ways out, every other failing path
gives. SIGKILL still runs no trap; nothing in a shell can cover that.

The announcement goes ahead of the terminal line, which is a real trade: the
transcript renders only a hook's first stderr line (iss-208), so a successful
install now leads with "provisioning…" and the `ahoy install` instruction is
one line further down. That is the right way round. A success the reader has
to expand to see costs them a scroll; a hang with nothing on screen at all
cost an evening. The announcement is placed AFTER the lock, so a session that
merely lost the race stays genuinely silent — it is not provisioning, so it
must not say it is.

The no-bundled-binary half is made structural rather than incidental. The
released `abcd-<os>-<arch>` artefacts are out of the payload today by omission
— gitignored, named by no include — but omission is a config an edit can undo,
and a bundled copy would be an unverified, permanently stale second source for
the binary that then runs as the shell guard. The deny now sits above the
include list, on the artefact's name, and REJECTS: naming one fails the ship
rather than being dropped in silence. It is the whole basename, so prose about
an artefact is untouched.

The §4 checklist itself becomes a gate: a fresh plugin root, a release serving
the real built binary, a PATH with no Go, and the manual evening's three
assertions made on every build — exactly one terminal line and it is the
success, a provisioned regular executable holding the verified bytes, and that
binary answering with no toolchain anywhere. Two companions pin the failure
side: a refusal leaves no half-installed binary and no temp directory, and a
run signalled mid-provision produces the refusal rather than the empty output
the old script gave.

Assisted-by: Claude:claude-opus-5[1m]
…ance

Resolves: iss-253
Assisted-by: Claude:claude-opus-5[1m]
The structural-tier entry linked the open-ledger path, which the galloping-probe
fix moved. The plan's narrative — designed, deliberately not next — is left as
the record of when that was true.

Assisted-by: Claude:claude-opus-5[1m]
The galloping change dissolved two of the three things the old comment on
maxAdjacencyProbeWindow described, and the rewrite dropped the third with them.
It still holds: a FAILING probe attempt is deliberately never grown — that is
the cost bound the constant exists for — so a pattern whose earliest required
structural marker falls past the first window still matches nothing there and
its token is missed entirely rather than truncated. jwt_shaped behind a long
header is the case. A limit nobody wrote down is one the next reader rediscovers
by being bitten.

Assisted-by: Claude:claude-opus-5[1m]
The by-links arrangement published overlapping positions by construction.
Its islands were placed by a spring layout with no collision pass at all,
its pairs sat a fixed 0.012 apart when their own radii needed more, and
its two rim rows mapped each record's arc-width onto a circle whose
circumference was smaller than the sum of those widths. On this record
that was 1,245 overlapping bubble pairs, and the chart never came to rest
on the arrangement.

The gate could not see any of it: the count lived inside coil() and
measured the coil alone. Widening it is not a one-line change, because
positions are published normalised to the unit disk while radii are
quoted in reference pixels — the renderer reconciles the two by drawing a
point at point x coil_radius, and comparing across the two spaces reports
an overlap for very nearly every pair (79,800 on the 400-node fixture).
countOverlaps takes the count in the renderer's space and Arrangements.
overlapCount sums both arrangements into the published number.

The arrangement is now built under one packing rule. Each island is sized
from the area its own bubbles occupy; each pair is separated by what its
two radii need; the core is drawn back to its share of the stage and
compacted; and the rim winds outward from the core's own edge under the
coil's forbidden-interval walk, which is now a shared clearOutward rather
than a second copy. Where the stage has room left over the whole
arrangement is grown to fill it, which can never bring two bubbles closer
together.

Measured on this record: 1,245 overlapping pairs before, 0 after, with
the outermost radius at 0.962 against 0.970 — a reverted earlier attempt
had left 367 overlaps and pushed that radius to 1.83.

Resolves: iss-2608231350127745
Resolves: iss-2608231322321751

Assisted-by: Claude:claude-opus-5[1m]
…ning

GitHub's native secret scanning and secret-scanning push protection were
enabled on this repository by hand, with nothing in the tree recording the
intent and nothing to reapply it anywhere else. Push protection blocks a secret
at push time — earlier than any CI scan — and secret scanning covers the
default branch continuously, so both belong to abcd's managed-repo posture
rather than to one maintainer's memory.

`abcd ahoy remote` reads the two toggles and reports what an apply would
change, touching nothing. `abcd ahoy remote apply` enables them — secret
scanning first, because GitHub refuses push protection on a repository whose
secret scanning is off — and mirrors the desired state into
.abcd/work/rulesets/repo-settings.json, whose `observed` half also records the
merge-hygiene settings abcd deliberately does not drive: they had no source of
truth in the tree at all, and a mirror that blurred what abcd restores with
what it merely saw would read as a promise nothing enforces.

Four gates stand before any change leaves the machine, and each refuses or
aborts rather than guesses: the folder must be a repo abcd manages, the
repository must be the one this checkout's own origin names (owner and name are
charset-checked before either reaches an API path, so a crafted remote cannot
redirect the request to another endpoint), the config must not set
scan.native_secret_scanning to false, and the caller must CONFIRM the specific
toggles named — invariant 10 asks for a verb the user invokes AND confirms, and
an unanswered run declines. The call goes through `gh`, so the write is made by
the caller's own authenticated identity and abcd never holds a token.

The read precedes the writes, a failed step stops the sequence rather than
attempting one that cannot succeed, and no partial apply is ever mirrored.
Idempotent in both directions: an already-correct repository takes no remote
write, and a re-run rewrites nothing in the tree.

Assisted-by: Claude:claude-opus-5[1m]
Eight typed cross-references in this record point at targets absent from
the tree — adr-22 supersedes adr-14, adr-15 and adr-17; adr-25 supersedes
adr-8; adr-27 supersedes adr-16; adr-28 supersedes adr-18; adr-35
supersedes adr-4; itd-3 names a spec_id, spc-1, with no file — and the
question the record asked was whether anything checks them.

Measured: the detector fires. `abcd site check` on a tree carrying one
new `supersedes: adr-999` refuses with `adr-54 → adr-999 (supersedes) is
unresolved and outside the committed baseline`, and the eight known
dangles are carried by `.abcd/site-baseline.json` rather than failed. But
nothing pinned any of it, so every one of those behaviours was one
refactor away from silently going out.

The four ratchet behaviours now have tests: a new supersedes dangle
fails, a spec_id naming a spec with no file fails by the other route
(a graph field the record rule carries without judging, as against a
handle field it parses), a baselined backlog of both kinds passes, and a
baselined reference whose target is later written passes with an
invitation to shrink. A fifth pins the exception the detector rests on —
a supersedes naming a PRUNED record still counts, or the one field that
declares an absent id would be the one field nothing could check.

Each was checked against a mutant: excusing supersedes like any other
field, and dropping the graph fields from the health walk, each turn a
new test red.

Resolves: iss-2608220150157498

Assisted-by: Claude:claude-opus-5[1m]
Resolves: iss-370
Resolves: iss-87
Resolves: iss-2608270636272755
Resolves: iss-2608270512210664

Assisted-by: Claude:claude-opus-5[1m]
Nine findings across the three new detectors. Six were defects, two were
documentation that had drifted ahead of the code, and one was this diff
tripping its own new gate.

The outbound scrub sanitised with a silently weakened pattern set. Every other
write-time redactor in the repo consults sc.Unavailable() before trusting a
scan; this one did not, so a broken .abcd/config/pii.json dropped the repo's own
detectors, ScrubOutbound returned the artefact byte-identical with err == nil,
and the routine posted it — on the one surface whose pre-edit revision a forge
keeps. It refuses now.

Both lint-side detectors were weaker than the scanner they claim to share a
definition with, which is the exact drift the class exists to prevent.
harness_leak read only the LEFTMOST match per pattern, so a documentation URL
earlier on a line disarmed the pattern for a real session URL after it — and the
suppressing URL in the reproduction is the one this repo's own research notes
carry. The footer's SkipAt read the whole rest of the line for a reserved
documentation host, so any unrelated example.com link later on the line excused a
genuine footer; it now reads the footer's OWN link target. The same SkipAt
demanded column zero, which the model-authored half of the class does not
satisfy — a footer written into a bullet or a quoted reply went through — so a
list marker and a blockquote marker are admitted, while prose before the match
stays a skip and the writable-about property is unchanged.

cross_store_id_claim walked the whole tree, untracked files included. That made
a blocker out of the gitignored local tier AGENTS.md tells agents to use for
scratch output, and out of a git worktree created inside the checkout, which the
concurrent-sessions guidance contemplates. It reads the TRACKED set now, the
same scope the privacy rule uses; an untracked file is not yet a claim on an id.
Its decision-shape test also matched the frontmatter `status:` key it documented
itself as ignoring, so a verbatim copy of any ADR fired with no Status section in
it at all — the scan starts at the body.

agent_contract's changelog sub-check was diff-only and nothing passes a range,
so it never ran outside a test while agents/README.md said the contract was
enforced. The entry requirement is tree-shaped now and holds on every
invocation; the armed range keeps the one thing a tree cannot say, an edit with
no prompt_version bump, and CI arms it when the base commit is present. The
canary check was a bare Stat that an empty file satisfied. And the capability
scope parser flipped out of the block on a YAML block-sequence item, so a
contract-complete prompt drew a finding telling its author to add a field that
was already there.

Finally, this diff's own scanner fixtures made `abcd lint` fail its new privacy
rule. They cannot move to a documentation host — that would suppress the very
detection each case asserts — so they carry the rule's own abcd-lint:allow
waiver, which is what that escape is for.

Two residual coverage boundaries are captured rather than guessed at:
iss-2608281948217899 (which session-URL spellings the covered harnesses mint)
and iss-2608281948289198 (a prompt filed one directory down).

Assisted-by: Claude:claude-opus-5[1m]
The roadmap promises that Pass B — the pass that mines chat transcripts
for the rationale nobody wrote down — "ships as a declared exemption in
_provenance.json, never a silent gap" (phase-6-lifeboat.md). itd-88's
fidelity audit found the promise had no implementing code: no exemption
field existed anywhere in the lifeboat package, so a section Pass B would
have grounded arrived as a blank indistinguishable from one nothing could
ever ground, and a reader was left to answer a question no pass had been
run against.

Provenance gains a pass_b_exemption carrying its reason — a typed marker
rather than a bare bool, because what was promised is a DECLARED
exemption and a reader of the artefact has to be able to see why the pass
is absent. It is derived from the tiers the pack actually drew on, not
asserted: Tiers() is git, conventions and abcd-native and none of them is
a transcript store, so every lifeboat this build packs is exempt, and the
declaration stops being written when a pack draws on a tier outside that
set.

The embark coverage handoff carries the declaration through from the
provenance and the blanks-first report names it before the blanks, so the
pass that did not run is stated where the work a human is being handed is
stated. The field is an omitempty pointer: a lifeboat with no marker —
one packed before the field existed, or one a transcript pass did run for
— round-trips byte-identically and reports exactly as it did, which a
test asserts by diffing the two renders line for line.

Resolves: iss-136

Assisted-by: Claude:claude-opus-5[1m]
Three findings from re-reading the diff against the shapes it does not test.

The linked-worktree fallback trusted the common dir's PARENT to be a working
tree. For a bare repository — or one made with `--separate-git-dir` — it is
not: it is whatever directory happens to hold the git dir, so a worktree of a
bare clone would read `<that directory>/.abcd/.work.local/private-names.txt`
and enforce the private list of an unrelated repository living next door. The
primary root must now carry a `.git` entry, which a real working tree always
does.

The attribution prompt assumed git strips `#` lines. Under
`commit.cleanup=verbatim`, `whitespace` or `scissors` it strips nothing, and
under a repo-chosen `core.commentChar` the `#` prefix is not a comment at all —
so the block went into the commit message verbatim, permanently. Both settings
are now read: the character is substituted, and a mode that cannot strip skips
the prompt with a loud reason rather than corrupting history. `auto` is refused
because it cannot be predicted from outside the message.

The guard's scratch-directory comment claimed a present store implies a local
tier. In a linked worktree the store can be inherited while the tier is absent,
and the temp-directory branch is then the right one for a second reason: a copy
of the staged tree must not be written into another checkout.

Assisted-by: Claude:claude-opus-5[1m]
…empt

The doc comment on renderCoverageBlanks still described the shape it had
before the exemption line: it said an absent coverage prints nothing, and
an absent coverage that declares Pass B exempt now prints the
declaration.

Assisted-by: Claude:claude-opus-5[1m]
Two blockers from each pass, plus their notes.

The Go resolver did not apply the working-tree test its shell counterpart had
just gained, so for a worktree of a BARE clone the board resolved, read and
rendered the private store of an unrelated repository next door — and printed
"these entries are enforced here too" about entries the guard, which does apply
the test, was not enforcing. A status surface announcing protection that is not
running is the one failure this layer forbids. Both halves now require a `.git`
entry, and a bare-clone worktree is pinned in both suites.

`--attribution` was gated on the safe-autocreate CATEGORY's approval, which is
only granted when some other resolvable gap is open. On an already-installed
repo — the one the adopt phase runs `install --attribution` in — the flag wrote
nothing and reported nothing: the silent degradation itd-162 exists to remove,
reintroduced one flag along. An explicit flag is now its own approval.

The remote verb resolved WHICH repository from git's upward search and WHETHER
it may from a cwd-rooted config read, so from a subdirectory it read an absent
config as consent and PATCHed a repository whose maintainer had opted out, then
wrote the mirror into a stray `.abcd` tree while reporting the committed path.
Every question is now anchored at the working-tree root, and a config that does
not answer is a refusal rather than consent. The API host is pinned explicitly:
`gh` otherwise takes it from GH_HOST, so the github.com-only rule constrained
the path while the environment chose the endpoint.

The zero-entry banner was keyed on the SUM across both stores, so an emptied
worktree store went silent whenever the inherited one had an entry — the exact
truncation the banner exists to catch. It is per store now.

The guard printed the primary checkout's absolute path on the success path of
every commit. A checkout's directory name is very often the private name its
own store bans, so that echoed the banned string to stderr, scrollback and any
log capturing hook output — on a commit that passed. Output names the store
relative to the other checkout instead; the CLI render redacts $HOME and now
says the fallback is read-side only, since `add --private` still writes locally.

The attribution prompt read only `core.commentChar`, missing the
`core.commentString` that supersedes it in git 2.45+, so a repo setting the
newer key had the whole block written into every commit message verbatim.

An unreadable store fell to `set -e` as a bare "Permission denied"; it now
refuses loudly, the standard this file already holds a missing tool to. A
non-interactive `remote apply` that declines exits non-zero, so a change that
did not happen cannot look to a script like one that did.

Assisted-by: Claude:claude-opus-5[1m]
pass_b_exemption.reason comes verbatim from _provenance.json, which
manifest verification deliberately excludes, so it is free text in any
lifeboat anyone can hand a user. The human render masked it, but the
handoff is also what --json emits: an adversarial review measured raw
U+009B, U+202E, U+200B and DEL reaching the JSON payload, where a
consumer piping it onward never sees the render's own masking.

Every other string in CoverageHandoff is sanitised where the handoff is
BUILT — Question, Searched — and this one now is too, which is also where
the convention says to look for it. The render keeps its own sanitise;
masking is idempotent, and the rule there is that every lifeboat-derived
string passes through it.

The measured leak on source_name and record_manifest_sha256 is the same
pre-existing class (iss-359) and is deliberately left alone: it is one
change over all of them, not a fix for whichever field was touched last.
So is the unbounded length of the reason, which cannot forge a line —
sanitize masks the newline — and which source_name shares today.

Also corrects what passBExemption's comment claims: the code writes no
declaration when the pack drew on any tier outside Tiers(), not
specifically a transcript tier.

Assisted-by: Claude:claude-opus-5[1m]
**The brace refusal had a nine-character bypass of itself.** `braceExpansionAt`
treated `(`, `)` and a backtick as word terminators, but none of them ends a
bash word when it opens a substitution — bash brace-expands straight through
one, so `git push {--force,$(true)} origin main` hands the child byte-identical
`--force` argv while the scan read "no group here" and the guard allowed it.
The same for the backtick spelling and for `<(…)`. The scan now skips a
substitution for STRUCTURE, counting an alternative inside it rather than
reasoning about what it would produce, and bare parens stay terminators.

**And the `$` exemption was one byte too wide.** `\${a,b}` is a literal dollar
followed by a live brace group — bash expands it to `$a $b` — so the exemption
now requires a `$` that is not itself escaped.

**The forward scan was quadratic.** It looks ahead from every structural `{`,
so a word of nothing but `{` re-reads the same tail once per byte: a megabyte
of them, well inside the guard's own stdin cap, took over six minutes. That is
a hang on the PreToolUse path reachable by any command an agent can be asked to
run. A budget shared across one tokenize call bounds the total look-ahead, and
exhausting it refuses rather than allows.

**The scanner's galloping probe was quadratic too, in the way the round-6 cost
regression was.** Removing the fixed cap also removed the cap on how long a
probe-recovered match may be, and every recovered match is re-validated up to
maxAdjacencyBacktrack times at O(match length). A line where Theta(n) junctions
each start a Theta(n)-long match therefore squared: 1.3s / 5.0s / 19.1s over
14KB / 29KB / 59KB, where the fixed window had been 0.6s / 1.3s / 2.5s. One
growth budget per line, a multiple of the line's own length, keeps a single long
token whole — the shape the gallop exists for — while a line engineered to grow
the window at every junction exhausts it and reverts to the bounded fixed
window. The same measurements are now 0.66s / 1.32s / 2.73s: linear again, and
back on the pre-change curve. The shape is pinned as a cost case, and a
cost-CLASS test asserts the growth ratio rather than a wall-clock ceiling — a
ceiling could not have caught this, since both curves sit far under any fixed
bar at the sizes the other guards use.

**The bootstrap announcement was taking the only line a reader gets.** Only the
first line of a hook's stderr reaches the transcript (iss-208, measured), and
the success notice already spends it on the one-time `ahoy install` instruction
placed there for exactly that reason (iss-207). An announcement ahead of it took
that line from the success and — far worse — from the refusal's cause, which is
the silence itd-154 exists to end. The announcement is dropped and the two
assertions that would have caught it are restored to `firstLine`; the EXIT trap,
which is the half that actually closes the silence, now names provisioning in
the one line it emits. The literal "provisioning…" line the spec asks for is
deliberately not shipped: it cannot be had without paying for it with the line
that matters more.

Two smaller holes close with them. The reserved brace id joins
`reservedEntryIDs`, so a repo registry cannot dress an ordinary entry up as the
guard's own voice — the invariant its own comment already stated. And the
payload deny covers the bare `abcd` that `go build ./cmd/abcd` produces, which
is the name the binary runs under in the plugin root and so the likeliest of the
three to be committed by accident; the committed-tree half of its test asserts
against `git ls-files` instead of only logging.

Assisted-by: Claude:claude-opus-5[1m]
iss-28's gate: a test that spawns git must do it with gittest.Env, so it cannot
read the developer's own git configuration and pass or fail on it. The committed
-tree half of the platform-binary deny asks git which files are tracked; it now
asks the same way every other test does.

Assisted-by: Claude:claude-opus-5[1m]
An adversarial security review reopened this branch's own bypass four
characters wider, and in a worse form. `braceExpansionAt` treated the first `}`
that returned depth to zero as the group's end, so a group whose first
alternative carries a literal `}` was read as inert text — and bash does not
read it that way. Checked against bash 5.3: `git commit -m {msg},--no-verify}`
expands to argv `git commit -m msg} --no-verify`, which RUNS: `-m` swallows the
junk alternative as the commit message and `--no-verify` skips the hooks. The
byte-identical spelling blocks on `git-commit-no-verify`; this one was a silent
allow, as were `git push {x},--force} origin main` and
`cd /tmp/x && rm {y},-rf} *`.

A `}` now ends the scan only once a separator has been seen at that level;
without one it is an ordinary byte inside a group that is still open, which is
how bash reads it. A differential check against real bash over the brace,
quote, `$` and backslash shapes finds no remaining word bash expands into
different argv that the guard allows. It does not over-block: `{a}`, `{a}b`,
`{a},x`, `{a} b,c}`, `{}` and `awk {print}` are single words to bash and stay
allowed, while `{a}{b,c}` and `{a},b}` — which bash does expand — are refused.

Two comments that the same review found falsified are corrected with it. The
scanner's maxAdjacencyBacktrack claimed a junction further back than the window
"would need a crafted multi-kilobyte token, which no bundled pattern can
produce"; recovering an open-ended token at its TRUE length made a few-hundred
-byte `sk-proj-` key enough, so the note now says what the consequence actually
is — a lost label, not a lost redaction, since the bytes stay inside the
enclosing hard-fail span. And the payload deny is described where it sits: after
the include match, so an artefact nobody asked for stays a default-deny miss
rather than a reported violation.

Assisted-by: Claude:claude-opus-5[1m]
Neither is a defect in what ships; both are the part of an acceptance criterion
this round decided not to buy, and a decision nobody wrote down is one the next
reader has to rediscover.

Assisted-by: Claude:claude-opus-5[1m]
REPPL added 13 commits August 28, 2026 21:38
The full-history secret scan checks out with fetch-depth 0 and scans every
remote ref, so a secret literal on a superseded branch fails the gate on
pull requests that never touched it.

Assisted-by: Claude:claude-opus-5[1m]
… what

Four findings from an adversarial review of this branch.

The exemption's derivation was a constant wearing a derivation's clothes,
and its guard was inverted against the way a tier is actually added.
TiersPresent is produced only by tiersPresent(), which appends exactly
the members of Tiers(), so `if !settled[t]` could never fire — and
registering a transcript adapter means adding its tier to BOTH Tiers()
and tiersPresent(), which would have put it in `settled` too. The
declaration would then still be written for a pack Pass B did contribute
to: a false claim in a durable artefact, which is worse than the silent
gap the field was added to close. The set of tiers that carry transcripts
is now named, empty, and checked positively, and it is a parameter so the
branch that STOPS the declaration is exercised before any adapter exists.

The final settle over every node is a no-op on the pipeline that seeds
it — the core pass and the rim's own walk leave nothing to clear — so
removing it left the package green, and a pass whose whole job is to
guarantee an invariant had no test standing behind it. It keeps the
invariant when an earlier stage misbehaves (removing the core pass
instead shows it catching one), so it stays, and settle is now tested
directly against a seed of piled-up bubbles that no arrangement produces.

Two test defects: the pruned-record assertion assigned where it had to
accumulate, so a second unresolved reference from the same record would
have erased the finding; and stripPassBExemption's comment promised a
return value it does not have.

Assisted-by: Claude:claude-opus-5[1m]
`scanner.ScrubOutbound` has no caller: nothing under `internal/surface`,
`commands/` or the plugin surface reaches it, and `internal/` cannot be
imported from outside the module, so it cannot acquire one from elsewhere.
Three surfaces are genuinely wired: the canonical pattern set the
store-before-commit redactors read, `abcd lint`'s privacy rule, and the
`harness_leak` record/docs-lint rule.

Claiming the fourth is worse than lacking it, because all three wired
surfaces judge text that is already committed or already stored and none of
them reaches a forge artefact, which is the vector iss-178 reports. The
loud-staging principle says a stage that no-ops must say so, so AGENTS.md,
the iss-178 resolution and spc-45 name the primitive as a primitive: real
and tested, deliberately without a forge client per spc-45's own scope
decision, with the re-read-and-strip protocol carrying the posting-time
protection until it is wired.

Assisted-by: Claude:claude-opus-5[1m]
The primitive is real and tested; what it lacks is a front door. Records the
two candidate shapes a future change can take, so the gap the prose stopped
claiming away has a ledger entry to find it by.

Assisted-by: Claude:claude-opus-5[1m]
The harness-leak tests embedded a session-identifier-shaped literal as a
source constant, in the very change that adds the detector for that class.
This repository scans FULL history (gitleaks git, fetch-depth 0) and main
cannot be force-pushed, so such a literal is unremovable once merged — the
trap iss-2608282038283692 documents from the other direction — and iss-178
had said the remedy would carry leak shape only, with no session ids
reproduced.

The literal also forced three escapes to get itself past this class's OWN
detector: a line-scoped lint waiver on each fixture, a split literal
(strings.Join on the id and on the footer) so the source line no longer
matched, and a reserved-documentation host standing in for a specimen. All
three are gone. Every session-identifier-shaped fixture is now built at
runtime from internal/testsecret, seeded per case, which is the principle
the secret fixtures already keep (secret-shaped-fixtures-at-runtime).

The scanner's helpers assert the generated id is opaque by the detector's
own test, so a fixture that silently stopped exercising the pattern fails
loudly rather than passing a weakened assertion — verified by mutation:
substituting a non-opaque id fails all six session-URL tests.

testsecret gains SyntheticHex for the lower-case-hex and UUID spellings;
Synthetic's output is unchanged (both now share one walk).

`abcd lint` reports 0 harness-leak findings over the tree with no waiver
left to hide one, and `gitleaks git` over this branch's history is clean.

Assisted-by: Claude:claude-opus-5[1m]
checkAgentChangelog reached the filesystem with a bare os.ReadFile while
every sibling read in the same function was already guarded — agents_dir by
containedRepoPath, each prompt by containedRealPath plus
fsutil.ReadGuarded, whose refusal says "the lint reads only inside the
repository". The changelog was the one that was neither contained nor
capped, and both its path and the file it names are repo-controlled: the
path comes out of the in-tree lint config and the file out of the tree, so
a fork pull request supplies them and CI's `go run ./cmd/record-lint` is
what reads them.

Two shapes, both reproduced as failing tests first:

- agents/CHANGELOG.md committed as a symlink to /dev/zero. The read never
  returned — the test hit its 20s ceiling with the lint still allocating,
  which on a CI runner is memory exhaustion.
- `"changelog": "../../../../etc/hosts"`. The read left the repository, and
  the traversed path was echoed back in the finding's File field.

Guarded with the primitives already in this function rather than a third
mechanism: containedRepoPath refuses the configured path, containedRealPath
refuses a symlink out of the tree, and fsutil.ReadGuarded (O_NOFOLLOW,
non-regular refused, size capped at maxAgentPromptBytes) refuses the
device. An absent changelog stays a state, not a fault.

A third test pins the other direction — an in-repo symlinked changelog is
still followed, as containedRealPath already does for a prompt — so the
guard cannot later be tightened into a refusal of ordinary in-tree layout.

Assisted-by: Claude:claude-opus-5[1m]
… arithmetic

The linked-worktree fallback located the primary checkout as the common dir's
parent and accepted it if that directory carried a `.git` entry. The parent is
not the working tree wherever the git dir sits outside it — a bare repository,
or one made with `--separate-git-dir` — and the `.git` test does not close the
gap: clone bare into a directory that lives inside somebody else's checkout (or
point `--separate-git-dir` there) and the parent IS a real working tree with a
real `.git`. Both halves then read that stranger's
`.abcd/.work.local/private-names.txt` and the committed guard enforced their
private list on this repository's commits: a match/no-match oracle over their
patterns, their keys printed into this repo's hook output and agent transcripts,
and every commit here refused by one malformed line over there.

Both halves now ask git which working tree is the main one — the first record of
`git worktree list --porcelain` — and then require that candidate to CONFIRM the
relationship from its own side: its `--show-toplevel` must be the candidate
itself (no directory that merely holds a git dir can answer that; git says "must
be run in a work tree" for a bare repo and a `--separate-git-dir` git dir alike),
and its `--git-common-dir` must be ours. The second test is what makes the answer
unspoofable by layout: a neighbouring checkout discovers its own `.git`, never
the mirror planted inside it, so the two common dirs differ and the candidate is
refused. In the shell half the probe runs with git's hook environment unset
inside the subshell, because an inherited GIT_DIR overrides `-C` and would make
the confirmation answer with our own repository whatever directory it was handed
— a check that passes vacuously is worse than no check.

Resolution still never fails a commit: every failure leaves the guard
single-store, exactly as in a standalone checkout.

Assisted-by: Claude:claude-opus-5[1m]
checkDeliveryState carried the identical unguarded read to the one just
fixed in checkAgentChangelog — a bare os.ReadFile on a path out of the
in-tree lint config, naming a file out of the tree. Both rules are armed as
blockers in this repository's own .abcd/record-lint.json, so the same
`go run ./cmd/record-lint` CI step reads both, and guarding one of the two
leaves the step exposed either way.

Reproduced first, both failing before the change:

- CHANGELOG.md committed as a symlink to /dev/zero. The read never
  returned; the test hit its 20s ceiling with the lint still allocating.
- A configured changelog resolving outside the checkout. The rule's
  existing fail-closed behaviour hid this, because it only refuses a path
  it CANNOT read — point it at a file that is there and the gate happily
  judged a document outside the repository.

Same primitives as the sibling: containedRepoPath, containedRealPath,
fsutil.ReadGuarded. The cap is its own constant because a root changelog is
a repository's largest prose file and grows with every release; 4 MiB sits
far above any real one (this repo's is 260 KB) while still bounding a
hostile tree.

Assisted-by: Claude:claude-opus-5[1m]
`abcd banlist list --private` in a linked worktree emitted
`"primary_root": "/Users/<name>/…/<checkout>"` straight through the JSON
encoder, and the text render applied RedactHome — which rewrites a `$HOME`
prefix and leaves the checkout's DIRECTORY NAME standing. That name is very
often the private name the store itself bans (a project codename is the
commonest entry there is), and this layer's whole contract is that no pattern
value reaches output. The render runs on the success path of an ordinary status
read, so it lands in scrollback, agent transcripts, CI logs and any file the
caller redirected `--json` to; this repo's own privacy-hygiene rule classes
`/Users/<name>` as a SeverityError in committed content.

The committed shell guard already refuses to print it and says why. Give the Go
half the same restraint, in the stronger form: InheritedReport no longer carries
the path at all, so no front door can print what it does not hold. The remedy a
reader needs is "the primary checkout's <store path>", and the store path is
repo-relative — it names the location without naming the checkout.

Pinned by the Go twin of the shell guard's own
TestPreCommitHook_NeverPrintsThePrimaryCheckoutsPath, over both renders and both
verbs, with the primary checkout deliberately NAMED for the string its store
bans.

Assisted-by: Claude:claude-opus-5[1m]
@REPPL
REPPL enabled auto-merge August 28, 2026 21:58
TestGallopingProbeStaysBoundedOnLongLines failed on both CI runners at
22.5s against its 15s bar while passing locally at 11.0s: the bar is
wall clock, and the race detector's instrumentation costs more on the
runners than on a development machine.

The shape is not a regression. Measured on one machine, the branch that
introduced the probe and the merged tree take the same time, and the
cost-CLASS guard beside it still passes. What failed is a ceiling with
25% headroom on the machine that happened to run it.

Shrink the input rather than loosen the budget, which is the lever
scaleAdversarial already documents: this case costs an order of
magnitude more per unit than its siblings, so it carries a smaller
multiplier. It still discriminates, because its growth here is linear
in the multiplier (3.5s, 6.4s, 12.6s at 500, 1000, 2000 under -race),
so a return to quadratic still leaves the bar at once.

Assisted-by: Claude:claude-opus-5[1m]
@REPPL
REPPL added this pull request to the merge queue Aug 28, 2026
Merged via the queue into main with commit 328a675 Aug 28, 2026
12 checks passed
@REPPL
REPPL deleted the feat/implement-itd-150-162 branch August 28, 2026 22:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant