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
7 changes: 7 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,13 @@ jobs:
run: bun scripts/run-network-denied.ts -- sh -c 'bun run schema:emit && git diff --exit-code schema/adr.schema.json'
- name: Verify committed Action bundle matches source (network denied)
run: bun scripts/run-network-denied.ts -- git diff --exit-code packages/ci/dist
# MANIFEST.md's record inventory is a pure function of docs/adr/, and it drifted
# six records before anyone noticed (#131). A guard that merely reported the
# divergence would still leave a human to hand-edit the table — which is how it
# drifted — so the block is regenerated and the tree asserted unchanged, exactly
# like the schema emit above. Both halves wrapped, for the reason recorded there.
- name: Verify MANIFEST inventory matches the corpus (network denied)
run: bun scripts/run-network-denied.ts -- sh -c 'bun run emit:manifest && git diff --exit-code MANIFEST.md'
- name: Verify dependency boundaries (adapters + toolkit confinement)
run: bun scripts/run-network-denied.ts -- bun run check:deps
- name: Verify frozen oracle hashes match (feature 010 Barrier B, R5 mechanism 2)
Expand Down
24 changes: 24 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,30 @@ Until `1.0.0`, minor releases may include breaking changes

### Added

- **`MANIFEST.md`'s decision-corpus inventory is generated, not hand-written.**
`bun run emit:manifest` renders the record table and the status counts from
`adr graph --format json` between stable markers, and `clean-clone-builds`
regenerates it and asserts the tree is unchanged — the same shape as the
schema emit and the committed Action bundle. The inventory had drifted six
records before it was noticed
([#131](https://github.com/mbeacom/adrkit/issues/131)). No public CLI surface
was added: the CLI stays read-only and the writing lives in a repo-local
script ([#132](https://github.com/mbeacom/adrkit/issues/132)). Judgment prose
outside the markers stays hand-maintained.

### Fixed

- **`adr queue` no longer stays silent when a proposed record has a review
deadline but no routing tier.** `item.tier-absent` now fires whenever the tier
cannot be determined on a record that has entered review — a `review` block,
or a top-level `reviewBy`. The carve-out the spec actually states is
two-conditioned (both absent), but only the first condition was implemented,
so a `cross-team` record with `reviewBy` and no `review` block was listed with
`tier=None` and no finding at all
([#111](https://github.com/mbeacom/adrkit/issues/111)). A `proposed` record
with neither remains `not-queued` and silent. Severity stays `info`, so no
exit code changes.

- **Trusted CI gates that the pull request cannot edit.** A new
`.github/workflows/trusted-gates.yml` runs on `pull_request_target`, which
GitHub executes from the repository's default branch — workflow file,
Expand Down
57 changes: 55 additions & 2 deletions MANIFEST.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,62 @@ adrkit/

- The ADR corpus lives in [docs/adr/](docs/adr/), with `0000-template.md` plus
numbered records.
- There are 36 files: the template plus 35 records, ids `0001`-`0035`, with 33
accepted and 2 superseded records.
- The schema source of truth lives in
`packages/core/src/schema/adr.schema.ts`.
- `schema/adr.schema.json` is generated from that source and hosted at the
schema `$id` through the docs site.

### Inventory

The block below is generated from the corpus by `bun run emit:manifest`, and
`clean-clone-builds` fails if the committed copy has drifted from what the
corpus says. Do not hand-edit it; edit the records instead. Anything outside the
markers — including judgment about what is open or unfinished — stays
hand-written.

<!-- BEGIN GENERATED: adr-inventory -->

<!-- Generated by `bun run emit:manifest`; do not hand-edit. -->

There are 35 records, ids `0001`-`0035`, alongside
`0000-template.md`: 33 accepted, 2 superseded.

| Id | Status | Title |
| --- | --- | --- |
| `0001` | accepted | Record architecture decisions as versioned markdown in git |
| `0002` | accepted | Type the frontmatter as a MADR superset with an affects matcher |
| `0003` | accepted | Ship as a Spec Kit extension plus a standalone CLI, not a competing harness |
| `0004` | accepted | Treat git as the source of truth and the database as a derived index |
| `0005` | superseded | Gate proposals with a deterministic-first evaluator and declarative escalation |
| `0006` | accepted | License Apache-2.0 with a DCO and develop in a single monorepo |
| `0007` | accepted | Isolate integrations as optional adapters and build only against public surfaces |
| `0008` | accepted | Migrate MADR corpora in place and treat all other imports as one-way with a re-import diff |
| `0009` | accepted | Pin affects resolution semantics and bind entity refs to pluggable catalogs |
| `0010` | accepted | Use Bun as the package manager and test runner while publishing Node-targeted artifacts |
| `0011` | accepted | Host the canonical JSON Schema at its $id on adrkit.dev |
| `0012` | accepted | Bind catalog entities to owned paths with an explicit annotation |
| `0013` | accepted | Reconcile adapter isolation and catalog binding with the offline snapshot generator |
| `0014` | accepted | Stage phase-landing evidence across a three-rung validation ladder |
| `0015` | accepted | Validate descriptors against Backstage field formats before canonicalizing identity |
| `0016` | accepted | Require every check to be observed failing before it counts as coverage |
| `0017` | accepted | Keep dependency audit scope explicit and release-scoped |
| `0018` | accepted | Adopt MCP SDK v2 and serve protocol revision 2026-07-28 dual-era |
| `0019` | accepted | Ship the Spec Kit extension, treating the spike's no-go as a measurement artifact |
| `0020` | accepted | Rescope SC-010 and authorize work toward the Backstage catalog adapter |
| `0021` | superseded | Resolve inbound source annotations without changing the schema |
| `0022` | accepted | Scan inbound markers in check and CI without giving them exit-code authority |
| `0023` | accepted | Read a marker only where the format hides it: fences and markdown prose |
| `0024` | accepted | Report the measured scan extent, not the window constant |
| `0025` | accepted | Ship badges as recipes over existing output, not a new CLI surface |
| `0026` | accepted | Identify the CI comment by the strongest author evidence the token allows |
| `0027` | accepted | Ratify the deterministic evaluator and bind calibration reporting to the first probabilistic pass |
| `0028` | accepted | Ship decision memory as a portable agent plugin, and omit the MCP wiring hosts cannot honor |
| `0029` | accepted | Scope Backstage publication as a downstream consumer, tiered on the entity-ownership mapping |
| `0030` | accepted | Keep extension surfaces that carry a dependency tree outside this repository |
| `0031` | accepted | Publish a narrow consumer SDK as the contract, and document the CLI JSON as its sibling |
| `0032` | accepted | Publish one lockstep OCI image after the coordinated release succeeds |
| `0033` | accepted | Select interactive graph presentation at the CLI boundary while preserving piped DOT |
| `0034` | accepted | Extend the portable agent plugin with decision backfill |
| `0035` | accepted | Execute the gates that certify a pull request from the default branch |

<!-- END GENERATED: adr-inventory -->
13 changes: 7 additions & 6 deletions docs/RELEASING.md
Original file line number Diff line number Diff line change
Expand Up @@ -356,12 +356,13 @@ bootstrap described below.
Leave *historical* statements alone ("expanded in v0.5.0" records when something
happened and stays true). This drifts silently: v0.6.0 shipped with all three
site surfaces still advertising v0.5.0, so the hosted docs were a release behind
for anyone reading them. Check `MANIFEST.md`'s inventory and its Verification
counts against the corpus in the same pass — it is hand-maintained
([#131](https://github.com/mbeacom/adrkit/issues/131)) and drifts the same way; it
was missing ADR-0026 and understating the record and accepted counts by one when
v0.7.0 was cut. `ls docs/adr/*.md | wc -l` and
`grep -h '^status:' docs/adr/*.md | sort | uniq -c` give the numbers to compare.
for anyone reading them. `MANIFEST.md`'s decision-corpus inventory no longer
needs this pass: it is generated by `bun run emit:manifest` and
`clean-clone-builds` fails when the committed copy has drifted from the corpus
([#131](https://github.com/mbeacom/adrkit/issues/131)). It used to be
hand-maintained, and was missing ADR-0026 and understating the record and
accepted counts by one when v0.7.0 was cut. Prose *outside* the generated
markers is still hand-written and still worth reading in this pass.
5. Merge the version change only after CI passes.
6. Create and push the matching annotated tag, such as `v0.3.0`.
7. Approve the protected `npm` environment deployment.
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
"check:freeze-hashes": "bun run scripts/check-freeze-hashes.ts",
"check:doc-pins": "bun run scripts/check-doc-cli-versions.ts",
"check:site-grammar": "bun run scripts/check-site-corpus-grammar.ts",
"emit:manifest": "bun run scripts/emit-manifest.ts",
"check:dco": "bun run scripts/check-dco.ts",
"check:gate-integrity": "bun run scripts/check-gate-integrity.ts",
"check:ci-comment": "bun run scripts/check-ci-comment.ts",
Expand Down
3 changes: 2 additions & 1 deletion packages/ci/dist/queue-action.js
Original file line number Diff line number Diff line change
Expand Up @@ -46367,7 +46367,8 @@ function computeItemFindings(frontmatter) {
const findings = [];
const review = frontmatter.review;
const queuedAt = review?.queuedAt;
if (review !== undefined && review.tier == null) {
const enteredReview = review !== undefined || frontmatter.reviewBy != null;
if (enteredReview && review?.tier == null) {
findings.push({ code: "item.tier-absent", severity: "info", message: ITEM_MESSAGES.tierAbsent });
}
if (frontmatter.reviewBy != null && queuedAt != null) {
Expand Down
21 changes: 18 additions & 3 deletions packages/core/src/queue/kernel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,9 +106,24 @@ function computeItemFindings(frontmatter: AdrFrontmatter): ItemFinding[] {
const review = frontmatter.review;
const queuedAt = review?.queuedAt;

// No item finding is generated for the absence of `review` alone (spec §Edge cases):
// tier-absent fires only when a `review` block is present but omits `tier`.
if (review !== undefined && review.tier == null) {
// `item.tier-absent` fires whenever the routing tier cannot be determined on a
// record that has *entered* review — `review` present, or a top-level `reviewBy`
// deadline (#111).
//
// The spec's carve-out is two-conditioned and its justification is "not yet
// entered into the review workflow" (spec.md §Edge cases): `review` block **and**
// top-level `reviewBy` both absent. This previously tested only the first
// condition, so ADR-0022 — `blastRadius: cross-team`, `reviewBy: 2027-02-08`, no
// `review` block — sat in the queue with `tier=None` and no finding at all, and
// was ratified without a routing tier. That is the ADR-0016 shape: "could not
// determine" rendering identically to "nothing to report". The contract's own
// condition (contracts/kernel.md `item.tier-absent`) is simply "`review?.tier` is
// absent"; the carve-out is the only narrowing, and it does not reach a record
// carrying an explicit deadline.
//
// A `proposed` record with neither stays silent, exactly as the spec intends.
const enteredReview = review !== undefined || frontmatter.reviewBy != null;
if (enteredReview && review?.tier == null) {
Comment on lines +125 to +126
findings.push({ code: 'item.tier-absent', severity: 'info', message: ITEM_MESSAGES.tierAbsent });
}

Expand Down
28 changes: 26 additions & 2 deletions packages/core/test/ordering-contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,16 +92,39 @@ describe('sortFindings orders every tuple field by code unit', () => {
});
});

describe('no scanned module on the check --json path reaches for localeCompare', () => {
describe('no scanned module on a serialized-output path reaches for localeCompare', () => {
// The same source-scan shape as the adapter's
// `test/glob-order.test.ts` guard, widened to every core module that feeds
// `CheckOutcome`: `check/`, `load/`, `markers/`, `ordering/`, and `validate/`.
// `queue/` is scanned for the same reason on its own contract rather than on
// `CheckOutcome`'s: QueueReport v1 promises byte-for-byte identical output for
// identical inputs (007-arb-queue SC-001), which a locale-dependent sort would
// break as a difference between machines.
//
// `graph/` is deliberately NOT scanned, for the same shape of reason as
// `affects/` and with the same honesty about what a clean run therefore means.
// `buildAdrGraph` still orders nodes and edges with `localeCompare`, and
// ADR-0033 clause 8 pins that: "The graph JSON shape remains exactly
// `{ nodes, edges }`, with existing node and edge fields, **historical locale
// ordering**, and missing-target omission unchanged." Migrating it is a change
// to an accepted decision, not a defect fix, so it is recorded on #115 rather
// than made here. `scripts/emit-manifest.ts` does not depend on that order: it
// re-sorts the nodes it reads with `compareCodeUnits` before rendering, so the
// `MANIFEST.md` no-diff gate is locale-independent regardless of how this
// resolves.
// These are scanned as whole directories rather than as a file allowlist, so a
// new module added to any of them is covered the day it lands — an allowlist
// silently exempts new files, which is how `validate/index.ts` stayed unscanned
// while `validate/findings.ts` was named individually. See the header for why
// `affects/` is excluded.
const SCANNED_DIRS = ['src/check', 'src/load', 'src/markers', 'src/ordering', 'src/validate'];
const SCANNED_DIRS = [
'src/check',
'src/load',
'src/markers',
'src/ordering',
'src/queue',
'src/validate',
];

function tsFilesUnder(relativeDir: string): string[] {
const root = join(import.meta.dir, '..', relativeDir);
Expand All @@ -126,6 +149,7 @@ describe('no scanned module on the check --json path reaches for localeCompare',
expect(scanned).toContain('src/check/index.ts');
expect(scanned).toContain('src/load/corpus.ts');
expect(scanned).toContain('src/markers/resolve.ts');
expect(scanned).toContain('src/queue/kernel.ts');
expect(scanned).toContain('src/validate/findings.ts');
// `lintCorpus` lives here and produces the `records` `checkChanges` reads, so
// its absence from the scan was the gap the directory walk closes.
Expand Down
49 changes: 49 additions & 0 deletions packages/core/test/queue/kernel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,55 @@ describe('buildQueueReport — item findings', () => {
});
});

/**
* #111 — the real regression, reconstructed.
*
* ADR-0022 sat in the queue as `proposed`, `blastRadius: cross-team`, with
* `reviewBy: 2027-02-08` and no `review` block: `tier=None`, `findings=[]`. It
* was ratified without a routing tier because the queue said nothing. The
* spec's carve-out requires `review` **and** `reviewBy` to *both* be absent
* ("not yet entered into the review workflow"); a record carrying an explicit
* deadline has entered it, and the spec's very next sentence gives that exact
* combination SLA treatment.
*/
test('item.tier-absent when reviewBy is present with no review block (the ADR-0022 shape)', () => {
const report = buildQueueReport({
corpus: single({ deciders: ['@mbeacom'], reviewBy: '2027-02-08' }),
asOf: '2026-01-08',
});
expect(report.items[0]?.tier).toBeNull();
expect(report.items[0]?.deadlineDate).toBe('2027-02-08');
expect(report.items[0]?.slaState).toBe('within-sla');
expect(report.items[0]?.itemFindings).toContainEqual({
code: 'item.tier-absent',
severity: 'info',
message: TIER_ABSENT_MESSAGE,
});
});

test('the finding is info, so it never changes the queue exit code', () => {
// Completeness signal, not a blocker: `adr queue` exits non-zero only on
// error-severity corpus findings, and this is neither.
const report = buildQueueReport({
corpus: single({ deciders: ['@mbeacom'], reviewBy: '2027-02-08' }),
asOf: '2026-01-08',
});
expect(report.items[0]?.itemFindings.every((f) => f.severity === 'info')).toBe(true);
expect(report.corpusFindings.filter((f) => f.severity === 'error')).toEqual([]);
});

test('the carve-out survives: neither review nor reviewBy stays silent', () => {
// The genuine "proposed but not yet entered into the review workflow" state.
// Widening the finding to every tierless record would fire here, which the
// spec explicitly forbids.
const report = buildQueueReport({
corpus: single({ deciders: ['@mbeacom'] }),
asOf: '2026-01-08',
});
expect(report.items[0]?.slaState).toBe('not-queued');
expect(report.items[0]?.itemFindings).toEqual([]);
});

test('item.deciders-empty when queuedAt present and deciders empty', () => {
const report = buildQueueReport({
corpus: single({ deciders: [], review: { tier: 'arb', queuedAt: '2026-01-01T00:00:00Z', slaDays: 14 } }),
Expand Down
Loading
Loading