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
1 change: 1 addition & 0 deletions docs/architecture/INDEX.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ _Documentation structure, tooling, coherence_
| [ADR-302](./documentation/ADR-302-unified-documentation-model.md) | A unified documentation model — typed graph, ways packaging, cross-repo convergence | Accepted |
| [ADR-303](./documentation/ADR-303-active-set-semantics-adr-archive-and-supersession-reading-for-the-adr-corpus.md) | Active-set semantics, adr archive, and supersession reading for the ADR corpus | Accepted |
| [ADR-304](./documentation/ADR-304-typed-decision-records-the-adr-v1-contract.md) | Typed decision records: the adr/v1 contract | Accepted |
| [ADR-305](./documentation/ADR-305-capabilities-active-at-adoption-need-no-add-decision.md) | Capabilities active at adoption need no add decision | accepted |

## Legacy (Pre-Domain Numbering)

Expand Down
8 changes: 8 additions & 0 deletions docs/architecture/adr.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,14 @@ capabilities:
loop: The development loop skills (start, develop, merge, release, wrap)
testing: The test suites and the live install fixture

# Capabilities that were active when agent-ways adopted adr/v1 on the
# adopted date. No record added them, so none needs an add decision. A change
# on one with no prior record to name stands on the baseline. Any capability
# declared after adoption needs an add (ADR-305).
baseline:
adopted: 2026-09-27
capabilities: [docs, method, matching, disclosure, authoring, cli, attend, install, config, governance, loop, testing]

# Retire targets name a surface; these are agent-ways' namespaces.
surfaces:
cli: {}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
---
contract: adr/v1
kind: decision
verb: change
capability: adr
amends: [ADR-304#6]
basis:
- operator: aaronsb
level: guided
said: "Declared start active (Recommended)"
via: "session 2026-09-27: the operator selected option 1 of 4 on #582; the label was written by the agent"
- operator: aaronsb
level: guided
said: "the prior decision (the base decision) was that we could no longer test agent-ways correctly, directly on the host, it had to go into a container"
via: session 2026-09-27, reviewing PR #583
- operator: aaronsb
level: guided
said: "Accept as built (Recommended)"
via: "session 2026-09-27: the operator selected this for decisions 3 to 9 of the ADR-304 stack handoff, which included widening the §6 stale-citation wording; the label was written by the agent"
- evidence: "issue #582: ADR-186 changes a testing capability that existed before any record described it"
- evidence: "adr cite already warns on superseded, deprecated, rejected, abandoned and archived targets (cmd_cite.py, V1_NON_ACTIVE_STATUSES)"
agent:
name: Claude
model: claude-opus-5-5
considered:
- operator: aaronsb
said: "ok. so basically, I think it's the correct direction and implements the change to adr as discussed."
via: session 2026-09-27, reviewing PR #583
covers: []
status: accepted
date: 2026-09-27
deciders:
- aaronsb
- Claude
related:
- 304
---

# ADR-305: Capabilities active at adoption need no add decision

## Summary

- **Decided:** a project records under `baseline` in `adr.yaml` the date it adopted adr/v1 and the capabilities it already had then. Those capabilities need no `add` decision, and a `change` on one with no prior record to name stands on the baseline. A capability declared after adoption still needs an `add`.
- **Trades away:** a record of why each baseline capability exists. The vocabulary line is its only description.
- **One-way?** No. Removing a name from `baseline` restores both checks for it.
- **Probes:** *Confident:* the outcome for a record does not depend on which other records have been migrated, since only decisions dated after adoption can be its prior, and every one of those was written as v1. *Not confident:* whether `baseline` will be used to skip an `add` for a capability that is actually new.
- **Inversion:** at one end every capability needs a written `add`, which misstates history for a corpus that predates the contract. At the other end the vocabulary is its own authority and nothing needs an `add`, which lets new capabilities in unrecorded. This decision exempts only what existed before adoption.

## Context

ADR-304 §6 requires every capability in the vocabulary to have an accepted `add` decision. Most of agent-ways' capabilities (matching, attend, install, testing and others) worked long before any record described them. Migrating an old record as an `add` misstates it. ADR-186 is the example the operator gave: it moved testing off the host and into a container, which changed a testing capability that no record had added. #582 set out three options: a baseline `add` record per capability, declared capabilities starting active, and a `change` that may list several capabilities. The operator chose the second, and the agent designed the mechanism within that direction.

## Decision

### 1. Baseline capabilities

`adr.yaml` may carry:

```yaml
baseline:
adopted: 2026-09-27
capabilities: [docs, testing]
```

`adopted` is a `YYYY-MM-DD` date. Every name in `capabilities` must be in the vocabulary. Either defect fails lint.

### 2. The add check, amended

The ADR-304 §6 bullet on accepted `add` decisions now reads:

- Every capability in the vocabulary, except those listed in `baseline`, has an accepted `add` decision. This warns while any v0 record remains and fails after, so a corpus that is still migrating does not fail on every capability.

### 3. A change on a baseline capability

ADR-304 §3 requires a `change` to supersede or amend a prior decision on the same capability. A `change` with no `supersedes` or `amends` edge on a baseline capability instead stands on the baseline when either:

- it is dated on or before `adopted`, or
- no earlier decision on that capability is dated after `adopted`. Earlier means by date, then number. `constrain` decisions do not count, and neither do rejected or abandoned ones.

Only decisions dated after adoption count as a prior, so migrating an older record never changes the outcome for another record. A record with no date, or a date that is not `YYYY-MM-DD`, cannot stand on the baseline.

### 4. Stale citations, widened

The ADR-304 §6 `doclint` bullet on superseded decisions now reads:

- A citation of a superseded, deprecated, rejected, abandoned or archived record warns. For a superseded record, the warning names the successor.

`adr cite` already behaves this way. The amendment brings the text in line with it.

## Consequences

### Positive

- Migrating a record no longer requires inventing history. ADR-186 migrates as a `change` on `testing`.
- An adopting project writes one list and one date, not one record per capability.

### Negative

- ADR-123 spans attend, matching and disclosure. Only `constrain` may list several capabilities, so ADR-123 stays on v0 until it is split or a rule for multi-capability changes is decided.
- A post-adoption `change` need not name a pre-adoption record on the same capability, even after that record is migrated. The v0 prior warning still applies when it does name one.

### Neutral

- Projects that declare no `baseline` behave as before.

## Alternatives Considered

- **A baseline `add` per capability.** This keeps the history complete, but at the cost of twelve records written after the fact, each dated at adoption.
- **A multi-capability `change`.** This would let ADR-123 migrate, but it loosens the one-change, one-capability rule, and it does not fix the missing `add` decisions.
- **Count every v1 decision as a prior.** This was the first version of this PR. Which change counted as first then depended on the order in which records were migrated, and a frozen record could start failing when an older one migrated.
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
---
contract: adr/v1
kind: decision
verb: change
capability: testing
agent: {name: Claude, model: unrecorded}
basis:
- evidence: the reviews of PRs #501, #502, #504 and #508, each of which found a defect on the install path by reading the code, none of it run end to end on a clean machine
status: Accepted
date: 2026-09-17
deciders:
Expand All @@ -13,6 +20,14 @@ related:

# ADR-186: Live integration fixture: install-path test levels and the tier 2 gate

## Summary

- **Decided:** the install path is tested in a container, at two levels. Tier 1 installs and configures with no API key, on every pull request that touches the path. Tier 2 exercises a model with a key, on dispatch or a schedule only.
- **Trades away:** job time and network dependence on tier 1, and tokens on a fixed cadence for tier 2.
- **One-way?** No. The fixture is additive, and removing the job removes the gate and nothing else.
- **Probes:** *Confident:* the #501 shape (the refusal, the recovery and the kept hooks) is asserted on every pull request. *Not confident:* none stated in the original record.
- **Inversion:** at one end, a model runs on every pull request, so a fork's check passes with no secret and every pull request spends tokens. At the other end nothing runs a clean install and reviews keep finding install defects by reading code. The two tiers sit between them.

## Context

The install path is the installer script, `make setup` with its prebuilt downloads, `ways reconcile` into a config directory that already holds a user's own files, and the hook scripts that Claude Code runs from the merged `settings.json`. The reviews of PRs #501, #502, #504 and #508 each found a defect on that path by reading the code, and each said the same thing: none of it had been run end to end on a clean machine.
Expand Down
73 changes: 63 additions & 10 deletions hooks/ways/documentation/adr/adr-tool
Original file line number Diff line number Diff line change
Expand Up @@ -592,6 +592,17 @@ def _str_list(value) -> Optional[list]:
def _mapping(value) -> dict:
return value if isinstance(value, dict) else {}

def _iso_date(value) -> Optional[str]:
"""value as a YYYY-MM-DD string, or None. YAML may load a date as one."""
text = str(value) if value is not None else ''
return text if re.fullmatch(r'\d{4}-\d{2}-\d{2}', text) else None

def v1_baseline(ctx) -> tuple:
"""(adoption date or None, set of baseline capability names)."""
baseline = _mapping(ctx.config.get('baseline'))
return (_iso_date(baseline.get('adopted')),
set(_str_list(baseline.get('capabilities')) or []))

def v1_kinds(ctx) -> dict:
return _mapping(ctx.config.get('kinds'))

Expand Down Expand Up @@ -700,12 +711,26 @@ def rule_v1_config_shape(ctx):
bad("contract: adr/v1 declares no capabilities")
if 'surfaces' in config and not isinstance(config['surfaces'], dict):
bad("surfaces: expected a mapping of namespace to settings")
if 'baseline' in config:
baseline = config['baseline']
names = _str_list(baseline.get('capabilities')) if isinstance(baseline, dict) else None
if names is None:
bad("baseline: expected 'adopted' (a YYYY-MM-DD date) and 'capabilities' (a list of names)")
else:
if not _iso_date(baseline.get('adopted')):
bad("baseline.adopted: expected a YYYY-MM-DD date")
if isinstance(config.get('capabilities'), dict):
for name in names:
if name not in config['capabilities']:
bad(f"baseline: '{name}' is not in the capabilities vocabulary")

@config_rule(contract=V1)
def rule_v1_capabilities_added(ctx):
"""Every capability in the vocabulary has an accepted `add` decision. This
warns while v0 records remain, so a migrating corpus is not failed on every
capability, and fails once migration is done (ADR-304 §6)."""
"""Every capability in the vocabulary has an accepted `add` decision, except
those in `baseline`: capabilities that were active when the project
adopted the contract, which no record added. This warns while v0 records
remain, so a migrating corpus is not failed on every capability, and fails
once migration is done (ADR-304 §6, amended by ADR-305)."""
added = set()
for adr in ctx.corpus:
if (is_v1_record(adr, ctx)
Expand All @@ -716,8 +741,9 @@ def rule_v1_capabilities_added(ctx):
# hold the check at warning.
migrating = any(not is_v1_record(adr, ctx) and not is_archived(adr.path)
for adr in ctx.corpus)
_, baseline = v1_baseline(ctx)
for name in v1_capabilities(ctx):
if name not in added:
if name not in added and name not in baseline:
ctx.config_issues.append(Issue(
f"capability '{name}' has no accepted add decision",
'warning' if migrating else 'error'))
Expand Down Expand Up @@ -840,11 +866,40 @@ def rule_v1_edges(adr, ctx):
if section and not section_exists(target, section):
v1_issue(adr, f"{field_name}: ADR-{number} has no section '{section}'")

def decision_order(adr) -> tuple:
"""Records in decision order: by date, then number (ADR-304 §3)."""
base, _, part = str(adr.number or '0').partition('.')
return (str(adr.date or ''), int(base or 0), int(part or 0))

def _stands_on_baseline(capability: str, adr, ctx) -> bool:
"""A change with no prior edge stands on the baseline when the capability
is in it and either the change predates adoption, or no live decision on
the capability has been made since adoption before it. Only decisions
dated after adoption count: each was written as v1, so migrating an older
record never moves the answer (ADR-305)."""
adopted, baseline = v1_baseline(ctx)
when = _iso_date(adr.date)
if capability not in baseline or not adopted or not when:
return False
if when <= adopted:
return True
for other in ctx.corpus:
other_when = _iso_date(other.date)
if (other is not adr and is_v1_record(other, ctx)
and other.frontmatter.get('verb') not in (None, 'constrain')
and str(other.status or '').lower() not in ('rejected', 'abandoned')
and covers(other, capability)
and other_when and other_when > adopted
and decision_order(other) < decision_order(adr)):
return False
return True

@corpus_rule(contract=V1)
def rule_v1_change_replaces(adr, ctx):
"""A change decision supersedes or amends a prior decision on the same
capability. When the prior covers more than this capability ('*' or a
list), the change amends it (ADR-304 §3)."""
list), the change amends it (ADR-304 §3). A change on a baseline
capability with no prior record to name stands on the baseline (ADR-305)."""
if not is_v1_record(adr, ctx) or adr.frontmatter.get('verb') != 'change':
return
edges = []
Expand All @@ -861,6 +916,8 @@ def rule_v1_change_replaces(adr, ctx):
continue
if fits:
v1_issue(adr, f"a change on '{capability}' against a broader decision amends it rather than superseding it")
elif not edges and _stands_on_baseline(capability, adr, ctx):
continue
elif v0_priors:
numbers = ', '.join(f"ADR-{t.number}" for t in v0_priors)
v1_issue(adr, f"cannot confirm the prior decision on '{capability}': {numbers} is still v0", 'warning')
Expand Down Expand Up @@ -2220,7 +2277,7 @@ def cmd_cite(args):
verb = adr.frontmatter.get('verb')
if (is_v1_record(adr, ctx) and verb in ('add', 'cut')
and str(adr.status or '').lower() == 'accepted'):
order = (str(adr.date or ''), _number_order(adr.number))
order = decision_order(adr)
for capability in capability_scope(adr):
if capability not in latest or order >= latest[capability][0]:
latest[capability] = (order, adr)
Expand Down Expand Up @@ -2334,10 +2391,6 @@ def _cite_key(number: str) -> str:
base = base.lstrip('0') or '0'
return f"{base}.{part.lstrip('0') or '0'}" if part else base

def _number_order(number) -> tuple:
base, _, part = _cite_key(number or '0').partition('.')
return (int(base), int(part or 0))

def _cite_files(root: Path) -> list:
"""Repository files, sorted: git's view when available (tracked and
untracked but not ignored), else a walk that prunes dependency and build
Expand Down
6 changes: 1 addition & 5 deletions hooks/ways/documentation/adr/src/cmd_cite.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ def is_proposed(adr) -> bool:
verb = adr.frontmatter.get('verb')
if (is_v1_record(adr, ctx) and verb in ('add', 'cut')
and str(adr.status or '').lower() == 'accepted'):
order = (str(adr.date or ''), _number_order(adr.number))
order = decision_order(adr)
for capability in capability_scope(adr):
if capability not in latest or order >= latest[capability][0]:
latest[capability] = (order, adr)
Expand Down Expand Up @@ -188,10 +188,6 @@ def _cite_key(number: str) -> str:
base = base.lstrip('0') or '0'
return f"{base}.{part.lstrip('0') or '0'}" if part else base

def _number_order(number) -> tuple:
base, _, part = _cite_key(number or '0').partition('.')
return (int(base), int(part or 0))

def _cite_files(root: Path) -> list:
"""Repository files, sorted: git's view when available (tracked and
untracked but not ignored), else a walk that prunes dependency and build
Expand Down
Loading
Loading