feat: repo identity — one canonical block, every surface held to it (itd-102) - #158
Merged
Conversation
The 2026-07-27 grill settled the design; the spec records the settlements plus the mechanism (fixed-bullet markdown identity block, config records only its location, warn-tier positioning check with re-render as a proposed diff, iss-143 as acceptance corpus). Fourth iss-80 mint collision: renumbered to spc-19. Assisted-by: Claude:claude-fable-5
The lexical repo-relative-path guard lived unexported inside the lifeboat packer, but it is the general primitive any caller needs before joining a path that arrived as data onto a trusted root. Move it to the canonical path home (internal/fsutil) so a second caller extends the primitive rather than copying it; lifeboat's validRelPath now delegates. Behaviour-preserving: the implementation is unchanged, and the exported form gains its own table test. Assisted-by: Claude:claude-opus-5
A repository's positioning fragments silently: the README strapline, the manifest description, and the conventions file's opening are edited at different moments until three surfaces tell three stories (iss-143). This adds the core primitive that stops that. internal/core/positioning parses one canonical markdown identity block — the fixed `- **Title:** / **Tagline:** / **Pitch:**` bullets under a recorded heading, title and tagline required, a wrapped pitch joined — and compares every registered surface against it. Markdown stays the single source of truth; .abcd/positioning.json records only where the block lives, how loudly the family reports, and which surfaces render from it. The registry is data: a surface names candidate files (first present wins, so one entry covers several manifest formats), a locator (a capture-group regexp or a top-level JSON field), and the block fields it must carry. Comparison normalises markup, dashes, wrapping, and case, so a bolded or line-wrapped tagline is not drift while a reworded one is. Every registry value is validated before use, and every file read goes through the guarded primitive. The iss-143 three-variant drift is the acceptance corpus, one test per variant plus all three together. Assisted-by: Claude:claude-opus-5
Drift is found, never fixed behind the maintainer's back. Propose runs the positioning check and, for every surface that drifted, renders what the surface would say if it derived from the identity block — as a unified diff naming the exact removed line and the canonical one that replaces it. It writes nothing, and there is no flag that makes it: a test snapshots every file's content and mtime around the call and fails on any change. A surface the locator could not find yields no hunk rather than a patch against a line that was never located, and a manifest replacement is re-escaped so the proposed JSON still parses. A further test applies every proposal and re-runs the check, so an adopted proposal is proven to leave the repo clean. Assisted-by: Claude:claude-opus-5
The audit verb gains an identity-positioning rule: it holds every registered surface to the repo's canonical identity block and names the file, the exact drifted line, and the canonical line it should carry. Warn-tier by default — it highlights, it never gates and never rewrites; a repo that wants a hard gate sets "severity": "blocker" in its registry and every finding in the family becomes an error. The rule is Where-gated on a committed .abcd/positioning.json, so a repo that has not adopted the check is skipped rather than failed. A registry that cannot be loaded, and a block that cannot be read, are findings of this family rather than a silent pass or an abort that takes the other rules down with it. Every error path is path-free: a test drives each one and fails on an absolute repo root in a finding (iss-81). Assisted-by: Claude:claude-opus-5
`abcd identity` renders the canonical block and every registered surface's verdict; `abcd identity render` prints the proposed correction for each drifted surface as a unified diff; `abcd identity init` records the block and the pointer that says where it lives. render writes nothing, and the tests hold it there: a tree snapshot (content and mtime) around the call, and a check that --write/--apply/ --fix/--in-place/--force are all rejected. init adopts an existing block rather than re-interviewing over it, refuses to repoint a registry that is already committed, and re-running it on an adopted repo is a no-op. Bare `identity` is a status render and exits 0 even when it reports drift; the gate is `abcd audit` and its tri-state exit. Assisted-by: Claude:claude-opus-5
`abcd identity render` passed its whole unified diff through the single-line sanitiser, which masks newlines so an injected line break cannot forge report lines. On a diff that is the wrong trade: every newline became a '?' and the patch arrived as one unreadable smear that no tool could apply. termsafe gains SanitizeBlock — the same masking, applied per line with the newlines preserved — and the render path uses it. Bare carriage returns are still masked, so a line cannot be overwritten in place. The surface test now asserts the markers open their own lines. Assisted-by: Claude:claude-opus-5
.abcd/positioning.json registers abcd's canon — the brief product chapter's "Identity (canonical)" section, unchanged — and the three surfaces that render from it: the README strapline, the plugin manifest description, and the AGENTS.md opening. All three come out clean. Adds the /abcd:identity plugin command (bare / render / init, with the interview wording and the detect-before-asking rule), its brief surface page and registry row, the identity-positioning row in the audit rule table, the prepare flow's identity step, the regenerated CLI reference and surface snapshot, and a CHANGELOG entry. Assisted-by: Claude:claude-opus-5
The proposal rendered a hunk no tool would apply in three cases, all found by pointing `git apply --check` at it: - a file ending in a newline was split into N+1 lines, so the hunk carried a phantom trailing context line that is not in the file; - a file NOT ending in a newline got no no-newline marker, which every patch tool requires; - a CRLF file got an LF patch, which cannot match its bytes. Line endings now round-trip (the diff and the proposed content both carry CRLF back when the file uses it), the marker is emitted against the trailing context line or against both sides of the change, and a locator whose paragraph terminator is end-of-file no longer swallows the file's final newline into the replaced span — which also panicked the renderer by indexing past the last line. The regression test drives five shapes through `git apply --check`. Assisted-by: Claude:claude-opus-5
The diff test only asked whether `git apply` accepted the hunk. The patch and the SurfaceDiff.Proposed field are two renderings of the same change, so it now applies the patch for real and compares the resulting file to Proposed byte for byte — if they ever disagree, one of them is lying to the maintainer. A trailing-blank-line shape joins the cases. Assisted-by: Claude:claude-opus-5
Two review findings, both reproduced before the fix. SanitizeBlock dropped the carriage return from every line, so the core's CRLF round-trip was voided at the front door: `abcd identity render` printed an LF patch that no tool would apply to a CRLF surface, while the same repo's --json output carried a correct one. The CR of a CRLF pair is committed by the newline that follows it and can overprint nothing, so it now survives; every bare carriage return — including one inside a run before a newline, and one trailing the final line — is still masked. Asserted at the CLI boundary, which is where the guarantee has to hold; the core test could not see this. `identity init` routed a heading with incomplete bullets into the scaffold branch, but writeBlock only appends: the append left a second copy of the heading, the parser still read the broken first one, Init failed, no registry was written, and every retry appended again. Only an absent file or heading is fixable by appending, so an incomplete block is now refused before any write, naming the file and the missing bullet. Also backs the finding-message truncation off to a rune boundary: the byte cut split a multi-byte rune and %q rendered the remains as byte escapes in the report. Assisted-by: Claude:claude-opus-5
The positioning check resolved every configured path with filepath.Join(root, rel) behind fsutil.ValidRelPath. That guard is LEXICAL — it rejects ".." and absolute paths and can say nothing about symlinks — and fsutil.ReadGuarded's O_NOFOLLOW guards only the LEAF. An audited repo that COMMITS a symlinked directory (git mode 120000: `etclink -> /etc`, or `home -> ../../..`) and names a file through it therefore had its read resolve straight out of the repository. A surface's content is quoted verbatim into the Report, so `abcd audit` printed an outside file into its finding and `abcd identity --json` emitted up to 1 MiB of it whole; SSH keys, cloud credentials, and host tokens are all reachable from a relative-ancestor link. Containment now rests on os.Root, the pattern the lifeboat embark path already uses, so the OS enforces it against every ancestor component rather than a string check that cannot see one. All four sites route through a root opened per operation: the surface read (check), the canonical block read (block), the registry read (config), and onboarding's block and registry writes (init) — plus the proposal's re-read, which carries surface bytes into a diff. ValidRelPath stays as the cheap first reject; it is no longer load-bearing. fsutil gains the contained counterparts of its three primitives, so the one-canonical-primitive invariant holds and no caller hand-rolls containment: ReadGuardedInRoot keeps every leaf guarantee ReadGuarded gives (symlinked leaf refused, non-regular refused, O_NONBLOCK, the byte cap enforced on both the fstat size and the bytes read) and adds os.SameFile to close the lstat-to-open swap; WriteFileAtomicInRoot and WriteFileAtomicPreserveModeInRoot keep the write, fchmod, fsync, rename, parent-fsync ordering with an unpredictable temp name created inside the root. A refusal is never an absence: an escaping path is not an os.IsNotExist error, so the loop that skips absent candidates fails closed on it, and the new ErrSurfaceUnreadable sentinel names the refusal to a caller. The regression test builds the attack in both real shapes — an absolute target and a relative ancestor — and asserts that no byte from outside the root reaches a Report, a Block, a Config, a proposal, or an error message, and that onboarding writes nothing outside. Before the change it reproduces the leak at all four sites. Assisted-by: Claude:claude-opus-5
One CRLF anywhere in a surface file makes every emitted hunk line carry a carriage return, so a file with mixed endings gets a diff whose LF-only lines gain a CR they do not have on disk. Recording it rather than handling it: the failure is loud, not silent — the context lines stop matching and `git apply` refuses the patch, so nothing is ever written on the strength of a wrong diff. Assisted-by: Claude:claude-opus-5
Both sit beside the escape the containment fix closed and neither is one: an unbounded surface count in a committed registry (a per-run memory bound, not a boundary crossing), and a registry naming a surface under .git/, which stays inside the containment root and reaches only the operator's own stdout. Recorded rather than fixed so the containment change stays one purpose. Assisted-by: Claude:claude-opus-5
…ntity # Conflicts: # .abcd/development/brief/04-surfaces/README.md # .abcd/development/release/surface.json # .abcd/work/DECISIONS.md # docs/reference/cli/commands.md # internal/surface/cli/cli.go
REPPL
enabled auto-merge
July 28, 2026 07:39
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Implements itd-102 (repo identity) per spc-19 — the spec derived from the 2026-07-27 Grill Settlements. abcd asks a repo for its identity once and holds every surface to it.
What this delivers
internal/core/positioning/— a new package (deliberately not folded intointernal/core/identity/, which is the git commit-author gate; the two share only the English word). Parses the canonical identity block (fixed- **Title:** / **Tagline:** / **Pitch:**bullets, title+tagline required, pitch optional, multi-line accepted), and runs a positioning check over the three default surfaces — README strapline, plugin manifest description, AGENTS.md opening — comparing each to the block..abcd/positioning.jsonrecords the block location and the surface registry (config-driven, not hardcoded to one manifest format); per-repo config upgrades the warn-tier finding to blocker; additional surfaces are registrable, none silently.abcd audit. A drifted surface yields a warn finding naming the exact file, line, drifted text, and the canonical line it should carry — with iss-143's real three-variant tagline drift as the fixture corpus.abcd identity renderprints the proposed corrected surface as a unified diff to stdout and writes nothing (asserted by test; no flag can make it write).abcd identity initscaffolds the block + pointer during onboarding; the prepare skill gains the detect-existing-block-first interview step.abcd audithere is clean on the identity family.Settlements honoured
Reviews
identity renderemitted an unappliable patch; aninitpath that appended a duplicate heading on every retry), final SHIP. One residual below the bar — mixed line-ending files emit a CR on LF-only lines; the failure is loud (git applyrefuses), now documented on theunifiedDiffcomment rather than silently fixed.abcd auditread and print files from outside the repo root (~/.ssh,~/.aws/credentials). Fixed by routing all four positioning reads, theinitwrite path (also found escaping), and the render re-read throughos.Root(the repo'sreadLifeboatFilecontainment pattern); lexicalValidRelPathis no longer load-bearing. Symlinked-ancestor regression tests (absolute + relative shapes) added. Re-review reproduced the pre-fix exploit and confirmed it refuses post-fix on macOS and Linux: final APPROVE.Known gaps, recorded not hidden
.git/; it stays inside the containment root and reaches only the operator's own stdout.Reuse
validRelPathwas promoted fromlifeboat/pack.gotofsutil.ValidRelPathin a behaviour-preserving commit rather than copied a third time.Gates:
make preflight, docs lint and record-lint all clean (0 blockers).