diff --git a/README.md b/README.md
index e8c7662..e7e3194 100644
--- a/README.md
+++ b/README.md
@@ -5,9 +5,10 @@ handlers, and return generated UI commands. The browser receives checked UI
commands; ordinary server-first apps do not need a frontend framework,
handwritten UI JavaScript, selector targeting, or raw runtime primitives. req: pitch/001 req: canonical_authoring/001
-Status: the repository currently has a strong v0 example/API path, an advanced
-Kanban milestone sketch, and a full techdemo. The polished v1 production SaaS
-tutorial app is still a goal, not a completed artifact.
+Status: the repository currently has a strong v0 example/API path, a compile-tested
+SaaS tutorial skeleton, an advanced Kanban milestone sketch, and a full techdemo.
+The polished v1 product story is still being filled out through docs, recipes,
+diagnostics, and stability guidance.
## The normal path
@@ -120,9 +121,10 @@ See `docs/versioning.md`.
updates. Start here.
- `examples/saas`: compile-tested v1 tutorial skeleton covering auth/session,
CSRF-safe mutation, local persistence, generated swaps, page/push shape, plain
- CSS, and one explicit island without provider-heavy platform scope. The SQLx
- persistence recipe in `docs/recipes/sqlx-persistence.md` shows the provider
- boundary without moving SQL into core.
+ CSS, and one explicit island without provider-heavy platform scope. Read the
+ walkthrough in `docs/tutorial-saas.md`; the SQLx persistence recipe in
+ `docs/recipes/sqlx-persistence.md` shows the provider boundary without moving
+ SQL into core.
- `examples/kanban`: advanced / north-star milestone boundary sketch. It may
expose manual registry or render escape hatches while exploring product limits.
- `examples/techdemo`: advanced integration demo with a leaf island and broader
diff --git a/docs/tutorial-saas.md b/docs/tutorial-saas.md
new file mode 100644
index 0000000..0a87dde
--- /dev/null
+++ b/docs/tutorial-saas.md
@@ -0,0 +1,228 @@
+# Tutorial: production-shaped SaaS app
+
+This walkthrough explains the canonical v1 tutorial path in `examples/saas`.
+It is intentionally provider-light: the app proves auth/session shape,
+CSRF-safe mutation, local persistence, generated swaps, page/push shape, plain
+CSS, and one explicit island without moving SQL, auth, flags, deploy, or
+observability providers into hemx core. req: examples/001 req: laws/002
+
+Run it:
+
+```sh
+cargo run -p hemx-saas-example
+cargo test -p hemx-saas-example
+```
+
+## What you are building
+
+The tutorial app is a small project dashboard:
+
+- a full page shell rendered by Rust and hemplate
+- a `Dashboard` template with a project creation form
+- typed domain inputs: `CsrfToken`, `ProjectName`, and `ProjectId`
+- an app-owned `LocalProjectStore` persistence adapter
+- an auth/session-shaped `AppContext`
+- a CSRF-checked mutation handler
+- generated form, summary, flash, keyed row, page-panel, and live-status effects
+- an SSE/polling-shaped live status endpoint
+- plain CSS and one explicit metrics island script
+
+The important point is not the project domain; it is the boundary: templates
+declare the UI surface, Rust owns domain state, handlers return generated UI
+commands, and the browser runtime only applies checked effects. req: canonical_authoring/001 req: modes/001
+
+## Files to read first
+
+- `examples/saas/templates/dashboard.heml` — the UI contract
+- `examples/saas/src/lib.rs` — domain types, app context, handlers, and tests
+- `examples/saas/src/main.rs` — Axum route wiring and runtime/static assets
+- `examples/saas/templates/app.css` — plain CSS
+- `examples/saas/templates/metrics.js` — explicit leaf-island JavaScript
+- `examples/saas/README.md` — scope and provider boundaries
+
+## 1. Declare the surface in hemplate
+
+The dashboard template names only facts that hemx can check and generate:
+
+```heml
+
+
+
+ {+ self.flash +}
+ {+ self.summary +}
+
+
+
+```
+
+There are no selectors, numeric ids, raw targets, or runtime opcodes in the
+template. The `h-key` gives the keyed row target enough information for generated
+append/replace/remove helpers. `{+ row +}` renders the child hemplate partial;
+`{+= html =+}` is only for already-trusted HTML. req: canonical_authoring/002 req: list/001
+
+## 2. Keep domain types ordinary
+
+The form type is Rust domain code, not a generated DTO:
+
+```rust
+#[derive(Clone, Debug)]
+#[hemx::form("new_project")]
+pub struct NewProject {
+ csrf: CsrfToken,
+ name: ProjectName,
+}
+```
+
+`ProjectName` trims submitted input via `FromStr`; `CsrfToken` is a typed value;
+`ProjectId` implements `Display` for stable keyed row ids. The generated form
+contract checks that the Rust shape matches the HTML controls. req: form/001 req: codegen/004
+
+## 3. Put platform boundaries in app state
+
+`AppContext` carries the authenticated session and persistence adapter:
+
+```rust
+#[derive(Clone)]
+pub struct AppContext {
+ session: Session,
+ store: LocalProjectStore,
+}
+```
+
+The local store is deliberately small and testable. Production providers are
+recipes, not core dependencies:
+
+- SQLx: `docs/recipes/sqlx-persistence.md`
+- auth/session and CSRF middleware: `docs/recipes/auth-session-csrf.md`
+- observability, feature flags, and killswitches:
+ `docs/recipes/observability-flags.md`
+- deploy/runtime compatibility: `docs/recipes/deploy-versioning.md`
+
+This keeps hemx focused on the UI contract while the app owns platform choices.
+req: auth/001 req: laws/004
+
+## 4. Write one boring handler
+
+The create handler checks session/CSRF, validates input, persists a record, and
+returns generated UI commands:
+
+```rust
+#[hemx::handler]
+async fn create_project(
+ State(ctx): State,
+ Form(form): Form,
+) -> Result {
+ if form.csrf != ctx.session.csrf {
+ return Err(AppError::CsrfRejected);
+ }
+ if form.name.as_str().is_empty() {
+ return Err(AppError::Validation("Project name required"));
+ }
+
+ let project = ctx.store.insert(form.name, &ctx.session)?;
+ let total = ctx.projects().len();
+
+ Ok((
+ dashboard::project_row.append(ProjectRow::from(project)),
+ dashboard::summary.set(project_summary(total)),
+ dashboard::new_project.clear(),
+ dashboard::flash.set("Project created"),
+ dashboard::live_status.set(format!("{total} projects persisted locally")),
+ ))
+}
+```
+
+The handler does not choose targets with CSS selectors, construct raw effects,
+parse raw forms, or call the runtime. It returns intent through generated helpers
+and tuple composition. req: canonical_authoring/003 req: dx/007
+
+## 5. Map failures explicitly
+
+Expected validation and platform failures cross one app error boundary:
+
+```rust
+impl IntoHandlerFailure for AppError {
+ fn into_handler_failure(self, context: HandlerErrorContext) -> HandlerFailure {
+ match self {
+ AppError::Validation(message) => HandlerFailure::effects(
+ (
+ dashboard::new_project.error("name", message),
+ dashboard::new_project.focus("name"),
+ ),
+ context,
+ ),
+ other => HandlerFailure::effects(dashboard::flash.set(other.message()), context),
+ }
+ }
+}
+```
+
+That keeps user mistakes visible in the generated form error target and keeps
+infrastructure failures out of the normal success path. req: failure/004
+
+## 6. Add page and push shape without a frontend app
+
+The settings handler swaps a generated page panel and pushes history:
+
+```rust
+(
+ dashboard::page_panel.put(&SettingsPage { message: "..." }),
+ dashboard::nav.set("Settings"),
+ hemx::push("/settings"),
+)
+```
+
+The live-status endpoint sends generated effect batches over SSE/polling-shaped
+transport. Routing, auth, and connection policy stay in Axum/app code; hemx does
+not become a router or transport framework. req: page_swap/002 req: push/003
+
+## 7. Keep CSS and islands explicit
+
+Appearance is plain CSS in `templates/app.css`. The metrics widget is an opaque
+leaf island declared with `data-hemx-island="metrics"` and implemented by
+`templates/metrics.js`. The island may inspect its own leaf DOM; ordinary forms,
+lists, page swaps, and live status do not require handwritten JavaScript. req: canonical_authoring/007 req: dx/008
+
+## 8. Test at the product boundary
+
+`cargo test -p hemx-saas-example` proves the tutorial shape:
+
+- the page contains the root, generated form, CSRF field, SSE marker, island,
+ CSS, and island asset
+- stale CSRF does not mutate the store and maps to generated UI
+- validation maps to a generated form error
+- valid mutation persists locally and returns generated keyed row, summary, form,
+ and live-status effects
+- page swap and push shape use generated targets
+
+These tests are intentionally app-level. They prove behavior without browser
+provider setup or external database side effects. req: test/001 req: examples/001
+
+## 9. Productionize by swapping adapters, not changing hemx
+
+To move from the local tutorial skeleton to production:
+
+1. Replace `LocalProjectStore` with a SQLx adapter from
+ `docs/recipes/sqlx-persistence.md`.
+2. Replace the demo `Session` with an Axum/Tower extractor and CSRF service from
+ `docs/recipes/auth-session-csrf.md`.
+3. Wrap routes/handlers with app-owned metrics, flags, and killswitches from
+ `docs/recipes/observability-flags.md`.
+4. Deploy server, generated output, and `/hemx.js` as one release unit following
+ `docs/recipes/deploy-versioning.md`.
+5. Follow `docs/versioning.md` for semver and upgrade notes.
+6. Use `docs/diagnostics.md` when a template/build/derive/runtime mistake fails
+ the app.
+
+The handler and template model should stay recognizable throughout those swaps.
+If productionizing requires raw ids, selector retargeting, manual registries, or
+client app state, treat that as a design smell and either add a named advanced
+escape hatch or keep the provider integration outside the beginner path. req: public_api/005 req: runtime/003
diff --git a/examples/saas/README.md b/examples/saas/README.md
index f48dfc4..fff6200 100644
--- a/examples/saas/README.md
+++ b/examples/saas/README.md
@@ -1,6 +1,6 @@
# hemx SaaS tutorial skeleton
-This is the compile-tested skeleton for the v1 production-shaped tutorial app. It is intentionally provider-light: auth/session, CSRF, persistence, deploy, metrics, and islands are explicit app boundaries, not hemx core services. req: examples/001 req: auth/001
+This is the compile-tested skeleton for the v1 production-shaped tutorial app. It is intentionally provider-light: auth/session, CSRF, persistence, deploy, metrics, and islands are explicit app boundaries, not hemx core services. Read the walkthrough in `../../docs/tutorial-saas.md`. req: examples/001 req: auth/001
What it proves today: