Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .abcd/record-lint.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
"issue_id_unique": {"enabled": true, "severity": "blocker", "issues_dir": ".abcd/work/issues"},
"issue_impact_valid": {"enabled": true, "severity": "blocker", "issues_dir": ".abcd/work/issues"},
"spec_lifecycle": {"enabled": true, "severity": "blocker", "specs_dir": "specs", "intents_dir": "intents"},
"spec_id_unique": {"enabled": true, "severity": "blocker", "specs_dir": "specs", "intents_dir": "intents"},
"persona_registry": {"enabled": true, "severity": "blocker", "registry": ".abcd/development/personas.json"},
"context_status_free": {"enabled": true, "severity": "blocker", "target": ".abcd/work/CONTEXT.md"},
"surface_coverage": {"enabled": true, "severity": "blocker", "commands_dir": "commands/abcd", "skills_dir": "skills", "registry": ".abcd/development/brief/04-surfaces/README.md"},
Expand Down
13 changes: 13 additions & 0 deletions .abcd/work/DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -749,3 +749,16 @@ parallel-agent merge contention bites.
itd-28 implements in the following run against the adapter decision.
Queue: plans/2026-07-24-next-run-queue.md (supersedes the 2026-07-18
queue file's pick-up role).

- 2026-07-25 — Record-id collisions across parallel branches (iss-115, iss-120)
fixed as a class: one canonical allocator primitive (recordid.MaxAcrossRefs)
mints max+1 over the union of the working tree AND every git ref, so a
committed id on another branch is seen; git-unreadable-over-a-repo degrades
loudly to tree-only, a non-repo mints quietly (no refs to collide). Detection
completes the class — a new spec_id_unique record-lint rule via the shared
validateIDUnique primitive (ADR ids left out of scope: this directive is
iss-/itd-/spc- only). Rejected: id-range leases per programme, mint-at-merge,
and non-sequential/hash ids — all trade away human-readable sequential ids
without a maintainer mandate. The residual uncommitted-mint window (both
branches mint before either commits) is accepted behind the armed detectors on
the merged PR union.
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ category: "bug"
source: "impl-review"
found_during: "itd-95/itd-96 merge to main"
found_at: ".abcd/development/specs"
resolution: "No spec-id uniqueness lint and local-tree-only minting fixed as a class: refs-union allocator (recordid.MaxAcrossRefs) folded into all three id families, plus the new spec_id_unique record-lint rule."
impact: fix
---

There is no uniqueness lint for spec ids, so two concurrent branches always mint the same spc-N. abcd mints the next free id from the local tree, so any two branches cut from the same base collide silently: the itd-73/itd-67 programme and the itd-95/itd-96 programme both minted spc-10 and spc-11 on 2026-07-21. The collision is invisible on each branch and only appears on merge, where it breaks the bidirectional intent-spec link in BOTH directions at once — 'itd-95 names spc-10, but spc-10 claims itd-73' and 'itd-67 names spc-11, but spc-11 claims itd-96' — because whichever spec wins the store lookup leaves the other intent's link dangling. iss-74 added an issue-id uniqueness lint; specs need the same guard, and record-lint is the natural home so CI catches it on the PR rather than a human catching it at merge. Consider also whether minting should reserve ids in a way concurrent branches cannot duplicate.
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ category: "tech-debt"
source: "user-observation"
found_during: "post-merge retrospective"
found_at: "internal/core/capture/capture.go"
resolution: "Local-tree id minting no longer races under parallel programmes: minting is now max+1 over the working tree AND every git ref, with loud degrade when git is unreadable and the armed record-lint detectors as the residual-window backstop."
impact: fix
---

Sequential record ids are minted from the local tree, so two parallel programmes mint the same next id — both took spc-10, spc-11, iss-110 and iss-111, costing four renumber commits across the episode. This generalises iss-115 (spec ids lack a uniqueness lint): issue and intent ids have armed detectors via the shared validateIDUnique primitive, spec and ADR ids have none, and detection-after-collision is the only mitigation anywhere. The design call is whether detection everywhere is enough or minting itself should be made collision-free (id-range leases per programme, mint-at-merge), which trades away human-readable sequential ids.
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,20 @@ called out in a **Breaking** section.

### Added

- **Record-id minting now sees every branch, and a spec-id uniqueness lint closes
the class** (iss-115, iss-120). Sequential ids (`iss-N`, `itd-N`, `spc-N`) were
minted from the local working tree only, so two branches cut from the same base
silently minted the same next id — invisible on each branch and surfacing only
at merge. Minting now folds in the highest id committed on every local and
remote-tracking branch (a single canonical refs-union scan), so once one branch
commits an id, the other mints past it. When git cannot be read over a present
repository the mint degrades to working-tree-only and says so loudly on stderr
(never a silent fallback); a directory that is not a repository has no branches
to collide with and mints quietly. The residual window — two branches that both
mint before either commits — is caught by the record-lint uniqueness rules on
the merged pull request, which now cover spec ids too: the new `spec_id_unique`
rule flags every file claiming a duplicate `spc-N`, mirroring the existing
`issue_id_unique` and intent-id guards.
- **`abcd capture resolve` and `abcd intent "<text>"` can now stamp a product
`impact`** (iss-117). A resolved issue and a shipped intent are in the release
set, so the `issue_impact_valid` and `intent_impact_valid` record-lint blockers
Expand Down
12 changes: 11 additions & 1 deletion internal/core/capture/alloc.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,14 @@ func withLedgerLock(issuesRoot string, fn func() error) error {
// reservePath reserves an iss-N id and creates a zero-byte placeholder under
// open/, mirroring reserve_issue_path: flock -> scan max N -> O_EXCL create
// with bump-retry. When forceID is non-empty it demands that exact id.
func reservePath(issuesRoot, slug, forceID string) (string, string, error) {
//
// refFloor is the highest iss-N observed across other git refs
// (recordid.MaxAcrossRefs), computed by the caller before the lock: the reserved
// id starts at max(local max N, refFloor)+1, so a branch that has already
// committed a higher id is not re-minted (iss-115, iss-120). It is a floor, not a
// substitute for the local scan — the local scan alone sees uncommitted mints in
// this worktree, and the O_EXCL bump-retry still resolves any residual clash.
func reservePath(issuesRoot, slug, forceID string, refFloor int) (string, string, error) {
// Validate a caller-supplied ForceID against the iss-N shape BEFORE it is used
// to build a path or create a placeholder — a traversal id (../../evil) must
// never touch the filesystem outside the ledger, even transiently.
Expand All @@ -121,6 +128,9 @@ func reservePath(issuesRoot, slug, forceID string) (string, string, error) {
}

maxN := maxIssN(issuesRoot)
if refFloor > maxN {
maxN = refFloor
}
// Guard the id arithmetic below (maxN+1+attempt) against int overflow: a
// hand-crafted MaxInt-adjacent filename would otherwise wrap to a negative
// "iss--N" that fails reIssID, creating a bogus placeholder that only fails
Expand Down
2 changes: 1 addition & 1 deletion internal/core/capture/alloc_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ func TestReservePathRefusesOverflowingCounter(t *testing.T) {
if err := os.WriteFile(filepath.Join(ir, "open", name), nil, 0o644); err != nil {
t.Fatal(err)
}
id, target, err := reservePath(ir, "note", "")
id, target, err := reservePath(ir, "note", "", 0)
if !errors.Is(err, ErrAllocatorContention) {
t.Fatalf("reservePath must refuse an overflowing counter, got id=%q target=%q err=%v", id, target, err)
}
Expand Down
14 changes: 9 additions & 5 deletions internal/core/capture/capture.go
Original file line number Diff line number Diff line change
Expand Up @@ -119,12 +119,16 @@ type CaptureRequest struct {
ForceID string // migrator-only; "" = auto-allocate
}

// CaptureResult is the outcome of a successful Capture.
// CaptureResult is the outcome of a successful Capture. MintWarning is the
// loud-degrade note from the iss-id refs-union scan (empty when the scan
// completed) — the surface MUST render it so a degrade to working-tree-only
// minting is never silent.
type CaptureResult struct {
ID string `json:"id"`
Slug string `json:"slug"`
Path string `json:"path"`
Status State `json:"status"` // always "open"
ID string `json:"id"`
Slug string `json:"slug"`
Path string `json:"path"`
Status State `json:"status"` // always "open"
MintWarning string `json:"mint_warning,omitempty"`
}

// ResolveRequest moves an open issue to resolved/.
Expand Down
15 changes: 14 additions & 1 deletion internal/core/capture/workflow.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"strings"

"github.com/REPPL/abcd-cli/internal/core/changelog"
"github.com/REPPL/abcd-cli/internal/core/recordid"
"github.com/REPPL/abcd-cli/internal/fsutil"
)

Expand Down Expand Up @@ -42,7 +43,18 @@ func Capture(req CaptureRequest) (CaptureResult, error) {
return CaptureResult{}, err
}

issID, placeholder, err := reservePath(issuesRoot, slugNorm, req.ForceID)
// Fold in the highest iss-N committed on any other git ref so two parallel
// branches cannot re-mint the same id once one has committed it (iss-115,
// iss-120). The scan runs outside the ledger lock (refs are immutable during
// this op) and yields a floor for reservePath; a degrade to working-tree-only
// is surfaced via MintWarning, never swallowed. A ledger outside the work tree
// (a non-default IssuesRoot) has no ref coverage, so it keeps tree-only minting.
scan := recordid.RefScan{}
if rel, relErr := filepath.Rel(repoRoot, issuesRoot); relErr == nil && !strings.HasPrefix(rel, "..") {
scan = recordid.MaxAcrossRefs(repoRoot, "iss", []string{filepath.ToSlash(rel)})
}

issID, placeholder, err := reservePath(issuesRoot, slugNorm, req.ForceID, scan.Max)
if err != nil {
return CaptureResult{}, err
}
Expand All @@ -55,6 +67,7 @@ func Capture(req CaptureRequest) (CaptureResult, error) {
// Machine output carries a repo-relative locator, never an absolute
// developer-identity path (iss-81).
result.Path = fsutil.RepoRel(repoRoot, result.Path)
result.MintWarning = scan.Warning()
return result, nil
}

Expand Down
2 changes: 1 addition & 1 deletion internal/core/capture/workflow_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ func TestReservePathRejectsUnsafeForceID(t *testing.T) {
t.Fatal(err)
}
for _, bad := range []string{"../../evil", "iss-1/x", "iss-1 ", "not-an-id", "iss-1/../../evil"} {
id, target, err := reservePath(ir, "note", bad)
id, target, err := reservePath(ir, "note", bad, 0)
if err == nil {
t.Fatalf("reservePath must reject unsafe ForceID %q before any fs op (got id=%q target=%q)", bad, id, target)
}
Expand Down
46 changes: 34 additions & 12 deletions internal/core/intent/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package intent

import (
"fmt"
"math"
"os"
"path/filepath"
"regexp"
Expand All @@ -11,6 +12,7 @@ import (
"time"

"github.com/REPPL/abcd-cli/internal/core/changelog"
"github.com/REPPL/abcd-cli/internal/core/recordid"
"github.com/REPPL/abcd-cli/internal/fsutil"
)

Expand All @@ -36,10 +38,10 @@ const maxSlugLen = 60
// null and whose spec_id is null) and passes Validate; a human expands it, then
// `abcd intent plan` schedules it. This is the quoted-text create path itd-46
// delivers — the create half of what spc-6 AC3 (promote) needs.
func CreateFromText(repoRoot, text, impact string) (Intent, error) {
func CreateFromText(repoRoot, text, impact string) (Intent, string, error) {
trimmed := strings.TrimSpace(text)
if trimmed == "" {
return Intent{}, fmt.Errorf("intent: refusing to create from empty text")
return Intent{}, "", fmt.Errorf("intent: refusing to create from empty text")
}
// impact is optional on a draft (intent_impact_valid gates the move into
// shipped/, not the seed), but when set it must be a legal, non-internal
Expand All @@ -49,23 +51,25 @@ func CreateFromText(repoRoot, text, impact string) (Intent, error) {
if impact != "" {
imp, err := changelog.ParseImpact(impact)
if err != nil {
return Intent{}, fmt.Errorf("intent: %w", err)
return Intent{}, "", fmt.Errorf("intent: %w", err)
}
if imp == changelog.ImpactInternal {
return Intent{}, fmt.Errorf("intent: impact must not be internal on an intent — a press-release-first intent is user-facing by definition; declare one of additive|breaking|fix, or record the work as an issue instead")
return Intent{}, "", fmt.Errorf("intent: impact must not be internal on an intent — a press-release-first intent is user-facing by definition; declare one of additive|breaking|fix, or record the work as an issue instead")
}
}
slug, err := deriveIntentSlug(trimmed)
if err != nil {
return Intent{}, err
return Intent{}, "", err
}

var created Intent
var mintWarning string
err = withIntentMintLock(repoRoot, func() error {
id, err := nextIntentID(repoRoot)
id, warn, err := nextIntentID(repoRoot)
if err != nil {
return err
}
mintWarning = warn
draftsDirAbs := filepath.Join(repoRoot, IntentsRelDir, BucketDrafts)
if err := ensureRealDir(draftsDirAbs, filepath.Join(IntentsRelDir, BucketDrafts)); err != nil {
return err
Expand All @@ -92,9 +96,9 @@ func CreateFromText(repoRoot, text, impact string) (Intent, error) {
return nil
})
if err != nil {
return Intent{}, err
return Intent{}, "", err
}
return created, Validate(created)
return created, mintWarning, Validate(created)
}

// deriveIntentSlug lowercases the text, collapses non-[a-z0-9] runs to a single
Expand All @@ -119,9 +123,14 @@ func deriveIntentSlug(text string) (string, error) {
var slugNonAlnumRe = regexp.MustCompile(`[^a-z0-9]+`)

// nextIntentID returns the next free itd-N: max N over every intent file in every
// bucket, plus one. Called under the mint lock so the scan and the subsequent
// write are one critical section (no two concurrent creates observe the same max).
func nextIntentID(repoRoot string) (string, error) {
// bucket AND over intent filenames on every other git ref, plus one. Called under
// the mint lock so the working-tree scan and the subsequent write are one critical
// section (no two concurrent creates in the same worktree observe the same max).
// Folding in recordid.MaxAcrossRefs is what stops two parallel branches from
// re-minting the same itd-N once one has committed it (iss-115, iss-120). The
// returned mintWarning is non-empty (and MUST be surfaced) when the ref scan
// degraded to working-tree-only, so the fallback is never silent.
func nextIntentID(repoRoot string) (id, mintWarning string, err error) {
max := 0
for _, bucket := range Buckets {
dir := filepath.Join(repoRoot, IntentsRelDir, bucket)
Expand All @@ -146,7 +155,20 @@ func nextIntentID(repoRoot string) (string, error) {
}
}
}
return fmt.Sprintf("itd-%d", max+1), nil
scan := recordid.MaxAcrossRefs(repoRoot, "itd", []string{IntentsRelDir})
if scan.Max > max {
max = scan.Max
}
// Guard the max+1 below against int overflow: a hand-crafted MaxInt itd-N
// (a local file or a fetched remote-tracking ref carrying itd-<MaxInt>-x.md)
// parses to math.MaxInt with no error, so max+1 would wrap to math.MinInt and
// mint itd--9223372036854775808 — a malformed draft WriteFileAtomic persists
// before Validate runs, plus a mint DoS for the family. Refuse clearly instead,
// mirroring the capture allocator's ceiling guard.
if max >= math.MaxInt {
return "", "", fmt.Errorf("intent: itd-N counter near the integer ceiling (highest observed %d); refusing to allocate", max)
}
return fmt.Sprintf("itd-%d", max+1), scan.Warning(), nil
}

// seedDraft renders the canonical draft skeleton: the full draft frontmatter set
Expand Down
8 changes: 4 additions & 4 deletions internal/core/intent/create_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import (
func TestCreateFromTextSeedsDraft(t *testing.T) {
root := t.TempDir()

it, err := CreateFromText(root, "I want users to feel the card respects their time", "")
it, _, err := CreateFromText(root, "I want users to feel the card respects their time", "")
if err != nil {
t.Fatalf("CreateFromText: %v", err)
}
Expand Down Expand Up @@ -59,7 +59,7 @@ func TestCreateFromTextAllocatesNextID(t *testing.T) {
writeFile(t, root, plannedDir+"/itd-9-beta.md",
"---\nid: itd-9\nslug: beta\nspec_id: spc-1\nkind: standalone\n---\n# beta\n")

it, err := CreateFromText(root, "another product intent", "")
it, _, err := CreateFromText(root, "another product intent", "")
if err != nil {
t.Fatalf("CreateFromText: %v", err)
}
Expand All @@ -73,7 +73,7 @@ func TestCreateFromTextAllocatesNextID(t *testing.T) {
func TestCreateFromTextRefusesEmpty(t *testing.T) {
root := t.TempDir()
for _, in := range []string{"", " ", "\t\n"} {
if _, err := CreateFromText(root, in, ""); err == nil {
if _, _, err := CreateFromText(root, in, ""); err == nil {
t.Fatalf("CreateFromText(%q) must be refused", in)
}
}
Expand All @@ -87,7 +87,7 @@ func TestCreateFromTextRefusesEmpty(t *testing.T) {
// over a freshly seeded draft — the "abcd audit stays green" guarantee.
func TestCreateFromTextPassesRecordLint(t *testing.T) {
root := t.TempDir()
if _, err := CreateFromText(root, "seeded from a quoted-text capture", ""); err != nil {
if _, _, err := CreateFromText(root, "seeded from a quoted-text capture", ""); err != nil {
t.Fatalf("CreateFromText: %v", err)
}
cfg := lint.Config{
Expand Down
6 changes: 3 additions & 3 deletions internal/core/intent/impact_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ func lintIntentImpact(t *testing.T, root string) []lint.Finding {
// intent the tool can never ship past its own blocker.
func TestCreateFromTextStampsImpact(t *testing.T) {
root := t.TempDir()
it, err := CreateFromText(root, "a user-facing improvement worth shipping", "additive")
it, _, err := CreateFromText(root, "a user-facing improvement worth shipping", "additive")
if err != nil {
t.Fatalf("CreateFromText: %v", err)
}
Expand Down Expand Up @@ -67,7 +67,7 @@ func TestCreateFromTextStampsImpact(t *testing.T) {
func TestCreateFromTextRejectsBadImpact(t *testing.T) {
root := t.TempDir()
for _, bad := range []string{"additiv", "Additive", "internal", `"fix"`} {
if _, err := CreateFromText(root, "some intent text", bad); err == nil {
if _, _, err := CreateFromText(root, "some intent text", bad); err == nil {
t.Fatalf("CreateFromText with impact %q must be refused", bad)
}
}
Expand All @@ -82,7 +82,7 @@ func TestCreateFromTextRejectsBadImpact(t *testing.T) {
// before, so the existing capture->intent promotion flow is unchanged.
func TestCreateFromTextImpactOptional(t *testing.T) {
root := t.TempDir()
it, err := CreateFromText(root, "seeded without a judgement yet", "")
it, _, err := CreateFromText(root, "seeded without a judgement yet", "")
if err != nil {
t.Fatalf("CreateFromText: %v", err)
}
Expand Down
9 changes: 6 additions & 3 deletions internal/core/intent/intent.go
Original file line number Diff line number Diff line change
Expand Up @@ -182,10 +182,13 @@ func sortStrings(s []string) {
}

// PlanResult reports a completed Plan: the updated planned intent and the spec
// minted to realise it.
// minted to realise it. MintWarning is the loud-degrade note from the spec-id
// refs-union scan (empty when the scan completed) — the surface MUST render it so
// a degrade to working-tree-only minting is never silent.
type PlanResult struct {
Intent Intent `json:"intent"`
Spec spec.Spec `json:"spec"`
Intent Intent `json:"intent"`
Spec spec.Spec `json:"spec"`
MintWarning string `json:"mint_warning,omitempty"`
}

// LinkResult reports a completed Link: the updated intent and the spec it now
Expand Down
2 changes: 1 addition & 1 deletion internal/core/intent/intent_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,7 @@ func TestPlanReusesExistingSpecForIntent(t *testing.T) {
root := t.TempDir()
writeFile(t, root, draftsDir+"/itd-10-alpha.md", draftWithAC("itd-10", "alpha"))
// Pre-create the spec for this intent; the draft is still an unlinked draft.
sp, err := spec.Create(root, "itd-10", "alpha")
sp, _, err := spec.Create(root, "itd-10", "alpha")
if err != nil {
t.Fatal(err)
}
Expand Down
5 changes: 3 additions & 2 deletions internal/core/intent/lifecycle.go
Original file line number Diff line number Diff line change
Expand Up @@ -138,8 +138,9 @@ func Plan(repoRoot, intentID string) (PlanResult, error) {
return PlanResult{}, err
}
sp, ok := store.ByIntent(intentID)
var mintWarning string
if !ok {
sp, err = spec.Create(repoRoot, intentID, it.Slug)
sp, mintWarning, err = spec.Create(repoRoot, intentID, it.Slug)
if err != nil {
return PlanResult{}, err
}
Expand Down Expand Up @@ -184,7 +185,7 @@ func Plan(repoRoot, intentID string) (PlanResult, error) {
it.SpecID = sp.ID
it.Bucket = BucketPlanned
it.Path = plannedRel
return PlanResult{Intent: it, Spec: sp}, nil
return PlanResult{Intent: it, Spec: sp, MintWarning: mintWarning}, nil
}

// Link retroactively writes the derived spec_id link on an existing planned
Expand Down
Loading