| 1 | package sibling |
| 2 | |
| 3 | import ( |
| 4 | "fmt" |
| 5 | "sort" |
| 6 | |
| 7 | "amadan.net/oficina/suite" |
| 8 | ) |
| 9 | |
| 10 | // Disposition is what memoria does with one candidate passage once its app |
| 11 | // of record has ruled on it. §4.1's table, as a type. |
| 12 | // |
| 13 | // The table has five rows and **exactly one of them is silent.** Every |
| 14 | // other row produces a line on the page, because the alternative is a |
| 15 | // shorter answer that looks like a complete one — the plausible empty list, |
| 16 | // which §4.1 says is why each value exists separately at all. A |
| 17 | // synchronisation condition is not an authorization decision and must not |
| 18 | // share its value; the same sentence, one layer down, says it must not |
| 19 | // share its silence either. |
| 20 | type Disposition uint8 |
| 21 | |
| 22 | const ( |
| 23 | // Withhold is the zero value **on purpose**, and this is the load-bearing |
| 24 | // line in the file. |
| 25 | // |
| 26 | // Go gives an unset field, a missing map entry and a |
| 27 | // forgotten-to-be-assigned variable the zero value, and here that means |
| 28 | // the safest and loudest outcome rather than the most disclosing one. If |
| 29 | // Render were iota's first constant, every one of those accidents would |
| 30 | // speak a passage nobody authorised — the exact failure `requireSession` |
| 31 | // was rewritten to fail closed for, arriving through a different door. |
| 32 | // |
| 33 | // It is loud as well as closed: an outcome nobody assigned is not the |
| 34 | // one value permitted to say nothing. |
| 35 | Withhold Disposition = iota |
| 36 | |
| 37 | // Render: VISIBLE. This person may read this revision. |
| 38 | Render |
| 39 | |
| 40 | // OmitSilently: NOT_VISIBLE. The only value that may be silent, and |
| 41 | // correct — a notice here would report the existence of a passage this |
| 42 | // person may not have, which is the same oracle S3's "404, never 403" |
| 43 | // and §4.2's indistinguishability were both built to deny. |
| 44 | OmitSilently |
| 45 | |
| 46 | // WithholdAndRefetch: STALE. memoria is behind; the item exists and is |
| 47 | // readable and refetching fixes it. Not a denial. |
| 48 | WithholdAndRefetch |
| 49 | |
| 50 | // WithholdAndReannounce: AHEAD. **Nothing produces this yet**, and that |
| 51 | // is recorded rather than hidden — see aheadNotYetOnTheWire below, which |
| 52 | // is the tripwire that fails the day it becomes reachable. |
| 53 | WithholdAndReannounce |
| 54 | |
| 55 | // numDispositions is one past the last, so a test can iterate the |
| 56 | // whole set instead of a list of the ones it remembered. |
| 57 | // |
| 58 | // It exists because a test claimed to assert something "about the |
| 59 | // whole set" while ranging a literal of five values: adding a sixth |
| 60 | // constant left it green and the new value silent. A set a test |
| 61 | // enumerates by hand is a set that stops being enumerated. |
| 62 | numDispositions |
| 63 | ) |
| 64 | |
| 65 | // Renders reports whether the passage's text may be spoken. |
| 66 | func (d Disposition) Renders() bool { return d == Render } |
| 67 | |
| 68 | // Notices reports whether the page must say something was withheld. |
| 69 | // |
| 70 | // Not the negation of Renders: `OmitSilently` neither renders nor notices, |
| 71 | // and it is the only disposition that does neither. |
| 72 | // |
| 73 | // **Written as the two exceptions rather than as a list of the loud |
| 74 | // values, and that is the whole point.** The list version enumerated |
| 75 | // Withhold, WithholdAndRefetch and WithholdAndReannounce, so a sixth |
| 76 | // constant added to the block below was born silent — it rendered |
| 77 | // nothing and said nothing, a second value permitted to hide a passage |
| 78 | // without admitting to it. A review found it by appending one constant |
| 79 | // and touching nothing else, which is precisely the edit |
| 80 | // TestAheadIsStillAShippingLag instructs the next person to make when the |
| 81 | // module ships AHEAD. |
| 82 | // |
| 83 | // Written this way, a new disposition is loud until somebody deliberately |
| 84 | // makes it quiet. That is the direction the default should point. |
| 85 | func (d Disposition) Notices() bool { |
| 86 | return d != Render && d != OmitSilently |
| 87 | } |
| 88 | |
| 89 | // Decide maps one S2 answer to what memoria does about it. §4.1's table. |
| 90 | // |
| 91 | // **Total, with the default on the safe side.** An answer this client does |
| 92 | // not recognise withholds loudly; it never falls through to omission. Gate |
| 93 | // 6's mutation is exactly this — a sixth value `PERHAPS` in the fixture, |
| 94 | // which must go red rather than be quietly dropped — and the reason is that |
| 95 | // omission is the one outcome a reader cannot tell from an honest absence. |
| 96 | // |
| 97 | // The module folds any unrecognised wire word to UNKNOWN before this is |
| 98 | // reached (`known`, suite/s2.go), so in practice the default arm is a |
| 99 | // second line of the same defence rather than the only one. Both are here |
| 100 | // because the module's rule is the module's to change. |
| 101 | func Decide(a suite.Authorization) Disposition { |
| 102 | switch a { |
| 103 | case suite.VISIBLE: |
| 104 | return Render |
| 105 | case suite.NOT_VISIBLE: |
| 106 | return OmitSilently |
| 107 | case suite.STALE: |
| 108 | return WithholdAndRefetch |
| 109 | case suite.UNKNOWN: |
| 110 | return Withhold |
| 111 | } |
| 112 | return Withhold |
| 113 | } |
| 114 | |
| 115 | // aheadNotYetOnTheWire is the fifth row of §4.1's table, and it is not a |
| 116 | // constant this client can switch on. |
| 117 | // |
| 118 | // The module this repository pins carries four values. `AHEAD` is adopted — |
| 119 | // suite doctrine revision 10, settled 2026-09-06 on memoria's own argument |
| 120 | // — and lands on a branch that has not shipped, held by suite CI being |
| 121 | // unconfigured (§13). Until it does, a sibling answering "AHEAD" is folded |
| 122 | // to UNKNOWN by the module and withheld loudly here: **safe, and lossy.** |
| 123 | // What is lost is the marking for re-announce, which is the collapse the |
| 124 | // fifth value was cut to prevent. |
| 125 | // |
| 126 | // This constant exists so a test can assert the lag is still a lag. See |
| 127 | // TestAheadIsStillAShippingLag: it goes red on the day the module gains the |
| 128 | // value, which is the day `Decide` owes it an arm and this file owes the |
| 129 | // paragraph above a deletion. A shipping dependency that nothing checks is |
| 130 | // a shipping dependency nobody notices has arrived. |
| 131 | const aheadNotYetOnTheWire = suite.Authorization("AHEAD") |
| 132 | |
| 133 | // Outcome accumulates one page's loud withholdings, per source app. |
| 134 | // |
| 135 | // **It is told an app and a disposition, and never an item.** §6.5 says the |
| 136 | // page names what is missing and from which app; it does not name what was |
| 137 | // missed, and the way to guarantee that is to give this type no way to |
| 138 | // learn it. A parameter that is not there cannot reach a log line, a notice |
| 139 | // or a metric label — which is the same argument gate 4 makes about |
| 140 | // content in logs, made structurally instead of by grep. |
| 141 | // |
| 142 | // Counts are carried for the withholding that names a number and **not** |
| 143 | // for reconciling. |
| 144 | // |
| 145 | // §8.1 gives counted copy for one value — *"Sheets did not answer; 7 |
| 146 | // results withheld."* — and this said "the two dispositions §8.1's copy |
| 147 | // counts", which §8.1 does not support: there is no STALE line there. The |
| 148 | // refetch line's count is this file's own choice, made for the same reason |
| 149 | // and said plainly here rather than borrowed from a section that does not |
| 150 | // say it. |
| 151 | // |
| 152 | // Reconciling carries no count because §4.1's post-restore rule 1 requires |
| 153 | // one line for the source rather than one per withheld passage: a source |
| 154 | // being reconciled leaves the ranking wholesale, so the number of passages |
| 155 | // it would have contributed describes a page that was never built. |
| 156 | type Outcome struct { |
| 157 | counts map[noticeKey]int |
| 158 | order []noticeKey |
| 159 | } |
| 160 | |
| 161 | type noticeKey struct { |
| 162 | app string |
| 163 | kind Disposition |
| 164 | } |
| 165 | |
| 166 | // Record files one ruling against the page. Dispositions that render or |
| 167 | // omit silently are no-ops here, which is what "silent" means. |
| 168 | func (o *Outcome) Record(app string, d Disposition) { |
| 169 | if !d.Notices() { |
| 170 | return |
| 171 | } |
| 172 | if o.counts == nil { |
| 173 | o.counts = make(map[noticeKey]int) |
| 174 | } |
| 175 | k := noticeKey{app: app, kind: d} |
| 176 | if _, seen := o.counts[k]; !seen { |
| 177 | o.order = append(o.order, k) |
| 178 | } |
| 179 | o.counts[k]++ |
| 180 | } |
| 181 | |
| 182 | // Lines renders the page's withholding notices in the order they were |
| 183 | // recorded, so a page is stable across reloads. |
| 184 | // |
| 185 | // That is **candidate order, not arrival order** — the caller records |
| 186 | // after the fan-in, and the ordering the reader gets is the ordering of |
| 187 | // the results the lines are about. This comment said "the order the |
| 188 | // sources were first heard from", which describes ordering by which |
| 189 | // sibling answered first: the one thing a concurrent fan-out must not let |
| 190 | // the page depend on. |
| 191 | func (o *Outcome) Lines() []string { |
| 192 | out := make([]string, 0, len(o.order)) |
| 193 | for _, k := range o.order { |
| 194 | n := o.counts[k] |
| 195 | switch k.kind { |
| 196 | case Withhold: |
| 197 | out = append(out, fmt.Sprintf("%s did not answer; %s withheld.", |
| 198 | k.app, results(n))) |
| 199 | case WithholdAndRefetch: |
| 200 | out = append(out, fmt.Sprintf("%s has moved on from %s; refetching.", |
| 201 | k.app, results(n))) |
| 202 | case WithholdAndReannounce: |
| 203 | // No count, deliberately. Rule 1 of §4.1's post-restore |
| 204 | // reconciliation: the source leaves the ranking entirely, so the |
| 205 | // number of passages it would have contributed is not a number |
| 206 | // about this page. |
| 207 | out = append(out, fmt.Sprintf( |
| 208 | "%s is reconciling after a restore; its results are not on this page.", |
| 209 | k.app)) |
| 210 | } |
| 211 | } |
| 212 | return out |
| 213 | } |
| 214 | |
| 215 | // SourcesToReannounce lists the apps §4.1 requires memoria to mark for a |
| 216 | // full re-announce. **It never lists an app to delete from**: `AHEAD` is |
| 217 | // not a reason to remove anything, and the only thing that ever retires a |
| 218 | // phantom chunk is a re-announce that reached its closing marker. |
| 219 | func (o *Outcome) SourcesToReannounce() []string { |
| 220 | var out []string |
| 221 | for _, k := range o.order { |
| 222 | if k.kind == WithholdAndReannounce { |
| 223 | out = append(out, k.app) |
| 224 | } |
| 225 | } |
| 226 | sort.Strings(out) |
| 227 | return out |
| 228 | } |
| 229 | |
| 230 | func results(n int) string { |
| 231 | if n == 1 { |
| 232 | return "1 result" |
| 233 | } |
| 234 | return fmt.Sprintf("%d results", n) |
| 235 | } |
| 236 | |