oficina / home Public

Clone
git clone https://amadan.net/oficina/home

Plain git — no account needed to clone.

Download

Download this file

1# πŸ€– The Oficina home
2
3The suite's glue: identity, directory, provisioning, person preferences.
4Consumed by `oficina/sheets` and `oficina/docs`. **It is not an application
5and holds no documents.**
6
7This file is binding. Where it disagrees with a general skill, this file
8wins. Where it disagrees with the design spec, the spec wins and this file
9is wrong β€” fix it in the same commit.
10
11**The design:** `docs/superpowers/specs/2026-08-30-home-design.md`. Β§1 is
12the settled-decisions table: dated, attributed, with the rejected
13alternative beside each.
14
15---
16
17## The one rule
18
19> **The home is glue: thin, blind, replaceable. It knows that you exist and
20> where your teams are. It never stores a readable address, and it never
21> retains what is in anything.**
22
23**"Retains", amended from "learns" on 2026-09-05** β€” spec ruling 18 and
24`design-activity-pull`, approved that day. The falsifiable form is unchanged **by that amendment** β€” ruling 23 later changed it, and the form above is the current one.
25**It means holds without a bound**: the home does see titles and does keep
26them in RAM across requests, bounded by a TTL and a hard maximum; at rest is
27the unbounded case, and that is what it never does. "Blind" above is Factor
28XI's category label, not a claim it never sees one. Spec Β§0 carries the full
29gloss.
30
31The falsifiable form: **publish this database in full and the worst anyone
32learns is how many people exist, what their teams are called, which person
33refs belong to which team, and which of them own it.** No document. No cell.
34No readable address.
35
36**"And which of them own it" arrived with ruling 23** and is the whole cost
37of the membership roster: an owner row names a person ref from one team plus
38a fact that is usually public, where the old wording needed several teams
39and an outside fact. Spec Β§0 and Β§3.3 carry the full reasoning.
40
41*Two absolutes were argued and lost, and stay lost:* **"never stores your
42address"** β€” a magic link's address is at rest between send and click,
43sealed (Β§3.2), not absent β€” and **"some unidentified person"**: a team-set
44fingerprint plus one outside fact names the row (Β§3.3).
45
46Three gates enforce it (Β§12; Β§13 step 1 lists all three): a **plaintext
47grep** over the raw SQLite file and WAL **plus this app's store objects**,
48against a seeded corpus of known addresses; a **no-address-at-rest** test
49asserting the schema has no address column and that a full sign-in plus
50directory upsert **plus rekey plus the roster seed verb** leaves no readable
51address anywhere, **WAL
52included**; and **no-content-in-logs** (gate 9). **Gate 9 needs no database** β€” it scans strings, and `cmd/home/main.go`
53emits log lines β€” **so it was never sequenced behind one**, and it landed
54beside the skeleton. Gates 1 and 2 do need one, and it now exists: gate 1
55scans the raw file, WAL and SHM for canaries, and gate 2 runs in two
56halves, a static read of every migration and a full sign-in asserted to
57leave no readable address anywhere. Gate 2's expensive half must be
58**extended, not replaced**, when the directory upsert, the rekey and the
59roster seed verb land β€” it asserts the sign-in third of its sentence
60today. A guard written after the code it guards is a guard written to
61pass. (Stated as the rule rather than as a status: which gates exist today
62is a question for `git log`, and a status line in a binding file is a
63sentence that goes stale without anyone noticing.)
64
65---
66
67## The four things this service never does
68
69Each of these is something somebody will propose, in good faith, as an
70improvement.
71
721. **Never *store* content from an instance.** No previews at rest, no sheet
73 thumbnails, no cell values, no titles in a database, a log, an error
74 string, a metric label or an on-disk cache. It **may render**, transiently
75 and per request, **and only to that person**, what an instance hands it
76 for the person it hands it for β€” and nothing wider: no search over pulled
77 content, no cross-person aggregation, no analytics, no operator view, and
78 no retry queue holding a response body.
79
80 **"Per request" does not forbid the cache the spec approves.**
81 `design-activity-pull`'s Β§7 keeps pulled titles in RAM across requests,
82 bounded by a refresh TTL and a hard maximum. Read alone, "transiently and
83 per request" would rule that out, so read it with spec Β§0's gloss:
84 **retains means holds without a bound**, at rest is the unbounded case,
85 and the RAM hold is permitted because it is bounded and tested.
86
87 **Amended 2026-09-05** from *never render*, on spec ruling 18. "Renders"
88 was doing custody's work: the clause exists to stop the home becoming a
89 place documents live, and storage is what makes it that. It also gained a
90 test β€” spec Β§12's gates 1 and 9 grew a corpus of known titles, URLs and
91 actor strings that day, so *never stores* has a grep behind it where
92 *never renders* had none.
932. **Never hold a per-item grant.** Which sheets you may edit, which
94 document a guest may comment on: all instance-side, decided there on
95 every request. **The home does hold the membership roster** β€”
96 `(team, person_ref, role)`, ruling 23 β€” so it knows who is on a team and
97 who owns it. It never knows what they may do with any particular thing.
98 This replaced *"never hold an authorization model"* on 2026-09-05; the
99 line moved from *facts versus may-this-person* to **resources the home
100 owns versus an instance's items**, because the roster is a person-level
101 entitlement the home now holds on purpose.
1023. **Never cross a team.** Data does not cross a team label; a team's
103 instances across apps are one trust domain (spec ruling 23, suite ruling
104 1). This replaced *"never federate β€” data does not cross an instance
105 boundary"* on 2026-09-05: the boundary was drawn in the wrong place, since
106 `acme.docs` and `acme.sheets` are the same team and always were. **Sharing across teams** is a guest grant on the
107 instance, never a copy. **Team standing is the roster**, not a grant and
108 not a directory row (spec Β§5.5, Β§6: a directory row never grants team
109 standing whatever its `kind` says). Β§6 keeps the
110 two apart deliberately: "a row implying an org membership someone does
111 not have is a lie the directory tells on the org's behalf".
1124. **Never become a support console.** Reading someone's data to help them
113 is precisely what **rule 1 above** forbids β€” "only to that person", "no
114 operator view". It used to be the one rule; since the 2026-09-05
115 amendment "never retains" no longer reaches a console that displays
116 without keeping, so the anchor moved and a reference left pointing at
117 the one rule would rest on nothing.
118
119**And it stays small.** Every proposed feature is weighed against "does this
120belong in an instance instead?" The answer is usually yes. This service has
121a suite-wide blast radius, and a home that grows features grows that radius.
122
123---
124
125## Review β€” RULED 2026-08-30 by Paul
126
127**Every spec and every branch is reviewed by the highest-capability model
128available before it is approved or merged.** Cheaper models draft; the most
129capable available reviews. A review by a weaker model than the author is not
130a review.
131
132**Record which model reviewed** β€” in the spec's status line, and in the
133branch discussion for a branch. A review nobody can attribute is a review
134nobody can check. The model is **discovered, not hardcoded**; a session that
135cannot reach one at least as capable as the author's says so and waits.
136
137---
138
139## Shipping cadence β€” RULED 2026-08-30 by Paul
140
141Everything on a worktree, on a branch. Never directly on `main`.
142
1431. Branch per piece of work, cut from `main`.
1442. Commit regularly and push to the branch.
1453. **Squash-merge to `main` only when** the work is done, it is approved
146 (including the review pass above), and **CI is actually green** β€”
147 checked, not assumed. Any one of the three missing means it does not
148 merge.
149
150Review on a deployed canary, never localhost.
151
152**Every branch gets a discussion, and that discussion is the review
153record** β€” it survives a squash where a PR body does not. Tasks are a
154checklist, not a log. **A refusal gets surfaced in the discussion, never
155routed around** (amadan's own house rule, and the one most likely to be
156quietly ignored).
157
158---
159
160## Mechanical rules
161
162**Identity**
163- The **credential origin** serves sign-in, WebAuthn, recovery and assertion
164 minting, and **never renders an account-authored string**. It is the
165 relying party. This is a permanent constraint with its own test, not a
166 naming choice.
167- **The RP ID is the full host β€” `id.oficina.works` β€” asserted by a test.**
168 Never widen it to a registrable suffix. WebAuthn *permits* an RP ID that
169 is a suffix of the origin's hostname, so `oficina.works` would be
170 accepted; it is also valid at every host in the zone, the directory and
171 every team instance included, which collapses the very split the two
172 origins exist to make (Β§4, rulings 2 and 13; the host itself is ruling
173 16). Note where the first door actually is: `passkey.Config` has no RP ID
174 field β€” the RP ID is always `Origin`'s hostname β€” so the way to widen it
175 is to widen `Origin`, or to bypass `passkey` and build a
176 `webauthn.Config` directly. The second door is
177 `passkey.Config.LegacyRPID`, passed straight through as an RP ID accepted
178 **for assertions only** (registration never takes it), so it must stay
179 empty unless a rename drill (Β§5.7) is actually running, and then hold a
180 full host. The test asserts both: `Origin`'s hostname is exactly
181 `id.oficina.works`, and `LegacyRPID` is empty or a full host. Both are
182 accepted silently, which is why neither can rest on care.
183- Assertions carry **identity and display preferences, never
184 authorization**. The boundary test (Β§5.4) is **two-sided**: a claim may
185 ride the assertion only if **(a)** the instance makes no trust decision
186 based on it, validating against a **closed enumerated set** and treating a
187 failed match as *absent* rather than as the raw value; **and (b)** the
188 home already holds it for its own purposes, so carrying it adds nothing to
189 what the home knows. Theme passes both. A role fails (a). **"Which
190 documents you can see" passes (a) and fails (b)** β€” it hands the home a
191 document-level graph it has no business holding.
192- **Do not restore "the instance would behave identically given a hostile
193 value."** Β§5.4 retracts it: a hostile theme gives an ugly page, which *is*
194 different behaviour, so the test's own example fails it β€” and without (a)'s
195 closed set, a free-string theme in CSS is an injection, not an ugly page.
196- Assertion expiry is **120 seconds, not "seconds"** β€” sized to worst-case
197 cold wake plus clock skew, because the exchange request *is* what wakes a
198 hibernating instance and a tighter window makes the first sign-in to any
199 cold instance fail and loop. Widening it costs nothing **against the
200 threat model**, because **single-use `jti` is what stops replay**, not
201 brevity. The long-lived thing is the session the instance mints.
202- Every assertion names the **app and instance host** it is for. Without
203 audience binding, team A's assertion replays against team B, and a
204 deployment has thousands of siblings to replay against.
205- **Signed identity claims are trusted for identity; every other claim must
206 be cosmetically failable.** The subject flagrantly fails (a) β€” a hostile
207 subject is account takeover β€” so the test must exempt identity by
208 construction or it proves too much and gets argued around.
209
210 **One narrow exception, added 2026-09-05 with ruling 23: `roster_epoch`.**
211 It is not a closed enumerated set and does not fail cosmetically, so it
212 would fail test (a) as written. What admits it is that **the instance
213 decides nothing from it** β€” an unparseable value is ignored, a differing
214 value buys exactly one call-8 refetch, and call 8's answer is what is
215 enforced. A builder following the test without this exception rejects a
216 claim Β§5.3 now puts on every assertion. Spec Β§5.4 carries the reasoning.
217- **Any "prune when all consumers have caught up" is unbounded in a
218 hibernating fleet.** A dormant instance runs no timers to advance its
219 cursor. Retention is a fixed horizon or a per-consumer TTL, never a
220 slowest-consumer rule. This shipped twice in one spec before it was
221 caught.
222
223**Data**
224- **No address column exists**, so there is none to accidentally populate.
225 Addresses are learned transiently and hashed β€” and where one must survive
226 a restart (a magic link between send and click), it is **sealed before it
227 is written**, never cleaned up afterwards. Deleting a row does not erase
228 bytes: SQLite leaves deleted pages and Litestream ships WAL frames
229 continuously, so a transiently readable write reaches the bucket before
230 any delete. `PRAGMA secure_delete` is on.
231- **The session Subject is an opaque person ref, never an address.**
232 `rastrillo/auth` as shipped sets `Subject: id.Address` and stores readable
233 addresses in `auth_links.address` and, via `Subject: id.Address`, in
234 `sessions.subject` β€” `auth_sessions` is no longer written at v0.22.0, its
235 rows having been migrated into the shared `sessions` table, so the leak
236 moved rather than closed. Verified at source, filed upstream as
237 `rastrillo/rastrillo` #2. We hand-roll session minting until that lands,
238 and take classification and the Keymail upgrade from **`keymaildev/signin`
239 directly, not from `auth`** β€” corrected 2026-09-05: `auth`'s
240 classification is reachable only through `auth.Begin`, which hardwires
241 `Links: &linkStore{db: cfg.DB}` and writes `auth_links.address`, so "use
242 `auth` only for the parts that hold nothing" was never a thing that could
243 be done. `signin.Flow` takes a `LinkStore` the app supplies, so the app
244 seals what it stores.
245- **The home hashes; instances never hold the pepper.** A pepper on every
246 instance makes one compromise an offline dictionary attack against the
247 whole suite directory.
248- Migrations are additive-only.
249- **No address, team name, queue payload, or pulled content β€” a title, a
250 URL, an actor (added 2026-09-05 with Β§2.2's amendment) β€” in any log line, error string,
251 job status message or metric label.** Platform-held logs are storage we
252 cannot grep.
253
254**Frontend**
255- **Write the attribute spelling: `rst-btn`, not `class="rst-btn"`** β€”
256 spec ruling 20, 2026-09-05. Β§11 ruled the class vocabulary on 2026-08-30
257 and every reason it gave has since died, so it went back to Paul rather
258 than being propped up with a third. rastrillo's design system rules "One
259 spelling, not two. No permanent class fallback" and drops the class
260 selectors before 1.0; this app has no `rst-` markup yet, so it starts on
261 the spelling that survives β€” free today, a sweep after the first
262 template.
263
264 **The seven utilities are the exception, and they are not a loophole.**
265 `rst-sr-only`, `rst-mono`, `rst-m-hide`, `rst-grow`, `rst-nm`,
266 `rst-danger` and `rst-cell-mut` keep `class=`, because at v0.22.0 they
267 have **no attribute twin at all** β€” five `.rst-sr-only` selectors, zero
268 `[rst-sr-only]`. tokens.css's header says why ("Utilities keep the class
269 spelling and are not paired… they stay class after the move") and Β§6-v3
270 agrees ("`class` is for utilities and the app's own CSS"). Write
271 `<span rst-sr-only>` and nothing matches, so screen-reader-only text
272 renders **visible**. Kinds, variants and parts are attributes; these
273 seven are classes. This is also what the 6 surviving `class=` occurrences
274 in v0.22.0's partials are β€” utilities, not residue awaiting a flip.
275
276 Record the graveyard, so neither dead reason is revived:
277 - *"The attribute grammar is ruled but not released."* Dead since
278 **v0.21.0**, this repo's *first* pin. tokens.css there pairs every
279 *component* rule with an attribute twin and says so in its own header: "TWO
280 SPELLINGS, ON PURPOSE, FOR ONE RELEASE." The original claim β€” zero bare
281 `rst-*` selectors against ~528 class occurrences β€” was verified on
282 2026-08-30 against v0.20.x, was true then, and was already false by the
283 time the skeleton existed. No later bump falsified it.
284 - *"The partials we render alongside write classes."* Dead at
285 **v0.22.0**, the current pin, where the shipped partials flipped to the
286 attribute spelling β€” rastrillo's design-system Β§6-v3 stage 2. Counted
287 in `ui/partials/` with template comments stripped, the class spelling
288 falls from **124 occurrences at v0.21.0 to 6 at v0.22.0**, replaced by
289 the attribute spelling. (Count the class spelling, not the attribute
290 one: `class="rst-…"` is unambiguous, while attribute-spelling totals
291 move with how you treat valued attributes like `rst-tone="…"`, and two
292 careful counts of it disagreed.) `callout.html` at the pin
293 is `<div rst-callout rst-tone="…">`. tokens.css's header still claims
294 "the class spelling is what every app and every shipped partial writes
295 today" β€” **that sentence is stale upstream**, and this file quoted it
296 as evidence until it was checked.
297 **Re-check on every pin move, name the version you checked, and count
298 both `ui/tokens.css` and `ui/partials/`** β€” checking one and not the
299 other is how the second reason survived a pass that was looking for
300 exactly this.
301- **rastrillo is `amadan.net/rastrillo/rastrillo`** (moved 2026-10-01,
302 pinned at 29e4491, v0.26.0+8 β€” the version `connect`, `handoff` and
303 `roster` already required). The old path,
304 `github.com/carlosframework/rastrillo`, stops at v0.23.0: code importing
305 it gets no fix, and its own checks cannot tell β€” Home's sign-in field
306 zoomed iOS on every tap for a month that way (`TestNoZoomOnTap`,
307 `make test-browser`). `internal/guardstest`'s `TestNoPreMoveRastrillo`
308 forbids it in every module here; it stays in go.mod only as estilo's
309 indirect requirement. Re-checked at this pin: `ui/partials/` still has
310 the same 6 utility `class=` occurrences, and nothing in the
311 class-versus-attribute reasoning above moved. The CLI is a go.mod
312 `tool`: run it as `go tool rastrillo …`, never a `rastrillo` from PATH.
313 `make release` stamps `amadan.net/rastrillo/rastrillo.BuildVersion`; the
314 old symbol would leave `/api/version` answering "dev".
315- **`rastrillo doctor`.** `internal/home/static/tokens.css` is vendored,
316 and `TestVendoredAssetsMatchTheLibrary` (noscript_test.go) is the
317 standing gate: run `go tool rastrillo doctor --fix` on every rastrillo
318 upgrade rather than re-copying by hand. **Then delete the scripts it
319 adds** (`static/rastrillo.js`, `select.js`, `datetime.js`,
320 `calendar.js`). Home serves none of them, and the shim doctor adds at
321 this pin still carries rastrillo's old busy rule, which fights estilo's
322 `busy.js` over every submit. `TestNoRastrilloShimBesideBusy` fails if
323 one is left on disk or served.
324- **No script by default; script only to enhance, and always prefer
325 none** (Paul, 2026-09-29, replacing rulings 21/24's closed list of
326 screens). Every screen works with scripts off β€” magic-link sign-in,
327 recovery codes, the springboard, sign-out β€” and a script may be added
328 to a page only to enhance it or to provide interaction HTML and CSS
329 cannot: the passkey ceremonies (`webauthn.JS()` plus a little glue),
330 the assertion handoff's auto-submit (its Continue button stays for
331 script-off), and estilo's busy rule on every submit button (the
332 spinner, the double-submit guard). Reach for HTML and CSS first; a
333 script is the exception that has to earn its place, not a tool.
334 Scripts are **static same-origin files, never inline `<script>`**, and
335 every one Home serves is named in `noscript_test.go`'s closed list β€”
336 a rule with a list is checkable, a rule with an escape hatch is not.
337- **The shared chrome** (estilo spec shared-chrome, Β§12.1 row 8;
338 `internal/home/chrome.go`). Every page Home draws for a person sits under
339 estilo's 52px bar, branded Oficina β€” except the public front page,
340 which keeps its own design. On the directory a signed-in person has one
341 account menu (Passkeys and Recovery codes on id.*, Appearance, Help β†’
342 Open source, the one Sign out); id.*'s pages are the door, with a
343 brand-only bar. Unknown routes get estilo's error page for a browser
344 (`Accept: text/html`) and plain text otherwise. Calma's sky goes only on
345 the door, the team home's header and the front page's hero. Home does
346 not load estilo's `chrome.js`: its menus work without it, and the
347 script rule above prefers none β€” the stated cost is that an open menu
348 closes from its own trigger, not on Escape or a click outside.
349
350**Deploys**
351- One binary, `CGO_ENABLED=0`, cgo-free SQLite.
352- A 200 is not a deploy verification. Only `X-Carlos-Version` on the
353 canonical host, after the watch.
354- A promote is not a deploy. Until the process cycles, the old binary serves.
355- **The home does not hibernate.** It is the one app on the estate where
356 cold-wake latency is a suite-wide outage. Stated because hibernation is
357 the platform default and would otherwise apply silently.
358- **A backup you have never restored is a hope, not a backup.** Losing this
359 database loses credentials **and the roster**, neither of which rebuilds β€” call 2 cannot write membership and no channel carries an instance's copy back, so roster recovery is an operator reading instances out and re-seeding (spec Β§10). The restore drill runs
360 before launch.
361
362---
363
364## Authorship
365
366πŸ€– / πŸ‘¨ markers, `Co-Authored-By: Claude …` trailers, published prompt and
367carbon ledgers. AI is a guest with a name tag: opt-in, disclosed, and every
368path works with it switched off.
369
370## Work in a worktree, and catch main up first
371
372Take a worktree for the work. Do not work in the shared checkout.
373
374 git -C <repo> fetch origin
375 git -C <repo> worktree add ../<repo>-<task> -b <branch> origin/main
376
377Both halves matter, for different reasons.
378
379**The worktree**, because several sessions share this machine and some
380share this repository, and a checkout has one HEAD, one index and one
381working tree between all of them. A branch switch under somebody else's
382uncommitted work destroys it with nothing on screen to say so, and two
383sessions editing the same paths disagree quietly instead of conflicting
384loudly. A worktree gives the work its own HEAD and its own files, and
385costs a directory.
386
387**From `origin/main`, after a fetch** β€” not from whatever the checkout
388happens to be sitting on. A branch cut from a stale main is a merge
389conflict scheduled for later, against files somebody else has renamed in
390the meantime. A branch cut from another task's branch is worse: it
391carries that task's commits into yours, where they are reviewed as
392though you wrote them, and the two can only be separated by hand.
393
394Remove it when the work has landed β€” `git worktree remove` β€” so that
395`git worktree list` keeps saying something true.
396
397Written down because of 2026-09-17, in `meet`. The shared checkout was
398sitting on one task's branch while holding a *different* task's feature
399uncommitted on disk β€” some four hundred lines, a new migration among
400them β€” put there by a session nobody else knew was working in that tree.
401Two other sessions were meanwhile coordinating over the same checkout by
402asking each other not to touch it, which is the arrangement a worktree
403makes unnecessary. Nothing was lost. Nothing about the arrangement would
404have said so if it had been, and by then the same feature existed twice,
405once on each side of a rename, because the two sessions had branched
406from different starting points.
407