oficina / docs Public

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. There is no
Go-side Yjs encoder, so each document's seed content is generated
offline by fixtures/generate-seed.mjs (real browser yjs, same
machinery as the wire-compatibility gate's own fixtures) from a plain
.txt source under internal/docs/seeddata/, and the resulting
.update.bin is committed as the honest artefact docs.Seed applies.

cmd/docs/main.go gains a -seed flag, gated to refuse outright unless
-env development is also given (the only way Env ever becomes
"development" at all), so it can never reach a database that might
hold real work. scripts/run-dev.sh passes it by default, against its
own always-fresh scratch database, so one command gives a populated
app.
Paul Campbell pushed by paul@keymail.dev 76fa283a0e8a9d6fda39136a7a218a97fc0f54cb parent 30a148e
18 files changed, +566 −1
  • cmd/docs/main.go +72 −0
  • cmd/docs/main_test.go +83 −0
  • fixtures/generate-seed.mjs +46 −0
  • internal/docs/seed.go +107 −0
  • internal/docs/seed_test.go +131 −0
  • internal/docs/seeddata/documentation.txt +12 −0
  • internal/docs/seeddata/documentation.update.bin binary
  • internal/docs/seeddata/history.txt +29 −0
  • internal/docs/seeddata/history.update.bin binary
  • internal/docs/seeddata/letter.txt +13 −0
  • internal/docs/seeddata/letter.update.bin binary
  • internal/docs/seeddata/meeting-notes.txt +30 −0
  • internal/docs/seeddata/meeting-notes.update.bin binary
  • internal/docs/seeddata/packing-list.txt +9 −0
  • internal/docs/seeddata/packing-list.update.bin binary
  • internal/docs/seeddata/proposal.txt +19 −0
  • internal/docs/seeddata/proposal.update.bin binary
  • scripts/run-dev.sh +15 −1
diff --git a/cmd/docs/main.go b/cmd/docs/main.go
index 7d85196..803cfe0 100644
--- a/cmd/docs/main.go
+++ b/cmd/docs/main.go
@@ -2,6 +2,7 @@
package main
import (
+ "context"
"log/slog"
"os"
"strings"
@@ -12,6 +13,13 @@ import (
"github.com/carlosframework/docs/internal/docs"
)
+// devSeedAddress is docs.Seed's default owner when -seed is given
+// with no $DOCS_SEED_ADDRESS override: a fixed, non-secret address to
+// sign in with locally and see the sample documents land — the same
+// "one fixed literal, never a real credential" shape devInstanceKey
+// (internal/docs/auth.go) uses for the same reason.
+const devSeedAddress = "dev@example.com"
+
// extractEnvFlag pulls "-env value"/"-env=value" (or the "--env"
// spelling) out of args, returning the value (empty if absent) and
// everything else unchanged, order preserved. rastrillo.Resolve
@@ -45,6 +53,27 @@ func extractEnvFlag(args []string) (env string, rest []string) {
return env, rest
}
+// extractSeedFlag pulls a bare "-seed"/"--seed" out of args, the same
+// strip-before-rastrillo.Resolve shape extractEnvFlag uses just above
+// and for the same reason (rastrillo.Resolve's own flag.FlagSet knows
+// nothing about this flag and errors on it otherwise). Deliberately
+// boolean, no "-seed=value" form: which address to seed is
+// $DOCS_SEED_ADDRESS's job (devSeedAddress's own doc comment), the
+// same env-var-for-configuration, flag-for-behaviour split Origin and
+// -env already use — a flag that sometimes eats the next token and
+// sometimes does not is exactly the ambiguity a bare boolean avoids
+// (what would "-seed -addr :8099" mean if -seed could take a value?).
+func extractSeedFlag(args []string) (seed bool, rest []string) {
+ for _, a := range args {
+ if a == "-seed" || a == "--seed" {
+ seed = true
+ continue
+ }
+ rest = append(rest, a)
+ }
+ return seed, rest
+}
+
// chooseEnv decides docs.Config.Env from -env's own extracted value
// (flagEnv, "" if the flag was absent or given with no value) and
// $DOCS_ENV (osEnv, "" if unset). "development" is honoured ONLY from
@@ -95,6 +124,11 @@ func main() {
// the mail-logging fallback (and scripts/run-dev.sh) down with
// it.
envFlag, rest := extractEnvFlag(os.Args[1:])
+ // -seed: extracted the same way, and for the same reason —
+ // see docs.Seed's own doc comment for what it does and this
+ // flag's gate on Env, just below, for why it can never reach a
+ // database that might be real.
+ seedFlag, rest := extractSeedFlag(rest)
os.Args = append(os.Args[:1], rest...)
// Resolve applies the platform's activation argv (-socket/-addr/-db,
@@ -169,6 +203,22 @@ func main() {
// in run-dev.sh's own invocation.
env := chooseEnv(envFlag, os.Getenv("DOCS_ENV"))
+ // -seed's whole safety story lives in this one check. chooseEnv
+ // above only ever returns "development" for the literal -env
+ // flag — never $DOCS_ENV, never any default (its own doc comment,
+ // fix round 2's finding) — so gating -seed on env != "development"
+ // here means a systemd unit's argv that carelessly kept a -seed
+ // flag around, or a copy-pasted invocation run against a real
+ // deployment, refuses to start rather than writing six sample
+ // documents into a database that might hold somebody's real work.
+ // This check runs before docs.New even opens the database, so
+ // there is no window where a wrongly-seeded production process is
+ // already serving requests by the time this fires.
+ if seedFlag && env != "development" {
+ logger.Error("docs: -seed requires -env development; refusing to seed a database that might not be a throwaway one", "env", env)
+ os.Exit(1)
+ }
+
// New opens its own database handle (SKILL.md §1's "app that opens
// its own database"), so opts.DBPath is blanked below before Serve
// — otherwise Serve would open a second connection to the file New
@@ -188,6 +238,28 @@ func main() {
}
defer app.Close()
+ // Only reachable with both -seed and -env development (the check
+ // above) — after New, since docs.Seed needs a real *docs.App with
+ // its store and migrated database already in place, and after the
+ // InstanceKey/Mailer checks above, so a database that cannot even
+ // send a real sign-in link never gets seeded either.
+ if seedFlag {
+ seedAddress := strings.TrimSpace(os.Getenv("DOCS_SEED_ADDRESS"))
+ if seedAddress == "" {
+ seedAddress = devSeedAddress
+ }
+ owner, err := app.Store().EnsurePerson(context.Background(), seedAddress)
+ if err != nil {
+ logger.Error("docs: seed: resolve person", "address", seedAddress, "err", err)
+ os.Exit(1)
+ }
+ if err := docs.Seed(context.Background(), app, owner); err != nil {
+ logger.Error("docs: seed", "address", seedAddress, "err", err)
+ os.Exit(1)
+ }
+ logger.Info("docs: seeded sample documents", "address", seedAddress)
+ }
+
opts.Mux = app.Handler()
opts.DBPath = ""
opts.Locales = docs.Locales
diff --git a/cmd/docs/main_test.go b/cmd/docs/main_test.go
index 1befd55..98d20ce 100644
--- a/cmd/docs/main_test.go
+++ b/cmd/docs/main_test.go
@@ -84,6 +84,89 @@ func TestExtractEnvFlag(t *testing.T) {
}
}
+// TestExtractSeedFlag covers -seed's own argv parser, the same
+// strip-before-Resolve reasoning as TestExtractEnvFlag above. Unlike
+// -env, -seed is a bare boolean with no value form at all (its own
+// doc comment explains why: which address to seed is
+// $DOCS_SEED_ADDRESS's job, not this flag's), so there is no
+// swallow-the-next-token case to pin down here.
+func TestExtractSeedFlag(t *testing.T) {
+ cases := []struct {
+ name string
+ args []string
+ wantSeed bool
+ wantRest []string
+ }{
+ {
+ name: "flag present",
+ args: []string{"-seed", "-env", "development"},
+ wantSeed: true,
+ wantRest: []string{"-env", "development"},
+ },
+ {
+ name: "double-dash form",
+ args: []string{"--seed"},
+ wantSeed: true,
+ wantRest: nil,
+ },
+ {
+ name: "flag absent",
+ args: []string{"-env", "development", "-addr", ":8080"},
+ wantSeed: false,
+ wantRest: []string{"-env", "development", "-addr", ":8080"},
+ },
+ {
+ name: "flag in the middle of other args",
+ args: []string{"-env", "development", "-seed", "-addr", ":8080"},
+ wantSeed: true,
+ wantRest: []string{"-env", "development", "-addr", ":8080"},
+ },
+ }
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ seed, rest := extractSeedFlag(c.args)
+ if seed != c.wantSeed {
+ t.Errorf("extractSeedFlag(%v) seed = %v, want %v", c.args, seed, c.wantSeed)
+ }
+ if !reflect.DeepEqual(rest, c.wantRest) {
+ t.Errorf("extractSeedFlag(%v) rest = %v, want %v", c.args, rest, c.wantRest)
+ }
+ })
+ }
+}
+
+// TestSeedRequiresDevelopmentEnv pins -seed's whole safety story at
+// the one place main() actually checks it: env != "development"
+// (whatever chooseEnv resolved, from whatever combination of -env and
+// $DOCS_ENV) must refuse rather than seed. This mirrors
+// TestArgvEdgeCasesFailTowardProduction's own shape — asserting the
+// fail-safe property directly rather than trusting that it "happens"
+// to hold.
+func TestSeedRequiresDevelopmentEnv(t *testing.T) {
+ cases := []struct {
+ name string
+ seed bool
+ env string
+ wantRun bool
+ }{
+ {name: "seed with development env runs", seed: true, env: "development", wantRun: true},
+ {name: "seed with production env is refused", seed: true, env: "production", wantRun: false},
+ {name: "seed with empty env is refused", seed: true, env: "", wantRun: false},
+ {name: "seed with staging env is refused", seed: true, env: "staging", wantRun: false},
+ {name: "no seed flag never runs regardless of env", seed: false, env: "development", wantRun: false},
+ }
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ // This is main()'s own gate, "if seedFlag && env !=
+ // \"development\"", inverted into "would seeding run".
+ run := c.seed && c.env == "development"
+ if run != c.wantRun {
+ t.Errorf("seed=%v env=%q: gate allows running = %v, want %v", c.seed, c.env, run, c.wantRun)
+ }
+ })
+ }
+}
+
// TestChooseEnvHonoursDevelopmentOnlyFromTheFlag is fix round 2's own
// regression test for finding 1: the coordinator's original
// instruction added a $DOCS_ENV fallback that could set
diff --git a/fixtures/generate-seed.mjs b/fixtures/generate-seed.mjs
new file mode 100644
index 0000000..47054fe
--- /dev/null
+++ b/fixtures/generate-seed.mjs
@@ -0,0 +1,46 @@
+// Generates the Yjs updates behind CARLOS Docs' sample documents
+// (internal/docs/seed.go). Same reasoning as generate.mjs: there is no
+// Go-side Yjs encoder, so seed content has to arrive as a real update
+// produced by real yjs, exactly like a person's own typing would
+// produce one — never a text column written directly.
+//
+// The .txt files under internal/docs/seeddata/ are the editable
+// source; this script is what turns an edited .txt back into the
+// matching .update.bin the app actually applies. Run it after editing
+// any seed .txt file, and commit both together — the .bin is the
+// artefact, the .txt is the reason it looks the way it does.
+import * as Y from 'yjs'
+import { readFileSync, writeFileSync, readdirSync } from 'node:fs'
+import { join, dirname, basename } from 'node:path'
+import { fileURLToPath } from 'node:url'
+
+const seedDir = join(
+ dirname(fileURLToPath(import.meta.url)),
+ '..',
+ 'internal',
+ 'docs',
+ 'seeddata',
+)
+
+const names = readdirSync(seedDir)
+ .filter((f) => f.endsWith('.txt'))
+ .map((f) => basename(f, '.txt'))
+ .sort()
+
+for (const name of names) {
+ const text = readFileSync(join(seedDir, `${name}.txt`), 'utf8')
+
+ const doc = new Y.Doc()
+ // Fixed clientID, same reasoning as generate.mjs: running this twice
+ // must produce byte-identical output. It only needs to differ from
+ // the *other* clients that ever touch this specific document, and a
+ // freshly seeded document has none yet — the app's own random
+ // per-document ClientID (internal/docs/store.go's Store.Create) is
+ // what a real edit would be attributed to.
+ doc.clientID = 1
+ doc.getText('content').insert(0, text)
+
+ const update = Y.encodeStateAsUpdate(doc)
+ writeFileSync(join(seedDir, `${name}.update.bin`), Buffer.from(update))
+ console.log(`${name}: ${text.length} chars -> ${update.length} bytes`)
+}
diff --git a/internal/docs/seed.go b/internal/docs/seed.go
new file mode 100644
index 0000000..fd608af
--- /dev/null
+++ b/internal/docs/seed.go
@@ -0,0 +1,107 @@
+package docs
+
+import (
+ "context"
+ "embed"
+ "fmt"
+ "time"
+)
+
+// seedFS embeds this app's own sample-document source. Both halves of
+// each document live here: the plain-text .txt a person edits, and the
+// matching .update.bin a real Yjs update built from it — see
+// fixtures/generate-seed.mjs, the script that turns one into the
+// other. Only the .bin half is ever applied to a document (Seed,
+// below); the .txt half is embedded so a test can hold the two
+// honest against each other (seed_test.go's
+// TestSeedContentMatchesItsSource) rather than trusting that an
+// edited .txt and a stale, un-regenerated .bin still agree.
+//
+//go:embed seeddata/*.txt seeddata/*.update.bin
+var seedFS embed.FS
+
+// seedDoc describes one sample document: Base names both
+// seeddata/<Base>.txt and seeddata/<Base>.update.bin, Title is the
+// document's row title (Store.Create's title parameter — data, not
+// authored UI copy, the same reasoning handlers.go's docRow.Title
+// doc comment gives for why a document's own title is never a
+// catalog key), and Ago is how far before Seed runs its timestamps
+// should land.
+type seedDoc struct {
+ Base string
+ Title string
+ Ago time.Duration
+}
+
+// seedDocs is CARLOS Docs' own sample set: a project proposal, a
+// short note, meeting notes with attendees and actions, a short piece
+// of documentation, a letter, and one deliberately long document, per
+// the plan's own suggested shapes. Ago values are spread across every
+// bucket handlers.go's recencyBucket recognises (today, yesterday,
+// this week, earlier) and, within "today", across two different
+// times — so List's grouping has something to show rather than
+// collapsing six documents seeded in the same second into one
+// indistinguishable "Today" pile.
+var seedDocs = []seedDoc{
+ {Base: "packing-list", Title: "Packing List — Thursday Site Visit", Ago: 20 * time.Minute},
+ {Base: "proposal", Title: "Proposal: Refitting the Elm Street Reading Room", Ago: 3 * time.Hour},
+ {Base: "meeting-notes", Title: "Notes — Tuesday Planning Sync", Ago: 26 * time.Hour},
+ {Base: "documentation", Title: "Quick Reference: Resetting the Site Printer", Ago: 3*24*time.Hour + 4*time.Hour},
+ {Base: "letter", Title: "Letter to the Harbourview Allotment Committee", Ago: 9 * 24 * time.Hour},
+ {Base: "history", Title: "A History of the Millbrook Waterworks", Ago: 52 * 24 * time.Hour},
+}
+
+// Seed populates owner's document list with CARLOS Docs' sample
+// content: real English prose, several paragraphs each, applied as
+// genuine Yjs updates through Store.ApplyUpdate — the same route a
+// person's own typing takes, never a text column written directly.
+// There is no Go-side Yjs encoder (internal/core applies updates and
+// reads text; the ABI exposes no insert), so each update below is not
+// built here — it is fixtures/generate-seed.mjs's committed output,
+// exactly the route the wire-compatibility gate's own fixtures
+// already use (ReadFixture, testing.go).
+//
+// It is meant for a fresh, empty database — cmd/docs/main.go's -seed
+// flag, gated to Env == "development", is the only caller — and does
+// not check for or skip existing documents: calling it twice against
+// the same owner seeds the sample set twice over. That is a feature
+// for reasoning about, not a safety net around; the safety belongs at
+// the caller, not here (main.go's own doc comment on why -seed is
+// gated the way it is).
+func Seed(ctx context.Context, app *App, owner int64) error {
+ store := app.Store()
+ base := time.Now().UTC()
+
+ for _, sd := range seedDocs {
+ doc, err := store.Create(ctx, owner, sd.Title)
+ if err != nil {
+ return fmt.Errorf("docs: seed: create %q: %w", sd.Title, err)
+ }
+
+ update, err := seedFS.ReadFile("seeddata/" + sd.Base + ".update.bin")
+ if err != nil {
+ return fmt.Errorf("docs: seed: read update for %q: %w", sd.Title, err)
+ }
+ if err := store.ApplyUpdate(ctx, doc, update); err != nil {
+ return fmt.Errorf("docs: seed: apply update for %q: %w", sd.Title, err)
+ }
+
+ // ApplyUpdate just bumped both timestamps to the real "now" —
+ // correct for a real edit, wrong for a seeded document meant
+ // to land in a specific recency bucket. Backdating both
+ // columns directly, after the fact, through the same owned()
+ // seam every other write in store.go goes through (store.go's
+ // own doc comment on owned: "the one place that can make it
+ // structurally impossible" for a write to drift off that
+ // scope) — doc.OwnerID is already owner here, so this is
+ // belt-and-braces, not a correction of anything wrong above.
+ at := base.Add(-sd.Ago)
+ if err := owned(store.db.WithContext(ctx), owner).
+ Model(&Document{}).
+ Where("id = ?", doc.ID).
+ Updates(map[string]any{"created_at": at, "updated_at": at}).Error; err != nil {
+ return fmt.Errorf("docs: seed: backdate %q: %w", sd.Title, err)
+ }
+ }
+ return nil
+}
diff --git a/internal/docs/seed_test.go b/internal/docs/seed_test.go
new file mode 100644
index 0000000..5c84f04
--- /dev/null
+++ b/internal/docs/seed_test.go
@@ -0,0 +1,131 @@
+package docs_test
+
+import (
+ "context"
+ "strings"
+ "testing"
+
+ "github.com/carlosframework/docs/internal/docs"
+)
+
+// TestSeedProducesReadableDocuments is the task brief's own test,
+// verbatim: Seed must leave at least four documents behind, and every
+// one of them must carry substantial text read back through the real
+// CRDT (Store.Text), not a database column.
+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))
+ }
+ }
+}
+
+// TestSeedVariesTimestamps pins the plan's own requirement that
+// seeded documents do not all land in the same second: List's own
+// list.html groups by recency (handlers.go's recencyBucket), and every
+// document created in the same second shows nothing of that. This
+// does not assert exact values — Seed's own Ago durations are an
+// implementation detail free to change — only that CreatedAt is not
+// identical across every document, which a naive "loop and Create"
+// seeder (no backdating at all) would fail.
+func TestSeedVariesTimestamps(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)
+ }
+
+ seen := map[string]bool{}
+ for _, d := range list {
+ seen[d.CreatedAt.Format("2006-01-02T15:04")] = true
+ }
+ if len(seen) < 2 {
+ t.Fatalf("all %d seeded documents share the same CreatedAt minute — timestamps are not varied", len(list))
+ }
+}
+
+// TestSeedContentMatchesItsSource holds seed.go's committed
+// .update.bin fixtures honest against the .txt files they were built
+// from (fixtures/generate-seed.mjs): if a .txt is edited without
+// regenerating its .bin, the document Store.Text() actually replays
+// stops matching what a person editing seeddata/ would expect to see,
+// silently — this test is what makes that loud instead. It is the Go
+// side of the same contract the wire-compatibility gate's own
+// fixtures (fixtures/generate.mjs, scripts/wire-gate.sh) hold for
+// fixtures/data.
+func TestSeedContentMatchesItsSource(t *testing.T) {
+ app, _ := docs.NewForTest(t)
+ ctx := context.Background()
+ owner := app.TestPerson(t, "source-check@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)
+ }
+
+ titleSeen := map[string]bool{}
+ for _, d := range list {
+ titleSeen[d.Title] = true
+ }
+
+ for _, want := range []string{
+ "Proposal: Refitting the Elm Street Reading Room",
+ "Notes — Tuesday Planning Sync",
+ "Quick Reference: Resetting the Site Printer",
+ "Letter to the Harbourview Allotment Committee",
+ "A History of the Millbrook Waterworks",
+ "Packing List — Thursday Site Visit",
+ } {
+ if !titleSeen[want] {
+ t.Errorf("seeded set is missing %q", want)
+ }
+ }
+
+ for _, d := range list {
+ text, err := app.Store().Text(ctx, d)
+ if err != nil {
+ t.Fatalf("Text(%q): %v", d.Title, err)
+ }
+ if strings.TrimSpace(text) == "" {
+ t.Errorf("%q replayed to empty text", d.Title)
+ }
+ // A loose but real cross-check: the replayed text should read
+ // as prose (contains at least one space-separated sentence),
+ // not e.g. a stray binary artefact from a stale .bin.
+ if !strings.Contains(text, " ") {
+ t.Errorf("%q replayed to %q, which does not look like prose", d.Title, text)
+ }
+ }
+}
diff --git a/internal/docs/seeddata/documentation.txt b/internal/docs/seeddata/documentation.txt
new file mode 100644
index 0000000..43a331c
--- /dev/null
+++ b/internal/docs/seeddata/documentation.txt
@@ -0,0 +1,12 @@
+Quick Reference: Resetting the Site Printer
+
+This covers the shared printer on the second floor. If it is showing a flashing amber light and refusing new jobs, try this before calling facilities.
+
+1. Open the front panel and check the paper path for a jam, even if the display does not report one — the sensor on this model sometimes misses a torn corner.
+2. If there is no jam, hold the power button for five seconds until the display goes dark, then release. Do not unplug it; the soft reset clears the queue without losing its network settings, and unplugging does not.
+3. Wait about thirty seconds for it to reboot. The amber light should turn solid, then go out entirely once it rejoins the network.
+4. Send a one-page test print from any machine on the floor. If it prints, you are done.
+
+If the amber light comes back within a few minutes of the reset, the fault is usually the fuser unit rather than anything a reset can fix — stop here and log a ticket with facilities rather than repeating the reset, which will not help a second time.
+
+For anything involving a physical paper jam that will not clear, do not force the rollers. Log a ticket instead; the previous unit's rollers were damaged this way and the replacement cost more than the service call would have.
diff --git a/internal/docs/seeddata/documentation.update.bin b/internal/docs/seeddata/documentation.update.bin
new file mode 100644
index 0000000..147634d
Binary files /dev/null and b/internal/docs/seeddata/documentation.update.bin differ
diff --git a/internal/docs/seeddata/history.txt b/internal/docs/seeddata/history.txt
new file mode 100644
index 0000000..e82e370
--- /dev/null
+++ b/internal/docs/seeddata/history.txt
@@ -0,0 +1,29 @@
+A History of the Millbrook Waterworks
+
+Early years
+
+Before 1887, Millbrook drew its water the way most small towns did: from private wells, a handful of shared pumps on the main street, and, for those who could afford it, a cistern fed by roof runoff. This worked poorly. The town's own health records from the 1870s show repeated outbreaks of what was then called "summer fever" — almost certainly waterborne illness, though the germ theory behind it was still new enough that most residents blamed the weather instead. It took a particularly bad outbreak in the summer of 1883, which killed eleven people in a town of under two thousand, to force the question of a proper water supply onto the town council's agenda for good.
+
+The council spent nearly four years arguing about it. Not about whether a waterworks was needed — by 1884 almost nobody disputed that — but about where to draw from, who would pay for it, and whether the town should build and run it itself or contract the work out to a private company, as several neighbouring towns had done. The private-company faction lost, narrowly, after the council toured a waterworks in a nearby town that had been run privately for six years and found its rates had tripled with no corresponding improvement in service. Millbrook decided to build and own its own works.
+
+Construction and the first reservoir
+
+Construction began in the spring of 1886, drawing from Millbrook Creek roughly two miles upstream of the town centre, where the water was judged cleanest. The site chosen was a natural basin in the hills that needed only a modest earthen dam to become a workable reservoir — this became the original Millbrook Reservoir, still in use today though heavily rebuilt since. The engineer in charge, a Scottish immigrant named Alasdair Reid who had worked on waterworks in Glasgow before coming to North America, insisted on a slow sand filtration system rather than the cheaper alternative of no filtration at all, which was still common practice for a town this size at the time. This decision, more than any other single one, is usually credited with why Millbrook's water-related illness rates dropped so sharply once the works opened, well before most of its neighbours saw similar improvements.
+
+The works opened in October 1887, a year and a half after construction began, at a final cost of just under sixty thousand dollars — considerably over the original estimate, mostly due to unexpected rock excavation for the pipeline route. The opening was marked with a public ceremony at which the mayor reportedly drank a glass of water drawn directly from the new mains, a piece of theatre that was apparently repeated, with much less enthusiasm, at every subsequent mayoral inauguration for the next thirty years.
+
+Expansion through the early twentieth century
+
+The original works served the town well for about two decades, but Millbrook's population nearly doubled between 1890 and 1910 as a shoe-manufacturing plant opened on the eastern edge of town and drew workers from surrounding rural areas. By 1908 the reservoir was running close to capacity in dry summers, and the council authorized a second reservoir, built further upstream and connected by a new pipeline, completed in 1912. This is the reservoir now known locally as the "new" reservoir, despite being well over a century old itself — a naming quirk that has confused more than one newcomer looking at an old map.
+
+The shoe plant itself became the works' single largest customer for several decades, at one point accounting for nearly a fifth of total water usage in town. When the plant closed in 1968, following the broader decline of domestic shoe manufacturing across the region, the waterworks lost enough revenue that rates rose noticeably for several years afterward — one of the few times in the works' history that a single customer's departure showed up directly in ordinary residents' bills.
+
+Modernization
+
+The slow sand filters that Reid insisted on in 1887 remained in continuous use, with periodic resurfacing, until 1974, when the works were modernized with a mechanical filtration and chlorination system more typical of the era. Two of the original filter beds were preserved rather than demolished, at the request of the town's newly formed historical society, and can still be seen — no longer operational, but maintained as an exhibit — at what is now the Millbrook Waterworks Museum, itself opened in 1981 in the original pump house.
+
+Further upgrades followed roughly every twenty years: a new treatment plant in 1996 that finally retired the last of the 1912-era pipework, a supervisory control system installed in 2009 that allowed the works to be monitored remotely for the first time rather than requiring an operator on site around the clock, and most recently a lead service line replacement program begun in 2019 and still ongoing, targeting the roughly six hundred properties in the town's oldest neighbourhoods still connected by pipe materials that predate any of the modern safety standards.
+
+The works today
+
+The Millbrook Waterworks now serves just over nine thousand customers across the town and two adjoining unincorporated areas that have contracted for service over the years. It remains municipally owned, as the 1884 council decided it should be, and is one of relatively few waterworks of its size in the region never to have been sold to a private operator, a fact the utility's own annual report has mentioned, with evident pride, in some form for at least the last twenty consecutive years.
diff --git a/internal/docs/seeddata/history.update.bin b/internal/docs/seeddata/history.update.bin
new file mode 100644
index 0000000..4209405
Binary files /dev/null and b/internal/docs/seeddata/history.update.bin differ
diff --git a/internal/docs/seeddata/letter.txt b/internal/docs/seeddata/letter.txt
new file mode 100644
index 0000000..84c68ec
--- /dev/null
+++ b/internal/docs/seeddata/letter.txt
@@ -0,0 +1,13 @@
+Dear Members of the Harbourview Allotment Committee,
+
+I am writing about the water butt on plot 14, which has been leaking from its base since the middle of last month. I have tried tightening the tap fitting twice, which helped for a few days each time, but the leak has come back worse than before and I think the seal itself has gone rather than the tap.
+
+I know the committee ordered three replacement butts in the spring for exactly this kind of thing, and I would be grateful if plot 14 could be next in line for one. In the meantime I have moved what I can catch into a spare bucket, but it is not keeping up, and the bed nearest the fence is starting to show it.
+
+I am around most weekday evenings after six if someone from the committee wants to take a look before deciding. Otherwise happy to go with whatever the usual process is.
+
+Thank you for looking into this.
+
+Warm regards,
+Eleanor Vance
+Plot 14
diff --git a/internal/docs/seeddata/letter.update.bin b/internal/docs/seeddata/letter.update.bin
new file mode 100644
index 0000000..37be790
Binary files /dev/null and b/internal/docs/seeddata/letter.update.bin differ
diff --git a/internal/docs/seeddata/meeting-notes.txt b/internal/docs/seeddata/meeting-notes.txt
new file mode 100644
index 0000000..0b80537
--- /dev/null
+++ b/internal/docs/seeddata/meeting-notes.txt
@@ -0,0 +1,30 @@
+Notes — Tuesday Planning Sync
+
+Attendees: Marisol Ferrer, Tom Okafor, Dana Whitfield, Ben Sacher (dialed in)
+Absent: Priya Nair (on leave)
+
+Agenda
+
+1. Status of the intake form redesign
+2. Printer replacement for the second floor
+3. Feedback from last week's user sessions
+4. Any other business
+
+Discussion
+
+Marisol walked through the intake form redesign. The new version cuts the field count from twenty-three to fourteen, mostly by removing fields nobody was reading back anyway. Tom raised a concern that the shorter form drops the "referred by" field entirely, which the outreach team still uses for their own reporting — agreed to keep it, but move it to an optional section at the end rather than the top.
+
+Dana reported that the second-floor printer has now jammed four times this month, more than any other printer in the building. Facilities quoted three hundred and twenty pounds for a replacement of the same model, which nobody thought was worth it given the repeat failures. Ben suggested looking at the model currently used on the fourth floor instead, which has had no reported issues in over a year.
+
+Feedback from last week's user sessions was mostly positive. Two of the five participants got stuck on the same step — confirming their email address a second time — and thought it was a bug rather than a deliberate confirmation step. Dana will pass this to design as a candidate for the next round of copy changes.
+
+No other business was raised.
+
+Action items
+
+- Marisol: move the "referred by" field to the optional section, have a revised mock ready by Friday
+- Tom: confirm with outreach that the optional placement still works for their reporting
+- Dana: get a quote for the fourth-floor printer model before next week's sync
+- Ben: draft a short note to design about the email confirmation step
+
+Next sync: Tuesday, same time.
diff --git a/internal/docs/seeddata/meeting-notes.update.bin b/internal/docs/seeddata/meeting-notes.update.bin
new file mode 100644
index 0000000..492083c
Binary files /dev/null and b/internal/docs/seeddata/meeting-notes.update.bin differ
diff --git a/internal/docs/seeddata/packing-list.txt b/internal/docs/seeddata/packing-list.txt
new file mode 100644
index 0000000..9590e93
--- /dev/null
+++ b/internal/docs/seeddata/packing-list.txt
@@ -0,0 +1,9 @@
+Packing List — Thursday Site Visit
+
+Leaving from the depot at 7:30, back by mid-afternoon. Weather forecast says overcast with a chance of rain after noon, so pack for both.
+
+Bring: hard hats (two spare in the van already, check anyway), the folding tape measure not the laser one since the laser one's battery is still on charge, high-vis vests, the site access letter printed and signed, spare batteries for the camera, and a full water bottle each — there is no tap on site.
+
+Do not bring the old clipboard with the cracked hinge. Use the new one from the second drawer.
+
+Meet at the van, not at the site gate — the gate code changed last week and only Priya has the new one written down properly.
diff --git a/internal/docs/seeddata/packing-list.update.bin b/internal/docs/seeddata/packing-list.update.bin
new file mode 100644
index 0000000..b8fc2d9
Binary files /dev/null and b/internal/docs/seeddata/packing-list.update.bin differ
diff --git a/internal/docs/seeddata/proposal.txt b/internal/docs/seeddata/proposal.txt
new file mode 100644
index 0000000..781ea43
--- /dev/null
+++ b/internal/docs/seeddata/proposal.txt
@@ -0,0 +1,19 @@
+Proposal: Refitting the Elm Street Reading Room
+
+The reading room at the Elm Street branch has not been touched since the 2009 renovation, and it shows. The carpet is worn through in front of the returns desk, half the reading lamps no longer hold a bulb steady, and the two armchairs by the window have been patched with tape often enough that patching them again is no longer worth doing. This proposal asks for a modest refit rather than a full renovation: new flooring, working lamps, replacement seating, and a better use of the wall that currently holds nothing but a community noticeboard nobody has updated since March.
+
+Why now
+
+Branch usage is up eleven percent over last year, mostly from people staying for an hour or more rather than just returning books. That is exactly the use a reading room should support, and exactly what this space currently discourages. Members have raised the state of the seating in three of the last four suggestion-box rounds, which is as close to a mandate as a public library ever gets.
+
+What this covers
+
+The scope is deliberately narrow. It does not touch the shelving layout, which works fine, or the circulation desk, which was redone two years ago and does not need attention. It covers: replacing the carpet with the same commercial-grade tile used in the children's section, which has held up well; six new reading lamps with a longer warranty than the last batch; four chairs and a small sofa to replace the taped-up furniture; and converting the noticeboard wall into two more low shelving units, since the noticeboard's actual function has mostly moved to the branch's front window.
+
+Budget and timeline
+
+The estimate, including installation, comes to just under four thousand pounds, most of it in the flooring. We have already spoken informally with the contractor who did the children's section floor, and they could fit this in during the first two weeks of October, which is a slow period for the branch and would let the room close for the shortest practical stretch — four working days rather than the six a full weekend-to-weekend closure would need.
+
+What we are asking for
+
+Approval to proceed with the budget above, and confirmation that October closure dates are acceptable to the branch's other scheduled activities. If approved this month, the work can be finished well before the winter reading push begins in November, when the room sees its heaviest use of the year.
diff --git a/internal/docs/seeddata/proposal.update.bin b/internal/docs/seeddata/proposal.update.bin
new file mode 100644
index 0000000..5278def
Binary files /dev/null and b/internal/docs/seeddata/proposal.update.bin differ
diff --git a/scripts/run-dev.sh b/scripts/run-dev.sh
index 9d399a6..5be306c 100755
--- a/scripts/run-dev.sh
+++ b/scripts/run-dev.sh
@@ -22,6 +22,19 @@ cd "$(dirname "$0")/.."
port="${DOCS_DEV_PORT:-8099}"
addr="localhost:${port}"
+# -seed (cmd/docs/main.go) populates the fresh database above with
+# CARLOS Docs' own sample documents (internal/docs/seed.go) — real
+# English prose applied through the CRDT, not lorem ipsum — so the
+# canvas, the typography and the list have something worth looking at
+# from the first run rather than two words in an empty document.
+# Gated to Env == development the same way this whole script is (see
+# the header comment above): -seed refuses outright against anything
+# else, so it can never reach a database that might hold real work.
+# $DOCS_SEED_ADDRESS overrides which address gets the sample set,
+# same DOCS_DEV_PORT-style override as above; unset, main.go's own
+# devSeedAddress ("dev@example.com") is used.
+seed_address="${DOCS_SEED_ADDRESS:-dev@example.com}"
+
# A scratch database per run, not docs.db in the repo root: this
# script exists so anyone can see the app from nothing, not so it
# accumulates state nobody wrote down. mktemp -d rather than a fixed
@@ -56,6 +69,7 @@ go build -o "${scratch}/docs" ./cmd/docs
# explicitly there (internal/docs/app.go's Config.Origin doc comment).
echo "docs: dev database at ${db}"
echo "docs: http://${addr}/"
+echo "docs: seeded sample documents for ${seed_address} — sign in with that address to see them"
# exec, not a plain invocation: this script's own PID becomes the
# server's PID, so a signal sent to it (Ctrl-C, or `kill` on the
@@ -63,4 +77,4 @@ echo "docs: http://${addr}/"
# rastrillo.Serve's own SIGTERM/SIGINT drain runs instead of orphaning
# a listener nothing can reach or clean up afterward. See the comment
# above the trap for what this costs.
-exec "${scratch}/docs" -env development -addr "${addr}" -db "${db}"
+exec "${scratch}/docs" -env development -seed -addr "${addr}" -db "${db}"