The operator would not read the Telegram ask for ADR 0343: thirty plain lines, every clause on one, no bold, no summary. A settings proposal was the same wall of text, its values masked so the approval was blind. - messenger: an ask's message is the asker's headline and explanation alone; who asked and when, what each answer does, the expiry, where else it is answered and the asker's Details travel as the message's note (asks.Message.Note). Answered, the message is one line, "You chose Approve at 16:06 on Telegram · <headline>". An explanation that does not read at a glance (asks.Glance) is shown and logged. On a kind that proves who answers, an ask's values are shown whole (asks.Ask.Whole), held to the secret rule alone (from the earlier work on issue 383, #178). - telegram: every message in HTML, never reworded: the title bold, a key line monospace, the note apart in a collapsed blockquote; markup escaped. A tap is compared by its words, whitespace aside, and a message shown before is still compared by the digest it was kept under. - desk-channel: the note follows the body. - mesh-knowledge: a record that needs the operator carries a brief (headline, summary, at most three points), the 30th per-record rule; its ask shows the brief and never the clauses. An ask made before is composed again as it was asked (form), so its warrant still holds. - claude-code: an instructions proposal's explanation is one summary line (the proposer's, or the module's), its text in the Details; a question's context is at most 300 characters and its recommendation one point. Older proposals and questions keep their form. The SDK is pinned at mesh-sdk 9588871 (fix/639-an-ask-reads-at-a-glance), which adds Message.Note and asks.Glance.
660 lines
26 KiB
Go
660 lines
26 KiB
Go
package main
|
|
|
|
// The rules a record is held to (novox/hq ADR 0290 rule 4, to-be 55 §4 and §5).
|
|
//
|
|
// **30 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; and the brief a record that needs
|
|
// the operator is asked with (novox/hq issue 639). 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"
|
|
|
|
"git.novox.be/novox/mesh-sdk/go/asks"
|
|
)
|
|
|
|
// 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 30, 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 or proposed; a drafted one may be changed", "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"},
|
|
{"brief", "a record that needs the operator carries a brief its ask reads at a glance: a headline, a summary, at most three points, each fit for the phone", "novox/hq issue 639"},
|
|
}
|
|
|
|
// 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 30 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)
|
|
}
|
|
case l.Kind == LinkRestsOn:
|
|
if !decision || l.To.Kind != KindDecision || l.To.Clause > 0 {
|
|
refuse("link-fits", "rests-on goes from a decision to a decision it stands on: not %s", l)
|
|
}
|
|
case l.Kind == LinkTouches:
|
|
if r.Kind != KindResearch || l.To.Clause > 0 {
|
|
refuse("link-fits", "touches goes from a research effort to a record, an issue or a design: not %s", 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, LinkRestsOn:
|
|
if target.Status == StatusSuperseded && !superseders[to] {
|
|
refuse("rests-on", "%s %s %s, which is superseded", self, l.Kind, 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 {
|
|
// A drafted clause may be changed (novox/hq issue 494, ADR 0321): the change is recorded against the clause the
|
|
// knowledge base holds, by its number, which nothing renumbers once another record links it (Return).
|
|
if c.Status != ClauseInForce && c.Status != ClauseChanged {
|
|
refuse("changes-in-force", "%s changes %s §%d, which is %s", self, to, l.To.Clause, c.Status)
|
|
}
|
|
}
|
|
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)
|
|
}
|
|
}
|
|
if r.NeedsOperator || r.Brief != nil {
|
|
for _, why := range briefProblems(r.Brief) {
|
|
refuse("brief", "%s", why)
|
|
}
|
|
}
|
|
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, LinkRestsOn:
|
|
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 %s %s, which is superseded", k, l.Kind, 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
|
|
}
|
|
|
|
// UncitedOnAcceptance is the whole-set rule `cited` judged ahead, on a decision's working version as it would
|
|
// stand once accepted, over the records as JudgeSet reads them: "" when it names a home or a record accepted (or
|
|
// superseded) links it, else what the rule would say. hq held this rule at the merge of the record's own pull
|
|
// request, which carried the design citing it; the knowledge base holds it in the review, before the judgement, so
|
|
// an acceptance never leaves a decision cited by nothing (novox/hq issue 504). It is not refused at propose: no
|
|
// record can link a number only reserved, so a record that will cite a new one is proposed after it, accepted, and
|
|
// the new one proposed again. A citing record changed by a correction after the acceptance is what the rule, run
|
|
// after every acceptance, still says. An imported record was held to hq's checks, and its correction is not judged
|
|
// here, as JudgeSet does not judge it.
|
|
func UncitedOnAcceptance(r *Record, all []*Entry) string {
|
|
if r.Kind != KindDecision || len(r.Homes) > 0 || (r.Pending != nil && r.AcceptedAt.IsZero()) {
|
|
return ""
|
|
}
|
|
k := r.Key()
|
|
for _, e := range all {
|
|
from := e.Current
|
|
if from.Key() == k || !(from.Accepted() || from.Status == StatusSuperseded) {
|
|
continue
|
|
}
|
|
for _, l := range from.Links {
|
|
if to, ok := l.To.Record(); ok && to == k {
|
|
return ""
|
|
}
|
|
}
|
|
}
|
|
return fmt.Sprintf("%s would be accepted with nothing citing it: it names no home and no accepted record links it. Name "+
|
|
"its home (homes: the hq design or playbook that cites it), or have the record that links it accepted first; then "+
|
|
"propose this one again", k)
|
|
}
|
|
|
|
// 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",
|
|
"brief": "2026-10-13",
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// briefProblems are the ways a brief would not read at a glance on the operator's phone (novox/hq issue 639):
|
|
// missing; a headline empty or longer than an ask's; a summary or a point the router could refuse, or that
|
|
// names a path, an address or a long word (shown); more than three points, or one too long (asks.Glance).
|
|
func briefProblems(b *Brief) []string {
|
|
if b == nil {
|
|
return []string{"a record that needs the operator carries a brief (headline, summary, points): what it changes for the operator, " +
|
|
"in their words, which its ask shows in place of its clauses"}
|
|
}
|
|
var out []string
|
|
h := strings.TrimSpace(b.Headline)
|
|
if h == "" || strings.Contains(h, "\n") || len([]rune(h)) > asks.HeadlineLength {
|
|
out = append(out, fmt.Sprintf("the brief's headline is empty, more than one line, or longer than %d characters", asks.HeadlineLength))
|
|
}
|
|
if s := strings.TrimSpace(b.Summary); s == "" || strings.Contains(s, "\n") {
|
|
out = append(out, "the brief's summary is empty or more than one line")
|
|
}
|
|
for i, p := range b.Points {
|
|
if p = strings.TrimSpace(p); p == "" || strings.Contains(p, "\n") {
|
|
out = append(out, fmt.Sprintf("the brief's point %d is empty or more than one line", i+1))
|
|
}
|
|
}
|
|
if err := asks.Glance(b.Explanation()); err != nil {
|
|
out = append(out, "the brief "+strings.TrimPrefix(err.Error(), "the explanation "))
|
|
}
|
|
for _, line := range append([]string{h}, strings.Split(b.Explanation(), "\n")...) {
|
|
if !shown(line) {
|
|
out = append(out, fmt.Sprintf("the brief's line %q names a path, an address, a dotted name or a long word, which the operator's phone is not shown", line))
|
|
}
|
|
}
|
|
return out
|
|
}
|