From a1fc0ef6793b398bb7d7710617bfc7160bdb53b5 Mon Sep 17 00:00:00 2001 From: Nils Lehnen <30603423+iderex@users.noreply.github.com> Date: Wed, 2 Sep 2026 16:19:37 +0200 Subject: [PATCH] Give a record that narrows one clause of another a shape, and check it (#267) 0001 permits a pointer to a later record that goes further on a case the earlier one already names, and fixes nothing about where the pointer goes or what it looks like. The one instance in the tree took the shape that reading allows: a paragraph at line 120 of 0103, inside the section it concerns. 0267 decides the pointer is a pair of header fields instead. The narrowing record carries `Narrows: NNNN, ` and the record it narrows carries `Narrowed-by: NNNN, ` beside its `Status:` line, each naming the other and each naming the clause that moved. The record states what this asks of 0001 rather than leaving it to be inferred, in both the directions it changes clause three, and 0001 receives the first `Narrowed-by:` line in the tree. The failure it prevents is a reader who stops at a record's header. Today that reader opens 0103, sees `accepted` with nothing beside it, and applies its fourth refused behaviour one clause wider than the rule in force - refusing a dependency this board decided is admissible - with nothing telling them anything is missing. A wrong reading of a record reads exactly like a right one. Both fields are refused unless they pair, which is what makes the second one worth its cost. A pointer only one end carries rots the first time a record is renumbered or withdrawn, and it rots in silence, which is the same defect class one level up. `.github/decision-records/decision-records.sh` refuses a field naming a record that does not exist, a field naming a record and no clause, a field below the first heading, a record narrowing itself, and a half with no answering half. Sixteen fixtures run before it judges anything, nine of them violations and seven near misses that must not bite - a `Status:` line, the field quoted in a fenced or indented block, the field indented by one space, and a record carrying neither field, which is fifty-eight of the sixty-two. The pairing was watched failing on the real tree rather than only on a fixture: deleting the `Narrows:` line from 0243 reddens the run naming 0103's line 7, and restoring it returns the four fields to green. What the check cannot reach is printed on every run rather than only written down. Whether the clause a field names is the clause that actually moved is a judgement no reading of the text makes, and a later record that narrows an earlier one and writes no field at all is silent to every rule in it. The means is bash and awk, matching .github/doc-paths/doc-paths.sh and .github/invariants/invariants.sh: the subject is tracked text, the rules owe executed fixtures rather than a type system, and nothing here needs a toolchain, so the check has a verdict on a tree the compiler cannot build. It is its own script and its own workflow rather than a second rule family inside doc-paths, because that check's subject is a path that resolves and its own run prints an accounting of what it does not read that this would have made false. Nothing in src/ changes and no record's own text is touched beyond the added fields. Signed-off-by: Nils Lehnen <30603423+iderex@users.noreply.github.com> --- .github/decision-records/decision-records.sh | 449 ++++++++++++++++++ .github/workflows/decision-records.yml | 64 +++ CONTRIBUTING.md | 12 + docs/decisions/0001-decision-records.md | 2 + ...admits-a-dependency-and-what-is-refused.md | 2 + ...e-means-a-certificate-is-validated-with.md | 2 + ...cord-that-narrows-one-clause-of-another.md | 166 +++++++ docs/decisions/README.md | 1 + 8 files changed, 698 insertions(+) create mode 100644 .github/decision-records/decision-records.sh create mode 100644 .github/workflows/decision-records.yml create mode 100644 docs/decisions/0267-a-record-that-narrows-one-clause-of-another.md diff --git a/.github/decision-records/decision-records.sh b/.github/decision-records/decision-records.sh new file mode 100644 index 0000000..294cc17 --- /dev/null +++ b/.github/decision-records/decision-records.sh @@ -0,0 +1,449 @@ +#!/usr/bin/env bash +# A narrowing is named in both directions (#267). +# +# 0001 fixes that a record is added or superseded and never edited in place, and +# permits a pointer to a later record that goes further on a case the record +# already names. 0267 decides the shape that pointer takes where the later record +# narrows one clause of an earlier one: a `Narrowed-by:` line in the narrowed +# record's header and a `Narrows:` line in the narrowing record's, each naming the +# other and each naming the clause. +# +# What this check is for: a pointer only one side carries rots the first time a +# record is renumbered or withdrawn, and it rots in silence. That is the same +# defect class the field exists to fix, one level up - a reader who does not +# follow the pointer reads a clause wider than the rule in force, and a reader who +# follows a pointer nothing answers learns nothing at all. +# +# The rules live here as shell functions rather than as steps inside the workflow +# because each one owes a fixture proving it bites, and a fixture run against a +# second copy of the logic proves the copy. `selftest` and `check` call the same +# functions, so a rule cannot pass its fixture and refuse something else in the +# gate. +# +# Verbs: +# selftest run every fixture and prove each rule bites +# check apply the rules to every tracked decision record, and refuse +# +# `check` reads the repository through `git ls-files`, so the authority for what +# exists is the set of tracked records rather than the working tree. A record +# present on disk and not added is not one a reader can reach, and a pointer at it +# is a pointer at nothing. + +set -euo pipefail + +# -------------------------------------------------------------------------- +# Rules. `scan_fields` reads one record on stdin and writes records to stdout, +# one per line, as LINEKINDRAW. +# +# KIND is `narrows`, `narrowed-by`, `narrows-late` or `narrowed-by-late`, where +# late means the field was written after the first `## ` heading. The header is +# where a reader lands before the prose, which is the whole reason the decision +# took a field rather than a sentence in a section, so a field written below the +# first heading is refused there rather than being counted as though it had been +# in front of the reader. +# +# awk rather than grep throughout, for the reason .github/doc-paths/doc-paths.sh +# already gives: grep exits 1 when it selects nothing, which is the ordinary +# answer here, and a pipeline that has to tell "nothing matched" from "the scanner +# broke" is how a gate ends up passing on everything. +# +# No POSIX character classes and no interval expressions in any pattern below, for +# the reason that file gives too: the awk on the runner is mawk and the awk on a +# contributor's machine is frequently gawk, and those two constructs are where the +# older mawk builds disagree with it. +# +# Fenced and indented blocks are not read. A record arguing about this format +# quotes the field it is about, and a quotation of a field is not a field. +# -------------------------------------------------------------------------- + +scan_fields() { + awk ' + BEGIN { fence = 0; inprose = 0 } + { + line = $0 + sub(/\r$/, "", line) + + if (line ~ /^[ \t]*```/ || line ~ /^[ \t]*~~~/) { fence = 1 - fence; next } + if (fence) next + if (line ~ /^( |\t)/) next + if (line ~ /^## /) { inprose = 1 } + + if (line ~ /^Narrows:/) { + kind = inprose ? "narrows-late" : "narrows" + raw = substr(line, 9) + } else if (line ~ /^Narrowed-by:/) { + kind = inprose ? "narrowed-by-late" : "narrowed-by" + raw = substr(line, 13) + } else next + + gsub(/^[ \t]+|[ \t]+$/, "", raw) + printf "%d\t%s\t%s\n", FNR, kind, raw + } + ' +} + +# Every tracked decision record, and the four-digit number each one carries, as +# NUMBERPATH. +# +# The number comes from the file name rather than from the first line, because the +# file name is what a pointer resolves against and 0001 fixes the two to agree. +record_universe() { + git ls-files 'docs/decisions/*.md' | awk ' + { + sub(/\r$/, "") + if ($0 == "") next + p = $0 + i = length(p) + while (i > 0 && substr(p, i, 1) != "/") i-- + base = substr(p, i + 1) + if (base !~ /^[0-9][0-9][0-9][0-9]-/) next + printf "%s\t%s\n", substr(base, 1, 4), p + } + ' | sort -u +} + +# The verdict over the whole corpus. +# +# Standard input is the corpus: PATHNUMBERLINEKINDRAW, every +# field of every record in one stream. $1 is the universe `record_universe` +# writes. +# +# It is one pass over everything rather than one pass per record, because the +# second direction is not a property of any single record. A record carrying +# `Narrowed-by: 0243` is right or wrong depending on what 0243 carries, and a +# checker judging each file alone could only ever see half of that. +verdicts() { + awk -F'\t' -v universe="$1" ' + BEGIN { + while ((getline u < universe) > 0) { + sub(/\r$/, "", u) + if (u == "") continue + split(u, uf, "\t") + exists[uf[1]] = 1 + } + } + { + n = ++nf + f_path[n] = $1; f_num[n] = $2; f_line[n] = $3; f_kind[n] = $4; f_raw[n] = $5 + if (($4 == "narrows" || $4 == "narrowed-by") && $5 ~ /^[0-9][0-9][0-9][0-9],/) { + pair[$4 "|" $2 "|" substr($5, 1, 4)] = 1 + } + } + END { + for (i = 1; i <= nf; i++) { + path = f_path[i]; num = f_num[i]; ln = f_line[i]; kind = f_kind[i]; raw = f_raw[i] + + if (kind == "narrows-late" || kind == "narrowed-by-late") { + field = (kind == "narrows-late") ? "Narrows:" : "Narrowed-by:" + printf "REFUSE\t%s\t%d\tcarries %s below the first heading, where a reader who stopped at the header has already passed it\n", path, ln, field + refusals++ + continue + } + + field = (kind == "narrows") ? "Narrows:" : "Narrowed-by:" + + if (raw !~ /^[0-9][0-9][0-9][0-9],/) { + printf "REFUSE\t%s\t%d\t%s is not a four-digit record number followed by a comma and the clause\n", path, ln, field + refusals++ + continue + } + + target = substr(raw, 1, 4) + clause = substr(raw, 6) + sub(/^[ \t]+/, "", clause) + + if (target == num) { + printf "REFUSE\t%s\t%d\t%s names %s, which is this record itself\n", path, ln, field, target + refusals++ + continue + } + + if (!(target in exists)) { + printf "REFUSE\t%s\t%d\t%s names %s, which is no record in docs/decisions/\n", path, ln, field, target + refusals++ + continue + } + + if (clause == "") { + printf "REFUSE\t%s\t%d\t%s names record %s and no clause, so a reader still has to diff two records to find which one moved\n", path, ln, field, target + refusals++ + continue + } + + if (kind == "narrowed-by") { + if (!(("narrows|" target "|" num) in pair)) { + printf "REFUSE\t%s\t%d\tnames %s as narrowing it, and %s carries no Narrows: line naming %s back\n", path, ln, target, target, num + refusals++ + continue + } + } else { + if (!(("narrowed-by|" target "|" num) in pair)) { + printf "REFUSE\t%s\t%d\tnarrows %s, and %s carries no Narrowed-by: line naming %s back\n", path, ln, target, target, num + refusals++ + continue + } + } + + paired++ + } + printf "COUNT\t%d\t%d\n", paired + 0, refusals + 0 + } + ' +} + +# -------------------------------------------------------------------------- +# selftest +# +# Every fixture below judges against its own universe rather than against this +# repository. A row that judged the real tree would prove the state of the tree on +# the day it ran, not the rule. +# -------------------------------------------------------------------------- + +selftest_failures=0 + +assert_out() { + local what="$1" expected="$2" actual="$3" + if [ "$expected" = "$actual" ]; then + printf 'ok %s\n' "$what" + else + printf 'FAIL %s\n expected: %s\n actual: %s\n' \ + "$what" "$(printf '%s' "$expected" | tr '\n' '|')" "$(printf '%s' "$actual" | tr '\n' '|')" + selftest_failures=$((selftest_failures + 1)) + fi +} + +# The refusals a corpus produces against a fixed universe, one per line. +# +# A fixture is one or more records, each given as NUMBER and body. The bodies are +# scanned by the same function the gate scans a tracked record with. +judge_fixture() { + local universe="$1" + shift + local cf num body + cf="$(mktemp)" + while [ "$#" -gt 0 ]; do + num="$1" + body="$2" + shift 2 + printf '%s' "$body" | scan_fields \ + | awk -F'\t' -v p="docs/decisions/$num-x.md" -v n="$num" \ + '{ printf "%s\t%s\t%s\t%s\t%s\n", p, n, $1, $2, $3 }' >> "$cf" + done + verdicts "$universe" < "$cf" | awk -F'\t' '$1 == "REFUSE" { print $2 ":" $3 ": " $4 }' + rm -f "$cf" +} + +selftest() { + local uni + uni="$(mktemp)" + printf '0001\tdocs/decisions/0001-x.md\n0103\tdocs/decisions/0103-x.md\n0243\tdocs/decisions/0243-x.md\n' > "$uni" + + local narrowed clean_status + narrowed='# 0103. A record + +Date: 2026-08-24 + +Status: accepted. Supersedes nothing. Superseded by nothing. + +Narrowed-by: 0243, on the fourth refused behaviour + +Issue: #103 + +## The decision +' + local narrowing='# 0243. A later record + +Date: 2026-08-31 + +Status: accepted. Supersedes nothing. Superseded by nothing. + +Narrows: 0103, on the fourth refused behaviour + +Issue: #243 + +## The decision +' + + echo "== a narrowing named in both directions ==" + assert_out "passes: both halves present, each naming the other and the clause" \ + "" "$(judge_fixture "$uni" 0103 "$narrowed" 0243 "$narrowing")" + assert_out "bites: the narrowed record alone, with nothing pointing back" \ + "docs/decisions/0103-x.md:7: names 0243 as narrowing it, and 0243 carries no Narrows: line naming 0103 back" \ + "$(judge_fixture "$uni" 0103 "$narrowed")" + assert_out "bites: the narrowing record alone, with nothing pointing back" \ + "docs/decisions/0243-x.md:7: narrows 0103, and 0103 carries no Narrowed-by: line naming 0243 back" \ + "$(judge_fixture "$uni" 0243 "$narrowing")" + assert_out "bites: both halves present and each naming a third record instead of the other" \ + "$(printf 'docs/decisions/0103-x.md:7: names 0001 as narrowing it, and 0001 carries no Narrows: line naming 0103 back\ndocs/decisions/0243-x.md:7: narrows 0001, and 0001 carries no Narrowed-by: line naming 0243 back')" \ + "$(judge_fixture "$uni" \ + 0103 "${narrowed/0243, on the fourth/0001, on the fourth}" \ + 0243 "${narrowing/0103, on the fourth/0001, on the fourth}")" + + echo "== the record named has to exist ==" + assert_out "bites: a pointer at a number no record carries" \ + "docs/decisions/0103-x.md:7: Narrowed-by: names 0244, which is no record in docs/decisions/" \ + "$(judge_fixture "$uni" 0103 "${narrowed/0243, on the fourth/0244, on the fourth}")" + assert_out "bites: a record narrowing itself" \ + "docs/decisions/0103-x.md:7: Narrowed-by: names 0103, which is this record itself" \ + "$(judge_fixture "$uni" 0103 "${narrowed/0243, on the fourth/0103, on the fourth}")" + + echo "== the clause is the payload and is required ==" + assert_out "bites: the record named and the clause left off" \ + "docs/decisions/0103-x.md:7: Narrowed-by: names record 0243 and no clause, so a reader still has to diff two records to find which one moved" \ + "$(judge_fixture "$uni" 0103 "${narrowed/0243, on the fourth refused behaviour/0243, }")" + assert_out "bites: a bare record number with no comma after it" \ + "docs/decisions/0103-x.md:7: Narrowed-by: is not a four-digit record number followed by a comma and the clause" \ + "$(judge_fixture "$uni" 0103 "${narrowed/0243, on the fourth refused behaviour/0243}")" + assert_out "bites: an issue reference written where the record number belongs" \ + "docs/decisions/0103-x.md:7: Narrowed-by: is not a four-digit record number followed by a comma and the clause" \ + "$(judge_fixture "$uni" 0103 "${narrowed/0243, on the fourth/#243, on the fourth}")" + + echo "== the field sits in the header ==" + clean_status='# 0103. A record + +Date: 2026-08-24 + +Status: accepted. Supersedes nothing. Superseded by nothing. + +Issue: #103 + +## The decision + +Narrowed-by: 0243, on the fourth refused behaviour +' + assert_out "bites: the same field one heading too far down" \ + "docs/decisions/0103-x.md:11: carries Narrowed-by: below the first heading, where a reader who stopped at the header has already passed it" \ + "$(judge_fixture "$uni" 0103 "$clean_status")" + + echo "== what a field is not ==" + assert_out "passes: a Status line, which carries the other kind of pointer" \ + "" "$(judge_fixture "$uni" 0103 '# 0103. A record + +Status: accepted. Supersedes nothing. Superseded by 0243. + +Issue: #103 +')" + assert_out "passes: the field quoted inside a fenced block, which is a record arguing about the format" \ + "" "$(judge_fixture "$uni" 0103 '# 0103. A record + +The shape is: + +``` +Narrowed-by: 9999, a clause naming nothing +``` + +## The decision +')" + assert_out "passes: the field quoted inside an indented block, where this board writes them more often" \ + "" "$(judge_fixture "$uni" 0103 '# 0103. A record + +The shape is: + + Narrowed-by: 9999, a clause naming nothing + +## The decision +')" + assert_out "passes: a sentence beginning with the word and no colon at column zero" \ + "" "$(judge_fixture "$uni" 0103 '# 0103. A record + +Narrowed by 0243 is what this became, in prose rather than in a field. + +## The decision +')" + assert_out "passes: the field indented by one space, which is prose and not a header line" \ + "" "$(judge_fixture "$uni" 0103 '# 0103. A record + + Narrowed-by: 9999, a clause naming nothing + +## The decision +')" + assert_out "passes: a record carrying neither field, which is nearly all of them" \ + "" "$(judge_fixture "$uni" 0103 '# 0103. A record + +Status: accepted. Supersedes nothing. Superseded by nothing. + +Issue: #103 + +## The decision +')" + + rm -f "$uni" + + echo + if [ "$selftest_failures" -ne 0 ]; then + echo "::error::$selftest_failures decision-record fixture(s) did not hold. The rules below are not the rules that were proven, so this run judges nothing." + return 1 + fi + echo "Every fixture held. The rules the gate applies are the rules these fixtures ran." +} + +# -------------------------------------------------------------------------- +# check +# -------------------------------------------------------------------------- + +check() { + local universe corpus num path out + universe="$(mktemp)" + corpus="$(mktemp)" + record_universe > "$universe" + + while IFS=$'\t' read -r num path; do + [ -n "${path:-}" ] || continue + scan_fields < "$path" \ + | awk -F'\t' -v p="$path" -v n="$num" \ + '{ printf "%s\t%s\t%s\t%s\t%s\n", p, n, $1, $2, $3 }' >> "$corpus" + done < "$universe" + + local records fields paired=0 refusals=0 + records="$(awk 'END { print NR }' "$universe")" + fields="$(awk 'END { print NR }' "$corpus")" + + echo "Records read: every tracked file under docs/decisions/ whose name begins with four digits and a hyphen." + echo "Read for: a Narrows: or Narrowed-by: line at column zero, outside a fenced or indented block." + echo "Records: ${records}. Fields found: ${fields}." + echo + + echo "-- a narrowing is named in both directions" + out="$(verdicts "$universe" < "$corpus")" + while IFS=$'\t' read -r tag a b c; do + case "$tag" in + REFUSE) + echo "::error file=${a},line=${b}::${a}:${b}: ${c}" + echo " ${a}:${b}: ${c}" + refusals=$((refusals + 1)) + ;; + COUNT) + paired=$((paired + a)) + ;; + esac + done <&2; exit 2 ;; +esac diff --git a/.github/workflows/decision-records.yml b/.github/workflows/decision-records.yml new file mode 100644 index 0000000..7a6f008 --- /dev/null +++ b/.github/workflows/decision-records.yml @@ -0,0 +1,64 @@ +# A narrowing is named in both directions (#267). +# +# 0267 decides that a record narrowing one clause of an earlier one carries a +# `Narrows:` line and the record it narrows carries a `Narrowed-by:` line, each +# naming the other and each naming the clause. A pointer only one side carries +# rots the first time a record is renumbered or withdrawn, and it rots in silence, +# so the pairing is read rather than trusted. +# +# The check-run name is exactly `A narrowing is named in both directions`, on the +# job. GitHub takes that name from the job's `name:` and falls back to the job id, +# and a ruleset matches the literal, so the name a requirement would be attached +# to is visible in this file rather than derived from it. +# +# `.github/decision-records/decision-records.sh` holds the rules and the fixtures. +# Every run proves every rule against its own violating record and its own near +# miss before it judges the tree, so a rule cannot pass its fixture and refuse +# something else in the gate. +# +# The job carries no `if:` and no path filter, for the reason +# .github/workflows/build.yml already gives: GitHub creates a check run for a job +# it started and then skipped, carrying the same name a job that did the work +# would. A path filter would be wrong here for a second reason: the failure this +# check exists for is a pointer at a record that moved, and the change that moves +# a record is not the change that opened the paragraph naming it. +# +# Nothing here compiles. A pattern over tracked text needs no toolchain, so this +# check has a verdict on a tree the compiler cannot build. +name: decision-records + +on: + pull_request: + branches: ["**"] + types: [opened, synchronize, reopened] + push: + branches: [main] + +# Deny at the workflow level and grant per job, so a job added later starts with +# nothing rather than with what this one needs. +permissions: {} + +concurrency: + # Namespaced on the workflow name rather than the bare word, for the reason + # #178 recorded: a group string two workflows share means the run created + # second cancels the other, and the gate that dies that way leaves a green tick + # beside no verdict. + group: decision-records-workflow-${{ github.ref }} + cancel-in-progress: true + +jobs: + narrowing: + name: A narrowing is named in both directions + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read # check out the records the rules read + + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # Nothing here pushes, so do not leave the token in .git/config. + persist-credentials: false + + - name: Prove every rule bites, then apply the rule set + run: bash .github/decision-records/decision-records.sh check diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index af52326..277441d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -167,6 +167,18 @@ it; where none does, the run prints that the comparison was not made. span that names a path not tracked in this tree. `.github/doc-paths/doc-paths.sh` carries what it reads and, on every run, the list of what it does not. +**`A narrowing is named in both directions`** refuses a `Narrows:` or +`Narrowed-by:` field in a decision record that names a record which does not +exist, that names a record and no clause, that sits below the first heading, or +that the record it names does not name back. Both fields are 0267's, which is +where the shape and its reasons are; +`.github/decision-records/decision-records.sh` holds the rules and proves every +one of them against its own violating record and its own near miss before it +judges anything. What it cannot reach is printed on every run: whether the clause +a field names is the clause that actually moved is a judgement no reading of the +text makes, and a later record that narrows an earlier one and writes no field at +all is silent to every rule in it. + **`Analyse the shell the gate runs (shellcheck)`** analyses every tracked shell file. The rules it does not refuse are in `.github/shell-analysis/excluded-rules` with the reason for each. diff --git a/docs/decisions/0001-decision-records.md b/docs/decisions/0001-decision-records.md index e193d91..acd99ca 100644 --- a/docs/decisions/0001-decision-records.md +++ b/docs/decisions/0001-decision-records.md @@ -4,6 +4,8 @@ Date: 2026-08-09 Status: accepted. Supersedes nothing. Superseded by nothing. +Narrowed-by: 0267, on the third permitted edit, the pointer to a later record, which takes a fixed form and becomes a pair of fields where the later record narrows a clause + Issue: #2 ## The decision diff --git a/docs/decisions/0103-what-admits-a-dependency-and-what-is-refused.md b/docs/decisions/0103-what-admits-a-dependency-and-what-is-refused.md index 192b896..34d8bc8 100644 --- a/docs/decisions/0103-what-admits-a-dependency-and-what-is-refused.md +++ b/docs/decisions/0103-what-admits-a-dependency-and-what-is-refused.md @@ -4,6 +4,8 @@ Date: 2026-08-24 Status: accepted. Supersedes nothing. Superseded by nothing. +Narrowed-by: 0243, on the fourth behaviour refused outright, a dependency that writes to a log + Issue: #103 ## The decision diff --git a/docs/decisions/0243-the-means-a-certificate-is-validated-with.md b/docs/decisions/0243-the-means-a-certificate-is-validated-with.md index 3cee695..4258b71 100644 --- a/docs/decisions/0243-the-means-a-certificate-is-validated-with.md +++ b/docs/decisions/0243-the-means-a-certificate-is-validated-with.md @@ -4,6 +4,8 @@ Date: 2026-08-31 Status: accepted. Supersedes nothing. Superseded by nothing. +Narrows: 0103, on the fourth behaviour refused outright, a dependency that writes to a log + Issue: #243 ## The decision diff --git a/docs/decisions/0267-a-record-that-narrows-one-clause-of-another.md b/docs/decisions/0267-a-record-that-narrows-one-clause-of-another.md new file mode 100644 index 0000000..005db2c --- /dev/null +++ b/docs/decisions/0267-a-record-that-narrows-one-clause-of-another.md @@ -0,0 +1,166 @@ +# 0267. A record that narrows one clause of another + +Date: 2026-09-02 + +Status: accepted. Supersedes nothing. Superseded by nothing. + +Narrows: 0001, on the third permitted edit, the pointer to a later record, which becomes a pair of named fields + +Issue: #267 + +## The decision + +A record that narrows one clause of an earlier record while leaving the rest of +it standing carries `Narrows: NNNN, ` in its header, the record it +narrows carries `Narrowed-by: NNNN, ` beside its `Status:` line, and +a check refuses either field where the record it names does not exist, where no +clause is named after the number, or where the other record does not name it +back. + +## Why the pointer is a field and not a sentence + +[0001](0001-decision-records.md) permits a pointer to a later record that goes +further on a case this record already names, and says nothing about where the +pointer goes or what it looks like. The one instance in the tree took the shape +that reading allows, which is a paragraph inside the section it concerns: + + sed -n '120,124p' docs/decisions/0103-what-admits-a-dependency-and-what-is-refused.md + +A reader who reaches that paragraph learns what moved. The reader this decision is +for is the one who does not reach it - who opens +[0103](0103-what-admits-a-dependency-and-what-is-refused.md), reads a `Status:` +line saying `accepted` with nothing else beside it, and takes the whole record as +the rule in force. The narrowing is at line 120 of a record with more than two +hundred, and following a pointer is only optional when it is possible to miss one. + +The header is where every reader lands before the prose. A field there is the +smallest change that makes the pointer unmissable, and it is the reason this +decision is a field rather than a convention about where in a section to put the +paragraph. + +## What the field carries, and why the clause is not optional + +`Narrowed-by: 0243` alone leaves the reader diffing two records to find which of +[0103](0103-what-admits-a-dependency-and-what-is-refused.md)'s five grounds moved. +The complaint this record answers is that a reader takes the fourth behaviour one +clause wider than the rule in force, so the clause identity is the payload and the +field is refused without it. + +The clause is prose and no rule here reads it for sense. What is refused is its +absence, and whether the sentence names the clause that actually moved is caught +by the review. That bound is printed on every run of the check rather than only +written here. + +## Why both directions, and what the second one is for + +A pointer only one end carries rots the first time a record is renumbered or +withdrawn, and it rots in silence, which is the same defect class this record +exists to fix one level up. Two fields naming each other make the rot a red gate: +a record deleted, renumbered or written with the pointer on one side only is +refused by name. + +That is also what makes the backward field worth its cost. +[0243](0243-the-means-a-certificate-is-validated-with.md) already argues the +narrowing at length in its own prose, so `Narrows:` adds no argument to it and +tells its reader nothing they could not find. What it does is give the check +something to compare, and a rule that can only be evaluated from one side is a +rule that goes stale from the other. + +## What this asks of 0001, stated rather than assumed + +[0001](0001-decision-records.md)'s third permitted edit is a pointer to a later +record where the pointer changes no sentence's meaning and adds no argument. This +record changes that clause in two ways and both are written here so a reader can +argue with them. + +The pointer takes a fixed form where the later record narrows rather than merely +goes further. A paragraph is no longer one of the shapes it may take in that case. + +The pointer becomes a pair, so the narrowing record receives a field naming an +*earlier* record. Clause three as written permits a pointer to a later record and +says nothing about the other direction, and `Narrows:` is the other direction. +Both halves pass clause three's own test - remove either field and both records +say exactly what they said before - which is why this is a narrowing of that +clause rather than a supersession of the record carrying it. + +Where the field goes is not something [0001](0001-decision-records.md) fixes and +this record does not read it as though it did. Its header rule says which three +lines a record must carry and in what order; it does not say that a record carries +nothing else, any more than its heading rule does, which says in so many words +that a record's own headings may sit between the first two. A reader who takes the +three-line rule for an exhaustive list should argue with that reading here rather +than discover it. + +## A pointer in the tree that is already an argument + +[0103](0103-what-admits-a-dependency-and-what-is-refused.md)'s paragraph does not +only point. It restates the narrowed rule and the condition it rests on, so +removing it would take something away, and by +[0001](0001-decision-records.md)'s own test that makes it an argument rather than +a pointer. This record does not repair that: the no-editing rule is what stops a +landed paragraph being trimmed, and the paragraph is where it is. The field lands +beside it and the record keeps its text, which is the arrangement 0001 asks for +everywhere else. + +## Why this is written down before the code + +Without a shape, every later narrowing is argued from scratch by whoever writes +it, and the three answers the issue set out are all still available each time. The +tree then carries pointers in three shapes, a reader learns which shape to look +for by having met one, and the check that would catch a broken pointer cannot be +written at all because there is nothing fixed for it to read. + +The specific failure is the one already in the tree rather than an expected one. A +reader of [0103](0103-what-admits-a-dependency-and-what-is-refused.md) who stops +at its header applies a refusal one clause wider than the rule in force, and +refuses a dependency this board decided is admissible. Nothing today tells that +reader anything is missing, which is what makes it expensive: a wrong reading of a +record reads exactly like a right one. + +It is also cheaper now than later by the same arithmetic +[0001](0001-decision-records.md) uses on itself. One instance exists. Fixing the +shape after five means five records to reach, none of which may be edited except +under whatever this record decides. + +## Alternatives, and what each cost + +A supersession of the whole record. `superseded by NNNN` on +[0103](0103-what-admits-a-dependency-and-what-is-refused.md) and a fresh record +restating it. It costs five things nobody argued with - the licence set, the worth +test, the other four grounds and the clause for a standing requirement - discarded +to move one, and it gets worse with use: every later narrowing restates a record +in full, so the change that matters is buried in a diff that does not, and the +reader who wants to know what moved reconstructs it. + +A partial supersession. `superseded in part by NNNN` in the `Status:` line, which +is closer and fails on the point this is about. It tells a reader that something +was narrowed without telling them what, so they read both records end to end to +find the clause. It also makes `Status:` false in the direction that costs most: a +reader who concludes the record is spent stops applying four grounds that still +hold. + +Neither, with the pointer left as prose. The cheapest, and it is the state this +record ends. It leaves a reader who stops at the header applying a rule that was +narrowed, leaves the shape to be re-argued at every instance, and leaves nothing +for a check to read. + +A field with the record number and no clause. One token shorter and mechanically +identical to check for existence. It costs the reader the thing they came for, and +it is the option that looks like this decision from a distance, which is why the +clause is refused by name rather than recommended in prose. + +## What would reverse this + +A narrowing that cannot be named in one clause, twice. One awkward fit is a field +written badly. Two is a narrowing that is really a supersession wearing a field, +and the answer is the partial supersession this record declined rather than a +longer clause. + +A record is narrowed by two later records on the same clause and the two disagree. +This record fixes a pointer and decides nothing about precedence, and the first +time that matters the format needs a rule this one does not carry. + +The check is superseded by a decision-record reader that judges the whole header, +under #110's successor or otherwise, and the shape that reader can actually read +differs from the shape written here. The check wins, for the reason +[0001](0001-decision-records.md) already gives about a shape nothing refuses. diff --git a/docs/decisions/README.md b/docs/decisions/README.md index de5972a..181243d 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -63,5 +63,6 @@ allocated, and why a record is superseded rather than edited are in - [0115. Creating the core, stopping it, and a host that suspends it](0115-creating-and-stopping-the-core.md) - [0116. Learning that something cached has changed](0116-learning-that-something-cached-has-changed.md) - [0243. The means a certificate is validated with, and what it costs](0243-the-means-a-certificate-is-validated-with.md) +- [0267. A record that narrows one clause of another](0267-a-record-that-narrows-one-clause-of-another.md) - [0268. A conjunctive licence expression, and the term the set does not name](0268-a-conjunctive-licence-expression.md) - [0272. The route next up is read from](0272-the-route-next-up-is-read-from.md)