| 1 | # The app's first screens — Implementation Plan |
| 2 | |
| 3 | > **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. |
| 4 | |
| 5 | **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. |
| 6 | |
| 7 | **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. |
| 8 | |
| 9 | **Tech Stack:** Go 1.26, rastrillo `v0.21.0`, `internal/core` (wazero + the Rust crate), SQLite via rastrillo's `db`. |
| 10 | |
| 11 | **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). |
| 12 | |
| 13 | ## Why this plan, and why it is not the layout engine |
| 14 | |
| 15 | 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. |
| 16 | |
| 17 | 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. |
| 18 | |
| 19 | ## Global Constraints |
| 20 | |
| 21 | - **`CGO_ENABLED=0` everywhere.** No cgo, ever. |
| 22 | - **rastrillo pinned to `v0.21.0` exactly** — below it, the attribute grammar renders unstyled. |
| 23 | - **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. |
| 24 | - **Never draw entropy from `yrs`.** Document GUIDs and client ids are assigned by the app and stored. `clippy.toml` enforces this. |
| 25 | - **`internal/core` is 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 over `ErrUpdateLogFull`. |
| 26 | - **No user-visible string is hardcoded** — every one is a catalog key (§10.3). |
| 27 | - Work on a branch; push regular commits; squash-merge when done, approved, and CI green. |
| 28 | |
| 29 | --- |
| 30 | |
| 31 | ### Task 1: The app skeleton, served and reachable |
| 32 | |
| 33 | **Files:** |
| 34 | - Create: `cmd/docs/main.go`, `internal/docs/app.go`, `internal/docs/handlers.go`, `internal/docs/render.go` |
| 35 | - Modify: `go.mod` |
| 36 | |
| 37 | **Interfaces:** |
| 38 | - Produces: `docs.New(cfg) (*App, error)` and an `http.Handler`; a `GET /` route rendering a page through rastrillo's shell. |
| 39 | |
| 40 | - [ ] **Step 1: Add rastrillo, pinned** |
| 41 | |
| 42 | ```bash |
| 43 | CGO_ENABLED=0 go get github.com/carlosframework/rastrillo@v0.21.0 |
| 44 | ``` |
| 45 | |
| 46 | Confirm `go.mod` records `v0.21.0` exactly, not a pseudo-version. |
| 47 | |
| 48 | **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: |
| 49 | |
| 50 | ```bash |
| 51 | cat "$(CGO_ENABLED=0 go env GOMODCACHE)/github.com/carlosframework/rastrillo@v0.21.0/SKILL.md" |
| 52 | ``` |
| 53 | |
| 54 | Follow its five-file shape rather than inventing one. If it contradicts anything in this plan, **SKILL.md wins** — say so in your report. |
| 55 | |
| 56 | - [ ] **Step 2: Write a failing test** |
| 57 | |
| 58 | `internal/docs/app_test.go`: |
| 59 | |
| 60 | ```go |
| 61 | package docs_test |
| 62 | |
| 63 | import ( |
| 64 | "net/http" |
| 65 | "net/http/httptest" |
| 66 | "testing" |
| 67 | |
| 68 | "github.com/carlosframework/docs/internal/docs" |
| 69 | ) |
| 70 | |
| 71 | func TestRootRendersAPage(t *testing.T) { |
| 72 | app, err := docs.NewForTest(t) |
| 73 | if err != nil { |
| 74 | t.Fatalf("NewForTest: %v", err) |
| 75 | } |
| 76 | req := httptest.NewRequest(http.MethodGet, "/", nil) |
| 77 | rec := httptest.NewRecorder() |
| 78 | app.Handler().ServeHTTP(rec, req) |
| 79 | |
| 80 | if rec.Code != http.StatusOK { |
| 81 | t.Fatalf("GET /: status %d, want 200", rec.Code) |
| 82 | } |
| 83 | if ct := rec.Header().Get("Content-Type"); ct == "" { |
| 84 | t.Error("no Content-Type on the response") |
| 85 | } |
| 86 | } |
| 87 | ``` |
| 88 | |
| 89 | `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). |
| 90 | |
| 91 | - [ ] **Step 3: Run it and watch it fail** |
| 92 | |
| 93 | ```bash |
| 94 | CGO_ENABLED=0 go test ./internal/docs/ -run TestRootRendersAPage |
| 95 | ``` |
| 96 | |
| 97 | Expected: build failure — package `docs` does not exist. |
| 98 | |
| 99 | - [ ] **Step 4: Implement the skeleton** |
| 100 | |
| 101 | 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. |
| 102 | |
| 103 | Link `ui.TokensCSS()` and `ui.ThemeCSS("day")` — take the bytes from the module, do not copy files by hand. |
| 104 | |
| 105 | - [ ] **Step 5: Run the test and watch it pass** |
| 106 | |
| 107 | ```bash |
| 108 | CGO_ENABLED=0 go test ./internal/docs/ -run TestRootRendersAPage -v |
| 109 | ``` |
| 110 | |
| 111 | - [ ] **Step 6: Run it for real and look at it** |
| 112 | |
| 113 | ```bash |
| 114 | CGO_ENABLED=0 go build -o /tmp/docs ./cmd/docs && /tmp/docs -addr :8099 |
| 115 | ``` |
| 116 | |
| 117 | Then `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.** |
| 118 | |
| 119 | - [ ] **Step 7: Commit** |
| 120 | |
| 121 | ```bash |
| 122 | git add cmd internal/docs go.mod go.sum |
| 123 | git commit -m "app: rastrillo skeleton serving a page |
| 124 | |
| 125 | rastrillo v0.21.0 pinned exactly -- below it the attribute grammar |
| 126 | renders unstyled. Follows SKILL.md's five-file shape rather than an |
| 127 | invented one." |
| 128 | ``` |
| 129 | |
| 130 | --- |
| 131 | |
| 132 | ### Task 2: Documents in SQLite, with the core supplying their text |
| 133 | |
| 134 | **Files:** |
| 135 | - Create: `internal/docs/models.go`, `internal/docs/store.go`, `internal/docs/store_test.go` |
| 136 | |
| 137 | **Interfaces:** |
| 138 | - Consumes: `core.New`, `(*Runtime).NewDoc(ctx, guid, clientID)`, `(*Handle).Apply`, `(*Handle).Text` from plan 1. |
| 139 | - Produces: `Store.Create(ctx, title) (*Document, error)`, `Store.List(ctx) ([]*Document, error)`, `Store.Get(ctx, sqid) (*Document, error)`, `Store.Text(ctx, doc) (string, error)`. |
| 140 | |
| 141 | - [ ] **Step 1: Write the failing test** |
| 142 | |
| 143 | `internal/docs/store_test.go`: |
| 144 | |
| 145 | ```go |
| 146 | package docs_test |
| 147 | |
| 148 | import ( |
| 149 | "context" |
| 150 | "testing" |
| 151 | |
| 152 | "github.com/carlosframework/docs/internal/docs" |
| 153 | ) |
| 154 | |
| 155 | func TestCreateListGetRoundTrip(t *testing.T) { |
| 156 | app, err := docs.NewForTest(t) |
| 157 | if err != nil { |
| 158 | t.Fatalf("NewForTest: %v", err) |
| 159 | } |
| 160 | ctx := context.Background() |
| 161 | |
| 162 | made, err := app.Store().Create(ctx, "First document") |
| 163 | if err != nil { |
| 164 | t.Fatalf("Create: %v", err) |
| 165 | } |
| 166 | if made.Sqid == "" { |
| 167 | t.Fatal("Create returned an empty sqid") |
| 168 | } |
| 169 | if made.GUID == "" || made.ClientID == 0 { |
| 170 | t.Fatal("Create must assign a GUID and client id, never draw them from yrs") |
| 171 | } |
| 172 | |
| 173 | list, err := app.Store().List(ctx) |
| 174 | if err != nil { |
| 175 | t.Fatalf("List: %v", err) |
| 176 | } |
| 177 | if len(list) != 1 || list[0].Title != "First document" { |
| 178 | t.Fatalf("List = %+v, want one document titled First document", list) |
| 179 | } |
| 180 | |
| 181 | got, err := app.Store().Get(ctx, made.Sqid) |
| 182 | if err != nil { |
| 183 | t.Fatalf("Get: %v", err) |
| 184 | } |
| 185 | if got.ID != made.ID { |
| 186 | t.Errorf("Get returned id %d, want %d", got.ID, made.ID) |
| 187 | } |
| 188 | } |
| 189 | |
| 190 | func TestTextComesFromTheCore(t *testing.T) { |
| 191 | app, err := docs.NewForTest(t) |
| 192 | if err != nil { |
| 193 | t.Fatalf("NewForTest: %v", err) |
| 194 | } |
| 195 | ctx := context.Background() |
| 196 | |
| 197 | doc, err := app.Store().Create(ctx, "With content") |
| 198 | if err != nil { |
| 199 | t.Fatalf("Create: %v", err) |
| 200 | } |
| 201 | |
| 202 | // The "hello" fixture is a real Yjs update produced by browser yjs. |
| 203 | update := docs.ReadFixture(t, "hello") |
| 204 | if err := app.Store().ApplyUpdate(ctx, doc, update); err != nil { |
| 205 | t.Fatalf("ApplyUpdate: %v", err) |
| 206 | } |
| 207 | |
| 208 | text, err := app.Store().Text(ctx, doc) |
| 209 | if err != nil { |
| 210 | t.Fatalf("Text: %v", err) |
| 211 | } |
| 212 | if text != "hello world" { |
| 213 | t.Errorf("Text = %q, want %q — the text must come from the CRDT, not a column", text, "hello world") |
| 214 | } |
| 215 | } |
| 216 | ``` |
| 217 | |
| 218 | `ReadFixture` loads `fixtures/data/<name>.update.bin`, in `internal/docs/testing.go`. |
| 219 | |
| 220 | - [ ] **Step 2: Run it and watch it fail** |
| 221 | |
| 222 | ```bash |
| 223 | CGO_ENABLED=0 go test ./internal/docs/ -run 'TestCreateListGetRoundTrip|TestTextComesFromTheCore' |
| 224 | ``` |
| 225 | |
| 226 | - [ ] **Step 3: Implement** |
| 227 | |
| 228 | Schema, per spec §4 but only what this plan needs: |
| 229 | |
| 230 | ```sql |
| 231 | CREATE TABLE IF NOT EXISTS documents ( |
| 232 | id INTEGER PRIMARY KEY, |
| 233 | sqid TEXT NOT NULL UNIQUE, |
| 234 | title TEXT NOT NULL, |
| 235 | guid TEXT NOT NULL, |
| 236 | client_id INTEGER NOT NULL, |
| 237 | created_at DATETIME NOT NULL, |
| 238 | updated_at DATETIME NOT NULL, |
| 239 | deleted_at DATETIME |
| 240 | ); |
| 241 | CREATE TABLE IF NOT EXISTS doc_updates ( |
| 242 | id INTEGER PRIMARY KEY, |
| 243 | doc_id INTEGER NOT NULL, |
| 244 | seq INTEGER NOT NULL, |
| 245 | update_bin BLOB NOT NULL, |
| 246 | created_at DATETIME NOT NULL |
| 247 | ); |
| 248 | ``` |
| 249 | |
| 250 | Migrations are idempotent `CREATE TABLE IF NOT EXISTS` plus an additive `ALTER` list — rastrillo handles this; follow its pattern. |
| 251 | |
| 252 | **`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. |
| 253 | |
| 254 | **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. |
| 255 | |
| 256 | - [ ] **Step 4: Run the tests and watch them pass** |
| 257 | |
| 258 | ```bash |
| 259 | CGO_ENABLED=0 go test ./internal/docs/ -v |
| 260 | ``` |
| 261 | |
| 262 | - [ ] **Step 5: Commit** |
| 263 | |
| 264 | ```bash |
| 265 | git add internal/docs |
| 266 | git commit -m "app: documents in SQLite, text from the CRDT core |
| 267 | |
| 268 | Content is not a column -- Text replays the update log through the |
| 269 | wazero core. GUID and client id are assigned by the app and stored, |
| 270 | never drawn from yrs, which is the Go side of the rule clippy.toml |
| 271 | enforces in Rust." |
| 272 | ``` |
| 273 | |
| 274 | --- |
| 275 | |
| 276 | ### Task 3: The document list |
| 277 | |
| 278 | **Files:** |
| 279 | - Create: `internal/docs/views/list.html`, `internal/docs/list_test.go` |
| 280 | - Modify: `internal/docs/handlers.go`, `internal/docs/render.go` |
| 281 | |
| 282 | **Interfaces:** |
| 283 | - Consumes: `Store.List` from Task 2. |
| 284 | - Produces: `GET /` rendering the list. |
| 285 | |
| 286 | - [ ] **Step 1: Write the failing test** |
| 287 | |
| 288 | ```go |
| 289 | func TestListShowsDocuments(t *testing.T) { |
| 290 | app, _ := docs.NewForTest(t) |
| 291 | ctx := context.Background() |
| 292 | if _, err := app.Store().Create(ctx, "Board minutes"); err != nil { |
| 293 | t.Fatal(err) |
| 294 | } |
| 295 | |
| 296 | req := httptest.NewRequest(http.MethodGet, "/", nil) |
| 297 | rec := httptest.NewRecorder() |
| 298 | app.Handler().ServeHTTP(rec, req) |
| 299 | |
| 300 | body := rec.Body.String() |
| 301 | if rec.Code != http.StatusOK { |
| 302 | t.Fatalf("status %d", rec.Code) |
| 303 | } |
| 304 | if !strings.Contains(body, "Board minutes") { |
| 305 | t.Error("the document's title is not on the page") |
| 306 | } |
| 307 | if strings.Contains(body, `class="rst-`) { |
| 308 | t.Error("class vocabulary found — this app writes the attribute grammar (Global Constraints)") |
| 309 | } |
| 310 | } |
| 311 | |
| 312 | func TestEmptyListSaysSo(t *testing.T) { |
| 313 | app, _ := docs.NewForTest(t) |
| 314 | req := httptest.NewRequest(http.MethodGet, "/", nil) |
| 315 | rec := httptest.NewRecorder() |
| 316 | app.Handler().ServeHTTP(rec, req) |
| 317 | if rec.Code != http.StatusOK { |
| 318 | t.Fatalf("status %d", rec.Code) |
| 319 | } |
| 320 | // An empty state is a designed screen, not a blank page. |
| 321 | if len(strings.TrimSpace(rec.Body.String())) < 200 { |
| 322 | t.Error("empty list rendered almost nothing — there should be an empty state") |
| 323 | } |
| 324 | } |
| 325 | ``` |
| 326 | |
| 327 | The class-vocabulary assertion is a real gate: it makes the markup constraint mechanical rather than remembered. |
| 328 | |
| 329 | - [ ] **Step 2: Run and watch fail.** `CGO_ENABLED=0 go test ./internal/docs/ -run TestListShows` |
| 330 | |
| 331 | - [ ] **Step 3: Implement the list** |
| 332 | |
| 333 | 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. |
| 334 | |
| 335 | Design the row so a 26×34 thumbnail drops into its left edge later without reflowing anything else — thumbnails come with the layout engine. |
| 336 | |
| 337 | Use rastrillo's shell and partials; write the attribute grammar; every string a catalog key. |
| 338 | |
| 339 | - [ ] **Step 4: Run and watch pass.** |
| 340 | |
| 341 | - [ ] **Step 5: Commit** |
| 342 | |
| 343 | ```bash |
| 344 | git add internal/docs |
| 345 | git commit -m "app: the document list |
| 346 | |
| 347 | Per spec §6.4 and Paul's jank rulings: a list, newest first, no template |
| 348 | carousel, no filter chrome, no view toggles. The row reserves space a |
| 349 | page-one thumbnail drops into once the layout engine exists. |
| 350 | |
| 351 | The test asserts no class-vocabulary markup, which makes the attribute |
| 352 | grammar constraint mechanical rather than remembered." |
| 353 | ``` |
| 354 | |
| 355 | --- |
| 356 | |
| 357 | ### Task 4: Create and read a document |
| 358 | |
| 359 | **Files:** |
| 360 | - Create: `internal/docs/views/show.html` |
| 361 | - Modify: `internal/docs/handlers.go`, `internal/docs/list_test.go` |
| 362 | |
| 363 | **Interfaces:** |
| 364 | - Produces: `POST /documents` (create, redirect to the new document) and `GET /d/{sqid}` (read-only render). |
| 365 | |
| 366 | - [ ] **Step 1: Write the failing tests** |
| 367 | |
| 368 | ```go |
| 369 | func TestCreateThenRead(t *testing.T) { |
| 370 | app, _ := docs.NewForTest(t) |
| 371 | |
| 372 | form := strings.NewReader("title=Minutes") |
| 373 | req := httptest.NewRequest(http.MethodPost, "/documents", form) |
| 374 | req.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| 375 | rec := httptest.NewRecorder() |
| 376 | app.Handler().ServeHTTP(rec, req) |
| 377 | |
| 378 | if rec.Code != http.StatusSeeOther { |
| 379 | t.Fatalf("POST /documents: status %d, want 303", rec.Code) |
| 380 | } |
| 381 | loc := rec.Header().Get("Location") |
| 382 | if !strings.HasPrefix(loc, "/d/") { |
| 383 | t.Fatalf("Location = %q, want /d/<sqid>", loc) |
| 384 | } |
| 385 | |
| 386 | req2 := httptest.NewRequest(http.MethodGet, loc, nil) |
| 387 | rec2 := httptest.NewRecorder() |
| 388 | app.Handler().ServeHTTP(rec2, req2) |
| 389 | if rec2.Code != http.StatusOK { |
| 390 | t.Fatalf("GET %s: status %d", loc, rec2.Code) |
| 391 | } |
| 392 | if !strings.Contains(rec2.Body.String(), "Minutes") { |
| 393 | t.Error("the document's title is not on its own page") |
| 394 | } |
| 395 | } |
| 396 | |
| 397 | func TestUnknownDocument404s(t *testing.T) { |
| 398 | app, _ := docs.NewForTest(t) |
| 399 | req := httptest.NewRequest(http.MethodGet, "/d/nosuchthing", nil) |
| 400 | rec := httptest.NewRecorder() |
| 401 | app.Handler().ServeHTTP(rec, req) |
| 402 | if rec.Code != http.StatusNotFound { |
| 403 | t.Errorf("status %d, want 404 — a document you cannot see does not exist", rec.Code) |
| 404 | } |
| 405 | } |
| 406 | ``` |
| 407 | |
| 408 | The 404 test encodes spec §4.2's contract: not-yours is indistinguishable from not-there. |
| 409 | |
| 410 | - [ ] **Step 2: Run and watch fail.** |
| 411 | |
| 412 | - [ ] **Step 3: Implement** |
| 413 | |
| 414 | `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). |
| 415 | |
| 416 | `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. |
| 417 | |
| 418 | Give the canvas `color-scheme: light` and paint it from literals, per spec §10.2, so it stays paper in a dark theme. |
| 419 | |
| 420 | - [ ] **Step 4: Run and watch pass.** |
| 421 | |
| 422 | - [ ] **Step 5: Commit** |
| 423 | |
| 424 | ```bash |
| 425 | git add internal/docs |
| 426 | git commit -m "app: create and read a document |
| 427 | |
| 428 | POST redirects 303 so a refresh does not repost; both work with no |
| 429 | JavaScript. The read view takes its text from the CRDT through wazero |
| 430 | rather than a column, and its canvas opts out of the theme's scheme so |
| 431 | paper stays paper in the dark." |
| 432 | ``` |
| 433 | |
| 434 | --- |
| 435 | |
| 436 | ### Task 5: A way in, that cannot ship |
| 437 | |
| 438 | **Files:** |
| 439 | - Create: `internal/docs/devauth.go`, `internal/docs/devauth_test.go` |
| 440 | - Modify: `cmd/docs/main.go` |
| 441 | |
| 442 | **Interfaces:** |
| 443 | - Produces: a development-only identity so the app is reachable in a browser. |
| 444 | |
| 445 | 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. |
| 446 | |
| 447 | - [ ] **Step 1: Write the failing test** |
| 448 | |
| 449 | ```go |
| 450 | func TestDevAuthRefusesOutsideDevelopment(t *testing.T) { |
| 451 | // The guard is the point of this task: a build without the dev flag |
| 452 | // must refuse to start rather than silently authenticate everyone. |
| 453 | _, err := docs.NewWithDevAuth(docs.Config{DevAuth: true, Env: "production"}) |
| 454 | if err == nil { |
| 455 | t.Fatal("dev auth must refuse to start outside development") |
| 456 | } |
| 457 | if !strings.Contains(err.Error(), "development") { |
| 458 | t.Errorf("error should say why: %v", err) |
| 459 | } |
| 460 | } |
| 461 | |
| 462 | func TestDevAuthGivesAnIdentityInDevelopment(t *testing.T) { |
| 463 | app, err := docs.NewForTest(t) // NewForTest sets Env: development |
| 464 | if err != nil { |
| 465 | t.Fatal(err) |
| 466 | } |
| 467 | req := httptest.NewRequest(http.MethodGet, "/", nil) |
| 468 | rec := httptest.NewRecorder() |
| 469 | app.Handler().ServeHTTP(rec, req) |
| 470 | if rec.Code == http.StatusUnauthorized { |
| 471 | t.Error("dev auth should provide an identity in development") |
| 472 | } |
| 473 | } |
| 474 | ``` |
| 475 | |
| 476 | - [ ] **Step 2: Run and watch fail.** |
| 477 | |
| 478 | - [ ] **Step 3: Implement** |
| 479 | |
| 480 | 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. |
| 481 | |
| 482 | 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. |
| 483 | |
| 484 | - [ ] **Step 4: Run and watch pass.** |
| 485 | |
| 486 | - [ ] **Step 5: Commit** |
| 487 | |
| 488 | ```bash |
| 489 | git add internal/docs cmd |
| 490 | git commit -m "app: a development-only identity, guarded so it cannot ship |
| 491 | |
| 492 | Spec §8.1 puts sign-in at the home's credential origin and the home does |
| 493 | not exist yet. This refuses to start outside development rather than |
| 494 | silently authenticating everyone, and it is deleted when assertion |
| 495 | verification lands." |
| 496 | ``` |
| 497 | |
| 498 | --- |
| 499 | |
| 500 | ### Task 6: Look at it in a browser |
| 501 | |
| 502 | **Files:** |
| 503 | - Create: `scripts/run-dev.sh` |
| 504 | - Modify: `.amadan/ci.d/10-gate`, `AGENTS.md` |
| 505 | |
| 506 | - [ ] **Step 1: Add the run script** |
| 507 | |
| 508 | `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. |
| 509 | |
| 510 | - [ ] **Step 2: Run it and drive it** |
| 511 | |
| 512 | Start it, then exercise the real flows over HTTP and **report the actual output at each step**: |
| 513 | |
| 514 | ```bash |
| 515 | ./scripts/run-dev.sh & |
| 516 | sleep 2 |
| 517 | curl -s localhost:8099/ | head -30 |
| 518 | curl -s -X POST localhost:8099/documents -d 'title=Overnight test' -i | head -5 |
| 519 | curl -s localhost:8099/d/<sqid-from-the-redirect> | head -40 |
| 520 | ``` |
| 521 | |
| 522 | Confirm: the list renders, creating redirects, the document page shows its title, and the page links the design system's CSS. |
| 523 | |
| 524 | - [ ] **Step 3: Verify the markup constraint held** |
| 525 | |
| 526 | ```bash |
| 527 | curl -s localhost:8099/ | grep -c 'class="rst-' || true |
| 528 | ``` |
| 529 | |
| 530 | Expected: 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. |
| 531 | |
| 532 | - [ ] **Step 4: Extend the gate** |
| 533 | |
| 534 | 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`. |
| 535 | |
| 536 | - [ ] **Step 5: Commit and confirm CI** |
| 537 | |
| 538 | ```bash |
| 539 | git add scripts .amadan AGENTS.md |
| 540 | git commit -m "app: a one-command dev run, and the gate covers the app" |
| 541 | git push |
| 542 | ``` |
| 543 | |
| 544 | Then confirm `amadan ci status oficina/docs -branch <branch>` is exit 0. |
| 545 | |
| 546 | --- |
| 547 | |
| 548 | ## Self-Review |
| 549 | |
| 550 | **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. |
| 551 | |
| 552 | **Placeholder scan:** no TBDs. Two things are deliberately deferred with a comment rather than a silent omission: content sealing (§2) and thumbnails (§6.4). |
| 553 | |
| 554 | **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. |
| 555 | |
| 556 | **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. |
| 557 | |