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. The inversion is the design. It dissolves the two objections that parked this shape before — a per-item ACL nobody can express as a value, and a revoke tail measured in weeks — because the app that owns the grants join answers the question about the grants, live, every time. Only one clause of the home design changes, and only its verb: §2.2's "never renders content from an instance" becomes "never stores". §0's falsifiable claim is untouched, because nothing new goes at rest. Ruled by Paul, 2026-09-05: a real feed with titles rather than a contentless heat map, and pull rather than push. Three further rulings are this session's and marked as such. The cost is disclosed rather than buried: instances hibernate and the home does not, so the first front-door load of the day will mostly miss its deadline, and every load warms the person's fleet. Mitigated by a RAM-only cache and by rendering the waking state honestly — which is also the load-bearing test here, since a feed that silently drops what it could not fetch looks exactly like a slow week. Not approved: AGENTS.md requires a review pass by the highest-capability model available and it has not run. AGENTS.md is therefore left as written — a binding file must not follow an unreviewed spec.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 file changed,
+416
−0
docs/superpowers/specs/2026-09-05-activity-pull-design.md+416 −0
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..77531c8 |
| --- /dev/null |
| +++ b/docs/superpowers/specs/2026-09-05-activity-pull-design.md |
| @@ -0,0 +1,416 @@ |
| +# 🤖 Activity on the front door — design |
| + |
| +**Status:** design, 2026-09-05. Drafted by the activity-stream session |
| +(`claude-opus-5`). **Review pass outstanding** — AGENTS.md requires the |
| +highest-capability model available, recorded here by name, before this is |
| +approved. It has not run. Do not merge on the strength of this line. |
| +**Scope:** how the home's front door (§11 of the home design) shows a person |
| +what has been happening across their teams and apps, 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; one clause of it changes, 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 a person's instances "what's theirs, recently?" when it renders |
| +their front door, merges the answers, and holds them nowhere but memory (§7). |
| + |
| +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. |
| +- **Only one clause changes** (§3), and it changes from *never renders* to |
| + *never stores*. |
| + |
| +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 | |
| +| 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 | |
| + |
| +Rulings 3, 4 and 5 are this session's and were flagged as such when the |
| +design was approved. 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. A Fable review returned REQUEST CHANGES on five counts and |
| +Paul's call was to park it and build the home's identity layer first. |
| + |
| +**Provenance, stated because it is weaker than it looks.** Two sessions |
| +independently reported that review, in agreeing detail. The primary record |
| +was not findable: it is not a discussion on `oficina/home`, `oficina/sheets` |
| +or `oficina/docs`, and not a markdown file in the `oficina` tree. |
| +Corroboration is indirect — `memoria`'s README lists `escritorio` and |
| +`plausible empty list` among the phrases you can search *in the suite's own |
| +specs*, which is where both sessions' language came from. **Treat the five |
| +findings as real and the citation as unverified.** If the record surfaces, |
| +check this section against it. |
| + |
| +The five, and what this design does with each: |
| + |
| +| # | The objection | What happens to it here | |
| +|---|---|---| |
| +| 1 | **Titles are sealed content, not pointers.** Sheets §2.1 seals sheet titles under its DEK; a mail "title" is a subject line. The source app cannot hand one out in the clear either. | **Survives, and is ruled through** (ruling 1). The instance decrypts under its own DEK and hands the title to the home for one render. The home never stores it, so it never needs Sheets-grade custody — §3. | |
| +| 2 | **A per-item ACL is not expressible by the poster.** Sheets access is a grants join — team rows plus individual grants minus removals — so no app can post "who may see this" as a value. A consumer re-evaluating the rule drifts, and fails 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 named in the assertion. There is no copy to drift from. | |
| +| 3 | **Push-based revoke has a weeks-long tail.** The outbox in §6 is the *instance's*, and drains on wake and on share mutation only. Tolerable for a hint; not for a rendered list. | **Dissolved by the inversion.** Revoke is instant because the answer is computed after the revoke, every time. | |
| +| 4 | **It answered a different question.** Per-team aggregation gives a person in three teams three lists; the cross-team list is forbidden at the only place that could hold it (§5.4 — "handing the home a document-level graph it has no business holding"). | **Answered, differently.** The cross-team list exists only as a rendered page, assembled per request and discarded. §5.4 governs what may *ride an assertion* into the home's custody; nothing rides in and nothing is held. §3 covers the clause that does change. | |
| +| 5 | **Blocked on person refs** from §13 step 4, not started. | **Still true.** §10. | |
| + |
| +**The review's own recommendation was this shape**: *a stateless pull inside |
| +an existing app, not a new process posting to a central store.* This design |
| +is that, with one difference worth naming — the review imagined the pull |
| +living inside an existing app, and here it lives in the home, which is the |
| +only place that knows all of a person's teams. That is the same knowledge |
| +§6 already gives the directory, not new knowledge. |
| + |
| +**This is not escritorio revived.** No new app, no new process, no new store, |
| +no new repo. It is §11's front door — *"your teams grouped by app, and a New |
| +button"* — growing a recency section. |
| + |
| +--- |
| + |
| +## 3. The one clause that changes |
| + |
| +Nothing new goes at rest, so **§0 is unamended**. Publish the database and |
| +you still learn only counts, team names, memberships, and §3.2's list. |
| + |
| +§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, what an instance hands it for the person it hands it for. |
| + |
| +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 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 (§9 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 seventh 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 |
| + |
| +**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. Every property §5.3 relies on is a property of the |
| +token, not of how it travelled. |
| + |
| +**Why not a service token.** A per-instance home→instance bearer would be a |
| +seventh 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. |
| + |
| +--- |
| + |
| +## 6. Fan-out, and the cost this design pays |
| + |
| +A person in three teams across two apps is six hosts. 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 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 person's whole fleet**, which changes the |
| + hibernation economics the platform assumes. A person who opens the front |
| + door twenty times a day keeps six instances awake. |
| + |
| +Neither 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. |
| + |
| +**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. |
| +- **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. |
| +- **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. |
| +- **The TTL is short enough that a stale row is a curiosity, not a lie.** A |
| + person removed from a team keeps seeing that team's last rows until the TTL |
| + expires. That is the one residue of objection 3 and it is bounded by a |
| + number we choose, not by a hibernating fleet's wake schedule — which is the |
| + difference between minutes and weeks. |
| + |
| +**A failed pull never evicts a good cached answer.** It renders the cached |
| +rows *plus* the failure marker (§8), because "here is what I knew, and I |
| +could not reach them just now" is more useful and more honest than either |
| +silence or an empty list. |
| + |
| +--- |
| + |
| +## 8. What the front door renders, including when it cannot |
| + |
| +§11 stands: your teams grouped by app, and a New button. 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 instances, 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) |
| +- `bigco · docs — 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. |
| + |
| +--- |
| + |
| +## 9. Testing gates |
| + |
| +Numbered from §12's nine. |
| + |
| +10. **No pulled content at rest.** 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. |
| +11. **A silent instance is a visible gap.** 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.** |
| +12. **The activity assertion is audience-bound.** One minted for |
| + `acme.sheets.oficina.works`, replayed against `bigco.sheets.oficina.works`, |
| + fails. Extends gate 3. |
| +13. **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. |
| +14. **The credential origin renders no pulled content.** Extends gate 5. |
| +15. **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). An |
| + instance is a consumer-supplied endpoint and this page is the suite's |
| + most public surface. |
| +16. **Scriptless.** The recency section and every refresh link work with |
| + scripts stripped. Extends gate 6. |
| + |
| +--- |
| + |
| +## 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 plan on `docs/step-2-signin-plan` 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. **Not appearing is |
| +a valid state**: an app that has not implemented `/activity` simply |
| +contributes no rows, and the front door renders the rest. There is no |
| +registration step and no flag; a `404` from an instance is "this app does not |
| +participate", rendered as nothing rather than as a failure. |
| + |
| +--- |
| + |
| +## 11. Deliberately not built |
| + |
| +| Thing | Trigger | |
| +|---|---| |
| +| 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 | They have no repo and no spec respectively. | |
| +| 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. Not run. This spec is not approved. |
| +- **The escritorio record.** §2's provenance is second-hand. Someone who |
| + knows where it lives should confirm the five findings against the primary. |
| +- **Numbers.** The per-instance deadline, the total fan-out budget, the RAM |
| + cache TTL and the body cap are all named as decisions and none is chosen. |
| + They want measurement against a real canary, 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. |