Skip to content
Merged
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
---
id: adr-32
slug: issue-ledger-is-working-tier-data
status: accepted
date: 2026-07-08
supersedes: null
superseded_by: null
related_intents: [itd-4, itd-36]
related_rfcs: []
related_adrs: [adr-3, adr-5, adr-26, adr-30]
---

# ADR-32: The issue ledger is working-tier data, not authored record

## Context

The capture ledger (itd-4) writes schema-checked `iss-N` files with folder-as-status
(`open/`, `resolved/`, `wontfix/`). It was sited at `.abcd/development/activity/issues/` —
inside the durable design-record tree that `record-lint` governs.

Dogfooding surfaced a shipped-vs-shipped contradiction: `capture`'s issue schema *requires*
a `created:` field, while `record-lint`'s `no_git_metadata` rule *bans* `created:` anywhere
under `.abcd/development` (git log/blame is canonical). Running `capture` therefore broke
`record-lint` — two shipped components mutually incompatible, the moment the tool is used on
its own repo. The finding is itself in the ledger as `iss-15`.

The root cause is a category error: the ledger is generated, schema-governed *data* that
opens and resolves, but it was living in the tree meant for authored, git-canonical *prose*.

## Decision

1. **Tier.** The issue ledger lives in the **work tier** — `.abcd/work/issues/` — not the
design record. It is committed, shared working state (folder-as-status per adr-3), not
durable design record. This takes it out of `record-lint`'s root and dissolves the conflict
at its source.

2. **No git-inferable timestamps.** `created` and `updated` are dropped from the schema; git
is the canonical source of time (consistent with `no_git_metadata` and the
derive-don't-store principle). The reader **tolerates** legacy `created`/`updated` on read
(accept, then drop) so an older ledger degrades gracefully rather than being rejected.

3. **Priority is derived, never stored.** Issues carry a one-direction `blocked_by: [iss-N]`
dependency edge; the inverse and the ranking are computed. `capture list`/status order
*unblocked-first, then by severity*, annotating blocked rows `[blocked-by iss-N]`. This
reuses the spec engine's dependency-graph model (adr-26, the run-seam selector) rather than
inventing a parallel priority label.

4. **README contracts by category.** Per-folder READMEs stay for the authored record — they
are local membership contracts. A generated-data store gets **one** README at its root; its
state leaves are self-evident. The tier boundary keeps `directory_coverage` aimed only at
prose, so the lint needs no change.

## Alternatives Considered

- **Exempt `.abcd/development/activity/` in `record-lint`.** The first-pass fix, briefly
recommended and then reversed. Rejected as symptom suppression: it carves the lint around a
category error rather than fixing it, and leaves the ledger in the wrong tier. The reversal
is the methodology-over-local-fixes principle in action — recorded so the reasoning is not
re-litigated.
- **A stored `priority` field.** Rejected: priority is volatile and contextual; a stored label
is the hand-maintained-status drift the roadmap dashboard and adr-5 already avoid.
- **Blanket-exempt all of `.abcd/` from READMEs.** Rejected: it discards the local contracts
the authored record relies on, to fix a problem that only exists for generated data.
- **Keep the timestamps as portable data.** Rejected: the ledger is read in-repo with git
present; git derives creation and the lifecycle moves, and the store is not bundled in the
release artefact.

## Consequences

- The `capture` ⊥ `record-lint` conflict is gone at the root; running `capture` no longer
breaks the gate.
- Design-record references to the old path (e.g. `build-sequence.md`) are now drift to
reconcile, tracked as ledger issues.
- Future generated stores (logbook, lifeboat) inherit the rule: working/generated data lives
outside the record tier and carries no git-inferable metadata.
- A self-hosting datapoint: abcd's first real contradiction was found by running abcd on abcd
(`iss-15`) — the dogfooding thesis paying out in practice.
1 change: 1 addition & 0 deletions .abcd/development/decisions/adrs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,3 +133,4 @@ The intent lint (a Go implementation) extends to verify these reciprocally.
| [adr-29](0029-native-transcript-corpus.md) | A native local redacted transcript corpus | accepted | 2026-07-06 |
| [adr-30](0030-record-information-architecture.md) | Design-record information architecture — flat artefact-type folders | accepted | 2026-07-06 |
| [adr-31](0031-derived-versioning-from-intents.md) | The release version is derived from the intents in it, never authored (extends adr-19, adr-20) | accepted | 2026-07-07 |
| [adr-32](0032-issue-ledger-is-working-tier-data.md) | The issue ledger is working-tier data, not authored record — move to `.abcd/work/issues/`, drop git-inferable timestamps, derive priority | accepted | 2026-07-08 |
88 changes: 88 additions & 0 deletions .abcd/work/issues/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
# Issue ledger

The per-repo issue ledger: abcd's structured replacement for a free-form
`issues.md`. It lives here, under the shared working tier (`.abcd/work/`), so it
is committed and travels with the repository. Each issue is a single
YAML-frontmatter + Markdown-body file named `iss-<N>-<slug>.md`, with an
unpadded, per-repo `iss-N` id namespace.

This document is the store contract. The write side is
`internal/core/capture`; the front door is `abcd capture`.

## The three states

An issue's status is its folder — there is no `status:` frontmatter field.
Membership of one of these three directories *is* the status signal:

- `open/` — live, unresolved issues.
- `resolved/` — issues closed by an action; each carries a non-empty
`resolution` note.
- `wontfix/` — issues closed by an explicit decision not to act; each carries a
non-empty `wontfix_reason`.

An issue moves between states by being relocated between these folders, never by
editing a field. Do not add `README.md` files inside `open/`, `resolved/`, or
`wontfix/`: only genuine `iss-N` files belong there (stray markdown is ignored
by the scanner, but keeping the folders clean keeps the contract honest).

## Schema fields

Frontmatter is validated strictly (unknown keys are rejected). The reader
handles `schema_version: 1`.

Required:

- `schema_version` — integer, currently `1`.
- `id` — `iss-N` (matches the filename's id).
- `slug` — kebab-case summary (matches the filename's slug).
- `severity` — one of `critical`, `major`, `minor`, `nitpick`.
- `category` — the loose taxonomy (`bug`, `documentation`, `drift`,
`inconsistency`, `tech-debt`, `security`, `ux`, `process`,
`architectural-insight`, `future-work-seed`, `observation`).
- `source` — the surfacing channel (`plan-review`, `impl-review`,
`manual-test`, `review-followup`, `agent-finding`, `user-observation`,
`drift-detection`, `memory-curation`).
- `found_during` — non-empty session or command context.

Optional:

- `found_at` — repo-relative path or conceptual location.
- `related_intents` — list of `itd-N` ids.
- `related_specs` — list of `fn-N` ids.
- `related_issues` — list of `iss-N` ids.
- `blocked_by` — list of `iss-N` ids this issue depends on (see below).
- `promoted_to` — the `itd-N` this issue graduated into.
- `resolution` — required and non-empty in `resolved/`; forbidden elsewhere.
- `wontfix_reason` — required and non-empty in `wontfix/`; forbidden elsewhere.
- `resolved_by` — optional pointer object (`intent`, `spec`, `commit`).
- `details`, `suggested_fix`, `synthesis_clusters` — free-form provenance.

There is no `created` or `updated` field. Git is the canonical source of an
issue's timeline; the ledger does not duplicate it.

## The capture verb

`abcd capture "<text>"` appends a new issue to `open/`, allocating the next
`iss-N`. Flags refine the frontmatter — `--severity`, `--category`, `--source`,
`--slug`, `--found-during`, `--found-at`, and `--blocked-by` (a comma-separated
list of `iss-N` ids). Bare `abcd capture` renders a read-only status board;
`abcd capture list` filters by state; `abcd capture resolve` and
`abcd capture wontfix` move an open issue to its closed folder with a note.

## Derived priority

`blocked_by` records a typed dependency edge in one direction only: the
dependent issue names the issues it waits on. There is no stored priority field.

Priority is a read-time projection computed by `list` and the status board:

1. **Unblocked issues come first.** An issue is *blocked* if any of its
`blocked_by` targets is still in `open/`; once every target has moved to
`resolved/` or `wontfix/`, the issue is unblocked again.
2. **Within each group, higher severity comes first**
(`critical` > `major` > `minor` > `nitpick`).

Blocked rows are annotated with the still-open targets, for example
`[blocked-by iss-3,iss-7]`. Because the projection is derived, resolving a
blocker automatically re-prioritises everything that depended on it — nothing is
stored, so nothing goes stale.
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,26 @@ called out in a **Breaking** section.

## [Unreleased]

### Added

- `abcd capture --blocked-by <iss-N,…>` records typed dependency edges on a new
issue, and `capture list` / the status board now render a derived-priority
view: unblocked issues first, then by severity, with blocked rows annotated
`[blocked-by iss-N,…]`. There is no stored priority — the ordering is a
read-time projection, so resolving a blocker re-prioritises its dependents
automatically.
- A store-contract README for the issue ledger (`.abcd/work/issues/README.md`).

### Changed

- The issue ledger moved from `.abcd/development/activity/issues` to
`.abcd/work/issues` (the committed shared-working tier).

### Removed

- The `created` and `updated` frontmatter fields on issues. Git is the canonical
source of an issue's timeline; the ledger no longer duplicates it.

## [v0.1.0] - 2026-07-07

First tagged milestone: the Go rebuild through Phase 2. abcd is a single,
Expand Down
17 changes: 12 additions & 5 deletions commands/abcd/capture.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@ argument-hint: "[text] | list --open|--resolved|--wontfix|--all | resolve <iss-N
# `/abcd:capture` — issue ledger

The lightweight write side of the structured issue ledger under
`.abcd/development/activity/issues/`. Every issue gets a stable `iss-N` id,
schema-checked frontmatter, and folder-as-status (`open/`, `resolved/`,
`wontfix/`). Bare invocation **performs zero writes**.
`.abcd/work/issues/`. Every issue gets a stable `iss-N` id, schema-checked
frontmatter, and folder-as-status (`open/`, `resolved/`, `wontfix/`). Bare
invocation **performs zero writes**.

## Status (bare)

Expand All @@ -36,7 +36,12 @@ default): `--severity` (`nitpick|minor|major|critical`, default `minor`),
`--category` (default `observation`), `--source` (default `user-observation`),
`--found-during` (session/command context, default `manual-capture`),
`--found-at` (optional repo-relative path), `--slug` (overrides the slug derived
from the text). Report the new `id`, `status`, and `path` from the JSON.
from the text), `--blocked-by` (comma-separated `iss-N` ids this issue depends
on). Report the new `id`, `status`, and `path` from the JSON.

Priority is **derived, never stored**: an issue is ranked lower while any of its
`--blocked-by` targets is still open, and `blocked_by` records the dependency in
one direction only (the inverse is computed).

## Query the ledger

Expand All @@ -48,7 +53,9 @@ abcd capture list --open --json # or --resolved / --wontfix / --all

The unfiltered form `abcd capture list` exits 2 with a "choose a filter"
message; there is no implicit default. Summarise each issue's `id`, `status`,
`severity`, and `slug`, sorted numerically by `iss-N`.
`severity`, and `slug`. The list is returned in **derived-priority order**:
unblocked issues first, then by severity (`critical` → `nitpick`); rows still
blocked by an open dependency are demoted and annotated `[blocked-by iss-N,…]`.

## Resolve / wontfix

Expand Down
15 changes: 9 additions & 6 deletions internal/core/capture/capture.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
// or prompt surface. The front doors under internal/surface/* marshal these
// results for their transport.
//
// The ledger lives at <repoRoot>/.abcd/development/activity/issues with three
// The ledger lives at <repoRoot>/.abcd/work/issues with three
// status directories (open/, resolved/, wontfix/) whose folder membership IS
// the status signal — there is no status: frontmatter field. Each issue is a
// YAML-frontmatter + Markdown-body file named iss-<N>-<slug>.md with an
Expand All @@ -20,7 +20,7 @@ import (
)

// LedgerRelPath is the ledger root relative to the repo worktree.
const LedgerRelPath = ".abcd/development/activity/issues"
const LedgerRelPath = ".abcd/work/issues"

// Enumerated field types (validated at the boundary; values mirror
// scripts/abcd/schemas/issue.schema.json).
Expand Down Expand Up @@ -85,18 +85,21 @@ type Issue struct {
Source Source `json:"source"`
FoundDuring string `json:"found_during"`
FoundAt string `json:"found_at,omitempty"`
Created string `json:"created"` // YYYY-MM-DD
Updated string `json:"updated,omitempty"`
RelatedIntents []string `json:"related_intents,omitempty"`
RelatedSpecs []string `json:"related_specs,omitempty"`
RelatedIssues []string `json:"related_issues,omitempty"`
BlockedBy []string `json:"blocked_by,omitempty"` // iss-N dependency edges
PromotedTo string `json:"promoted_to,omitempty"`
Resolution string `json:"resolution,omitempty"`
WontfixReason string `json:"wontfix_reason,omitempty"`
ResolvedBy *ResolvedBy `json:"resolved_by,omitempty"`
Status State `json:"status"` // derived from folder
Path string `json:"path"` // absolute
Body string `json:"body"`
// BlockedByOpen is the derived subset of BlockedBy whose targets are still in
// open/ (the priority projection populated by List/Status). Not a stored
// field: an empty slice means the issue is unblocked.
BlockedByOpen []string `json:"blocked_by_open,omitempty"`
}

// CaptureRequest is the input to Capture (append a new issue).
Expand All @@ -112,7 +115,8 @@ type CaptureRequest struct {
FoundAt string // optional; "" omits the field
RelatedIntents []string
RelatedSpecs []string
ForceID string // migrator-only; "" = auto-allocate
BlockedBy []string // iss-N dependency edges; each must match ^iss-[0-9]+$
ForceID string // migrator-only; "" = auto-allocate
}

// CaptureResult is the outcome of a successful Capture.
Expand Down Expand Up @@ -214,7 +218,6 @@ var (
reItdID = regexp.MustCompile(`^itd-[0-9]+$`)
reFnID = regexp.MustCompile(`^fn-[0-9]+$`)
reSlug = regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`)
reCreated = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`)
reFilenameID = regexp.MustCompile(`^(iss-[0-9]+)(?:-[a-z0-9]+(?:-[a-z0-9]+)*)?\.md$`)
reAbcdListID = regexp.MustCompile(`^(itd|fn|iss)-[0-9]+$`)
reSortIssID = regexp.MustCompile(`^iss-([0-9]+)(-|$|\.)`)
Expand Down
4 changes: 0 additions & 4 deletions internal/core/capture/roots.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,8 @@ import (
"path/filepath"
"regexp"
"strings"
"time"
)

// nowDate returns today's date as YYYY-MM-DD. A package var so tests can pin it.
var nowDate = func() string { return time.Now().Format("2006-01-02") }

// resolveRoots resolves (repoRoot, issuesRoot) from the request fields plus git
// discovery, mirroring _issue_lib._resolve_roots (contracts A–D). repoRoot is
// canonicalised; issuesRoot is made absolute without following symlinks so the
Expand Down
25 changes: 11 additions & 14 deletions internal/core/capture/validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,17 @@ var knownFields = map[string]bool{
"details": true, "suggested_fix": true, "related_intents": true,
"promoted_to": true, "related_specs": true, "related_issues": true,
"synthesis_clusters": true, "wontfix_reason": true, "resolution": true,
"resolved_by": true, "created": true, "updated": true,
"resolved_by": true, "blocked_by": true,
// created/updated are no longer written, but legacy ledgers still carry
// them. Tolerate (accept, then drop) them on read so an existing committed
// ledger is not rejected as an unknown property; the reader ignores their
// values entirely.
"created": true, "updated": true,
}

// uniqueItemsFields are the array properties issue.schema.json flags
// uniqueItems:true.
var uniqueItemsFields = []string{"related_intents", "related_specs", "related_issues", "synthesis_clusters"}
var uniqueItemsFields = []string{"related_intents", "related_specs", "related_issues", "synthesis_clusters", "blocked_by"}

// validateStrict validates a frontmatter map against the issue schema. It
// special-cases schema_version first (mirrors _validate_strict) and rejects
Expand All @@ -41,7 +46,7 @@ func validateStrict(fm map[string]any) error {
}

// Required strings.
for _, req := range []string{"id", "slug", "severity", "category", "source", "found_during", "created"} {
for _, req := range []string{"id", "slug", "severity", "category", "source", "found_during"} {
v, present := fm[req]
if !present {
return fmt.Errorf("%w: missing required property %q", ErrMissingRequiredField, req)
Expand Down Expand Up @@ -70,23 +75,15 @@ func validateStrict(fm map[string]any) error {
if strings.TrimSpace(fm["found_during"].(string)) == "" {
return fmt.Errorf("%w: found_during must be non-empty", ErrMalformedFrontmatter)
}
if !reCreated.MatchString(fm["created"].(string)) {
return fmt.Errorf("%w: created %q is not YYYY-MM-DD", ErrMalformedFrontmatter, fm["created"])
}

// Optional scalar strings.
for _, opt := range []string{"found_at", "details", "suggested_fix", "updated", "wontfix_reason", "resolution", "promoted_to"} {
for _, opt := range []string{"found_at", "details", "suggested_fix", "wontfix_reason", "resolution", "promoted_to"} {
if v, present := fm[opt]; present {
if _, isStr := v.(string); !isStr {
return fmt.Errorf("%w: %q must be a string", ErrMalformedFrontmatter, opt)
}
}
}
if v, present := fm["updated"]; present {
if !reCreated.MatchString(v.(string)) {
return fmt.Errorf("%w: updated %q is not YYYY-MM-DD", ErrMalformedFrontmatter, v)
}
}
if v, present := fm["promoted_to"]; present {
if !reItdID.MatchString(v.(string)) {
return fmt.Errorf("%w: promoted_to %q does not match ^itd-[0-9]+$", ErrMalformedFrontmatter, v)
Expand All @@ -102,6 +99,7 @@ func validateStrict(fm map[string]any) error {
{"related_intents", reItdID, "itd-N"},
{"related_specs", reFnID, "fn-N"},
{"related_issues", reIssID, "iss-N"},
{"blocked_by", reIssID, "iss-N"},
}
for _, f := range idListFields {
v, present := fm[f.field]
Expand Down Expand Up @@ -223,8 +221,6 @@ func issueFromFrontmatter(fm map[string]any, status State, path, body string) Is
Source: Source(asString(fm["source"])),
FoundDuring: asString(fm["found_during"]),
FoundAt: asString(fm["found_at"]),
Created: asString(fm["created"]),
Updated: asString(fm["updated"]),
PromotedTo: asString(fm["promoted_to"]),
Resolution: asString(fm["resolution"]),
WontfixReason: asString(fm["wontfix_reason"]),
Expand All @@ -235,6 +231,7 @@ func issueFromFrontmatter(fm map[string]any, status State, path, body string) Is
iss.RelatedIntents = asStrList(fm["related_intents"])
iss.RelatedSpecs = asStrList(fm["related_specs"])
iss.RelatedIssues = asStrList(fm["related_issues"])
iss.BlockedBy = asStrList(fm["blocked_by"])
if rb, ok := fm["resolved_by"].(map[string]any); ok {
iss.ResolvedBy = &ResolvedBy{
Intent: asString(rb["intent"]),
Expand Down
Loading
Loading