oficina / memoria Public

docs(spec): draft 5, and the eleven findings it has not answered

Draft 4 fixed the ten findings draft 3 recorded as acknowledged-and-not-
fixed. It was reviewed (Claude Fable 5.1, REQUEST CHANGES, 16 required),
and draft 5 answers those sixteen: twelve graded REMOVED by the follow-up
review, four not.

The substantive changes across both drafts:

- S7's predicate is "on this team's roster", so a guest holding one Sheets
  grant is NOT_MEMBER. A sibling that cannot tell the two apart answers
  INDETERMINATE and admits nobody — kept distinct from UNKNOWN so a
  permanent misconfiguration cannot read as a flaky network.
- The S7 cache has a 15-minute TTL and the session an explicit 12 hours,
  instead of silently inheriting rastrillo's 30-day default. A person
  removed from every sibling had been readable for a month.
- S2 gains AHEAD for a sibling restored from backup, and a re-announcing
  source LEAVES the ranking until reconciliation finishes, so its stale
  chunks cannot consume top-k and quietly shorten a page.
- Revisions are opaque tokens compared only for equality. memoria never
  orders them, so STALE and AHEAD are the sibling's verdict.
- Provenance is flattened to roots carrying (id, revision), so two hops
  cannot launder authorization and a superseded artefact is regenerated
  rather than spoken.
- memoria certifies units, S6 does not, and there is no unit algebra at
  all: €k against €#,##0 is MIXED_UNITS rather than a silent thousandfold
  conversion.
- The breaker's counters are durable, and are therefore on the leak list —
  per-person authorization outcomes in replicated storage is a new SHAPE
  of state, not more of an existing one. The spec says plainly that if
  that record is unacceptable the answer is to drop the breaker, not to
  keep an in-memory version that can never fire.

What this commit does NOT do is claim draft 5 is finished. Its review
returned REQUEST CHANGES with eleven required changes, and they are listed
in §13 rather than folded silently into a draft 6 — the same treatment
draft 3's ten got, for the same reason: the review before it found two
fixes that renamed a problem instead of removing it, and a list nobody can
check is how that happens. The four that were not merely cosmetic: the
STALE/AHEAD boundary is undefined for the commonest sibling shape; the
breaker's NOT_VISIBLE clause is window-global rather than per asker, so
the routine case it claims to exclude still fires it; gate 27's fixture
cannot go green (40 chunks cannot fill 50 slots); and gate 8 carries a
mutation that stays green — a clause that reads as rigour.

Four items are marked NEEDS PAUL and are not decided here: the roster-vs-
sharing requirement on Sheets and Docs (escalated to a blocker of
decision 1's class, because memoria cannot overturn another app's settled
decision in its own table), the 12-hour session TTL, the no-override
policy on unit refusal, and the sign-in dial.

The draft-5 review's attribution is disputed and recorded as such: it was
dispatched to Fable and reported itself as Claude Opus 5, which wrote the
draft. If the self-report is right the pass does not satisfy §Review. This
is the second time a reviewer's self-reported id has disagreed with what
was dispatched, so AGENTS.md now says a self-reported model id is not
evidence.

AGENTS.md's review count is corrected in the same commit, per its own rule.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YJRwhRTr2kBeBjQNuH4HWm
Paul Campbell pushed by paul@keymail.dev 7cc6e52bfb7ae5ea0d9064d66d77d1f3764a00c9 parent 4e0bee4
3 files changed, +1355 −138
  • .gitignore +4 −0
  • AGENTS.md +13 −3
  • docs/superpowers/specs/2026-09-03-memoria-design.md +1338 −135
