oficina / docs Public
The app's first screens — 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: A rastrillo app you can open in a browser — list your documents, create one, and read one — with the document's text coming from the real CRDT core through wazero.
Architecture: rastrillo v0.21.0 for the app shell, routes, templates and SQLite. Documents are rows; their content is a Yjs update log applied through internal/core (the wazero host built in plan 1). Server-rendered throughout: no editor yet, no JavaScript beyond what rastrillo ships.
Tech Stack: Go 1.26, rastrillo v0.21.0, internal/core (wazero + the Rust crate), SQLite via rastrillo's db.
Spec: docs/superpowers/specs/2026-08-30-docs-design.md — §3 (system shape), §4 (data model), §6.2 (where zero-JS stops), §6.4 (the document list), §10 (design system).
Why this plan, and why it is not the layout engine
Spec §13 sequences the layout engine next. Re-sequenced deliberately: the goal is something testable in a browser, and a readable document does not need pagination. Page geometry, PDF and thumbnails all come from the layout engine and all wait for the plan after this one.
So this plan renders a document as ordinary flowing HTML. §6.4's real page-one thumbnails are out of scope — the list shows documents without them, and the row layout is designed so thumbnails drop in later without a rework.
Global Constraints
CGO_ENABLED=0everywhere. No cgo, ever.- rastrillo pinned to
v0.21.0exactly — below it, the attribute grammar renders unstyled. - *Markup is the bare `rst-
attribute grammar** (<div rst-box>), variants in the value matched with~=`. Adopt per screen, not per element — mixed authoring inside one component matches neither spelling by design. - Never draw entropy from
yrs. Document GUIDs and client ids are assigned by the app and stored.clippy.tomlenforces this. internal/coreis falsification-grade. Its update log is capped at 4096 and it has no state export. This plan may create documents and apply a handful of updates; it must not assume the package is production-ready, and must not paper overErrUpdateLogFull.- No user-visible string is hardcoded — every one is a catalog key (§10.3).
- Work on a branch; push regular commits; squash-merge when done, approved, and CI green.
---
Task 1: The app skeleton, served and reachable
Files:
- Create:
cmd/docs/main.go,internal/docs/app.go,internal/docs/handlers.go,internal/docs/render.go - Modify:
go.mod
Interfaces:
- Produces:
docs.New(cfg) (*App, error)and anhttp.Handler; aGET /route rendering a page through rastrillo's shell.
- [ ] Step 1: Add rastrillo, pinned
CGO_ENABLED=0 go get github.com/carlosframework/rastrillo@v0.21.0Confirm go.mod records v0.21.0 exactly, not a pseudo-version.
Read SKILL.md from the module before writing any app code — it is the authoritative authoring doc and carries the app shape, scoping rules and identity plugins:
cat "$(CGO_ENABLED=0 go env GOMODCACHE)/github.com/carlosframework/rastrillo@v0.21.0/SKILL.md"Follow its five-file shape rather than inventing one. If it contradicts anything in this plan, SKILL.md wins — say so in your report.
- [ ] Step 2: Write a failing test
internal/docs/app_test.go:
package docs_test
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/carlosframework/docs/internal/docs"
)
func TestRootRendersAPage(t *testing.T) {
app, err := docs.NewForTest(t)
if err != nil {
t.Fatalf("NewForTest: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/", nil)
rec := httptest.NewRecorder()
app.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("GET /: status %d, want 200", rec.Code)
}
if ct := rec.Header().Get("Content-Type"); ct == "" {
t.Error("no Content-Type on the response")
}
}NewForTest builds an App against a temp SQLite file and is the constructor every later test uses — put it in internal/docs/testing.go (not _test.go, so other packages' tests can use it).
- [ ] Step 3: Run it and watch it fail
CGO_ENABLED=0 go test ./internal/docs/ -run TestRootRendersAPageExpected: build failure — package docs does not exist.
- [ ] Step 4: Implement the skeleton
Follow SKILL.md's shape. The app needs: a chi router from rastrillo, a SQLite handle via rastrillo's db, html/template views, and rastrillo's shell partials for the page chrome. Wire every route in one place so httptest exercises the real router.
Link ui.TokensCSS() and ui.ThemeCSS("day") — take the bytes from the module, do not copy files by hand.
- [ ] Step 5: Run the test and watch it pass
CGO_ENABLED=0 go test ./internal/docs/ -run TestRootRendersAPage -v- [ ] Step 6: Run it for real and look at it
CGO_ENABLED=0 go build -o /tmp/docs ./cmd/docs && /tmp/docs -addr :8099Then curl -s localhost:8099/ | head -40. Confirm it is a real HTML page with the design system's CSS linked, not a bare string. Report the actual HTML head.
- [ ] Step 7: Commit
git add cmd internal/docs go.mod go.sum
git commit -m "app: rastrillo skeleton serving a page
rastrillo v0.21.0 pinned exactly -- below it the attribute grammar
renders unstyled. Follows SKILL.md's five-file shape rather than an
invented one."---
Task 2: Documents in SQLite, with the core supplying their text
Files:
- Create:
internal/docs/models.go,internal/docs/store.go,internal/docs/store_test.go
Interfaces:
- Consumes:
core.New,(*Runtime).NewDoc(ctx, guid, clientID),(*Handle).Apply,(*Handle).Textfrom plan 1. - Produces:
Store.Create(ctx, title) (*Document, error),Store.List(ctx) ([]*Document, error),Store.Get(ctx, sqid) (*Document, error),Store.Text(ctx, doc) (string, error).
- [ ] Step 1: Write the failing test
internal/docs/store_test.go:
package docs_test
import (
"context"
"testing"
"github.com/carlosframework/docs/internal/docs"
)
func TestCreateListGetRoundTrip(t *testing.T) {
app, err := docs.NewForTest(t)
if err != nil {
t.Fatalf("NewForTest: %v", err)
}
ctx := context.Background()
made, err := app.Store().Create(ctx, "First document")
if err != nil {
t.Fatalf("Create: %v", err)
}
if made.Sqid == "" {
t.Fatal("Create returned an empty sqid")
}
if made.GUID == "" || made.ClientID == 0 {
t.Fatal("Create must assign a GUID and client id, never draw them from yrs")
}
list, err := app.Store().List(ctx)
if err != nil {
t.Fatalf("List: %v", err)
}
if len(list) != 1 || list[0].Title != "First document" {
t.Fatalf("List = %+v, want one document titled First document", list)
}
got, err := app.Store().Get(ctx, made.Sqid)
if err != nil {
t.Fatalf("Get: %v", err)
}
if got.ID != made.ID {
t.Errorf("Get returned id %d, want %d", got.ID, made.ID)
}
}
func TestTextComesFromTheCore(t *testing.T) {
app, err := docs.NewForTest(t)
if err != nil {
t.Fatalf("NewForTest: %v", err)
}
ctx := context.Background()
doc, err := app.Store().Create(ctx, "With content")
if err != nil {
t.Fatalf("Create: %v", err)
}
// The "hello" fixture is a real Yjs update produced by browser yjs.
update := docs.ReadFixture(t, "hello")
if err := app.Store().ApplyUpdate(ctx, doc, update); err != nil {
t.Fatalf("ApplyUpdate: %v", err)
}
text, err := app.Store().Text(ctx, doc)
if err != nil {
t.Fatalf("Text: %v", err)
}
if text != "hello world" {
t.Errorf("Text = %q, want %q — the text must come from the CRDT, not a column", text, "hello world")
}
}ReadFixture loads fixtures/data/<name>.update.bin, in internal/docs/testing.go.
- [ ] Step 2: Run it and watch it fail
CGO_ENABLED=0 go test ./internal/docs/ -run 'TestCreateListGetRoundTrip|TestTextComesFromTheCore'- [ ] Step 3: Implement
Schema, per spec §4 but only what this plan needs:
CREATE TABLE IF NOT EXISTS documents (
id INTEGER PRIMARY KEY,
sqid TEXT NOT NULL UNIQUE,
title TEXT NOT NULL,
guid TEXT NOT NULL,
client_id INTEGER NOT NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
deleted_at DATETIME
);
CREATE TABLE IF NOT EXISTS doc_updates (
id INTEGER PRIMARY KEY,
doc_id INTEGER NOT NULL,
seq INTEGER NOT NULL,
update_bin BLOB NOT NULL,
created_at DATETIME NOT NULL
);Migrations are idempotent CREATE TABLE IF NOT EXISTS plus an additive ALTER list — rastrillo handles this; follow its pattern.
guid and client_id are assigned by the app (a UUID from Go's stdlib-adjacent source and a counter or random int64 from math/rand/v2), stored, and passed to core.NewDoc. They are never drawn from yrs — clippy.toml bans that on the Rust side and this is the Go side of the same rule.
Content is NOT a column. Store.Text opens a core handle, replays the document's updates in seq order, and reads the text back. Sealing is out of scope for this plan (spec §2 is a later concern) — but leave a comment saying so, so nobody reads the plaintext blob as a decision.
- [ ] Step 4: Run the tests and watch them pass
CGO_ENABLED=0 go test ./internal/docs/ -v- [ ] Step 5: Commit
git add internal/docs
git commit -m "app: documents in SQLite, text from the CRDT core
Content is not a column -- Text replays the update log through the
wazero core. GUID and client id are assigned by the app and stored,
never drawn from yrs, which is the Go side of the rule clippy.toml
enforces in Rust."---
Task 3: The document list
Files:
- Create:
internal/docs/views/list.html,internal/docs/list_test.go - Modify:
internal/docs/handlers.go,internal/docs/render.go
Interfaces:
- Consumes:
Store.Listfrom Task 2. - Produces:
GET /rendering the list.
- [ ] Step 1: Write the failing test
func TestListShowsDocuments(t *testing.T) {
app, _ := docs.NewForTest(t)
ctx := context.Background()
if _, err := app.Store().Create(ctx, "Board minutes"); err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodGet, "/", nil)
rec := httptest.NewRecorder()
app.Handler().ServeHTTP(rec, req)
body := rec.Body.String()
if rec.Code != http.StatusOK {
t.Fatalf("status %d", rec.Code)
}
if !strings.Contains(body, "Board minutes") {
t.Error("the document's title is not on the page")
}
if strings.Contains(body, `class="rst-`) {
t.Error("class vocabulary found — this app writes the attribute grammar (Global Constraints)")
}
}
func TestEmptyListSaysSo(t *testing.T) {
app, _ := docs.NewForTest(t)
req := httptest.NewRequest(http.MethodGet, "/", nil)
rec := httptest.NewRecorder()
app.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status %d", rec.Code)
}
// An empty state is a designed screen, not a blank page.
if len(strings.TrimSpace(rec.Body.String())) < 200 {
t.Error("empty list rendered almost nothing — there should be an empty state")
}
}The class-vocabulary assertion is a real gate: it makes the markup constraint mechanical rather than remembered.
- [ ] Step 2: Run and watch fail.
CGO_ENABLED=0 go test ./internal/docs/ -run TestListShows
- [ ] Step 3: Implement the list
Per spec §6.4: a list, newest first, grouped by recency, one row per document showing title, who touched it and when. Cut, per Paul's four jank rulings: no template carousel, no "Owned by anyone" filter chrome, no grid/list/AZ toggles, no commercial or platform chrome.
Design the row so a 26×34 thumbnail drops into its left edge later without reflowing anything else — thumbnails come with the layout engine.
Use rastrillo's shell and partials; write the attribute grammar; every string a catalog key.
- [ ] Step 4: Run and watch pass.
- [ ] Step 5: Commit
git add internal/docs
git commit -m "app: the document list
Per spec §6.4 and Paul's jank rulings: a list, newest first, no template
carousel, no filter chrome, no view toggles. The row reserves space a
page-one thumbnail drops into once the layout engine exists.
The test asserts no class-vocabulary markup, which makes the attribute
grammar constraint mechanical rather than remembered."---
Task 4: Create and read a document
Files:
- Create:
internal/docs/views/show.html - Modify:
internal/docs/handlers.go,internal/docs/list_test.go
Interfaces:
- Produces:
POST /documents(create, redirect to the new document) andGET /d/{sqid}(read-only render).
- [ ] Step 1: Write the failing tests
func TestCreateThenRead(t *testing.T) {
app, _ := docs.NewForTest(t)
form := strings.NewReader("title=Minutes")
req := httptest.NewRequest(http.MethodPost, "/documents", form)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
app.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusSeeOther {
t.Fatalf("POST /documents: status %d, want 303", rec.Code)
}
loc := rec.Header().Get("Location")
if !strings.HasPrefix(loc, "/d/") {
t.Fatalf("Location = %q, want /d/<sqid>", loc)
}
req2 := httptest.NewRequest(http.MethodGet, loc, nil)
rec2 := httptest.NewRecorder()
app.Handler().ServeHTTP(rec2, req2)
if rec2.Code != http.StatusOK {
t.Fatalf("GET %s: status %d", loc, rec2.Code)
}
if !strings.Contains(rec2.Body.String(), "Minutes") {
t.Error("the document's title is not on its own page")
}
}
func TestUnknownDocument404s(t *testing.T) {
app, _ := docs.NewForTest(t)
req := httptest.NewRequest(http.MethodGet, "/d/nosuchthing", 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", rec.Code)
}
}The 404 test encodes spec §4.2's contract: not-yours is indistinguishable from not-there.
- [ ] Step 2: Run and watch fail.
- [ ] Step 3: Implement
POST /documents creates and redirects with 303 See Other, so a refresh does not repost. Creation is a plain form post that works with no JavaScript (§6.2).
GET /d/{sqid} renders the document read-only: its title, and its text from Store.Text — the CRDT, through wazero, not a column. Render the text as flowing HTML inside a page-shaped canvas: white, centred, with margins. No pagination — that arrives with the layout engine, and the container is built so page breaks can be drawn into it later.
Give the canvas color-scheme: light and paint it from literals, per spec §10.2, so it stays paper in a dark theme.
- [ ] Step 4: Run and watch pass.
- [ ] Step 5: Commit
git add internal/docs
git commit -m "app: create and read a document
POST redirects 303 so a refresh does not repost; both work with no
JavaScript. The read view takes its text from the CRDT through wazero
rather than a column, and its canvas opts out of the theme's scheme so
paper stays paper in the dark."---
Task 5: A way in, that cannot ship
Files:
- Create:
internal/docs/devauth.go,internal/docs/devauth_test.go - Modify:
cmd/docs/main.go
Interfaces:
- Produces: a development-only identity so the app is reachable in a browser.
Spec §8.1 puts sign-in at the home's credential origin. The home does not exist yet. This task provides a local stand-in and makes it structurally impossible to ship.
- [ ] Step 1: Write the failing test
func TestDevAuthRefusesOutsideDevelopment(t *testing.T) {
// The guard is the point of this task: a build without the dev flag
// must refuse to start rather than silently authenticate everyone.
_, err := docs.NewWithDevAuth(docs.Config{DevAuth: true, Env: "production"})
if err == nil {
t.Fatal("dev auth must refuse to start outside development")
}
if !strings.Contains(err.Error(), "development") {
t.Errorf("error should say why: %v", err)
}
}
func TestDevAuthGivesAnIdentityInDevelopment(t *testing.T) {
app, err := docs.NewForTest(t) // NewForTest sets Env: development
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodGet, "/", nil)
rec := httptest.NewRecorder()
app.Handler().ServeHTTP(rec, req)
if rec.Code == http.StatusUnauthorized {
t.Error("dev auth should provide an identity in development")
}
}- [ ] Step 2: Run and watch fail.
- [ ] Step 3: Implement
A single fixed development identity, enabled only when Env == "development" and an explicit -dev-auth flag is set. Constructing it in any other environment returns an error that names the reason — the app refuses to start rather than starting insecure.
Put a comment at the top of the file saying, in one sentence, that this exists because the home does not, and that it is deleted when §8.1's assertion verification lands.
- [ ] Step 4: Run and watch pass.
- [ ] Step 5: Commit
git add internal/docs cmd
git commit -m "app: a development-only identity, guarded so it cannot ship
Spec §8.1 puts sign-in at the home's credential origin and the home does
not exist yet. This refuses to start outside development rather than
silently authenticating everyone, and it is deleted when assertion
verification lands."---
Task 6: Look at it in a browser
Files:
- Create:
scripts/run-dev.sh - Modify:
.amadan/ci.d/10-gate,AGENTS.md
- [ ] Step 1: Add the run script
scripts/run-dev.sh builds and runs the app with dev auth on a fixed port against a scratch database, so anyone can see it in one command. Print the URL.
- [ ] Step 2: Run it and drive it
Start it, then exercise the real flows over HTTP and report the actual output at each step:
./scripts/run-dev.sh &
sleep 2
curl -s localhost:8099/ | head -30
curl -s -X POST localhost:8099/documents -d 'title=Overnight test' -i | head -5
curl -s localhost:8099/d/<sqid-from-the-redirect> | head -40Confirm: the list renders, creating redirects, the document page shows its title, and the page links the design system's CSS.
- [ ] Step 3: Verify the markup constraint held
curl -s localhost:8099/ | grep -c 'class="rst-' || trueExpected: 0 from our own templates. If rastrillo's own partials emit classes, that is expected and fine — they are rastrillo's output, not ours (spec §10.1.1). Report which came from where.
- [ ] Step 4: Extend the gate
Add CGO_ENABLED=0 go build ./... to .amadan/ci.d/10-gate if it is not already covered, and note the app in .amadan/README.md.
- [ ] Step 5: Commit and confirm CI
git add scripts .amadan AGENTS.md
git commit -m "app: a one-command dev run, and the gate covers the app"
git pushThen confirm amadan ci status oficina/docs -branch <branch> is exit 0.
---
Self-Review
Spec coverage. Implements §3's shape, §4's documents and update log (minus sealing, deliberately deferred and commented), §6.2's zero-JS create-and-read, §6.4's list minus thumbnails, §10's design system and canvas scheme opt-out. Does not implement: the layout engine and pagination (next plan), the editor, collaboration, sharing, import/export, or real identity — §8.1 is stubbed with a guard that refuses to ship.
Placeholder scan: no TBDs. Two things are deliberately deferred with a comment rather than a silent omission: content sealing (§2) and thumbnails (§6.4).
Type consistency: Store.Create/List/Get/Text/ApplyUpdate are defined in Task 2 and consumed in Tasks 3 and 4; NewForTest and ReadFixture are defined in Task 1's step 2 and used throughout; core.NewDoc(ctx, guid, clientID) matches plan 1's shipped signature.
Known risk carried from plan 1: internal/core caps its update log at 4096 and has no state export. This plan applies few updates per document, so it will not hit the cap — but any task that starts replaying long histories must stop and raise it rather than working around it.