Skip to content

test(contract): pin the shipped AP SSID format (replaces #489) - #524

Closed
Amperstrand wants to merge 8 commits into
mainfrom
pr/ssid-format-contract
Closed

Amperstrand wants to merge 8 commits into
mainfrom
pr/ssid-format-contract

Conversation

@Amperstrand

Copy link
Copy Markdown
Collaborator

Rebased replacement for #489 (same contract; original closed — its head branch was deleted and the PR stayed pinned to the orphaned SHA).

What

The open-AP SSID format becomes a checked contract: 99-tollgate-setup names the access point <brand>-<4 hex> (TollGate-A1B2), one shared name on both radios, per-device suffix. tests/contract/check-ssid-format.sh (CI gate in both workflow twins + hooks/pre-commit) fails when that stops being true. Rationale: the Go side keys on the TollGate- prefix (discovery_log.go, vendor_element_manager.go) — a band suffix would fork the router's network identity.

Rebased onto current main with the harness adapted to the band-aware setup: the bootstrap state declares banded radios, detect_band_radios runs before setup_public_wifi (the driver's order), and the readback follows the tollgate_*_open sections the band-aware setup writes.

Verification

  • Gate green on this head; mutation-verified on the real drift axis: ssid="$GATEWAY_NAME-2.4GHz" → exit 1. (A section-label-only change is correctly ignored — checked during test(contract): pin the shipped AP SSID format #489's original review.)
  • Contract battery: node tests/contract/js-schema-lint.mjs PASS, bash tests/contract/build-purity.sh 3/3 (run on the rebased tree).

@Amperstrand

Copy link
Copy Markdown
Collaborator Author

Verification — the replacement carries #489's full contract: green at head, red on all four injected drift classes, triple wiring confirmed; one mechanical CHANGELOG misplacement to fix

Verified at head bcc7016 (= current main tip + the 3 commits; MERGEABLE, zero commits behind). No src/ file is touched, so the Go battery is not applicable; the relevant surface here is the contract gate itself, the wiring, and the contract battery.

Green at head. bash tests/contract/check-ssid-format.sh → exit 0, 18 ok lines, 1.2 s, self-locating (run from the worktree root); bash -n and shellcheck clean. The band-aware harness is the right adaptation: it bootstraps a first-boot wireless state with banded radios, calls detect_band_radios before setup_public_wifi (the driver's order), and reads the SSIDs back from the tollgate_2g_open/tollgate_5g_open sections the band-aware setup writes — the asserted contract is unchanged from #489.

Red on all four drift classes from #489's verification, each on a fresh git archive copy with exactly one mutation:

Mutation Result
GATEWAY_NAME="${BRAND_HOSTNAME}-${RANDOM_SUFFIX}-2.4GHz" (band suffix) exit 1, 12 checks FAIL — behavioural regex ×2, default-brand pin, all 6 generations, suffix shape, B2 + B3. Matches #489's red profile exactly.
GATEWAY_NAME="TollGate-${RANDOM_SUFFIX}" (hardcoded brand) exit 1, 1 check FAIL — B2 only; behavioural stays green under the default brand, which is the point of the structural layer.
public .ssid write deleted (L301) exit 1, 12 checks FAIL — behavioural <unset> ×2 + downstream.
all .ssid writes deleted (L301+766+778) exit 1, 13 checks FAIL — plus B1's anti-vacuity guard (no '.ssid="…"' write found — this check is observing nothing).

One scope shift vs #489 worth naming for maintainers: the band-aware public write is uci set "wireless.$iface.ssid=$GATEWAY_NAME" — its quoting places it outside B1's \.ssid=" grep, so B1 now observes only the two private writes, and the public path's variable-reference property is enforced by B2 (the GATEWAY_NAME assignment) plus the behavioural layer. I additionally injected a fully-hardcoded literal at the public write (.ssid=TollGate-A1B2): still caught — exit 1 via the randomness check (6 generations produced only 1 distinct SSIDs). No coverage hole found on any axis I could construct.

Wiring, exercised not just parsed. The Shipped AP SSID format check step sits directly after build-purity.sh in both workflow twins (.github/workflows/test.yml and .ngit/act/workflows/test.yml). hooks/pre-commit was driven in a scratch clone: staging a non-packaging/files/ path does not trigger the checker; staging a packaging/files/ touch runs it green and the commit proceeds; staging a band-suffix drift under packaging/files/ blocks the commit (exit 1, 1 SSID-format check(s) FAILED). AGENTS/CONTRIBUTING/release-process now name the gate alongside the other contract checks.

Consistency with #531. #531 is open at review time (not yet merged), and its tree writes private_ssid="TollGate-Private-${RANDOM_SUFFIX}". Dropping this checker onto a fresh archive of #531's head → exit 0, with B3 explicitly observing and passing TollGate-Private-${RANDOM_SUFFIX} — the two PRs agree on the format family (<brand>-<4hex> open, TollGate-Private-<4hex> private, same suffix source) and the gates compose in either merge order. The doc window is the one cosmetic asymmetry: this PR's README documents TollGate-Private-XXXX while the shipped script still broadcasts c08r4d0r-XXXX until #531 lands — #489's text carried an in-window caveat for this; the rebased README bullet does not (the caveat now lives only in the commit message). Either land #531 first or restore the one-line caveat. Both PRs touch the workflow twins, hooks/pre-commit, CHANGELOG.md and 99-tollgate-setup in adjacent spots, so the second to land needs the routine one-round rebase.

Contract battery at head: node tests/contract/js-schema-lint.mjs PASS (64 schema entries), bash tests/contract/build-purity.sh 3/3, check-mirror-sync.py exit 0 (the .ngit twin change is in sync).

One repo-rule fix needed: the CHANGELOG entry is in the wrong section. [Unreleased] is empty (CHANGELOG.md L11) and the new bullet landed at L293-L306 — inside the already-tagged [v0.6.0-alpha4] section, appended after the #523-folded entries. Post-tag changes belong under [Unreleased] (create the ### Changed / Internal subhead there); this is the exact class #476 had to fix in its own flight, and if it merges as-is the release-notes finalize would attribute the gate to alpha4. Mechanical move. Carried-over optional follow-up from #489: the checker duplicates load_brand()'s net4sats mapping at check-ssid-format.sh L159 — deriving the expected prefix from the already-sourced lib would remove the drift risk when a third brand lands. The CHANGELOG bullet also lacks the ([#524](…)) PR link the finalized sections carry (currently true of the whole section, so a batch fix is fine).

Disposition: request-changes — one blocking item (move the CHANGELOG entry under [Unreleased]); the contract itself is fully verified: green at head, four-for-four red on the drift classes with the same failure profile #489 demonstrated, wiring triple-confirmed by execution, and clean composition with #531 in either order.

@Amperstrand

Copy link
Copy Markdown
Collaborator Author

Wave-3 follow-up fixup pushed (c8853944, explicit-SHA lease, fast-forward from bcc70166): the SSID-format contract entry moved from inside the frozen [v0.6.0-alpha4] section (tag exists — new entries belong in [Unreleased]) to a fresh [Unreleased] → Changed / Internal. No code touched.

@felixfelix-bot

Copy link
Copy Markdown
Contributor

Review at 864aae68a03c0c2c72f606e15c6aee7e849b7848 (live head; the review card cited c8853944, one CHANGELOG fixup behind — reviewed at the tip).

APPROVED — no change requests. The contract is real, mutation-hardened, and correctly wired into both CI workflow twins, hooks/pre-commit and the runbooks. Three non-blocking findings below.

Verified first-hand at that head:

  • bash tests/contract/check-ssid-format.sh → exit 0, 16 ok / 0 fail.
  • Mutation kills, all exit 1 (failing-check counts): band suffix on GATEWAY_NAME (12), hardcoded literal at the open-AP .ssid write (1), deleted write (12), per-label -$label suffix (11). The gate is not vacuous and catches every drift class it exists for.
  • node tests/contract/js-schema-lint.mjs PASS (64 entries); bash tests/contract/build-purity.sh 3/3; bash tests/uci-defaults-band_test.sh 22/22; shellcheck -x clean; no conflict markers in the diff; script committed 100755.
  • Structural anchors present at head: 99-tollgate-setup lines 336/837 (GATEWAY_NAME), 445 (gatewayname), 766/778 (private .ssid).
  • CI: no kind-9842 results for this head and no GitHub check-runs (Actions queue wedge) — documented absence, not green; the local battery above is the evidence for this head.

Findings (none blocking):

  1. [RISK] tests/contract/check-ssid-format.sh:79-83 — the stub uci show renders section-type lines quoted; real uci show prints them bare. At head this makes wifi_device_sections() return empty and find_radio_by_band match nothing, so detect_band_radios silently takes the radio0/radio1 fallback (99-tollgate-setup:257) and first_ap_iface_on adopts nothing — the behavioural layer exercises the create-with-label path, not the band-declared path the bootstrap (harness lines 104-117) sets up. Two divergences currently cancel: fixing only the show rendering (one case, cf. the sibling shim tests/uci-defaults-band_test.sh:30-68, which documents "section types bare") flips the gate to 12 false FAILs, because the SSIDs then land on default_radio0/1 while the readback (line 142) keys on tollgate_*_open. Related gap: B1/B3's \.ssid=\" regex (lines 247, 286) never matches the open-AP write at 99-tollgate-setup:301 (uci set "wireless.$iface.ssid=$GATEWAY_NAME" — no quote after ssid=), so only the private writes (766/778) are structurally inspected; a self-randomizing literal at :301 (inline $(hexdump …)) would pass every layer — today it dies only on the generation-distinctness check. None of this opens a drift hole for the four classes above, but the behavioural layer passes for the wrong reason and the "banded first-boot state" is inert. Fix the stub rendering and the readback keys together, and anchor B1/B3 to the open-AP write site.
  2. [NIT] CHANGELOG.md:202 — fixup bf25db3 deleted the ### Changed / Internal heading from [Unreleased] (it exists on main); the pre-existing getMacAddress entry (fix(session): renew after expiry; add an explicit session state (none/active/expired) #541) and the new SSID-contract entry now sit mis-filed under ### Fixed. Restore the heading between the portal-pin entry and the SSID entry.
  3. [NIT] README.md:205 — TollGate-Private-XXXX documents the post-fix(packaging): stop broadcasting the operator username in the default private SSID #531 state, but fix(packaging): stop broadcasting the operator username in the default private SSID #531 is still open. At this head the script still writes c08r4d0r-${RANDOM_SUFFIX} (99-tollgate-setup:726). If this merges before fix(packaging): stop broadcasting the operator username in the default private SSID #531, the README describes a private SSID no shipped router has — add "(pending fix(packaging): stop broadcasting the operator username in the default private SSID #531)" or land fix(packaging): stop broadcasting the operator username in the default private SSID #531 first.

@felixfelix-bot felixfelix-bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

APPROVED (review at head 864aae68a03c0c2c72f606e15c6aee7e849b7848) — no change requests; the SSID-format contract is mutation-hardened (4/4 drift classes killed). Three non-blocking findings ([RISK] harness-stub fidelity in tests/contract/check-ssid-format.sh:79-83, two NITs: CHANGELOG heading, README private-AP vs open #531) — full evidence in the review comment above.

@Amperstrand

Copy link
Copy Markdown
Collaborator Author

Technical review (evidence pass) — PR head 864aae68a03c0c2c72f606e15c6aee7e849b7848 (base 24 commits behind main).

This pins the open-AP SSID format as a checked contract and — unusually for a test-only PR — the checking machinery is genuinely good. I reproduced the full verification story on the head: check-ssid-format.sh green, js-schema-lint.mjs PASS, build-purity.sh 3/3; the two claimed mutations also reproduce — appending -2.4GHz to the generated SSID exits 1 with 11 failing assertions, and hardcoding a literal at a .ssid="…" write site trips the structural layer (B1). The Go-side rationale checks out: discovery_log.go:283 matches on ssid[:9] == "TollGate-" and vendor_element_manager.go:164 on HasPrefix(ni.SSID, "TollGate-"), so a band-suffixed SSID really would fork the router's network identity. The two-layer design (behavioural execution of the real functions against a stub uci, structural checks on the branches a checkout can't run) with anti-vacuity guards on every grep is the right shape, and wiring it into both workflow twins, hooks/pre-commit, and the three runbooks is complete — no new dependency surface, no shipped-artifact change, no new attack surface (sandbox is mktemp'd and trap-cleaned).

Two one-line fixes should land before merge, though — both in the docs the PR itself adds.

The changelog entry ate its section header. bf25db3 ("move the SSID-format contract entry to [Unreleased]") replaced the ### Changed / Internal header with the new bullet, so on the head the entry sits at the tail of Fixed — and the pre-existing getMacAddress entry (a Changed/Internal item) is left headerless under Fixed too: CHANGELOG.md#L199-L223. This entry is CI/test work, which per the house categories belongs in Changed / Internal anyway. Fix: restore the ### Changed / Internal line between the #536 entry and the SSID bullet (the commit-5 dedup itself was correct — the rebase duplicate it removed was real).

The README documents a private AP this branch doesn't ship. The new SSID-conventions section says the private management AP is TollGate-Private-XXXX (README.md#L203-L210), but the script on this head still generates c08r4d0r-${RANDOM_SUFFIX} (99-tollgate-setup:726); TollGate-Private-XXXX is the post-#531 state, and #531 is still open. The public-AP, passphrase, WPA2, and gatewayname claims all match the script — only this bullet writes a cheque #531 hasn't cashed. Either reword to today's truth with a pointer to #531, or move that sentence into #531 so docs and default change together.

One non-blocking fragility worth knowing about: the behavioural layer exercises the fallback, not the band-aware path it claims to follow. The stub's show prints section types quoted (wireless.radio0='wifi-device' — check-ssid-format.sh#L77-L85), but real uci show prints them bare, and both wifi_device_sections and first_ap_iface_on match the bare form (99-tollgate-setup:209-L216). I verified empirically: in the harness, band detection finds no radios and detect_band_radios silently resolves via the radio0/radio1 index fallback, and setup_band_ap never adopts the stock default_radioN sections — it creates tollgate_*_open fresh, which is exactly why the readback at check-ssid-format.sh#L140-L150 finds them. The declared banded bootstrap is inert. This is contract-equivalent today — both paths funnel through the single .ssid=$GATEWAY_NAME write in configure_radio_ap, which the behavioural layer does execute — but if SSID derivation ever branched by adoption path, the harness would keep passing while the real first-boot path drifted. The existing tests/uci-defaults-band_test.sh shim gets show fidelity right (its comment even says so); making this stub print section types bare and having the readback follow whichever section each radio's AP actually landed on would close the gap and let the two harnesses share one uci stand-in. Related trivia, no action needed: B1's \.ssid=" grep doesn't observe the uci set "wireless.$iface.ssid=…" indirection form (only the private writes and the executed public write are netted), and the pass labels say default_radio0/1 while the values read come from tollgate_2g/5g_open — a leftover from the pre-rebase harness that could confuse someone debugging a failure.

Hygiene: five well-chunked commits with genuinely useful messages (the red-evidence narrative in the first one is exemplary), no attribution footers. Overlaps are handled correctly — #489 is properly superseded, and the #531 relationship just needs the README wording above. The branch is 24 behind a main that has since restructured [Unreleased] (sections reordered, tail-duplicate removed), so expect CHANGELOG conflicts at merge time — rebase first and fold the header fix into it rather than resolving it at squash time.

Disposition: request-changes — restore the ### Changed / Internal header and fix or defer the private-AP README bullet. The test itself needs no changes; with those two one-liners this is a clean land.

Amperstrand pushed a commit that referenced this pull request Sep 27, 2026
Resolving a CHANGELOG conflict with a section-level 'take ours' can
resurrect an entry a branch commit had already moved or reworded — the
same bullet then ships in two sections, release notes describe one
change twice, and entry-counting fold scripts go wrong. This happened
twice in one rebasing session (#524 and #531 both carried a
resurrected stale copy past a --ours resolution until audited).

check-changelog-duplicates.py fails when the same bold lead-in appears
in two sections and at least one is [Unreleased] — the live edit zone.
The same lead-in inside already-released sections (15 such pairs exist
in history, e.g. 'gonuts re-pin.') is frozen history and deliberately
tolerated: release notes are not rewritten. Wired into hooks/pre-commit
next to the version-sync check; pinned by changelog-duplicates_test.sh
across all three verdicts.

PR #581 review follow-ups, folded into the same commit:

- Wire the check and its verdict script into .github/workflows/test.yml
  beside the deps/import contract checks: pre-commit cannot gate
  sequencer commits (a bad --ours resolution during rebase --continue
  sails past the hook), so CI is the only place the check runs for
  everyone regardless of hooks.
- Scope the docstring and the failure/success wording to the
  version-section keying actually enforced — the same lead-in in two
  version sections with [Unreleased] among them — instead of promising
  lead-in-wide uniqueness the frozen-history tolerance contradicts.
- Append the PR link to the changelog entry; the rebase onto post-#577
  main resolves the CHANGELOG conflict keep-both and drops the branch's
  inherited orphaned second [Unreleased] section.
Amperstrand pushed a commit that referenced this pull request Sep 27, 2026
TollGate-Private-XXXX is the post-#531 state and #531 is still open;
the shipped 99-tollgate-setup broadcasts c08r4d0r-${RANDOM_SUFFIX}.
Say that, and point at #531 for the rename (PR #524 review follow-up).
@Amperstrand
Amperstrand force-pushed the pr/ssid-format-contract branch from 864aae6 to 7100ad5 Compare September 27, 2026 17:56
@Amperstrand

Copy link
Copy Markdown
Collaborator Author

Review follow-up + rebase onto main — new head 7100ad56 (from 864aae68, force-pushed with an explicit-SHA lease).

Per finding:

  • ### Changed / Internal header restored, folded into the rebase as the review suggested. The SSID-contract entry sits under [Unreleased] → Changed / Internal with the header intact (the getMacAddress entry is back under its proper heading too), exactly once in the file, and now carries the ([#524](…)) link at its tail to match the finalized sections' style (the wave-1 review's noted gap). Git auto-dropped 864aae68 during the rebase ("patch contents already upstream") — its dedup is folded into the conflict resolution, and the alpha4-section duplicate is gone.
  • README private-AP bullet reworded to today's truth: c08r4d0r-XXXX (same XXXX suffix as the public AP), with a pointer to fix(packaging): stop broadcasting the operator username in the default private SSID #531 for the proposed TollGate-Private-XXXX rename. Verified against the shipped script on this head: default nym c08r4d0r (resolve_nym → DEVICE_CODE_DEFAULT_NYM), same code as the public AP, psk2+ccmp, Word-Word-Word-NN passphrase — the section's other claims were already accurate.
  • Rebased onto main (afc86b3f). Only CHANGELOG conflicted; resolved keep-both.

Verification at the new head: bash -n on check-ssid-format.sh + hooks/pre-commit clean; node tests/contract/js-schema-lint.mjs PASS (64 entries); bash tests/contract/build-purity.sh 3/3; python3 tests/contract/check-mirror-sync.py OK; tests/uci-defaults-band_test.sh 22/22; no conflict markers.

⚠️ Deferred — the checker needs a decision that #605 invalidated. bash tests/contract/check-ssid-format.sh is red (13 checks FAIL) at the rebased head. This is not rebase breakage: #605 ("one device code") landed on main on 2026-09-27 — after this PR's reviews — and changed the contract this gate pins:

  • the suffix is no longer a 4-hex RANDOM_SUFFIX from /dev/urandom per boot; it is a single device CODE of four [A-Z0-9] characters, minted once, persisted in /etc/config/tollgate, adopted from store/hostname/SSID (setup_device_identity; driver order at lines 2218/2229);
  • setup_public_wifi now consumes DEVICE_SSID (GATEWAY_NAME="$DEVICE_SSID", line 1307), so the harness's direct call dies with DEVICE_SSID: unbound variable;
  • the private SSID is now <nym>-<CODE> (private_ssid_for_code), which also bears on fix(packaging): stop broadcasting the operator username in the default private SSID #531's still-open proposal;
  • the structural anchors moved or changed shape (assignment line, reinstall/whitelabel branches → captive_ssid_for_code).

The rebased tree itself is healthy — main's own tests/uci-defaults-device-code_test.sh passes 72/72 here and the band test 22/22 — so the red is the checker pinning the pre-#605 format, not a broken merge. Re-pinning it (alphabet [A-Z0-9], mint-once semantics vs the generation-distinctness check, how the layers relate to #605's own test, and what of #531 remains applicable) is a contract decision I did not make unilaterally: either direct the adaptation here, or close this PR in favour of a fresh gate written against the #605 identity model. The CHANGELOG entry's "<brand>-<4 hex characters>" wording and the README intro/public-AP "4-hex RANDOM_SUFFIX" claims are stale the same way and should follow that decision.

Amperstrand pushed a commit that referenced this pull request Sep 28, 2026
TollGate-Private-XXXX is the post-#531 state and #531 is still open;
the shipped 99-tollgate-setup broadcasts c08r4d0r-${RANDOM_SUFFIX}.
Say that, and point at #531 for the rename (PR #524 review follow-up).
@Amperstrand
Amperstrand force-pushed the pr/ssid-format-contract branch from 7100ad5 to 2d0998e Compare September 28, 2026 06:32
@Amperstrand

Copy link
Copy Markdown
Collaborator Author

README bullet updated to the decided model + one new finding.

The private-AP bullet no longer future-tenses #531 ("renames the default to TollGate-Private-XXXX") — #531 is closing as superseded. It now documents what #605 decided: names derive from a single generated device code (docs/architecture/one-device-code.md), the private AP is c08r4d0r-XXXX, and the c08r4d0r prefix is the project's generic default nym — every installation shares it, so it names the product rather than any operator; personalizing the nym is a visible operator choice. The stale RANDOM_SUFFIX mechanics sentence is gone. Head is now 2d0998e5 (README-only amend; nothing else touched).

While verifying, a real finding: check-ssid-format.sh fails at this head against the #605-era setup script — 13–14 checks red, root cause in section A: the harness's stub environment hits /etc/tollgate/brand: No such file or directory (stub lib.sh:319) and then DEVICE_SSID: unbound variable (lib.sh:1307), i.e. the harness still models the pre-#605 derivation (RANDOM_SUFFIX) while the script now uses the device-code path. Confirmed identical at the previous head 7100ad56 — introduced by the rebase onto #605-main, not by today's edit. This is exactly the drift the original review's fragility note predicted ("if SSID derivation ever branched by adoption path, the harness would keep passing while the real first-boot path drifted") — except it went the other way: the harness now fails where the real path changed. Before merge, the stub needs the #605 model: provide /etc/tollgate/brand handling and DEVICE_SSID in the stub, and pin the assertions to the device-code forms.

felixfelix-bot and others added 8 commits September 28, 2026 19:11
The open AP is named `<brand>-<4 hex characters>` (`TollGate-A1B2` for the
default brand) and both radios carry that one name. Nothing enforced it: the
4-character device suffix and the single-name property were conventions in
99-tollgate-setup, so a band suffix ("TollGate-A1B2-2.4GHz") or a hardcoded
brand could come back unnoticed. The Go side keys on the `TollGate-` prefix
(discovery_log.go `hasTollGateSSID`, vendor_element_manager.go
`calculateScore`), and the SSID rides in every beacon, so the drift is both a
client-matching and a naming-honesty bug.

tests/contract/check-ssid-format.sh asserts the contract in two layers:

- behavioural: sources the real load_brand()/setup_public_wifi() out of the
  script (driver stripped) and runs them against a stub `uci` that records
  what they would have written, then checks the SSID actually generated —
  both radios matching ^TollGate-[0-9A-F]{4}$ for the default brand, one
  shared name for both radios, a per-device 4-hex suffix over 6 generations.
- structural: checks the assignment sites a checkout cannot execute — the
  same-version reinstall path recovers its SSID from live config and the
  whitelabel branch reads /etc/tollgate/brand — failing on a band token or on
  an SSID written from anything but a variable.

Red evidence, all reverted before this commit: appending "-2.4GHz" to the
gateway name fails 12 checks; hardcoding the brand at the `.ssid` write fails
the structural layer (which the behavioural layer alone cannot see); deleting
the write fails the anti-vacuity guard.

Wired into both CI test workflows (build-purity job), hooks/pre-commit, and
the release/contribution runbooks.

Refs: t_9e5f0b8d
Public AP is BRAND-XXXX with a 4-hex uppercase suffix from /dev/urandom,
shared across the 2.4GHz and 5GHz radios (band steering). Private
management AP is BRAND-Private-XXXX with a WPA2 word-word-word-NN
passphrase. nodogsplash gatewayname derives as '<gateway> Portal'.
The public format is pinned by tests/contract/check-ssid-format.sh.

Documents the post-fix private SSID (TollGate-Private-XXXX); the
operator-username variant (c08r4d0r-XXXX) is a security leak being
fixed on pr/private-ssid-no-operator-username.
Main's setup now resolves radios by band before assigning APs
(detect_band_radios -> setup_band_ap), so the harness bootstraps a
banded first-boot wireless state, calls detect_band_radios before
setup_public_wifi, and reads the SSIDs back from the tollgate_*_open
sections the band-aware setup writes (adopting or creating them per
radio). The asserted contract is unchanged: one shared
`^TollGate-[0-9A-F]{4}$` name on both open radios, per-device suffix,
no band tokens.
The wave-3 catch: the entry sat inside the frozen [v0.6.0-alpha4]
section (the tag exists); new entries belong in [Unreleased].
TollGate-Private-XXXX is the post-#531 state and #531 is still open;
the shipped 99-tollgate-setup broadcasts c08r4d0r-${RANDOM_SUFFIX}.
Say that, and point at #531 for the rename (PR #524 review follow-up).
…odes

Codes minted by the current writers are hex, but the contract's acceptance
domain is [A-Z0-9] (adopted codes from earlier writers, e.g. the
pre-device-code installer's full-alphanumeric mints). Per #618's ADR
clarification.
#605 changed the derivation this gate pins: the SSID is no longer a
per-boot RANDOM_SUFFIX but DEVICE_SSID, built by setup_device_identity
from a code minted once and stored in /etc/config/tollgate. The harness
still modelled the pre-#605 world — setup_public_wifi died on
'DEVICE_SSID: unbound variable' after load_brand's brand read, and the
checker was 13 checks red at head.

The behavioural layer now runs the driver's own order (load_brand →
setup_device_identity → detect_band_radios → setup_public_wifi) with
DEVICE_CODE_STORE redirected into the per-run state (the env seam the
device-code tests use), so a wiped state is a fresh device that really
mints, and a new keep-mode run pins the other half of the contract: a
same-store rerun adopts the stored code and never re-mints.

The stub uci now renders section types bare in show output like the real
uci (the band-test shim's rule): with quoted section types band
detection and AP adoption were silently disabled, so the harness
exercised the fallback create path while claiming to follow the banded
first-boot state. The readback follows whichever sections the APs
actually landed on (the adopted default_radioN today) instead of keying
on tollgate_*_open, so the labels cannot lie about the path that ran.

Assertion domain is the full [A-Z0-9] code alphabet (this side's mint
emits hex; the installer's emits all of [A-Z0-9]; the contract is the
shared alphabet), and B2 accepts the $DEVICE_SSID derivation in place
of the removed RANDOM_SUFFIX/hexdump forms.

Verified: green at head (18 ok / 0 fail, 5 consecutive runs); red on
fresh-archive mutations — band suffix on the derivation (exit 1),
hardcoded literal at GATEWAY_NAME (exit 1: distinctness + store/SSID
disagreement + B2), deleted public ssid write (exit 1); band suite
22/22, device-code suite 72/72.
The README public-AP bullet still called the XXXX a '4-hex uppercase
device code' and the changelog entry still pinned ^TollGate-[0-9A-F]{4}$
and a 'per-device 4-hex suffix' — both stale against the #605 decision
(docs/architecture/one-device-code.md): the code is four characters of
[A-Z0-9] (this side's mint emits hex, the installer's emits the full
alphabet), minted once and reused from the store. The changelog entry
now also describes the harness as it works after the one-device-code
adaptation: the identity chain is sourced and run, the stub renders
uci show section types bare, and the code-reuse property is asserted.
@Amperstrand
Amperstrand force-pushed the pr/ssid-format-contract branch from d839b3d to e98badb Compare September 28, 2026 17:12
@Amperstrand

Copy link
Copy Markdown
Collaborator Author

One-device-code adaptation of the SSID harness + rebase onto main — new head e98badb5 (from d839b3d6, force-pushed with an explicit-SHA lease; d839b3d6's alphabet change is kept and finished).

Per finding (the 2026-09-28 blocker):

  • The stub now models feat(packaging): one device code — hostname, captive SSID and private SSID agree #605's derivation. The behavioural layer runs the driver's own order — load_brand → setup_device_identity → detect_band_radios → setup_public_wifi — with DEVICE_CODE_STORE redirected into the per-run state via the env seam the device-code tests use, so the harness never touches the host's /etc/config. A wiped state is a fresh device: setup_device_identity really mints (hexdump idiom), derives DEVICE_SSID, and stores the code — no more DEVICE_SSID: unbound variable at lib.sh:1307. The brand file is handled the way a router handles it: a host without /etc/tollgate/brand takes load_brand's own default-brand first-boot branch (the read's failure is that path, not an error — stated in an info line so the harness.log noise can't mislead a debugger), and a host with net4sats still flips the expected prefix.
  • The other half of the feat(packaging): one device code — hostname, captive SSID and private SSID agree #605 contract is pinned, not just minted. Two new checks run a second setup against the same state and store (what an upgrade/reinstall sees): the SSID must be identical both times (never re-minted) and the store's tollgate.device.code must be exactly what the SSID derives from. This is the measured bench drift the store exists to stop.
  • Assertion domain kept as [A-Z0-9]. Every regex is now ^TollGate-[A-Z0-9]{4}$ / ^[A-Z0-9]{4}$ (this side's mint emits hex; the installer's emits the full alphabet; the contract is the shared alphabet — docs/architecture/one-device-code.md). B2 accepts the $DEVICE_SSID derivation and drops the pre-feat(packaging): one device code — hostname, captive SSID and private SSID agree #605 ${BRAND_HOSTNAME}-${RANDOM_SUFFIX} / hexdump-recovery cases.
  • The uci show fidelity note is folded in (felixfelix-bot's finding 1 / the wave-3 review's fragility note): the stub renders section-type lines bare like real uci show (the band-test shim's documented rule). Band detection and AP adoption now actually run — the SSIDs land on the adopted stock default_radio0/1 sections, visible in the new per-section info lines — and the readback follows whichever sections the APs really landed on instead of keying on tollgate_*_open, so the labels can no longer lie about the path that ran. A count check pins "one write per radio".
  • The stale docs followed the decision (as the 2026-09-27 review anticipated): the README public-AP bullet now says four characters of [A-Z0-9], minted once — no more "4-hex" — and the changelog entry describes the identity-chain harness, the bare-rendering stub and the code-reuse property instead of ^TollGate-[0-9A-F]{4}$.

Rebased onto main (54e8c368) — clean, no conflicts; the entry remains exactly once under [Unreleased] → Changed / Internal.

Verification at the new head:

  • bash tests/contract/check-ssid-format.sh → exit 0, 17 ok / 0 FAIL (5 consecutive green runs; the 6-fresh-device mint-distinctness check showed no flake).
  • Mutation kills on fresh git archive copies, one mutation each, new checker: band suffix on the DEVICE_SSID derivation → exit 1 (behavioural regexes + suffix shape + store/SSID disagreement); hardcoded TollGate-A1B2 at the GATEWAY_NAME assignment → exit 1 (1 distinct code in 6 fresh devices + store/SSID disagreement + B2 structural); deleted public .ssid write → exit 1 (no SSID written + write-count + regexes).
  • bash -n + shellcheck clean; tests/uci-defaults-band_test.sh 22/22; tests/uci-defaults-device-code_test.sh 72/72; python3 tests/contract/check-mirror-sync.py OK; node tests/contract/js-schema-lint.mjs PASS. No src/ file touched, so the Go battery is not applicable.

Amperstrand pushed a commit that referenced this pull request Oct 5, 2026
Resolving a CHANGELOG conflict with a section-level 'take ours' can
resurrect an entry a branch commit had already moved or reworded — the
same bullet then ships in two sections, release notes describe one
change twice, and entry-counting fold scripts go wrong. This happened
twice in one rebasing session (#524 and #531 both carried a
resurrected stale copy past a --ours resolution until audited).

check-changelog-duplicates.py fails when the same bold lead-in appears
in two sections and at least one is [Unreleased] — the live edit zone.
The same lead-in inside already-released sections (15 such pairs exist
in history, e.g. 'gonuts re-pin.') is frozen history and deliberately
tolerated: release notes are not rewritten. Wired into hooks/pre-commit
next to the version-sync check; pinned by changelog-duplicates_test.sh
across all three verdicts.

PR #581 review follow-ups, folded into the same commit:

- Wire the check and its verdict script into .github/workflows/test.yml
  beside the deps/import contract checks: pre-commit cannot gate
  sequencer commits (a bad --ours resolution during rebase --continue
  sails past the hook), so CI is the only place the check runs for
  everyone regardless of hooks.
- Scope the docstring and the failure/success wording to the
  version-section keying actually enforced — the same lead-in in two
  version sections with [Unreleased] among them — instead of promising
  lead-in-wide uniqueness the frozen-history tolerance contradicts.
- Append the PR link to the changelog entry; the rebase onto post-#577
  main resolves the CHANGELOG conflict keep-both and drops the branch's
  inherited orphaned second [Unreleased] section.
@c03rad0r
c03rad0r requested review from c03rad0r and removed request for felixfelix-bot October 6, 2026 18:41
@felixfelix-bot

Copy link
Copy Markdown
Contributor

Rebased onto main @ 977fa96f — offered as a PR to main since this branch cannot be updated from the felixfelix-bot fork (no write access). Only conflict: README.md, where main's new ### Network settings subsection and this PR's ## SSID conventions section land at the same spot — both kept. git diff main <rebased> = 9 files, +460/−8, identical to this PR's own stats; check-ssid-format.sh green against current main's setup script. Details and both swap routes in the linked PR.

felixfelix-bot pushed a commit to felixfelix-bot/tollgate-module-basic-go that referenced this pull request Oct 6, 2026
…ollGate#531's

TollGate-Private-XXXX is the post-OpenTollGate#531 state and OpenTollGate#531 is still open;
the shipped 99-tollgate-setup broadcasts c08r4d0r-${RANDOM_SUFFIX}.
Say that, and point at OpenTollGate#531 for the rename (PR OpenTollGate#524 review follow-up).
@felixfelix-bot

Copy link
Copy Markdown
Contributor

Conflicts still resolved — now refreshed onto the current main (0dbae452) so the replacement PR is a clean base-first rebase rather than sitting on a pre-#671 base.

Replacement PR: #672 — #672 — base main, head pr/ssid-format-contract-rebased @ 477fa403, 8 commits, 9 files, +460/−8 (the same shape as this PR), MERGEABLE.

All 8 commits replayed onto 0dbae452 with no conflict left: main's newer work merged underneath cleanly (the #671 CHANGELOG entry and tests/uci-defaults-rootfs_test.sh land in their own regions).

The one file that still conflicts in a plain main × this-PR merge is README.md — the PR's ## SSID conventions section vs main's ### Network settings subsection, which is the region #524 was always going to collide on. In the rebased branch it is resolved by keeping both, and the result is provably just that: git diff main -- README.md is a single hunk, +26/−0, exactly the PR's own section, with neither heading duplicated (## SSID conventions ×1, ### Network settings ×1).

Verification on the refreshed head:

  • docs/release-process.md, hooks/pre-commit, tests/contract/check-ssid-format.sh are blob-identical to this PR's head e98badb5; the other six (.github/workflows/test.yml, .ngit/act/workflows/test.yml, AGENTS.md, CHANGELOG.md, CONTRIBUTING.md, README.md) are byte-identical to git merge-tree main <this PR> — i.e. identical to git's own three-way merge, so nothing was hand-edited beyond the README region above.
  • bash -n + shellcheck -S warning clean on tests/contract/check-ssid-format.sh and hooks/pre-commit.
  • The gate itself ran green on the new base, against current main's 99-tollgate-setup: layer A → TollGate-3D1D written to both radios, matches ^TollGate-[A-Z0-9]{4}$, one shared name, 6 fresh devices → 6 distinct codes, same-store rerun reuses the code (TollGate-5A28 both times); layer B → every assignment site ok. exit 0.

No force-push to your branch is needed from anyone: merging #672 lands the identical 8 commits, and this PR then closes as superseded. The alternative (force-push pr/ssid-format-contract-rebased onto pr/ssid-format-contract) is in #672's body if you prefer that route.

c03rad0r pushed a commit that referenced this pull request Oct 6, 2026
* test(contract): pin the shipped AP SSID format

The open AP is named `<brand>-<4 hex characters>` (`TollGate-A1B2` for the
default brand) and both radios carry that one name. Nothing enforced it: the
4-character device suffix and the single-name property were conventions in
99-tollgate-setup, so a band suffix ("TollGate-A1B2-2.4GHz") or a hardcoded
brand could come back unnoticed. The Go side keys on the `TollGate-` prefix
(discovery_log.go `hasTollGateSSID`, vendor_element_manager.go
`calculateScore`), and the SSID rides in every beacon, so the drift is both a
client-matching and a naming-honesty bug.

tests/contract/check-ssid-format.sh asserts the contract in two layers:

- behavioural: sources the real load_brand()/setup_public_wifi() out of the
  script (driver stripped) and runs them against a stub `uci` that records
  what they would have written, then checks the SSID actually generated —
  both radios matching ^TollGate-[0-9A-F]{4}$ for the default brand, one
  shared name for both radios, a per-device 4-hex suffix over 6 generations.
- structural: checks the assignment sites a checkout cannot execute — the
  same-version reinstall path recovers its SSID from live config and the
  whitelabel branch reads /etc/tollgate/brand — failing on a band token or on
  an SSID written from anything but a variable.

Red evidence, all reverted before this commit: appending "-2.4GHz" to the
gateway name fails 12 checks; hardcoding the brand at the `.ssid` write fails
the structural layer (which the behavioural layer alone cannot see); deleting
the write fails the anti-vacuity guard.

Wired into both CI test workflows (build-purity job), hooks/pre-commit, and
the release/contribution runbooks.

Refs: t_9e5f0b8d

* docs(readme): document SSID format conventions

Public AP is BRAND-XXXX with a 4-hex uppercase suffix from /dev/urandom,
shared across the 2.4GHz and 5GHz radios (band steering). Private
management AP is BRAND-Private-XXXX with a WPA2 word-word-word-NN
passphrase. nodogsplash gatewayname derives as '<gateway> Portal'.
The public format is pinned by tests/contract/check-ssid-format.sh.

Documents the post-fix private SSID (TollGate-Private-XXXX); the
operator-username variant (c08r4d0r-XXXX) is a security leak being
fixed on pr/private-ssid-no-operator-username.

* test(contract): follow the band-aware setup in the SSID harness

Main's setup now resolves radios by band before assigning APs
(detect_band_radios -> setup_band_ap), so the harness bootstraps a
banded first-boot wireless state, calls detect_band_radios before
setup_public_wifi, and reads the SSIDs back from the tollgate_*_open
sections the band-aware setup writes (adopting or creating them per
radio). The asserted contract is unchanged: one shared
`^TollGate-[0-9A-F]{4}$` name on both open radios, per-device suffix,
no band tokens.

* docs(changelog): move the SSID-format contract entry to [Unreleased]

The wave-3 catch: the entry sat inside the frozen [v0.6.0-alpha4]
section (the tag exists); new entries belong in [Unreleased].

* docs(readme): the private-AP bullet documents today's SSID, not #531's

TollGate-Private-XXXX is the post-#531 state and #531 is still open;
the shipped 99-tollgate-setup broadcasts c08r4d0r-${RANDOM_SUFFIX}.
Say that, and point at #531 for the rename (PR #524 review follow-up).

* test(contract): assert the acceptance domain [A-Z0-9], not hex-only codes

Codes minted by the current writers are hex, but the contract's acceptance
domain is [A-Z0-9] (adopted codes from earlier writers, e.g. the
pre-device-code installer's full-alphanumeric mints). Per #618's ADR
clarification.

* test(contract): follow the one-device-code model in the SSID harness

#605 changed the derivation this gate pins: the SSID is no longer a
per-boot RANDOM_SUFFIX but DEVICE_SSID, built by setup_device_identity
from a code minted once and stored in /etc/config/tollgate. The harness
still modelled the pre-#605 world — setup_public_wifi died on
'DEVICE_SSID: unbound variable' after load_brand's brand read, and the
checker was 13 checks red at head.

The behavioural layer now runs the driver's own order (load_brand →
setup_device_identity → detect_band_radios → setup_public_wifi) with
DEVICE_CODE_STORE redirected into the per-run state (the env seam the
device-code tests use), so a wiped state is a fresh device that really
mints, and a new keep-mode run pins the other half of the contract: a
same-store rerun adopts the stored code and never re-mints.

The stub uci now renders section types bare in show output like the real
uci (the band-test shim's rule): with quoted section types band
detection and AP adoption were silently disabled, so the harness
exercised the fallback create path while claiming to follow the banded
first-boot state. The readback follows whichever sections the APs
actually landed on (the adopted default_radioN today) instead of keying
on tollgate_*_open, so the labels cannot lie about the path that ran.

Assertion domain is the full [A-Z0-9] code alphabet (this side's mint
emits hex; the installer's emits all of [A-Z0-9]; the contract is the
shared alphabet), and B2 accepts the $DEVICE_SSID derivation in place
of the removed RANDOM_SUFFIX/hexdump forms.

Verified: green at head (18 ok / 0 fail, 5 consecutive runs); red on
fresh-archive mutations — band suffix on the derivation (exit 1),
hardcoded literal at GATEWAY_NAME (exit 1: distinctness + store/SSID
disagreement + B2), deleted public ssid write (exit 1); band suite
22/22, device-code suite 72/72.

* docs: the SSID contract wording follows the device-code alphabet

The README public-AP bullet still called the XXXX a '4-hex uppercase
device code' and the changelog entry still pinned ^TollGate-[0-9A-F]{4}$
and a 'per-device 4-hex suffix' — both stale against the #605 decision
(docs/architecture/one-device-code.md): the code is four characters of
[A-Z0-9] (this side's mint emits hex, the installer's emits the full
alphabet), minted once and reused from the store. The changelog entry
now also describes the harness as it works after the one-device-code
adaptation: the identity chain is sourced and run, the stub renders
uci show section types bare, and the code-reuse property is asserted.

---------

Co-authored-by: felixfelix-bot <felixfelix-bot@users.noreply.github.com>
Co-authored-by: Amperstrand <amperstrand@localhost>
@c03rad0r c03rad0r closed this in #672 Oct 6, 2026
Amperstrand pushed a commit that referenced this pull request Oct 6, 2026
Resolving a CHANGELOG conflict with a section-level 'take ours' can
resurrect an entry a branch commit had already moved or reworded — the
same bullet then ships in two sections, release notes describe one
change twice, and entry-counting fold scripts go wrong. This happened
twice in one rebasing session (#524 and #531 both carried a
resurrected stale copy past a --ours resolution until audited).

check-changelog-duplicates.py fails when the same bold lead-in appears
in two sections and at least one is [Unreleased] — the live edit zone.
The same lead-in inside already-released sections (15 such pairs exist
in history, e.g. 'gonuts re-pin.') is frozen history and deliberately
tolerated: release notes are not rewritten. Wired into hooks/pre-commit
next to the version-sync check; pinned by changelog-duplicates_test.sh
across all three verdicts.

PR #581 review follow-ups, folded into the same commit:

- Wire the check and its verdict script into .github/workflows/test.yml
  beside the deps/import contract checks: pre-commit cannot gate
  sequencer commits (a bad --ours resolution during rebase --continue
  sails past the hook), so CI is the only place the check runs for
  everyone regardless of hooks.
- Scope the docstring and the failure/success wording to the
  version-section keying actually enforced — the same lead-in in two
  version sections with [Unreleased] among them — instead of promising
  lead-in-wide uniqueness the frozen-history tolerance contradicts.
- Append the PR link to the changelog entry; the rebase onto post-#577
  main resolves the CHANGELOG conflict keep-both and drops the branch's
  inherited orphaned second [Unreleased] section.
Amperstrand pushed a commit that referenced this pull request Oct 6, 2026
Resolving a CHANGELOG conflict with a section-level 'take ours' can
resurrect an entry a branch commit had already moved or reworded — the
same bullet then ships in two sections, release notes describe one
change twice, and entry-counting fold scripts go wrong. This happened
twice in one rebasing session (#524 and #531 both carried a
resurrected stale copy past a --ours resolution until audited).

check-changelog-duplicates.py fails when the same bold lead-in appears
in two sections and at least one is [Unreleased] — the live edit zone.
The same lead-in inside already-released sections (15 such pairs exist
in history, e.g. 'gonuts re-pin.') is frozen history and deliberately
tolerated: release notes are not rewritten. Wired into hooks/pre-commit
next to the version-sync check; pinned by changelog-duplicates_test.sh
across all three verdicts.

PR #581 review follow-ups, folded into the same commit:

- Wire the check and its verdict script into .github/workflows/test.yml
  beside the deps/import contract checks: pre-commit cannot gate
  sequencer commits (a bad --ours resolution during rebase --continue
  sails past the hook), so CI is the only place the check runs for
  everyone regardless of hooks.
- Scope the docstring and the failure/success wording to the
  version-section keying actually enforced — the same lead-in in two
  version sections with [Unreleased] among them — instead of promising
  lead-in-wide uniqueness the frozen-history tolerance contradicts.
- Append the PR link to the changelog entry; the rebase onto post-#577
  main resolves the CHANGELOG conflict keep-both and drops the branch's
  inherited orphaned second [Unreleased] section.
Amperstrand pushed a commit that referenced this pull request Oct 6, 2026
Resolving a CHANGELOG conflict with a section-level 'take ours' can
resurrect an entry a branch commit had already moved or reworded — the
same bullet then ships in two sections, release notes describe one
change twice, and entry-counting fold scripts go wrong. This happened
twice in one rebasing session (#524 and #531 both carried a
resurrected stale copy past a --ours resolution until audited).

check-changelog-duplicates.py fails when the same bold lead-in appears
in two sections and at least one is [Unreleased] — the live edit zone.
The same lead-in inside already-released sections (15 such pairs exist
in history, e.g. 'gonuts re-pin.') is frozen history and deliberately
tolerated: release notes are not rewritten. Wired into hooks/pre-commit
next to the version-sync check; pinned by changelog-duplicates_test.sh
across all three verdicts.

PR #581 review follow-ups, folded into the same commit:

- Wire the check and its verdict script into .github/workflows/test.yml
  beside the deps/import contract checks: pre-commit cannot gate
  sequencer commits (a bad --ours resolution during rebase --continue
  sails past the hook), so CI is the only place the check runs for
  everyone regardless of hooks.
- Scope the docstring and the failure/success wording to the
  version-section keying actually enforced — the same lead-in in two
  version sections with [Unreleased] among them — instead of promising
  lead-in-wide uniqueness the frozen-history tolerance contradicts.
- Append the PR link to the changelog entry; the rebase onto post-#577
  main resolves the CHANGELOG conflict keep-both and drops the branch's
  inherited orphaned second [Unreleased] section.
Amperstrand added a commit that referenced this pull request Oct 6, 2026
…581)

Resolving a CHANGELOG conflict with a section-level 'take ours' can
resurrect an entry a branch commit had already moved or reworded — the
same bullet then ships in two sections, release notes describe one
change twice, and entry-counting fold scripts go wrong. This happened
twice in one rebasing session (#524 and #531 both carried a
resurrected stale copy past a --ours resolution until audited).

check-changelog-duplicates.py fails when the same bold lead-in appears
in two sections and at least one is [Unreleased] — the live edit zone.
The same lead-in inside already-released sections (15 such pairs exist
in history, e.g. 'gonuts re-pin.') is frozen history and deliberately
tolerated: release notes are not rewritten. Wired into hooks/pre-commit
next to the version-sync check; pinned by changelog-duplicates_test.sh
across all three verdicts.

PR #581 review follow-ups, folded into the same commit:

- Wire the check and its verdict script into .github/workflows/test.yml
  beside the deps/import contract checks: pre-commit cannot gate
  sequencer commits (a bad --ours resolution during rebase --continue
  sails past the hook), so CI is the only place the check runs for
  everyone regardless of hooks.
- Scope the docstring and the failure/success wording to the
  version-section keying actually enforced — the same lead-in in two
  version sections with [Unreleased] among them — instead of promising
  lead-in-wide uniqueness the frozen-history tolerance contradicts.
- Append the PR link to the changelog entry; the rebase onto post-#577
  main resolves the CHANGELOG conflict keep-both and drops the branch's
  inherited orphaned second [Unreleased] section.

Co-authored-by: Amperstrand <amperstrand@localhost>
c03rad0r pushed a commit that referenced this pull request Oct 6, 2026
…nflicts stop existing (#679)

* feat(changelog): fold changelog.d fragments into CHANGELOG.md

Two PRs that change operator-visible behaviour both append one bullet to the
same anchor of CHANGELOG.md, so the second merge conflicts every time — about a
dozen hand-resolved hunks on the 0.6.0 train, and two of those resolutions
(#524, #531) resurrected an entry a branch had already moved past a
keep-both-sides resolution. 15 open PRs carry a CHANGELOG.md hunk at once as
this lands, so the merge order was also a changelog-editing order.

A fragment is one file per change — changelog.d/<pr>-<slug>.<type>.md, the body
being the bullet verbatim — so unique filenames cannot conflict and a merge
never touches the changelog at all. `scripts/changelog-assemble.py fold` writes
the accumulated entries into the release section in one deterministic step (one
wave under the release heading, newest PR first, subsections in canonical
order) and deletes the folded fragments; `check` validates names and bodies;
`--dry-run` prints the wave and writes nothing.

The contract test went red first — 21 failing cases with the tool absent — and
green after: 18 cases covering where entries land, the order they land in, what
is deleted, the dry run, and each verdict of check. shellcheck clean; the fold
was also run against the real 3,500-line CHANGELOG.md in a copy to see the wave
it produces (folded into `## [Unreleased]`, everything else untouched).

Decision and the rejected alternatives: docs/architecture/changelog-fragments-decision.md

* chore(changelog): validate fragments in the pre-commit hook and CI

`check` runs where the other contract checks run: the pre-commit hook refuses a
malformed fragment (wrong filename, unknown type, a body that is not the bullet)
before it is committed, and the `contract-lint` job runs the check plus the
tool's contract test on every PR. Wired into `.github/workflows/test.yml` and
its ngit port, which is the lane that actually executes.

* docs(changelog): record the fragment decision and the workflow

The decision record states the problem (one shared anchor, a dozen hand-resolved
hunks a train, two resurrections), the decision (fragments + one fold), and the
alternatives it rejects — a `merge=union` gitattributes driver (hides the shared
anchor and silently keeps both sides of a bullet that was rewritten) and
generating the changelog from commit subjects (loses the prose this changelog
is worth reading for). CONTRIBUTING sends authors to changelog.d/ instead of
CHANGELOG.md, and the release process folds the accumulated fragments in its own
commit before the release commit is cut.

* docs(changelog): this PR's own entry is a fragment

The rule this PR adds applies to it: the changelog entry lives in
changelog.d/679-changelog-fragments.added.md and is not folded here, so the
first fold the maintainer runs produces it.

---------

Co-authored-by: Felix <felix@opentollgate.org>
@Amperstrand
Amperstrand deleted the pr/ssid-format-contract branch October 17, 2026 19:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants