Files
mesh-catalog/modules/mesh-knowledge/cmd/mesh-knowledge/rules.go
T
jschoubben ec00e13e22
mesh/merge-gate pass: builds new: modules/mesh-knowledge, sent nowhere; no bus step; every machine composes with the change as it did without (4 of 4 compo…
mesh/repo-check pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-check-typescript.sh (typescript)
mesh/delivery delivered
Hold a decision record to the words rules only from 2026-10-11 (review of #206)
hq's words check never read a decision record, so a correction to any decision
of October 7 to 10 was refused for words it was allowed to write. The links an
accepted record holds are kept by its correction without being judged again.
2026-10-11 05:14:37 +02:00

578 lines
22 KiB
Go

package main
// The rules a record is held to (novox/hq ADR 0290 rule 4, to-be 55 §4 and §5).
//
// **29 rules judge one record or its prose**, and `propose` refuses a record that breaks any of them, naming every
// one it breaks at once. They are hq's checks (00-META/checks/records.py, cycle.py, index.py, words.py) as they
// judge one record, moved to the write: 25 on its structure, 4 on its prose. Each has an id, said in the refusal,
// and one case in the table test (TestEveryPerRecordRuleRefusesAProposal).
//
// **4 rules judge the whole record set** (wholeSetRules) and run after every acceptance, and in the check
// process every few minutes: a failure is kept as a finding, which the module's health answers, so the mesh
// raises a condition naming the rule and the records. It does not undo the acceptance.
import (
"fmt"
"regexp"
"strings"
)
// Rule is one rule that judges one record: its id and what it holds.
type Rule struct {
ID string
Holds string
// From is the hq check it comes from.
From string
}
// PerRecordRules are the 29, in the order they are judged.
var PerRecordRules = []Rule{
{"kind", "a record is a decision or a research effort", "cycle.py"},
{"reserved", "its number was reserved for its kind and holds no other record", "cycle.py (duplicate numbers)"},
{"title", "it has a title, one line of at most 200 characters", "records.py numbering"},
{"topic", "a decision's topic is one of the reading order's", "records.py topics, index.py"},
{"date", "its date is a day, YYYY-MM-DD", "records.py insights, cycle.py deferrals"},
{"deciders", "a decision names who decided it", "records.py (deciders on every record)"},
{"research-status", "a research effort is active, graduated or abandoned", "cycle.py research"},
{"has-clause", "a decision has at least one clause", "records.py clauses"},
{"clause-numbering", "its clauses are numbered from 1, in order, each once", "records.py clauses"},
{"clause-lead", "every clause opens with a bold lead", "records.py clauses"},
{"clause-size", "every clause says something, in at most 2000 bytes", "records.py clauses"},
{"context", "a decision has a Context section", "the decision record's form"},
{"consequences", "a decision has a Consequences section", "the decision record's form"},
{"checked", "a decision says how it is checked", "AGENTS.md: a rule states how it is checked"},
{"overview", "a research effort has its overview", "cycle.py research"},
{"link-kind", "every link is of a known kind and goes out from the record or one of its clauses", "records.py links"},
{"link-target", "every link names a record, and a clause, that exist", "records.py links, rests-on"},
{"link-fits", "a link's kind fits its ends", "cycle.py research became, records.py supersession"},
{"self-link", "no link points at the record itself", "records.py supersession"},
{"rests-on", "a record it extends is accepted or proposed, never superseded or withdrawn", "records.py rests-on"},
{"supersedes-accepted", "a record it supersedes or changes is accepted", "records.py supersession, partial-supersession"},
{"changes-in-force", "a clause it changes is in force or changed, never superseded, proposed or drafted", "records.py partial-supersession"},
{"live-citation", "a superseded record it cites is cited beside the record that replaced it", "records.py live-citation"},
{"became", "a graduated research effort names what it became", "cycle.py research"},
{"text-links", "every link its text writes to a decision record or research effort names one the knowledge base holds", "records.py links"},
{"retired-word", "its prose uses no word the glossary retired", "words.py retired"},
{"daemon-line", "it writes no daemon's line", "words.py lines"},
{"deferral", "a paragraph that puts work off names the issue that tracks it", "cycle.py deferrals"},
{"insight-form", "a progressive insight its text mentions is in the dated, marked form", "records.py insights"},
}
// Refusal is one rule a record breaks, and how.
type Refusal struct {
Rule string `json:"rule"`
Says string `json:"says"`
}
func (r Refusal) String() string { return r.Rule + ": " + r.Says }
// Lookup is the served version of a record the knowledge base holds, or nil.
type Lookup func(Key) *Record
// MostClauseBytes is the most one clause may hold.
const MostClauseBytes = 2000
var (
isDay = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`)
deferral = regexp.MustCompile(`(?i)until (?:it is |they are |the code (?:is )?)?renamed|until the code rename|` +
`\ba later rename|not renamed here|identifier to rename|left (?:for|to) (?:a )?later|not decided here|` +
`\bpostponed\b|future work|\bdeferred\b|later work|not built here|\ba later change`)
issueRef = regexp.MustCompile(`(?i)tracked (?:by|in|as)\s+\[?issue (\d{3,5})|\[?issue (\d{3,5})\]?(?:\([^)]*\))?\s+tracks\b`)
insightPhrase = regexp.MustCompile(`(?i)progressive insights?`)
insightMarker = regexp.MustCompile(`\*\*Progressive insights?[ \t]*[—–-][ \t]*(\d{4}-\d{2}-\d{2})[^*\n]*\*\*`)
)
// decisionSection is the text of a record's Decision section.
func decisionSection(r *Record) (string, bool) {
for _, s := range r.Sections {
if s.Name == "Decision" || strings.HasPrefix(s.Name, "Decision ") || strings.HasPrefix(s.Name, "Decision:") {
return s.Text, true
}
}
return "", false
}
func hasSection(r *Record, names ...string) bool {
for _, s := range r.Sections {
for _, n := range names {
if strings.EqualFold(strings.TrimSpace(s.Name), n) && strings.TrimSpace(s.Text) != "" {
return true
}
}
}
return false
}
// allText is every word of a record an agent reads: its title, clauses and sections.
func allText(r *Record) string {
var b strings.Builder
b.WriteString(r.Title + "\n\n")
for _, c := range r.Clauses {
b.WriteString(c.Text + "\n\n")
}
for _, s := range r.Sections {
b.WriteString(s.Text + "\n\n")
}
return b.String()
}
// JudgeRecord holds a proposed record to the 29 rules. held is the entry its number holds (a reservation, the
// proposal being revised, or the accepted record an insight corrects); lookup reads every other record.
func JudgeRecord(r *Record, held *Entry, lookup Lookup, ref *Reference) []Refusal {
var out []Refusal
refuse := func(rule, format string, a ...any) {
if Holds(rule, r.Kind, r.Date) {
out = append(out, Refusal{Rule: rule, Says: fmt.Sprintf(format, a...)})
}
}
decision := r.Kind == KindDecision
if r.Kind != KindDecision && r.Kind != KindResearch {
refuse("kind", "%q is neither decision nor research", r.Kind)
}
switch {
case held == nil:
refuse("reserved", "%s was never reserved: reserve gives the number", r.Key())
case held.Current.Status == StatusWithdrawn:
refuse("reserved", "%s was reserved and withdrawn: reserve a new number", r.Key())
}
title := strings.TrimSpace(r.Title)
if title == "" || strings.Contains(title, "\n") || len([]rune(title)) > 200 {
refuse("title", "the title is empty, more than one line, or longer than 200 characters")
}
if decision && !contains(ref.Topics, r.Topic) {
refuse("topic", "%q is not a topic of the reading order: %s", r.Topic, strings.Join(ref.Topics, ", "))
}
if !isDay.MatchString(r.Date) {
refuse("date", "%q is not a day written YYYY-MM-DD", r.Date)
}
if decision && len(r.Deciders) == 0 {
refuse("deciders", "a decision names who decided it")
}
if !decision && !contains(ResearchStatuses, r.Effort) {
refuse("research-status", "%q is not active, graduated or abandoned", r.Effort)
}
if decision && len(r.Clauses) == 0 {
refuse("has-clause", "a decision is made of clauses, and this one has none")
}
for i, c := range r.Clauses {
if c.N != i+1 {
refuse("clause-numbering", "clause %d stands %d. in order: clauses are numbered 1, 2, 3 … each once", c.N, i+1)
}
if c.Lead() == "" {
refuse("clause-lead", "clause %d does not open with a bold lead (**What is decided.** …)", c.N)
}
if strings.TrimSpace(c.Text) == "" || len(c.Text) > MostClauseBytes {
refuse("clause-size", "clause %d is empty or longer than %d bytes", c.N, MostClauseBytes)
}
}
if decision && !hasSection(r, "Context") {
refuse("context", "a decision has a Context section: why it was decided")
}
if decision && !hasSection(r, "Consequences") {
refuse("consequences", "a decision has a Consequences section: what follows from it, and what got harder")
}
if decision && !hasSection(r, "How it is checked") {
refuse("checked", "a decision has a section \"How it is checked\": a rule says how it is verified")
}
if !decision && !hasSection(r, "00-overview.md", "overview") {
refuse("overview", "a research effort has its overview, a section named 00-overview.md")
}
self := r.Key()
superseders := map[Key]bool{}
for _, l := range r.Links {
if to, ok := l.To.Record(); ok && l.Kind == LinkSupersedes {
superseders[to] = true
}
}
cited := map[Key]bool{}
for _, l := range r.Links {
if to, ok := l.To.Record(); ok {
cited[to] = true
}
}
for _, l := range r.Links {
if !contains(LinkKinds, l.Kind) {
refuse("link-kind", "%q is not a kind of link: %s", l.Kind, strings.Join(LinkKinds, ", "))
continue
}
if l.From > 0 && r.Clause(l.From) == nil {
refuse("link-kind", "the link %s goes out from clause %d, which the record does not have", l, l.From)
}
to, isRecord := l.To.Record()
if isRecord && to == self {
refuse("self-link", "the link %s points at the record itself", l)
continue
}
var target *Record
if isRecord {
target = lookup(to)
if target == nil || target.Status == StatusReserved || target.Status == StatusWithdrawn {
refuse("link-target", "the link %s names %s, which the knowledge base does not hold", l, to)
continue
}
if l.To.Clause > 0 && target.Clause(l.To.Clause) == nil {
refuse("link-target", "the link %s names clause %d, which %s does not have", l, l.To.Clause, to)
continue
}
}
kept := held != nil && held.Current.Accepted() && hasLink(held.Current, l)
switch {
case kept:
// An insight keeps what the accepted record linked, as hq wrote it: a change hq named by record waits
// for its clause to be judged.
case l.Kind == LinkBecame:
if r.Kind != KindResearch || !(l.To.Kind == KindDecision || l.To.Kind == TargetDesign) || l.To.Clause > 0 {
refuse("link-fits", "became goes from a research effort to a decision record or a design: not %s", l)
}
case l.Kind == LinkChanges:
if !decision || l.To.Kind != KindDecision || l.To.Clause == 0 {
refuse("link-fits", "changes goes from a decision (or a clause of it) to a clause of another decision, named as 0276 §4: not %s", l)
}
case l.Kind == LinkSupersedes || l.Kind == LinkExtends:
if r.Kind != l.To.Kind || !isRecord || l.To.Clause > 0 {
refuse("link-fits", "%s goes from a record to a record of its kind: not %s", l.Kind, l)
}
}
if target == nil {
continue
}
if held != nil && held.Current.Accepted() && hasLink(held.Current, l) {
// An insight keeps what the accepted record linked: judged when it was accepted.
continue
}
switch l.Kind {
case LinkExtends:
if target.Status == StatusSuperseded && !superseders[to] {
refuse("rests-on", "%s extends %s, which is superseded", self, to)
}
case LinkSupersedes:
if !target.Accepted() || target.Status == StatusSuperseded {
refuse("supersedes-accepted", "%s supersedes %s, which is %s, not accepted", self, to, target.Status)
}
case LinkChanges:
if !target.Accepted() || target.Status == StatusSuperseded {
refuse("supersedes-accepted", "%s changes %s, which is %s, not accepted", self, to, target.Status)
} else if c := target.Clause(l.To.Clause); c != nil {
if (c.Status != ClauseInForce && c.Status != ClauseChanged) || c.Source == SourceDrafted {
refuse("changes-in-force", "%s changes %s §%d, which is %s (%s)", self, to, l.To.Clause, c.Status, c.Source)
}
}
case LinkCites:
if target.Status == StatusSuperseded {
named := false
for _, by := range supersedersOf(target) {
named = named || cited[by]
}
if !named {
refuse("live-citation", "%s cites %s, which is superseded, and not the record that replaced it", self, to)
}
}
}
}
if r.Kind == KindResearch && r.Effort == StatusGraduated {
became := false
for _, l := range r.Links {
became = became || l.Kind == LinkBecame
}
if !became {
refuse("became", "a graduated research effort names what it became, as a became link")
}
}
for _, s := range r.Sections {
for _, m := range mdLink.FindAllStringSubmatch(s.Text, -1) {
target := m[1]
var k Key
switch {
case researchFolder.MatchString(target):
n := atoi(researchFolder.FindStringSubmatch(target)[1])
k = Key{KindResearch, n}
case adrFile.MatchString(target) && !strings.Contains(target, "04-ISSUES") && !strings.Contains(target, "03-DESIGN"):
k = Key{KindDecision, atoi(adrFile.FindStringSubmatch(target)[1])}
default:
continue
}
if k == self {
continue
}
if t := lookup(k); t == nil || t.Status == StatusReserved || t.Status == StatusWithdrawn {
refuse("text-links", "the section %s links %s, which the knowledge base does not hold", s.Name, k)
}
}
}
text := allText(r)
if words := ref.RetiredIn(text); len(words) > 0 {
refuse("retired-word", "it uses %s, retired by the glossary", quoteAll(unique(words)))
}
if lines := ref.LinesIn(text); len(lines) > 0 {
refuse("daemon-line", "it writes %s, a daemon's line: the operator types nox", quoteAll(unique(lines)))
}
for _, s := range r.Sections {
if strings.Contains(strings.ToLower(s.Name), "option") || strings.Contains(strings.ToLower(s.Name), "rejected") {
continue
}
for _, p := range paragraphsOf(s.Text) {
if m := deferral.FindString(p); m != "" && !issueRef.MatchString(p) {
refuse("deferral", "the section %s puts work off (%q) and names no issue that tracks it (\"tracked by issue N\")", s.Name, m)
}
}
}
for _, c := range r.Clauses {
if m := deferral.FindString(c.Text); m != "" && !issueRef.MatchString(c.Text) {
refuse("deferral", "clause %d puts work off (%q) and names no issue that tracks it (\"tracked by issue N\")", c.N, m)
}
}
marked := insightMarker.FindAllStringSubmatchIndex(text, -1)
for _, loc := range insightPhrase.FindAllStringIndex(text, -1) {
inside := false
for _, mk := range marked {
inside = inside || (mk[0] <= loc[0] && loc[1] <= mk[1])
}
line := lineAround(text, loc[0])
if inside || strings.Contains(line, "](") || strings.HasPrefix(strings.TrimSpace(line), "#") {
continue
}
refuse("insight-form", "it mentions a progressive insight outside the dated marked form **Progressive insight — YYYY-MM-DD.**")
break
}
for _, mk := range insightMarker.FindAllStringSubmatch(text, -1) {
if isDay.MatchString(r.Date) && mk[1] < r.Date {
refuse("insight-form", "a progressive insight dated %s predates the record (%s)", mk[1], r.Date)
}
}
return out
}
func hasLink(r *Record, l Link) bool {
for _, x := range r.Links {
if x.Kind == l.Kind && x.From == l.From && x.To == l.To {
return true
}
}
return false
}
func lineAround(text string, at int) string {
start := strings.LastIndex(text[:at], "\n") + 1
end := strings.Index(text[at:], "\n")
if end < 0 {
return text[start:]
}
return text[start : at+end]
}
// paragraphsOf cuts a text at blank lines and a list's items.
func paragraphsOf(text string) []string {
var out []string
for _, chunk := range strings.Split(text, "\n\n") {
for _, item := range regexp.MustCompile(`\n(?:[-*] |\d+\. )`).Split(chunk, -1) {
if strings.TrimSpace(item) != "" {
out = append(out, item)
}
}
}
return out
}
// supersedersOf are the records a record says superseded it: its superseded-by, a file name as hq wrote it or
// "ADR NNNN" as an acceptance here writes it.
func supersedersOf(r *Record) []Key {
var out []Key
for _, by := range strings.Split(r.Extra["superseded-by"], ",") {
by = strings.TrimSpace(by)
if m := adrFile.FindStringSubmatch(by); m != nil {
out = append(out, Key{KindDecision, atoi(m[1])})
} else if ref, err := ParseRef(by); err == nil {
if k, ok := ref.Record(); ok {
out = append(out, k)
}
}
}
return out
}
func atoi(s string) int {
n := 0
for _, c := range s {
n = n*10 + int(c-'0')
}
return n
}
func contains(list []string, s string) bool {
for _, x := range list {
if x == s {
return true
}
}
return false
}
func unique(list []string) []string {
seen := map[string]bool{}
var out []string
for _, s := range list {
if !seen[strings.ToLower(s)] {
seen[strings.ToLower(s)] = true
out = append(out, s)
}
}
return out
}
func quoteAll(list []string) string {
q := make([]string, len(list))
for i, s := range list {
q[i] = fmt.Sprintf("%q", s)
}
return strings.Join(q, ", ")
}
// ---- the whole record set ----------------------------------------------------------------------------------
// WholeSetRules are the 4 rules that judge the whole record set.
var WholeSetRules = []Rule{
{"cited", "a decision accepted here is cited by another record or names a home in hq", "cycle.py decisions"},
{"supersession", "a superseded record is named by the record that superseded it, and every record an accepted record supersedes is superseded", "records.py supersession"},
{"partial", "a changed clause is changed by an accepted record's changes link, and every clause an accepted record changes is changed or superseded", "records.py partial-supersession"},
{"rests-on-live", "no accepted record extends a superseded one, but the record that superseded it", "records.py rests-on"},
}
// JudgeSet holds the served versions of every record to the 4 whole-set rules. An imported record was held to
// hq's own checks at the commit it was read at, so the first rule judges only a record accepted here.
func JudgeSet(records []*Record) []Finding {
byKey := map[Key]*Record{}
for _, r := range records {
byKey[r.Key()] = r
}
inbound := map[Key][]struct {
from *Record
l Link
}{}
for _, r := range records {
if !r.Accepted() && r.Status != StatusSuperseded {
continue
}
for _, l := range r.Links {
if to, ok := l.To.Record(); ok {
inbound[to] = append(inbound[to], struct {
from *Record
l Link
}{r, l})
}
}
}
var out []Finding
add := func(rule, says string, keys ...Key) {
names := make([]string, len(keys))
for i, k := range keys {
names[i] = k.String()
}
out = append(out, Finding{Rule: rule, Records: names, Says: says})
}
for _, r := range records {
k := r.Key()
if r.Kind == KindDecision && r.Status == StatusAccepted && !r.AcceptedAt.IsZero() && len(r.Homes) == 0 {
cited := false
for _, in := range inbound[k] {
cited = cited || in.from.Key() != k
}
if !cited {
add("cited", fmt.Sprintf("%s is accepted and nothing cites it: no record links it and it names no home", k), k)
}
}
if r.Status == StatusSuperseded {
by := false
for _, in := range inbound[k] {
// The record that superseded it may since have been superseded itself, as hq's 0125 by 0127 by 0131.
by = by || in.l.Kind == LinkSupersedes
}
if !by {
add("supersession", fmt.Sprintf("%s is superseded and no record says it supersedes it", k), k)
}
}
if r.Accepted() {
for _, l := range r.Links {
to, ok := l.To.Record()
target := byKey[to]
if !ok || target == nil {
continue
}
switch l.Kind {
case LinkSupersedes:
if target.Status != StatusSuperseded {
add("supersession", fmt.Sprintf("%s supersedes %s, which is %s", k, to, target.Status), k, to)
}
case LinkChanges:
if l.To.Clause == 0 {
continue
}
if c := target.Clause(l.To.Clause); c != nil && c.Status != ClauseChanged && c.Status != ClauseSuperseded {
add("partial", fmt.Sprintf("%s changes %s §%d, which is %s", k, to, l.To.Clause, c.Status), k, to)
}
case LinkExtends:
if target.Status == StatusSuperseded {
superseder := false
for _, in := range inbound[to] {
superseder = superseder || (in.l.Kind == LinkSupersedes && in.from.Key() == k)
}
if !superseder {
add("rests-on-live", fmt.Sprintf("%s extends %s, which is superseded", k, to), k, to)
}
}
}
}
}
for _, c := range r.Clauses {
if c.Status != ClauseChanged {
continue
}
by := false
for _, in := range inbound[k] {
by = by || (in.l.Kind == LinkChanges && in.l.To.Clause == c.N && in.from.Accepted())
}
if !by {
add("partial", fmt.Sprintf("%s §%d is changed and no accepted record changes it", k, c.N), k)
}
}
}
return out
}
// RuleFrom is the day each dated rule began to hold a record, as hq's checks hold it: a record dated earlier was
// never held to it, and a dated correction of it (a progressive insight, which keeps the record's date) is not.
// words.py held the retired words from 2026-10-07 and the daemons' lines from 2026-10-09; cycle.py the deferrals
// from 2026-10-09; records.py the clauses from 2026-10-11 (ADR 0290 rule 3). The decision record's sections, and
// a superseded record cited alone, were held by no check of hq's (words.py and live-citation never read decision
// records): they hold from the day the knowledge base takes the write, 2026-10-11. Every other rule holds every
// record, as hq's checks held it.
var RuleFrom = map[string]string{
"retired-word": "2026-10-07",
"daemon-line": "2026-10-09",
"deferral": "2026-10-09",
"has-clause": "2026-10-11",
"clause-numbering": "2026-10-11",
"clause-lead": "2026-10-11",
"clause-size": "2026-10-11",
"context": "2026-10-11",
"consequences": "2026-10-11",
"checked": "2026-10-11",
"live-citation": "2026-10-11",
}
// DecisionFrom overrides RuleFrom for a decision record: hq's words check never read 02-DECISIONS ("a decision
// record keeps the words it was written with"), so its retired words and daemons' lines hold a decision only from
// the day the knowledge base takes the write.
var DecisionFrom = map[string]string{
"retired-word": "2026-10-11",
"daemon-line": "2026-10-11",
}
// Holds says whether a rule holds a record of this kind and date. A record with no day is held to every rule.
func Holds(rule, kind, date string) bool {
from, dated := RuleFrom[rule]
if d, ok := DecisionFrom[rule]; ok && kind == KindDecision {
from = d
}
return !dated || !isDay.MatchString(date) || date >= from
}