diff --git a/.gitignore b/.gitignore
index 5c0d95e..1d5109f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,3 +6,7 @@
/memoria.db
/memoria.db-wal
/memoria.db-shm
+
+# The copy-review working directory: a string index, a server log and the
+# submitted result. Working artefacts of a review, not source.
+/copy-review/
diff --git a/AGENTS.md b/AGENTS.md
index 6cf9924..de659c3 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -113,9 +113,19 @@ so; it never renders as a confident zero.
Every spec and every branch is reviewed by a model that did not write it,
before it is approved or merged, and the reviewing model is recorded.
-Five passes so far: Fable and GPT Sol on spec drafts 1–3, Fable on the
-code. Every one returned REQUEST CHANGES, and the code review was the most
-useful of them.
+Eight passes so far: Fable and GPT Sol on spec drafts 1–3, Fable 5.1 on
+draft 4, one on draft 5 whose attribution is disputed (see the spec's
+review table — the reviewer named itself as the model that wrote the
+draft, so that pass may not satisfy this rule at all), Fable on the slice-0
+code, and Fable 5.1 on the drag-and-drop branch. **Every one returned
+REQUEST CHANGES**, and the code reviews have been the most useful: both
+found guards that could not fail, and the drag-and-drop review found two
+by re-running the mutations their author claimed to have run rather than
+taking the claim.
+
+A reviewer's self-reported model id is not evidence. Record what was
+dispatched and what came back, and record the disagreement when they
+differ — twice now they have.
---
diff --git a/docs/superpowers/specs/2026-09-03-memoria-design.md b/docs/superpowers/specs/2026-09-03-memoria-design.md
index afbe6b8..a50f1d9 100644
--- a/docs/superpowers/specs/2026-09-03-memoria-design.md
+++ b/docs/superpowers/specs/2026-09-03-memoria-design.md
@@ -3,7 +3,7 @@
The Oficina suite's memory. One instance per team, at
`<team>.memoria.oficina.works`.
-**Status:** draft 3, 2026-09-03.
+**Status:** draft 5, 2026-09-04.
Review history, per AGENTS.md §Review. None of these models wrote the
draft they reviewed; all drafts were written by Opus.
@@ -12,10 +12,18 @@ draft they reviewed; all drafts were written by Opus.
|---|---|---|
| 1 | GPT Sol (commandcode), Fable | REQUEST CHANGES ×2 — 12 and 21 findings, three structural gaps found by both |
| 2 | GPT Sol via Codex on a ChatGPT subscription — **it reported its own model id as `gpt-5`, which disagrees with the `gpt-5.6-sol` Codex labelled it; recorded as the discrepancy it is rather than resolved** | REQUEST CHANGES — 4 ANSWERED, 1 REWORDED, 6 PARTIAL, 1 blocker and 6 new findings |
-| 3 | none yet | — |
-
-§1.2 records what the draft-1 reviews changed; §1.3 what the draft-2
-review changed. **Draft 3 has not been reviewed.**
+| 3 | Fable | REQUEST CHANGES — 22 findings. Ten were recorded in §13 as acknowledged and not fixed; draft 4 fixes them |
+| 4 | Claude Fable 5.1 (`claude-fable-5-1`) | REQUEST CHANGES — 16 required changes. Draft 5 is those sixteen; §1.6 says where each went |
+| 5 | Dispatched to Fable; **the reviewer reported itself as Claude Opus 5 (`claude-opus-5`), which is the model that wrote drafts 4 and 5.** Recorded as the discrepancy it is, exactly as draft 2's was. If the self-report is right, this review does not satisfy §Review and draft 5 still needs a reviewer that did not write it | REQUEST CHANGES — 11 required changes. 12 of draft 4's 16 findings graded REMOVED; the two the review was told to be hardest on (post-restore, the breaker) came back PARTIAL |
+
+§1.2 records what the draft-1 reviews changed, §1.3 what the draft-2
+review changed, §1.4 what the review of the built code changed, §1.5 what
+the draft-3 review changed, and §1.6 what the draft-4 review changed.
+**Draft 5 has been reviewed once, with the attribution caveat in the table
+above, and returned REQUEST CHANGES. Its eleven required changes are NOT
+yet fixed** — they are recorded in §13 under "Draft-5 review, not yet
+fixed", the same way draft 3's ten were, rather than quietly carried.
+Draft 6 is those eleven, and four of them wait on the NEEDS PAUL rulings.
Numbers in §7 are now measured rather than asserted, and recorded in
`docs/BENCHMARKS.md` with the machine and command. Doing that found draft
@@ -42,9 +50,13 @@ does:
memoria asks it about *this person* on every path that speaks a
passage. memoria evaluates nothing.
- **Content that has no other owner** — Notion, Drive, forwarded mail,
- uploads. There is nobody to ask, so **memoria is the app of record**
- and its own grants are authoritative rather than a stale copy of
- something else.
+ uploads. There is nobody to ask, so **memoria is the app of record**.
+ What that authority consists of is §4.2's two facts and nothing else —
+ the asker is admitted, and the document is present and not deleted.
+ **memoria holds no per-person grants**, here or anywhere: holding them
+ would be the authorization model §4.3 says it does not have. Drafts 1
+ through 4 all said "its own grants are authoritative", which named a
+ table that does not exist and never will.
The second rule is Sheets' rule, unchanged: **the bucket never holds a
plaintext cell.** For memoria this binds harder than it looks, because an
@@ -65,7 +77,7 @@ embedding is a plaintext cell in a costume — §3.1.
| 7 | **Every SIBLING call is a pull. memoria exposes no sibling-facing surface.** Its ingest endpoint serves external connectors only, which are memoria-owned content, not a sibling push. | An outbox per sibling; and draft 1's §9, which specified the push surface this decision forbids | Fable contract pass; corrected after review |
| 8 | **Provenance or deletion, at `(id, revision)` granularity.** A derived artefact whose provenance stops resolving *or whose source changed* is deleted or regenerated. memoria never fine-tunes. | Draft 1's id-level rule, which left "the Q3 target is X" standing after the source said Y | Fable ingestion pass; corrected after review |
| 10 | **memoria never does arithmetic. A number comes from a cell, or from Sheets' engine via S6.** S6 takes an enumerated function over cell refs — never expression text — so memoria is never a formula author. | memoria computing and showing its working (a second calculator, arrived at one `mean` at a time); writing a formula into a scratch sheet (a write, forbidden by §6.4) | Paul asked for numeric questions, 2026-09-03; shape ruled by Fable and GPT Sol independently, converging |
-| 11 | **Admission is derived from the siblings, not from a members table memoria maintains.** A person may enter memoria if at least one sibling says they are a member of this team. | Draft 2's own members table, which left a person removed everywhere else still able to read memoria-owned content | after the draft-2 review, 2026-09-03 |
+| 11 | **Admission is derived from the siblings, not from a members table memoria maintains.** A person may enter memoria if at least one sibling says they are a member of this team. **One exception, and it is stated rather than discovered:** the platform-configured owners list the app refuses to start without bypasses `S7` entirely. It admits operators, it is fixed at provisioning, it does not grow, and it carries a revocation tail §4.3 measures — the 15-minute figure is false for owners. | Draft 2's own members table, which left a person removed everywhere else still able to read memoria-owned content; and, for the exception, leaving it implicit in §2 and §4.3, where drafts 3 and 4 left it and where it read as a contradiction rather than a carve-out | after the draft-2 review, 2026-09-03; exception recorded explicitly 2026-09-04 |
| 12 | **Name: `memoria`.** House convention is plain Spanish office nouns. A *memoria* is both a memory and, in Spanish office usage, a written record. | `archivo`; `cerebro`; `base`; anything English | Paul, 2026-09-03 |
### 1.1 The proposal review, and what it killed
@@ -133,8 +145,11 @@ enumeration diff, and memoria runs no timers. Draft 1's claim that it
§3.3 says make memoria a bigger target.
**Fixed by §0's split:** for this content there is nobody to ask, so
-memoria owns it outright. Its own grants are authoritative rather than a
-copy, and §4.2 says so instead of implying a re-check that cannot exist.
+memoria owns it outright, and §4.2 says so instead of implying a re-check
+that cannot exist. Drafts 1–4 wrote that fix as "its own grants are
+authoritative rather than a copy". That sentence was the defect it was
+written to remove, one section over: it names a per-person grants table,
+memoria has none, and §4.2's authority is admission plus presence.
Beyond the three, both reviews found the same arithmetic errors (§7), the
same contradiction between §9 and decision 7, and the same over-claim in
@@ -224,14 +239,128 @@ not fail for the thing they named.** A gate is a claim about the world,
and an unmutated gate is an unverified claim — including, twice on this
branch, a gate whose subject had been deleted while it stayed green.
+### 1.5 What the draft-3 review changed
+
+Draft 3 was reviewed by Fable: 22 findings, REQUEST CHANGES. Ten were
+recorded in §13 as acknowledged and not fixed rather than quietly
+carried, and draft 4 is those ten: **six weak predicates, two plain gaps,
+one warm-up that could not be made, and one correction made inside the
+draft itself.** Draft 4 wrote "those ten" over a list that added to nine,
+because the warm-up and the correction were folded together; they are two
+items and are counted as two below.
+
+**Six were a predicate that could not fail.** `MEMBER` meant "known to
+this app", so a guest satisfied it. `MIXED_UNITS` could not fire on three
+cells formatted `General`, so a wrong average got certified. The circuit
+breaker's "*everything*" fired on the routine case, so its notice would
+be learned and ignored before the day it was true. Gate 2 named "a
+threshold" and never gave one. Gate 4's credential half asserted the
+absence of a link the test app never sends. The S7 cache had no TTL, so
+it took `rastrillo/auth`'s 30 days by omission. Each is AGENTS.md's rule
+about guards written to pass, arriving in a different costume — and the
+missing TTL is the other rule as well: a default is not a decision, the
+way a failed count is not a zero.
+
+**Draft 4's answer is to make each of them fail by default.** A unit is
+certified rather than contradicted (§8.7). Admission requires a positive
+statement of team membership, and a sibling that cannot make that
+statement admits nobody (§4.3). A gate asserting an absence must first
+assert the presence of the thing whose absence it checks (§10). Where a
+number was missing, draft 4 gives one and says what it costs.
+
+**Two were plain gaps rather than weak guards.** S2 had no value for a
+sibling that is *behind* memoria, which a restore produces and which fell
+through to the one silent value — fixed by `AHEAD` (§4.1). And derived
+provenance was not transitive, so two hops laundered what one hop cannot
+— fixed by flattening provenance to roots at generation time (§5.2).
+
+**One was a warm-up that could not be made.** §6.5 had memoria fire a
+warm-up `S7` on its own wake. `S7` takes a person ref and there is nobody
+there on a wake, so no fixture could ever have received that call. The
+warm-up is an `S1` poll now, which is the call that already has no asker
+— and which a gate can therefore observe (gate 24).
+
+**And one is §1.4's lesson repeating itself inside the same draft.** The
+warm-up error is draft 2's per-person `S3` again, made three sections
+after the correction for it was written, in the same document, by the
+same author. It is counted separately because it is a different kind of
+finding: the item above is a call that has to change, this one is the
+observation that writing a correction does not inoculate the next
+section against the mistake it corrects. §10's two rules exist because of
+it.
+
+### 1.6 What the draft-4 review changed
+
+Draft 4 was reviewed by Claude Fable 5.1, which wrote none of the drafts:
+REQUEST CHANGES, 16 required changes. Draft 5 is those sixteen. Four
+groups, and the first is the one that matters.
+
+**A fix that renamed its problem instead of removing it.** Draft 4
+rewrote §8.1's circuit breaker because draft 3's fired on the routine
+case, and the rewrite's prose promised "a property of the sibling, not of
+one asker" — while its predicate still counted zero `VISIBLE` across
+whoever happened to query that day. A 24-hour outage of `UNKNOWN`s
+therefore tripped the *refusal* breaker; three Docs-only members querying
+on a day the Sheets user was away tripped it too. The predicate counts
+`NOT_VISIBLE` now, and the signal is a per-asker transition (§8.1). This
+is the third time this project has shipped prose that claimed more than
+its predicate delivered, which is why it is first.
+
+**A silent-omission hole in the fix for a silent-omission hole.**
+`AHEAD` was added in draft 4 to stop a sibling's restore reading as an
+empty list. Draft 4 then specified no reconciliation: chunks at revisions
+the sibling no longer has were withheld one at a time, forever, while
+still consuming top-k — so a page of 50 could render nearly empty with no
+notice, the cardinal sin arriving through the door `AHEAD` was cut to
+close. §4.1 now specifies the re-announce as a complete statement,
+retirement under gate 7's rule, revision override in either direction,
+and the removal of a reconciling source from the ranking. Gate 27 is new
+and holds all of it.
+
+**Three contradictions the drafts carried rather than resolved.** §8.7
+said both that S6 is called only when units are certified and that S6
+returns `UNIT_UNKNOWN`; memoria certifies now and S6 does not (§8.7).
+§2 said memoria holds the owners list and holds no roster four lines
+apart; the owners list is not a roster and decision 11 now carries it as
+a stated exception. And "memoria's own grants are authoritative" survived
+in three places after §4.2 established there are no per-person grants —
+the same defect draft 3 shipped between §2 and decision 11, relocated
+(§0, §1.2, gate 18).
+
+**Nine gates that could not fail for the thing they named,** which is
+§1.4's lesson landing on §10 itself: gate 2 asserted an absence with no
+presence assertion, gate 4 named a route from memory, gate 23's
+concurrency half was always-red or never-red on its own fixture, gate 24
+raced the first query, gate 25's field-name regex let `principal` and
+`who` through, and gate 26 named the instance key where the code
+fingerprints under the DEK — verified in `internal/memoria/store.go`,
+which calls `s.sealer.Fingerprint`, and the sealer holds the DEK. A gate
+that goes red on correct code is worse than no gate.
+
---
## 2. What memoria is
A fourth per-team instance app, beside sheets, docs and calendar, with
-its own DEK, its own members table and its own origin. It holds the
-team's content in a sealed index and answers questions over it with
-citations.
+its own DEK and its own origin. It holds the team's content in a sealed
+index and answers questions over it with citations.
+
+**It has no members table**, and draft 3 said it did — in this sentence,
+word for word from draft 2, three sections before decision 11 rejected
+the table. A reader of §2 built it; a reader of decision 11 did not.
+Admission is relayed from the siblings (§4.3).
+
+**The owners list is not a roster, and draft 4 called it one four lines
+above saying memoria never holds one.** The app refuses to start without
+a platform-configured owners list, and that list is decision 11's stated
+exception, not a small members table. What separates the two is not size:
+a roster answers "who is on this team", changes as people join and leave,
+and would have to be reconciled against the siblings that also answer it.
+The owners list answers "who operates this instance", is fixed at
+provisioning, does not grow one person at a time, and is reconciled
+against nothing because no sibling holds an opinion about it. Extending
+it per person would make it a roster, and that is the move decision 11
+rejects.
**What it is for.** Nothing in the suite answers a question. Docs ships
title and metadata search; Sheets has a recency list; the home's call 3
@@ -241,6 +370,10 @@ getting an answer grounded in the team's own documents is the gap.
**What it never does.**
- **Never decides who may read sibling content.** It asks (§4.1).
+- **Never holds a team roster.** Admission is relayed from the siblings,
+ and refused when none of them will vouch (§4.3). The
+ platform-configured owners list is not one, for the reasons above, and
+ is decision 11's one exception.
- **Never writes to a sibling**, and exposes no sibling-facing surface.
- **Never acts as a person.** It holds no signing key; every assertion's
`aud` binds to "app + instance host" (home §5.3).
@@ -311,6 +444,19 @@ which team is awake.
**From a sibling:** bodies transit the router in plaintext on every S3
fetch (§6.1).
+**From the circuit breaker's counters — a per-person record, and the only
+one memoria keeps.** §8.1's breaker needs a 7-day baseline and memoria
+hibernates, so its counters are durable: per `(sibling, asker, UTC day)`,
+a `VISIBLE` count and a `NOT_VISIBLE` count. Sealed under their own
+purpose key, so this is not a bucket leak; it is a statement about what
+memoria *holds*. Anyone who can read them learns, per person per day, how
+much of each sibling that person was allowed to see — which is a shape of
+the team's permissions, not its content. Retained **8 days**, and the
+retention is load-bearing rather than tidy: an unbounded version is a
+year-long history of who was locked out of what. The alternative is a
+breaker that cannot fire (in-memory, reset every wake), which §8.1 rejects
+because a guard that is off reads as a guard that found nothing.
+
**From the sign-in form, to anyone at all.** rastrillo's classifier
fetches `https://<the address's domain>/.well-known/keymail` when a
sign-in begins, and it does so **before** the admission gate — so an
@@ -470,14 +616,14 @@ Every path that would speak a passage originating in a sibling app calls
```
S2(person_ref, [(item_id, revision), …])
- → per item: VISIBLE | NOT_VISIBLE | STALE | UNKNOWN
+ → per item: VISIBLE | NOT_VISIBLE | STALE | AHEAD | UNKNOWN
```
The sibling answers from the grants join it already maintains — Sheets
§4.5, "rows this team can see, plus rows granted to me individually,
minus rows I have been removed from". memoria evaluates none of it.
-**Four-valued, and each value exists because collapsing it into another
+**Five-valued, and each value exists because collapsing it into another
produces a plausible empty list — the failure shape sheets-core AGENTS.md
names as the worst available.**
@@ -486,6 +632,7 @@ names as the worst available.**
| `VISIBLE` | this person may read this revision | render it |
| `NOT_VISIBLE` | this person may not read it | omit it silently — correct, and the only value that may be silent |
| `STALE` | the item exists and is readable, but the sibling has moved past this revision | **withhold loudly and refetch.** Not a denial |
+| `AHEAD` | the sibling does not have this revision at all: memoria holds history the sibling does not. **Two shapes, one value** — the sibling's log stops short of this revision, or the sibling has no row for this item whatsoever | **withhold loudly and re-announce.** Not a denial, and not a reason to delete |
| `UNKNOWN` | the sibling did not answer usefully | withhold loudly |
**`STALE` is here because draft 2 did not have it**, and mapped a stale
@@ -494,19 +641,118 @@ therefore looked exactly like "you have no matching results", recreating
the failure draft 2 claimed to have removed. A synchronisation condition
is not an authorization decision and must not share its value.
+**`AHEAD` is here because draft 3 did not have it**, and the draft-3
+review found the hole. `STALE` is defined as the sibling having moved
+*past* the revision memoria asked about. Nothing covered the opposite: a
+sibling restored from backup holds revision 5 of an item memoria indexed
+at 7. It cannot authorize a revision it does not have, so the natural
+answer was `NOT_VISIBLE` — the one silent value — and the plausible empty
+list comes back through the restore door, on the day the suite is least
+healthy and least likely to be believed.
+
+**What memoria does with `AHEAD` is not what it does with `STALE`, and
+the difference is the whole reason for a fifth value.** `STALE` means
+memoria is behind, and refetching fixes it. `AHEAD` means the *sibling*
+lost history, so refetching that revision 404s and refetching the item
+yields revision 5 — an older body which is now the sibling's truth. So
+memoria withholds the passage loudly, marks the source for the full
+re-announce §6.1's S1 row already defines for a rewound cursor, and
+**deletes nothing**: a sibling's restore must not purge memoria's corpus,
+and the re-announce is what reconciles the two. Wholesale `AHEAD` from
+one sibling is a restore signal and §8.1's third breaker names it as one,
+rather than as the mis-scoped predicate it is not.
+
+**Why "no row at all" is `AHEAD` and not `NOT_VISIBLE`.** A sibling that
+has never heard of an item memoria holds is in the same position as one
+whose log stops short of it: memoria holds history the sibling does not.
+An ordinary deletion does not arrive this way — it arrives as an `S4`
+tombstone on the feed (§6.1), which is a positive statement that the item
+is gone. Silence is not that statement. Mapping it to `NOT_VISIBLE` would
+put a history gap on the one value that may be silent, which is the
+mistake `AHEAD` was cut to fix; mapping it to `UNKNOWN` would report no
+answer when the sibling answered. So it is `AHEAD`, and whether the item
+is gone for good is settled by the re-announce, not by S2.
+
+**memoria never compares two revisions, and this is what makes `STALE`
+and `AHEAD` well-defined.** A revision is an opaque token issued by the
+sibling, like S1's cursor. Nothing in this design requires revisions to
+be numeric, ordered or monotonic, and memoria tests them only for
+equality. `STALE` and `AHEAD` are the *sibling's* verdict on its own log
+— §6.1's S2 row already says the sibling must compare the asked-for
+revision against its log rather than against its current row — and a
+sibling whose revisions are content hashes or ULIDs answers them just as
+well as one that counts. Without this rule, a restored sibling holding
+"5" against memoria's "7" invites memoria to prefer the larger number,
+which is exactly the wrong answer.
+
+#### Post-restore reconciliation, and the phantom chunks
+
+Draft 4 stopped at "withhold and re-announce", and the review found what
+that leaves running. Between a sibling's restore and a completed
+re-announce, every chunk memoria holds from that sibling at a lost
+revision answers `AHEAD`. Each is withheld loudly — but each was *ranked*
+first, so it consumed a slot in top-k. A search matching fifty chunks,
+twenty of them from the restored sibling, rendered thirty results and a
+notice about twenty. In the bad case — the sibling that supplies most of
+the corpus — the page renders empty or nearly so, and the notice is the
+only thing standing between the reader and "no results". **That is the
+plausible empty list, arriving through the door `AHEAD` was cut to
+close.** Four rules close it.
+
+1. **A source marked for re-announce leaves the ranking until the
+ re-announce completes.** Its chunks are not scored, not fused and not
+ counted against top-k, so the other sources fill the page to its full
+ depth. This costs nothing that was not already lost: every one of those
+ chunks would have been withheld anyway. The page names the excluded
+ source and says it is reconciling — one line, not one line per
+ withheld passage.
+2. **A re-announce is a complete statement, not a stream of hints.** It
+ opens with a marker, lists every item the sibling currently holds as
+ team-visible, and closes with a marker carrying the item count. An
+ announce that does not reach its closing marker, or whose count
+ disagrees with what memoria received, **has failed and authorises
+ nothing** — this is gate 7's rule about enumeration diffs, and it binds
+ here for the same reason: a truncated announce is no information, not
+ an assertion of absence.
+3. **On a completed re-announce, an item memoria holds and the announce
+ omits is retired.** Retirement removes the chunks from the index and
+ tombstones the rows, which makes it a deletion in memoria's own store
+ and therefore subject to §7.4's durable tombstone log. It is not a
+ purge on `AHEAD`: nothing is retired on the S2 answer, and nothing is
+ retired by an announce that failed. This is the only thing that ever
+ removes a phantom chunk, which is why the announce has to be able to
+ say "complete" and mean it.
+4. **The announce's revision replaces memoria's, in either direction.**
+ The sibling's log is the authority on its own items, so an item
+ announced at revision 5 against memoria's 7 becomes revision 5: memoria
+ refetches the body at 5 via S3 and replaces its chunks. There is no
+ numeric comparison and no "keep the newer one" — see the opaque-token
+ rule above. This is decision 8's provenance rule doing its ordinary
+ job: the source moved to a different revision, so the derived rows go.
+
+Gate 27 holds all four, including the case where the announce fails
+mid-way and nothing is retired.
+
**`UNKNOWN` is synthesised by the caller, not only returned by the
sibling.** A sibling that never answers cannot return anything, so the
client maps every non-answer to `UNKNOWN`: timeout, non-2xx, malformed
body, an item missing from the response, a duplicate item, a short
-response to a 256-item request. **The decoder is total** — every requested
+response to a 256-item request, and any result value the client does not
+recognise. **The decoder is total** — every requested
pair gets exactly one result or the whole call is `UNKNOWN`. Without that
rule a missing entry falls through to omission, which is the silent path
again. Gate 6.
-**This subsumes membership.** There is no separate "is P a member?"
-question and no membership synchronisation, because a non-member's items
-all come back `NOT_VISIBLE` from the app that knows. The four
-unreconciled tables of §1.2 are simply not consulted.
+**This subsumes per-item membership, and draft 3 dropped the
+qualifier.** There is no membership *synchronisation* and no membership
+table, because a non-member's items all come back `NOT_VISIBLE` from the
+app that knows, and the four unreconciled tables of §1.2 are simply not
+consulted. But there is exactly one separate "is P a member?" question,
+and §4.3's `S7` is it. It is asked once at the door, about the team
+rather than about an item, and it exists because §4.2's memoria-owned
+content has no sibling to ask per passage. Draft 3 said no such question
+existed and then specified it two sections later; that is a sentence that
+survives redrafting by being about nothing.
**Revision matters.** A document that stays team-visible but whose newer
revision removed a sentence must not have its stale chunk spoken: a
@@ -522,8 +768,26 @@ on it — measured before the constant is committed.
### 4.2 memoria-owned content: memoria is the app of record
Notion, Drive, forwarded mail and uploads have no sibling to ask. For
-this content **memoria's own members and grants are authoritative** — not
-a copy, so there is nothing to drift from.
+this content **memoria is the authority** — not a copy, so there is
+nothing to drift from.
+
+**What that authority consists of, precisely, because draft 3 said
+"memoria's own members and grants" and memoria has neither.** There is no
+members table (decision 11, §2) and there are no per-person grants:
+holding grants would be an authorization model, which §4.3 says memoria
+does not have. The test is two facts and nothing else:
+
+1. **the asker is admitted** — §4.3's relayed answer, which is a
+ statement about the *team* and not about this document; and
+2. **the document is present in memoria and not deleted.**
+
+So memoria-owned content is team-wide by construction. Anything a
+connector or an upload puts in is offered to everyone the siblings call a
+member of this team, and the only revocation is deletion. That is a
+strong thing to say of a Drive folder, which is why §3.6's README says it
+in those words, why §3.5 makes least-privilege connector credentials a
+requirement rather than a nicety, and why §4.3's predicate has to mean
+member rather than guest: fact 1 is the whole of the door.
This is the honest resolution of gap 3, and it costs something that must
be said plainly rather than implied: **revoking a Notion page from the
@@ -547,25 +811,197 @@ could still sign in and read the team's Notion pages, Drive files,
forwarded mail and uploads. The membership drift decision 3 was written to
eliminate survived in exactly the half nobody was looking at.
-**The rule: a person is admitted if at least one sibling answers that they
-are a member of this team.** That is one more call, `S7(person_ref) →
-MEMBER | NOT_MEMBER | UNKNOWN`, and it is cached for the life of a session
-rather than per request, because it changes on the timescale of employment
-rather than of editing.
+**The rule: a person is admitted if at least one sibling answers that
+they are a member of this team.** That is one more call:
-Three consequences worth stating:
+```
+S7(person_ref) → MEMBER | NOT_MEMBER | INDETERMINATE | UNKNOWN
+```
-- **`UNKNOWN` from every sibling refuses the session.** Fail closed: an
- unreachable suite means nobody new signs in, which is an outage, where
- the alternative is admitting someone nobody can vouch for.
-- **A session already minted survives a sibling outage** for its TTL,
- exactly as home §2.1 accepts for the suite as a whole.
-- **memoria still holds no authorization model.** It holds no answer of
- its own — it relays one, and §4.1 still governs every passage after the
- door.
+**`MEMBER` has to mean "member of this team", not "known to this app",
+and draft 3 did not say which.** Sheets §8.2 and Docs §8.2 both describe
+sharing as membership, so a person granted a single sheet is a member
+there, in those specs' own words. Under draft 3's rule that person was
+admitted to memoria — and §4.2 then handed them every Notion page, Drive
+file, forwarded mail and upload the team holds, because §4.2's authority
+is admission plus presence and nothing else. **One shared sheet bought
+the whole memoria-owned corpus.** So the predicate is stated narrowly,
+and the values carry the distinction rather than leaving it to a comment:
-No guests, no anonymous link viewers. Admission is not authorization: an
-admitted person still sees only what §4.1 and §4.2 allow.
+| Result | Means | memoria does |
+|---|---|---|
+| `MEMBER` | this person is on this team's roster at this sibling: they reach the team's shared content by being on the team, not by holding a grant on some item | admit |
+| `NOT_MEMBER` | this person is not on this team's roster at this sibling — **whether they were removed, whether they are a guest holding individual grants, or whether this sibling has no record of them at all.** All three are the same answer to the question actually asked | do not admit on this sibling's word |
+| `INDETERMINATE` | this sibling cannot separate a member from a guest. Not an outage — a statement about the sibling's own model | never admits, and is an operator-visible defect at that sibling |
+| `UNKNOWN` | no useful answer: timeout, non-2xx, malformed body, an unrecognised value | do not admit on this sibling's word |
+
+**`INDETERMINATE` and `UNKNOWN` both refuse, and they are still two
+values**, for the same reason `STALE` is not `NOT_VISIBLE`: one is
+transient and one is permanent. A sibling that has not implemented the
+distinction answers `INDETERMINATE` on every call forever, and collapsing
+it into `UNKNOWN` makes a permanent misconfiguration read as a flaky
+network for as long as it lasts. `INDETERMINATE` is reported once on the
+owner's sources page, as a sibling that cannot vouch for anyone, rather
+than as an outage per sign-in.
+
+**S7's decoder is total, the same way S2's is** (§4.1), and draft 4 left
+it to be inferred. Every `S7` call yields exactly one of the four values.
+A timeout, a non-2xx, a malformed body, an unrecognised value, an empty
+body, or **an answer about a person ref other than the one asked** is
+`UNKNOWN`. The last of those is the one worth naming: a sibling that
+answers about the wrong person has answered, and a decoder that reads the
+verdict without checking the subject would admit on it.
+
+**"This sibling has never heard of this person" is `NOT_MEMBER`, not
+`UNKNOWN`.** The question is "is this person on this team's roster at
+you", and a sibling with no record of them can answer it: no. Calling it
+`UNKNOWN` would say the sibling failed to answer when it answered
+perfectly well, and — because `UNKNOWN` from every sibling refuses too —
+it would make no difference at the door while making the sources page
+report an outage that is not happening.
+
+**The consequence is that admission does not work until at least one
+sibling can answer this question, and today none of them can — and that
+is a BLOCKER of decision 1's class, not a ticket in someone else's
+repo.** Draft 4 filed it as "work in their repos, the same shape as
+S1–S3", and the review was right that it is a different shape. S1–S3 ask
+the siblings to *build* something they have not built. This asks them to
+**stop meaning what their own settled specs say they mean**: Sheets §8.2
+and Docs §8.2 both define sharing *as* membership, in those words, and
+§4.3's `MEMBER` requires them to hold a roster that a shared item does
+not join. §1.3's own rule is that **memoria cannot overturn another app's
+settled decision in its own table** — and narrowing `MEMBER` in §4.3 is
+exactly that move, made in a subsection instead of in §1, which is worse
+rather than better.
+
+So it goes to §13 marked as needing Paul, beside the decision-1 blocker,
+and it has the same two possible outcomes: the siblings adopt a
+member/guest distinction, or decision 11 is narrowed so admission does
+not rest on their answer. **Neither is memoria's to choose.** Until one
+of them lands, `S7` answers `INDETERMINATE` everywhere and only the
+configured owners are admitted — which is the correct failure, and is
+also §11's slice-1 position held one slice longer.
+
+**The cache, and the number draft 3 never chose.** Draft 3 said `S7` is
+"cached for the life of a session … because it changes on the timescale
+of employment rather than of editing". The timescale claim is true and
+the conclusion did not follow from it. memoria overrides nothing, so the
+session lifetime is `rastrillo/auth`'s `DefaultSessionTTL` of 30 days,
+and a person removed from every sibling went on reading memoria-owned
+content for up to a month. **A default is not a decision.** Two numbers,
+both memoria's to set:
+
+- **The `S7` answer is cached for 15 minutes.** The first request past
+ that re-asks before serving anything. Fifteen minutes is chosen against
+ cost: a query already makes a live S2 call to every contributing
+ sibling (§6.5), so one more call per person per quarter-hour is inside
+ the noise of a path that is already sibling-bound, while asking per
+ request would double the call count on the cheapest answer in the
+ design.
+- **memoria's session TTL is 12 hours** — set explicitly rather than
+ inherited, and **provisional: §13 marks it as Paul's to set.**
+
+ **Draft 4's reason for the number was wrong and is replaced.** It said
+ a sign-in is "a magic link sitting in mail we cannot delete", which
+ conflates two different lifetimes. The link's lifetime is
+ `rastrillo/auth`'s `pendingTTL`, **10 minutes**; the link in the mailbox
+ is dead long before anyone finds it. What lives 30 days by default is
+ the *session cookie*, which is `DefaultSessionTTL`, and it is not in
+ anybody's mail. The number may still be right; the argument for it was
+ about the wrong object.
+
+ **The argument that does apply is §3.3's.** A session is a bearer
+ credential sitting in a browser, and memoria's is worth more than any
+ sibling's, because behind it is the union of four apps' content rather
+ than one app's. A left-open laptop, a shared machine, a stolen device or
+ a copied cookie reaches everything the team has, and reaches it for as
+ long as the session lasts. Twelve hours is one working day: a person
+ signs in daily, and an abandoned session is stale by the next morning
+ rather than by the next month. Against that, the cost is one magic link
+ per person per working day, which is a real friction and is the half of
+ the trade only Paul can price.
+
+**So the revocation tail is at most 15 minutes for memoria-owned content,
+and zero for sibling content**, because §4.1's S2 runs live on every
+passage and no part of it is cached.
+
+**Except for owners, where that sentence is false, and draft 4 let it
+stand unqualified.** Decision 11's exception is the platform-configured
+owners list, and an owner is admitted without `S7` — so no refresh falls
+due and nothing revokes them. **An owner's tail is however long it takes
+to change platform config and restart the app**, which is an operator
+action on a hibernating instance, not a quarter of an hour. That is the
+right shape for an operator credential and the wrong shape for a member,
+which is precisely why the list must not grow one person at a time (§2).
+It is stated here so that "at most 15 minutes" is not read as covering
+the one set of people it does not cover.
+
+**That is a cached authorization decision, and §6.5 says never to cache
+one.** The reconciliation is not that the cache is small; it is which
+sentence §6.5 is making. §6.5 forbids caching a *sibling's* answer about
+a *passage* — a thing the sibling owns, which is asked one item at a
+time and can change at any moment. `S7` is an answer about a person's
+relationship to a team, owned by the same siblings and changing on the
+timescale decision 11 describes. For sibling content the cache is not
+authorization at all: §4.1 still runs on every passage, whatever S7 said.
+**For memoria-owned content the cache *is* the authorization, and that is
+the honest price of §4.2 having no other owner to ask.** Fifteen minutes
+is the size of that price, written down so it can be argued with, rather
+than a month arrived at by not looking.
+
+Three further consequences worth stating:
+
+- **`UNKNOWN` or `INDETERMINATE` from every sibling refuses the
+ session.** Fail closed: an unreachable or unimplemented suite means
+ nobody new signs in, which is an outage, where the alternative is
+ admitting someone nobody can vouch for.
+- **A refresh that cannot complete refuses the request. It does not
+ destroy the session, and it never extends the cached answer.** Draft 4
+ said both things four lines apart — "the first request past that
+ re-asks before serving anything" and "survives … until its next refresh
+ falls due, and then it does not" — which leaves an implementer to guess
+ between refusing one request and signing the person out. The ruling:
+
+ - a refresh that times out, errors or returns `UNKNOWN` from every
+ sibling is `UNKNOWN`. **`UNKNOWN` is never "keep serving on the
+ expired answer"** — that is the cached-authorization failure this
+ whole section is written against, and it would make the 15 minutes
+ a floor rather than a bound;
+ - the *request* is refused, with a stated reason, and the session
+ stays. The next request retries the refresh. So a 30-second sibling
+ blip costs a page, not a sign-in;
+ - the session is destroyed only by its own 12-hour TTL, by an explicit
+ sign-out, or by a **successful** refresh answering `NOT_MEMBER` or
+ `INDETERMINATE` from every sibling — a positive statement that this
+ person is not a member, which is a different thing from nobody
+ answering.
+
+ Destroying the session on a failed refresh would be worse in both
+ directions: it converts every transient outage into a fresh magic link
+ — a new live credential in mail, for a failure that was ours — while
+ buying nothing, because the refused request already served nothing.
+
+- **So a suite-wide outage stops everybody within a quarter of an hour,
+ and signs nobody out.** That is a narrower promise than draft 3's "for
+ its TTL", and it is the one the cache actually makes. Home §2.1 accepts
+ outage over unsound admission for the suite as a whole; this is that
+ trade on a shorter clock.
+- **memoria still holds no authorization model.** It holds no answer of
+ its own — it relays one, keeps it for a stated 15 minutes, and §4.1
+ still governs every passage after the door.
+
+**Slice 0 has no siblings, so it has no S7**, and the spec should say so
+rather than leave a reader to infer a table. Until the first sibling is
+connected the only people admitted are the platform-configured owners the
+app refuses to start without. That list admits operators, it is not a
+roster, and **it does not grow**: when the first sibling connects,
+everyone who is not an owner comes from `S7`. Extending the owners list
+one person at a time would be the members table decision 11 rejected,
+arriving under another name.
+
+No guests, no anonymous link viewers — and after this section, no guest
+of a *sibling* either. Admission is not authorization: an admitted person
+still sees only what §4.1 and §4.2 allow.
---
@@ -630,15 +1066,43 @@ review caught: S2 exists nowhere else, so an external source had no check
at all and a *mixed* artefact had no rule for combining two authorities.
The full algorithm:
-1. **Partition the provenance** by owner — sibling pairs on one side,
- memoria-owned ids on the other.
+1. **Partition the provenance** by owner — sibling `(id, revision)`
+ pairs on one side, memoria-owned `(id, revision)` pairs on the other.
+ There is no third class, and a derived artefact's id is not a
+ provenance entry; that is what the flattening rule below is for.
+
+ **Both sides carry a revision, and draft 4's did not.** Draft 4 wrote
+ "memoria-owned root *ids*", which reads as a small simplification and
+ is decision 8 half-applied: the decision is "provenance or deletion, at
+ `(id, revision)` granularity … *or whose source changed*", and an id
+ alone cannot say that a source changed. A summary built from revision 1
+ of an uploaded contract was served unchanged after someone uploaded
+ revision 2, citing the upload correctly and describing the wrong
+ document — the id-level rule decision 8 was written to replace,
+ surviving on the half of the corpus nobody had checked. So a
+ memoria-owned document carries a revision, bumped on every re-ingest,
+ re-upload and connector update, and its roots record it.
2. **Sibling pairs go to S2**, batched, cap 256 per call, as many calls as
the provenance needs. `VISIBLE` for every pair or the artefact is
withheld. `STALE` on any pair means the artefact is out of date, not
- forbidden: regenerate it, do not speak it.
-3. **memoria-owned ids are checked against memoria's own grants** — which
- for that content is the authority, not a copy (§4.2). Existence plus
- team-visibility in memoria is the whole test.
+ forbidden: regenerate it, do not speak it. `AHEAD` on any pair means a
+ source has been restored behind memoria (§4.1): withhold, re-announce,
+ delete nothing.
+3. **memoria-owned pairs are checked against §4.2's two facts, plus the
+ revision** — the asker is admitted, the document is present and not
+ deleted, **and the recorded revision is the document's current one.**
+ A superseded revision is not a denial and is not a permissions
+ failure: it is decision 8's "the source changed", so the artefact is
+ **regenerated, not spoken** — the same treatment `STALE` gets on the
+ sibling side in step 2, for the same reason. The two facts are the
+ whole *authorization* test, because for this content memoria is the
+ authority rather than a copy and holds no grants of its own to
+ consult; the revision check is a currency test sitting beside it, and
+ collapsing the two is what let draft 4 drop it.
+ **Note what that makes load-bearing:** the only door in front of every
+ memoria-owned source is §4.3's admission, which is why §4.3's
+ predicate has to mean member and not guest, and why its cache has a
+ number.
4. **A mixed artefact needs both to pass.** There is no precedence rule
and no fallback: authorities are ANDed, because an artefact is a claim
about all its sources at once.
@@ -647,6 +1111,49 @@ The full algorithm:
derived artefact that cannot say where it came from is decision 8's
unresolvable-provenance case and gets deleted, not served.
+**Provenance is flattened to roots at generation time, because it is not
+otherwise transitive.** Draft 3's algorithm partitioned by owner into two
+classes, and a derived artefact is memoria-owned — so an artefact built
+from another artefact landed in step 3, whose entire test is presence in
+memoria. The first artefact's sibling sources never reached S2. Two hops
+launder exactly what one hop is written to stop: summarise a Sheets
+document, then summarise the summary, and the second summary is speakable
+to a person Sheets would refuse. Nothing in draft 3 forbade the second
+hop, and nothing would have noticed it.
+
+So an artefact carries **two lists, and only one of them authorizes**:
+
+- **`roots`** — the `(id, revision)` pairs, sibling and memoria-owned
+ alike, that the artefact
+ ultimately rests on, resolved at generation time by substituting each
+ contributing artefact's own `roots` for its id. This is the list §4.1
+ runs against, and by construction it holds no artefact ids, so step 1's
+ partition is total for the same reason §4.1's decoder is.
+- **`built_from`** — the ids of the artefacts this one was generated
+ from. It exists so that regenerating or deleting an intermediate
+ propagates downstream, and **it is never consulted for authorization**.
+ Keeping the two apart is the point: one list answers "may this person
+ see it", the other answers "is it still current", and draft 3 had a
+ single list trying to be both.
+
+Three rules fall out, and each is an assertion rather than an intention:
+
+- **An artefact id appearing in `roots` is a defect**, not a case to
+ handle. It means flattening did not run, and it withholds the artefact
+ under step 5.
+- **An artefact whose sources cannot all be flattened is not generated.**
+ If a contributing artefact's own `roots` do not fully resolve there is
+ nothing to inherit, and generating anyway produces an artefact whose
+ provenance is a guess wearing a list's clothes.
+- **A cycle in `built_from` withholds every artefact on it and is logged
+ as a defect.** Nothing in this design creates one, which is precisely
+ why nothing would catch it.
+
+Flattening is also what makes §4.1's 256-pair cap load-bearing rather
+than decorative: an artefact three hops deep fans out to every root of
+every ancestor, and that fan-out is the number §4.1 says to measure
+before committing the constant.
+
**Where derived artefacts live:** memoria's own store, sealed like any
chunk, indexed and searchable — and therefore subject to §4.1 through
their provenance, never on their own account. They exist only with AI on,
@@ -689,13 +1196,14 @@ memoria pulls on its own wake and on each question. It runs no timers.
| # | Call | Notes |
|---|---|---|
-| S1 | Change feed | Cursor is an opaque position in the sibling's event log, issued by the sibling, stored per consumer. A row is `(item_id, revision, gone, touched_at)` — **no title, no content, no visibility flag** — so it passes the plaintext-grep gate unaided. Retention is a **per-consumer TTL** (home §9.1: "any 'prune when all consumers have caught up' is unbounded in a hibernating fleet"). Past TTL, or a restore-rewound cursor, takes a full re-announce. |
-| S2 | Authorization re-check | `(person_ref, [(item_id, revision)]) → VISIBLE / NOT_VISIBLE / STALE / UNKNOWN` per item. §4.1 specifies it, including the total decoder. Cap 256 pairs. |
+| S1 | Change feed | Cursor is an opaque position in the sibling's event log, issued by the sibling, stored per consumer. **A revision is opaque too:** memoria tests revisions for equality and never orders them (§4.1). A row is `(item_id, revision, gone, touched_at)` — **no title, no content, no visibility flag** — so it passes the plaintext-grep gate unaided. Retention is a **per-consumer TTL** (home §9.1: "any 'prune when all consumers have caught up' is unbounded in a hibernating fleet"). Past TTL, or a restore-rewound cursor, takes a full re-announce. |
+| S1a | Re-announce | The complete form of S1, and §4.1 makes it load-bearing rather than a recovery convenience. Opens with a marker, lists **every** item the sibling currently holds as team-visible, closes with a marker carrying the item count. Paginated; the cursor it issues is valid only once the closing marker is read. **An announce that does not close, or whose count disagrees with what memoria received, has failed and authorises no retirement** — gate 7's rule, and gate 27 holds it. |
+| S2 | Authorization re-check | `(person_ref, [(item_id, revision)]) → VISIBLE / NOT_VISIBLE / STALE / AHEAD / UNKNOWN` per item. §4.1 specifies it, including the total decoder. `AHEAD` requires the sibling to compare the asked-for revision against its own log rather than against its current row, **and to answer `AHEAD` rather than `NOT_VISIBLE` for an item it has no row for at all.** Cap 256 pairs. |
| S3 | Content fetch | **`(id, revision)`, no person** → plaintext body over TLS into memoria's RAM only. The sibling answers only for items that are **team-visible** — the custody predicate, not the grants join — and **404s, never 403s**. This is an ingest call and there is no asker on the ingest path. |
| S4 | Tombstone | *a feed row.* `gone` covers deletion and departure from team-visibility, indistinguishably. |
| S5 | Consumer expiry | *no call.* TTL expiry is the decommission verb. |
-| S6 | Aggregate | `(person_ref, fn, [(sheet, tab, row, col, revision)…]) → {value, display, format_code}` or `STALE(cell)` / `NOT_VISIBLE` / `UNKNOWN` / `NON_NUMERIC(cell)` / `MIXED_UNITS`. §8.7. Cap 64 cells. Never cached. |
-| S7 | Membership | `(person_ref) → MEMBER / NOT_MEMBER / UNKNOWN`. §4.3's admission relay. Cached per session, not per request. |
+| S6 | Aggregate | `(person_ref, fn, [(sheet, tab, row, col, revision)…]) → {value, display, format_code}` or `STALE(cell)` / `AHEAD(cell)` / `NOT_VISIBLE` / `UNKNOWN` / `NON_NUMERIC(cell)`. **Units are not S6's job**: `MIXED_UNITS` and `UNIT_UNKNOWN` are memoria's verdicts, reached before the call, because Sheets cannot see the header words memoria indexed (§8.7). Cap 64 cells. Never cached. |
+| S7 | Team membership | `(person_ref) → MEMBER / NOT_MEMBER / INDETERMINATE / UNKNOWN`. §4.3's admission relay. **`MEMBER` means "on this team's roster", never "known to this app": a guest holding individual grants is `NOT_MEMBER`, and a sibling that cannot tell the two apart answers `INDETERMINATE` and admits nobody.** Cached 15 minutes — not per request, and not for the life of a session. |
**Draft 1's S1 row carried a `visible-to-team` field, and that was a
leak.** With it, a `visible=false, gone=false` row tells memoria that a
@@ -825,16 +1333,35 @@ is declared.
which app.
- **A cold sibling is a wake, and a wake is slower than a deadline.** The
honest consequence: the first search after a quiet period reliably
- withholds. So memoria **fires S7 and a warm-up S2 on its own wake**,
- before anyone has searched, to spend that latency where nobody is
- waiting.
+ withholds. So memoria **polls S1 at every configured sibling on its own
+ wake**, before anyone has searched, to spend that latency where nobody
+ is waiting.
+- **Draft 3 said "fires S7 and a warm-up S2 on its own wake", and that
+ call cannot be made.** Both take a person ref and there is nobody there
+ on a wake — the same error as draft 2's per-person S3 (§6.1), repeated
+ three sections after the correction for it was written. S1 is the call
+ that already has no asker, it is the call memoria makes on wake anyway,
+ and warming a sibling is warming a sibling whichever endpoint does it.
+ A warm-up naming a person is a warm-up no fixture could ever have
+ received, which is how it survived a draft.
- **A reliably slow sibling produces permanent withholding**, which is
worse than it sounds: the product's primary path degrades to "Sheets did
not answer" forever, and a reader learns to ignore the notice. There is
no design that fixes this while failing closed. **The stated position
is that this is a suite health problem surfaced by memoria, not a
memoria problem to route around** — and if it happens, the answer is to
- fix or drop the source, never to cache its authorization.
+ fix or drop the source, never to cache its authorization. (§4.3's S7
+ cache is not an exception to that sentence and §4.3 argues why: this
+ clause is about a sibling's answer on a *passage*, which is never
+ cached and never will be.)
+
+**Each of these is a number a gate can assert, and draft 3 asserted none
+of them.** Gate 23 holds the deadline and the concurrency together,
+because either alone passes on the other's failure: a sibling sleeping
+past the deadline must not add its latency to the page, *and* three
+siblings called at once must not cost three times one sibling's time.
+Gate 24 holds the warm-up: every configured sibling receives its S1 poll
+before the query handler serves its first request.
---
@@ -991,6 +1518,14 @@ reviewer nor draft 1 caught.
from the tombstone log at wake, before first query.
- **A restore from backup takes the re-announce path** and replays
memoria-owned tombstones before serving.
+- **A *sibling's* restore is the mirror case, and §4.1 specifies it.**
+ memoria then holds revisions the sibling does not, which answer `AHEAD`.
+ Reconciliation is: the source leaves the ranking, the re-announce runs,
+ items the completed announce omits are **retired**, and announced
+ revisions replace memoria's whichever way the numbers point. Retirement
+ is a memoria-side deletion, so it goes through this section's durable
+ tombstone log like any other; an announce that fails to complete retires
+ nothing. Gate 27.
- **The tombstone log is durable independently of the database, and this
is what draft 2 was missing.** Restoring a backup from before a deletion
also restores a tombstone log from before it, so replaying that log
@@ -1030,10 +1565,103 @@ a provider — and per §3.2, nothing leaves the instance in this mode.
unavailable, never dropped.
- A failed or stale S1 → the page states the corpus is current as of a
time, rather than implying freshness.
-- **A circuit breaker.** If a sibling returns `NOT_VISIBLE` for
- *everything*, or S3 404s wholesale, that is far more likely a
- mis-scoped predicate on their side than a real mass revocation.
- memoria states the anomaly and does not purge. Gate 8.
+- **A circuit breaker, and draft 4's renamed its problem rather than
+ removing it.** Draft 3's fired on the routine case — a Docs-only member
+ gets wholesale `NOT_VISIBLE` from Sheets on every query they will ever
+ run, and a notice that fires for an ordinary person is a notice readers
+ learn to skip. Draft 4 wrote the right sentence, **"the predicate has to
+ be a property of the sibling, not of one asker"**, and then kept a
+ predicate that was a property of whoever happened to query that day:
+ *zero `VISIBLE` in a 24-hour window*. Two ordinary events satisfy it.
+
+ A 24-hour Sheets outage answers `UNKNOWN` to everything, which is zero
+ `VISIBLE`, which trips a breaker whose whole subject is refusal — an
+ availability problem reported as a permissions anomaly, on the day the
+ operator most needs to read the notice correctly. And on a team where
+ one person uses Sheets and three do not, a single day when that person
+ is away leaves three askers, four pages of queries, zero `VISIBLE` and a
+ `VISIBLE` last week. The routine case with a day off in it.
+
+ **So the predicate counts refusals, not the absence of grants, and the
+ signal is a per-asker transition.** Per sibling, over a rolling 24-hour
+ window:
+
+ - **the baseline set** is every asker who received at least one
+ `VISIBLE` from this sibling in the 7 days *ending where the window
+ begins* — `[start − 7d, start)`, disjoint from the window, so the
+ window can never be its own baseline;
+ - the breaker fires when **at least 3 askers in the baseline set each
+ asked this sibling at least one pair inside the window and received
+ zero `VISIBLE`**, and **the window contains at least one
+ `NOT_VISIBLE`** from this sibling.
+
+ Each clause earns its place. Counting `NOT_VISIBLE` rather than "no
+ `VISIBLE`" is what separates a sibling that is refusing from one that is
+ down: an outage produces `UNKNOWN`s and no `NOT_VISIBLE`, so it does not
+ trip this breaker at all — §6.5's partial-page notice is what reports
+ it. The per-asker transition is what makes the predicate a property of
+ the sibling: a Docs-only member is never in the baseline set, so no
+ number of them can trip it, whoever else did or did not query today.
+ And requiring each counted asker to have *asked* inside the window is
+ what stops absence being read as refusal.
+
+ **The 200-pair clause is dropped, and its removal is the point.** One
+ page of results is 50 pairs, so four queries met it; it did no work, and
+ a floor that everything clears is a clause that reads as rigour. What
+ replaces it is the requirement that three baseline askers each actually
+ queried the sibling in the window, which is a floor written against the
+ thing being measured rather than against the traffic.
+
+ **Where the windows live, and what that costs.** memoria hibernates
+ (§3.5), so an in-memory window resets on every wake and a breaker that
+ can never accumulate seven days of baseline is a breaker that is off —
+ reading, on the owner's page, as "no anomaly". So the counters are
+ **durable**: per `(sibling, asker, UTC day)`, two integers — `VISIBLE`
+ and `NOT_VISIBLE` — sealed under their own HKDF purpose, **retained 8
+ days and no longer**, which is the window plus the baseline plus one.
+ That is a per-person, per-sibling record of how much each person was
+ allowed to see, it is the only per-person record memoria keeps, and
+ **§3.2's leak list carries it** rather than leaving it to be discovered.
+ Two integers a day is the smallest thing that answers the question; a
+ per-refusal log would answer it too and is not built, because the
+ aggregate is sufficient and the log is not.
+
+ **A window that cannot be read says so.** If the counters are missing,
+ unreadable or short of a full baseline — a fresh instance, a restore, a
+ seal failure — the sources page reports **"cannot determine"** for that
+ sibling, naming which of the two it lacks. It never reports "no
+ anomaly", because a count that fails is not a count of zero, and this is
+ the one place in the design where that mistake would silently disable a
+ guard rather than mis-render a page.
+
+- **The S3 half has no asker**, so its predicate is the same shape with
+ the asker clause removed: **at least 20 fetches in the window, all 404,
+ with at least one success in the disjoint preceding 7 days.** Twenty
+ rather than two hundred because an ingest pass fetches far fewer items
+ than a search asks pairs, and the 200 here was the same borrowed number
+ doing the same non-work.
+
+- **Neither breaker's counters are read at query time.** They are written
+ on the path that already made the call and read only when the owner's
+ sources page is rendered, so a reader never waits on them.
+
+- **Wholesale `AHEAD` is a third breaker**, and it reads differently: not
+ a mis-scoped predicate but a restore (§4.1). It states a restore, marks
+ the source for re-announce, **takes that source out of the ranking**,
+ and purges nothing. It needs no baseline and no asker floor, because a
+ single `AHEAD` is already a positive statement that the sibling lost
+ history — there is no routine case to exclude. Gate 27.
+
+- **All three purge nothing, and none of them appears on a reader's
+ results page** — with one exception the `AHEAD` breaker has to make.
+ The refusal and S3 notices go to the team owner's sources page, because
+ a message telling one reader how much they were not allowed to see is a
+ disclosure with a number in it, and §4.1 is explicit that
+ `NOT_VISIBLE` is the one value that may be silent. **The reconciling
+ notice is different and does go on the results page**: it says a source
+ is temporarily out of the search, which is a statement about the suite's
+ health and carries no fact about anyone's permissions. Leaving it off
+ would be the silent short page gate 27 exists to catch. Gates 8 and 27.
### 8.2 Routing, decided after retrieval
@@ -1183,12 +1811,129 @@ count:
- zero → a named gap;
- **two or more → ambiguity, never extra evidence**, and never collapsed.
-**S6 is called only when every slot is filled with compatible units.** Any
-gap, ambiguity, `STALE` or `UNKNOWN` yields the slot table with citations
-and **no number**: *"Found June 2024 and June 2023; nothing matched June
-2025. No average shown."* A two-of-three average is the plausible-wrong
-answer that §8.1's loud withholding exists to prevent, and it is worse
-than an empty result because it is actionable.
+**S6 is called only when every slot is filled and every operand carries a
+certified unit.** Any gap, ambiguity, `STALE`, `AHEAD` or `UNKNOWN`
+yields the slot table with citations and **no number**: *"Found June 2024
+and June 2023; nothing matched June 2025. No average shown."* A
+two-of-three average is the plausible-wrong answer that §8.1's loud
+withholding exists to prevent, and it is worse than an empty result
+because it is actionable.
+
+**"Compatible units" was unfalsifiable in draft 3, and this section's own
+example is the case it cannot catch.** Three cells formatted `General`,
+one of them typed in thousands, carry no signal that distinguishes them.
+`MIXED_UNITS` therefore never fires, the three numbers average cleanly,
+and memoria certifies a figure wrong by a factor of a thousand while
+citing all three cells correctly. A check that cannot fire is a check
+written to pass, and this one guards the number the whole section exists
+to protect.
+
+**So the test is inverted: a unit is certified, not contradicted.** Every
+operand must carry a unit, and it may come from exactly two places:
+
+- **the cell's `format_code`**, which S6 already returns — a currency
+ symbol, a percentage, or a scaling comma (`#,##0,` is thousands in both
+ Sheets' and Excel's grammar); or
+- **an explicit unit word in the structure chunk's header band or row
+ label**, both of which §8.7's chunking already indexes: `€k`,
+ `thousands`, `$m`, `%`.
+
+`General` with no unit word is not a unit. It is the absence of one, and
+that absence has to be a verdict rather than a silence.
+
+**Who certifies: memoria, and not S6.** Draft 4 said both — "S6 is called
+only when units are certified" *and* "S6 gains `UNIT_UNKNOWN`" — which
+cannot both be true, because a value S6 returns is a value that arrives
+after S6 was called. The review was right to force the choice, and it
+falls to memoria for a reason that is not about which is tidier:
+**neither party holds both signals, and memoria is the one that holds
+neither-half-only.** The `format_code` comes from Sheets, but memoria
+already receives it (S6 returns it, and §8.7's structure chunk indexes it
+at ingest). The header word comes from memoria's own chunking, and Sheets
+has no idea what memoria's structure chunks say. An S6 that decided units
+would be deciding on half the evidence.
+
+So the certification rule is memoria's, and it is a fixed table rather
+than a judgement:
+
+| Signal on an operand | Unit token |
+|---|---|
+| `format_code` carries a currency symbol, a percent, or a scaling comma (`#,##0,` is thousands in Sheets' and Excel's grammar) | the canonicalised symbol-plus-scale, e.g. `€`, `€k`, `%` |
+| the structure chunk's header band or row label carries an explicit unit word — `€k`, `thousands`, `$m`, `%` | that word, canonicalised the same way |
+| both, and they canonicalise to the same token | that token |
+| both, and they canonicalise to different tokens | **none: the operand is uncertified** |
+| neither — `General`, no unit word | **none: the operand is uncertified** |
+
+And the two refusals are memoria's verdicts, delivered before S6 is
+called:
+
+| memoria's verdict | Means | memoria does |
+|---|---|---|
+| `MIXED_UNITS` | every operand is certified, and two of their tokens differ | no S6 call; no number; name the operands and their tokens |
+| `UNIT_UNKNOWN(cell)` | this operand certified to no token | no S6 call; no number; name the cells that carry none, and say what would fix them |
+
+**`€k` and `€` are different units, and memoria will not reconcile
+them.** This is the case the review asked to be ruled, and the ruling is
+the strict one: recognising `€k` and `€#,##0` as the same unit means
+multiplying one operand by a thousand, which is memoria doing arithmetic
+on a cell value — the exact thing decision 10 forbids, arriving as a
+helpful conversion. So they do not aggregate, `MIXED_UNITS` fires, and
+the refusal names both formats so the reader can see it is a formatting
+difference and fix it in Sheets. **memoria performs no unit algebra of
+any kind**: no scaling, no currency conversion, no percent-to-fraction.
+Tokens are compared for equality or they are not compatible.
+
+**A `General` cell under a `€k` header aggregates, and here is exactly
+what is displayed.** The header word certifies the operand as `€k`; if
+the other operands certify to `€k` too, S6 is called and returns a
+`value`, a `display` string and a `format_code`. **memoria displays S6's
+`display` string unchanged and names the unit beside it, from the
+header** — *"Average June revenue, 2024–2026: 1,350 (€k, from the column
+header "Jun 2024 €k")."* It does not fold the unit into the number, does
+not render `€1,350,000`, and does not re-format anything: folding the
+unit in is formatting, and §8.7 exists because a second formatter is the
+same bug class as a second calculator. Where the unit came from a header
+word rather than a format code, the answer says so, because that is a
+weaker provenance and the reader should be able to check it.
+
+**Re-certification on the way back, because the indexed format can be
+stale.** memoria certifies from the format code it indexed, then compares
+it against the `format_code` S6 returns for the same cell. A
+disagreement means the sheet was reformatted since ingest: **no number**,
+the slot table, and the source marked for re-ingest. This is `STALE`'s
+logic applied to formatting rather than to content, and without it the
+certification runs on a snapshot of the thing it is certifying.
+
+**All three outcomes can fail, which is the point.** Three cells
+formatted `€#,##0` aggregate. One `€#,##0` against one `k€#,##0` fires
+`MIXED_UNITS`. Three `General` cells under a header that says nothing
+fire `UNIT_UNKNOWN` and show no number. Gate 22 runs all three plus the
+`€k`-header case, and the `General` one is what draft 3 would have
+answered wrongly and confidently.
+
+**The cost is real and it is not hidden.** Plenty of real spreadsheets
+are `General` throughout, and on those memoria refuses every aggregate
+until someone formats the cells or writes the unit into the header. That
+is a worse product than one which answers, and a better one than one
+which answers wrongly: the refusal names the cells and says what would
+fix them, in Sheets, where the fix belongs. **The spec's position is that
+there is no override switch**, because an override is the `General` case
+with a checkbox in front of it, and a checkbox nobody reads is how the
+wrong number gets certified with the reader's own consent.
+
+**That position is not mine to settle, and §13 marks it as Paul's.** It
+trades a product cost nobody has measured against a correctness
+guarantee, and the measurement — how many of a real team's sheets are
+`General` throughout — does not exist. If it turns out to refuse nearly
+everything, the answer this spec would argue for is better header
+indexing rather than an override; but "we refuse most numeric questions"
+is a product decision, not a spec detail.
+
+**One limit, stated plainly:** a cell whose *value* was typed in
+thousands under a `General` format is undetectable to memoria, to S6 and
+to Sheets. Nothing in the suite knows. The header word is the only
+remaining signal, and where there is none, `UNIT_UNKNOWN` is the correct
+answer rather than a cautious one.
**The answer names its basis**, including which years "last three" meant,
because that is ambiguous on any given date.
@@ -1252,15 +1997,99 @@ renders on its own origin. The home never sees `q`.
## 10. Testing gates
+**Two rules govern this list, and §1.4 is why.** Three of the first five
+gates built could not fail for the thing they named.
+
+- **After writing a gate, break the thing it guards, watch it go red,
+ revert** — per path, not per file, and the test comment records what
+ was mutated and when. Every gate below states its mutation.
+- **A gate asserting an absence must first assert the presence of the
+ thing whose absence it checks.** Gate 4 is the worked example: "no
+ sign-in link in the logs" was true because the test app never sent one.
+ An assertion that could not have been produced is not an assertion that
+ passed, exactly as a count that fails is not a count of zero.
+
+The gates:
+
1. **Plaintext grep** over raw SQLite bytes and bucket objects, from the
first commit. `plaintextExempt` starts empty.
-2. **Inversion probe** — the gate the byte grep cannot see. A seeded
- corpus, an embedding-inversion attempt against whatever is at rest,
- and a threshold. Without this, §3.1's ruling is a comment.
+2. **Inversion probe** — the gate the byte grep cannot see, with the
+ threshold draft 3 promised and never gave.
+
+ **First, the presence assertion, which draft 4 omitted and without
+ which this gate is green on slice 0 for exactly the reason gate 4 was
+ green.** Slice 0 has no embeddings (§11), so an absence-only version
+ of this gate passes against a store containing no vectors at all, and
+ would keep passing on the day vectors arrive unsealed if the seeding
+ silently failed. So: seed a 200-chunk corpus of known text, then
+ **read the sealed vector blob back through `seal.Open` and assert it
+ decrypts to 200 vectors of the configured dimension**, with the
+ sentinel chunk's vector matching the one the stub embedder returned.
+ Only then is the absence check below an assertion rather than a
+ description of an empty world.
+
+ **Then the absence check, in two forms.** Take the bytes at rest —
+ the SQLite file, its WAL, and every bucket object — and:
+
+ - **parse them as contiguous matrices** at each plausible dimension
+ (256/512/768/1024/1536) under each plausible dtype (int8, f32, f64,
+ both endiannesses). For every parse yielding a well-formed matrix,
+ compute its pairwise cosine matrix and correlate that against the
+ true corpus's. **Fail if any parse correlates above r = 0.20** —
+ far below what a real embedding scores against itself, far above
+ what noise reaches over 200 chunks;
+ - **grep for the sentinel vector**, the way
+ `TestPlaintextNeverReachesTheDatabase` greps for a canary string.
+ One seeded chunk gets a fixed, distinctive vector from the stub
+ embedder; the gate searches the at-rest bytes for its leading
+ components in each dtype and endianness **and** for their decimal
+ text renderings. This is what catches the encodings the matrix
+ parse cannot see — a JSON array, a length-prefixed or row-framed
+ layout, one vector per row with a header between them — because
+ none of those presents as a contiguous matrix, and all of them are
+ shapes a store might plausibly grow.
+
+ Mutation: write the vectors unsealed and both halves go red — the
+ correlation at r ≈ 1.0, the grep on the sentinel. Break the seeding
+ and the gate fails on the presence assertion rather than passing.
+
+ **What it still does not guard, stated rather than implied:** an
+ encoding that stores neither a contiguous matrix nor any sentinel
+ component recognisably — a quantisation or transform applied before
+ writing, say — passes both halves. The claim is therefore *"nothing
+ at rest is a vector in a form anyone would write one in"*, not
+ *"nothing at rest is derivable from a vector"*. The stronger probe,
+ running an actual inversion model against the at-rest bytes, needs
+ weights this binary cannot carry and is deferred with that reason in
+ §13.
3. **Feed rows carry no content and no visibility flag** — grep extended
over S1 rows, asserting the absence of the field draft 1 had.
4. **No content in logs**, covering query text, answer text, cited
- snippets and any model-call ledger.
+ snippets and any model-call ledger — **and its credential half must
+ first prove a credential was sent.** `TestNoContentInLogs` asserts
+ that no sign-in link reaches the logs while the test app wires no auth
+ and therefore never sends one, so that half passes on an empty world.
+ It must instead build the app with a mail sink, drive the begin route
+ with a real address, assert **(a)** the sink captured a link, and only
+ then assert **(b)** that link's token appears in no log line. (a) is
+ what gives (b) the ability to fail. Mutation: log the link and (b)
+ goes red; disable the mailer and the gate fails on (a) rather than
+ passing.
+
+ **The routes, read out of the code rather than remembered**, because a
+ gate naming a route the app does not serve is the renamed-test failure
+ §10 opens with. `internal/memoria/app.go` registers `GET /signin` (the
+ form), `POST /auth/begin` (the submit, `a.auth.Begin`) and
+ `GET /auth/verify` (the landing, `a.auth.Verify`);
+ `internal/memoria/auth.go` sets `SigninPath: "/signin"`. So the gate
+ posts to `/auth/begin`, and the token it then hunts for is the one in
+ the `/auth/verify?…` link the sink captured. The existing
+ `TestNoContentInLogs` greps for the literals `"sign-in link"` and
+ `"auth/verify?"`, which is the right target and the wrong method: it
+ asserts nothing was logged without ever proving one was sent, and
+ greps for a route spelling rather than for the captured token, so a
+ mailer that logged the token alone would slip through. Both halves
+ move to the captured link.
5. **Two people get different answers.** End-to-end, two assertions, one
sibling holding a document granted to one of them: the passage renders
for one person and not the other, and the sibling's own grants join is
@@ -1268,20 +2097,86 @@ renders on its own origin. The home never sees `q`.
call happened, which the draft-2 review correctly called weaker than
the claim — it stays green with a constant `VISIBLE`, with the wrong
person ref, or with authorization performed *after* disclosure.
-6. **Every S2 result is handled, and the decoder is total.** `UNKNOWN`,
- `STALE` and `NOT_VISIBLE` each produce their specified behaviour;
- `STALE` withholds loudly rather than omitting; and a timeout, non-2xx,
- malformed body, missing item, duplicate item or short response all map
- to `UNKNOWN` rather than falling through to omission.
+6. **Every S2 result is handled, and the decoder is total.** All five
+ values get their specified behaviour, `AHEAD` included and named
+ because draft 4 added the value and left it out of the gate that
+ proves the decoder covers everything: `VISIBLE` renders, `NOT_VISIBLE`
+ omits silently, `STALE` withholds loudly and refetches, **`AHEAD`
+ withholds loudly, marks the source for re-announce and deletes
+ nothing**, `UNKNOWN` withholds loudly. And the total-decoder half: a
+ timeout, non-2xx, malformed body, missing item, duplicate item, short
+ response, **or any result value the client does not recognise** maps
+ to `UNKNOWN` rather than falling through to omission. Mutation: add a
+ sixth value `PERHAPS` to the fixture's responses and the gate must go
+ red on the unrecognised-value case, not green by ignoring it; drop
+ `AHEAD`'s arm and the `AHEAD` case falls to the default, which must
+ also be red.
7. **An enumeration diff distinguishes empty from failed**, and deletes
nothing on a timeout, credential error or partial page.
-8. **Circuit breaker** — a wholesale `NOT_VISIBLE` or 404 response states
- an anomaly and purges nothing.
+8. **Circuit breaker, at §8.1's predicate** — five named cases, and
+ three of them are cases where it must stay silent.
+
+ - **Fires.** Three people each had `VISIBLE` from Sheets in the
+ baseline week; in the window each queries Sheets and receives only
+ `NOT_VISIBLE`. It fires, states the anomaly, appears on the owner's
+ sources page and **not** on any reader's results page, and purges
+ nothing.
+ - **Silent: three Docs-only members.** A team where one person uses
+ Sheets and three do not. The three query 50 times each — four pages,
+ 200 pairs, comfortably over every threshold draft 4 had — Sheets
+ answers wholesale `NOT_VISIBLE`, and the Sheets user does not query
+ that day. **The breaker stays silent**, because none of the three is
+ in the baseline set. This is the case draft 4's predicate got
+ wrong, and it is the mutation target below.
+ - **Silent: a 24-hour outage.** Sheets answers `UNKNOWN` to
+ everything for a full window, including to baseline askers. Zero
+ `VISIBLE`, no `NOT_VISIBLE`. **The refusal breaker does not fire**;
+ §6.5's partial-page notice reports the outage instead.
+ - **Silent: below the asker floor.** Two baseline askers, refused all
+ window. Nothing fires.
+ - **Cannot determine.** Delete the durable counters, or start the app
+ with fewer than 7 days of history, and the sources page must say it
+ cannot determine the breaker's state for that sibling, **naming
+ which of window and baseline it lacks**. Assert the string. It must
+ not say "no anomaly", and it must not say nothing.
+
+ Survives a restart: write counters, stop the app, start it, and the
+ breaker still sees the baseline — which is the assertion that they
+ are durable rather than in-memory, and memoria hibernates.
+
+ The S3 breaker gets the same five-case treatment at its own numbers
+ (20 fetches, all 404, one prior success), and the `AHEAD` breaker
+ is covered by gate 27.
+
+ **Mutations, and each is per path.** (i) Replace the per-asker
+ transition with draft 4's "zero `VISIBLE` in the window" and both the
+ Docs-only case and the outage case go red. (ii) Count `VISIBLE`
+ absence instead of `NOT_VISIBLE` presence and the outage case goes
+ red alone. (iii) Let the baseline overlap the window and the
+ fires-case goes red, because every firing asker is then their own
+ baseline. (iv) Return "no anomaly" when the counters are missing and
+ the cannot-determine case goes red.
9. **Derived artefacts expand to full provenance**, and one
- `NOT_VISIBLE` source withholds the artefact.
+ `NOT_VISIBLE` source withholds the artefact — **including at two
+ hops.** An artefact built from an artefact built from a sibling
+ document must send that document's `(id, revision)` to S2, and must be
+ withheld when the sibling refuses it. Assert also that no `roots`
+ entry is an artefact id. Mutation: stop flattening at generation time
+ and the two-hop case renders, which is the laundering §5.2 forbids.
+
+ **And the memoria-owned revision case** (§5.2 steps 1 and 3), which
+ is where draft 4's id-only roots failed: generate an artefact from
+ revision 1 of an uploaded document, re-upload the document as
+ revision 2, and assert the artefact is **not served** — it is
+ regenerated, and until it is, the reader gets the slot table rather
+ than a stale summary. Assert too that its `roots` entry recorded a
+ revision at all. Mutation: record memoria-owned roots as bare ids and
+ this case goes red while every sibling case stays green, which is
+ exactly how it survived draft 4.
10. **Deletion survives a restore** — a tombstone applied, a snapshot
superseded and retired, then a restore from the prior generation must
- not serve the deleted passage.
+ not serve the deleted passage. This is *memoria's* restore; the
+ mirror case, a **sibling** restored behind memoria, is gate 27.
11. **Cross-team dial refusal**, including redirects.
12. **Token rotation** — all three phases, each interrupted and resumed,
across a partial multi-sibling delivery.
@@ -1297,10 +2192,28 @@ renders on its own origin. The home never sees `q`.
17. **No sibling-facing endpoint exists.** Enumerate memoria's routes and
fail any inbound route not on the person-facing or connector list —
the regression gate for the push surface draft 1 shipped by accident.
-18. **External-source authorization and disclosure.** A memoria-owned
- document is governed by memoria's grants, an upstream revocation does
- *not* remove it, and the copy the README promises is deletable is
- deletable.
+18. **External-source authorization and disclosure, against §4.2's two
+ facts.** Draft 4 said "governed by memoria's grants", which named a
+ table §4.2 says does not exist; a gate written from that sentence
+ would go looking for grant rows and find none. The two facts are the
+ whole test, so the gate asserts both, each able to fail on its own:
+
+ - **admission is the only door.** Two admitted people both see a
+ memoria-owned document — there is no per-person filter to
+ distinguish them, and asserting that is asserting §4.2's
+ team-wide-by-construction claim rather than trusting it. Then flip
+ one person's only vouching sibling to `NOT_MEMBER` and, past the
+ cache, they see nothing memoria-owned at all.
+ - **presence is the other.** Delete the document *in memoria* and it
+ is gone for everyone; revoke it upstream instead — disconnect the
+ Notion integration, remove memoria's Drive access — and it is
+ **still served**, which is the §3.6 README's promise stated as a
+ test rather than as copy.
+
+ Mutation: add any per-person condition to the memoria-owned path and
+ the first case goes red; make an upstream revocation remove the
+ document and the second goes red *and* §3.6 becomes false, which is
+ the pairing worth having.
19. **Mixed-provenance authorization** — a derived artefact with one
sibling source and one memoria-owned source requires both, spans
multiple S2 batches, and withholds when either fails.
@@ -1308,9 +2221,193 @@ renders on its own origin. The home never sees `q`.
rather than asserted, failing when the measured footprint at the
stated chunk count exceeds the budget. §7's numbers were wrong twice
with no gate to notice.
-21. **Admission is relayed, not held.** A person no sibling calls a member
- is refused, and `UNKNOWN` from every sibling refuses rather than
- admits.
+21. **Admission is relayed, not held, and a guest is not a member.** A
+ person no sibling calls a member is refused; `UNKNOWN` from every
+ sibling refuses rather than admits; `INDETERMINATE` never admits even
+ when it is the only answer received; and **a person a sibling reports
+ as `NOT_MEMBER` while holding an individual grant there is refused**,
+ then confirmed to reach no memoria-owned document. **A sibling that
+ has never heard of the person answers `NOT_MEMBER`, not `UNKNOWN`**,
+ and a sibling answering about a *different* person ref than the one
+ asked is `UNKNOWN`.
+
+ **The cache, and draft 4's assertion could not fail.** It said "a
+ person whose sibling flips to `NOT_MEMBER`" — but admission is an OR
+ over the siblings, so with two fixtures the second still saying
+ `MEMBER` keeps them admitted at 14 minutes *and* at 16, and the gate
+ passes with no cache at all. The fixture must flip **the only sibling
+ vouching for them**: one fixture answering `MEMBER`, the rest
+ `NOT_MEMBER` throughout, then flip that one. Served at 14 minutes,
+ refused at 16. And the session TTL is 12 hours rather than the
+ framework's `DefaultSessionTTL` of 30 days.
+
+ **The refresh-failure arm** (§4.3): make the only vouching sibling
+ time out at minute 16. The request is **refused**, the session
+ **survives**, and the next request retries — assert all three,
+ because the failure this separates is "refuse the request" from
+ "destroy the session", which draft 4 specified both ways.
+
+ Mutation: drop the TTL override and the TTL assertion goes red;
+ treat `INDETERMINATE` as `MEMBER` and the `INDETERMINATE` case does;
+ serve the cached `MEMBER` on a failed refresh and the refresh arm
+ does; **and flip a non-vouching sibling instead of the vouching one
+ and the cache arm must stay green — which is the check that the
+ fixture is the right one.**
+22. **Unit certification, and memoria is what certifies** (§8.7). Five
+ cases, and the first assertion in each is **that S6 was or was not
+ called**, because §8.7's ruling is that the refusals happen *before*
+ the call:
+
+ - three cells formatted `€#,##0` → S6 called once, a number shown,
+ and the number is S6's `display` string byte for byte;
+ - `€#,##0` against `k€#,##0` → **S6 is never called**, `MIXED_UNITS`,
+ no number, and the notice names both format codes;
+ - three cells formatted `General` under a header carrying no unit
+ word → **S6 is never called**, `UNIT_UNKNOWN`, no number, and the
+ notice names the cells — the case draft 3 would have averaged;
+ - a `General` cell under a header reading `€k`, with its co-operands
+ certifying to `€k` too → S6 called, a number shown, **the unit
+ named beside it and attributed to the header** rather than folded
+ into the figure;
+ - the re-certification arm: certify from the indexed format code,
+ then have the S6 fixture return a *different* `format_code` for the
+ same cell → no number, and the source marked for re-ingest.
+
+ Mutations: treat `General` as a unit and case 3 goes red; rescale
+ `k€` to `€` and case 2 goes red, which is the assertion that memoria
+ does no unit algebra; re-format S6's value instead of passing its
+ `display` through and cases 1 and 4 go red; move certification after
+ the S6 call and every "S6 is never called" assertion goes red.
+23. **The deadline and the concurrency** (§6.5), **on two fixtures,
+ because one cannot hold both.** Draft 4 asserted the overlap against
+ the deadline fixture — one sibling at 5 s, two at 10 ms, an 800 ms
+ deadline — and that assertion is unwritable. If the recorded latency
+ is the client's, the slow sibling is cut off at 800 ms and 90% of the
+ sum is 738 ms, which a concurrent client (≈800 ms) exceeds: always
+ red. If it is the fixture's, the sum is 5.02 s, 90% of it is 4.5 s,
+ and a strictly sequential client still finishes in ≈820 ms because it
+ abandons the slow one at the deadline: never red. Same test, opposite
+ vacuity, depending on which clock — which is why it needs its own
+ fixture of comparable sub-deadline latencies.
+
+ - **The deadline half**, unchanged: three fixtures, one sleeping 5 s
+ and two answering in 10 ms. The page renders in under 1.2 s, names
+ the slow sibling, and shows the other two's passages.
+ - **The concurrency half**, its own fixture: **three siblings each
+ answering in 300 ms**, all comfortably inside the 800 ms deadline
+ so none is cut off. Concurrent is ≈300 ms, sequential ≈900 ms, the
+ sum is 900 ms and 90% of it is **810 ms**. Fail if total wall time
+ exceeds 810 ms. That bound is 2.7× the concurrent time and 0.9× the
+ sequential one, so it separates the two by a wide margin at both
+ ends rather than sitting on top of either.
+
+ Mutation: make the client sequential and the concurrency half goes
+ red at ≈900 ms; remove the deadline and the deadline half goes red at
+ ≈5 s. Each mutation must be checked against **both** fixtures, since
+ the point of the split is that neither fixture can see both faults.
+24. **The wake warm-up** (§6.5). Bring the app up against three sibling
+ fixtures, **issue no query at all**, and assert each fixture received
+ an S1 poll within **N ms** of the app coming up — N a stated constant
+ in the low seconds, not a race against anything.
+
+ **Draft 4 ordered the poll against the first query, and that cannot
+ be written either way.** If the warm-up is asynchronous, "before the
+ handler served a request" is a race between two goroutines and the
+ gate is flaky. If it is synchronous, satisfying the assertion means
+ the first query *waits* for three sibling wakes — which puts the
+ latency back on the reader, and §6.5's entire reason for the warm-up
+ is to spend it where nobody is waiting. Asserting **unprompted
+ presence** avoids both: the claim is that the poll happens on its
+ own, not that it wins a race.
+
+ Mutation: delete the warm-up and every fixture goes unpolled, red.
+ Make the warm-up fire lazily on the first query and the gate goes red
+ too, because no query is ever issued — which is the assertion draft
+ 4's ordering could not make. This gate is also why the warm-up is S1
+ rather than draft 3's S7: an S7 warm-up carries a person ref that
+ does not exist on a wake, so no fixture could ever have received it
+ and no gate could have caught its absence.
+25. **S3 carries no person**, asserted end to end rather than by
+ spelling. A fixture sibling holds an item granted individually to the
+ asker and **not** team-visible. It must 404, that item must never
+ appear in memoria's store, and the fixture must record that **no S3
+ request it received carried any field it did not expect** — the
+ fixture decodes S3 into its own struct with unknown fields rejected,
+ which is a total check where a name list is not.
+
+ **The meaningful mutation, and it takes two edits:** add a person ref
+ to memoria's S3 call **and** make the fixture honour it by serving
+ the individually-granted item. Then the 404 assertion and the
+ store assertion both go red. Either edit alone proves nothing — a
+ person ref the fixture ignores changes no behaviour, which is why a
+ field-name check on its own is not a guard.
+
+ **Draft 4's field-name regex** — `person|user|asker|actor|sub` — is
+ **demoted to a lint, not a gate.** It is the shape §10 opens with:
+ `principal`, `who`, `ref`, `identity`, `on_behalf_of` and `viewer`
+ all sail straight through it, and `sub` false-positives on `subject`
+ and `submitted_at`. It stays as a cheap advisory because a matching
+ field name is worth a second look; nothing rests on it.
+26. **`external_id` is keyed, and reverting it is caught.** The same body
+ ingested twice yields the same id; the same body **under a second
+ DEK** yields a **different** id; and `sha256(body)`, in hex and in
+ base64, appears nowhere in the row or the bucket object. Mutation:
+ revert to a bare SHA-256 and the second and third assertions go red.
+ Draft 3's fix shipped with no gate at all, so reverting it left every
+ test green.
+
+ **The key is the DEK, and draft 4 said "instance key".** Checked
+ against the code: `internal/memoria/store.go` fingerprints via
+ `s.sealer.Fingerprint(seal.PurposeExternal, …)`, and the `Sealer`
+ holds the DEK — `seal.New(dek)`, HKDF over `s.dek` per purpose. The
+ instance key is a different value with a different job:
+ `internal/memoria/auth.go` uses `InstanceKey` to seal auth's pending
+ blob, and it never reaches `seal`. So a gate written from draft 4's
+ wording would rotate `MEMORIA_INSTANCE_KEY`, see the id unchanged —
+ because it correctly is — and go red on correct code, which is worse
+ than the missing gate it replaced. Rotate `MEMORIA_DEK`.
+27. **A sibling's restore reconciles, and the page does not go quietly
+ short** (§4.1). Gate 10 covers memoria's own restore; this is the
+ mirror case, and draft 4 shipped `AHEAD` with nothing holding what
+ happens after it.
+
+ Fixture: memoria holds 60 chunks from Sheets and 40 from Docs. Sheets
+ is restored behind memoria and now (a) holds revision 5 of an item
+ memoria holds at 7, (b) holds no row at all for a second item, (c)
+ answers normally for the rest.
+
+ - **`AHEAD`, not `NOT_VISIBLE`, for both (a) and (b)** — the no-row
+ case included, which is the one that has no obvious home.
+ - **Nothing is deleted**, and the source is marked for re-announce.
+ - **The empty-page assertion, which is the point of the gate.** Run a
+ query matching all 100 chunks with a page size of 50. The page
+ renders **50 results, all from Docs**, plus a line naming Sheets as
+ reconciling. Assert the count, exactly. Draft 4's behaviour — rank
+ the Sheets chunks, then withhold them one at a time — fills the
+ page with `AHEAD` chunks and renders **fewer than 50** plus a pile
+ of notices; run the same case with the Docs fixture removed and it
+ renders **zero results on a corpus that has them**. That is the
+ failure §0 exists to prevent, so the gate asserts the count rather
+ than the notice: a notice is what draft 4 already had.
+ - **A failed announce retires nothing.** Run a re-announce that dies
+ after page 2 with no closing marker, and one whose closing count
+ disagrees with what was received. Both leave every chunk in place.
+ - **A completed announce retires the omitted item**, and only it: the
+ item at (b) is gone from the index and tombstoned in the durable
+ log (§7.4), while everything the announce named survives.
+ - **Revision 5 replaces revision 7.** The announce names the item at
+ 5, memoria refetches at 5 via S3, and the chunks it serves are the
+ revision-5 body. Assert on the body, not on the number.
+ - **The source rejoins the ranking** once the announce completes: the
+ same query now returns Sheets chunks again.
+
+ Mutations, per path: retire on the S2 `AHEAD` rather than on the
+ announce → the nothing-is-deleted case red. Retire on an incomplete
+ announce → the failed-announce case red. Keep `AHEAD` chunks in the
+ ranking → the 50-result assertion red at 30. Compare revisions
+ numerically and keep 7 over 5 → the body assertion red. Map "no row"
+ to `NOT_VISIBLE` → the (b) case red, and note it would otherwise be
+ *silent*, which is what makes asserting it worth the trouble.
---
@@ -1324,9 +2421,17 @@ egress at all.** This is the design Docs §15 item 2 deferred for want of
one, so proving it unblocks Docs regardless of memoria's fate.
**Slice 1 — the sibling contract (§6) and §4.1.** One source app first —
-Docs, since it has bodies. S1–S4, the per-asker gate, loud withholding,
-the circuit breaker. Needs person refs, so gated on home §13 step 4.
-Needs the drain to grow multi-host provisioning and three-phase rotation.
+Docs, since it has bodies. S1–S4 **including S1a's closing marker and
+count**, **S7 with §4.3's member/guest distinction**, the per-asker gate,
+loud withholding, the circuit breaker with its durable counters, and
+§4.1's post-restore reconciliation — which is not an edge case to defer,
+because `AHEAD` without it is a slow leak of phantom chunks into every
+page's top-k (gate 27).
+Needs person refs, so gated on home §13 step 4. **Until that sibling can
+tell a member from a guest, S7 answers `INDETERMINATE` and only the
+configured owners are admitted** — slice 0's position, held one slice
+longer, which is the correct failure and not a workaround. Needs the
+drain to grow multi-host provisioning and three-phase rotation.
**Slice 2 — the model layer (§8).** Named provider, router,
answer-above-citations, the §3.6 README, the Docs §11.2 qualification
@@ -1363,61 +2468,159 @@ rows.
## 13. Open
-- **§Review pass on draft 4.** Drafts 1–3 have been reviewed, five passes
- in total. The items below are draft 3's review, **acknowledged and not
- yet fixed** — recorded rather than quietly carried, because the review
- before this one found two fixes that renamed a problem instead of
- removing it.
-
- - **`MEMBER` is undefined for a guest.** Sheets §8.2 and Docs §8.2 both
- call a guest a member. §4.3 admits on "at least one sibling says
- MEMBER", so a person granted one Sheets sheet may be admitted — and
- §5.2 step 3 then gives them everything memoria owns. The predicate
- S7 asks needs to be "member of this team", not "known to this app".
- - **The S7 cache has no stated TTL, and the framework's default is 30
- days.** `rastrillo/auth` sets `DefaultSessionTTL = 30 * 24h` and
- memoria overrides nothing, so a person removed from every sibling
- reads memoria-owned content for up to a month. The number was never
- chosen. It also sits against §6.5's "never to cache its
- authorization", which for memoria-owned content is what the cache is.
- - **§2 and §4.2 still specify the members table decision 11 rejected**,
- word for word from draft 2. A reader of §2 builds it; a reader of
- decision 11 does not.
- - **§4.1 still says there is "no separate 'is P a member?' question"**,
- which §4.3's S7 is.
- - **No S2 value covers a sibling that is BEHIND memoria** — restored
- from backup, holding revision 5 of an item memoria indexed at 7.
- `STALE` is defined as the sibling having moved *past* the revision, so
- the natural answer is `NOT_VISIBLE`, which is the one silent value.
- The plausible-empty-list failure returns through the restore door.
- - **The circuit breaker fires on a routine case.** Under "admit on at
- least one sibling", a Docs-only member gets wholesale `NOT_VISIBLE`
- from Sheets on every query, which §8.1 calls an anomaly.
- - **Derived-artefact provenance is not transitive.** An artefact built
- from another artefact partitions as memoria-owned and never reaches
- S2 for the first artefact's sibling sources. Two hops launder what
- one hop cannot.
- - **§8.7's unit compatibility has no test it can run.** Three cells
- formatted `General`, one of them in thousands, carry no signal
- distinguishing them, so `MIXED_UNITS` cannot fire and the average is
- confidently wrong. The spec's own example is "one year in k€".
- - **Several draft-3 fixes have no gate**: S3's signature, the keyed
- `external_id` (reverting it leaves every test green), §6.5's deadline
- and concurrency, the wake warm-up. Gates 2 and 8 name "a threshold"
- and "*everything*" without a number, so neither can be written as
- stated.
- - **`TestNoContentInLogs`'s credential half cannot fail**, because the
- test app wires no auth and so never sends a link.
-
-- **§Review pass on draft 3 itself.** Done: Fable, 22 findings, REQUEST
- CHANGES. The blocker it found was mine — see §6.1 on S3.
+**Four items need Paul and are marked `NEEDS PAUL`. Nothing else here is
+a ruling; the rest is work, measurement or somebody else's repo.**
+
+- **Draft-5 review, not yet fixed.** Draft 5 was reviewed and returned
+ REQUEST CHANGES with eleven required changes. They are listed here
+ rather than folded silently into a draft 6, the same way draft 3's ten
+ were — the review before this one found two "fixes" that renamed a
+ problem instead of removing it, and a list nobody can check is how that
+ happens. **Note the attribution caveat in the review table: the reviewer
+ reported itself as the model that wrote this draft.** If that self-report
+ is accurate this pass does not satisfy §Review at all, and draft 5 needs
+ a reviewer that did not write it before any of the below is trusted.
+
+ Of draft 4's sixteen, twelve were graded REMOVED. The four that were not:
+
+ - **The `STALE`/`AHEAD` boundary is undefined for a sibling that keeps
+ only current state**, which is the commonest sibling shape. As written
+ such a sibling answers `UNKNOWN` for every non-current revision, so
+ ordinary staleness withholds loudly forever; and a log-keeping sibling
+ with bounded retention cannot tell "pruned" from "lost in a restore",
+ so after any long hibernation it answers `AHEAD` and §4.1's rule 1
+ takes the whole source out of the ranking on the first query back.
+ §8.1's "there is no routine case to exclude" is false for a
+ hibernating fleet. Proposed fix: `STALE` is a known item at a
+ non-current token whether or not the sibling still holds that token;
+ `AHEAD` is no-row only.
+ - **The breaker's `NOT_VISIBLE` clause is window-global, not per counted
+ asker**, so one routine query from a Docs-only member plus a
+ 24-hour outage still fires the refusal breaker — the exact case the
+ prose claims is now excluded. Worse, "asked in the window" is not
+ computable from the two integers the spec stores: an asker whose
+ window was all `UNKNOWN` is indistinguishable from one who never
+ asked. Both are fixed by requiring each counted asker to have
+ `VISIBLE = 0` and `NOT_VISIBLE ≥ 1` in the window.
+ - **Gate 27's fixture arithmetic cannot go green** — 40 Docs chunks
+ cannot render 50 results — and its first bullet asserts what the
+ *fixture* returns, so it cannot go red for memoria's code.
+ - **Gate 8 carries a mutation that stays green.** Overlapping the
+ baseline with the window makes the baseline a superset, so no firing
+ case changes. The disjointness clause does no work in the predicate:
+ a clause that reads as rigour.
+
+ The other seven are: `STALE` refetch has no revision to fetch under the
+ opaque-token ruling; rule 1's "costs nothing that was not already lost"
+ is false and its mark is not stated durable; the breaker's counters
+ cannot distinguish a quiet week from a fresh instance, and a failed
+ write silently lowers the count; per-cell `format_code` is never said to
+ be ingested, so memoria-side unit certification has no input; an
+ unreadable or gapped tombstone log has no stated rule; gate 25 asserts a
+ 404 with no request behind it; and gate 6 confuses its case with its
+ mutation. Plus a list of cheap consistency edits, including that
+ AGENTS.md still says "Five passes so far".
+
+ **Not required but worth answering in writing:** the top-k starvation
+ rule 1 fixes for `AHEAD` exists identically for `NOT_VISIBLE` — someone
+ who can see a tenth of Sheets gets a page of five, with no refill and no
+ notice, by design. Over-fetching and refilling withheld slots would fix
+ both without excluding a whole source. This draft never considers it.
+- **§Review pass on draft 4 itself.** Done: Claude Fable 5.1, 16 required
+ changes, REQUEST CHANGES. Its sharpest finding was that draft 4's
+ circuit-breaker fix renamed the problem rather than removing it — the
+ prose claimed a property of the sibling and the predicate still
+ measured whoever queried that day (§8.1).
+- **`NEEDS PAUL` — the roster-vs-sharing requirement is a BLOCKER of
+ decision 1's class, and draft 4 filed it as a ticket.** §4.3 requires
+ `MEMBER` to mean "on this team's roster"; Sheets §8.2 and Docs §8.2
+ both define sharing *as* membership, in those words. So §4.3 does not
+ ask the siblings to build a call they have not built — that is S1–S3
+ and it is ordinary work. It asks them to **stop meaning what their own
+ settled specs say they mean**, and §1.3's rule is that memoria cannot
+ overturn another app's settled decision in its own table. Narrowing
+ `MEMBER` in a subsection is that move made somewhere less visible.
+ Two possible rulings, neither memoria's: **the siblings adopt a
+ member/guest distinction**, or **decision 11 is narrowed** so admission
+ does not rest on their answer. Until one lands, `S7` is
+ `INDETERMINATE` everywhere and only the configured owners are
+ admitted. Same threads as the decision-1 blocker below.
+- **No sibling can answer S7 yet.** The *implementation* is ordinary work
+ in their repos, the same shape as S1–S3, once the ruling above settles
+ what `MEMBER` may mean. The two are separate items because they need
+ different things: one needs Paul, one needs a branch.
+- **`AHEAD` asks the siblings for something S2 did not previously
+ need** (§4.1): the sibling must compare the asked-for revision against
+ its own log rather than against its current row, which a sibling that
+ keeps only current state cannot do without keeping more. Same thread as
+ above. A sibling that cannot answer `AHEAD` must answer `UNKNOWN` for
+ those pairs, never `NOT_VISIBLE`; that is a contract clause somebody has
+ to accept. **§4.1's reconciliation adds two more clauses to the same
+ thread:** `AHEAD` must also cover an item the sibling has no row for at
+ all, and **S1a's re-announce must carry a closing marker and an item
+ count** — without them memoria cannot tell a complete announce from a
+ truncated one, and gate 27's retirement has nothing to rest on. A
+ sibling that cannot close its announce authorises no retirement, which
+ means memoria keeps phantom chunks out of the ranking forever rather
+ than removing them. That is safe and it is not fixed.
+- **Gate 2's stronger probe needs a model this binary cannot carry.** The
+ correlation test in gate 2 fails if anything at rest is a vector, which
+ is the assertion available under `CGO_ENABLED=0` with no weights in the
+ repo. Running an actual inversion attack against the at-rest bytes is
+ the probe §3.1's ruling really wants, and it is deferred with this
+ reason rather than left as the word "threshold".
+- **The circuit breaker still cannot fire on a team of two.** §8.1's
+ rewritten predicate needs three askers who had `VISIBLE` in the
+ baseline week, so the smallest teams get only the S3 breaker.
+ Unresolved, and a weaker per-asker version reintroduces exactly the
+ notice nobody reads.
+- **The breaker's counters are the first per-person record memoria
+ keeps** (§3.2, §8.1), and that is a change of shape rather than of
+ scale. Sealed, two integers per `(sibling, asker, day)`, 8-day
+ retention — but memoria previously held no statement about an
+ individual at all, and now holds a per-day trace of how much each
+ person was allowed to see. The alternative is an in-memory window,
+ which on a hibernating instance is a breaker that never fires and
+ reports "no anomaly" while doing so. If the record is unacceptable the
+ answer is to drop the breaker and say so, not to keep it in a form that
+ cannot work.
+- **15 minutes is chosen, not measured** (§4.3). It is a bet that one
+ extra sibling call per person per quarter-hour is invisible beside the
+ S2 calls a query already makes; the latency measurement below settles
+ it, and if a cold S7 costs seconds the number moves.
+- **`NEEDS PAUL` — the 12-hour session TTL** (§4.3), marked provisional in
+ the body. **Draft 4's justification was wrong and has been replaced**:
+ it said a session is "a magic link sitting in mail we cannot delete",
+ which conflates two lifetimes. `rastrillo/auth`'s `pendingTTL` is **10
+ minutes** and governs the link; `DefaultSessionTTL` is **30 days** and
+ governs the cookie, which is in a browser and not in anyone's mail. The
+ argument that does apply is §3.3's: memoria's session is worth more
+ than any sibling's because four apps' content sits behind it, so an
+ abandoned one should go stale by the next morning rather than the next
+ month. Twelve hours is one working day. **The cost is one magic link
+ per person per working day, and pricing that friction is Paul's.**
+- **`NEEDS PAUL` — "there is no override switch" for §8.7's unit
+ refusal.** The spec argues for it: an override is the `General` case
+ with a checkbox in front of it. But `General` is the commonest format
+ there is, so this refuses aggregates on ordinary spreadsheets, and
+ nobody has measured how many of a real team's sheets it would refuse.
+ **"We refuse most numeric questions" is a product decision, not a spec
+ detail.** The measurement should come first; if it refuses nearly
+ everything, this spec would argue for better header indexing rather
+ than an override, but that is an argument, not a ruling.
+- **§8.7's ruling that memoria certifies units *shrinks* the S6 contract
+ rather than growing it.** `MIXED_UNITS` and `UNIT_UNKNOWN` come off
+ S6's return list, because Sheets cannot see the header words memoria
+ indexed and would be deciding on half the evidence. Worth noting in the
+ S1–S3 thread: this is one less thing to ask of Sheets, and the fixed
+ certification table is memoria's to build and gate 22's to hold.
- **BLOCKER, and not mine to close: the source apps must adopt decision
1.** home §2.2, Sheets PRODUCT.md principle 3 and Docs §8.2 each
currently forbid content crossing an instance boundary. Paul ruled the
suite is one trust domain, and memoria cannot enact that in its own
table. `oficina/docs` discussion 33 is open; Sheets and home need the
same. **Slice 1 is blocked on agreement, not on code.**
-- **The sign-in dial (§3.2) needs a decision.** The fetch lives in
+- **`NEEDS PAUL` — the sign-in dial (§3.2).** The fetch lives in
`rastrillo/auth` and `keymaildev/signin`, not here. memoria could gate
`/auth/begin` on the owners list before rastrillo sees the address,
which is small but changes guest sign-in that slice 1 will want; or it