Skip to content
Draft
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 .github/workflows/advisory-andon.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ jobs:
GIT_CONFIG_VALUE_0="AUTHORIZATION: basic $(printf 'x-access-token:%s' "$GITHUB_TOKEN" | base64 | tr -d '\n')"
export GIT_CONFIG_VALUE_0
bash scripts/ci/advisory_andon.sh --self-test
bash scripts/ci/rearm_ready.sh --self-test
out="${RUNNER_TEMP:?}/andon.ndjson"
rc=0
cargo deny --locked --all-features --format json check advisories >/dev/null 2>"$out" || rc=$?
Expand Down
8 changes: 8 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,13 @@ jobs:
run: setsid --wait bash scripts/ci/install_pinned_toolchain.sh --self-test
- name: install the toolchain rust-toolchain.toml pins
run: setsid --wait bash scripts/ci/install_pinned_toolchain.sh
# docs/roadmaps/roadmap.yaml is generated (gitignored): roadmap-base.yaml plus
# entries/. sov.roadmap-valid reports NOT-RUN when the file is absent, so build
# it before any section reads it. Checks the generated file is idempotent too.
- name: generate docs/roadmaps/roadmap.yaml (base + entries)
run: |
make roadmap-aggregate
make roadmap-aggregate-check
- name: Sections (the determinism raster returns first; the rest keep running)
run: >-
python3 scripts/ci/fat_driver.py run
Expand Down Expand Up @@ -411,6 +418,7 @@ jobs:
bash scripts/ci/advisory_gate_selftest.sh "$G/advisory_gate.sh"
bash scripts/ci/advisory_step_selftest.sh
bash scripts/ci/advisory_positive_control.sh
bash scripts/ci/rearm_ready.sh --self-test
- name: Every deny.toml exemption must still be live
env:
GITHUB_TOKEN: ${{ github.token }}
Expand Down
29 changes: 29 additions & 0 deletions .github/workflows/rearm-ready.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
name: rearm-ready
# PMAT-5793 (ADG-INV-005): after main moves or on a timer, re-arm ready PRs whose head is the one that was approved.
# Never a PR or merge_group job. A PR whose head moved after approval is left alone (needs a fresh review).
on:
schedule:
- cron: '41 * * * *'
push:
branches: [main]
workflow_dispatch:
permissions:
contents: read
pull-requests: write
concurrency:
group: rearm-ready
cancel-in-progress: false
jobs:
rearm:
runs-on: [self-hosted, Linux, X64, clean-room]
timeout-minutes: 10
steps:
- uses: actions/checkout@v7
- name: Self-test, then re-arm approved un-armed PRs
env:
GH_TOKEN: ${{ github.token }}
REARM_REVIEWERS: ${{ vars.REARM_REVIEWERS }}
run: |
set -euo pipefail
bash scripts/ci/rearm_ready.sh --self-test
bash scripts/ci/rearm_ready.sh run
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -140,3 +140,8 @@ docs/audits/quorum-*.json.lanes/
# PVL-001 EV-8a (#4202): `pv discharge run`'s full log. The tracked summary is its sibling
# crates/aprender-contracts-staging/discharge-summary.json, outside the tree it hashes.
crates/aprender-contracts-staging/lean/discharge.json

# GENERATED by `make roadmap-aggregate` from roadmap-base.yaml + entries/ (never committed)
docs/roadmaps/roadmap.yaml
.pmat/context.db-shm
.pmat/context.db-wal
4 changes: 2 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -595,10 +595,10 @@ readme-sync-check: ## Fail if README.md is not what the generator produces
.PHONY: oracle-owl oracle-owl-check
.PHONY: roadmap-aggregate roadmap-aggregate-check
roadmap-aggregate: ## Regenerate docs/roadmaps/roadmap.yaml from docs/roadmaps/entries/ (#3296)
@python3 scripts/lib/roadmap_fragments.py aggregate --write
@python3 scripts/lib/roadmap_fragments.py aggregate --base-file docs/roadmaps/roadmap-base.yaml --write

roadmap-aggregate-check: ## Fail if roadmap.yaml is not what the aggregator produces, or if it is not idempotent
@python3 scripts/lib/roadmap_fragments.py aggregate --check
@python3 scripts/lib/roadmap_fragments.py aggregate --base-file docs/roadmaps/roadmap-base.yaml --check

