| 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 |