From bf8226b380f86d89bf4f966c0e678df84311611f Mon Sep 17 00:00:00 2001 From: Nils Lehnen <30603423+iderex@users.noreply.github.com> Date: Sat, 5 Sep 2026 20:32:21 +0200 Subject: [PATCH] Tell a decided question from an open one on the legal notice [#236] The legal notice rendered every entry of data/publisher.json in one of two states, and all three entries were undecided, so the produced page said three times that the question of who publishes this site is not decided yet. Entry 8 of #7 was answered on 2026-08-24 and decisions/0017 carries that answer, so the page had been saying something false for twelve days. I measured that at a2ed5ea before changing anything: jq -r 'to_entries[] | "\(.key) \(.value.state) \(.value.waiting)"' data/publisher.json publisher undecided entry 8 of issue 7 contact undecided entry 8 of issue 7 postal undecided entry 8 of issue 7 go run . build | grep legal wrote dist/legal/index.html (3562 bytes, 0 of 3 answered) The loader admitted exactly two states, and neither fits an answer that is decided and whose value is booked outside this repository and has not arrived: an answered entry with no value is refused, and an undecided one renders as not decided. This adds the third state. A decided entry names the record that carries the decision and what the value waits on, carries no value, and renders as a sentence saying exactly that. The loader refuses a decided entry carrying a value, naming no record, naming a record outside decisions/, naming a record that is not in the tree, or naming nothing the value waits on, and it refuses an answered or an undecided entry that names a record. I deleted each of those refusals in turn and the suite went red for each one. The three entries in data/publisher.json are decided and point at 0017. The fixture tree carries one entry in each of the three states, so the golden page shows all three sentences rather than one repeated, and the build log counts the decided entries beside the answered ones. What it prevents is the page calling a decided question open, which is the one sentence on it that a reader who followed its pointer to #7 could already see was false. Signed-off-by: Nils Lehnen <30603423+iderex@users.noreply.github.com> --- content/legal.txt | 5 +- data/publisher.json | 15 +- internal/site/install.go | 2 +- internal/site/legal.go | 96 ++++++++++--- internal/site/legal_test.go | 128 ++++++++++++++++-- .../testdata/golden/legal/index.html.golden | 11 +- .../site/testdata/tree/data/publisher.json | 9 +- .../tree/decisions/0001-a-fixture-decision.md | 4 + templates/page.html.tmpl | 7 + 9 files changed, 232 insertions(+), 45 deletions(-) create mode 100644 internal/site/testdata/tree/decisions/0001-a-fixture-decision.md diff --git a/content/legal.txt b/content/legal.txt index 7df4b45..75244a7 100644 --- a/content/legal.txt +++ b/content/legal.txt @@ -11,7 +11,10 @@ for a page with this name. Every answer below is read out of the tree rather than written into the page, so an answer that changes is a change to one value and not an edit to prose. Where an answer has not been taken, the line says so and says what it is waiting on, -rather than leaving an empty space that reads as an answer of nothing. +rather than leaving an empty space that reads as an answer of nothing. Where an +answer has been taken and the value it needs has not arrived yet, the line names +the record that carries the decision and what the value is waiting on, because +a decided question and an open one are different things to tell a reader. Publishing a site under a name of one's own carries a provider identification duty in some jurisdictions. Which one applies turns on where the publisher sits diff --git a/data/publisher.json b/data/publisher.json index 8b115f6..4534da3 100644 --- a/data/publisher.json +++ b/data/publisher.json @@ -1,17 +1,20 @@ { "publisher": { "asks": "Who publishes this site", - "state": "undecided", - "waiting": "entry 8 of issue 7" + "state": "decided", + "record": "decisions/0017-what-the-site-says-about-who-publishes-it.md", + "waiting": "the imprint-address service being booked, which happens outside this repository" }, "contact": { "asks": "How to reach whoever publishes it", - "state": "undecided", - "waiting": "entry 8 of issue 7" + "state": "decided", + "record": "decisions/0017-what-the-site-says-about-who-publishes-it.md", + "waiting": "the rotatable contact alias being set up, which happens outside this repository alongside the service booking" }, "postal": { "asks": "Whether a postal address is published", - "state": "undecided", - "waiting": "entry 8 of issue 7" + "state": "decided", + "record": "decisions/0017-what-the-site-says-about-who-publishes-it.md", + "waiting": "the imprint-address service being booked, whose address is the one that will be published" } } diff --git a/internal/site/install.go b/internal/site/install.go index 3f66568..8cdfd4a 100644 --- a/internal/site/install.go +++ b/internal/site/install.go @@ -48,7 +48,7 @@ const ( ) // catalogue is what the tree says about the address an operator pastes into a -// server. State is one of the two the legal notice declares, Address is the +// server. State is one of the first two the legal notice declares, Address is the // value an answered one carries, and Waiting is what an undecided one is // waiting on. Exactly one of the last two carries anything. type catalogue struct { diff --git a/internal/site/legal.go b/internal/site/legal.go index 7a232b8..2c69eef 100644 --- a/internal/site/legal.go +++ b/internal/site/legal.go @@ -21,11 +21,21 @@ // That is the whole reason for the closed set below. A value nobody has decided // and a value somebody forgot are the same empty string, and they render as the // same empty element on a page that looks finished. Here they cannot be the same -// thing: an entry says which of the two states it is in, an answered one with +// thing: an entry says which of the three states it is in, an answered one with // nothing in it is refused, and an undecided one renders as a sentence naming // what it waits on. A reader of the page can then tell a question that is open // from an answer that went missing, which is the distinction the page exists to // keep. +// +// The third state is the one between those two, and it exists because a decision +// and the value it produces can arrive weeks apart. The question of who publishes +// this site was decided on 2026-08-24 and the answer is a record in decisions/, +// and the name, the address and the contact route it decides on are booked +// outside this repository and had not arrived twelve days later. A page that +// went on saying the question was not decided was saying something false, and a +// page that showed an empty answer was the blank above. So a decided entry names +// the record that carries the decision and what the value waits on, carries no +// value, and renders as a sentence saying exactly that. package site import ( @@ -54,20 +64,31 @@ const ( // refuses a page missing the link compares against this. var LegalAddress = addressOf(LegalPath) -// The two states an entry may be in, and there is no third. A page cannot show a -// value it does not have, so the only question is whether it says why. +// The three states an entry may be in, and there is no fourth. A page cannot +// show a value it does not have, so the only question is whether it says why, +// and there are two different whys: nobody has decided, or somebody has and the +// value has not arrived. const ( Answered = "answered" Undecided = "undecided" + Decided = "decided" ) +// RecordDir is where a decided entry's record has to live. It is the directory +// decisions/README.md sets the shape of, and an entry naming a file anywhere +// else is naming something that is not a decision of record. +const RecordDir = "decisions/" + // notice is one thing the page answers, or says it cannot. Asks is the question -// in the words a reader reads, Answer is what it is answered with, and Waiting is -// what an unanswered one is waiting on. Exactly one of the last two carries -// anything, which is what the loader refuses everything else for. +// in the words a reader reads, Answer is what it is answered with, Record is the +// decision record a decided one points at, and Waiting is what an unanswered one +// is waiting on. An answered notice carries Answer alone, an undecided one +// Waiting alone, and a decided one Record and Waiting together, which is what +// the loader refuses everything else for. type notice struct { Asks string Answer string + Record string Waiting string } @@ -76,10 +97,11 @@ type entry struct { Asks string `json:"asks"` State string `json:"state"` Value string `json:"value"` + Record string `json:"record"` Waiting string `json:"waiting"` } -var entryFields = map[string]bool{"asks": true, "state": true, "value": true, "waiting": true} +var entryFields = map[string]bool{"asks": true, "state": true, "value": true, "record": true, "waiting": true} // readPublisher reads what the tree says about who publishes this site, in the // order the file gives, and returns every reason it will not rather than the @@ -89,7 +111,11 @@ var entryFields = map[string]bool{"asks": true, "state": true, "value": true, "w // sequence: who publishes comes before how to reach them. A map would lose that, // so the keys are read out of the raw object and put back in the order they were // written. -func readPublisher(name string) ([]notice, error) { +// +// root is the tree the file belongs to, and it is what a decided entry's record +// is looked for in. A record is a pointer a reader follows, and a pointer at a +// file that is not there is the exact failure this state was added to end. +func readPublisher(root, name string) ([]notice, error) { body, err := os.ReadFile(filepath.Clean(name)) if err != nil { return nil, err @@ -128,6 +154,12 @@ func readPublisher(name string) ([]notice, error) { reasons = append(reasons, reason) continue } + if n.Record != "" { + if _, err := os.Stat(filepath.Join(root, filepath.FromSlash(n.Record))); err != nil { + reasons = append(reasons, fmt.Sprintf("%q is %s and names %q as the record carrying the decision, and there is no such file in the tree, so the page would send a reader to a record that is not there", key, Decided, n.Record)) + continue + } + } out = append(out, n) } @@ -145,8 +177,15 @@ func readPublisher(name string) ([]notice, error) { // one carrying a value is a value somebody believes is published and no reader // will ever see, which is worse than a blank because whoever wrote it has stopped // looking. An undecided one naming nothing to wait on reads as a question nobody -// is holding. And an answered one still naming something reads as open while -// showing an answer, which is the pair a reader cannot resolve. +// is holding. An answered one still naming something reads as open while showing +// an answer, which is the pair a reader cannot resolve. A decided one carrying a +// value is an answer that has arrived and is being withheld from the page, a +// decided one naming no record is a claim that something was decided with +// nothing a reader can check it against, a decided one naming a record outside +// the decisions directory is pointing at something that is not a decision of +// record, and a decided one naming nothing it waits on reads as decided for no +// reason the page can give. Whether the record is in the tree is decided by the +// caller, which is the one that knows the tree. func readEntry(key string, e entry) (notice, string) { if strings.TrimSpace(e.Asks) == "" { return notice{}, fmt.Sprintf("%q asks nothing, and an entry with no question renders as an answer to a question the page never puts", key) @@ -159,19 +198,39 @@ func readEntry(key string, e entry) (notice, string) { if strings.TrimSpace(e.Waiting) != "" { return notice{}, fmt.Sprintf("%q is %s and still names %q as what it waits on, which reads as open and shows an answer at the same time", key, Answered, e.Waiting) } + if strings.TrimSpace(e.Record) != "" { + return notice{}, fmt.Sprintf("%q is %s and still names %q as the record carrying the decision, which is a pointer the page has no sentence for once the value is on it", key, Answered, e.Record) + } return notice{Asks: e.Asks, Answer: e.Value}, "" case Undecided: if strings.TrimSpace(e.Value) != "" { return notice{}, fmt.Sprintf("%q is %s and carries the value %q, which no reader will see and which whoever wrote it has stopped looking at", key, Undecided, e.Value) } + if strings.TrimSpace(e.Record) != "" { + return notice{}, fmt.Sprintf("%q is %s and names %q as the record carrying the decision, and a question with a decision record behind it is not undecided", key, Undecided, e.Record) + } if strings.TrimSpace(e.Waiting) == "" { return notice{}, fmt.Sprintf("%q is %s and names nothing it waits on, which reads on the page as a question nobody is holding", key, Undecided) } return notice{Asks: e.Asks, Waiting: e.Waiting}, "" + case Decided: + if strings.TrimSpace(e.Value) != "" { + return notice{}, fmt.Sprintf("%q is %s and carries the value %q, which is an answer that has arrived and is being kept off the page; an entry whose value has arrived is %s", key, Decided, e.Value, Answered) + } + if strings.TrimSpace(e.Record) == "" { + return notice{}, fmt.Sprintf("%q is %s and names no record carrying the decision, which reads on the page as a decision a reader has no way to check", key, Decided) + } + if !strings.HasPrefix(e.Record, RecordDir) || strings.TrimPrefix(e.Record, RecordDir) == "" { + return notice{}, fmt.Sprintf("%q is %s and names %q as the record carrying the decision, which is not under %s and so is not a decision of record", key, Decided, e.Record, RecordDir) + } + if strings.TrimSpace(e.Waiting) == "" { + return notice{}, fmt.Sprintf("%q is %s and names nothing the value waits on, which reads on the page as decided and missing for no reason it can give", key, Decided) + } + return notice{Asks: e.Asks, Record: e.Record, Waiting: e.Waiting}, "" case "": - return notice{}, fmt.Sprintf("%q declares no state, and %s and %s are the only two an entry may be in", key, Answered, Undecided) + return notice{}, fmt.Sprintf("%q declares no state, and %s, %s and %s are the only three an entry may be in", key, Answered, Undecided, Decided) default: - return notice{}, fmt.Sprintf("%q declares the state %q, and %s and %s are the only two an entry may be in", key, e.State, Answered, Undecided) + return notice{}, fmt.Sprintf("%q declares the state %q, and %s, %s and %s are the only three an entry may be in", key, e.State, Answered, Undecided, Decided) } } @@ -212,7 +271,7 @@ func writeLegal(root, out, label string, tmpl *template.Template, said descripti if err != nil { return nil, fmt.Errorf("reading the legal notice prose: %w", err) } - p.Notices, err = readPublisher(values) + p.Notices, err = readPublisher(root, values) if err != nil { return nil, fmt.Errorf("reading who publishes this site: %w", err) } @@ -234,14 +293,17 @@ func writeLegal(root, out, label string, tmpl *template.Template, said descripti } slashed := path.Join(label, LegalPath) - answered := 0 + answered, decided := 0, 0 for _, n := range p.Notices { - if n.Answer != "" { + switch { + case n.Answer != "": answered++ + case n.Record != "": + decided++ } } - fmt.Fprintf(log, "wrote %s (%d bytes, %d of %d answered)\n", - slashed, rendered.Len(), answered, len(p.Notices)) + fmt.Fprintf(log, "wrote %s (%d bytes, %d of %d answered, %d decided and waiting on a value)\n", + slashed, rendered.Len(), answered, len(p.Notices), decided) return []string{slashed}, nil } diff --git a/internal/site/legal_test.go b/internal/site/legal_test.go index bae60af..2b0a4d3 100644 --- a/internal/site/legal_test.go +++ b/internal/site/legal_test.go @@ -31,6 +31,8 @@ const legalTemplate = `