oficina / home Public

Design: activity on the front door, pulled not pushed

The ask was an activity-stream API instances post to. The direction
inverted during design: the home asks each of a person's instances what
is theirs at render time, merges, and holds nothing at rest. That
inversion is what makes the feature possible inside the one rule rather
than against it — no per-item ACL to copy, no revoke tail, and §0's
falsifiable claim survives word for word.

Only §2.2's verb changes, from never renders to never stores, plus §0's
prose gloss, which now reads as never retains. Both change on approval,
not now.

Paul ruled the scope per-team (ruling 6, and home spec ruling 18), which
cost this design its headline. It had argued that only the home can
answer "my things" rather than "this team's things", and per-team
concedes exactly that. §2.1 records the argument as retired and rebuilds
on the credential: the alternative is a page in one app fetching the
sibling with the viewer's identity, which needs the home to mint an
aud=acme.docs assertion and hand it to Sheets — a cross-instance bearer,
which §11's first row calls full impersonation. Only the credential
origin mints, so only a page at the home can hold one per sibling
without any instance holding one for another.

Ruling 8 is the one that nearly did not get asked. Browser-mediated
composition — an iframe per instance, same-site so the instance's own
cookie rides along — satisfies ruling 2 as written and would have left
§2.2 unamended, taking the RAM cache, the cross-person title residue,
the swap and core-dump questions and the hostile-JSON surface with it.
Paul ruled server-side pull anyway, and §2.1 now records what the frames
would have cost: per-app panels instead of one ordered list, a blank
frame where ruling 5's named gap belongs, a sign-in prompt instead of
rows for most guests, hostile pixels on the most public page, and a
guest-wake billing problem with no throttle that keeps rows.

Four review passes, three returning CHANGES NEEDED. They found: a design
written against a superseded §11; §7 contradicting itself on whether
cached rows survive a failed pull, with the safety claim losing; §10's
404 rule inverting ruling 5, so a broken instance vanished silently; the
platform edge being a shared cache upstream of both ends, which would
have served one person's titles to another's pull; a RAM cache described
as one person's when it holds every recent person's, in the process that
mints sessions; two "open numbers" that were load-bearing rather than
tunable; and a fan-out set stated two ways, the wrong one of which would
have disclosed a guest's sibling apps through the honesty mechanism.

Blocked on §13 steps 3 and 4, and deliberately last. It has no claim on
anything earlier and must not acquire one.

Reviewed by Fable across four passes; the fourth approved.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 83df65647b2a2e16eab903c0bf1c536cf017577a parent 84a6108
3 files changed, +1125 −0
  • docs/superpowers/reviews/2026-09-02-escritorio-fable-verdict.md +105 −0
  • docs/superpowers/reviews/2026-09-02-escritorio-review-brief.md +107 −0
  • docs/superpowers/specs/2026-09-05-activity-pull-design.md +913 −0

CI passed — run details

diff --git a/docs/superpowers/reviews/2026-09-02-escritorio-fable-verdict.md b/docs/superpowers/reviews/2026-09-02-escritorio-fable-verdict.md
new file mode 100644
index 0000000..1f12954
--- /dev/null
+++ b/docs/superpowers/reviews/2026-09-02-escritorio-fable-verdict.md
@@ -0,0 +1,105 @@
+# Review: escritorio's visibility model
+
+I read the home design spec in full, both AGENTS.md files, the relevant sections of the Sheets design spec (§2, §8.2–8.3, §9.1.1, §10.2), the home repo's actual state, and the memory notes on open Oficina decisions. Findings first, severity-ordered; recommendation at the end.
+
+## Two things the brief gets wrong about its sources
+
+**The outbox belongs to the instance, not the home, and the home does not hibernate.** The brief says "The home's spec drains *its* outbox 'on wake and on each share mutation, not on a ticker'". The spec's sentence (§6) is about the *instance's* outbox: "The outbox drains on wake and on each share mutation, not on a ticker — a hibernating instance runs no timers." And §2.1: "**The home does not hibernate.** It is the one app on the estate where cold-wake latency is a suite-wide outage." This matters for Option C: the thing that would carry a revoke is Sheets' outbox, whose stated semantics are "For a quiet team a failed upsert may wait weeks for an organic wake, which is acceptable precisely because the directory is a hint" (Sheets spec §9.1.1). **Confirmed.**
+
+**Aside, not the brief's fault:** `home/AGENTS.md` is stale against its own spec — it still says "never stores your address", "some unidentified person", and "Assertion expiry is **seconds**", all three of which the spec explicitly retracted (§0: "the first draft's absolute phrasing … was false"; §3.3: "§0 says 'some person' rather than 'some unidentified person'"; §5.3: "Expiry is 120 seconds, not 'seconds'"). AGENTS.md's own rule is "Where it disagrees with the design spec, the spec wins and this file is wrong — fix it in the same commit." Nobody did. **Confirmed.**
+
+## Findings
+
+### 1. Per-team escritorio answers a question nobody asked (Q6) — the most important finding
+
+The stated want was "a list of MY sheets and docs". A per-team escritorio gives a person in three teams three lists. That is not a partial answer; it is the *same* answer the suite already gives, one hop closer.
+
+What already exists, per the specs:
+- Sheets spec §10.2: "**Home is your sheets by recency and a New button.**" Every app instance already has a per-team recency list.
+- Home spec §11: "**The directory is the suite's front door**: your teams grouped by app, and a New button."
+
+So escritorio's job 1 (launch links) is the home's job by the home's own spec, and job 2 (per-team merged list) is a per-team union of four screens that each already exist. Its only new value is "one page instead of four, per team". That is a modest UI convenience, not a new capability.
+
+The thing actually asked for — one cross-team list — is exactly what the home spec forbids the home from holding, and says so in words: §5.4, "'Which documents you can see' passes (a) and fails (b), handing the home a document-level graph it has no business holding." A per-team escritorio does not solve this; it routes around the *appearance* of it while leaving the want unmet. If the want is real, the honest options are (i) relitigate ruling 11 / §2.2 at the home with eyes open, or (ii) tell the user the architecture rules it out and why. Building escritorio does neither. **Confirmed** against both specs.
+
+### 2. Titles are first-class sealed content in this family; "pointers only" is not a cheap concession (Q2)
+
+Sheets spec §2.1 lists under **Sealed**: "Sheet titles, tab names, comment text." Its README-published leak sentence (§2.2) is that publishing Sheets' database yields "who works with whom, how big their spreadsheets are, how often they edit and what colour they painted things. You learn **no value in any cell**" — and no title. `sheets-core/AGENTS.md` bans a sheet title from any log line alongside cell content.
+
+The escritorio index as settled ("title, app, item ref, URL, touched_at, actor person refs") therefore takes a value that Sheets seals under its own DEK and writes it into a second SQLite file, in a second process, replicated to a second bucket. And "Mail" is in the app list: an email's "title" is its subject line, which is body-grade content in anybody's threat model. "Q3 layoffs — final" was the brief's example; "Re: your termination" is Mail's.
+
+Implications:
+- Escritorio cannot be "thin, blind"; it is a content-holding app and must meet the sibling's one rule — its own DEK, sealed titles, the plaintext-grep gate, the no-title-in-logs rule. That is most of a Sheets-grade trust model for a listing page.
+- Every option's custody sentence (finding 6) includes titles unless escritorio seals them. The brief's framing treats sealing as out of scope; it is not.
+- A stale title is itself a leak shape the brief does not name: Docs renames "Acquisition of X — term sheet" to "Project Blue"; A/C show the old name until the next push. Only a revoke was considered.
+
+**Confirmed** against Sheets spec §2.1/§2.2 and AGENTS.md.
+
+### 3. Option A's `visible_to` is not a list, it is the grants join — so A and C make escritorio a second authorization evaluator (Q1, Q5)
+
+The Sheets spec says access is "a grants join, hand-written, expressing 'rows this team can see, plus rows granted to me individually, minus rows I have been removed from'" (§ on data). The brief's Option A ("each pushed entry carries the person refs who may see it") assumes visibility is a per-item property. It is not; it is a function of item grants **and team membership**. Two ways to make A work, both bad:
+
+- **The app pushes the fully expanded set per item.** Then a member joining or leaving the team changes the visibility of every team-visible item, so one `members` mutation fans out to N item pushes. And a membership change is not a "share mutation" — the outbox's only drain triggers are "wake and each share mutation", so unless the spec is widened, a removed member's list simply does not update until someone shares something. **Suspected** on the drain-trigger point (the wording is narrow; whether the implementation counts membership as a share mutation is not stated), **confirmed** on the fan-out.
+- **Escritorio holds team membership and evaluates the join itself.** Now it holds an authorization model, evaluated by a second implementation of the same rule, in a process that is not the authority. The Sheets AGENTS.md is explicit about why this rule is dangerous to re-implement: getting it wrong "fails as a plausible empty list, not as an error, which is the worst available failure shape". Two evaluators of one rule drift, and the drift shows a title to the wrong person or hides one from the right person, silently.
+
+Either way A/C hand escritorio the graph. Option C inherits all of this and adds a fast path for one of the several event types (revoke) while leaving membership removal, rename, and delete on the slow path. **Confirmed** against the grants-join wording.
+
+### 4. Option C's "seconds" is a median, and the tail is unbounded (Q4)
+
+Trace a revoke under C, using only the specs' own mechanics:
+
+1. Owner un-shares in Sheets. Sheets is awake (it is serving the click).
+2. Priority push = an HTTP call to escritorio. If escritorio is hibernating, that call **wakes it** — a request wakes an app — so the receiver hibernating is not by itself fatal. But the cold wake costs what §5.3 sizes it at: "process start plus decrypt plus migrate-check can exceed a few seconds", against a 120 s budget chosen for exactly that worst case. So the push either sits **in the request path** (the revoke response blocks on another app's cold wake — the Sheets spec forbids this shape: call 2 goes "through an outbox in the instance, never a call in the request path") or it is **asynchronous after the response**.
+3. If asynchronous, the goroutine dies at "a deploy, a hibernation eviction or a panic" (Sheets spec, verbatim on a different subsystem, same platform fact). The revoke then sits in the outbox.
+4. The outbox retries only "on wake and on each share mutation". For a quiet team that is "weeks", by the Sheets spec's own admission.
+5. Restore-from-backup: an escritorio restored from replication holds `visible_to` from before the revoke, with no mechanism to learn it missed one. The home spec had to invent a pepper epoch plus full re-announce for precisely this class of problem, and generalised it into a binding rule in both AGENTS.md files: "**Any 'prune when all consumers have caught up' is unbounded in a hibernating fleet.**"
+
+So C needs, to be honest: per-item version numbers, idempotent pushes, an epoch, a full re-announce verb, and a fail-closed rule when escritorio suspects it is stale. That is the home's directory machinery rebuilt per team — and unlike the directory, escritorio's copy is *rendered*, so the "acceptable because it's a hint" argument that makes the weeks-lag tolerable for the home does not transfer. "Seconds" describes the case where nothing goes wrong. **Confirmed** on every step's mechanics; the tail length is the Sheets spec's own number.
+
+### 5. Option B is not "secretly A"; its real problems are a new oracle endpoint and a blocking zero-JS page (Q3)
+
+The brief asks whether a short-TTL cache turns B into A. No — the two stalenesses are different in kind. A's is a persisted graph copy with an unbounded tail (finding 4). B's cache would be in-RAM, per viewer, bounded by TTL, and evicted at hibernation for free. Bounded-and-volatile is categorically better than unbounded-and-replicated. **Suspected** (reasoning).
+
+B's actual costs, which the brief does not list:
+
+- **Latency shape.** The front door for a quiet team — the team most likely to have all four apps asleep — blocks on the slowest of four parallel cold wakes, tens of seconds by §5.3's sizing, on every first load of the day. The zero-JS baseline (home spec §11, both AGENTS.md) means no progressive render: the page is blank until the last app answers. It is not disqualifying if escritorio renders what it has after a deadline and marks the rest "waking", but that is a design the brief does not describe.
+- **A visibility oracle.** B requires each app to expose "of these refs, which may person P see?" to a bearer-authenticated caller. That is four new per-consumer tokens per team (Pegamento's contract: hashed bearer, allowlisted origin, body cap) and, if escritorio's token leaks, an endpoint that answers membership questions about arbitrary persons and refs. It must be designed to answer only refs the caller supplied and to 404 rather than distinguish "no such ref" from "not yours", or it becomes an enumeration oracle. Not fatal, but unpriced.
+- **B still holds titles.** Finding 2 applies in full. B fixes the ACL custody, not the content custody.
+
+**Suspected** on the oracle risk; **confirmed** on the zero-JS and cold-wake facts.
+
+### 6. The blast-radius claim is half right, and the half that is wrong matters (Q1, Q5)
+
+The claim: a per-team instance may hold what the home may not "because it shares a blast radius with the content it indexes". The home spec's own principle cuts against the framing: §2, "**Blast radius is bounded by what a service holds, not by how many processes it runs in.**" Per-team boundary is necessary but says nothing about what escritorio may hold. What it holds is titles from four apps plus (A/C) four sharing graphs. Compromising one escritorio yields **wider breadth than any single app instance** (every title across four apps) at shallower depth (no bodies). It does not exceed the team, so the cross-team aggregation §0 fears is genuinely avoided. But the home spec's feature test — "Every proposed feature is weighed against 'does this belong in an instance instead?'" — is not passed by escritorio; escritorio *is* the proposal to create a new instance whose sole purpose is aggregation.
+
+The "publish the database and see" sentence, per option:
+
+- **A and C:** *Publish a team's escritorio database and you learn the title of every sheet, document, event and mail subject the team has, who touched each and when, and for every item exactly which people — members and outside guests — may see it: the team's complete sharing graph across four apps in one file. Titles are readable unless escritorio seals them with its own DEK.*
+- **B:** *Publish a team's escritorio database and you learn the title of every item across four apps, who touched each and when, and four hashed bearer tokens. You learn nothing about who may see what.*
+
+Note Sheets already publishes its grants in plaintext (§2.2: "Membership, grants, and roles — including readable email addresses"), so A's per-item ACL is not a new *category* relative to one app; the new things are the union across four apps and the titles. **Confirmed** against §2 and Sheets §2.2.
+
+### 7. Guests and link viewers do not fit any option cleanly (Q1, Q5)
+
+Sheets §8.2: a guest "see[s] exactly the sheets granted to them and learn[s] nothing about the team beyond §9.5's disclosure". Sheets §8.3: "A view link mints nothing — read-only against a capability token; no member, no directory entry." So a link viewer has no person ref, cannot appear in `visible_to`, and cannot have an escritorio session — fine. But a guest with one grant *does* have a person ref and will land on escritorio: escritorio must produce a page showing that one item and nothing else, including no hint of the other three apps' existence in that team, and the guest needs a directory row of `kind: guest` for escritorio itself (or escritorio's sign-in must be derived from the granting app's row — unspecified). **Suspected**; the specs do not address a 5th app's sessions for guests.
+
+### 8. Escritorio is blocked on work that has not started, and on a decision Paul has not made (Q8)
+
+- The home repo at `/home/paulca/amadan.net/oficina/home` is four Go files, 191 lines, "one placeholder page" per its own commit message. §13 steps 2–4 (sign-in, assertions, directory with person refs) have not begun. Every escritorio option keys on person refs — `visible_to`, "may this person see", and escritorio's own sessions all need `sub` — and the home spec says "**step 4 must land before either ships**, because call 2's person ref is what their grant rows key on." Escritorio is a third consumer of a call that does not exist. **Confirmed.**
+- The memory note records an open question "asked twice, not yet answered": whether the central host is a *directory that links* or an *authenticator that mints assertions*. Escritorio's whole identity story depends on that answer. **Confirmed** against the memory file.
+- Of the four apps escritorio would index, `mail` has no repo and `calendar` has a directory with no spec. A merged list across four apps, two of which do not exist, is a merged list across two — and those two already have per-team recency screens (finding 1). **Confirmed** by listing the repos.
+
+### 9. A fourth option exists, and it is "don't build escritorio; put the merged list where the data already is" (Q7)
+
+Two concrete alternatives, both dropping the new app:
+
+- **The home's directory as the only front door** (already specified, §11), linking to each app's own per-team recency list (already specified, Sheets §10.2). Zero new custody, zero new contract calls, zero new evaluators of the grants rule. Cost: one extra click per app. This is what the specs already say the suite does.
+- If a per-team merged list is genuinely wanted later: **a pull-based, stateless page owned by one existing app** (e.g. Sheets' own home page gains a "Docs" section fetched from the sibling with the viewer's identity, fail-soft on cold wake, nothing stored). This is Option B with the escritorio process deleted — one fewer database to seal, one fewer blast radius, and B's oracle endpoint is still needed but at least the caller is an app that already meets the one rule.
+
+Neither yields the cross-team "MY sheets" list. Nothing in A, B, C or these does, because the architecture forbids it at the only place that could hold it. That should be said plainly to the user rather than absorbed into a new app.
+
+## Recommendation
+
+**Do not build escritorio now; if a merged list must exist, it is Option B's mechanism without Option B's process.** The per-team justification is technically sound for cross-team aggregation but does not survive the home spec's own "what does it hold, not how many processes" test: escritorio holds titles that Sheets seals as first-class content, and under A or C it also holds a second, drifting copy of the grants join whose revoke path has the same weeks-long tail the specs already accept only because the directory is a hint — and escritorio's copy is not a hint, it is rendered. B is the only option where a stale escritorio cannot show a title to the wrong person; its costs (four cold wakes on a scriptless page, a visibility endpoint that must not become an oracle) are real but bounded and priceable, and its residual custody is titles only. But B still needs the home's person refs (§13 step 4, not started) and still does not give the user the cross-team list they asked for. So: answer the actual question first — the suite's architecture rules out "one list of MY things" at the home by §5.4, and either that ruling is reopened deliberately or the want is declined — and until the home's steps 2–4 land, point people at the directory the home spec already calls "the suite's front door".
+
+**The strongest argument against this:** the directory-plus-one-click answer scales linearly in clicks with the number of apps, and "your stuff, in one place" is the single thing every office suite's users actually use the front door for. If Calendar and Mail do ship, four separate recency screens per team is a real usability cost that no amount of architectural tidiness pays for, and a stateless B-shaped page will feel slow on exactly the quiet teams that are the product's likely customers. That cost is genuine; my position is only that it should be paid once the identity layer exists, not designed around before it does.
\ No newline at end of file
diff --git a/docs/superpowers/reviews/2026-09-02-escritorio-review-brief.md b/docs/superpowers/reviews/2026-09-02-escritorio-review-brief.md
new file mode 100644
index 0000000..cce17db
--- /dev/null
+++ b/docs/superpowers/reviews/2026-09-02-escritorio-review-brief.md
@@ -0,0 +1,107 @@
+# Review brief: escritorio's visibility model
+
+You are reviewing a **design decision that has not been made yet**, not code.
+Your job is to attack three options, then recommend one. Be adversarial. A
+"looks fine" review is a failed review.
+
+## Read these first (do not trust this brief's summary of them)
+
+- `/home/paulca/amadan.net/oficina/home/docs/superpowers/specs/2026-08-30-home-design.md`
+ — the suite home's design. §0, §2.2, §3, §6 and ruling 9 are the load-bearing
+ parts. **Quote it rather than paraphrasing; do not invent rules it does not state.**
+- `/home/paulca/amadan.net/oficina/home/AGENTS.md`
+- `/home/paulca/amadan.net/oficina/sheets-core/AGENTS.md` — the sibling app's
+ binding rules, for house conventions.
+
+## What is being designed
+
+**escritorio** — a proposed new app in the Oficina suite (sheets, docs,
+calendar, mail + the home). It is **one instance per team**, like Sheets is.
+It is the team's front door. It does two things:
+
+1. Launch links out to Sheets, Docs, Calendar, Mail for that team.
+2. Show **one merged list** of that team's items across those apps.
+
+**Settled already:** the index is **pointers only** — title, app, item ref,
+URL, touched_at, actor person refs. No body text, no snippets, no
+cross-app full-text search. Adding body text later is a separate decision.
+
+**Why this app exists at all:** the home is centralised and must stay blind
+(§0: "it never learns what is in anything"; §2.2: "Never renders content from
+an instance"). So the home can never show a merged document list. The claim
+being tested is that a **per-team instance** may hold what the centralised home
+may not, because it shares a blast radius with the content it indexes.
+**Attack that claim first.** If it is wrong, the whole app is wrong.
+
+## Platform constraints (verify against the specs, don't take my word)
+
+- Apps are rastrillo apps and **hibernate**. A request wakes them; there are no
+ timers while asleep. The home's spec drains its outbox "on wake and on each
+ share mutation, not on a ticker" for exactly this reason.
+- Identity comes from the home as an assertion; `sub` is an opaque **person
+ ref**, stable across email change. Instances store person refs against grants.
+- The home's directory is "a hint, never an authority" (ruling 9). Instances
+ re-check authorization on every request.
+
+## The question
+
+Escritorio must not show you the title of something you cannot see. Sheets and
+Docs are the authority on sharing. How does escritorio filter?
+
+**Option A — app posts the ACL; escritorio renders locally.**
+Each pushed entry carries the person refs who may see it. Escritorio filters
+from its own copy. No network on page load. Escritorio holds the sharing graph.
+Between a revoke and the next push it can show a title to someone just
+un-shared.
+
+**Option B — escritorio asks the app at render time.**
+Escritorio stores entries with no ACL. On each page load it asks each app "of
+these refs, which may this person see?" The app stays sole authority; a stale
+escritorio cannot leak. Page load fans out to ~4 apps, each of which may be
+hibernating.
+
+**Option C — Option A, plus revoke is a priority push.**
+A revoke jumps the outbox rather than waiting for the next ordinary push. Lag
+becomes seconds. Escritorio still holds the graph.
+
+## Attack these specifically
+
+1. **Is the per-team-instance justification sound**, or does escritorio just
+ re-create the aggregation risk the home was designed to avoid, one team at a
+ time? Does a team's escritorio have a *larger* blast radius than any single
+ app instance, since it spans four of them?
+2. **Are titles actually low-value?** "Q3 layoffs — final", "Acquisition of
+ X — term sheet". Argue the case that pointers-only is not the cheap
+ concession it appears to be, and say what that implies for the choice.
+3. **Option B and hibernation.** Is a cold wake of four apps on every front-door
+ load merely slow, or actually disqualifying? Does a short-TTL cache rescue
+ it, and if so does the cache re-introduce exactly Option A's staleness — i.e.
+ is B secretly A with worse latency?
+4. **Option C's priority push.** Does it work when the *receiver* hibernates?
+ Trace it. What happens to a revoke if escritorio is asleep, if the push
+ fails, or if the pushing app dies mid-queue? Is "seconds" defensible or
+ marketing?
+5. **Option A/C custody.** Escritorio holding `visible_to` person-ref lists is a
+ new copy of the sharing graph, per team. What does publishing escritorio's
+ database reveal, in the home spec's own "publish the database and see" form?
+ Write that sentence for each option.
+6. **The unit is per-TEAM, but the user's stated want was "a list of MY sheets
+ and docs".** A person in three teams gets three escritorios and still has no
+ single list. Is per-team the right unit, or does this design answer a
+ different question than the one asked? This may be the most important finding
+ available to you — do not skip it.
+7. **Anything a fourth option would fix** that none of A/B/C do. Propose it if
+ you find one; say so plainly if you don't.
+8. **YAGNI.** Should any of this be built now, or does the suite need the home's
+ sign-in and directory (spec §13 steps 2–4) to exist first? Is escritorio
+ blocked on work that has not started?
+
+## Output
+
+- Findings, ordered by severity. For each: the claim, why it is wrong or risky,
+ and what it costs. Mark each **confirmed** (you verified it against a file)
+ or **suspected** (reasoning only).
+- Then: **one recommendation** — A, B, C, or a fourth option — in a short
+ paragraph, with the single strongest argument against your own choice stated
+ honestly.
+- Do not write code. Do not edit any file.
diff --git a/docs/superpowers/specs/2026-09-05-activity-pull-design.md b/docs/superpowers/specs/2026-09-05-activity-pull-design.md
new file mode 100644
index 0000000..aab6362
--- /dev/null
+++ b/docs/superpowers/specs/2026-09-05-activity-pull-design.md
@@ -0,0 +1,913 @@
+# 🤖 Activity on the front door — design
+
+**Status:** design, 2026-09-05, revised three times the same day.
+Drafted by the activity-stream session (`claude-opus-5`).
+
+**Review passes: three, all by Fable 5.1, all returning CHANGES NEEDED.**
+The first found six blocking (a superseded §11, §7 contradicting itself on
+stale rows, §10's 404 rule inverting ruling 5, the shared platform edge cache
+upstream of both ends, the RAM cache understated as one person's, and two
+"open numbers" that were load-bearing). The second found three (the fan-out
+set stated two ways, one of which disclosed a guest's sibling apps; §2's
+framing backwards after the rescope; §2.1 answering an alternative nobody
+proposed). The third found that §2.1's credential argument was not the whole
+story — browser-mediated composition needs no amendment at all — which Paul
+then ruled on. **The fourth pass APPROVED**, and the nits it listed are
+applied in the commit that carries this line.
+**Peer pass, and why it is not the review pass.** The home-spec-compliance
+session (`claude-opus-5`) reviewed the first draft and found four things,
+all applied: the "no new knowledge" defence was true and beside the point
+(§2.2, now a retraction); gate 11 tested only the happy path while §6 makes
+failure the normal case (§9); ruling 3's safety rests entirely on `aud`
+binding and needed a written "never" against the obvious optimisation (§11);
+and one claim in §5 was falsified by §5.3's own last line. That session
+wrote enough of the resulting argument to no longer be independent, and it
+says so itself. **A fresh reviewer is still required.**
+
+**Scope:** how the home's front door (§11 of the home design) shows a person
+what has been happening in the team they are looking at, across that
+team's apps that they personally have rows in, without the home
+learning or holding any of it.
+
+**Reads on top of** `docs/superpowers/specs/2026-08-30-home-design.md`. Every
+`§n` below refers to that spec unless it says otherwise. Nothing here
+replaces it; two clauses of it change, and §3 says which.
+
+---
+
+## 0. The short version
+
+The ask was "an activity stream API the other apps post to, so home shows a
+feed on sign-in." **The direction inverted during design, and the API is
+therefore not the one that was asked for.** Instances do not post. The home
+asks each of the viewer's own instances in the selected team "what's theirs, recently?" when it renders
+their front door, merges the answers, and holds them nowhere but memory (§7).
+
+**Scoped to the selected team — RULED 2026-09-05 by Paul**, and recorded in
+the home spec as ruling 18. The front door is one team's page (ruling 17,
+`?team=<sqid>` per ruling 22), so the feed under it is that team's, merged
+across the viewer's own rows in that team. An earlier draft specified a
+cross-team merged list and argued for it; that argument is retired in §2.1
+rather than deleted.
+
+That inversion is the whole design. It is what makes the feature possible
+inside the one rule instead of against it:
+
+- **The home stores nothing new.** §0's falsifiable claim — publish the
+ database and the worst anyone learns is counts, team names and memberships
+ — survives word for word.
+- **The app that owns the grants answers the question about the grants.** No
+ authorization model at the home (§2.2), no copied ACL to drift, no revoke
+ tail.
+- **Two clauses change** (§3): §2.2's verb, from *never renders* to *never
+ stores*, and §0's prose gloss, which now reads as *never retains*.
+
+What it costs is latency, honestly rendered. §6 and §7.
+
+---
+
+## 1. Settled decisions
+
+| # | Ruling | Rejected | Who, when |
+|---|---|---|---|
+| 1 | **A real feed with titles, not a contentless heat map** | "4 changes in acme·sheets" with no titles; no feed at all | Paul, 2026-09-05 |
+| 2 | **Pull at render. The home holds no activity at rest** | Push into the home (the parked shape); push-a-signal / pull-detail hybrid | Paul, 2026-09-05 |
+| 8 | **Server-side pull, and amend §2.2 — not browser-mediated composition** | An `<iframe>` per instance, which satisfies ruling 2 as written and would leave §2.2 unamended, at the cost of the ordered merge, the server deadline, and any rows at all for a viewer without a live session at that sibling — §2.1 | Paul, 2026-09-05 |
+| 3 | **Reuse the §5.3 assertion for the pull. No new home→instance credential** | A dedicated service token per instance | This session, 2026-09-05 |
+| 4 | **The last good pull is cached in RAM per person, never at rest** | No cache (every load pays cold wake); a cache on disk (stores content) | This session, 2026-09-05 |
+| 5 | **A silent instance renders as a named gap, never as an absent row** | Rendering only what arrived | This session, 2026-09-05 |
+| 6 | **Scoped to the selected team, following `?team=<sqid>`** — home spec ruling 18 | A cross-team merged list, which this design specified and argued for until 2026-09-05; the argument is retired in §2.1 rather than deleted | Paul, 2026-09-05 |
+| 7 | **Participation is a registry flag, never inferred from a `404`** — §10 | Treating a 404 as "does not participate", which makes a broken or hostile instance vanish silently and inverts ruling 5 | This session, 2026-09-05 |
+
+Rulings 3, 4, 5 and 7 are this session's and were flagged as such. Ruling 8
+is Paul's, taken after the third review found an alternative that would have
+needed no amendment at all (§2.1). Ruling 5 is not a courtesy: §2 explains why it is the
+load-bearing test in the document.
+
+---
+
+## 2. Why pull, and what it inherits from a parked design
+
+**This shape was proposed before, in the other direction, and parked.** On
+2026-09-02 Paul proposed `escritorio` — a per-team app aggregating sheets,
+docs, calendar and mail into one list, apps posting entries opt-in, scoped to
+pointers only. Three visibility options were put to a Fable review: **A**,
+the app posts the ACL and escritorio filters locally; **B**, escritorio asks
+each app at render time; **C**, A plus a priority push on revoke. The verdict
+was nine severity-ordered findings, each marked **confirmed** or
+**suspected**, and a **conditional** recommendation — not "park it", which
+was a later paraphrase: *"Do not build escritorio now; if a merged list must
+exist, it is Option B's mechanism without Option B's process."* Paul parked
+it and put the home's identity layer first.
+
+**Findings are quoted with their own confidence marking below.** The verdict
+distinguishes what it verified against a file from what it reasoned to, and
+flattening that distinction is how a citation degrades into a recollection.
+
+**Both sources are in this repo**, because the citation was otherwise
+uncheckable — they existed only as local files in a sweepable cache
+directory, and the verdict only inside a subagent transcript. No discussion
+was opened and no spec was written at the time.
+
+- `docs/superpowers/reviews/2026-09-02-escritorio-review-brief.md`
+- `docs/superpowers/reviews/2026-09-02-escritorio-fable-verdict.md`
+
+**Prefer those files to discussion #6.** #6 is a good-faith reconstruction
+written from a session's private memory before the originals were found; its
+wording is that session's, not Fable's. These two are the contemporaneous
+documents. Quote from them.
+
+The findings that bear on this design, and what happens to each:
+
+| # | The finding | What happens to it here |
+|---|---|---|
+| 1 | ~~**It answered a different question.**~~ **Overtaken by ruling 6: per team is now the shape.** Per-team aggregation gives a person in three teams three lists, which the verdict called the wrong answer and Paul has since ruled the right one — the verdict's second path, not its first. The §5.4 argument below stands on its own and is what still matters. The cross-team list is forbidden at the only place that could hold it — §5.4, *"'Which documents you can see' passes (a) and fails (b), handing the home a document-level graph it has no business holding."* **Ruling 6 took the verdict's SECOND path** — three lists, per team — so what was reopened is §2.2's verb, not §5.4. The verdict's own resolution: *"either that ruling is reopened deliberately or the want is declined."* | **§2.2's verb was reopened deliberately** (rulings 1 and 2, Paul, 2026-09-05). **§5.4 was not** — the cross-team want was declined instead, which is the verdict's *second* path, and ruling 6 is where that landed. §2.1 argues why a rendered page is not a held graph. |
+| 2 | **Titles are first-class sealed content, not cheap pointers.** Sheets §2.1 seals titles under its DEK; a mail "title" is a subject line — *"Re: your termination"*. Any indexer needs Sheets-grade custody. | **Survives, and is ruled through** (ruling 1). The instance decrypts under its own DEK and hands the title over for one render. The home stores nothing, so it never acquires the custody obligation — §3. **Also kills the stale-title leak** the verdict names ("Project Blue" still listed under its old name), which no cached copy can avoid and a pull cannot produce. |
+| 3 | **`visible_to` is not a list, it is the grants join** — team rows plus individual grants minus removals. Either the app fans out one membership change to N item pushes, or the indexer re-implements the rule and drifts, failing as *a plausible empty list, not an error*. | **Dissolved by the inversion.** Nobody posts an ACL because nobody posts. The instance evaluates its own join at read time for the person in the assertion. There is no second evaluator and no copy to drift from. |
+| 4 | **"Seconds" for a revoke is a median with an unbounded tail.** The carrier is *Sheets'* outbox, and Sheets §9.1.1 says *"for a quiet team a failed upsert may wait weeks for an organic wake, which is acceptable precisely because the directory is a hint"* — an argument that does not transfer to a rendered list. | **Dissolved by the inversion.** Revoke is instant because the answer is computed after it, every time. The only residue is the RAM cache TTL — §7. |
+| 5 | **Option B's real costs:** a scriptless page blocking on four cold wakes (*confirmed*), and a visibility endpoint that must not become an enumeration oracle (*suspected*). | **The first is designed for, in the shape the verdict itself pre-approved** — *"not disqualifying if escritorio renders what it has after a deadline and marks the rest 'waking', but that is a design the brief does not describe."* §6 and §8 describe it. **The second is answered in §5.1**, which the first draft of this spec missed. It is treated as real despite being marked suspected, because the cost of the answer is one gate. |
+| 8 | **Blocked on §13 step 4**, not started. | **Still true.** §10. |
+
+**Finding 6 does not evaporate — it argues for §3**, and an earlier draft of
+this spec wrongly filed it as escritorio-specific. Its principle, quoted
+there against escritorio's per-team defence:
+
+> **"Blast radius is bounded by what a service holds, not by how many
+> processes it runs in."**
+
+That is the same move §3's amendment makes, pointed the other way. Custody is
+what counts, not topology — which is exactly why *"never renders"* was never
+the protection and *"never stores"* is. It is the verdict's own principle
+doing this design's work, and it is the one sentence to cite if only one is
+cited.
+
+Finding 7 (guests) is handled in §5.2 and §8. Finding 9's fourth option is
+this design.
+
+**The verdict recommended this mechanism.** Its words: *"a pull-based,
+stateless page owned by one existing app… fetched from the sibling with the
+viewer's identity, fail-soft on cold wake, nothing stored"* — Option B's
+mechanism with the escritorio process deleted.
+
+**The one place this design departs from it**, named because a reviewer who
+knows the verdict will reach for it: the verdict put that page inside an
+existing app, and this puts it at the home. §2.1.
+
+### 2.1 Why the home, and why a rendered page is not a held graph
+
+The verdict's finding 1 said a per-team feed gives "three lists" and answers
+a different question than the one asked. **Ruling 18 accepts three lists**,
+so this design now takes the verdict's *second* path — the want is declined
+in its original form — rather than its first. An earlier draft of this
+section opened by calling finding 1 "the strongest objection available"; it
+is no longer an objection at all, it is the shape.
+
+What still needs answering is §5.4, and home spec §13 step 10 asks for that
+reopening to be revisited rather than left standing.
+
+**Why the home and not an app — re-justified, because Paul's per-team
+ruling retired the argument this section used to make.** That argument was:
+only the home knows every team you belong to, so only the home can answer
+*"my things"* rather than *"this team's things"*. Per-team scope concedes
+exactly that question, so the argument is gone and is recorded here as gone.
+
+What survives is narrower and still uniquely the home's — **and it is the
+credential, not the knowledge.**
+
+An earlier draft of this paragraph said "only the home knows which apps a
+team has instances in", and answered an alternative nobody proposed (Sheets
+holding Docs' grants, which would indeed be federation). Both are wrong. The
+knowledge is not exclusive: home spec §9's call 4b lets an instance learn a
+sibling exists, and the springboard shows the viewer anyway. And the
+verdict's actual recommendation was not grant-holding — it was "a
+pull-based, stateless page owned by one existing app… fetched from the
+sibling with the viewer's identity", which holds nothing.
+
+**That alternative fails on the credential.** "With the viewer's identity"
+means Sheets presenting something at `acme.docs` that speaks for *you* — so
+the home would have to mint an `aud=acme.docs` assertion and hand it to
+Sheets. §11's first row names that exactly: a cross-instance bearer is full
+impersonation, and it is the one thing this design refuses to build. Only
+the credential origin mints, so only a page at the home can hold a
+viewer-bound assertion per sibling without any instance ever holding one for
+another.
+
+**That is not the whole story, and an earlier draft said it was.** There are
+three shapes of "fetched from the sibling with the viewer's identity", and
+only one of them needs a cross-instance bearer. All three are named here,
+because §2.2 is a doctrine clause and amending it without weighing the
+alternative that needs no amendment would be the wrong way to get there.
+
+- **Browser-mediated composition.** The front door renders an
+ `<iframe src="https://acme.docs.oficina.works/activity">` per row the
+ viewer has. `oficina.works` is the registrable domain and the apex is not
+ on the public suffix list, so instance and front door are **same-site** and
+ the instance's own `SameSite=Lax` session cookie rides along. Zero JS, pull
+ at render, nothing at rest — **and the home never touches a title, so
+ §2.2 would stand unamended.** With it would go the RAM cache, §2.1's
+ cross-person residue, the swap and core-dump questions, the one-process
+ question, and gates 11 and 16's parsing surface. **RULED 2026-09-05 by
+ Paul: server-side pull, and amend §2.2.** What the frames cost is the
+ feature: no ordered merge across apps, only per-app panels; no server-side
+ deadline, so a cold instance is an empty frame under a heading rather than
+ ruling 5's named gap — the honesty mechanism replaced by a blank; a viewer
+ without a live session at that sibling gets a sign-in prompt where rows
+ should be, which is most guests and every first visit; a hostile instance
+ draws pixels on the suite's most public page; and §6's guest-wake billing
+ problem survives with **no home-side throttle that keeps rows on the page**
+ — the home authors the markup so it could withhold a frame, but having no
+ rows of its own it can only replace it with nothing. **And every instance
+ would have to relax its framing policy for the apex**: rastrillo sends
+ `frame-ancestors 'none'` and `X-Frame-Options: DENY` by default. The home
+ is not in the request path. Recorded as considered and costed, not as
+ overlooked.
+- **Instance → instance under the caller's own consumer credential**, passing
+ `sub`. Fails, but not on the bearer: it would have `acme.docs` trusting
+ `acme.sheets` for *who you are*, and home spec §5.4 reserves signed
+ identity claims to the credential origin. It also makes `acme.sheets` an
+ oracle over every person ref at `acme.docs`.
+- **A redirect chain** carrying rows. Not composition at all, and it puts
+ titles in URLs — history and referrers — which is exactly why §5.3 keeps
+ assertions out of query strings.
+
+**Per-team is also cheaper on every axis this design worries about.** §6's
+fan-out drops from every instance a person can reach to the apps in one
+team; §7's cache holds less; §5's oracle surface shrinks. The ruling cost
+this design its headline and improved everything else.
+
+**Why §5.4 is not breached.** §5.4 governs **what may ride an assertion into
+the home's custody**, and its two-sided test is about claims the home comes
+to hold. The failing example is a *held* document-level graph. Here:
+
+- **Nothing rides in.** The assertion carries `sub` and theme, exactly as
+ §5.3 already specifies. The activity flows the other way, in a response.
+- **Nothing is held.** §3's amendment and gate 11 are the mechanism, not the
+ intention — a grep proves it.
+- **The home learns nothing durable it did not already know.** It knew which
+ teams in which apps you belong to; that is the directory, §6. It does not
+ learn which documents you can see, because it never asks that question and
+ never records an answer.
+
+**The honest residue**, stated rather than argued away and larger than an
+earlier draft admitted: for the lifetime of one HTTP response, and for the
+RAM cache's TTL, the home's process memory holds a cross-app list of titles
+**for every person who rendered inside that window** — not for one person.
+That is a cross-person, cross-app set of recent titles, resident in the same
+process that mints sessions. §3's amendment lists cross-person aggregation
+as never licensed, and home spec §2.2 forbids the console it would build; this builds it as a data structure while forbidding it as a
+feature, and the distinction only holds because nothing reads across the
+per-person keys. **So the cache is bounded**: a maximum number of person
+entries and a maximum bytes per person, both named in §12, evicting oldest
+first.
+
+**What "in RAM" leans on, named rather than assumed.** Instances hibernate by
+process death (SIGTERM then SIGKILL), so they hold nothing. The home runs
+under a memory cap with reclaim before kill. Two platform facts were NOT
+established when this was checked on 2026-09-05: whether swap is configured,
+and whether core dumps are disabled. **Both must be settled before this
+ships** — a swapped or dumped page of titles is at rest, and §3's amendment
+is about storage, not intent. That is real, it is what
+ruling 1 bought, and it is the thing a reviewer should press on. The claim is
+not that this is nothing — it is that *transient and unreplicated* is a
+different category from *stored and shipped to a bucket*, and that §0's
+falsifiable test is the line that matters because it is the one with a test
+behind it.
+
+### 2.2 Two costs that are not knowledge, and are not covered by §2.1
+
+An earlier draft of this spec defended call 7 with "the same knowledge §6
+already gives the directory, not new knowledge". **That sentence is true and
+beside the point**, and it is retracted here rather than left for a reviewer
+to pull on — when it gives way it takes §3 with it. The accurate form:
+
+> **No new knowledge at the home; new emission to instances, and §9's uniform
+> call direction spent as a detection signal. Both disclosed.**
+
+**(a) A passive map becomes a broadcast.** §6's knowledge sits still today.
+Call 7 makes every front-door render fire an authenticated, timestamped
+request at the selected team's hosts that the viewer has rows in (§6). Each instance thereby learns
+*"this person opened their front door at 09:14:03"* — including on mornings
+they never open that app, and from a host they may have been removed from
+whose directory row has not drained.
+
+§3.3 coarsens published timestamps **to the day**, and says why: "timestamps
+correlate with events observable at instances." Call 7 hands every instance
+in the fleet a fine-grained, signed, unrepudiable version of the signal §3.3
+deliberately blunted. Note the shape: ruling 4 keeps the pepper off instances
+so one compromised instance cannot learn about the whole suite. This is a
+much weaker leak than the pepper, but it is **the same shape** — one instance
+learning about a person's life outside it — and it arrives by design rather
+than by compromise. **Mitigation is thin and named as thin:** the RAM cache
+(§7) collapses repeat renders within its TTL, so the emission is per-TTL
+rather than per-load. Nothing removes it.
+
+**(b) It spends a detection property.** home spec §9's calls, call 4b included, are uniformly
+person→home or instance→home; **zero run outward**. That uniformity is a
+security property rather than an accident of drafting: today a home making
+content-bearing requests to instances is anomalous *by construction*, so such
+traffic is evidence of compromise, visible to instances — the only parties
+positioned to see it. After call 7, an exfiltrating home is indistinguishable
+from a working home rendering front doors.
+
+§3.1's second threat model already concedes a great deal when the live host
+falls. This removes the one signal that would have told anyone it had. It is
+not recoverable by a mitigation in this spec; an instance-side anomaly rule
+(call 7 arriving for a person with no recent session) is the only lever, and
+it belongs to the instances, not here.
+
+**Neither cost is a reason not to build it** under rulings 1 and 2. Both are
+reasons the review pass (§12) should be run by someone who has read this
+section.
+
+**This is not escritorio revived.** No new app, no new process, no new store,
+no new repo. It is §11's front door — *"one page: a grid of apps, with a team switcher as
+a dropdown in the header"* (ruling 17) — growing a recency section.
+
+---
+
+## 3. The two clauses that change
+
+Nothing new goes at rest, so §0's **falsifiable** form is unamended —
+publish the database and you still learn only counts, team names,
+memberships and §3.2's list. **Its prose form does move**, and saying so is
+the honest version: "it never learns what is in anything" now has to be read
+as *never retains*. That reading belongs in §0's own gloss rather than left
+for the two documents to disagree about.
+
+§2.2's first never-do changes, and only its verb:
+
+> ~~**Never renders content from an instance.** No previews, no thumbnails,
+> no cell values.~~
+>
+> **Never *stores* content from an instance.** No previews at rest, no
+> thumbnails, no cell values, no titles in a database, a log, an error
+> string, a metric label or an on-disk cache. It **may render**, transiently
+> and per request, **and only to that person**, what an instance hands it for
+> the person it hands it for.
+
+**On approval, seven places change, listed so nobody hunts:** this design's
+status block and §12's first bullet; home spec §0's gloss and §2.2's verb
+with its "until then" note; §11's "No activity feed yet"; §13 step 10's
+"must be revised"; §15's open bullet; and `AGENTS.md`'s "The one rule", which
+quotes §0 verbatim, and its four-things list. The same edit lands in `AGENTS.md`'s "four things this service never does" —
+**on approval, not now.** That file is binding and says the spec wins where
+they disagree, but a binding file must not be rewritten to follow a spec
+whose review has not run (§12). Until then, `AGENTS.md` is correct as written
+and this document is a proposal against it.
+
+**Why the amendment is small rather than a hole in the doctrine:**
+
+- **The escritorio verdict's own principle says custody is the thing**, not
+ topology: *"blast radius is bounded by what a service holds, not by how
+ many processes it runs in"* (finding 6, quoting home §2). It was written to
+ deny a per-team process the defence that its boundary made its holdings
+ safe. Read forward, it is the argument for this edit: if what a service
+ *holds* is what bounds the damage, then a clause about *rendering* was
+ never the protection, and one about *storing* is.
+- **The exposure is bounded by where it lands.** The front door is on the
+ **directory origin** (`oficina.works`), which already renders
+ account-authored strings — team names — and already escapes them. A title
+ is the same class of string arriving by a different route. §4's split
+ holds: **the credential origin renders none of this**, and gate 5 already
+ asserts it.
+- **"Renders" was doing custody's work.** The original clause was written to
+ stop the home *becoming a place documents live* — a support console, a
+ preview cache, an index. Storage is what makes it that. A string that
+ exists for the length of one HTTP response, in RAM, addressed to the one
+ person entitled to it, does not.
+- **It is testable.** "Never stores" has a grep behind it (home spec §12, gate 1).
+ "Never renders" had no test at all, which is a fair sign it was a
+ statement of intent rather than a mechanism.
+
+**What the amendment does not license**, recorded so it is not read wider
+than it is: no search over pulled content, no cross-person aggregation, no
+analytics, no operator view, no on-disk cache, no retry queue holding a
+response body. §11.
+
+---
+
+## 4. Call 7 — the activity pull
+
+The next call in §9's contract, and **the first that runs home →
+instance**. Every existing call is instance → home or person → home. That
+direction change is the main thing an implementer needs to hold in mind.
+
+```
+GET https://<team>.<app>.oficina.works/activity?limit=10&since=<rfc3339>
+Authorization: Bearer <assertion>
+Accept: application/json
+```
+
+**Request.** `limit` is advisory and the instance caps it. `since` is
+advisory and lets a warm instance answer cheaply; an instance that ignores it
+is still correct.
+
+**Response**, `200`:
+
+```json
+{
+ "items": [
+ {
+ "title": "Q3 Budget",
+ "url": "https://acme.sheets.oficina.works/s/9fk2",
+ "verb": "edited",
+ "actor": "Ana",
+ "at": "2026-09-04T09:12:00Z"
+ }
+ ],
+ "truncated": false
+}
+```
+
+| Field | Meaning |
+|---|---|
+| `title` | The object's display title, decrypted by the instance under its own DEK. The home renders and discards it. |
+| `url` | Absolute, and **on the answering instance's own host** — the home rejects any other, or an instance becomes an open redirector through the front door. |
+| `verb` | A **closed enumerated set**: `created`, `edited`, `commented`, `shared`. Anything else renders as `changed`, per §5.4's rule that a failed match is treated as *absent*, never as the raw value. |
+| `actor` | A display string the instance chooses. **Never an address** — the home does not hold addresses (§3.2) and must not receive one. An instance with no display name for the actor omits the field, and the row renders without an attribution. |
+| `at` | RFC 3339, from the instance's clock. Used for ordering only. |
+| `truncated` | The instance had more and capped. Rendered as "more in Sheets", never silently dropped. |
+
+**A hard body cap, with its number carrying its reason in a comment** —
+§9's contract item 6, which the home spec's first draft failed to adopt. The
+home reads at most that many bytes and treats an overrun as a failed pull
+(§7), not as a truncated parse. An instance is a consumer-supplied endpoint;
+a front door that will read an unbounded body from one has a memory-exhaustion
+bug on its most public page.
+
+**Empty is a first-class answer.** `{"items":[]}` with `200` is what a person
+with no grant gets, and what a quiet team gets. It is **not** what a broken
+team gets — see §7, which is the entire point of ruling 5.
+
+---
+
+## 5. Authentication, and why it needs nothing new
+
+**Who mints, and where the key is.** Home spec §4 puts assertion minting on
+the **credential origin**, and the front door is on the **directory origin**,
+which obtains assertions like any other consumer. The fan-out therefore mints
+N assertions per render from a page that is not the minting origin. Say which
+this is: **one process serving two hostnames**, so the signing key's exposure
+is unchanged from §5.3 and no cross-origin mint call exists. **Nothing on
+main establishes that today** — AGENTS.md says one binary, and §5.3 gives the
+directory origin its own `jti` store as a verifier — so §12 carries it as a
+thing to settle. It is load-bearing: split the origins and the directory
+needs a mint call and a different key story — and note the
+consequence, that the directory origin is now also the surface parsing N
+consumer-supplied JSON bodies per render (gate 16).
+
+**The pull carries a §5.3 assertion, unchanged.** Ed25519, `aud` bound to the
+answering instance's app and host, `sub` the person ref, `iss` the credential
+origin, `kid`, 120-second expiry, single-use `jti`.
+
+The only difference from the existing flow is transport: it rides an
+`Authorization` header on a server-to-server request instead of an auto-POST
+through the browser.
+
+**Why that transport swap is safe, specifically.** An earlier draft claimed
+"every property §5.3 relies on is a property of the token, not of how it
+travelled." **That is false, and §5.3's own last line is the counter-example:**
+"Delivery is an auto-POST, not a query string, so assertions stay out of edge
+logs, browser history and referrers" (the pre-2026-09-05 wording; main now
+reads "browser history and referrers") — a property of travel, not of the
+token. The conclusion survives on the specific argument rather than the
+general one: an `Authorization` header achieves the same two things an
+auto-POST still claims — its third leg, edge logs, having been removed: §5.3's "edge logs" reason was
+retracted on main on 2026-09-05, since no platform request log carrying
+paths was found. There is no browser involved so there is no history, and there
+is no `Referer` on a server-to-server call. **What must never happen is the
+assertion moving into the query string** — the failure §5.3 was guarding
+against, which a header avoids for the same reason a POST body does.
+
+**Why not a service token.** A per-instance home→instance bearer would be a
+further credential to mint, store, rotate, revoke and leak, and it would
+prove only that the caller is the home — not *which person the call is for*.
+The assertion proves both in one artifact the suite already verifies. Ruling
+3.
+
+**The instance decides capability entirely from its own grants** — §5.5,
+verbatim and unmodified. A valid assertion for a person with no grant on that
+instance returns an empty list. **This is the answer to §2's objection 2**,
+and it is an answer precisely because the home does not participate in it.
+
+**Per-verifier `jti` stores stay sufficient.** Audience binding means an
+activity assertion for `acme.sheets` is not valid at `bigco.sheets`, so no
+shared store is needed — §5.3's reasoning carries over untouched.
+
+**Rate limiting is the instance's.** The home is one caller and a
+well-behaved one, but an instance must not assume that; a compromised home is
+in scope for every instance's own limits.
+
+### 5.1 It must not become a visibility oracle
+
+The escritorio verdict's finding 5 named this against Option B, whose
+endpoint answered *"of these refs, which may person P see?"* — a shape that
+turns into an enumeration oracle the moment its caller's token leaks. **The
+first draft of this spec did not address it.**
+
+Call 7 is a narrower shape, and the narrowness is deliberate:
+
+- **The caller supplies no refs.** It asks "what is this person's, recently?"
+ and the instance chooses the rows. There is no set to probe with.
+- **The subject is not caller-chosen.** It rides a signed, audience-bound
+ assertion the home mints. Forging one means holding the credential
+ origin's Ed25519 private key, at which point the suite has lost anyway.
+- **No grant and no such person are the same answer** — `200` with an empty
+ list. An instance must not distinguish them, or the endpoint answers
+ membership questions about arbitrary person refs. **Gate 18.**
+- **Errors carry no detail.** "Not for you" and "no such thing" are one
+ response, per §12's gate 4 pattern of 404 rather than 403.
+
+**What a leaked home does yield**, said plainly: an attacker holding the
+signing key can mint an assertion for any person ref and read that person's
+recent titles from any instance they have a grant on. That is strictly less
+than what the same key already buys — it can mint a *session* at those
+instances — so call 7 widens no boundary. It is not a new hole; it is inside
+an existing one, and §2.1 of the home design already prices a compromised
+home.
+
+### 5.2 Guests
+
+A guest — `kind: guest` in the directory (§6) — has a person ref and a grant,
+so they get a front door and their guest teams appear on it. This needs no
+special case, which is itself the argument for the design: the instance
+answers from its own grants, and a guest's grants are exactly the rows it
+returns.
+
+What a guest sees is already disclosed: §6 rules that a guest grant discloses
+the granting team's name, "in the sharing app's share dialog, in words". No
+new disclosure is created here.
+
+**A link-share viewer has no person ref** (Sheets §8.3 — "a view link mints
+nothing"), so they have no front door and no rows. Correct by construction.
+
+---
+
+## 6. Fan-out, and the cost this design pays
+
+**The viewer's own directory rows in the selected team — not the team's
+apps.** The distinction is a leak, not a nicety, and an earlier draft of this
+line got it wrong. Home spec §11: "Tiles come from the viewer's own rows,
+never from the label… a guest in `acme.docs` would learn that Acme also uses
+Sheets, which is another person's row and what gate 4 forbids." Fanning out
+to the team's apps would ask `acme.sheets` on that guest's behalf, and
+ruling 5 would then render "acme · sheets — waking" on their front door —
+disclosing the very thing §11 forbids, through the honesty mechanism. §8.1
+already had this right; §6 and §2.1 did not.
+
+So a member of a team in three apps is three hosts; a guest with one row in
+that team is one host. The home fans out in
+parallel, with a per-request deadline, and renders what arrived.
+
+**Zero JS (§11).** One server-side fan-out, no progressive loading, no
+skeleton that fills in. The page is complete when it is sent.
+
+**The client is hardened explicitly, because Go's defaults are wrong for
+this.** `http.DefaultClient` has no timeout and follows redirects. Required:
+the deadline is a **context covering connect, TLS, headers and the full body
+read**, not just the response headers; **redirects are never followed** — a
+3xx is a failed pull, because an instance redirecting to a document URL would
+otherwise put that URL inside a `net/http` error string, which §3.4 of the
+home spec rules is storage; TLS verification on; and **bounded concurrency
+across all in-flight renders**, so one instance trickling bytes cannot
+exhaust the process that also serves sign-in (home spec §2.1 makes it the
+suite's single point of failure).
+
+**The cost, stated rather than buried: instances hibernate and the home does
+not.** §2.1 makes the home the one app that never sleeps; every instance is
+the opposite. §5.3 sizes assertion expiry at 120 seconds precisely because a
+cold wake is "process start plus decrypt plus migrate-check" and "can exceed
+a few seconds". So:
+
+- **The first front-door load of the day will mostly miss its deadline.** Not
+ an edge case — the normal morning.
+- **Every load warms the instances the viewer has rows in**, which changes the
+ hibernation economics the platform assumes — and **instance-hours are
+ billed to the instance's account, not to the person opening the door**. A
+ guest with one row in `acme.docs` would otherwise wake Acme's instance on
+ every front-door load, at Acme's expense. So there is a **minimum pull
+ interval per (person, instance), enforced even on an explicit refresh**;
+ the interval starts on a **completed** pull, not on an attempt, so a timed
+ out instance can be retried immediately — otherwise the refresh affordance
+ would be dead during exactly the window it exists for.
+
+**Three timers, and which governs.** The **minimum pull interval** decides
+whether a pull may be attempted at all. Within that, the **refresh TTL**
+decides whether the home bothers. The **hard maximum age** (§7) overrides
+both: past it the rows go, whatever the other two say.
+
+Neither the cold-wake latency nor the wake economics is fixed here. Both are **mitigated and disclosed**:
+
+- The RAM cache (§7) makes every load after the first instant.
+- The waking state is rendered honestly (§8), so a slow first load is legible
+ rather than mysterious or wrong.
+
+**A fan-out budget with no specified behaviour is not a budget.** §12 lists
+one as an open number. The number can wait; **what happens at the limit
+cannot**, because an un-asked instance must render as its own kind of gap or
+ruling 5 is breached silently — the person cannot tell "nothing to say" from
+"never asked". Un-asked instances render as a distinct gap with a refresh
+affordance, and the budget is per render, not per person.
+
+**Two absences, and they are not the same.** An app the registry does not
+mark as participating (§10) is **never asked and renders nothing at all** —
+it is not part of this feature. An instance dropped by the budget **would**
+have been asked and renders the distinct gap. A builder needs both sentences
+or they will collapse into one.
+
+**The named escape hatch**, if the morning experience proves miserable in
+use: the rejected hybrid — instances push a *contentless* ping (person ref,
+app, team, coarse timestamp, no title, no object ref) so the home knows which
+instances have news and wakes only those. It bounds the fan-out to instances
+that actually have something to say, at the cost of a small at-rest activity
+table the home does not have today. **Trigger: the first real complaint about
+front-door latency, or a measured p50 over one second on a warm fleet.** Not
+built now, because it adds custody to solve a problem we have not yet
+measured.
+
+---
+
+## 7. The cache is in RAM, and that is load-bearing
+
+Ruling 4. The home keeps, per person, the last successful pull per instance,
+in process memory, with a short TTL.
+
+- **Never at rest.** Not SQLite, not a file, not a platform cache, not an
+ HTTP cache header that would let an intermediary keep it. §3's amendment
+ permits rendering, not storing, and a disk cache is storing.
+- **The platform edge is a shared cache upstream of both ends, and it must
+ be closed on both.** It caches any 2xx GET whose response carries a
+ positive `max-age`, keyed on host, path and query, shared across
+ every visitor of a host — and it does **not** refuse on a request
+ `Cookie`. So: the front door sends `Cache-Control: no-store` (the home
+ spec's gate 10), **and call 7's contract requires instances to send
+ `no-store` too**. An instance answering `public, max-age=60` would have
+ the edge serve person A's rows to the home's next pull for person B with
+ the same query string, and the home's own cache key — person ref and
+ instance host — cannot see that happen. **A cacheable response is
+ therefore a failed pull**: the home discards it and renders a gap. Gate 16
+ covers it.
+- **Precedent:** §3.2 already handles readable addresses "transient in RAM
+ elsewhere". This is the same category, and it is the only category the one
+ rule leaves open.
+- **A restart empties it**, and that is correct rather than unfortunate.
+- **`since` comes only from this cache, and is never persisted.** A future
+ "remember the last pull per (person, instance) across restarts" is the
+ contentless activity table §11 forbids, arriving as an optimisation rather
+ than as a decision.
+- **Keyed by person ref and instance host**, so one person's cache can never
+ render on another's page — the same isolation gate 4 already asserts for
+ the directory.
+
+**Two ages, not one, because an earlier draft contradicted itself.** It said
+the TTL bounds staleness "by a number we choose, not by a hibernating fleet's
+wake schedule", and then said a failed pull never evicts a good cached
+answer. Both cannot hold: if failure preserves rows, a person removed from a
+team keeps seeing its titles until that instance next *answers*, which is
+exactly the hibernation tail the first sentence disclaims.
+
+- **Refresh TTL** — how long before the home tries again. Short.
+- **Hard maximum age** — after which rows are **discarded regardless of pull
+ outcome**, failure included. This is the one that bounds the lie, and it is
+ the number the removed-from-a-team case turns on.
+
+**A failed pull does not evict a good cached answer that is still inside the
+hard maximum.** Past it, the rows go and the gap is rendered (§8), because
+"here is what I knew" stops being honest once "when I knew it" is old enough
+that the grant may be gone. Gate 20 drives the hard maximum, not the refresh
+TTL — a test against the refresh TTL proves the easy half.
+
+---
+
+## 8. What the front door renders, including when it cannot
+
+home spec §11 is now one page — a grid of apps for the selected team, with a team switcher in the header (ruling 17). This adds a recency
+section above or beside it — **layout is the front-door session's call, not
+this spec's.**
+
+Every row: title, app, team, verb, actor if known, relative time, linking to
+the instance's own URL. Merged across the viewer's own instances in the selected team,
+ordered by `at` descending,
+capped.
+
+**And then the part that matters.** Ruling 5:
+
+> **An instance that did not answer must be visually distinguishable from an
+> instance with nothing to say.** A quiet team and a broken team must never
+> render the same.
+
+This is the direct answer to the failure mode Fable named — *a plausible
+empty list, not an error*. A feed that silently drops what it could not fetch
+is the worst available shape, because it looks exactly like a slow week and
+nobody files a bug against a slow week.
+
+So each instance that failed to answer gets a row of its own, in words:
+
+- `acme · sheets — waking, refresh` (deadline missed; likely cold)
+- `acme · calendar — unavailable` (error, refused, or unparseable)
+- `acme · sheets — showing what we had at 09:14` (cache served, live pull failed)
+
+Each with a refresh link, which is a plain `GET` and works with scripts off.
+
+**And when nothing at all arrives**, the page says so — *"we couldn't reach
+your apps just now"* — rather than rendering an empty feed. The empty state
+and the everything-failed state are different pages, and a person can tell
+which one they are looking at.
+
+### 8.1 A named gap discloses nothing, including to a guest
+
+Ruling 5 is the most visible thing in this document, so the objection it
+attracts is answered here rather than left to be derived. **A named gap
+renders a team and app name — does that leak, particularly to a guest?**
+
+The escritorio verdict's finding 7 (*suspected*) flags that guests fit no
+option cleanly: a guest must see their one item with no hint the team's other
+apps exist. Against ruling 5 that looks alarming. It is not:
+
+- **The fan-out set is the person's own directory rows** (§6), so the home
+ only ever names a team and app that person already has a row for.
+- **Call 3 already hands them the same list directly** — "every team across
+ every app, with `kind`". A named gap discloses strictly nothing the
+ directory would not answer if asked.
+
+So the gap is bounded by the directory, and the directory is the thing the
+front door is *for*.
+
+**One consequence to expect rather than fix.** The directory is a hint with a
+drain lag (§6), so the home will sometimes call an instance the person was
+removed from weeks ago, or one whose instance was deleted (home spec §9's call 2, second
+event). §5.5 makes that safe — the answer is an empty list, or a 404 — but
+ruling 5 then renders a named gap for a team they have left, or an
+"unavailable" for a host that no longer exists.
+
+**That is a wrong-looking page with a correct mechanism behind it**, which is
+precisely the shape that gets bug-reported and then "fixed" by widening
+something. It is recorded here so the fix, when someone reaches for it, is
+the right one: **prune the stale directory row**, never suppress the gap.
+Suppressing gaps is ruling 5 in reverse.
+
+---
+
+## 9. Testing gates
+
+Numbered from the home spec's **ten**. `main` has gates 1-10, the tenth
+being no-store (ruling 22), so these start at 11.
+
+11. **No pulled content at rest — driven through the failure paths, not the
+ happy one.** A full front-door render against seeded instances leaves no
+ title, URL or actor string in the SQLite file, the WAL, any log line,
+ error string, metric label or on-disk artifact. Extends gates 1 and 9;
+ gate 1's corpus grows a set of known titles.
+
+ **The happy path is the exception here, so testing it is close to
+ testing nothing.** §6 says the first load of the day will mostly miss its
+ deadline, which makes failure the normal morning. And the natural Go
+ spelling of a failed parse —
+ `fmt.Errorf("parsing activity from %s: %w", body, err)` — puts the entire
+ response body, every title in it, into an error string, which §3.4 rules
+ is storage. A gate that renders only well-formed responses stays green
+ while exactly that ships.
+
+ So the gate drives **canary titles through every failure path**:
+ malformed JSON, a body over the cap, a bad `at`, a wrong content type, a
+ `url` on a foreign host, a connection reset mid-body, and a timeout. The
+ canary must appear in none of the places above. This is the class gate
+ 9's own review turned up three of, and each one was a guard that looked
+ complete.
+12. **A silent instance is a visible gap** — timeout, empty body, **and a `404` from an app that DECLARED participation** (§10's registry flag), which must render as a gap rather than as nothing. With one instance timing out and
+ one returning empty, the render distinguishes them. This gate fails if
+ both produce the same output. **The most important test in this
+ document.**
+13. **The activity assertion is audience-bound.** One minted for
+ `acme.sheets.oficina.works`, replayed against `bigco.sheets.oficina.works`,
+ fails. Extends gate 3.
+14. **Two-person isolation on the pull.** Person A's assertion returns only
+ A's rows; a cached answer for A never renders on B's page. Extends
+ gate 4.
+
+ **And the guest case, which is the one that bites.** A guest with a row
+ in `acme.docs` only: assert the render fans out to `acme.docs` and to
+ **nothing else in that team**, and that no gap naming `acme · sheets`
+ appears. Fanning out by the team's apps rather than the viewer's rows
+ would disclose that Acme uses Sheets through ruling 5's honesty
+ mechanism — home spec §11's gate 4 concern, arriving by the back door.
+
+15. **The credential origin renders no pulled content.** Extends gate 5.
+16. **A hostile instance response cannot hurt the front door.** Oversized
+ body (cap enforced), a `url` on another host (rejected), an unknown
+ `verb` (renders `changed`), HTML in `title` and `actor` (escaped), a
+ malformed `at` (row dropped, instance marked, page still renders), **a
+ cacheable response** (`public, max-age=…` — discarded and rendered as a
+ gap, §7), and **a redirect** (never followed, treated as a failed pull,
+ §6). An instance is a consumer-supplied endpoint and this page is the
+ suite's most public surface.
+17. **Scriptless.** The recency section and every refresh link work with
+ scripts stripped. Extends gate 6.
+18. **Not an oracle** (§5.1). A person ref with no grant and a person ref
+ that does not exist produce byte-identical responses from an instance.
+
+19. **One assertion, one host.** The fan-out mints a separate assertion per
+ instance, and a test asserts that no two requests in one render carry the
+ same `aud`. §11's first row is the reason this is a gate and not a note.
+
+20. **The hard maximum age is enforced through failure.** Seed a cached
+ answer, make the instance fail, advance past the hard maximum, and
+ assert the rows are gone and a gap is rendered — not that they survive
+ because the pull failed. A test against the refresh TTL proves the easy
+ half; this is the half that bounds the lie (§7).
+
+---
+
+## 10. Sequencing, and what blocks it
+
+**Blocked, and not marginally.** Call 7 needs:
+
+- **§13 step 3** — assertion minting. The pull has no credential without it.
+- **§13 step 4** — the directory and person refs. The home cannot know which
+ instances to ask, and `sub` has nothing to carry.
+
+Step 2 (sign-in) has a reviewed plan on main at `docs/superpowers/plans/2026-09-03-home-signin.md` (`b92c9b8`) and cannot begin
+until `HOME_DEK` is minted, which only Paul can do.
+
+**This is spec-now, build-later, and the slot is after step 4** — a step 10 in
+§13's list. It has no claim on anything earlier, and it must not acquire one:
+the identity layer is the suite's critical path and this is a front-door
+nicety.
+
+**Consumers.** Call 7 is implemented by every instance that wants to appear.
+Sheets is the near-term one — §10.2 there is already a recency list, so the
+endpoint is a projection of a query it has. Docs likewise.
+
+**The projection is not free, and the endpoint should say so.** Sheets §2.1
+seals titles under its DEK, so answering call 7 decrypts once per row rather
+than reading a column. That is a real cost on a list query and it is the
+argument for the instance capping `limit` hard and low — the front door wants
+a handful of rows per app, not a page of them.
+
+**Participation is declared, not inferred.** An earlier draft said a `404`
+from an instance means "this app does not participate", rendered as nothing.
+That is ruling 5 exactly backwards: a participating app that loses its route
+in a deploy, or a hostile one, would vanish silently from every front door —
+"a plausible empty list, not an error", which is the failure ruling 5 exists
+to prevent. `404` is already overloaded too: §5.1 uses it for "no such
+thing" and §8.1 for a dead host rendered *unavailable*.
+
+So participation is **a flag in the home's compiled-in app registry** (home
+spec §11), beside `provisionable` and `integrated`. An app that does not
+declare it is never asked. An app that declares it and then answers `404`
+is **a gap**, rendered as one, and gate 12 covers that case.
+
+---
+
+## 11. Deliberately not built
+
+| Thing | Trigger |
+|---|---|
+| **One assertion reused across the fan-out** | **Never.** Written here because someone will propose it in good faith, citing §6 — six signatures on a latency-critical path is exactly the optimisation §6 creates the incentive for. Ruling 3 is safe *only* because `aud` is app-plus-instance-host and §5.5 makes each instance decide from its own grants. An assertion valid at every host in a person's fleet is a cross-instance bearer, which is full impersonation everywhere they have a grant. Ed25519 signing is microseconds; the latency is the network and the cold wake. Gate 19. |
+| The contentless-ping hybrid (§6) | A real latency complaint, or measured p50 over one second warm |
+| Any at-rest store of pulled **content** — titles, URLs, actors | Never, under §3's amendment as written. Changing this reopens §0. |
+| Any at-rest **contentless** activity table | Only as §6's escape hatch, on §6's trigger. It is new custody — a per-person, per-team timestamp series, which is §3.3's de-anonymisation vector at fine resolution — so it is a decision, not a performance tweak. |
+| Search across the feed | Never at the home. `memoria` is where cross-corpus search lives, and it has its own custody story. |
+| Filtering by app, team, person, or date | Someone asks. It is rendering, not custody, so it is cheap — but the front door is a front door. |
+| Notifications, unread counts, marking read | Any of these needs per-person read state at the home, which is new custody. Argue it separately. |
+| Calendar and mail as sources | `oficina/calendar` has a repo and no spec; `oficina/mail` has neither. And the structural reason, not just the missing spec: an `integrated: false` app verifies no §5.3 assertion (home §11, §14), so it cannot answer call 7 at all. |
+| An operator view of anyone's feed | Never (§2.2, §0). |
+
+---
+
+## 12. Open
+
+- **The review pass.** AGENTS.md requires the highest-capability model
+ available, recorded by name. **Run — four passes by Fable 5.1, the fourth
+ approving.** The three that returned CHANGES NEEDED are summarised in the
+ status block. What remains open below is design, not review.
+- ~~`AGENTS.md` is stale against the home spec on three counts.~~
+ **Closed 2026-09-05:** `main` carries all three retractions at `5a42f2d`,
+ and `AGENTS.md` there is current.
+- **Numbers.** The per-instance deadline and the body cap are tunables with
+ specified behaviour at the limit, and can be chosen from measurement. Three
+ are **not** merely tunables and must be settled before this ships:
+ - the **hard maximum cache age** (§7), which is what bounds how long a
+ person removed from a team keeps seeing its titles — the refresh TTL
+ does not;
+ - the **cache bounds** (§2.1): maximum person entries and maximum bytes per
+ person, since the cache is cross-person;
+ - the **minimum pull interval** per (person, instance) (§6), which is what
+ stops a guest's front door billing someone else's account;
+ - the **refresh TTL** (§7), which home spec §15 lists as open here and
+ which the earlier draft of this bullet dropped from both lists.
+
+ The fan-out budget's *number* can wait; its behaviour at the limit is
+ specified in §6 and is not open.
+- **Whether the credential and directory origins are one process.** §5
+ assumes they are, and the signing-key story depends on it. Nothing on
+ main says either way.
+- **Two platform facts** the "in RAM" claim leans on and which were not
+ established: whether swap is configured, and whether core dumps are
+ disabled. Both must be answered before this ships (§2.1).
+ , not a guess in a spec.
+- **Front-door layout** is the other session's. This spec deliberately does
+ not draw it.
+- **Whether `actor` is worth having at all.** It is the field most likely to
+ be absent in practice (an instance needs a display name it may not hold),
+ and a feed of "Q3 Budget — edited" reads fine without it.