.PHONY: ratchet-semantics-test
ratchet-semantics-test: ## BSE-03: D2 ratchet polarity rows (--class readme)
Expand Down
25 changes: 25 additions & 0 deletions changelog.d/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# changelog.d/

One file per ticket: `changelog.d/<TICKET>.md` (e.g. `PMAT-1234.md`, `GH-5761.md`). A PR writes its
fragment and never edits `CHANGELOG.md`; the aggregate is generated at release, so N PRs never
contend on one file (FLOW-009 RH-1/RH-2, paiml/aprender#5761).

Fragment shape (schema only, checked per PR):

```
### Fixed

- fix(scope): what changed (Refs #N)
```

Exactly one `### <Section>` heading (Added, Changed, Fixed, Performance, Security, Removed) followed
by one or more `- ` bullets.

```
bash scripts/check_changelog_fragments.sh check # schema of every fragment
bash scripts/check_changelog_fragments.sh aggregate # release: fold into CHANGELOG.md [Unreleased], delete fragments
bash scripts/check_changelog_fragments.sh --self-test # case table
```

Roadmap fragments are `docs/roadmaps/entries/<id>.yaml` (apr-roadmap-fragments-v1);
`roadmap.yaml` is their generated aggregate.
1 change: 1 addition & 0 deletions ci/sections.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2721,6 +2721,7 @@ jobs:
bash scripts/ci/advisory_gate_selftest.sh "$G/advisory_gate.sh"
bash scripts/ci/advisory_step_selftest.sh
bash scripts/ci/advisory_positive_control.sh
bash scripts/ci/rearm_ready.sh --self-test
# Separate from the check above on purpose: a newly-FIXED upstream must
# never fail anyone's build. It fails only this guard, whose remedy is
# deleting a line.
Expand Down
24 changes: 22 additions & 2 deletions contracts/advisory-gate-diff-v1.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,12 @@ equations:
- "Both sides are scanned with the BASE deny.toml; a PR that adds an advisories ignore id fails (ignores-added), so the one bypass through deny.toml is closed. This is NOT a claim that a PR cannot edit its own judge: the gate scripts, ci/sections.yml and cargo config (a .cargo alias can shadow cargo-deny) run from the PR tree, so a PR that edits them is judged by its own edit. Those paths are gate content and need the arbiter sign-off (non-author quorum); pinning the judge to main is out of this PR"
- "deny.toml advisories ignores are id-wide: cargo-deny has no per-crate ignore. They stay bounded, not scoped: ignores-added fails any PR that adds one, and check_deny_exemptions_live.sh (now --all-features, same as the gate) fails a dead one. Per-crate scoping needs an upstream cargo-deny feature and is out of this PR"
- "The base is pinned: merge_group uses merge_group.base_sha, pull_request uses pull_request.base.sha, anything else (or a non-sha) is COULD-NOT-CHECK rc 75, never a pass"
auto_rearm:
formula: "when a gate fix lands, every PR that was ready (green, approved) before the stop is re-armed without a human"
domain: "open PRs with no auto-merge armed whose latest review is an anchored APPROVE by an allowlisted reviewer other than the author"
codomain: "armed set restored"
invariants:
- "Re-arm never arms a PR whose head changed after its approval"

proof_obligations:
- id: ADG-INV-001
Expand All @@ -70,6 +76,12 @@ proof_obligations:
formal: "∀ lock_48_0_6: A_record(lock) ∋ RUSTSEC-2026-0378 ∧ A_record(lock) ∋ RUSTSEC-2026-0379"
applies_to: one_scanner_of_record

- id: ADG-INV-005
type: invariant
property: "Ready PRs are re-armed after a gate fix"
formal: "∀ pr: head_unchanged ⇒ armed_after ≥ armed_before"
applies_to: auto_rearm

falsification_tests:
- id: FALSIFY-ADG-001
rule: "Main-resident advisory does not block a PR"
Expand Down Expand Up @@ -114,14 +126,22 @@ falsification_tests:
test: "scripts/ci/advisory_step_selftest.sh, 4 cases + 1 mutant: the step's run block is extracted from ci.yml and run against stub scripts on a throwaway origin/main; gate rc 75 gives 75, gate rc 1 gives 1, a failing self-test gives its rc, all green gives 0"
if_fails: "a red or could-not-check gate leaves the step green and a PR merges on an unjudged advisory"
mutation: "delete the advisory_gate.sh invocation from the step; the mutant case must stay green on a failing gate, which the cases above forbid (measured 2026-10-20)"
- id: FALSIFY-ADG-008
rule: "Re-arm never arms a changed head (ADG-INV-005)"
prediction: "a ready PR whose head sha moved after approval is not re-armed; one whose head is unchanged is"
test: "scripts/ci/rearm_ready.sh --self-test: select cases (head unchanged arms; moved or unapproved does not; empty arms nothing) and run against a stub gh, which enqueues exactly the PRs whose LATEST review is an anchored APPROVE by an allowlisted reviewer other than the author, with expectedHeadOid = the review's commit.oid; any later non-approval review cancels it; there is no gh pr merge fallback; .github/workflows/rearm-ready.yml calls rearm_ready.sh run"
if_fails: "a gate fix arms an unreviewed head into the queue"
mutation: "drop the approved_sha == current_sha test in select_, or the expectedHeadOid, anchor, allowlist, author exclusion or cancel; the matching case must turn RED"


qa_gate:
id: F-ADG-001
name: Advisory gate diff contract
description: "PR delta gate, main andon, one scanner of record"
description: "PR delta gate, main andon, one scanner of record, auto re-arm"
checks:
- pr_gate_verdict
- main_resident_advisory
- one_scanner_of_record
pass_criteria: "All 7 falsification tests pass; the scanner script has a must-fail/must-pass case table"
- auto_rearm
pass_criteria: "All 8 falsification tests pass; the scanner script has a must-fail/must-pass case table"
falsification: "Revert the gate to lock-vs-DB; FALSIFY-ADG-001 must turn RED"
22 changes: 22 additions & 0 deletions docs/roadmaps/entries/PMAT-5897.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
- id: PMAT-5897
github_issue: 5897
item_type: task
title: 'roadmap.yaml aggregate goes stale vs entries/: freeze roadmap-base.yaml, generate roadmap.yaml, do not commit it'
status: in_progress
priority: high
assigned_to: aprender-w5018
created: 2026-10-21T21:30:00Z
updated: 2026-10-21T21:30:00Z
spec: null
acceptance_criteria:
- 'docs/roadmaps/roadmap.yaml is gitignored and produced by make roadmap-aggregate from roadmap-base.yaml plus entries/'
- 'check_roadmap_fragment_required.sh judges generated mode (G1 no re-added aggregate, G2 no base edit, G3 fragment-only change) and its self-test stays green'
- 'x86-main generates the aggregate before any section, so sov.roadmap-valid never reports NOT-RUN'
- 'readers of roadmap.yaml that cannot work on an untracked file are listed as follow-up tickets (pmat work add/complete shim, session_docs_commit.sh)'
phases: []
subtasks: []
estimated_effort: null
labels:
- kind:code
notes: null
epic: null
File renamed without changes.
2 changes: 1 addition & 1 deletion scripts/batch_fold.sh
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ regen() { # regenerate the generated set once, assert the fixed points, commit i
| grep -vxE 'docs/roadmaps/roadmap\.yaml|contracts/contracts\.nt|contracts/shapes\.ttl|README\.md' || true)
[ -z "$extra" ] || die "regen: wrote outside the generated set: $(printf '%s ' $extra)"
if [ -n "$(git status --porcelain --untracked-files=no)" ]; then
git add -- docs/roadmaps/roadmap.yaml contracts/contracts.nt contracts/shapes.ttl README.md 2>/dev/null
git add -- $(git ls-files --error-unmatch docs/roadmaps/roadmap.yaml 2>/dev/null) contracts/contracts.nt contracts/shapes.ttl README.md 2>/dev/null
git -c commit.gpgsign=false commit -q -m "batch: regenerate the generated set once (batch_fold.sh --regen)

roadmap aggregate, pv extract (contracts.nt + shapes.ttl) and the
Expand Down
112 changes: 112 additions & 0 deletions scripts/check_changelog_fragments.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
#!/usr/bin/env bash
# check_changelog_fragments.sh -- changelog.d/<TICKET>.md fragments (FLOW-009 RH-1/RH-2, #5761).
#
# check [DIR] schema of every fragment; exit 1 on a bad one
# aggregate [DIR] [CHANGELOG] fold fragments into CHANGELOG [Unreleased], then delete them
# --self-test plant each falsifier; must PASS/FAIL as named
set -euo pipefail

SECTIONS="Added Changed Fixed Performance Security Removed"

# One defect per line: "FILE: reason". Empty output = valid.
check_dir() {
local dir=$1 f name
for f in "$dir"/*.md; do
[ -e "$f" ] || continue
name=$(basename "$f")
[ "$name" = README.md ] && continue
if ! [[ "$name" =~ ^[A-Za-z]+-[0-9]+\.md$ ]]; then
echo "$name: file name must be <TICKET>.md (e.g. PMAT-1234.md)"
continue
fi
awk -v name="$name" -v secs="$SECTIONS" '
BEGIN { n = split(secs, s, " "); for (i = 1; i <= n; i++) ok[s[i]] = 1 }
/^[[:space:]]*$/ { next }
/^### / { h++; sec = substr($0, 5); if (!(sec in ok)) printf "%s: unknown section \"%s\"\n", name, sec; if (b) printf "%s: heading after bullets\n", name; next }
/^- / { b++; if (!h) printf "%s: bullet before the ### heading\n", name; next }
{ printf "%s: line is neither a heading nor a \"- \" bullet: %s\n", name, $0 }
END { if (h != 1) printf "%s: needs exactly one ### heading (found %d)\n", name, h; if (!b) printf "%s: needs at least one bullet\n", name }
' "$f"
done
}

aggregate() {
local dir=$1 log=$2 tmp sec f body
[ -f "$log" ] || { echo "no $log" >&2; return 1; }
local bad
bad=$(check_dir "$dir")
[ -z "$bad" ] || { printf '%s\n' "$bad" >&2; return 1; }
tmp=$(mktemp)
body=""
for sec in $SECTIONS; do
local lines=""
for f in "$dir"/*.md; do
[ -e "$f" ] || continue
[ "$(basename "$f")" = README.md ] && continue
if grep -qx "### $sec" "$f"; then
lines="$lines$(grep '^- ' "$f")"$'\n'
fi
done
[ -z "$lines" ] || body="$body### $sec"$'\n\n'"$lines"$'\n'
done
[ -n "$body" ] || { echo "no fragments" >&2; rm -f "${tmp:?}"; return 0; }
awk -v body="$body" '
{ print }
/^## \[Unreleased\]/ && !done { print ""; printf "%s", body; done = 1 }
' "$log" >"$tmp"
mv "$tmp" "$log"
for f in "$dir"/*.md; do
[ "$(basename "$f")" = README.md ] || rm -f -- "${f:?}"
done
}

self_test() {
local d rc=0
d=$(mktemp -d)
# shellcheck disable=SC2064
trap "rm -rf -- '${d:?}'" RETURN
expect() { # name want(PASS|FAIL) content filename [needle]
mkdir -p "${d:?}/$1"
printf '%s\n' "$3" >"$d/$1/${4:-T-1.md}"
local out got=PASS
out=$(check_dir "$d/$1")
[ -z "$out" ] || got=FAIL
if [ "$got" != "$2" ] || { [ -n "${5:-}" ] && [[ "$out" != *"$5"* ]]; }; then
echo "SELF-TEST FAIL: $1 wanted $2 ${5:-}, got $got: $out" >&2
rc=1
fi
}
expect ok PASS $'### Fixed\n\n- fix(x): y (#1)'
expect bad-name FAIL $'### Fixed\n\n- a' notes.md 'file name must be'
expect no-heading FAIL '- a' T-1.md 'needs exactly one ### heading'
expect bad-section FAIL $'### Misc\n\n- a' T-1.md 'unknown section'
expect no-bullet FAIL '### Fixed' T-1.md 'at least one bullet'
expect prose FAIL $'### Fixed\n\nsome prose' T-1.md 'neither a heading'
expect two-headings FAIL $'### Fixed\n- a\n### Added\n- b' T-1.md 'needs exactly one'
# aggregate: both fragments land under [Unreleased], in section order, and the files go.
mkdir -p "$d/agg"
printf '# Changelog\n\n## [Unreleased]\n\n## [1.0.0]\n' >"$d/CHANGELOG.md"
printf '### Fixed\n\n- fix a (#1)\n' >"$d/agg/PMAT-1.md"
printf '### Added\n\n- add b (#2)\n' >"$d/agg/PMAT-2.md"
aggregate "$d/agg" "$d/CHANGELOG.md"
local want got
want=$(printf '# Changelog\n\n## [Unreleased]\n\n### Added\n\n- add b (#2)\n\n### Fixed\n\n- fix a (#1)\n\n\n## [1.0.0]')
got=$(cat "$d/CHANGELOG.md")
[ "$got" = "$want" ] || { echo "SELF-TEST FAIL: aggregate wrote: $got" >&2; rc=1; }
[ ! -e "$d/agg/PMAT-1.md" ] || { echo "SELF-TEST FAIL: fragment not deleted" >&2; rc=1; }
[ "$rc" -eq 0 ] && echo "changelog_fragments self-test: 8 cases ok"
return "$rc"
}

# guard_tree.sh runs this bare, from the repo root: no argument = check changelog.d.
cmd=${1:-check}
case "$cmd" in
-h|--help) echo "usage: $0 [check [DIR]] | aggregate [DIR] [CHANGELOG] | --self-test" ;;
--self-test) self_test ;;
check)
out=$(check_dir "${2:-changelog.d}")
if [ -n "$out" ]; then printf '%s\n' "$out" >&2; exit 1; fi
;;
aggregate) aggregate "${2:-changelog.d}" "${3:-CHANGELOG.md}" ;;
*) echo "usage: $0 check [DIR] | aggregate [DIR] [CHANGELOG] | --self-test" >&2; exit 2 ;;
esac
24 changes: 24 additions & 0 deletions scripts/check_roadmap_completion_is_cited.sh
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,11 @@ def is_candidate(rel):
d, fn = os.path.split(rel)
if not fn.lower().endswith(".yaml"):
return False
# roadmap-base.yaml is the frozen half of the GENERATED roadmap.yaml (#5897):
# the aggregate is scanned and baselined, so scanning the base too would
# report every entry a second time under a path the baseline is not keyed on.
if rel.replace(os.sep, "/") == "docs/roadmaps/roadmap-base.yaml":
return False
if re.fullmatch(r"(?i)roadmap.*\.yaml", fn):
return True
return d.replace(os.sep, "/").endswith("docs/roadmaps")
Expand Down Expand Up @@ -277,6 +282,12 @@ except Exception:
pass

in_scope, out_scope, unparsed = [], [], []
# The base is not scanned itself; without the generated aggregate nothing of it
# would be, which is a silent pass. Make that loud.
if (os.path.isfile(os.path.join(root, "docs/roadmaps/roadmap-base.yaml"))
and "docs/roadmaps/roadmap.yaml" not in cand):
unparsed.append(("docs/roadmaps/roadmap.yaml",
"generated aggregate absent -- run `make roadmap-aggregate`"))
for rel in sorted(cand):
p = os.path.join(root, rel)
if not os.path.isfile(p):
Expand Down Expand Up @@ -623,6 +634,19 @@ YAML
n_lane=$(grep -c 'lane-deadbeef' "$TD/scan3.out" "$TD/scan3.err" 2>/dev/null | awk -F: '{t+=$2} END {print t+0}')
row 'lane scratch contributes no records' 0 "$n_lane"

# GENERATED mode (#5897): roadmap-base.yaml is the frozen half of the generated
# roadmap.yaml and is never scanned as a second copy of its entries.
mk docs/roadmaps/roadmap-base.yaml <<'YAML'
roadmap:
- id: R-BASE-HALF
status: completed
notes: null
YAML
scan_roadmaps "$TD" >"$TD/scan4.out" 2>"$TD/scan4.err" || true
n_base=$(grep -c 'roadmap-base.yaml' "$TD/scan4.out" "$TD/scan4.err" 2>/dev/null | awk -F: '{t+=$2} END {print t+0}')
row 'roadmap-base.yaml contributes no records (the aggregate is the unit)' 0 "$n_base"
rm -f "${TD:?}/docs/roadmaps/roadmap-base.yaml"

n_out=$(grep -c '^SCOPE out crates/other/roadmap.yaml' "$TD/scan.err" || true)
row 'foreign schema is reported out of scope, not skipped' 1 "$n_out"
n_rec=$(grep -c 'crates/other/roadmap.yaml' <<< "$SCAN" || true)
Expand Down
Loading
Loading