oficina / suite Public
🤖 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, revision 8, 2026-09-05.
Revision 8 records that the contract module cannot be imported, which makes §12 step 2 built rather than done. amadan.net/oficina/suite does not resolve for the Go toolchain: the hub answers ?go-get=1 with a 302 to /auth/login, so go get never sees a go-import meta tag. Verified twice, independently. The module has zero consumers today and cannot get one, and §12 steps 3 and 4 both say "against the module".
Revision 7 moves S6 to where it is actually needed and records why it has the shape it has. Revision 6 gave it a step before memoria slice 1, which was earlier than the dependency requires and implied slice 1 was blocked on it. The memoria session supplied the piece neither of the other two had: slice 1 is S1, S2, S3 and §4.1 and needs no S6 at all; the numeric and aggregate class in its §8.2 router is slice 2, and that is where S6 bites.
Revision 6 schedules S6, which had a number and no builder. It is in §6's contract table and A.4 tells memoria to cite it, and no step of §12 ever built it — so it read as settled because it had a number. That is the hole §6's S4/S5 paragraph exists to prevent, with the difference that S4 and S5 were resolved away deliberately and said so, while S6 was live and merely unscheduled. Caught by the oficina/home activity-pull session while answering a memoria status check, before memoria adopted A.4 against a fixture nobody was going to write.
Why 5 and not 4, which is this document breaking its own gate 9. Two commits after revision 4 changed rules while leaving the number alone: 574cc84 corrected A.1's ruling number and recorded memoria's citation baseline, and bf0eeb8 added §4.3's call-7 carve-out, scoped §5.3's epoch exception to the call-8 path, rewrote §6's call-7 row and added a leak-list item. Gate 9 says the revision is bumped by any normative change, wherever it lives — §4.3 and §5.3 are exactly that, and revision 4 was cited while its own text moved underneath the citation. Caught by the oficina/home activity-pull session, which was handed one commit for revision 4 while this repository's own app amendments carried another, could confirm neither, and wrote the discrepancy down rather than picking the newer number quietly.
What to cite. Revision 5 is this commit. The app amendments on oficina/docs, oficina/sheets and oficina/home cite revision 4, 210312f, which was true when each was written and is now exactly one revision behind — which gate 9's one-revision grace allows, deliberately, as a compatibility allowance and not as evidence they meet current requirements. They are correct as they stand and become stale on the next normative change. 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, in an earlier spec named in the row, or in the sessions that produced revisions 3 and 4.
Three review passes, by two models, none of which wrote the draft. Claude Opus 5 on revision 1: six defects. GPT-6 via Codex on revision 2: request changes, sixteen findings. GPT-6 and Fable 5.1 independently on revision 3: request changes from both, agreeing closely — 6 and 7 of the sixteen closed respectively, none still fully open, plus twelve and five new findings. All three reports are beside this file, unedited: CODEX-FINDINGS.md, CODEX-FINDINGS-R3.md, FABLE-FINDINGS-R3.md.
Revision 4 does not claim to answer everything, because revisions 2 and 3 both made that claim and both were caught. What it fixes:
- Ruling 15 is now Paul's and it cites Sheets ruling 12. Link sharing is read-only; an "edit link" is a capability to obtain a guest grant at sign-in, so every write still has a named actor. Revision 3 proposed an anonymous link editor — the actor Paul had already rejected on 2026-08-30 — without citing the ruling that rejected it. §7.3 is deleted. Recorded plainly because it is the failure §2 diagnoses and §10 exists to prevent, committed by this document.
- The provenance envelope splits by path (§7). Revision 3 required an asker on S3 and on a link read, neither of which has one. Person-less paths check custody; only person paths check S2. Withholding is whole-artefact, since no envelope can map a synthesis back to the source of a sentence.
- A stale roster stops serving existing sessions (§5.3), which is what revision 3 said it was doing and was not.
- A restored home mints a new roster generation (§5.3). Refusing a lower epoch was not enough: a restore that reissues the same numbers catches up in two writes.
- §5.5 stops denying what ruling 13 concedes. A compromised credential origin reads and writes as whoever it signs in as, and no instance-side check distinguishes it. Revision 3 conceded that and then wrote the reassuring clause back two paragraphs later.
- Ruling 12's rejected column drops its impossibility claim and keeps the three facts verified against home §§6 and 7.
Openly not answered, and deliberately left for iteration (Paul, 2026-09-05): call 8's wire encoding and completion marker; result precedence when a stale roster and a non-custodial item collide; pinning sibling baselines, since four memoria citations resolve only on an unmerged worktree; and most of the fixtures the gates now describe.
Two that were on this list are off it. The activity path's cached authorization is answered: §4.3 now carves out call 7 explicitly and requires a hard maximum age, which the activity-pull session had already priced before being asked. A.1's ruling number is resolved: home ruling 22 was indeed taken and the amendment landed as 23. These are recorded here rather than in a passing sentence so the next reader can see the edge of the work.
Revision 4 has not been reviewed.
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 and the worst anyone learns is what that team could read in the owning app at the moment the copy was taken. An honest instance retires a copy that has left team-visibility within one feed pull plus the retention its own spec states — memoria §7.4 holds that number today, and a consumer that states none is not conformant. §4.2 carries the rule and deliberately carries no number, because the number is a property of the consumer's storage and not of this document. A compromised consumer never retires anything.
- Across. Take two teams. There is no path the suite provides 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. A person who exports a file and uploads it by hand has left the paths the suite provides, and nothing here prevents that.
"At the moment the copy was taken" is the design, not a hedge. Custody is bounded at acquisition and retired by the feed; it is never enforced by erasure, because no app can erase from another app's process — which is precisely why ruling 1 put the boundary at the team and not at the process. §9 item 7 already concedes this one layer up, for a page a browser has rendered. This is the same sentence for bytes at rest.
Revision 1 stated the test instantaneously. That was false for the window between a source privatising an item and the consumer pulling its gone row, and false permanently against a consumer that has been broken into. The home made this same correction on its own §0: "never stores your address" was a claim the schema could not keep, so it became "never stores a readable address" with the window carried in its §3.2. The precedent was in the family and this document did not follow it. Ruling 14.
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, never cached. Team standing is not authorization; it is the roster, a pulled copy refreshed by epoch (§5). 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) | | 12 | Membership is its own record at the home, written only by an owner. (team, person, role), written at the directory origin behind step-up, or seeded by the drain with the team's first owner. A directory row says where a person has things; it never grants or revokes team standing. Call 2 cannot write membership and call 8 reads only the membership record. | Deriving the roster from call-2 directory rows, which revision 1 chose. The row has no owner role to project (home §6: kind is member or guest; home §7: who counts as an owner "is the instance's business… and the home never learns the answer"); it is scoped per (team, app) where the roster is per team, so a projection can express "member of acme.docs but not acme.sheets", which ruling 1 forbids; and any instance writes it under a consumer token, so deriving from it routes around §5.4's step-up. Those three are verified against home §§6 and 7; whether they are together unbuildable is a stronger claim than the evidence carries, and revision 3 made it | Paul, 2026-09-05, on Fable's recommendation after the GPT-6 review (finding 1) | | 13 | A compromised home is three compromises and each is conceded on its own. A leaked database reveals the graph, the roster, and who owns each team. A compromised directory origin can add a member, and a member reads everything team-visible in every instance of that team. A compromised credential origin can sign in as anyone, owners included, and no instance can tell. | One undifferentiated "compromised home" that "cannot read a document", which took home §2's careful directory and widened it to home, and counted as evidence what only honest code leaves behind | Paul, 2026-09-05, on Fable's recommendation after the GPT-6 review (finding 2) | | 14 | Custody is bounded at acquisition and retired by the feed. It is not enforced by erasure. What an instance may acquire is team-visible at the time; what it may render is live-authorized (ruling 3); what it must retire is stated and timed. Three rules, not one. | §0's inside test stated instantaneously; source-driven tombstone push (home §6: no channel reaches a hibernating instance); sealing pulled content under a source-held key, which buys nothing once a consumer has indexed, since an embedding is content in another costume (memoria §3.1) | Paul, 2026-09-05, on Fable's recommendation after the GPT-6 review (finding 6) | | 15 | Three rungs and a read-only link. An item is private, granted to named people, or team. Orthogonally it may carry a read-only link capability — view, or comment where the app has that role. Only the team rung is team-visible, so custody keys on the rung and never on the link. Every write has a named actor: a member, or a guest who signed in and took a grant. | Treating a link as team-visible because "anyone can read it", which turns a capability held by whoever has the URL into a listing every team member finds by search. And anonymous link editing, which Sheets ruling 12 rejected on 2026-08-30 and which revision 3 rebuilt without citing it. An "edit link" is a capability to obtain a guest grant at sign-in — the granted rung, and how Docs already builds its commenter link | Paul, 2026-09-05, confirming Sheets ruling 12 |
---
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/docsdiscussion 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
teamstable (ruling 17), Sheets' owners list plusTeamRole, Docs' allowed-addresses list, and calendar'sKind = "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.
The asker subject is typed on the wire — {kind: person_ref | address, value} — because its meaning is shape-dependent and nothing else in an S2 or S6 call says which shape the pair is in. "Provisioned together" is an operational habit, not an enforced property, and a mismatched pair would otherwise send a value the receiver keys nothing on and get back NOT_VISIBLE: the one value §4.3 permits to be silent, returned for a mis-provisioning. With the kind on the wire, a receiver that does not key on it answers UNKNOWN and the gap is named, which is what §4.3 requires of everything that is not a denial. (Review pass, 2026-09-05.)
---
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
consumersrow 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.
The ladder every app implements (ruling 15). Four product states, three rungs and one capability:
| State | Rung | Team-visible? | |---|---|---| | Private to me | private | No | | Shared with specific people — members, or guests from outside (§5.2) | granted | No | | Shared with the whole team | team | Yes | | Anyone with the link can read | a read-only capability on any rung | No, by itself |
The product need this serves, in Paul's words: read and write with team-mates, read-only in public. Team-mates read and write by team standing or by a grant. The public reads by link and never writes. An "anyone with the link can edit" share is the granted rung wearing a convenient front door: the link carries you to sign-in, sign-in mints a guest grant, and from there you are a named actor like everyone else. Sheets ruling 12 settled this on 2026-08-30 — "link sharing is viewer-or-commenter, never editor" — and revision 3 rebuilt the actor it rejected without citing it. Ruling 15 cites it now. That failure is precisely what §2 diagnoses and §10 exists to stop, committed by the document that diagnoses it.
Custody keys on the rung, never on the link. The predicate is not "can somebody read this", it is "does team standing alone reach it". A link share is a capability held by whoever has the URL, and it is deliberately not a grant to anybody in particular.
Getting this wrong is a live leak, not a technicality. Ana keeps a document private and link-shares it with one contractor. If the link made it team-visible, memoria would ingest it, and the first teammate to search for its subject would be handed a passage from a document nobody gave them. The sharer chose "whoever I send this URL to". Treating that as "everyone on the team, findable by search" is a different decision made on their behalf. An item that is both on the team rung and link-shared is team-visible because of the rung; the link adds nothing either way.
A link share still moves nothing (ruling 2). Whoever holds the URL reads the item in the app that owns it, so no byte reaches another team's instance and §0's "across" half is untouched. A link holder is never on a roster, so no sibling ever names one as an S2 asker — siblings serve members, and a link holder is served by the owning app directly.
Individually granted items, guest-only items and link-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). A move down the ladder — team to granted or private — is a departure like any other, and reads as gone.
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 revision may be behind in either direction, and only one is STALE. memoria §4.1 defines STALE as the source having moved past the requested revision. A source restored from backup can be behind it — the consumer holds revision 7 and the app of record now has 5. VISIBLE would authorise a revision the app of record cannot vouch for; NOT_VISIBLE files a synchronisation failure under the one value that is allowed to be silent, which is the exact mistake STALE was introduced to fix. So a revision the app of record does not recognise is UNKNOWN: withheld loudly, the app named, the consumer free to re-ingest. memoria records this defect open in its §13; A.4 closes it. (GPT-6 review, finding 11.)
Never cached, at any scope. Not per session, not per page, not for the life of a warm process. Caching authorization is what makes a stale answer a leak (memoria §6.5), and the usability pressure to cache within a session is exactly the pressure this sentence exists to resist. The one copy an instance may hold is the roster (§5.3), and the roster answers team standing, not "may this person read this item". An earlier draft of ruling 3 said "cached no longer than a session", which conflated the two; memoria's session caught it.
One carve-out, and it is call 7. The rule above governs an authorization answer: an S2 VISIBLE held past its request is a standing capability, and it grows — the consumer re-uses it against items and people the app of record never spoke about. The home's activity cache is not that. It holds rendered output for one person, the home holds no grants, and it can only ever re-serve the same rows to the same person.
That is a real distinction and it is not a free one. Inside the cache's life a person removed from a team still sees titles, and calling that "not cached authorization" would be word-play. So it is priced rather than denied: the activity cache carries two ages, a short refresh TTL saying when to try again, and a hard maximum age after which rows are discarded regardless of the pull's outcome, failure included. The hard maximum is the one that bounds the lie. Without it the tail is the hibernating fleet's wake schedule rather than a number anybody chose.
This document states that the bound must exist and refuses to carry the number, exactly as §4.2 does for retention: the number is a property of the consumer, and the consumer states it. design-activity-pull §7 holds it. (Reconciled with the activity-pull session, 2026-09-05, which found the contradiction between this section and §6's call-7 row and priced it before being asked.)
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).
S2's answer is intersected with custody. The app of record runs its grants join for the named asker, and then answers NOT_VISIBLE for any item that is not team-visible, whatever the asker's individual grants say.
Without the intersection §4.2's guarantee is false. A sibling holds ids for items it ingested, and an item that later leaves team-visibility appears in the feed as gone, which §4.2 requires to be indistinguishable from deletion. An S2 answering from the full join would distinguish them perfectly: VISIBLE for the one that went private, when a grantee is the named asker, and NOT_VISIBLE for the one that was deleted. The leak the S1 visibility flag was removed to prevent, and the leak S3's "404, never 403" was designed to prevent, would arrive on the third call.
The intersection has a cost, and it is a product cost. A consumer that ingested an item while it was team-visible, and asks about it after it went private but before gone reaches it, now gets NOT_VISIBLE where it would have got VISIBLE for a person who still holds an individual grant. Ana loses that passage in memoria, and any answer built on it, while the document itself stays open to her in the app of record. That is the price of the indistinguishability §4.2 promises; it cannot be avoided while the promise stands, and the consumer's honest move is to say which app the answer went to rather than to return a shorter one silently.
Revision 2 first wrote that the intersection cost a correct consumer nothing, reasoning from visibility at ingestion. That reasoning is wrong: a later visibility change is exactly what the defect turns on, so the case where the intersection bites is the case the argument assumed away. It costs an incorrect consumer what decision 3 says it should — custody bounds what a sibling may hold, and a sibling has no business asking about what it may not hold. (Review pass, 2026-09-05. memoria §4.1 states the join unintersected — "plus rows granted to me individually" — so A.4 carries the correction. The cost paragraph is corrected from the GPT-6 review, finding 8, which also wants a transition fixture: ingest, downgrade while keeping Ana's grant, query before the feed catches up.)
---
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
ownerormember, in a membership record of its own —(team, person, role), not a projection of anything else the home holds. This is ruling 4's expansion and ruling 12's shape.
Revision 1 derived the roster from directory rows, and that was wrong three ways. It is recorded because the economy was tempting and someone will propose it again: it adds no table, and it leaves the home's publish-the-database claim untouched.
- The row has no owner role to project. Home §6's directory row is
(person ref, app, team sqid, host, kind, sealed team name)and "kindis member or guest". Home §7 is explicit that who counts as an owner "is the instance's business… and the home never learns the answer". §5.3's wire shape had no source. - The row is scoped per (team, app); the roster is per team. A projection can therefore express "a member of acme.docs but not of acme.sheets", which ruling 1 forbids outright.
- Any instance can write it under its consumer token, through call 2. Deriving team standing from it routes around §5.4's step-up completely.
Changing the reader without changing the writer is not a design. Ruling 9 made a directory row a hint on purpose; revision 1 made it authoritative and left the writer alone.
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 epoch claim is an invalidation hint, and it needs its own exception in home §5.4. Revision 2 said the claim passed that test unchanged. It does not: home §5.4 admits a non-identity claim when it is drawn from a closed enumerated set and fails cosmetically, and a monotonically rising epoch is neither of those. What makes it safe is a different argument and deserves its own words — the instance decides nothing from the claim. A claim it cannot parse is ignored. A claim that differs from what it holds buys exactly one call-8 refetch, and call 8's answer is what it enforces. So a hostile claim costs one request and can neither admit nor exclude anybody.
The exception is scoped to the call-8 path and nothing else. An instance answering call 7 sees the same assertion and therefore the same claim, and it must not invalidate anything on it — that would be an instance making a decision from a home-supplied claim, which is precisely what home §5.4 forbids and what this exception is narrow enough to avoid. Refetching a roster is not a decision; withholding an activity row would be. (Raised by the activity-pull session, 2026-09-05.)
A.1 records that exception in the home spec, which owns the test; this document cites it rather than asserting the old test already covered it. A person whose sign-in reveals a stale roster is admitted against the fresh roster, never against the claim. (GPT-6 review, finding 12.)
The copy has a maximum age. Every carrier of the epoch — call 2, call 5, call 8, the assertion — needs a person or a wake. Sibling calls carry no epoch, deliberately, since a sibling has no business knowing the roster's version. So an instance kept warm by sibling traffic alone, admitting nobody and never sleeping, has nothing to tell it that its roster moved, and §5.4's sentence would not be true of it. The copy therefore carries a TTL and is refetched on expiry. The number's reason, in the comment the contract module requires: it is the worst-case lag between a removal at the home and its effect on an instance nobody signs in to. Measured with §14's deadline rather than guessed here. (Review pass, 2026-09-05.)
Call 8 is paged, and a page is only meaningful inside one epoch. S2 and S6 cap a batch the caller supplies; call 8 is the first call whose response size the caller does not control, so it needs a cursor as well as a cap. The cursor is bound to the epoch it was issued in. Fetching page one at epoch 10 and page two at epoch 11 would otherwise compose a membership set that never existed at either — skipping a removal, or dropping an owner. So: the home refuses a cursor from a superseded epoch and says which epoch is current; the instance stages every page and replaces its roster atomically only when a complete set for one epoch has arrived; and a partial or failed fetch leaves the previous roster in place. A partial roster is never published, and an empty response is never read as "nobody is a member". (GPT-6 review, finding 10.)
A refetch that fails does not silently extend the copy. The TTL says when to refresh; it has to say what happens when refreshing does not work, or "removed within the TTL" is a promise about the happy path only. Past the TTL with no successful fetch, the roster is stale, and team standing stops being established for anybody — including people already in a session. Revision 3 let existing sessions run on, which on a 30-day sliding session is the outage-length revocation tail the paragraph claimed to reject, bought at the price of suite-wide UNKNOWN. A stale instance therefore serves only what an individual grant reaches, since a grant is instance-side and needs no roster, and answers UNKNOWN on S2 rather than NOT_VISIBLE — the gap is named, per §4.3, because an unreachable home is a synchronisation failure and not a denial.
This costs availability on purpose, and the cost is real: a team whose home is unreachable loses team-standing access until it returns. The alternative is that an unreachable home lets a removed member keep their access for as long as the outage lasts, which is the thing ruling 4 was adopted to end. (GPT-6 finding R3-2; Fable finding 5.)
A restored home gets a new roster generation, because refusing a lower epoch is not enough. Revision 3 said an instance never accepts an epoch below the one it holds. That stops the regression and not the damage: a restored home simply issues 10, 11, 12 again over the recovered roster, and two writes later the instance is accepting an epoch it considers fresh carrying members it had already removed.
So the roster carries (generation, epoch). The generation changes whenever the home cannot prove continuity with the epochs it last issued — any restore from backup. An instance seeing an unfamiliar generation holds its roster stale (above), admits nobody on team standing, and refuses to publish the new one until an owner confirms it at the directory origin. A restore is a human event and it should cost a human action; the alternative is that the recovery path is also the readmission path.
A home that loses the memberships table entirely has no rebuild path — call 2 cannot write it (ruling 12) and no channel reaches a hibernating instance. Home §10's "loses credentials, not access" is therefore false for the roster, and A.1 amends it. Recovery is the instances' copies plus an owner's confirmation, or it is re-entry by hand. (GPT-6 finding R3-3; Fable finding 4.)
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), and nowhere else. 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.
Call 2 cannot write membership (ruling 12). It writes directory rows, which say where a person has things, and no directory row grants or revokes team standing however its kind reads. The membership record is the only source for call 8.
The first owner is seeded by the drain, from the sealed queue payload home §7 already carries, because gating a membership write on an owner needs an owner to exist first, and that circularity has to break outside the roster.
A member removed at the home is removed everywhere within one epoch, and on an instance that admits nobody, within the roster TTL (§5.3). 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
Ruling 13. "A compromised home" is three different events and they cost three different things. Revision 1 ran them together, and it did so by taking home §2's careful sentence about a compromised directory and widening it to the home. Home §3.1 keeps the two apart and records that its own first draft collapsed them and was wrong to.
A leaked database reveals the graph, the roster, and — new with ruling 12 — who owns each team. It reveals no content.
A compromised directory origin can add a member. A member reads everything team-visible in every instance of that team, through each app's ordinary front door, because that is what ruling 1 means. It cannot read an individually granted or guest-only item, write as a person, or grant a per-item right. This is the delta ruling 4 and ruling 12 actually introduce.
A compromised credential origin can sign in as anyone, owners included. It holds the signing key (home §5.3), the instance's grants join is keyed on the sub the home asserted, and home §5.4 already calls a hostile sub account takeover. No membership row is needed and none is left behind. This is not new and ruling 4 did not cause it: it has been true since home ruling 1 put every credential on one origin, and a passkey does not help, because the passkey lives there too. It is cited here, not introduced here.
The step-up is not a bound on any of them, because the compromised authority is the one performing it. Neither is the directory row or the notification: those are what honest code leaves, not what a compromise must. Home §3.3's idiom applies — said plainly rather than counted as a fix.
What is left as a real bound, and it is narrower than revision 3 said. Against the directory tier: an instance's own grants decide what an admitted member may read, so adding a member does not reach an individually granted item. Against the credential tier: nothing here does. An attacker who can sign in as Ana has Ana's grants, reads what Ana reads and writes what Ana writes, and no instance-side check distinguishes them — that is what "signs in as anyone" means, and the bound has to be read as absent rather than restated in weaker words.
Revision 3 conceded the credential tier in one paragraph and then wrote the reassuring clause back in two paragraphs later, which is worse than revision 1's honest-but-wrong single bound. (GPT-6 finding R3-1.)
The Pegamento filing changes shape. It is not that this document diverges from §10.3 on membership. It is that the home was never a §10.3 glue: "a compromised glue grants nothing" is written for a registry, and home ruling 1 makes the home an identity authority. §14 files that, not the narrower claim.
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 owns the wire shape. The home renders and holds rendered output only, under two ages (§4.3). Not in the contract module yet — §12 step 2 is S1, S2 and S3. When it arrives, the fixtures cite that spec's gates 10–20 rather than restate them, or this contract rebuilds §2's drift one layer down. | | 8 roster | instance → home | consumer token | which instance asks | §5.3. Scoped to the caller's team. Paged; the copy has a TTL. | | 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: a passage crosses no request boundary without an S2 VISIBLE in the request that carried it (memoria §8.4, gate 26). Asker is a typed subject (§3); the answer is intersected with custody (§4.3). | | 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. Typed subject and the custody intersection, as S2. | | 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).
S6's shape is a settled decision of Paul's, not an API style. memoria decision 10 — "memoria never does arithmetic. A number comes from a cell, or from Sheets' engine via S6" — was reached by two models independently and forbids one specific failure: a model averaging numeric strings it retrieved. That failure stacks incomplete recall on wrong operand selection and arithmetic done outside the engine that owns it, and it cites its sources correctly while being wrong, which is the hardest failure in this product to catch. So S6 takes an enumerated function — SUM, AVERAGE, MIN, MAX, COUNT — over cell refs, and never expression text, because a call that accepted expression text would let a consumer grow into a formula author one release at a time.
Recorded here because §6's row said what S6 takes and not why, and a reader tidying an enumerated function into a general one would have been removing a ruling without knowing there was one. (memoria decision 10, Paul; surfaced by the memoria session, 2026-09-05.)
S6 is a call, and until its step it is a specification without a fixture. Ruling 7 says fixtures are the interface, so a call with no fixture has no interface — it has a description. §12 step 4 builds it, after memoria slice 1 and before slice 2, because Sheets is its only server and memoria its only caller and neither is useful to it alone. Until then a consumer cites S6's specification here and not a golden fixture, and A.4 says so rather than implying one exists.
S4 and S5 are not calls, and no number is reserved for them. memoria §6.1 resolved S4, the tombstone, into a feed row — gone covers deletion and departure from team-visibility indistinguishably — and S5, consumer expiry, into the per-consumer TTL, since expiry is the decommission verb. Both are settled; they are recorded here because a table that runs S1, S2, S3, S6, S8 leaves two holes, and a hole in a numbered contract is somewhere a later call falls. (Review pass, 2026-09-05.)
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.
Both answers carry the same envelope. §4.3 requires a derived artefact to carry the provenance of every source it was built from, transitively. That rule had no mechanism at the one moment it matters — when content crosses from one app's store into another's — so it is stated here, for both write paths.
A write carrying content derived from items in other apps carries a provenance envelope: per source, the app of record, the item id and the revision read. The receiver stores it as part of the item, not as metadata it may drop, and re-checks it on every disclosure path. Which check depends on whether the path has a person, because two of them do not:
| Path | Has an asker? | The check | |---|---|---| | An ordinary read, an export, an activity row | Yes | S2 to each app of record, for that person | | An onward S3 fetch | No — S3 is person-less by definition (§6) | Custody: every source still team-visible, or S3 answers 404 | | A read through a read-only link | No — a link holder is no roster subject | The item carries no link while any source is not itself link-shared |
Revision 3 wrote "the sources still authorize the asker" for all of them, naming an asker on two paths that have none and leaving gate 6b unwritable. The person-less paths fall back to the predicate that needs no person, which is the same split decision 3 has always drawn: custody for what may be held and served without an asker, authorization for what a person may read. (GPT-6 finding R3-4; Fable finding 3.)
Withholding is whole-artefact, never per-passage. If any source stops authorizing, the entire derived item is withheld and the gap is named. A synthesis carries no map from its sentences back to its sources, so an envelope that claimed to strike "the passage from the revoked sheet" would be asserting an alignment nobody computed. Removing the wrong sentences leaks; removing the right ones by luck is not a design. (GPT-6 finding R3-5.)
Two consequences, both counter-intuitive enough to state:
- A new id is not a new authority. Saving a memoria answer into Docs gives the result a Docs id; it does not give it Docs' authority over a Sheets cell it quotes. Ana's grant on the new Doc is necessary and not sufficient. Revoke the sheet and the saved answer loses that passage, in Docs, on the next read.
- A source the receiver cannot check is a source it must not accept. memoria's decision 7 and gate 17 forbid it serving a sibling-facing endpoint at all, so today a hand-off carrying memoria-owned material has no app of record to ask. Until A.4 opens that surface with its own decision and leak entry, a write whose sources include one is refused, rather than accepted with an envelope nobody can verify. A person's confirmation establishes their intent; it does not stand in for an authorization the receiver cannot make. (GPT-6 review, finding 3.)
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.
The receiver checks the origin and asks the person to commit. What makes the cookie ride the POST — same registrable domain — is also what makes this a write any same-site page can drive without the person meaning it, and a create endpoint that commits on receipt is a drive-by. Home call 6, whose shape this borrows, carries two protections this borrowed without (home §5.6): it validates hosts against the consumers allowlist, and it binds the flow to the browser that began it. So:
- The
Originheader must match a row in the receiver'sconsumerstable, which §4.1 already makes an origin allowlist. It holds the §4.1 barrier on a path that otherwise bypasses it. - The receiver renders what it received and the person commits it. One screen, one button, the person's own session. This is what makes the document Ana's act rather than memoria's, which is the entire claim of §7.1; without it §7.2's three constraints are reachable by any sibling through §7.1, and the distinction between the two sections collapses.
Both cost more than revision 2 said they did. It claimed no new machinery and no extra round trip. Neither is true, and the third hand established it against the pinned dependencies rather than by reading the specs: memoria's rastrillo default CSP sets form-action 'self', which stops the form on the way out; Docs mounts csrf.Protect above every route and its SameOrigin check refuses a same-site cross-origin POST, measured at 204 same-origin against 403 same-site; and for a person already signed in, receipt-then-commit is a second POST where revision 1 wrote on the first. So the real bill is a narrowly scoped sender form-action allowance, a dedicated receiving route carrying its own origin guard, and one more round trip — and gate 7 has to run through that middleware, since a handler-level fixture passes while a browser does not. Still scriptless, which was the property worth protecting.
Receipt and commit are two requests with different rules, and revision 2 left the second unspecified. Split:
- Receipt. The sibling's POST arrives, its
Originis checked against theconsumersallowlist, and the body is held as pending state: bounded, expiring, redeemable once, bound to the browser and — after sign-in — to the authenticated person. Nothing is created. - Commit. The person's confirmation POST comes from the receiver's own origin, against that pending state, and what it commits is the body the server already holds. The sibling-origin rule does not apply to this request and would wrongly reject it. Because the body is fixed server-side a second POST cannot substitute a different one, and because the pending state redeems once, a replay creates nothing.
Without the split, a bare second POST carrying a confirm flag can be sent by the same allowed sibling, and a screen appearing somewhere in the flow is not evidence that this browser saw this body.
The sending side has a boundary too, and it is earlier than the receiver's. §4.1's rule that a client refuses a host whose <team> label differs governs the sibling HTTP client; an HTML form action is not that client. A form action misconfigured to another team's Docs host puts the body on the wire before any Origin check at the far end can refuse it, which breaks §0's "across" test literally: the byte arrives, and being refused on arrival is not the same as not arriving. So the destination is validated against the sender's own <team> label before the form is rendered, not after it is submitted. (GPT-6 review, finding 4.)
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.
A link holder as a writer. Revision 3 built one, and it was wrong twice over: Sheets ruling 12 had already rejected it (ruling 15), and it would have put an actor on the write path that no app can name — which ruling 9 forbids and §7's provenance envelope cannot carry. A link reaches sign-in; sign-in mints a grant; the grant names a person. There is no fourth actor.
---
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.
Which recipient is the app's decision, never the team's. A team chooses whether, and never whom: a team-configurable endpoint would let an owner reinstate a proxy while every answer still printed "one named provider" (memoria decision 13, Paul, 2026-09-05).
Docs §11.2's rejection of conversion APIs stands under the first clause. Its own reason is that the egress sends customer documents to a third party, which is what the passage says; "unavoidable and unnamed" is this document's narrowing of it under ruling 6, not a reading of what it already meant. A per-team optional converter with one named provider could satisfy §8 and would not have satisfied Docs §11.2 as written. Ruling 6 authorises the change; the citation must not dress it as a discovery. (GPT-6 review, finding 15.) 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.
- A compromised sibling reads everything team-visible in its team, across every app, through S3, and everything that was team-visible when it was ingested. This is what "one trust domain" means and there is no version of ruling 1 without it. Custody buys two things and not a third: it cannot read an item that was never team-visible — private, granted, or link-only (§4.2) — and an honest consumer retires what leaves; a compromised one retires nothing, and no revocation reaches a copy it already holds (ruling 14).
- 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. The bound holds only because S2 is intersected with custody (§4.3); without the intersection it would also learn which private items exist, which item 1 does not cover and §4.2 forbids.
- 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.
- A compromised home is three events (§5.5, ruling 13). A leaked database gives up the graph, the roster, and who owns each team. A compromised directory origin can add a member, and that member then reads everything team-visible in every instance of the team. A compromised credential origin can sign in as anyone, owners included, with no membership row and nothing left behind — not new, and not caused by ruling 4: it is home ruling 1's standing cost of one credential origin. The first two cannot read a private, granted or link-only item, write as a person, or grant a per-item right. The third can do all of it, as whoever it signs in as. Neither the step-up nor the notification bounds any of them, because the compromised process is the one performing them.
- 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.
- 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.
- A rendered page is a copy in a browser, and revocation never reaches it. True of every surface in the suite: a Docs page, a memoria result, the home's activity list. Live authorization bounds what the next request shows; nothing bounds what the last one already did. Stated so nobody argues a surface is safe because it re-checks (memoria §3.2).
- Every instance of a team now holds the whole team's roster, with roles, rather than only the people it had heard of (§5.3). A compromised instance therefore learns the membership and the owners of its team, which under the old per-instance tables it could only partly infer.
- Owner is a new plaintext class at the home (ruling 12). Publishing the database now identifies each team's owners. Home §0's sentence and gate 8 change to say so; the roster is not the no-op for that claim revision 1 assumed.
- The home learns when a person visits their front door, at per-visit granularity, and tells that person's instances so they can answer call 7 (activity design §2.2). And once the home makes outbound calls to instances as a matter of course, its outbound traffic stops being a compromise signal (activity design §2.2(b)). Both were disclosed there and belong here too.
- A cacheable call-7 response is a cross-person leak at the platform edge, not merely a slow one. The shared edge would serve one person's rows to the home's next pull for another, so a cacheable response is a failed pull rather than a fast one.
design-activity-pullgate 16 tests it; recorded here because the failure is in the platform between two apps and neither app's own tests would see it. - A link capability is bearer-shaped (ruling 15). Whoever holds the URL reads or writes the item, the app cannot name them, and a forwarded link is a grant nobody recorded. This is what "anyone with the link" means and there is no version of it that is not this.
---
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.
- 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.
- S2 total decoder. For any 256-pair request, the client yields exactly one result per pair or
UNKNOWNfor the whole call. Tested with short, duplicate, malformed and non-2xx responses, and with an asker subject of a kind the receiver does not key on, which isUNKNOWNfor the whole call and neverNOT_VISIBLE. - Custody, one fixture per rung. S3 answers 200 for a
teamitem and 404 forprivate,granted, guest-only, link-shared-but-not-team (ruling 15) and deleted. The status and body are identical for "not team-visible" and "does not exist". S2 answersNOT_VISIBLEfor the same item id in each state, byte for byte the answer for the deleted one — the asker holds a live grant in theprivateandgrantedstates and a former grant in the deleted one, since no schema keeps a live grant against a deleted row. Add team-plus-link (team-visible because of the rung) and a link added or revoked after ingest (no change to custody either way). - No cross-team dial. Zero connections to a host whose
<team>label differs —acme-evil,acme.docs.oficina.works.evil— and zero follow-up connections when an allowed host answers with a redirect to another label. The literal "without connecting" cannot be written for the redirect case: a client must reach the allowed host to be told to redirect. Two fixture servers, or an instrumented transport that counts connections per host. The hand-off's destination check (§7.1) runs in the same gate, before the form renders. - Roster, on a fake clock. A member removed at the home, holding no other grant, gets an empty call-7 list and
NOT_VISIBLEon S2 from every instance after one epoch, and within the roster TTL on an instance that admits nobody. A guest fixture keeps exactly the activity its own grant reaches: losing team standing does not revoke an instance-side grant, and asserting an empty list for that person would implement a complete-offboarding policy §5.2 has not agreed to. A failed refresh past the TTL yieldsUNKNOWN, notNOT_VISIBLE, admits nobody new and stops serving team standing to an existing session. A home rewound — both to a lower epoch, and to the same epoch numbers under a recovered roster — readmits nobody, and an unfamiliar generation holds the roster stale until an owner confirms. A failed middle page leaves the previous roster in place. A lone instance, given a home hostname in a fixture, makes no request to it. - 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. The actor is derived from the authenticated consumer, never from the request body, and a spoofed actor field is refused. 6b. Provenance survives the hop (§7), one case per path because the paths differ. Write an item into app B derived from sources in A and C. Revoke A's source for a person: B's ordinary read and export withhold the whole artefact and name A — never a partial redaction. Make A's source non-team-visible: B's S3 answers 404, on custody, with no asker anywhere in the call. Give B's item a read-only link while A's source has none: the link is refused. Assert a write is refused when a source's app of record serves no S2 at all.
- The hand-off, through the real middleware. A handler-level fixture passes where a browser does not, so this runs against the app's actual CSP and CSRF stack: receipt refused when
Originis outside the consumers allowlist; a POST without a session lands in sign-in and returns to the pending state by nonce; a body over the cap refused with no partial write; commit refused when forged, replayed, sent with a changed body, or sent from a different session; the absent andnullorigin cases decided explicitly; a foreign-team destination refused before transmission; and one successful same-team flow end to end. - Publish the home's database. Counts, team names, memberships — and, new with ruling 12, who owns each team. Home §0's sentence changes rather than survives; revision 1's claim that the roster added no column class was wrong. The test runs against the actual persisted schema and its failure paths, not one roster response fixture, because a fixture cannot prove what an entire database holds at rest, and a column audit alone misses new information encoded in existing columns.
- Citation currency. Each app spec's Consumed suite rulings section names a doctrine commit and the doctrine revision it read, and the script checks the revision actually belongs to that commit. The revision is in the status block and is bumped by any normative change, wherever it lives — §§3 to 8 bind as hard as §1 and §6. Revision 2 scoped the bump to §1 and §6, which let a changed hand-off rule or stale-roster policy pass currency indefinitely. The script fails when a spec is more than one revision behind
main; that grace period is a deliberate compatibility allowance and not evidence the consumer meets current requirements. ("One ruling behind" was not measurable at all — a change to §6 adds no ruling. Review pass 2026-09-05; scope corrected from the GPT-6 review, finding 16.)
---
12. Sequencing
- ~~This spec, reviewed.~~ Done twice, 2026-09-05: Claude Opus 5, then GPT-6 via Codex. See the status block. Revision 3 answers all sixteen of the second review's findings; the third pass is §14's first open item.
- The four amendments (Appendix A) land in home, Docs, Sheets and memoria. This closes
oficina/docsdiscussion 33 and unblocks memoria slice 1, which its own §13 says is blocked on agreement, not code. A.1 is now the largest of the four, because ruling 12 gives the home a table it did not have and amends home §7's sentence that ownership is the instance's business. - The contract module — built, and not importable. The code, the fixtures and the harness are on
mainand their tests pass. Nothing can depend on them:go get amadan.net/oficina/suitefails because the hub redirects?go-get=1to a login page, so the toolchain never sees ago-importtag. Until that is fixed this step is done in the sense that the artefact exists, and not done in the sense ruling 7 means: "every app imports the module" has no consumers available to it.
Three ways out, and the choice is Paul's, because each is hub or hosting rather than doctrine: the hub serves go-import for repo paths without auth, which is the only one that works in CI and fixes every app at once; a documented GOPRIVATE=amadan.net/* with credentials CI can hold, if the hub can serve the tag behind auth; or the module moves to a resolvable host, which contradicts ruling 10 in hosting but not in structure.
What is deliberately not being done: a local replace directive, which works on one machine and breaks CI while making a go.mod a machine-local artefact, and vendoring, which is the second copy ruling 7 exists to prevent and defeats "fixtures are the interface" the first time a fixture changes. The memoria session declined both and recorded itself blocked, which is the right answer.
Types and fixtures only: S1, S2, S3, the hostile-hostname fixture, gates 1 to 4. No server half yet. Docs moves its module path.
- memoria slice 1 against the module, with Sheets and Docs serving S1 to S3. The second adoption corrects the fixtures.
- S6, before memoria's slice 2 and not before its slice 1. Sheets serves it, memoria calls it, and nobody else does either: an enumerated aggregate over cell refs, capped at 64, never expression text, carrying the typed asker and the custody intersection S2 carries.
Slice 1 does not need it — that is S1, S2, S3 and memoria §4.1, all of which the module already has. Slice 2 is memoria's model layer, and the numeric and aggregate class of its §8.2 router is the one query class S6 answers. Revision 6 put this step before slice 1, which implied a dependency that does not exist; revision 5 gave it no step at all, which let a numbered call read as a finished one.
- The home's identity layer (home §13 step 4) and, on top of it, call 8 and the roster (§5). Gates 5 and 8.
- Call 7, the activity pull, once the home can mint assertions (home §13 step 10).
- The hand-off from memoria to Docs. Gate 7.
- S8 and the designation UI in one receiving app. Gate 6.
Steps 2 to 4 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 | | An instance-side check on membership changes — refusing a roster addition, or a call-7 arrival, for a person with no recent session; an owner countersignature on roster writes | A designed mechanism. Ruling 13 concedes a compromised home rather than bounding it, and this is the shape a bound would take. Recorded so it reads as absent by decision, not by oversight | | A link share that a sibling can index | Never, under ruling 15. A link is a capability, and a capability that becomes searchable by the team is a different grant than the one made |
---
14. Open
- A third review pass, on revision 3. Two models have read this and the second returned request changes; revision 3 answers all sixteen of its findings and adds four rulings, so what needs reading now is different text. Rulings 12, 13 and 14 are Paul's, taken on Fable's recommendation. Ruling 15 is this session's proposal from Paul's product requirement and he has not ruled on it — a reader should treat it as the one rung of §1 that is not yet settled.
- What the second review said, kept for the record.
CODEX-FINDINGS.mdis unedited beside this file; the map from its findings to revision 3 is in the status block. Its six trust-boundary findings were: finding 1, the instance-token write path into the rows §5.1 made authoritative → ruling 12; finding 2, the compromised-home bound → ruling 13; finding 6, custody as rendering rather than erasure → ruling 14 and §0; finding 3, provenance missing from both write paths → §7's envelope; finding 5, the failed refetch and the rewound home → §5.3; finding 4, the commit protocol → §7.1. The ten at P2 are answered in place and cited where they landed. - Pegamento filing. §5.5's divergence from Pegamento §10.3, and ruling 1's application of §1 to a suite, both to
carlos/pegamento. - memoria's citation baseline. Four things this document cites — decisions 13 and 14, gate 26 and §8.4 — resolve only in memoria's unmerged
tensorx-providerworktree at8dfc3c9, not on itsmainat5869e73. The doctrine was read against a branch without saying so. Asked atoficina/memoriadiscussion 1; when it answers, either the citations move to the commit that carries them or the claims come out. Nothing else in this document depends on them. - The
/auth/begindial. rastrillo's sign-in classifier fetches.well-known/keymailon 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 inrastrillo/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 and revision (gate 9).
Where a replacement below still paraphrases a rule, the paraphrase is the defect, not the fix. §10 and AGENTS.md say a rule lives here once and an app spec names it. A replacement that puts a second copy of a boundary into an app spec and then adds a citation beneath it has left the second copy to drift, which is the failure §2 diagnosed. Read each amendment as delete the restatement, name the ruling; the wording given is there to identify what is being deleted, not to be pasted in as a new copy. (GPT-6 review, finding 14.)
A.1 oficina/home, spec §1 and §2.2
Add ruling 23, not 22. The home spec already has a ruling 22 — team selection is navigation state — so the number in this appendix was taken before it was ever used. Landed as 23 on oficina/home branch suite-amendment. (GPT-6 review of revision 3, finding R3-12, confirmed against the home spec on 2026-09-05.)
Ruling 23: "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~~ — already landed at 2b68301, along with the other six places that amendment touched. A.1 said "complete" because it was written before that commit; a to-do list in a document whose edits have landed is a trap for the next reader, and the home's own spec retired the future tense rather than ticking it off. Kept here as a record of what A.1 asked for and where it went, not as work outstanding. The original wording follows.
Ruling 18's 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, paged with an epoch-bound cursor.
A.1 is the largest of the four, because ruling 12 gives the home a record it does not have. Add a memberships table, (team, person, role), written only at the directory origin by an owner behind step-up, and seeded by the drain with the first owner from the sealed queue payload §7 already carries. Call 8 reads only that table. Amend §7's sentence that who counts as an owner "is the instance's business… and the home never learns the answer": the home now learns it, for team standing only, and per-item roles stay the instance's. Amend §6 so a directory row is explicitly a hint that never grants or revokes team standing, whatever its kind says, and call 2 cannot write membership. Amend §0 and its gate: publishing the database now identifies each team's owners, which is a new plaintext class (suite gate 8).
Amend §10. "Loses credentials, not access" is false for the roster: a home that loses memberships cannot rebuild it — call 2 cannot write it and no channel reaches a hibernating instance — so recovery is the instances' copies plus an owner's confirmation. The roster carries (generation, epoch) and a restore mints a new generation (suite §5.3).
In §5.3's claims table add roster_epoch. In §5.4 record a narrow invalidation-hint exception rather than claiming the existing test covers it: the epoch is not a closed enumerated set and does not fail cosmetically, and what makes it admissible is that the instance decides nothing from it — an unparseable claim is ignored, a differing claim buys exactly one call-8 refetch, and call 8's answer is what is enforced. In §14 move step-up for instances to built-when: "roster writes (suite §5.4)". (GPT-6 review, findings 1, 12 and 13.)
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 wherever sibling consumer tokens are configured." The sibling switch is independent of shape (suite §3): §12 steps 2 and 3 have Docs and Sheets serving lone peers before the home exists, so "in suite shape" would forbid the sequencing this document depends on.
§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: the rejection stands, and the reason changes rather than being restated. Docs' own words are that a conversion API sends customer documents to a third party. Suite §8 is narrower — a per-team optional converter with one named provider would satisfy it and would not have satisfied Docs §11.2 as written — so the amendment reads "Rejected under suite §8. Ruling 6 governs; this is a qualification of the original rejection, not a restatement of it." This closes discussion 33 by agreeing on the rule, not by claiming both specs always meant the same thing. (GPT-6 review, finding 15.)
Docs' link sharing is read-only (suite ruling 15). An "anyone with the link can edit" share is a link to sign-in that mints a guest grant, which is the shape Docs already uses for its commenter link; the writer is then a named guest and §7's envelope can carry them.
Docs is the first receiver of the hand-off, so its create endpoint takes suite §7.1 whole: the Origin check against the consumers allowlist at receipt, pending state, and a commit from Docs' own origin. Its pinned csrf.Protect refuses the receipt POST today and its route needs an explicit replacement guard; memoria's CSP needs a narrowly scoped form-action allowance for the sending side. Gate 7 is Docs' to run first, through that middleware rather than around it. (Review pass 2026-09-05; extended from the GPT-6 review, findings 4 and 9.)
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."
Ruling 12 — "link sharing is viewer-or-commenter, never editor" — needs no amendment. Suite ruling 15 adopts it suite-wide and cites it; Sheets keeps it as the original. This is the direction §10 asks for, and revision 3 went the other way.
§9: as A.2's §9.
AGENTS.md: keep "a contradiction gets fixed in the same commit" and add a second rule beside it. The existing rule is about that file and the Sheets design spec, both of which live in Sheets, so it is a local consistency obligation that works and was never the thing that broke. Revision 1 read it as a cross-repository claim it does not make. Add: "a contradiction with the suite doctrine is a bug in this spec, fixed by citing the ruling." (GPT-6 review, finding 15.)
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.1: intersect S2's answer with the custody predicate (suite §4.3), and type the asker subject (suite §3). The grants join as §4.1 states it — Sheets §4.5's "plus rows granted to me individually" — answers VISIBLE for an item that is not team-visible, which is the leak §6.1 closed on S3 and the S1 visibility flag, reopened on S2. (Review pass, 2026-09-05.)
§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 — with S6 cited as a specification rather than a fixture until §12 step 3 builds it. The module on main is S1, S2 and S3; there is no S6 fixture to conform to, and memoria's §8.7 numeric path would otherwise cite one that does not exist. Adopt the rest of A.4 without waiting for it. 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. Decisions 13 and 14 stand unchanged: §8 adopts 13's "the recipient is the app's, never the team's", and ruling 3's "never cached" is the invariant 14 rests on.
Decision 4 widens from "named on every answer" to "named on every answer and every derived artefact", because suite §8 says "every artefact it produced" and means it. An answer is transient; a derived artefact (§5.2) outlives the query and is regenerated when a source changes, so it can carry a different provider from the one that first made it. §5.2 records the producing model alongside provenance so a regenerated artefact never inherits the wrong name. Raised by the memoria session, 2026-09-05.
§4.1 also: define the source-behind case. STALE covers a source that has moved past the requested revision; a source restored from backup can be behind it, and memoria's own §13 records this open. A revision the app of record does not recognise is UNKNOWN, withheld loudly — never VISIBLE, which would authorise a revision nobody can vouch for, and never NOT_VISIBLE, which is the silent value and the exact mistake STALE was added to fix (suite §4.3). (GPT-6 review, finding 11.)
§9.1 and gate 17: decision 7's "no sibling-facing endpoint" now blocks a hand-off whose sources include memoria-owned material, because suite §7 requires the receiver to check every source with its app of record and memoria serves nobody to ask. Either open that surface with its own decision and leak entry, as memoria §12 requires, or record that such a hand-off is refused until it is. Not an editorial change: it decides whether "save this answer as a Doc" works for answers built on memoria's own uploads. (GPT-6 review, finding 3.)
§11: memoria's person-ref and home dependency on slice 1 survives A.4 as written. Slice 1 runs in lone shape against sibling tokens (suite §3, §12 step 3), so the dependency is on the typed asker subject, not on the home. Correct it, or slice 1 stays blocked on something that is not actually in its way.
§4.3 and its admission fixtures: retiring S7 leaves the admission gate citing a call that no longer exists. Replace the fixtures too, not only the prose.
§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 15, with the doctrine's commit and revision, when calendar's first screens merge. Calendar has no commenter role (verified in internal/calendar/models.go), so §5.6's "not before" applies to it unchanged.