| new file mode 100644 |
| index 0000000..6adf872 |
| --- /dev/null |
| +++ b/docs/superpowers/specs/2026-09-05-suite-sharing-doctrine.md |
| @@ -0,0 +1,652 @@ |
| +# 🤖 The Oficina suite — sharing doctrine |
| + |
| +How the apps of the suite share identity, membership and content with |
| +each other, and where the line is that nothing crosses. |
| + |
| +**Status:** design, 2026-09-05. Drafted by the suite session (Fable 5.1) |
| +from a brainstorm with Paul the same day. Every ruling in §1 attributed to |
| +Paul was given in that conversation or in an earlier spec named in the row. |
| +**Review pass outstanding.** AGENTS.md requires a review by the |
| +highest-capability model available, recorded by name, before this is |
| +approved. The author is that model, so the review must come from a session |
| +that did not write this. Do not adopt on the strength of this line. |
| +**Scope:** the rules that bind every app in the suite when it talks to |
| +another app or to the home. Consumed by `oficina/home`, `oficina/docs`, |
| +`oficina/sheets`, `oficina/memoria`, `oficina/calendar`, and any wrapper |
| +that later joins (mail via keymail, files via slopbox). |
| +**Reads on top of** `carlos/pegamento:docs/doctrine.md`. Where it |
| +diverges, §14 says so and names the filing. |
| + |
| +--- |
| + |
| +## 0. The one rule |
| + |
| +> **The team is the boundary. Inside it, the apps are one thing. Across |
| +> it, sharing is membership and nothing moves.** |
| + |
| +The falsifiable form, in two halves: |
| + |
| +- **Inside.** Take a team's instances of every app. Publish what any one of |
| + them holds about the others and the worst anyone learns is what that |
| + team could already see in the owning app. No instance holds a copy of a |
| + thing its team could not read there. |
| +- **Across.** Take two teams. There is no path by which a byte of one |
| + team's content reaches an instance of the other. A person in both teams |
| + reaches both through the home and carries nothing between them. |
| + |
| +Pegamento §1 says the app declares its own boundary. The suite is the app |
| +(memoria decision 1, Paul, 2026-09-03), and it declares the team. The |
| +process is not the boundary and never was: it was where the boundary |
| +happened to fall while every CARLOS app was one process. |
| + |
| +--- |
| + |
| +## 1. Settled decisions (don't relitigate casually) |
| + |
| +| # | Ruling | Rejected | Who, when | |
| +|---|---|---|---| |
| +| 1 | **The team is the boundary. An instance is a process.** A team's instances across every app are one trust domain at `<team>.<app>.oficina.works`. | One boundary per process, which left memoria holding person-initiated snapshots and made the home's activity feed a federation question | Paul, 2026-09-03 (memoria decision 1); reaffirmed suite-wide 2026-09-05 | |
| +| 2 | **Sharing is membership across teams, unchanged.** No data crosses a team label. A guest is a scoped member of the granting team. A link share moves nothing. | Any federation story; a "copy to my team" verb | Pegamento §3, applied | |
| +| 3 | **Custody and authorization are two rules.** Custody bounds what a sibling may *hold*: team-visible items only. Authorization is what a *person* may read: asked of the app of record, per person, per query, cached no longer than a session. A copy is never an authority. | One ACL copied between apps; "is a member" as the only question | Paul, 2026-09-03 (memoria decision 3); generalised 2026-09-05 | |
| +| 4 | **The home owns identity and the team, including the membership roster. It never owns a per-item grant.** | Per-instance rosters as the record, reconciled by asking siblings; the home holding document-level grants | Paul, 2026-09-05 | |
| +| 5 | **Two shapes, one switch.** A *lone* instance is its own identity authority and its own roster. A *suite* instance takes both from the home. Membership authority follows identity authority; an instance never has one without the other. | A suite instance half-trusting its local table; a lone instance dialling a home for a roster it does not need | Paul, 2026-08-31 (Docs §8.1, two shapes); extended to the roster 2026-09-05 | |
| +| 6 | **Egress is one suite sentence.** Egress that is a property of the product is forbidden. Egress that is per-team opt-in, to one named recipient, disclosed, with a working floor when off, is admissible. | Two contradicting texts, Docs §11.2 and memoria §8.3 | Paul, 2026-09-03 (memoria decision 4); made suite-wide 2026-09-05 | |
| +| 7 | **One contract, one Go module, both halves, fixtures as the interface.** Every app imports the module, serves its server half, and passes the conformance suite in CI. An app spec cites the doctrine and lists consumed rulings. It never restates one. | Each spec restating the contract in its own words, which is what drifted | Paul, 2026-09-05 | |
| +| 8 | **Two credentials, chosen by what the call proves.** A home-minted assertion proves a person. A consumer bearer token proves a same-team sibling. Custody calls carry the token and no asker. Per-person reads from a sibling carry the token and a named asker. | A token exchange putting the home in the loop of every search; one credential for everything | This session, 2026-09-05 | |
| +| 9 | **A write names its real actor. No sibling ever acts as a person.** A person writes through their own session, carried by their browser. A sibling writes as itself, into an area the team owner designated for it, touching only what it authored. | On-behalf-of writes, where a sibling token becomes a write capability over every member's identity; browser-carried only, which forbids unattended writes | Paul, 2026-09-05 | |
| +| 10 | **Separate repos. One shared repo for the doctrine and the contract.** The suite is a consolidation: mail and files arrive as wrappers around keymail and slopbox, so the contract must cross repository boundaries on day one. | A core monorepo for home, docs, sheets, memoria and estilo, which buys atomic spec edits for four apps and loses the property the day mail joins | Paul, 2026-09-05 | |
| +| 11 | **Activity is pulled at render and held nowhere at rest** — `oficina/home` ruling 18 and the `design-activity-pull` spec, adopted as suite contract call 7. | Push into the home; a contentless heat map | Paul, 2026-09-05 (home ruling 18) | |
| + |
| +--- |
| + |
| +## 2. What the collision was |
| + |
| +Nothing inter-app is built. The home is a skeleton. memoria's slice 1 is |
| +blocked. The activity pull is an unmerged worktree. So this is a spec |
| +alignment, not a migration, and it is cheap now and expensive in a month. |
| + |
| +The collision was never five apps disagreeing about the boundary. It was |
| +five specs each restating the same rules in their own words: |
| + |
| +- Home §2.2, Sheets PRODUCT.md principle 2 and Docs §8.2 each say data |
| + never crosses an *instance* boundary. memoria decision 1 says the |
| + boundary is the *team*. All four were written in good faith from the |
| + same Pegamento sentence, and the word "instance" carried a process |
| + assumption nobody had examined. |
| +- Docs §9 restated the home's five-call contract and was written against |
| + a superseded draft. The home spec now has six calls and the activity |
| + design a seventh. |
| +- Docs §11.2 and memoria §8.3 give opposite verdicts on the same egress, |
| + and memoria could only ask Docs to change (`oficina/docs` discussion 33) |
| + because Sheets AGENTS.md's rule that a contradiction is fixed in the |
| + same commit cannot be kept across repositories. |
| +- Four team models exist: the home's suite-wide `teams` table (ruling 17), |
| + Sheets' owners list plus `TeamRole`, Docs' allowed-addresses list, and |
| + calendar's `Kind = "team"` calendar everyone holds a grant on. memoria |
| + decision 11 works around there being no roster of record by asking |
| + every sibling and admitting on any yes. |
| + |
| +Restating is the defect. Co-location would not have fixed it; citation |
| +does. §10 is the mechanism. |
| + |
| +--- |
| + |
| +## 3. Two shapes |
| + |
| +An instance is provisioned in exactly one shape and the shape is not a |
| +runtime mode. |
| + |
| +**Lone.** One team, self-hosted, no home. The instance signs people in |
| +itself with rastrillo's magic link, keeps its own owners list and member |
| +rows, and never dials a home. This is Docs §8.1's ruling and Factor VII's |
| +requirement, not a degraded tier. Seapointish is the family precedent: |
| +"none" is a supported answer to "what glue does this app need". |
| + |
| +**Suite.** The instance accepts home assertions (home §5.3) and pulls its |
| +roster from the home (§5). |
| + |
| +The switch is the presence of a home consumer token in platform config. |
| +With it, the instance is a suite instance in every respect. Without it, a |
| +lone one in every respect. Ruling 5's reason: an instance that trusted the |
| +home for identity and its own table for membership would have two rosters |
| +of record, and the test in §0 could not be written for it. |
| + |
| +**Sibling calls are a separate switch**, and it is independent of shape. |
| +An instance serves and makes the sibling contract (§6) when sibling |
| +consumer tokens are in its platform config, which the drain mints without |
| +the home's involvement (memoria §6.2). So a self-hoster running Sheets and |
| +memoria with no home gets the two talking, and a suite instance always |
| +may. Two instances that talk were provisioned together and share a shape, |
| +which is what lets S2's named asker mean one thing: **the subject the |
| +receiving instance keys its members on**, a person ref in suite shape and |
| +a lowercased address in lone shape. A pair of lone instances has two |
| +rosters, one per instance, and that is what lone means; custody and |
| +authorization work unchanged because neither depends on the roster being |
| +shared. |
| + |
| +--- |
| + |
| +## 4. Boundaries |
| + |
| +### 4.1 The team |
| + |
| +The `<team>` DNS label, reserved suite-wide at the home (home ruling 19). |
| +Every barrier in this document is a barrier at that label. Two of them are |
| +structural: |
| + |
| +- **A sibling's `consumers` row** is both credential and origin allowlist. |
| +- **Every sibling client refuses a host whose `<team>` label differs from |
| + its own**, test-enforced against a fixture of hostile hostnames, and |
| + refuses to follow redirects. |
| + |
| +Neither is a boundary against code execution on an instance. They stop |
| +mis-provisioning and accidental cross-team dialling, and that is what |
| +they are claimed as (memoria §6.3). |
| + |
| +### 4.2 Custody |
| + |
| +What a sibling may *hold* a copy of. The predicate is **team-visible**: |
| +readable by every roster member by virtue of team standing alone. Each app |
| +defines it in one function, and the conformance suite (§11) proves that a |
| +content fetch for anything else answers 404, never 403. |
| + |
| +Individually granted items and guest-only items are never team-visible. |
| +The change feed therefore carries no visibility flag: departure from |
| +team-visibility is `gone`, indistinguishable from deletion, so a sibling |
| +cannot learn that a private item exists (memoria §6.1). |
| + |
| +### 4.3 Authorization |
| + |
| +What a *person* may read. Asked of the app of record with the asker |
| +named, per query, and answered from that app's grants join. Four values, |
| +none collapsible into another: `VISIBLE`, `NOT_VISIBLE`, `STALE`, |
| +`UNKNOWN`. Only `NOT_VISIBLE` may be silent. Everything else that is not |
| +`VISIBLE` withholds loudly and names the app (memoria §4.1). |
| + |
| +**The app of record for an item is the app whose store it was created |
| +in.** A copy never becomes an authority, and a derived artefact carries the |
| +provenance of every source it was built from, transitively, so two hops |
| +cannot launder what one cannot (memoria §13, now a suite rule). |
| + |
| +--- |
| + |
| +## 5. Identity and the roster |
| + |
| +### 5.1 What the home owns |
| + |
| +- **The person**: an opaque person ref, the assertion's `sub` (home §5.3). |
| +- **The team**: sqid, DNS label, sealed display name (home ruling 17). |
| +- **The team's instances**: `team_instances`, one row per (team, app). |
| +- **The roster**: which person refs are on which team, as `owner` or |
| + `member`. **This is ruling 4's expansion**, and it is smaller than it |
| + reads: the directory already holds person↔team↔kind rows (home §6). The |
| + roster is those rows with `kind = member`, made authoritative for team |
| + standing. |
| + |
| +### 5.2 What the home never owns |
| + |
| +A per-item grant. Which sheets a member may edit, which document a guest |
| +may comment on, which calendar a viewer sees: all instance-side, in the |
| +app's own grants table, decided from there on every request. The home |
| +answers only facts about its own records, never "may this person" (home |
| +ruling 19). "Is this person on this team" is a fact about its records. |
| +"May this person open this sheet" is not. |
| + |
| +Guests are not on the roster. A guest grant is minted in the instance and |
| +announced to the directory as `kind = guest` (home call 2), exactly as |
| +today. Nothing about guests changes. |
| + |
| +### 5.3 How the roster reaches an instance |
| + |
| +**Call 8, roster pull**, instance → home, under the instance's consumer |
| +token, scoped to the instance's own team: |
| + |
| +``` |
| +GET /roster?team=<sqid>&epoch=<n> |
| +→ { epoch, members: [ { person_ref, role: owner|member } ] } |
| +``` |
| + |
| +The home cannot push (home §6: no channel reaches a hibernating instance), |
| +so an instance pulls on wake and whenever it learns its copy is stale. It |
| +learns that from a **roster epoch** carried in every call-2, call-5 and |
| +call-8 response, and in the assertion as a claim. The claim passes home |
| +§5.4's test: a mismatched epoch triggers one refetch and a failed match is |
| +treated as absent, so the instance makes no trust decision on it, and the |
| +home already holds it. A person whose sign-in reveals a stale roster is |
| +admitted against the fresh one. |
| + |
| +**The instance enforces from its own copy**, on every request, exactly as |
| +it does today. What changes is where the copy comes from: the home rather |
| +than an owners list in an environment variable. |
| + |
| +### 5.4 Writing the roster |
| + |
| +At the home's directory origin, by a team owner, behind a fresh-assertion |
| +step-up (home §14 names it; this is its trigger). Adding a person announces |
| +them to every `team_instances` row's directory entry, so their front door |
| +shows the whole team on first sign-in. Removing a person bumps the epoch, |
| +and every instance that wakes or admits anyone refetches. |
| + |
| +**A member removed at the home is removed everywhere within one epoch.** |
| +That is the sentence memoria decision 11 could not write and the reason |
| +its four unreconciled tables existed. |
| + |
| +### 5.5 What this costs, said plainly |
| + |
| +A compromised home can add a member to a team. Before ruling 4 it could |
| +not: "a compromised glue grants nothing" (Pegamento §10.3). It still |
| +cannot read a document, cannot grant a sheet, and cannot act without |
| +leaving a directory row and a notification behind. The blast radius grows |
| +from "the graph" to "the graph plus membership", and §9 records it. |
| + |
| +Three things bound it: the owner step-up, the instance's own copy being |
| +what is enforced (so an outage or a rollback is a lag, not an admission), |
| +and the leak test in §0 being unchanged, since a roster row is a directory |
| +row the home already held. |
| + |
| +**Recorded as a divergence from Pegamento §10.3**, to be filed at |
| +`carlos/pegamento` (§14). |
| + |
| +### 5.6 Roles |
| + |
| +The roster knows two: `owner` and `member`. Per-item roles stay the app's |
| +own. Sheets has `commenter`; calendar does not; Docs has none yet. The |
| +doctrine does not unify them, because a shared role type designed from |
| +three specs at once would fit none (estilo's one rule). When two apps have |
| +shipped the same per-item vocabulary, it may move to the contract module. |
| +Not before. |
| + |
| +--- |
| + |
| +## 6. The suite contract |
| + |
| +One Go module, `amadan.net/oficina/suite`, with a client half, a server |
| +half that mounts beside an app's own router, and golden fixtures. memoria |
| +§6 specified S1 through S7 and the activity design specified call 7; both |
| +move here unchanged in substance. The home's calls 1 to 6 stay in the home |
| +spec, which owns them, and are cited here for completeness of direction. |
| + |
| +| Call | Direction | Credential | Proves | Notes | |
| +|---|---|---|---|---| |
| +| 1–6 | person → home, instance → home | session; consumer token | — | Home §9. Unchanged. | |
| +| 7 activity | home → instance | **assertion** for the person, `aud` = instance | who the front door is for | `design-activity-pull` §4–5. The home renders and discards; RAM cache only. | |
| +| 8 roster | instance → home | consumer token | which instance asks | §5.3. Scoped to the caller's team. | |
| +| S1 change feed | sibling → sibling | consumer token | same-team sibling | `(item_id, revision, gone, touched_at)`. No title, no content, no visibility flag. Per-consumer TTL; past it, full re-announce. | |
| +| S2 authorization | sibling → sibling | consumer token **+ named asker** | same-team sibling, asking for a person | Four values, total decoder, cap 256 pairs. Never cached across requests. | |
| +| S3 content | sibling → sibling | consumer token | same-team sibling | `(id, revision)`, **no person**. Team-visible only. 404, never 403. | |
| +| S6 aggregate | sibling → Sheets | consumer token + named asker | as S2 | Enumerated function over cell refs, never expression text. Cap 64. | |
| +| S8 write-as-self | sibling → sibling | consumer token | same-team sibling | §7.2. Into the caller's designated area only; only items the caller authored. | |
| +| hand-off | browser → instance | the **person's own session** | the person | §7.1. A redirect POST, the shape of home call 6. Not a server call. | |
| + |
| +**S7 membership is retired in suite shape.** It existed because there |
| +was no roster of record; call 8 is that record. A lone instance never |
| +needed it. memoria decision 11 is superseded (Appendix A.4). |
| + |
| +### 6.1 Why two credentials |
| + |
| +**Only the home can mint an assertion**, and putting the home in the loop |
| +of every memoria search would break memoria §6.4 and its 800 ms deadline |
| +at once. So a sibling asking "may Ana see these" sends its consumer token |
| +and names Ana. The sibling is trusted to name the asker because a |
| +compromised sibling already reads everything team-visible through S3; the |
| +naming adds nothing to what it can read. It would add something to what it |
| +can *write*, which is why S8 takes no asker (§7). |
| + |
| +**The home does hold the signing key**, so call 7 carries a real |
| +assertion and the answering instance decides from its grants with no |
| +trust in the home's word. Ruling 3 of the activity design, adopted. |
| + |
| +### 6.2 Authentication and rotation |
| + |
| +Per-consumer bearer tokens, minted by the operator drain, delivered as |
| +platform config, stored hashed by the receiver. Three-phase rotation: |
| +expand acceptance, switch the sender, contract acceptance (memoria §6.2). |
| +The home never sees a sibling token. Mutual TLS is unavailable because TLS |
| +terminates at the platform router. |
| + |
| +### 6.3 Availability |
| + |
| +Every pull carries a deadline, all siblings are called concurrently, and a |
| +sibling past its deadline is `UNKNOWN`: withheld loudly, the gap named |
| +with the app. Partial pages are the normal case. A cold sibling is a wake, |
| +and a wake is slower than any deadline, so a consumer pre-warms on its own |
| +wake (memoria §6.5, activity design §6). **A reliably slow sibling is a |
| +suite health problem the consumer surfaces, never one it routes around by |
| +caching authorization.** |
| + |
| +### 6.4 What the contract does not license |
| + |
| +Any hop across a `<team>` label. Any sibling presenting an assertion it |
| +did not receive from the home for its own `aud`. Any write except §7. Any |
| +item outside the custody predicate. The home in the loop of a sibling |
| +call. Content leaving the team's hosts except under ruling 6. |
| + |
| +--- |
| + |
| +## 7. Writes |
| + |
| +The question "may memoria write a Doc" has two answers because it is two |
| +questions. |
| + |
| +### 7.1 A person is present: the hand-off |
| + |
| +"Save this answer as a Doc" is Ana's act. memoria renders a form whose |
| +action is Docs' create endpoint on Docs' own host; Ana's browser POSTs it |
| +with her own Docs session; Docs sees Ana and Ana owns the document. |
| +memoria holds no write credential and Docs trusts nothing memoria says. |
| + |
| +Same-site cookies make this a plain form: `<team>.memoria.oficina.works` |
| +and `<team>.docs.oficina.works` share a registrable domain, so a `Lax` |
| +session cookie rides the POST. Body size is capped by the receiver, with |
| +the number's reason in a comment. A person with no Docs session is sent |
| +through sign-in and returns to the pending state by nonce, the flow home |
| +§5.5 already specifies. This is scriptless and satisfies every app's |
| +zero-JS floor. |
| + |
| +### 7.2 No person is present: write-as-self |
| + |
| +A weekly digest. A Notion page ingested and written out as a Doc. That is |
| +memoria's act, and rastrillo already models a non-human actor. S8 lets a |
| +sibling create or modify items **as itself**, under its consumer token, |
| +subject to three constraints the receiver enforces and the conformance |
| +suite tests: |
| + |
| +- **Only into an area the team owner designated for that sibling.** A |
| + folder, a tag, a calendar, whatever the app's own grouping is. The |
| + designation is an instance-side grant, made in the receiving app's |
| + share UI, and it is what makes S8 refusable per team. |
| +- **Only items the sibling authored.** Every S8 write carries the writer's |
| + identity in the item's actor field, and an S8 modify is refused for any |
| + item whose actor is not the caller. |
| +- **Never a person-authored item.** Stated separately because it is the |
| + attack the constraints exist to prevent. |
| + |
| +The attribution rule holds: anything that writes is attributed, and |
| +"memoria" is a name a reader can act on. |
| + |
| +### 7.3 What was rejected, and why it stays rejected |
| + |
| +**On-behalf-of writes**: a sibling writing "as Ana" under its own token. |
| +It would make every consumer token a write capability over every member's |
| +identity, and the receiving app could not tell Ana's edit from a |
| +compromised sibling's. The read-side version of this trust (S2's named |
| +asker) is acceptable because it adds no reach; the write-side version adds |
| +all of it. |
| + |
| +**Browser-carried only**: forbids unattended writes, which the product |
| +wants, and would push implementers toward a person-present pretence. |
| + |
| +--- |
| + |
| +## 8. Egress |
| + |
| +One sentence, binding on every app: |
| + |
| +> **Egress that is a property of the product is forbidden. Egress that is |
| +> per-team opt-in, to one named recipient, disclosed in the README's first |
| +> paragraph and on every artefact it produced, with a working floor when |
| +> it is off, is admissible.** |
| + |
| +Docs §11.2's rejection of conversion APIs stands under the first clause: |
| +the egress was unavoidable and unnamed. memoria §8.3's model provider |
| +stands under the second, and the comparison is like with like: memoria's |
| +embedding ingest is whole-corpus egress, comparable in volume to what |
| +Docs rejected, differing only in the three properties the second clause |
| +names (discussion 33). Both specs cite this section and delete their own |
| +wording (Appendix A). |
| + |
| +--- |
| + |
| +## 9. The suite leak list |
| + |
| +Each app keeps its own. These are the costs that exist only because the |
| +apps are a suite, and every one is a consequence of ruling 1 or 4. |
| + |
| +1. **A compromised sibling reads everything team-visible in its team**, |
| + across every app, through S3. This is what "one trust domain" means |
| + and there is no version of ruling 1 without it. It cannot read |
| + individually granted or guest-only items, which is what custody buys. |
| +2. **A compromised sibling can name any asker on S2**, and so learn which |
| + team-visible items a given member may see. Bounded by item 1: it can |
| + already read them all. |
| +3. **A compromised sibling can write into its own designated area** as |
| + itself (S8). It cannot write as a person or touch a person-authored |
| + item. |
| +4. **A compromised home can add a member to a team** (§5.5). It cannot |
| + read content, grant an item, or do so silently. |
| +5. **The home page holds titles and actor names in RAM** for the length of |
| + a response and a short cache (activity design §7). An XSS on the |
| + directory origin reaches call 7's output, which is titles, never |
| + documents. Never at rest. |
| +6. **The hand-off puts a document body in a POST** across two origins of |
| + one site, in the person's browser. It is the person's own content |
| + moving under the person's own session, and the receiver caps it. |
| + |
| +--- |
| + |
| +## 10. Where things live |
| + |
| +**Repositories stay separate** (ruling 10). `oficina/suite` holds this |
| +doctrine and the contract module. Estilo stays the design system's home |
| +and is the precedent for a shared module every app pins. |
| + |
| +**Cite, never restate.** Each app spec gains a section, *Consumed suite |
| +rulings*, listing ruling numbers from §1 and the doctrine commit it was |
| +read against. Where an app spec today restates a boundary, contract, or |
| +egress rule, the restatement is replaced by a citation (Appendix A). Sheets |
| +AGENTS.md's "a contradiction is fixed in the same commit" becomes **"a |
| +contradiction with the suite doctrine is a bug in the app spec, fixed by |
| +citing"**, which can be kept across repositories, where the old rule |
| +could not. |
| + |
| +**Conformance is the interface.** The contract module ships golden |
| +request and response fixtures for every call and a test each app runs in |
| +CI against the version it pins. Estilo's rule: two specs are guesses, two |
| +implementations are facts. The fixtures are what memoria's server half and |
| +Docs' server half both have to satisfy, and they are corrected by the |
| +second adoption. |
| + |
| +**The two-pins trap is the stated cost.** Apps pin the contract module; |
| +the module pins nothing (it is types, fixtures and a thin client, with no |
| +rastrillo dependency), so the chain is one deep. Contract changes are |
| +additive-only, and a removed field is a new major version. |
| + |
| +**Docs moves its module path** from `github.com/carlosframework/docs` to |
| +`amadan.net/oficina/docs` before it imports the contract. Every other app |
| +is already there. |
| + |
| +--- |
| + |
| +## 11. Testing gates |
| + |
| +Gates live in the contract module and each app runs them in CI. A gate |
| +that cannot be written as stated is a spec bug. |
| + |
| +1. **Fixture conformance.** Every server half reproduces the golden |
| + response for every golden request, per call. A new fixture is added |
| + with the first client that needs it. |
| +2. **S2 total decoder.** For any 256-pair request, the client yields |
| + exactly one result per pair or `UNKNOWN` for the whole call. Tested |
| + with short, duplicate, malformed and non-2xx responses. |
| +3. **Custody.** S3 answers 404 for a fixture item that is individually |
| + granted, guest-only, or deleted, and 200 for a team-visible one. The |
| + status is identical for "not team-visible" and "does not exist". |
| +4. **No cross-team dial.** The client refuses a hostile-hostname fixture |
| + (`acme-evil`, `acme.docs.oficina.works.evil`, a redirect to another |
| + label) without connecting. |
| +5. **Roster.** A member removed at the home receives an empty call-7 list |
| + and `NOT_VISIBLE` on S2 from every instance after one epoch. A lone |
| + instance, given a home hostname in a fixture, makes no request to it. |
| +6. **Write-as-self.** S8 is refused outside the caller's designated area, |
| + refused for an item whose actor is not the caller, and refused entirely |
| + when the team has designated no area. |
| +7. **The hand-off.** A POST without a session lands in sign-in and |
| + returns to the pending state by nonce. A body over the cap is refused |
| + with no partial write. |
| +8. **Publish the home's database.** Unchanged from home §0: counts, team |
| + names, memberships. The roster adds no column class, and the test |
| + proves it by running against a roster fixture. |
| +9. **Citation currency.** Each app spec's *Consumed suite rulings* section |
| + names a doctrine commit; a script fails when that commit is more than |
| + one ruling behind `main`. |
| + |
| +--- |
| + |
| +## 12. Sequencing |
| + |
| +0. **This spec, reviewed.** A review pass by a model that did not write |
| + it, recorded by name in the status block. |
| +1. **The four amendments** (Appendix A) land in home, Docs, Sheets and |
| + memoria. This closes `oficina/docs` discussion 33 and unblocks |
| + memoria slice 1, which its own §13 says is blocked on agreement, not |
| + code. |
| +2. **The contract module**, types and fixtures only: S1, S2, S3, the |
| + hostile-hostname fixture, gates 1 to 4. No server half yet. Docs moves |
| + its module path. |
| +3. **memoria slice 1 against the module**, with Sheets and Docs serving |
| + S1 to S3. The second adoption corrects the fixtures. |
| +4. **The home's identity layer** (home §13 step 4) and, on top of it, |
| + call 8 and the roster (§5). Gates 5 and 8. |
| +5. **Call 7**, the activity pull, once the home can mint assertions |
| + (home §13 step 10). |
| +6. **The hand-off** from memoria to Docs. Gate 7. |
| +7. **S8** and the designation UI in one receiving app. Gate 6. |
| + |
| +Steps 2 and 3 do not wait for the home. That is deliberate: the sibling |
| +contract has no home in the loop and its tokens are a separate switch |
| +(§3), so memoria can ship against Sheets and Docs in lone shape with |
| +sibling tokens and an owners list, and the roster arrives later as a |
| +change to where the list comes from. |
| + |
| +--- |
| + |
| +## 13. Deliberately not built |
| + |
| +| Thing | Trigger | |
| +|---|---| |
| +| Cross-team anything | Never, under ruling 2 | |
| +| On-behalf-of writes | Never, under ruling 9 | |
| +| A unified per-item role vocabulary | Two apps ship the same one (§5.6) | |
| +| A core monorepo | Never while the suite is a consolidation (ruling 10) | |
| +| Home-side search or aggregation over pulled content | Never, under home §2.2 as amended | |
| +| A roster in lone shape that syncs anywhere | Never; lone means lone | |
| +| Push from an instance to the home | A hibernation model that runs timers, which the platform does not have | |
| +| A token exchange so siblings can hold assertions | The home's latency budget can afford being in every search, which §6.1 says it cannot | |
| +| Mail and files as suite apps | A wrapper around keymail or slopbox that serves S1 to S3 and takes a roster | |
| + |
| +--- |
| + |
| +## 14. Open |
| + |
| +- **Review pass.** See status. |
| +- **Pegamento filing.** §5.5's divergence from Pegamento §10.3, and |
| + ruling 1's application of §1 to a suite, both to `carlos/pegamento`. |
| +- **The `/auth/begin` dial.** rastrillo's sign-in classifier fetches |
| + `.well-known/keymail` on the typed address's domain before the admission |
| + gate, so an anonymous visitor makes any instance dial any host |
| + (memoria QUESTIONS §5). It affects every app in the suite and belongs |
| + upstream in `rastrillo/auth`. Recorded here because no single app owns |
| + it. |
| +- **`rastrillo/rastrillo` #2**, the subject hook and sealed link store, |
| + still gates the home's identity layer and therefore steps 4 and 5. |
| +- **Whether calendar joins the contract in slice 1** or after its first |
| + screens merge. It has a spec and content; bodies stay off by default |
| + when it does (memoria §12). |
| +- **The deadline number.** 800 ms per sibling is memoria's starting |
| + assumption, unmeasured. It becomes the contract's default once measured |
| + against a cold wake on the platform. |
| + |
| +--- |
| + |
| +## Appendix A — the consuming amendments |
| + |
| +Each is the text an app spec adopts, replacing its own restatement. Each |
| +is one commit in its own repository, and each adds a *Consumed suite |
| +rulings* section naming this document's commit. |
| + |
| +### A.1 `oficina/home`, spec §1 and §2.2 |
| + |
| +Add ruling 22: *"The suite sharing doctrine (`oficina/suite`) binds this |
| +spec. The team is the boundary; the home owns identity, the team and the |
| +membership roster; it never owns a per-item grant. Call 8, roster pull, |
| +is added to §9."* |
| + |
| +In §2.2 replace *"Never federates. Data does not cross an instance |
| +boundary."* with *"Never crosses a team. Data does not cross a team |
| +label; a team's instances across apps are one trust domain (suite ruling |
| +1)."* Complete ruling 18's pending change of *"never renders"* to *"never |
| +stores"*. Replace *"Never holds an authorization model"* with *"Never |
| +holds a per-item grant. It holds the membership roster (suite ruling 4), |
| +and answers only facts about its own records (ruling 19)."* |
| + |
| +In §9 add call 8 per suite §5.3. In §5.3's claims table add |
| +`roster_epoch`, and in §5.4 record that it passes the two-sided test: a |
| +mismatch costs one refetch and a failed match is treated as absent. In |
| +§14 move *step-up for instances* to built-when: *"roster writes (suite |
| +§5.4)"*. |
| + |
| +### A.2 `oficina/docs`, spec §8.2, §9, §11.2 |
| + |
| +§8.2: replace *"'Sharing is membership' forbids data crossing the |
| +instance boundary."* with *"'Sharing is membership' forbids data crossing |
| +a team boundary (suite ruling 2). Same-team siblings may pull |
| +team-visible content under the suite contract (suite §6), and Docs serves |
| +S1 to S3 and S8 in suite shape."* |
| + |
| +§9: delete the restated five-call contract and the *consumed rulings* |
| +list. Replace with *"Docs consumes the home contract as the home spec |
| +states it, and the suite contract as `oficina/suite` states it. See |
| +Consumed suite rulings."* |
| + |
| +§11.2: replace the rejection's reason clause with *"Rejected under suite |
| +§8's first clause: unavoidable, unnamed egress. The second clause governs |
| +memoria, and Docs does not restate it."* This closes discussion 33. |
| + |
| +### A.3 `oficina/sheets`, PRODUCT.md principle 2, spec §8.2, §9, AGENTS.md |
| + |
| +PRODUCT.md principle 2: *"One instance per team. Sharing is membership |
| +across teams: the sheet never moves, and an outside person becomes a |
| +scoped member of the team that owns it. Within a team, Sheets is one of |
| +several apps sharing one trust domain (suite ruling 1), and same-team |
| +siblings may pull team-visible sheets under the suite contract. There is |
| +no federation story across teams and there will not be one."* |
| + |
| +§8.2: append *"Team standing comes from the roster the home holds (suite |
| +§5) in suite shape, and from `$SHEETS_OWNERS` in lone shape. Per-sheet |
| +roles stay here."* |
| + |
| +§9: as A.2's §9. |
| + |
| +AGENTS.md: replace *"a contradiction gets fixed in the same commit"* with |
| +*"a contradiction with the suite doctrine is a bug in this spec, fixed by |
| +citing it."* |
| + |
| +### A.4 `oficina/memoria`, spec §1, §1.3, §4.3, §6, §8.3, §13 |
| + |
| +§1: add *"Decisions 1, 3 and 4 are now suite rulings 1, 3 and 6; this |
| +spec cites them. Decision 11 is superseded: admission is the home's roster |
| +(suite §5) in suite shape and the owners list in lone shape. S7 is |
| +retired."* |
| + |
| +§1.3: correct the S3 prose to match §6.1: S3 takes no person; the fix |
| +removed the asker rather than adding one. |
| + |
| +§4.3: replace the body with a citation of suite §5.3 and delete the |
| +"at least one sibling says MEMBER" rule, which is what made a guest a |
| +member. |
| + |
| +§6: move the S1 to S6 definitions to the contract module and cite them. |
| +Replace §6.4's *"Any write"* with *"Any write except suite §7: a |
| +person's own hand-off, and S8 as memoria itself into its designated |
| +area."* |
| + |
| +§8.3: replace the qualification of Docs §11.2 with a citation of suite |
| +§8. |
| + |
| +§13: close the blocker *"the source apps must adopt decision 1"* against |
| +A.1 to A.3 when they land. |
| + |
| +### A.5 `oficina/calendar` |
| + |
| +No restatement exists to replace. Add *Consumed suite rulings* naming |
| +rulings 1 to 9 when calendar's first screens merge. |