oficina / docs Public

Spec §8.1 reframed, and plan 3: sign-in and sample content

Paul's ruling, and it is a reframing rather than an unblock. An earlier
draft treated the home as the only way in, which made instance-local
sign-in a stopgap. There are two deployment shapes with different correct
answers: a single instance has no one-person-many-teams problem -- that
is the problem the home exists to solve -- so it signs people in itself,
permanently. A suite adds the home; it does not replace the instance.

Magic link enrols no credential, so it creates no passkey relying party
and the one-relying-party ruling is untouched. And instances holding
readable addresses is the home spec's own design, which is exactly why
rastrillo/auth is wrong for the home and right for an app.

Plan 3 builds it: magic-link sign-in, documents owned and scoped 404-not-403,
sample documents with real prose applied through the CRDT rather than
written into a table, and then the public deploy Paul asked for.

The seed content is deliberately English rather than lorem ipsum: Latin
filler hides the line-length and rhythm problems the canvas exists to get
right.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011F5CivVm4CmL9ByfSSAaXA
Paul Campbell pushed by paul@keymail.dev 250856eea842194621f96dffa5c73f5ec2931304 parent 3ed4e0a
2 files changed, +366 −1
  • docs/superpowers/plans/2026-08-31-signin-and-samples.md +332 −0
  • docs/superpowers/specs/2026-08-30-docs-design.md +34 −1

CI passed — run details

diff --git a/docs/superpowers/plans/2026-08-31-signin-and-samples.md b/docs/superpowers/plans/2026-08-31-signin-and-samples.md
new file mode 100644
index 0000000..7841b81
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-31-signin-and-samples.md
@@ -0,0 +1,332 @@
+# Sign-in, and documents worth looking at — Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Real per-person sign-in at the instance, so Docs can go live on a public URL — and sample documents with enough content that the reading view is worth looking at.
+
+**Architecture:** rastrillo's `auth` plugin: magic link, auto-upgrading to keymail when an address resolves to a claimed inbox. Documents become owned, and every route requires a signed-in person. Seed content is real prose applied as Yjs updates through the CRDT, not rows in a table.
+
+**Tech Stack:** Go 1.26, rastrillo `v0.22.0` (`auth`, `sessions`, `csrf`, `scope`), `internal/core` (wazero + Rust CRDT), SQLite.
+
+**Spec:** `docs/superpowers/specs/2026-08-30-docs-design.md` — §8.1 (the two deployment shapes), §8.2 (sharing), §4.2 (the 404-not-403 contract), §6.2 (where zero-JS stops).
+
+## Why this plan exists
+
+Paul asked for Docs live at `console.carloku.com/a/bds/apps/docs`. It cannot go on a public URL as it stands: no route requires sign-in, so it is a public read-and-write documents endpoint.
+
+The home would solve it and is weeks away — verified, not assumed: `oficina/home` is a skeleton that boots and serves one page saying it is not built. Nothing about assertions exists anywhere.
+
+**But §8.1 was reframed on 2026-08-31 and this is no longer a stopgap.** A single instance signs people in itself; the home is what a *suite* adds. So this plan builds the single-instance path, which is a real deployment shape with a permanent answer, and the suite path arrives later as an addition rather than a replacement.
+
+## Global Constraints
+
+- **`CGO_ENABLED=0` everywhere.** No cgo.
+- **rastrillo pinned to `v0.22.0`.**
+- **Read `SKILL.md` from the module before writing app code.** It carries the identity plugin's contract, the scoping rules, and the mass-assignment rule. Where it contradicts this plan, **SKILL.md wins** — say so in the report.
+- **Bare `rst-*` attribute grammar.** A test walks our view FS and enforces it.
+- **Every user-visible string is a catalog key.**
+- **`scope.Owned` must NOT be used for documents.** Verified in plan 1: it is `Where("user_id = ?")`, and applied to a shared table it returns a short list rather than an error — a library that looks empty rather than broken. Docs scopes over its own ownership join, preserving 404-not-403.
+- **No secret, token or address is ever logged.** Magic-link tokens are 256-bit random, stored only as SHA-256.
+- Work on a branch; push regular commits; squash-merge when done, approved, and CI green.
+
+---
+
+### Task 1: Sign-in with a magic link
+
+**Files:**
+- Create: `internal/docs/auth.go`, `internal/docs/auth_test.go`
+- Modify: `internal/docs/app.go`, `cmd/docs/main.go`, `internal/docs/migrations/`
+
+**Interfaces:**
+- Produces: `GET /signin` (the form), `POST /signin` (sends the link), `GET /signin/{token}` (completes), `POST /signout`. A signed-in person is readable by handlers.
+
+- [ ] **Step 1: Read the plugin's contract first**
+
+```bash
+cat "$(CGO_ENABLED=0 go env GOMODCACHE)/github.com/carlosframework/rastrillo@v0.22.0/SKILL.md"
+```
+
+Find what `auth` requires: how it is mounted, what it needs from `sessions`, how it delivers mail, and what it expects of the app. **Quote the relevant part in your report** — later tasks depend on you having read it rather than inferred it.
+
+Note especially: read the viewer with `auth.From(r)` or `sessions.Current(r)`, **never `sessions.UserID`** — it returns `(0, false)` for an email subject, and dropping that `ok` scopes every query to uid 0.
+
+- [ ] **Step 2: Write the failing test**
+
+```go
+func TestSignInRequiredForEverything(t *testing.T) {
+ app, err := docs.NewForTest(t)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, path := range []string{"/", "/d/anything"} {
+ req := httptest.NewRequest(http.MethodGet, path, nil)
+ rec := httptest.NewRecorder()
+ app.Handler().ServeHTTP(rec, req)
+ if rec.Code != http.StatusSeeOther && rec.Code != http.StatusFound {
+ t.Errorf("GET %s signed out: status %d, want a redirect to sign-in", path, rec.Code)
+ }
+ }
+}
+
+func TestMagicLinkSignsSomebodyIn(t *testing.T) {
+ app, err := docs.NewForTest(t)
+ if err != nil {
+ t.Fatal(err)
+ }
+ link, err := app.TestSignIn(t, "reader@example.com")
+ if err != nil {
+ t.Fatalf("TestSignIn: %v", err)
+ }
+ // Following the link must establish a session.
+ req := httptest.NewRequest(http.MethodGet, link, nil)
+ rec := httptest.NewRecorder()
+ app.Handler().ServeHTTP(rec, req)
+ if len(rec.Result().Cookies()) == 0 {
+ t.Fatal("following the magic link set no session cookie")
+ }
+}
+```
+
+`TestSignIn` mints a link without sending mail and returns its path — put it in `internal/docs/testing.go`. It is test scaffolding for later tasks, which all need a signed-in request.
+
+- [ ] **Step 3: Run it and watch it fail.** `CGO_ENABLED=0 go test ./internal/docs/ -run 'TestSignIn|TestMagicLink'`
+
+- [ ] **Step 4: Implement**
+
+Mount rastrillo's `auth` plugin per SKILL.md. **Magic link only — do not enable a passkey second factor.** §8.1 explains why: a passkey enrols a credential whose relying party is this instance's host, and a person in several teams would enrol several. Magic link enrols nothing.
+
+Mail delivery in development must not require a mail server: write the link to the log, clearly marked, so `run-dev.sh` remains one command. In any other environment, real delivery is required and its absence is a startup refusal — the same fail-closed shape as the origin check.
+
+- [ ] **Step 5: Run and watch pass.**
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add internal/docs cmd
+git commit -m "app: sign in with a magic link
+
+rastrillo's auth plugin, magic link only and no passkey second factor --
+a passkey would enrol a credential whose relying party is this instance's
+host, and a person in several teams would enrol several. Magic link
+enrols nothing, so the one-relying-party ruling is untouched.
+
+Per spec §8.1 this is the single-instance shape's real answer, not a
+placeholder: a lone instance has no one-person-many-teams problem, which
+is the problem the home exists to solve."
+```
+
+---
+
+### Task 2: Documents belong to people
+
+**Files:**
+- Create: `internal/docs/migrations/0002_owners.sql`
+- Modify: `internal/docs/store.go`, `internal/docs/models.go`, `internal/docs/handlers.go`, `internal/docs/store_test.go`
+
+**Interfaces:**
+- Consumes: the signed-in person from Task 1.
+- Produces: `Store.Create/List/Get` all taking an owner; a document not yours 404s.
+
+- [ ] **Step 1: Write the failing test**
+
+```go
+func TestADocumentYouDoNotOwnIsNotThere(t *testing.T) {
+ app, _ := docs.NewForTest(t)
+ ctx := context.Background()
+
+ alice := app.TestPerson(t, "alice@example.com")
+ bob := app.TestPerson(t, "bob@example.com")
+
+ doc, err := app.Store().Create(ctx, alice, "Alice's private notes")
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // Bob must not be able to fetch it, and must not be told it exists.
+ if _, err := app.Store().Get(ctx, bob, doc.Sqid); err == nil {
+ t.Fatal("Bob fetched Alice's document")
+ }
+
+ list, err := app.Store().List(ctx, bob)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(list) != 0 {
+ t.Errorf("Bob's list has %d documents, want 0", len(list))
+ }
+}
+
+func TestUnownedDocumentOverHTTPIs404Not403(t *testing.T) {
+ app, _ := docs.NewForTest(t)
+ ctx := context.Background()
+ alice := app.TestPerson(t, "alice@example.com")
+ doc, _ := app.Store().Create(ctx, alice, "Alice's private notes")
+
+ req := app.TestRequest(t, "bob@example.com", http.MethodGet, "/d/"+doc.Sqid, nil)
+ rec := httptest.NewRecorder()
+ app.Handler().ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusNotFound {
+ t.Errorf("status %d, want 404 — a document you cannot see does not exist (spec §4.2)", rec.Code)
+ }
+}
+```
+
+`TestPerson` and `TestRequest` are scaffolding in `testing.go`: one creates a person, the other builds a request already carrying that person's session.
+
+- [ ] **Step 2: Run and watch fail.**
+
+- [ ] **Step 3: Implement**
+
+Add an owner to `documents` and scope every read and write by it. **Do not use `scope.Owned`** — see Global Constraints. Write the scoping as one function that every query goes through, and test that function directly.
+
+**403 must never appear.** A document you cannot see returns 404, so a stranger cannot learn a sqid exists by probing.
+
+- [ ] **Step 4: Run and watch pass.**
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/docs
+git commit -m "app: documents belong to people
+
+Every read and write scoped by owner, through one function rather than
+scattered WHERE clauses. Not scope.Owned: verified in plan 1 that it is
+Where(\"user_id = ?\") and returns a short list rather than an error on a
+shared table, which looks like an empty library rather than a bug.
+
+A document you cannot see 404s and never 403s, so a stranger cannot
+learn a sqid exists by probing it."
+```
+
+---
+
+### Task 3: Sample documents worth reading
+
+**Files:**
+- Create: `internal/docs/seed.go`, `internal/docs/seed_test.go`, `internal/docs/seeddata/`
+- Modify: `cmd/docs/main.go`, `scripts/run-dev.sh`
+
+**Interfaces:**
+- Produces: `docs.Seed(ctx, app, owner) error`, and a `-seed` flag that populates a fresh database.
+
+The reading view currently shows two words. Sample content is what makes the canvas, the typography and the empty-versus-full states worth looking at.
+
+- [ ] **Step 1: Write the failing test**
+
+```go
+func TestSeedProducesReadableDocuments(t *testing.T) {
+ app, _ := docs.NewForTest(t)
+ ctx := context.Background()
+ owner := app.TestPerson(t, "seed@example.com")
+
+ if err := docs.Seed(ctx, app, owner); err != nil {
+ t.Fatalf("Seed: %v", err)
+ }
+
+ list, err := app.Store().List(ctx, owner)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(list) < 4 {
+ t.Fatalf("seeded %d documents, want at least 4", len(list))
+ }
+
+ // Every seeded document must have real content, through the CRDT.
+ for _, d := range list {
+ text, err := app.Store().Text(ctx, d)
+ if err != nil {
+ t.Fatalf("Text(%q): %v", d.Title, err)
+ }
+ if len(text) < 200 {
+ t.Errorf("%q has %d characters — seed content should be substantial", d.Title, len(text))
+ }
+ }
+}
+```
+
+- [ ] **Step 2: Run and watch fail.**
+
+- [ ] **Step 3: Write the content**
+
+Four to six documents of genuinely plausible prose — several paragraphs each, varied lengths so the list shows range. **Write real sentences, not lorem ipsum**: the point is to see how the canvas reads, and Latin filler hides line-length and rhythm problems that English exposes.
+
+Suggested shapes, all fictional: a project proposal, meeting notes with attendees and actions, a short piece of documentation, a letter, and one deliberately long document so the canvas can be judged at length.
+
+Content lives in `seeddata/` as plain `.txt` files embedded with `go:embed` — editable without touching Go.
+
+- [ ] **Step 4: Apply it through the CRDT, not the database**
+
+Each document's text becomes a **Yjs update applied via `Store.ApplyUpdate`**. It must arrive the same way a person's typing will.
+
+There is no Go-side Yjs *encoder* — `internal/core` applies updates and reads text, and the ABI exposes no insert. **Resolve this and report which route you took:**
+1. Add an ABI function that inserts text into a document and returns the resulting update — a real ABI change, and the honest one if editing needs it anyway (it does; the next plan writes it).
+2. Generate the updates offline with the Node fixture generator (`fixtures/generate.mjs` already uses real `yjs`), commit the `.bin` files as seed fixtures, and have the seeder apply them.
+
+**Route 2 is likely right for this plan** — it reuses machinery that exists, keeps the ABI stable, and the fixtures are honest artefacts of real `yjs`. Route 1 is a bigger change belonging to the editing plan. If you choose route 1 anyway, say why.
+
+- [ ] **Step 5: Run and watch pass.**
+
+- [ ] **Step 6: Wire `-seed` and check it looks right**
+
+Add `-seed` to `cmd/docs/main.go` and use it in `run-dev.sh` so one command gives a populated app. Then run it, curl the list and two documents, and **paste the rendered HTML**. Judge it: does the canvas read well at these lengths? Is the list's grouping doing anything visible with varied dates?
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add internal/docs cmd scripts
+git commit -m "app: sample documents with real content
+
+Four to six documents of plausible English prose rather than lorem
+ipsum, because Latin filler hides the line-length and rhythm problems
+the canvas exists to get right.
+
+Applied as Yjs updates through the CRDT, the same route a person's
+typing will take -- not rows written into a table."
+```
+
+---
+
+### Task 4: Take it live
+
+**Files:**
+- Modify: `.amadan/README.md`, `AGENTS.md`
+
+Docs can now go on a public URL, because every route requires a signed-in person and documents are scoped to their owner.
+
+- [ ] **Step 1: Check what deployment needs**
+
+`carlos email enable` for account `bds`, since magic links need outbound mail. **If email is not available, stop and report** — a sign-in that cannot send its link is not sign-in, and a preview with mail stubbed out is a public app with extra steps.
+
+- [ ] **Step 2: Verify the guards hold in a release build**
+
+Build with `-tags release` and confirm: dev auth is absent; an unset `DOCS_ORIGIN` refuses; an unset mail configuration refuses. Paste the refusals.
+
+- [ ] **Step 3: Ship**
+
+```bash
+CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -tags release -ldflags "-s -w" -o docs-linux-arm64 ./cmd/docs
+carlos ship --account bds --app docs --kind binary --target linux-arm64 --label "first live" docs-linux-arm64
+```
+
+Then promote and create an instance per the platform's own flow — read `carlos deploy -h` and `carlos instances -h` rather than guessing, and **report each command's real output**.
+
+- [ ] **Step 4: Prove it from outside**
+
+Fetch the public URL and confirm: signed out redirects to sign-in; the sign-in form renders; `/api/version` reports the shipped sha. **Do not paste a working magic link into the report** — it is a credential.
+
+- [ ] **Step 5: Update the warnings**
+
+`AGENTS.md` currently says this app has no authentication and must not be deployed anywhere reachable. **That statement is now false and must change** — say what is true instead: every route requires sign-in, documents are owner-scoped, and the suite path (§8.1.1) is still to come.
+
+---
+
+## Self-Review
+
+**Spec coverage.** Implements §8.1's single-instance shape, §8.2's ownership, §4.2's 404-not-403. Does **not** implement: sharing with guests or links (§8.2's later half), the suite assertion path (§8.1.1), editing, the layout engine, sealing.
+
+**Placeholder scan:** none. Task 3 step 4 names an unresolved choice with a recommendation and requires the implementer to report which route they took — that is a decision point, not a placeholder.
+
+**Type consistency:** `TestSignIn`, `TestPerson` and `TestRequest` are introduced in Tasks 1–2 and used throughout; `Store.Create/List/Get` gain an owner parameter in Task 2 and Task 3's seeder uses the new signature.
+
+**Known risk carried forward:** `internal/core` caps its update log at 4096 with no state export. Seed documents are single updates so this plan will not approach it, but the editing plan will and must not paper over it.
diff --git a/docs/superpowers/specs/2026-08-30-docs-design.md b/docs/superpowers/specs/2026-08-30-docs-design.md
index 43be865..e42d22b 100644
--- a/docs/superpowers/specs/2026-08-30-docs-design.md
+++ b/docs/superpowers/specs/2026-08-30-docs-design.md
@@ -434,7 +434,40 @@ is destroyed — so the primitive serves both.
## 8. Identity and sharing
-### 8.1 Sign-in — one relying party
+### 8.1 Sign-in — two shapes, and which one a deployment is
+
+**Ruled by Paul, 2026-08-31, and it reframes what follows.** An earlier draft
+treated the home as the only way in, which made instance-local sign-in a
+stopgap. It is not. There are two deployment shapes and they have different
+correct answers:
+
+- **A single instance** — one team, self-hosted, no suite. It has no
+ "one person, many teams" problem, because there is one instance. So it
+ signs people in **itself**, with rastrillo's `auth` plugin: magic link,
+ auto-upgrading to keymail when the address resolves to a claimed inbox.
+ This is the right answer for that shape, not a placeholder for one.
+- **A suite deployment** — Docs beside Sheets and others, one person
+ spanning several teams. Here three instances must not mean three
+ credentials, which is the problem the home exists to solve, and sign-in
+ moves to its credential origin with instances verifying signed assertions.
+
+So an instance is built to accept identity from **either**: its own
+magic-link flow, or a verified assertion. The second is not a replacement for
+the first; it is what a suite adds.
+
+**Why magic-link-at-the-instance does not violate the one-relying-party
+ruling.** That ruling is about **passkeys** — a person in three teams must not
+enrol three credentials, and `passkey.Config.Origin` derives the RPID from the
+app's own host. Magic link enrols **no credential at all**, so it creates no
+relying party and strands nothing. An instance running it and later accepting
+assertions is not a migration; people simply sign in by a different route.
+
+**And it is consistent with the home's own blindness rule.** The home spec
+rules that **instances hold readable addresses and the home never does**. An
+app instance storing an address is the design, not a deviation — which is
+exactly why `rastrillo/auth` is wrong for the *home* and right for an *app*.
+
+### 8.1.1 The suite path — one relying party
Revision 1 held three positions at once: it specced instance-local
`rastrillo/auth` magic links, then said sign-in was unresolved, while §9 call