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)