diff --git a/docs/presentations/cfug-2026-09-15/deck.md b/docs/presentations/cfug-2026-09-15/deck.md index 5b13bf701..7e4d7f056 100644 --- a/docs/presentations/cfug-2026-09-15/deck.md +++ b/docs/presentations/cfug-2026-09-15/deck.md @@ -7,8 +7,10 @@ This is the speaker outline for the **25-slide PowerPoint** Slide numbers below match that file. It replaces the earlier 23-slide, five-part outline, which described a different demo. -The executable sequence is [`demo.md`](demo.md): eight live beats on slides -13–20, approximately 50 minutes, after roughly 25 minutes of context. +The current executable sequence is [`runbook.md`](runbook.md): eight live beats +on slides 13–20, approximately 50 minutes, after roughly 25 minutes of context. +Use its separately copyable commands, unique app name and explicit port; +[`demo.md`](demo.md) preserves earlier rehearsal history and optional follow-ups. The PowerPoint still needs the edits in [`slide-corrections.md`](slide-corrections.md); these Markdown changes do not rewrite its slide text or speaker notes. @@ -235,12 +237,14 @@ wheels generate policy Post **bcrypt → `passwordHash`**, using `bcryptHash`, `bcryptVerify`, `bcryptNeedsRehash`. Register, logout, wrong login, correct login. -> Notes: Add the layout navigation/logout button described in demo.md; -> auth generation does not add it for you. Password minimum is 12 -> characters. Show the stored bcrypt shape, not a real user's password. -> Edit policy show, add `authorize(post)` after the finder, reload: logged -> out 403, logged in 200. Remove that temporary authorization before the -> unauthenticated CRUD tests. Keep the comments eager load. +> Notes: Follow runbook.md's layout edit and five-step policy arc: unused +> deny-all policy; one-action gate; all-action filter; readers read/members +> write/admins delete; login proves member edits but denies deletion. No role +> column/admin promotion is staged. A missing record's 404 versus an existing +> denied record's 403 reveals existence; it is not anti-enumeration. Remove +> ONLY the demo authorizePost filter/helper before unauthenticated CRUD specs; +> retain requireRecord, Comments eager loading, auth and CSRF. Password minimum +> is 12 characters; show the bcrypt shape, not a real user's password. ## Slide 18 — Beat 6: red, green @@ -279,30 +283,37 @@ curl -i http://localhost:8080/api/products > `/api` is a namespace, not versioning. Verify diff/coverage and the suite > after API generation during rehearsal; don't claim success from old notes. -## Slide 20 — Beat 8: Wheels and AI coding agents +## Slide 20 — Beat 8: Wheels and Harness add Tags to Posts ```bash -wheels mcp wheels +wheels setup agents ``` -```json -{"mcpServers":{"wheels":{"command":"wheels","args":["mcp","wheels"]}}} -``` - -The client launches the stdio server in the app. Generated AI docs and the -error page's Copy button provide context. - -> Notes: Show actual tools/list, not a frozen count. On installed build -> 2482, MCP generate ignored attributes and wrote an empty Tag: inspect the -> model/migration before proceeding, or use positional CLI generation. -> The after-auth generated seed also returned success without durable rows. -> Do not call that baseline a completed data loop. Positional generation + -> convention Tag seeding was verified. The separately patched CLI also -> passed real reordered-key MCP generation, migration, durable convention -> seeding, HTTP content and 30 app specs (1359 strict CLI specs). That is -> local-patch proof, not a globally upgraded Homebrew installation. Details -> are in demo.md. Check committed rows, not just HTTP 200 or passing specs. -> The protocol server is not a REPL. +1. Write/merge `.mcp.json` and `.opencode.json` in the **demo app**. +2. Open Harness there; configure/import the Wheels server if needed, reconnect/ + reload tools and verify actual discovery against this app's routes. +3. Paste [harness-tags-prompt.md](harness-tags-prompt.md): **implement**, not plan. +4. Show **Post↔Tags**, not disconnected Tags CRUD: multi-select, edit, clear all, + re-add, validation preservation, saved names on list/show, Tags management. +5. Verify migration round-trips, repeat-safe seeds, app tests and browser behavior. + +> Notes: Setup writes configuration; it does not install Harness or automatically +> enable MCP. The client launches `wheels mcp wheels`; the stdio process is not a +> REPL. Inspect the real advertised tools/schemas instead of promising a count. +> Disclose CLI fallback if MCP is unavailable. Preserve Comments, auth, CSRF, +> styling and the current public Post CRUD after the temporary policy gate was +> removed; no vendor changes or unrequested admin role. On 2026-09-15 build 2500, +> the exact prompt passed **36 → 97 specs**, repeat seeds and independent browser/ +> database review via CLI fallback. This is Lucee/SQLite, with a SQLite-specific +> join migration—not cross-database or native Harness GUI MCP proof. Invalid CSRF +> blocks mutation but returns existing development-mode 500, not 403. Report the +> current result rather than promising a fixed future count or the old Tag's 46. +> One later Post GET returned an unexplained missing-WO 500; three test/HTTP replay +> cycles passed but did not establish a fix. Disclose the runbook's stability caveat. +> Rehearse implementation beforehand if it exceeds five minutes, and disclose a +> prepared result. Current commands/evidence are in runbook.md; old build-2482/ +> 2488 and local-patch history remains in demo.md. A green scaffold suite or HTTP +> 200 alone is not proof of persisted assignment. --- diff --git a/docs/presentations/cfug-2026-09-15/demo.md b/docs/presentations/cfug-2026-09-15/demo.md index 02ddf1417..acb3faf58 100644 --- a/docs/presentations/cfug-2026-09-15/demo.md +++ b/docs/presentations/cfug-2026-09-15/demo.md @@ -1,4 +1,11 @@ -# Demo runbook — Rails-Style CFML +# Demo notes and rehearsal history — Rails-Style CFML + +**Present from [runbook.md](runbook.md):** it is the current numbered, independently +copyable command sequence, with safe unique-name/explicit-port preflight and the +current validation record. This file preserves the earlier detailed rehearsal +history and optional follow-ups; its older grouped commands, fixed app name/port +and build-specific counts are not the current stage script. Beat 8 below points +to the updated Harness Post↔Tags implementation, not the old Tags-only scaffold. Build a blog live with Wheels 4.1. This script follows the **eight demo beats on slides 13–20** of @@ -69,9 +76,10 @@ audit found two independent problems: prove persistence. The separately successful explicit Product API requests and pre-auth -Post/Comment seed checks remain valid. Use positional CLI generation and -convention seeding for the fallback below, or verify corrected local code; -**do not imply that these later corrections are in the installed build**. +Post/Comment seed checks remain valid. The historical fallback used positional +CLI generation and convention seeding, followed by separately corrected local +code; **those results do not change the failed installed-build baseline**. +The current runbook validates the current installation independently. **Independent convention-seed rehearsal:** in a fresh app, the Post-only seed produced **2 created / 0 skipped**, then **0 / 2**. The two parent IDs @@ -83,7 +91,7 @@ was also checked on the earlier prepopulated app. **Separate local correction/fallback evidence:** rebuilding the empty Tag with positional CLI attributes gave the intended name/slug schema. The -convention Tag seed below committed `CFUG Demo` / `cfug-demo`, repeated +historical convention Tag seed committed `CFUG Demo` / `cfug-demo`, repeated without adding a Tag, and displayed it in the browser. The correctly shaped app then passed **46/46**, rather than the empty-Tag run's 44. Applying the three local Seeder corrections separately produced durable @@ -105,15 +113,14 @@ held **2 Posts, 2 Comments, 1 Tag**; MCP `test` passed **30/30** without changing those live counts. This verifies the locally patched CLI, **not a global Homebrew upgrade or the original installed MCP implementation**. -> **Reading a convention-seed count.** `created + skipped` always equals the -> number of `seedOnce` blocks in `app/db/seeds.cfm`; each block lands in -> `created` or `skipped` depending on whether its row already exists. The -> `1/4` → `0/5` above is a **five-block file** — the four prepared -> Post/Comment blocks from `seeds-with-comments.cfm` (all already present by -> then) plus the Tag block. Following the main track instead, `app/db/seeds.cfm` -> holds **only** the Tag block, so the same two runs report **`1 created / 0 -> skipped`** then **`0 / 1`**. Both are correct. A repeat run that reports -> anything other than `0 created / skipped` is the actual bug. +> **Reading the historical convention-seed counts.** In these fixed-block +> files each `seedOnce` invocation landed in `created` or `skipped`. The +> `1/4` → `0/5` above used a **five-block file**: the four prepared Post/Comment +> blocks from `seeds-with-comments.cfm` plus the Tag block. The old standalone +> Tag track used just one block, yielding **1 created / 0 skipped → 0 / 1**. +> Both recorded results are valid. The current Post↔Tags prompt has a different +> seed shape, possibly with loops, so inspect actual invocation and row counts; +> do not apply this historical block-count formula to arbitrary seed code. The optional binding check reproduced the plain-bound Post's missing Comments and the query-to-array error from the old workaround. Explicit @@ -357,7 +364,8 @@ wheels reload Run this as a five-step arc, one edit and one reload per step, checking `/posts`, `/posts/1` and `/posts/new` after each. Stay logged out for steps -1–4. Every status below was measured on a stock app at build 2490. +1–4. The earlier notes recorded these statuses on a stock app at build 2490; +that is historical evidence, separate from the current runbook validation. 1. **Policy exists, nothing enforces it.** All three routes still **200**. The generated policy denies every action, but a policy is only consulted @@ -373,7 +381,8 @@ Run this as a five-step arc, one edit and one reload per step, checking `params.action`, so one filter dispatches to `index()`/`show()`/`new()`. All three **403**. `/posts/99999` stays **404** because the scaffold's `requireRecord` filter is declared first — nonexistent records 404 before - the policy is consulted, so 403-vs-404 cannot be used to probe IDs. + the policy is consulted. Different 403/404 responses DO reveal whether IDs + exist; this preserves missing-record behavior, not anti-enumeration. 4. **Make it a blog.** Grant `index()`/`show()` to everyone, `new`/ `create`/`edit`/`update` to `isLoggedIn()`, and `delete` to `isAdmin()` (two private helpers in the policy; `isAdmin()` checks @@ -381,14 +390,16 @@ Run this as a five-step arc, one edit and one reload per step, checking `/posts/1` **200** · `/posts/new` **403** · `/posts/1/edit` **403**. 5. **Log in.** `/posts/new` and `/posts/1/edit` **200**; pressing Delete → **403** with `Wheels.NotAuthorized` for the `delete` action and the row - untouched. `role` does not exist yet — say so. Making it real is three - edits, all verified: a migration adding `users.role` (default `member`), - `role: user.role` added to the `login(principal=…)` line in the generated - `Sessions.cfc` and `Registrations.cfc`, and nothing in the policy. Promote - one account with `UPDATE users SET role='admin'`, log in again, and the - same user's Delete succeeds (303, `deletedAt` set). `can("delete", post)` - around the Delete button hides it for guests and members and shows it - for admin — verified 0 / 0 / 1. + untouched. `role` does not exist yet — say so. The current staged recipe + proves member denial, NOT successful admin deletion. A role migration, + safe promotion of one account, role-bearing principals in registration/ + login and re-login are optional implementation work, not executed steps. + + **Historical note only:** the prior build-2490 notes recorded a separate + role-enabled experiment: admin delete redirected 303 with `deletedAt` set; + conditional Delete-button visibility was 0 / 0 / 1 for guest/member/admin. + No complete repeatable admin recipe accompanies that evidence here, so it + must not be presented as part of the current end-to-end validation. **Say:** “Default-deny at the policy, opt-in at the controller. One line gates one action; one filter gates them all; then three tiers in one file. The generated login code @@ -475,95 +486,118 @@ these passed with 200, 422, 404 and 204 respectively. Name, don't implement: middleware, SSE, jobs, local/S3 storage, multi-tenancy and deploy. This is the first beat to shorten if the evening runs long. -## Beat 8 — Wheels and AI coding agents (slide 20, ≈5 min) +## Beat 8 — Wheels and Harness add Tags to Posts (slide 20, ≈5 min presentation) + +Use [runbook.md, Beat 8](runbook.md#beat-8--wheels-and-harness-add-tags-to-posts-5-min-presentation) +for the stage sequence and current results. Full implementation and verification +may exceed the five-minute presentation budget; rehearse first and disclose any +prepared fallback. The target is **Tags assigned to existing Posts**, not a +separate Tags-only scaffold. -Show the stdio MCP entry point: +### 1. Generate the client configuration in the demo app ```bash -wheels mcp wheels +wheels setup agents ``` -This starts a protocol server, not an interactive REPL; let the MCP client -launch it, or stop it before returning to normal terminal commands. The -module name is required. Show `.mcp.json` for the **demo app**, not the -framework repository: +This writes/merges `.mcp.json` and `.opencode.json`, preserving other server +entries and failing closed on malformed JSON. It does **not** install Harness or +automatically enable its tools. Inspect the app's generated Wheels entry: ```json {"mcpServers":{"wheels":{"command":"wheels","args":["mcp","wheels"]}}} ``` -Verify `initialize` and `tools/list` with the configured client. The -rehearsal returned **19 tools**; show the actual list rather than assuming -that count on another build. Optional agent loop: generate a Tag with -`name:string{30} slug:string`, **inspect the generated fields**, migrate, -reload, create valid sample data, and visit `/tags`. +### 2. Reconnect and verify, rather than assuming -**On installed build 2482, use positional CLI generation for this step:** +Open Harness in the **demo app root**. Configure/import the Wheels server with +the client's supported mechanism if it was not discovered: command `wheels`, +arguments `mcp`, `wheels`. Reconnect/reload tools (or restart the assistant), inspect +the actual advertised schemas/list, and invoke the advertised routes tool against +this app. Do not promise a fixed tool count or invent prefixed tool names. +The client launches the stdio process; it is not a terminal REPL. If only terminal +tools are available, disclose a **CLI fallback**, not a successful MCP connection. -```bash -wheels generate scaffold Tag 'name:string{30}' slug:string -``` +### 3. Ask Harness to implement the feature -The baseline MCP call accepted `attributes` but silently generated an -empty Tag. Before migrating, inspect `app/models/Tag.cfc` for name/slug -presence and the name maximum of 30, and inspect its migration for both -columns. With a corrected MCP implementation, repeat the same inspection -and include a request with reordered JSON keys. A successful response is -not evidence that the requested fields were generated. If the empty Tag -migration was already applied, use the prepared fallback app or reconcile -that disposable migration deliberately; don't stack a second create-table -migration or assume `--force` replaces an already-applied schema. - -**Do not use the installed build's after-auth generated seed on stage.** -It reported **30 created, 2 skipped**, but committed no new Tag/Product -rows. Use the explicit convention alternative below. Local Seeder fixes -were verified separately with durable counts, but must be present in the -actual app before demonstrating the repaired all-model generated path. - -After migrating the **correct name/slug schema**, add this block to -`app/db/seeds.cfm` (create the file if absent; preserve existing blocks). -This fallback was verified after positional CLI generation and through the -separately patched stdio MCP client. It is not a claim that the original -installed MCP generated-data loop passed: +Paste the complete single block from [harness-tags-prompt.md](harness-tags-prompt.md). +The same prompt is embedded in the primary runbook. It requires: -```cfm - -seedOnce(modelName="Tag", uniqueProperties="slug", properties={ - name: "CFUG Demo", - slug: "cfug-demo" -}); - -``` +- Tag name max 30 and unique slug; PostTag with persisted FKs and unique pair; + verified Wheels many-to-many APIs and atomic synchronization. +- Existing Post new/edit multi-selection, clear-all/re-add, error preservation, + and readable Tags on Post list/show. Tags navigation and compact management. +- Comments, bcrypt/session auth, CSRF and styling preserved. No vendor edits; + no re-enabling the temporary policy filter removed before Beat 6. +- Reversible migration round-trips before seeding; repeat-safe convention seeds + resolving actual Post/Tag IDs and preserving unrelated data. +- Tests of both association directions, duplicates, invalid inputs, create/edit/ + clear, transaction rollback, repeat seeding and actual permission behavior; + browser verification of the real UI. Implement, don't just plan; no commit/push. + +### 4. Verify the persisted result + +Review the agent's migration round-trip evidence, then independently repeat the +completed convention seed: ```bash wheels seed --mode=convention -wheels seed --mode=convention -wheels console ``` -In the new console request, check the specific persisted row: - -```cfm -model("Tag").findOne(where="slug = 'cfug-demo'") +```bash +wheels seed --mode=convention ``` -Type `/exit`, then load `/tags` and confirm **CFUG Demo** is visible. Check -that the second seed added zero Tags. If using the MCP client to run the -seed, select its convention mode explicitly. This avoids autogenerated -User/Product records; do not switch back to all-model generated seeding -until its persistence is independently verified. - -> **Expected counts on the main track:** the first run reports **`1 created / -> 0 skipped`** and the repeat **`0 / 1`** — one `seedOnce` block in the file, -> so the two numbers always sum to one. (The recorded `1/4` → `0/5` in the -> rehearsal evidence came from a five-block `seeds.cfm`, i.e. the prepared -> Post/Comment fallback plus Tag. See the note above.) +```bash +wheels reload +``` -**Say:** “The agent can ask the running project, not just its memory of a -framework. The generated AI docs and the error page's Copy button give it -context. Tests remain the check on what it changes.” +```bash +wheels test +``` -Return to slides 21–25. Recap only what actually ran; acknowledge any cuts. +Inspect committed records in another request. The second seed must add no +Post/Tag/join duplicates. Final counts depend on the implemented seed and tests; +the pre-feature baseline is **36**, not a promise of a fixed **46** after Tags. + +**Browser:** Tags nav/management → Post with two Tags → edit selection → clear all +→ refresh → re-add → invalid Post preserves fields/selections → list/show display +saved names. Confirm Comments remain usable, auth/logout/CSRF still work, and +permissions match the actual app. A 200 or a green generated scaffold suite is +not proof that assignment persisted. + +**Current validation — 2026-09-15, Homebrew build 2500:** the exact prompt was +implemented through a disclosed CLI fallback, then independently reviewed in the +browser and database. The suite passed **97/97** (36 retained + 61 new); both literal +Beat 8.4 convention-seed repeats returned **0 created / 6 skipped**, after the +initial **6 / 0 → 0 / 6**. Final counts were **24 physical Posts** (one already +soft-deleted), **11 Comments, 1 User, 1 Product, 3 Tags and 6 joins**, unchanged by +repeat seeds/tests. Assignment/edit/clear/re-add, invalid selections and field +preservation, tag management and existing Comments/auth passed. The agent's +**19 browser checks** were followed by separate reviewer checks and screenshot +inspection. Hashes confirmed 10 protected app sources and 1,717 vendor files +unchanged. See the primary runbook for all evidence and limitations: this is +Lucee/SQLite with a SQLite-specific join migration; public CRUD remains public; +invalid CSRF is blocked without mutation but returns development-mode **500**, not +403. The separately verified **19-tool stdio server** is not a native Harness GUI +connection. + +**Stability caveat:** after those functional passes, one extra `/posts/11` request +returned **500** with a missing-`WO` server-log message; the initial response body +was not retained. Three further full-test → fresh-HTTP cycles passed **97/97 each** +and **9/9 HTTP 200**, but the intermittent error was **not reproduced or fixed**. +Its cause remains unknown; do not attribute it conclusively to Tags or the +framework, or call later green runs a resolution. See the runbook's stability note. + +**Historical boundary:** the build-2482 failures and separately patched MCP and +convention-seed successes remain in the rehearsal history above. Those runs +proved at most a standalone Tag scaffold/seed, not this many-to-many feature. +Build 2488's September 13 standalone Tag result (46 specs) is likewise historical; +current full-run evidence lives in the primary runbook's validation table. + +**Say:** “The agent added a feature to the app we already own. Conventions guide +it; persisted data, tests and the browser check its work.” Return to slides 21–25 +and recap only what actually ran. --- diff --git a/docs/presentations/cfug-2026-09-15/harness-tags-prompt.md b/docs/presentations/cfug-2026-09-15/harness-tags-prompt.md new file mode 100644 index 000000000..f798f9db4 --- /dev/null +++ b/docs/presentations/cfug-2026-09-15/harness-tags-prompt.md @@ -0,0 +1,27 @@ +# Harness prompt — Tags belong on Posts + +Paste the entire text block into Harness opened at the **demo app root**, after Beats 1–7 in [runbook.md](runbook.md). This is an implementation request, not a plan-only prompt. Run `wheels setup agents` first, then reconnect/reload the client's tools and verify discovery; writing client config files does not install or automatically enable Harness integration. + +```text +Implement a compact, working many-to-many tagging feature in THIS existing Wheels demo app. Do the implementation and verification now, not just a plan. Do not commit or push unless I ask. + +Context and discovery +- Read this app's CLAUDE.md and AGENTS.md first. Inspect its actual models, migrations/schema, routes, controllers, forms, layout, seeds and tests before editing. This is an APPLICATION, not the Wheels framework repository: do not alter vendor/ or install/upgrade the framework or CLI. +- Beats 1–7 created Post(title:string max 50, body:text, publishedAt:datetime), Comment(body:text, post_id), bcrypt/session User authentication, a PostPolicy, and a Product JSON API. Preserve the title exclusion/presence/length rules, comments and their related UI, auth/session/logout flows, CSRF protection, routes and existing styling. The temporary authorizePost demonstration filter was removed before the 36-spec baseline; the policy file remains but its existence alone does not enforce authorization. Inspect the actual state. Do not re-enable that temporary filter or invent a role column/admin user. +- Discover the connected Wheels MCP server and inspect its advertised tool names and argument schemas. Prefer native advertised MCP tools for supported operations; use the real wheels CLI for operations not advertised or if MCP is unavailable, and state which transport you used. Do not invent tools or assume a fixed tool count. Confirm the server/project URL from this app, not port 8080 or another rehearsal's registration. Read the shipped Wheels guides/API docs and actual implementation when necessary; specifically VERIFY supported hasMany/belongsTo/through/shortcut syntax, transaction behavior, query-versus-object collection shapes, migration/index/FK helpers and form helpers before using them. Do not import Rails APIs by guesswork. + +Persisted feature +- Add Tag with required name (maximum 30) and required unique slug, with model validation AND a database unique index on slug. Keep slug normalization and validation predictable and tested. +- Add a real PostTag join model/table, using the app's actual table and foreign-key naming conventions (stock new apps use underscore reference columns). Persist post/tag foreign keys, enforce referential integrity and a database unique constraint/index on the (post, tag) pair, and validate the association inputs. Wire Post -> Tags and Tag -> Posts through PostTag in both directions using verified Wheels APIs. Define deletion behavior explicitly so joins cannot orphan; preserve existing Post/Comment behavior. Handle Wheels timestamps/soft deletion deliberately so clearing and re-adding a tag works without violating pair uniqueness. +- Do NOT stop at an independent Tags-only CRUD scaffold. Integrate with the EXISTING Post create/update actions and forms: choose multiple existing Tags on new/edit; load the saved selection when editing; persist changes; allow every Tag to be cleared (including the browser's omitted/empty multi-select case); and show no selected tags on the next request. Reject malformed/nonexistent Tag IDs without partial writes. Deduplicate repeated IDs. Save the Post and synchronize its join rows atomically so invalid Post/Tag input cannot leave a changed Post or partial association set. +- Preserve entered Post fields and the submitted valid Tag selections when a form fails validation. Render useful errors. A failed edit must retain the original persisted association set. Escape tag names/slugs in rendered HTML. +- Show readable Tag names on Post index/show, keep Comments visible, and add a Tags navigation link plus compact Tag management (list/new/edit/delete with validations and safe handling of assigned Tags). Match the existing layout/button styling. Keep CSRF on all state-changing HTML forms. Follow whatever authorization is ACTUALLY enforced by this app for equivalent read/write/delete actions; don't weaken it, add unrequested role machinery, or mistake the retained unused PostPolicy for active enforcement. + +Seeds and verification +- Add reversible migrations. Apply the new migrations with latest, then down, then up BEFORE seeding; inspect the persisted schema and indexes/FKs, not merely successful generator output. If multiple migrations are necessary, round-trip each new migration in dependency-safe order before seeding. Never roll back pre-existing demo migrations or delete/reset the app, registry or live/test database. +- Extend app/db/seeds.cfm without discarding existing seed logic. Seed a few useful Tags (including CFUG Demo / cfug-demo) and join records for real Posts. Resolve existing Posts by stable attributes and use actual returned IDs; if a dedicated demo Post is needed, create/reuse it idempotently with valid body/date/title. Do not hard-code Post or Tag IDs, regenerate all models' random data, delete unrelated records, or modify existing User/Product/Comment data. Run convention seeding twice and prove the second run adds no duplicate Tags, Posts or join pairs. Check committed associations in a separate request. +- Add focused WheelsTest BDD tests for both association directions, duplicate slug/pair prevention, Post create/edit/clear, re-add after clearing, repeated selected IDs, malformed/nonexistent IDs, invalid Post/Tag input, preserved selections after validation failure, transaction rollback and repeat-safe seeding. Cover unauthenticated/unauthorized behavior consistently with the app's actual enforced policy; if equivalent Post CRUD is currently public, document and test that rather than claiming readers/members/admins are enforced. If testing requires changing fixtures for the new valid relationships, preserve existing coverage rather than deleting or weakening assertions. +- Run the full app suite, report actual before/after counts (expected pre-feature baseline: 36 passing; investigate any discrepancy), and inspect real browser behavior. Browser checks must cover Tags navigation/management; creating a Post with two Tags; editing the selection; clearing all; re-adding; invalid form submission retaining fields/selections; persisted names on list/show after refresh; Comments still visible; and auth/logout/CSRF and permission behavior. HTTP 200 or green generated specs alone are not proof of a working tagging UI. If no browser tool is available, explicitly report that verification gap rather than claiming completion. + +Finish with a concise change summary, exact commands/tool calls and test results, migration round-trip and seed-repeat evidence, app URL and browser checks, and any remaining limitations. Keep the feature small enough to explain on stage. +``` diff --git a/docs/presentations/cfug-2026-09-15/runbook.md b/docs/presentations/cfug-2026-09-15/runbook.md index 4b02168a1..d10761e4f 100644 --- a/docs/presentations/cfug-2026-09-15/runbook.md +++ b/docs/presentations/cfug-2026-09-15/runbook.md @@ -1,147 +1,383 @@ # Wheels live demo — presenter's runbook -**For the iPad.** Every command you type, in order, with what you should see and -one line on *why it matters*. Verified end to end on a stock `wheels new` app, -CLI build **2488**, on 2026-09-13. Exact counts below are what that run -produced — if yours differ, say so out loud rather than glossing over it. +**For the iPad and the projected terminal.** Follow the numbered steps in order. +Every Bash block contains **one terminal command**; copy it separately. A command +with continuation lines is still one command. Console expressions have their own +blocks; editor snippets name the file and whether to add or replace. + +**This is your single document for the demo.** Each beat starts with an **Opening +block** you can read or paraphrase: what we're doing, why, and what the audience +should expect. Follow the numbered steps and their **See / Say** cues. Expand an +**If asked** box only when a question comes up; those are explanation notes, **not +extra commands or required live edits**. The Harness prompt is already embedded +in Beat 8, and the fallback, timing and closing notes are included here too. + +**Opening the demo — say:** “We're going from a new folder to a working blog: +Posts, Comments, validation, login and explicit permission rules. Then we'll +prove a test can catch a broken rule, expose the same model style as a JSON API, +and ask Harness to add Tags to the Post UI. At each step we'll check what landed +in the database and what works in the browser—not just whether a command said +success.” + +**End-to-end validation — 2026-09-15:** all eight beats' functional steps passed, +but **one later intermittent HTTP 500 remains unexplained — see the stability +note below**. The run used **Homebrew CLI and packaged framework build 2500**, +without a framework/CLI source overlay, at **http://localhost:8092**. An independent agent implemented the exact +Tags prompt using the disclosed **CLI fallback**; a separate stdio MCP check +verified actual tool discovery. The app suite grew from **36/36 to 97/97**, and +independent browser/database checks confirmed Post↔Tags assignment, edit, clear, +validation preservation and repeat-safe seeds. This is a **Lucee/SQLite** result, +not a native Harness GUI auto-connection or cross-database claim. See the full +record and limitations at the end. Build **2488** results remain historical in +[demo.md](demo.md); [deck.md](deck.md) is the speaker outline. Markdown edits do +not update the PPTX. + +> Keep the projected terminal at least 100 columns wide. Use one terminal session +> for this sequence so its directory and exported variables stay set. Java 21, +> Python 3 (for the API ID capture), the chosen Wheels CLI, and a working browser +> should be ready before the audience arrives. Warm downloads in a separate app. + +## 0. Preflight — fresh app, no destructive reset + +### 0.1 Record the installed CLI -> Terminal width matters. Keep the projected terminal at ≥ 100 columns so -> `wheels routes` and the scaffold output don't wrap. +```bash +wheels --version +``` ---- +Use the version actually validated below. Updating the CLI does not update an +existing app's framework. Older builds may not support `wheels setup agents`; +verify that before the talk, not by improvising an upgrade on stage. -## Before you walk in +### 0.2 Choose a new name and explicit URL ```bash -wheels --version +mkdir -p "$HOME/GitHub/_demo" ``` -Expect **4.1.0-snapshot.2488** or newer. Anything older is missing the -button styling and the 404 guard, and the demo will *look* wrong. +```bash +cd "$HOME/GitHub/_demo" +``` ```bash -cd ~/GitHub/_demo -rm -rf blogdemo && rm -rf ~/.wheels/servers/blogdemo +export DEMO_APP="cfugdemo$(date +%Y%m%d%H%M%S)" ``` -Start from nothing. The whole point is that the audience watches the app come -into existence. +```bash +export DEMO_PORT=8092 +``` -Have these two browser tabs open and hidden: `http://localhost:8080` and an -empty tab for `/register` later. +```bash +export DEMO_URL="http://localhost:$DEMO_PORT" +``` ---- +The timestamp gives each rehearsal a fresh valid app name and its own registry +entry. Keep previous apps and databases; do not delete `blogdemo`, clear the +server registry, or remove a live test database. Confirm **8092 is free** before +starting. If occupied, choose a free HTTP port and repeat the last two exports; +do not stop somebody else's process. Wheels may select another shutdown port, +but it does not silently change an occupied HTTP port. + +
+Presenter prep — timing, offline use and a safe fallback + +- The labelled beats total about **55 minutes**, before questions and agent wait + time. The short **Say** lines are the normal path; these expandable notes are + optional. Trim the broad feature tour first. Do not cut the restore-to-green, + seed-persistence or tag-clear checks just to keep the original time estimate. +- Warm the JVM, JDBC driver and shipped docs before the meeting. Local docs + reduce the Wi-Fi dependency, but don't promise that every first install works + without downloads. +- Keep a separately named, rehearsed app and its exact URL ready. A fallback must + be at the required checkpoint: the final app already has Tags and is not an + honest substitute for showing a first scaffold or a pre-Tags 36-test baseline. + If switching, announce the switch and check the app directory and URL before + issuing more commands. Keep a disposable fallback login available off the + projector; don't change a real password to rescue a demonstration. Never reset + an unrelated app or registry entry. +- Run the CLI steps sequentially and wait for each to finish. An earlier + rehearsal produced offline-docs mirror warnings with concurrent CLI calls; + don't introduce that distraction or edit partially written code during seeding. +- Use the recorded CLI **and app** versions. A snapshot number isn't evidence + that a release has been announced. This runbook's verified track is build 2500; + the older 2482/2488 app paths and patch launchers are not this session's setup. + +
## Beat 1 — a running app (≈4 min) +> **Opening block — from an empty folder to a running application** +> +> **Say:** “First, let's remove the setup ceremony. We'll generate an application +> and start its server without hand-wiring a datasource or an Application.cfc.” +> +> **We'll do:** create a fresh app, start it on our chosen port, and inspect its +> welcome page, project layout and local documentation. +> +> **Expect:** a real Wheels app running on Lucee with SQLite, a details panel +> identifying the stack, and Guides/API cards that open locally. First boot may +> take longer while the runtime warms up; that is not a reason to start it twice. + +### 1.1 Create, enter, start + +```bash +wheels new "$DEMO_APP" +``` + +```bash +cd "$DEMO_APP" +``` + ```bash -wheels new blogdemo -cd blogdemo -wheels start +wheels start --port="$DEMO_PORT" ``` -**See:** `Using port 8080 (shutdown 8081)`, then `✅ Server started`. Open -`http://localhost:8080` — the Wheels wordmark, a "The details" panel, and two -cards: **Guides** and **API docs**. Click one; it opens a new tab and works -**offline** — the docs shipped with the app. +**See:** server started at `http://localhost:8092` on the default track. Open that +URL; keep a second browser tab for `/register` later. First JVM boot can take time; +wait rather than stacking start commands. + +### 1.2 Show the starter page and files + +**Browser:** the Wheels wordmark, **The details** panel, **Guides** and **API docs** +cards. Record the app framework and engine versions. Click both docs cards and +check that the shipped documentation opens locally. + +**Editor — inspect, no edit:** `CLAUDE.md`, `AGENTS.md`, `app/`, `config/`, `public/` +and `vendor/wheels/`. + +**Say:** “One command gave us an MVC app, SQLite, tests, and its own documentation. +It also shipped the instructions our coding agent will read in Beat 8.” -**Why it matters.** Zero configuration produced a running MVC app with a -SQLite database, a test suite, and its own documentation. Point at -`CLAUDE.md` and `AGENTS.md` in the tree: the app ships instructions for AI -coding agents too — that's Beat 8. +
+If asked — what's in the app, and do I need this exact stack? -> If port 8080 is taken, `wheels start` picks the next free *shutdown* port -> automatically and tells you. It will never silently move the HTTP port. +**What's MVC here?** The model owns data rules and ORM behavior; the controller +handles the request and chooses a response; the view renders it. Our application +code is in `app/`, routing/settings in `config/`, the web root in `public/`, and +this app's framework copy in `vendor/wheels/`. Generated files are ours to edit. ---- +**Why SQLite?** It removes database-server setup from this demonstration. It is a +real database, not a mock. Wheels has other engine/database adapters, but today's +measured result is Lucee/SQLite; the later join migration is SQLite-specific. +Don't turn a successful demo into a cross-database compatibility claim. + +**Is CommandBox required?** Not for this demonstrated Wheels CLI/Lucee path. +That is not a claim that CommandBox apps cannot use Wheels. + +**What did updating the CLI update?** The installed tool and its templates, not +an already-created app's vendored framework. Point at the starter's actual +version rather than assuming it matches the CLI. `CLAUDE.md` and `AGENTS.md` +provide conventions to coding agents; they do not connect or authorize tools. +An earlier app created inside the framework checkout picked up checkout source; +our separate demo workspace avoids confusing that with the packaged build. + +
## Beat 2 — scaffold, migrate, seed, CRUD (≈8 min) +> **Opening block — turn a model declaration into something usable** +> +> **Say:** “Now we'll describe a Post once and let Wheels write the routine MVC +> plumbing. We'll inspect the plan before it writes, apply the schema, and put +> real content on screen.” +> +> **We'll do:** dry-run and generate the scaffold, round-trip its migration, +> generate sample data, then create, edit and delete through the browser. +> +> **Expect:** a model, controller, views, migration, tests and registered routes; +> ten seeded Posts with dates; visible success messages; and a 404 when we revisit +> the deleted throwaway. Keep Hello, Wheels for the console demonstration later. + +### 2.1 Dry-run first + ```bash wheels generate scaffold Post 'title:string{50}' body:text publishedAt:datetime --dry-run ``` -**See:** a `Would write:` list of **eleven** project-relative paths — the ten -you'd expect plus `config/routes.cfm` — and **no files written**. Check -`ls app/models` — empty. - -**Say:** "Dry run first. Nothing is written, and it names every file it -*would* touch — including `config/routes.cfm`, which the real run reports as -a modification rather than pretending it didn't touch anything." +**See:** a project-relative `Would write:` list, including `config/routes.cfm`, +and no files written (historically eleven paths). Inspect `app/models/` in the +editor; no Post model should exist yet. ```bash wheels routes ``` -**See:** **41 route(s)** — and every one of them is under `/wheels/…`, -plus `/up`, the two wildcards, and `/`. Not one is *yours*. +**See:** the framework's routes, no Post routes. The build-2488 total was **41**; +read the current total rather than promising it. + +
+If asked — where did those files and routes come from? + +**What does the scaffold save us?** It writes the repetitive model/controller/ +view/test/migration files and registers the resource. The dry-run shows its +intended writes, including the route file; it does not preview database rows or +prove the resulting feature works. + +**Why Post, Posts and posts?** Singular model, plural controller and table are +conventions. `Post` is the data type; `Posts.cfc` handles requests for the +collection; `posts` is its table. That common vocabulary replaces routine wiring. +Non-conventional schemas need explicit configuration, not a different language. -**Say:** "Forty-one routes and I haven't written any. That's the framework's -own tooling — docs, tests, migrator, the console endpoint — mounted under -`/wheels`. Remember the number." +**Why so many route rows?** One resources declaration expands to actions and +HTTP verbs, with plain/format variants and both PATCH and PUT for update. The +framework also has its own tooling routes. Filter by the resource to show what +changed; sixteen rows are not sixteen hand-written actions. + +
+ +### 2.2 Generate and round-trip the migration before data ```bash wheels generate scaffold Post 'title:string{50}' body:text publishedAt:datetime +``` + +```bash wheels migrate latest +``` + +```bash +wheels migrate down +``` + +```bash +wheels migrate up +``` + +**See:** posts created, rolled back, then recreated. Only do this round-trip at +this fresh-schema point, before seeding or browser writes. The same rule applies +to each new migration later in this runbook. + +
+If asked — what is a migration, and why go down and up? + +A migration is a versioned schema change with forward and reverse behavior. A +model declaration is not itself a database migration, and creating a migration +file is not the same as applying it. We inspect the schema and the actual data +instead of trusting generated output alone. + +The round-trip proves this newly generated schema can be removed and recreated +**while it has no demo data**. It is not a general rollback recipe for populated +or production tables. Don't repeat it later after adding content. In Beat 8 the +join depends on Tags, so reverse the join before Tags, then restore Tags before +the join; leave the four earlier migrations alone. + +
+ +```bash wheels seed --generate +``` + +```bash wheels reload +``` + +```bash wheels routes --filter=posts ``` -**See:** `Created table posts` · `Seeded: 10 created, 0 skipped` · then a -table of exactly **16 route(s)**, all `posts#…` — index, show, new, edit, -create, update, delete, each in plain and `.[format]` form, with `PATCH` and -`PUT` both mapped to `update`. +**See:** ten seeded Posts with real datetimes; historically **16 Post routes**, +including plain/format variants and PATCH/PUT update. Generated seeding is **not +idempotent**: don't run it again casually. + +
+If asked — generated data versus repeat-safe seeds + +**Generated mode** attempts another batch of sample records across the app's +models. After Comment exists, the next run adds Posts as well as Comments. It is +useful for quickly filling a screen, not for reproducing a fixed dataset or +creating usable accounts with real passwords. + +**Convention mode** runs deliberate application seed logic in `app/db/seeds.cfm`. +In this demo it uses `seedOnce` and stable attributes to create/reuse Tags and +look up real parent IDs. Plain seeding detects convention files when present; +explicit generated mode bypasses them. Both paths need this app's running server. + +**Does seedOnce fix duplicates?** No: it finds an existing matching record or +creates one. It neither cleans up existing duplicates nor guarantees that every +seed script is repeat-safe. Count actual invocations and durable rows—not just +visible source blocks, because a loop may call the helper several times. The +current Tags seed makes six create/skip decisions: three Tags and three joins. + +**Why audit afterward?** Earlier rehearsals exposed false-success summaries and +assumed parent IDs. Those older-build failures explain our checks; don't import +their old patch/reset commands into this build-2500 flow. A successful exit is +not proof of committed rows or valid relationships. Read the data in a fresh +request and look for orphans and duplicate pairs. -**Say:** "Forty-one became fifty-seven. Sixteen routes from one line." +The separately rehearsed small-data fallback used two dated Posts and two +Comments, including a test with parent IDs 41/97. That proves why ID lookup +matters; it is **not a mid-demo switch of seed modes**. Keep this sequence intact. -Open `config/routes.cfm` and point at that line: +
+ +### 2.3 Show the generated declarations + +**Editor — inspect, no edit:** the new resource line in `config/routes.cfm`: ```cfm .resources("posts") ``` -**Why it matters.** The scaffold didn't just write files — it *registered* -the resource. `.resources()` expands to the full REST surface, so the routes -you see are the routes the framework will actually dispatch. `--filter` -keeps the projector honest: the audience sees the sixteen that changed, not -fifty-seven lines to hunt through. - -> If you want the raw total for the "fifty-seven" line, run plain -> `wheels routes` and read the last line. The filter view alone is enough -> for the point. - -Open `app/models/Post.cfc`: +**Editor — inspect, no edit:** these rules inside `app/models/Post.cfc`: ```cfm validatesPresenceOf("title,body,publishedAt"); validatesLengthOf(property="title", maximum=50, allowBlank=true); ``` -**Why it matters.** The `{50}` on `title` became *both* a column limit in the -migration *and* a validation rule in the model. One declaration, two -consequences, kept in sync by the generator. The seed produced ten posts with -real datetimes, not nulls. +**Say:** “The title's 50-character limit became both a database declaration and +a model validation. One resources line became the actual REST route table.” -**Browser:** `/posts` → ten posts. Create one titled **Hello, Wheels** (keep -it — it's used in Beat 4). Edit its body. Create a throwaway, then press its -**Delete** button. +### 2.4 Browser CRUD -**See:** the three actions — **Edit · Delete · ← all posts** — are identical -blue buttons in one flush row. Then type the deleted post's URL back into the -address bar. +1. Open `/posts`; check ten Posts and dates. +2. Create **Hello, Wheels**, with a body and publication date. Edit its body and + keep it for Beat 4. +3. Create a separate throwaway, then press **Delete**. Keep the original seeded + Posts intact for Comment seeding. +4. Revisit the throwaway's URL: expect **404**, not 500. The `deletedAt` soft-delete + marker keeps the row while the app treats it as gone. +5. Show the consistent **Edit · Delete · ← all posts** button row. -**See:** **404**, not an error page. +
+If asked — field limits, timestamps, deletion and buttons -**Why it matters.** Delete is a *soft* delete (`deletedAt`), so the row -survives for audit but the app treats it as gone — including returning a -proper 404 instead of a 500. That guard is in every scaffolded controller. +The quoted `title:string{50}` token reaches the generator literally. It produces +both a column-size declaration and a model rule. **SQLite does not enforce a +VARCHAR length just because it is declared**; the model validation is the +observed limit here. Later, the Tag UI also limits entry, and a tampered oversized +request still has to fail server-side validation. ---- +The usual `timestamps()` helper includes **createdAt, updatedAt and deletedAt**. +Our `publishedAt` is separate content data. Soft deletion sets the marker and +normal finders hide the row; a 404 therefore does not mean the row was physically +removed. Retain Hello, Wheels and delete a separate throwaway. + +A Delete control is a form submission, not a GET navigation link. Its helper +carries the intended method and CSRF token; it is styled beside the links so the +actions look consistent. The `.wheels-actions` wrapper is a div because placing +the helper's form inside a paragraph creates invalid HTML and breaks alignment. + +
## Beat 3 — it wrote files; you own them (≈5 min) -Open `app/models/Post.cfc` and add two lines inside `config()`: +> **Opening block — add a business rule, not more plumbing** +> +> **Say:** “The scaffold is ordinary code, not a black box. Let's change what a +> valid Post means and see whether the existing form knows how to explain it.” +> +> **We'll do:** reject the placeholder title Untitled, declare that Posts have +> Comments, reload, and try both an empty form and a completed-but-invalid form. +> +> **Expect:** presence errors for missing fields and a separate reserved-title +> error for Untitled, without editing the controller or form. The association +> declaration prepares the parent for the child scaffold in the next beat. + +### 3.1 Add the business rule and inverse association + +**Editor — `app/models/Post.cfc`: add these lines inside the existing `config()`. +Keep all generated validations.** ```cfm validatesExclusionOf(property="title", list="Untitled", allowBlank=true); @@ -152,100 +388,254 @@ hasMany(name="comments"); wheels reload ``` -**Browser:** `/posts/new`. Submit empty → **Title can't be empty**, -**Body can't be empty**. Submit with title **Untitled** → **Title is -reserved**. +### 3.2 Show validation -> `allowBlank=true` on the exclusion rule matters. Without it a blank title -> trips *both* the presence rule and the exclusion rule, and the empty form -> shows three errors instead of two. Each validation should complain about -> exactly one thing. +**Browser:** `/posts/new`. Submit blank title/body to show their presence errors; +if the date is blank too, expect its presence error. Submit **Untitled** with the +other fields completed: **Title is reserved**. `allowBlank=true` avoids a second +exclusion error on an already-blank title. -**Why it matters.** The generator gave you a starting point, not a cage. -You add a business rule in one line and the form enforces it — no controller -change, no view change. The `hasMany` is deliberate setup for Beat 4: watch -what the *next* scaffold does with it. +**Say:** “The generator gave us a starting point, not a cage. The existing form +renders our new rule without controller or view changes.” Do not add title +uniqueness: the generated sample titles repeat in the next seed. ---- +
+If asked — why can one model edit change the form's behavior? + +The controller already attempts a save, checks success and renders the form on +failure. The view already renders the model's errors. Adding a model validation +therefore feeds an existing path; it doesn't invent a new form at runtime. +Server-side validation still matters when a client bypasses browser controls. + +`allowBlank=true` on the exclusion lets the presence rule own the missing-value +error. A completed but reserved value is a different mistake and gets its own +message. The original presence and maximum-length rules remain in place. + +Keep associations, validations and callbacks in `config()`. Use all named +arguments when adding options; don't mix positional and named arguments. Reload +so cached model configuration is refreshed, but read unexpected errors rather +than assuming every 500 is a cache problem. + +Don't add a body minimum or title uniqueness as an improvised extra: the former +can invalidate generated fixtures, and the latter changes the sample-seed +contract. Also, declaring `hasMany` alone does **not** choose cascading deletion. + +
## Beat 4 — associations and a REPL (≈5 min) +> **Opening block — connect records and ask the running app questions** +> +> **Say:** “A blog isn't just isolated tables. A Comment belongs to a Post, and a +> Post has many Comments. We'll make that relationship visible in both the UI and +> the ORM.” +> +> **We'll do:** scaffold Comment with belongsTo, migrate and seed it, inspect the +> parent edits, add a Comment using a title-labelled picker, then use the console. +> +> **Expect:** one parent hasMany declaration, a Comments section on the Post, +> valid foreign-key targets, and queries that navigate in both directions. The +> second seed adds another ten Posts as well as ten Comments—it isn't child-only. + +### 4.1 Scaffold the child and round-trip its migration + ```bash wheels generate scaffold Comment body:text --belongsTo=post +``` + +```bash wheels migrate latest +``` + +```bash +wheels migrate down +``` + +```bash +wheels migrate up +``` + +```bash wheels seed --generate +``` + +```bash wheels reload ``` -**See:** two extra lines you didn't ask for — -`modify controller: app/controllers/Posts.cfc` and -`modify view: app/views/posts/show.cfm`. Then -`Seeded: 20 created, 0 skipped`. +**See:** historically **20 created**, ten Comments and another ten Posts. Confirm +actual counts and zero orphan Comments during rehearsal. The scaffold reports +parent controller/view modifications; read any skipped-wiring warnings. -**Say:** "I scaffolded Comment. It reached back and wired the *parent*." +### 4.2 Inspect both sides -Show the audience what changed in `Posts.cfc`: +**Editor — inspect, no edit:** `Comment.cfc` has `belongsTo("post")` and validates +`body,post_id`; the migration has the real `post_id` column. `Post.cfc` still has +exactly one `hasMany`. In `app/controllers/Posts.cfc`, `show()` includes Comments: ```cfm post=model("Post").findByKey(key=params.key, include="comments"); ``` -And in `Post.cfc` — still exactly **one** `hasMany`. It saw yours from Beat 3 -and reused it rather than adding a duplicate. +**Browser:** open a seeded Post with Comments. **Add a comment** offers Post +**titles**, not raw IDs. Select the desired Post explicitly (the flat add link +does not preselect it). Submit blank body, then a valid body. Return to that +Post and verify its related list. Keep the original seed rows for the examples +below; otherwise substitute IDs you actually observed. + +
+If asked — how did a child scaffold change the parent? -**Why it matters.** Both sides of the relationship are wired: `belongsTo` on -the child, eager-load on the parent's show action, a comments block in the -parent's view. And the seeder is association-aware — every one of those ten -comments points at a real post. Zero orphans. +The association flag gives the generator enough information to add the child +foreign-key field, belongsTo declaration and Post picker. For a conventional +parent it can also add/reuse hasMany, eager-load Comments in show, and add the +related view block. It preserves custom code and can skip parent edits it cannot +safely recognize; the output tells you what happened. -**Browser:** `/posts/1` shows its comment. **Add a comment** — the Post -dropdown lists **titles**, not IDs. Submit blank → **Body can't be empty**. -Pick **Post Title 3**, add a body, then visit `/posts/3` to see it. +The stock app uses underscore reference columns such as `post_id`. Model naming +and schema conventions avoid manual FK configuration in this simple case; a +custom schema may need explicit mapping. A plain integer field called postId is +not equivalent to asking the generator to wire a relationship. + +**ORM relationship versus database constraint:** belongsTo/hasMany describe how +models navigate. They are not proof that the database rejects orphan inserts. +The Comment checks establish valid stored relationships in this rehearsal; Beat +8 separately verifies actual join-table foreign keys, unique indexes and enabled +SQLite JDBC enforcement. + +
+ +### 4.3 Console — one complete expression per request ```bash wheels console ``` +**Console expression 1:** + +```cfm +model("Post").findByKey(1).comments() +``` + +**Console expression 2:** + ```cfm model("Comment").findByKey(3).post().title +``` + +**Console expression 3:** + +```cfm model("Post").where("title", "LIKE", "%Wheels%").orderBy("publishedAt", "DESC").get() +``` + +**See:** a related-comment query, **Post Title 3** on the preserved clean seed +track, then **Hello, Wheels**. Each expression is stateless; don't depend on a +variable assigned in a previous request. + +**Console — exit before the next terminal command:** + +```text /exit ``` -**See:** `=> Post Title 3`, then your **Hello, Wheels** row from Beat 2. +**Say:** “The console is in our live app and database. These query-builder +arguments bind values separately from SQL; raw SQL still needs developer care.” + +
+If asked — eager loading, query results, console state and nested routes -**Why it matters.** The console runs *inside the live app* — same models, -same database — not a mock. The second line is the chainable query builder: -`where` / `orderBy` / `get`, injection-safe because the value travels -separately from the SQL. +**Why include Comments in show?** `include="comments"` supplies associated data +for the related view. That generated block consumes an array of objects. A lazy +`post.comments()` call, or a default `findAll()`, returns a query instead. Both +are useful, but their loops and expectations differ—don't paste a query into an +array-oriented view. Explicit `returnAs="objects"` selects that collection shape +when needed; it is not a change to make during this beat. ---- +**Is the console a mock?** No: it uses this running development app and database. +Treat writes as real. Each entered expression is a separate request; a local +variable from one line is not a persistent REPL session. The supplied expressions +are reads, and `/exit` leaves the console—bare `exit` is treated as an expression. + +**Is every finder automatically injection-proof?** These two-/three-argument +query-builder calls separate values from SQL. Raw SQL/where fragments still need +care. Don't promise that any string someone writes becomes safe automatically. + +**Could URLs be nested?** Yes, the router supports nested resource callbacks. +We deliberately use flat routes to keep the demo moving; selecting the Post in +the form establishes the relationship. Don't introduce new route syntax mid-run. + +
## Beat 5 — authentication and authorization (≈13 min) +> **Opening block — distinguish who you are from what you may do** +> +> **Say:** “Authentication answers who you are. Authorization answers whether +> you're allowed to do this. We'll generate login, then build the permission +> story one visible step at a time.” +> +> **We'll do:** register and log in, inspect bcrypt storage, then move from an +> unused deny-by-default policy to one gated action, all gated actions, and a +> readers/members/admins rule set. +> +> **Expect:** an unused policy changes nothing; explicit gates produce 403s; +> readers can read and members can edit, but our member cannot delete. We are +> **not creating an admin account**. At the end we'll remove the demonstration +> gate for the generated tests, so the final demo must not be called secured CRUD. + +### 5.1 Generate session auth and round-trip its migration + ```bash wheels generate auth --strategy=session +``` + +```bash wheels migrate latest ``` -Open `app/models/User.cfc`. The column is **`passwordHash`**; the model -calls **`bcryptHash()`** and **`bcryptVerify()`**. +```bash +wheels migrate down +``` + +```bash +wheels migrate up +``` -**Why it matters — say this one carefully.** Nobody in this room should be -writing their own password hashing. The generator produced bcrypt with a -cost factor, a `passwordConfirmation` virtual field, reset tokens with -expiry, and session handling — the parts people get wrong, done once, done -right. +**Editor — inspect, no edit:** `app/models/User.cfc` stores **passwordHash** and +uses **bcryptHash()/bcryptVerify()**. Show the generated confirmation/reset/session +code; do not project a real password or credential. -The generator does *not* add a logout link to your layout (that's your -design decision). Add this to `app/views/layout.cfm`, inside the -``, **before `#flashMessages()#`, in both branches**: +
+If asked — bcrypt, salts, password reset and production readiness + +This generator stores **passwordHash** and calls the bcrypt helpers. The +plaintext password/confirmation are transient inputs, not stored password +columns. Bcrypt includes its salt and cost in the stored representation; the +rehearsal observed a 60-character `$2a$10$…` hash. It is a password verifier, not +something to decrypt. Don't substitute the older PBKDF2/passwordDigest story: +PasswordHasher is a separate framework service, not this generated model's path. + +We demonstrate registration, login, wrong-password rejection and logout. A +scaffold containing password-reset code is **not** proof that reset email is +wired or delivered; the generated controller has a delivery integration TODO. +Likewise, production still needs appropriate HTTPS, rate limiting, account +policies and secret handling. “Generated securely useful building blocks” is not +“a finished production authentication system with every flow tested.” + +
+ +### 5.2 Add deliberate navigation + +**Editor — `app/views/layout.cfm`: add this complete navigation block inside the +existing ``, immediately before `#flashMessages()#`, in BOTH the +content-only and full-page branches. Preserve everything else.** ```cfm
- #linkTo(route="posts", text="Posts", class="button")# - #linkTo(route="login", text="Log in", class="button")# - #buttonTo(route="logout", method="delete", text="Log out")# + #linkTo(route="posts", text="Posts", class="button")# + #linkTo(route="login", text="Log in", class="button")# + #buttonTo(route="logout", method="delete", text="Log out")#
``` @@ -253,49 +643,63 @@ design decision). Add this to `app/views/layout.cfm`, inside the wheels reload ``` -**Browser** (use a throwaway address; type the password, don't project it): +### 5.3 Register, log out, log in -| step | flash you should see | +| Browser action | Expected flash | |---|---| -| `/register`, 12+ char password + matching confirmation | **Welcome!** | -| press **Log out** | **You have been logged out.** | +| `/register`, disposable email, 12+ character password and matching confirmation | **Welcome!** | +| Press **Log out** | **You have been logged out.** | | `/login`, wrong password | **Invalid email or password.** | -| `/login`, right password | **Welcome back.** | +| `/login`, correct password | **Welcome back.** | + +**Say:** “Logout is a button: a state-changing DELETE with CSRF protection, not +a GET link. The generator does not add navigation to our layout for us.” + +
+If asked — sessions, CSRF and why super.config stays + +Login establishes the principal used by the session strategy; a policy can then +ask about that identity. Session identity, permission checks and CSRF tokens are +three different responsibilities. A CSRF token helps reject a forged browser +mutation; it does not decide whether an authenticated user is allowed to delete. -**Say:** "Log out is a *button*, not a link — it's a DELETE with a CSRF -token. A GET link that logs you out is a security hole the framework won't -let me create by accident." +`super.config()` keeps the base controller's CSRF protection. The generated form +helpers work with that protection and include the token. Removing the inherited +configuration or using a GET logout link would change the security behavior, not +just the styling. -Now authorization — a different question from authentication. This is a -five-step arc; each step is one edit, one reload, one browser check. Stay -**logged out** for steps 1–4; step 5 is where you log in. Keep `/posts`, -`/posts/1`, `/posts/new` and `/posts/1/edit` open in four tabs so you can -refresh them all after each reload. The arc lands on a realistic blog: -**anyone reads, members write, only an admin deletes.** +The later negative CSRF probes blocked mutations, but this development build +rendered **HTTP 500 with InvalidAuthenticityToken**. Don't promise a 403 for that +case, or confuse it with the separate unexplained missing-WO request in the +stability note. Policy denial is the 403 we intentionally demonstrate next. -### Step 1 — a policy that nobody asks +
+ +### 5.4 Policy step 1 — a policy nobody asks + +**Log out again.** Stay logged out for policy steps 1–4. Keep `/posts`, `/posts/1`, +`/posts/new` and `/posts/1/edit` ready; these examples use a preserved seeded Post. +The five-step arc is **readers read, members write, admins delete**. The admin +allow-path is a design extension, not part of the staged recipe. ```bash wheels generate policy Post -wheels reload ``` -Open `app/policies/PostPolicy.cfc`. **Every method returns `false`.** Now -refresh the three tabs. +```bash +wheels reload +``` -**See:** `/posts` **200** · `/posts/1` **200** · `/posts/new` **200**. -Everything still loads. +**Editor — inspect:** `app/policies/PostPolicy.cfc`; every generated method denies. +**Browser:** list/show/new still **200**. -**Say:** "Every method says no — and nothing changed. The policy is -deny-by-default, but a policy is a *question*. Nobody's asked it yet. That's -on purpose: a framework that locked down your whole app the moment you -generated a file would break things you can't see. You say where the gate -goes." +**Say:** “A policy is a question. Nobody has asked it yet. Generating one does not +silently lock down the application.” -### Step 2 — gate one action +### 5.5 Policy step 2 — gate one action -In `app/controllers/Posts.cfc`, add one line to `show()` **after** the -finder: +**Editor — `app/controllers/Posts.cfc`: replace ONLY `show()` with this complete +method. Keep the existing `requireRecord` filter and helper.** ```cfm function show() { @@ -308,17 +712,21 @@ function show() { wheels reload ``` -**See:** `/posts` **200** · `/posts/1` **403** · `/posts/new` **200**. +**Browser:** list **200**, existing show **403**, new **200**. +**Say:** “One line asks the policy for this action. Other actions still never ask.” -**Say:** "One line. Now `show` asks the policy, the policy says no, 403. -The other two never asked, so they're still open. Enforcement is per -action and explicit." +### 5.6 Policy step 3 — gate every action -### Step 3 — gate every action with one filter +**Editor — `app/controllers/Posts.cfc`: replace ONLY `show()` with this complete +method, removing the one-action gate.** -Remove the `authorize(post);` line from `show()`. Then in `config()`, add a -filter, and add the private method it names at the bottom of the -controller: +```cfm +function show() { + post=model("Post").findByKey(key=params.key, include="comments"); +} +``` + +**Editor — same file: replace ONLY `config()` with this complete method.** ```cfm function config() { @@ -326,7 +734,12 @@ function config() { filters(through="requireRecord", only="show,edit,update,delete"); filters(through="authorizePost"); } +``` +**Editor — same file: add this complete helper inside the component, before its +closing brace. Do not replace the existing `requireRecord` helper.** + +```cfm private function authorizePost() { authorize(model("Post")); } @@ -336,25 +749,38 @@ private function authorizePost() { wheels reload ``` -**See:** `/posts` **403** · `/posts/1` **403** · `/posts/new` **403**. +**Browser:** list/show/new all **403**. Missing `/posts/99999` should be **404**: +`requireRecord` runs first. **Be precise:** different 403/404 responses **do reveal +which IDs exist**. This ordering preserves the scaffold's missing-record behavior; +it is not an anti-enumeration defense. An app needing concealed existence must +choose a uniform response policy deliberately. + +**Say:** “One filter asks for every action. It is private so it isn't a routable +action itself.” + +
+If asked — how does one filter choose a policy method? -**Say:** "Now everything's shut. `authorize()` takes the model class, not -just a record, and it reads the current action off the request — so -`/posts` asks `index()`, `/posts/new` asks `new()`. One filter, one line, -every action. The private method is what makes it a filter and not a -routable action." +`authorize(model("Post"))` uses the current action, so index asks index, new asks +new, and so on. Passing a record is useful for rules depending on that record, +such as ownership; the class-level call is enough for our action-wide example. +Filters are private so they cannot become routable actions. Their order is +visible in `config()`, including the missing-record guard before authorization. -> Type `/posts/99999` — it's **404**, not 403. The scaffold's `requireRecord` -> filter is declared first, so a nonexistent record 404s *before* the policy -> is consulted. That's the right order: an attacker can't use 403-vs-404 to -> probe which IDs exist. Point at the two `filters()` lines — order is the -> code. +`authorize()` gates a server action. `can()` lets a view decide whether to show a +control, but hiding a button does not protect the endpoint. `policyScope()` is +for narrowing collections; a permission to show one record does not magically +filter an index query. We name these distinct tools, rather than claiming all +three behaviors have been implemented by one filter. -### Step 4 — now make it a blog +
-Everything is shut. Open it the way a real blog would: **anyone reads, -members write, only an admin deletes.** Replace the seven method bodies in -`PostPolicy.cfc` and add two private helpers at the bottom: +### 5.7 Policy step 4 — readers read, members write, admins delete + +**Editor — `app/policies/PostPolicy.cfc`: replace the seven generated action +methods with the following complete methods; add the two private helpers inside +the same component. Preserve the generated component declaration/inheritance and +any other infrastructure.** ```cfm public boolean function index() { return true; } @@ -380,78 +806,96 @@ private boolean function isAdmin() { wheels reload ``` -**See, logged out:** `/posts` **200** · `/posts/1` **200** · `/posts/new` -**403** · `/posts/1/edit` **403**. - -**Say:** "Readers read. That's a blog. Anyone can see the list and any -post; nobody anonymous can touch the New or Edit forms." +**Browser, logged out:** list/show **200**, new/edit **403**. +**Say:** “Readers read. Anonymous visitors cannot open the writing forms.” -### Step 5 — log in and prove the middle tier +### 5.8 Policy step 5 — prove the member tier -Log in. Refresh the same tabs. +**Browser:** log in. New/edit now **200**. Edit a Post successfully, then attempt +its **Delete**: **403**, with the Post still present. -**See, logged in:** `/posts/new` **200** · `/posts/1/edit` **200**. Open a -post and press **Delete** → **403**, and the post is still there. +**Say:** “A member can write, but this account has no role, so it cannot delete. +The admin condition is explicit, but we have not built admin accounts today.” -**Say:** "A member can write and edit. But delete asks `isAdmin()`, and -this account has no `role` — so the same person who just edited a post is -refused when they try to remove one. Three tiers, one file, and the -controller never changed after Step 3." +**Optional discussion only — NOT executed or claimed validated:** a real admin +allow-path requires a reversible `users.role` migration, safe account promotion, +and the role in the principals created by both registration and login. Re-login +would be needed to refresh that principal. Do not paste a blanket users-table +UPDATE or claim promotion was tested by this runbook. Conditional `can()` checks +could hide denied controls; that is separate from controller enforcement. -**Where does `role` come from?** It doesn't exist yet — and that's the -honest part worth saying aloud. The policy sees `variables.user`, which is -the struct the login code puts in the session: today that's -`{id, email}`. Making `isAdmin()` real is three small edits you can -describe without typing them: +| Call | Intent | +|---|---| +| `authorize(record)` | Gate an action; deny with 403 | +| `can("update", post)` | Ask whether a control should be visible | +| `policyScope(model("Post")).findAll()` | Narrow a list according to a scope policy | -1. **A column.** `wheels generate migration add_role_to_users`, then - `addColumn(table="users", columnType="string", columnName="role", default="member")`. -2. **Put it in the session.** The generated `Sessions.cfc` and - `Registrations.cfc` each have one `login(principal={id: …, email: …})` - line — add `role: user.role`. The framework's own `SessionStrategy` - docs show exactly this shape. -3. **Nothing in the policy.** `isAdmin()` already reads `variables.user.role`. +### 5.9 Before Beat 6 — remove ONLY the demonstration enforcement -Then `UPDATE users SET role='admin'` for one account, log in again, and -Delete works — verified: the same user goes from **403** to a successful -delete. Everything else is a *policy* decision, not a framework one. +**Editor — `app/controllers/Posts.cfc`: replace ONLY `config()` with this complete +method.** -**Why it matters.** Authentication answers *who are you*. Authorization -answers *are you allowed*. Wheels keeps them separate on purpose: policies -are plain CFCs you can unit-test, `authorize()` is explicit — no hidden -magic deciding access — and the filter shows how cheaply you go from -"gate one action" to "gate them all." The three tools map to three intents: +```cfm +function config() { + super.config(); + filters(through="requireRecord", only="show,edit,update,delete"); +} +``` -| call | intent | -|---|---| -| `authorize(record)` | **gate** — throw 403 if denied | -| `can("update", post)` | **ask** — show or hide a button without throwing | -| `policyScope(model("Post")).findAll()` | **narrow** — a list shows only what this user may see | +**Editor — same file: remove ONLY the entire `private function authorizePost()` +helper added in 5.6.** Keep `requireRecord`, CRUD actions, Comments eager loading, +auth/session/CSRF code, and the PostPolicy file. `show()` must remain the ungated +version from 5.6. This restores the scaffold's public Post CRUD for the generated +unauthenticated specs; it is a demonstration boundary, not production hardening. -> `can()` is how you'd finish the blog UI: wrap the Edit and Delete buttons -> in `` / `` so readers -> never see controls they can't use. Same policy, no throw. +```bash +wheels reload +``` -**Before Beat 6 — remove the `authorizePost` filter line and the private -method, and reload.** The scaffold's controller specs aren't logged in; -leaving the filter in makes them fail for a reason that has nothing to do -with the framework. Keep the policy file. +## Beat 6 — red, green (≈8 min) ---- +> **Opening block — prove the safety net can actually fail** +> +> **Say:** “A green test count is reassuring, but let's prove a test notices when +> we break the behavior it is supposed to protect.” +> +> **We'll do:** run the generated suite on the naturally fresh test database, add +> a body-validation example, remove just that rule, then restore it. +> +> **Expect:** 27 passing, then 28; an intentional 27 passed / 1 failed; and 28 +> passing again. The failure is the point of the demonstration. We'll restore the +> rule before moving on, and the development Posts should remain untouched. -## Beat 6 — red, green (≈8 min) +### 6.1 First test run on this app's naturally fresh test database ```bash -rm -f db/test.sqlite wheels test ``` -**See:** **27 passed** on a *fresh* test database. +**Expected historical baseline:** **27 passed**. Never delete the test database +to force a result or hide a failing first run with an off-screen second run. +If a count differs, investigate and record it. The app/test databases are isolated. + +
+If asked — where do the test records come from? + +The generated controller specs create the records they need. A Comment fixture +creates its parent and uses that returned ID; it should not depend on a seeded +development Post 1. The test runner uses this app's separate test application +and datasource. Our verification checked that tests left development row counts +unchanged; no test database was deleted to manufacture a passing first run. -**Say:** "Every scaffold generated its own specs. I haven't written a test -yet and I have 27." +The policy gate was removed as an explicit **demo boundary**, so the generated +unauthenticated CRUD specs match the final app. That is not production advice. +A secured real app should retain its gates and test authenticated, denied and +permitted paths with suitable identities—not turn authorization off for green. -Open `tests/specs/models/PostSpec.cfc` and add inside the `describe`: +
+ +### 6.2 Add a real spec + +**Editor — `tests/specs/models/PostSpec.cfc`: add this complete example inside the +existing `describe` block.** ```cfm it("requires a body", () => { @@ -465,172 +909,650 @@ it("requires a body", () => { wheels test ``` -**See:** **28 passed**. +**Expected:** baseline plus one (**28** historically). + +
+If asked — why test the specific body error? + +A model can be invalid for the wrong reason. We supply a title and date and then +assert both invalidity and an error on **body**, so this example identifies the +behavior we mean to protect. Removing only body presence should break this test; +restoring it should repair that same failure. The failing exit code is expected +in the red phase, not something to suppress. -Now break the rule on purpose. In `Post.cfc`, remove **only `body`** from -`validatesPresenceOf("title,body,publishedAt")` so it reads -`"title,publishedAt"`. +Generated tests are a starting point. The agent later adds relationship, +constraint and rollback tests because a larger green number alone doesn't prove +Tags can be assigned, cleared, or preserved after a failed edit. + +
+ +### 6.3 Break only the body-presence rule + +**Editor — `app/models/Post.cfc`: replace ONLY the existing combined presence-rule +statement with this complete statement. Keep all other rules/associations.** + +```cfm +validatesPresenceOf("title,publishedAt"); +``` ```bash wheels reload +``` + +```bash wheels test ``` -**See:** **27 passed, 1 failed** · `FAIL: requires a body`. +**Expected intentional red:** **27 passed / 1 failed**, `requires a body`. -Put `body` back. +### 6.4 Restore the rule before continuing + +**Editor — same file: replace ONLY that statement with this complete statement.** + +```cfm +validatesPresenceOf("title,body,publishedAt"); +``` ```bash wheels reload -wheels test ``` -**See:** **28 passed**. - -**Why it matters.** The test caught a real regression in the model within -seconds — this is the safety net that makes the "you own the files" promise -from Beat 3 safe. Tests run against an *isolated* test application and -database, so nothing here touched the data you've been clicking through. +```bash +wheels test +``` ---- +**Expected:** **28 passed** again. Do not continue until restored green. +**Say:** “The tests caught a real model regression without touching the Posts we +have been clicking through.” ## Beat 7 — the two-minute tour (≈7 min) +> **Opening block — inspect the app, then give it a JSON interface** +> +> **Say:** “The framework also needs to help us understand the app and expose +> data without HTML. We'll take a quick tooling tour, then build a small API.” +> +> **We'll do:** inspect project info, schema differences and change risk; generate +> Product's API; send an invalid request, then create, read, update and delete a +> real record using the ID returned by the server. +> +> **Expect:** an explained 422 for the missing SKU, 201 for a valid create, 200 for +> successful reads/updates, 204 for deletion, and 404 afterward. The test suite +> should reach 36 passing before the agent adds anything. + +### 7.1 Project tools + ```bash wheels info +``` + +```bash wheels migrate diff +``` + +```bash wheels coverage --top=5 ``` -**See:** `info` names the framework version, models, routes and the running -server. `migrate diff` → **No differences found — models and database are in -sync.** `coverage` ranks files by **CRAP** score — change risk. +**See:** the actual app/server version and model/routes info; historically diff +reported no differences and coverage ranked change risk by CRAP score. Inspect +current output rather than repeating an old result. Coverage runs tests. +Show the browser debug bar's timing, params, queries and complexity panels. + +
+If asked — what do diff, coverage and the debug bar tell us? + +**Info** identifies the actual project, version and server, which helps catch a +wrong-directory or wrong-port demo. **Migration diff** previews differences the +model/schema tooling recognizes. A clean result isn't proof that every intended +business field exists. We are previewing, not writing a migration in this step; +an optional write mode still requires inspection and subsequent application. + +**Coverage/CRAP** combines measured coverage and complexity to highlight code +that may be risky to change. It helps choose where to inspect or add tests; it is +not a security audit, a benchmark or a complete quality grade. The command runs +tests and temporarily instruments code, so give it time to finish before editing +or launching another coverage run. -**Say:** "`migrate diff` compares my *models* to the *actual schema*. If I'd -added a property without a migration, it would write the migration for me." +The **debug bar** exposes request timing, parameters and database queries. Use it +to explain what a click caused, not to imply that a fast sample request proves +production performance. Avoid exposing real credentials or sensitive data when +showing request details. + +
+ +### 7.2 Generate and round-trip the Product API migration ```bash wheels generate api-resource Product name price:decimal sku:string +``` + +```bash wheels migrate latest +``` + +```bash +wheels migrate down +``` + +```bash +wheels migrate up +``` + +```bash wheels reload -export DEMO_URL=http://localhost:8080 ``` +> **Presenter reminder — trust your URL variable.** The generator's informational +> curl example printed localhost:8080 during rehearsal even though this app ran +> on 8092. The copyable commands below use `$DEMO_URL`; do not substitute the +> generic hint or send requests to an older rehearsal app. + +### 7.3 Validation failure first + ```bash -curl -i -X POST "$DEMO_URL/api/products" -H 'Content-Type: application/json' \ +curl -i -X POST "$DEMO_URL/api/products" \ + -H 'Content-Type: application/json' \ -d '{"product":{"name":"Drum","price":149.99}}' ``` -**See:** **422** with `{"ERROR":"Validation failed","ERRORS":[{"property":"sku",...}]}` +**See:** **422**, SKU validation error. Read the body, not only the status. + +### 7.4 Create and capture the actual ID ```bash -curl -i -X POST "$DEMO_URL/api/products" -H 'Content-Type: application/json' \ +curl -sS -D api-product.headers -o api-product.json -w 'HTTP %{http_code}\n' \ + -X POST "$DEMO_URL/api/products" \ + -H 'Content-Type: application/json' \ -d '{"product":{"name":"Drum","price":149.99,"sku":"DRUM-001"}}' ``` -**See:** **201** with the created product. +**See:** **HTTP 201**. The response headers/body are saved in this disposable app. +If it is not 201, inspect the response before continuing; don't reuse a stale ID. + +```bash +python3 -m json.tool api-product.json +``` + +```bash +export PRODUCT_ID="$(python3 -c 'import json; e=json.load(open("api-product.json")); p=next(v for k,v in e.items() if k.lower()=="product"); n=next(v for k,v in p.items() if k.lower()=="id"); assert str(n).isdigit(), "Expected numeric created product ID"; print(n)')" +``` + +### 7.5 Read, update, reject invalid update, delete that ID ```bash curl -i "$DEMO_URL/api/products" +``` + +```bash +curl -i -X PATCH "$DEMO_URL/api/products/$PRODUCT_ID" \ + -H 'Content-Type: application/json' \ + -d '{"product":{"name":"Updated Drum","price":159.99,"sku":"DRUM-001"}}' +``` + +```bash +curl -i -X PATCH "$DEMO_URL/api/products/$PRODUCT_ID" \ + -H 'Content-Type: application/json' \ + -d '{"product":{"sku":""}}' +``` + +```bash curl -i "$DEMO_URL/api/products/99999" -curl -i -X DELETE "$DEMO_URL/api/products/1" ``` -**See:** **200** · **404** · **204**. +```bash +curl -i -X DELETE "$DEMO_URL/api/products/$PRODUCT_ID" +``` + +```bash +curl -i "$DEMO_URL/api/products/$PRODUCT_ID" +``` + +**See:** **200 · 200 · 422 · 404 · 204 · 404**. Never hard-code `/1` for a record +created after repeated requests. `/api` is a namespace, not API versioning. + +
+If asked — the API contract and what it does not promise + +- **201:** a valid create produced a record; read its returned ID. +- **200:** a successful read or update returned a response. +- **422:** the request reached validation but its data was unacceptable. The + missing SKU is intentional—its model rule is the same kind of rule a form uses. +- **204:** deletion succeeded with no response body to parse. +- **404:** no visible record matches the requested key, including after deletion. + +The created record is inside the response's **PRODUCT** wrapper. Our capture +reads that wrapper and its ID case-insensitively; it does not assume an ID at the +JSON root or reuse a guessed `/1`. Inspect a failed response before going on. -**Why it matters.** Same models, same validations, no views — a JSON API in -one command. The 422 carries the *same* error the HTML form would show. The -status codes are right without you thinking about them. +A JSON API uses the same model/rule approach without HTML views. The `/api` +prefix alone provides neither API versioning nor authentication. The generated +API controller is separate from the HTML controller's session/CSRF wiring; a +production API needs a deliberate access/security design. We have not built a +secured or versioned API in this beat. + +
+ +### 7.6 Lock in the pre-agent baseline ```bash wheels test ``` -**See:** **36 passed** — the API resource brought its own specs. +**Expected baseline before Tags: 36 passing**. Record the actual total for the +agent. No generated tests should be removed merely to keep this number. ---- +
+If asked — what else does Wheels cover? (verbal tour, no live edits) -## Beat 8 — Wheels and AI coding agents (≈5 min) +Keep this to a sentence per topic; it is the first material to cut if time is tight. -Show `.mcp.json` (create it if you like): +| Feature | Short explanation | +|---|---| +| Middleware | Cross-cutting request handling, such as CORS, security headers and rate limiting; configure the behavior your app needs. | +| Background jobs | Deferred work with worker/queue processing rather than making the user wait for everything in one request. | +| SSE/channels | Server-to-client updates for live experiences; not a feature we enabled in this blog. | +| Local/S3 storage | A storage abstraction so app code needn't be coupled to one storage backend. | +| Multi-tenancy | Tenant resolution and isolation choices; not automatically enabled by generating a model. | +| Deployment tooling | The CLI includes deployment support; today's local start is not a production deployment. | + +These are broader framework capabilities, **not additional validated features of +this demo**. Don't improvise migrations, workers, credentials or infrastructure +on stage to answer a breadth question. + +
+ +
+If asked — route model binding and slug URLs (explanation only) + +Binding can resolve a route key into `params.post` before an action and reject a +missing record. Binding by a slug is a further option, but **this Post schema +has no slug field**. Neither feature is an extra live change in this runbook. + +Do not paste the older rehearsal's replacement show method into the final Tags +app: the current action supplies both Comments and `selectedTags` for its view. +Binding does not automatically reproduce those view variables or eager-loaded +collections. The Comments block expects objects, whereas default association +readers return queries. Preserve the complete view contract when changing it. + +Also, `processRequest` tests with explicit controller/action call the controller, +not the dispatcher. A bound-model action needs its input supplied as a controller +fixture, plus separate HTTP tests proving route resolution and missing-key +behavior. Verify all affected CRUD paths, not one successful GET. The earlier +binding rehearsal preceded the current Tags feature; it isn't proof for a new +combined implementation. + +
+ +## Beat 8 — Wheels and Harness add Tags to Posts (≈5 min presentation) + +> **Opening block — let the agent extend the app, then check its work** +> +> **Say:** “Now we'll give Harness the project's conventions and tools and ask +> for a real enhancement: multiple Tags on our existing Posts. The finish line +> isn't a generated file; it's a relationship we can edit and see after refresh.” +> +> **We'll do:** run agent setup, verify the available transport, paste the full +> prompt, and review migrations, repeat-safe seeds, tests and browser behavior. +> +> **Expect:** Tags management plus Post selection/edit/clear/re-add, with Comments +> and login still working and failed edits leaving saved data intact. This +> rehearsal reached 97 passing tests; another implementation must justify its own +> count. Disclose CLI fallback or a prepared app instead of pretending it is a +> native MCP connection or a live five-minute build. + +The implementation and full verification can take longer than five minutes. +Rehearse the prompt end to end beforehand; on stage show the live tool connection, +implementation highlights and the working result. If using a rehearsed fallback, +say so. Independent Tags CRUD is **not** this beat's finish line. + +### 8.1 Generate the project client configuration + +```bash +wheels setup agents +``` + +**See:** `.mcp.json` and `.opencode.json` created/updated in **this app's root**. +The command merges the Wheels entry while preserving other configured servers; +malformed JSON fails without silently overwriting it. It does **not** install +Harness, enable an integration automatically, or grant tool permissions. + +**Editor — inspect, do not replace existing configuration:** the Wheels entry +inside `.mcp.json` has this shape: ```json {"mcpServers":{"wheels":{"command":"wheels","args":["mcp","wheels"]}}} ``` -**Say:** "That's the entire configuration. Any MCP-capable assistant — -Claude Code, Cursor, OpenCode — now has **19 tools** that run against *this -project*: generate, migrate, routes, test, seed, doctor…" +### 8.2 Connect Harness to this app and verify discovery + +1. Open/select **this demo app root** in Harness, not the framework repository or + a previous rehearsal. Use the client's supported MCP configuration/loading + mechanism; do not assume it reads either generated file automatically. +2. Reconnect/reload tools or restart the assistant as appropriate for that client. + The client launches `wheels mcp wheels` as a stdio server; do not run that + protocol process as if it were an interactive terminal command. +3. Inspect the actual discovered tool list and schemas. Have Harness invoke the + advertised routes tool against this app and compare it with our Post/API + routes. Names/capabilities/counts vary by version; do not promise “19 tools.” +4. If MCP is unavailable, say **CLI fallback** and use the real Wheels CLI through + Harness's terminal tools. A successful CLI invocation is not an MCP proof. + +
+If asked — what MCP adds, and what setup agents actually does + +MCP gives a capable client a structured way to discover and call the project's +advertised tools. The CLI remains the real underlying development surface; MCP +is not an alternate ORM or a source of authority over the app. + +Agent setup writes/merges two client-configuration shapes. It does not install +Harness, establish a connection by itself or grant permissions. Open the correct +app in the client, inspect tool discovery and make a project-specific call. In +our rehearsal, the stdio probe found 19 tools, but the implementation worker used +the disclosed CLI fallback because native tools were not exposed to it. Those +are distinct facts, not a claim of automatic GUI integration. + +The shipped agent instructions and `/wheels/ai` documentation give useful +context; they don't replace verification. A visible tool name, a successful +response or a green scaffold test is not proof that requested columns, persisted +rows and usable UI exist. That distinction is why this prompt names concrete +acceptance checks. + +
+ +### 8.3 Paste the complete implementation prompt + +Use the single copyable block in [harness-tags-prompt.md](harness-tags-prompt.md). +It is also reproduced below so this iPad card is self-contained. + +```text +Implement a compact, working many-to-many tagging feature in THIS existing Wheels demo app. Do the implementation and verification now, not just a plan. Do not commit or push unless I ask. + +Context and discovery +- Read this app's CLAUDE.md and AGENTS.md first. Inspect its actual models, migrations/schema, routes, controllers, forms, layout, seeds and tests before editing. This is an APPLICATION, not the Wheels framework repository: do not alter vendor/ or install/upgrade the framework or CLI. +- Beats 1–7 created Post(title:string max 50, body:text, publishedAt:datetime), Comment(body:text, post_id), bcrypt/session User authentication, a PostPolicy, and a Product JSON API. Preserve the title exclusion/presence/length rules, comments and their related UI, auth/session/logout flows, CSRF protection, routes and existing styling. The temporary authorizePost demonstration filter was removed before the 36-spec baseline; the policy file remains but its existence alone does not enforce authorization. Inspect the actual state. Do not re-enable that temporary filter or invent a role column/admin user. +- Discover the connected Wheels MCP server and inspect its advertised tool names and argument schemas. Prefer native advertised MCP tools for supported operations; use the real wheels CLI for operations not advertised or if MCP is unavailable, and state which transport you used. Do not invent tools or assume a fixed tool count. Confirm the server/project URL from this app, not port 8080 or another rehearsal's registration. Read the shipped Wheels guides/API docs and actual implementation when necessary; specifically VERIFY supported hasMany/belongsTo/through/shortcut syntax, transaction behavior, query-versus-object collection shapes, migration/index/FK helpers and form helpers before using them. Do not import Rails APIs by guesswork. + +Persisted feature +- Add Tag with required name (maximum 30) and required unique slug, with model validation AND a database unique index on slug. Keep slug normalization and validation predictable and tested. +- Add a real PostTag join model/table, using the app's actual table and foreign-key naming conventions (stock new apps use underscore reference columns). Persist post/tag foreign keys, enforce referential integrity and a database unique constraint/index on the (post, tag) pair, and validate the association inputs. Wire Post -> Tags and Tag -> Posts through PostTag in both directions using verified Wheels APIs. Define deletion behavior explicitly so joins cannot orphan; preserve existing Post/Comment behavior. Handle Wheels timestamps/soft deletion deliberately so clearing and re-adding a tag works without violating pair uniqueness. +- Do NOT stop at an independent Tags-only CRUD scaffold. Integrate with the EXISTING Post create/update actions and forms: choose multiple existing Tags on new/edit; load the saved selection when editing; persist changes; allow every Tag to be cleared (including the browser's omitted/empty multi-select case); and show no selected tags on the next request. Reject malformed/nonexistent Tag IDs without partial writes. Deduplicate repeated IDs. Save the Post and synchronize its join rows atomically so invalid Post/Tag input cannot leave a changed Post or partial association set. +- Preserve entered Post fields and the submitted valid Tag selections when a form fails validation. Render useful errors. A failed edit must retain the original persisted association set. Escape tag names/slugs in rendered HTML. +- Show readable Tag names on Post index/show, keep Comments visible, and add a Tags navigation link plus compact Tag management (list/new/edit/delete with validations and safe handling of assigned Tags). Match the existing layout/button styling. Keep CSRF on all state-changing HTML forms. Follow whatever authorization is ACTUALLY enforced by this app for equivalent read/write/delete actions; don't weaken it, add unrequested role machinery, or mistake the retained unused PostPolicy for active enforcement. + +Seeds and verification +- Add reversible migrations. Apply the new migrations with latest, then down, then up BEFORE seeding; inspect the persisted schema and indexes/FKs, not merely successful generator output. If multiple migrations are necessary, round-trip each new migration in dependency-safe order before seeding. Never roll back pre-existing demo migrations or delete/reset the app, registry or live/test database. +- Extend app/db/seeds.cfm without discarding existing seed logic. Seed a few useful Tags (including CFUG Demo / cfug-demo) and join records for real Posts. Resolve existing Posts by stable attributes and use actual returned IDs; if a dedicated demo Post is needed, create/reuse it idempotently with valid body/date/title. Do not hard-code Post or Tag IDs, regenerate all models' random data, delete unrelated records, or modify existing User/Product/Comment data. Run convention seeding twice and prove the second run adds no duplicate Tags, Posts or join pairs. Check committed associations in a separate request. +- Add focused WheelsTest BDD tests for both association directions, duplicate slug/pair prevention, Post create/edit/clear, re-add after clearing, repeated selected IDs, malformed/nonexistent IDs, invalid Post/Tag input, preserved selections after validation failure, transaction rollback and repeat-safe seeding. Cover unauthenticated/unauthorized behavior consistently with the app's actual enforced policy; if equivalent Post CRUD is currently public, document and test that rather than claiming readers/members/admins are enforced. If testing requires changing fixtures for the new valid relationships, preserve existing coverage rather than deleting or weakening assertions. +- Run the full app suite, report actual before/after counts (expected pre-feature baseline: 36 passing; investigate any discrepancy), and inspect real browser behavior. Browser checks must cover Tags navigation/management; creating a Post with two Tags; editing the selection; clearing all; re-adding; invalid form submission retaining fields/selections; persisted names on list/show after refresh; Comments still visible; and auth/logout/CSRF and permission behavior. HTTP 200 or green generated specs alone are not proof of a working tagging UI. If no browser tool is available, explicitly report that verification gap rather than claiming completion. + +Finish with a concise change summary, exact commands/tool calls and test results, migration round-trip and seed-repeat evidence, app URL and browser checks, and any remaining limitations. Keep the feature small enough to explain on stage. +``` -If your assistant is connected, ask it to scaffold a Tag. Otherwise: +
+If asked — why a join model, unique constraints and an atomic save? -```bash -wheels generate scaffold Tag 'name:string{30}' slug:string -wheels migrate latest -wheels reload -``` +A Post can have several Tags, and a Tag can belong to several Posts: that is +many-to-many. **PostTag** stores the relationships as real rows, not a comma list +of names on the Post. It also gives us a place to enforce valid parent IDs and +one row per Post/Tag pair. -Open `app/models/Tag.cfc`: `validatesPresenceOf("name,slug")` and -`maximum=30`. The attributes arrived intact. +Model validation provides useful form errors. Database foreign keys and unique +indexes protect the stored structure, including callers that bypass model +validation. This SQLite app explicitly enables FK enforcement on its JDBC +connections; a declared FK alone was not accepted as proof. Do not describe the +SQLite-specific join DDL as verified across every database. -Create `app/db/seeds.cfm`: +Saving the Post and replacing its join set must be atomic: either all of that +change survives or none does. The tests forced a **second join** to fail after +other writes had happened, then verified rollback of both the Post and joins. +Rejecting invalid input before writing is useful, but is not that same proof. -```cfm - -seedOnce(modelName="Tag", uniqueProperties="slug", properties={ - name: "CFUG Demo", - slug: "cfug-demo" -}); - -``` +The prompt asks for bounded app changes and preservation of Comments, auth, +CSRF and existing tests. It doesn't ask the agent to redesign the framework, +upgrade dependencies or introduce admin roles. Explain those boundaries while +work runs; if it takes too long, disclose the prepared result rather than +pretending the entire implementation happened instantly. + +
+ +### 8.4 Review the work before declaring it done + +1. Inspect Tag's required name/max-30/unique-slug schema and validation, plus the + real PostTag table, FKs and unique pair constraint. Verify both association + directions from a fresh request. +2. Inspect migration **latest → down → up** evidence before seeds. For multiple + new migrations require a dependency-safe round-trip of every new migration. + Do not roll back the pre-existing Post/Comment/User/Product migrations. +3. Confirm the convention seed resolves actual Post/Tag IDs and adds useful + associations without deleting unrelated data. Re-run the completed seed: ```bash wheels seed --mode=convention -wheels seed --mode=convention ``` -**See:** `1 created, 0 skipped`, then `0 created, 1 skipped`. +```bash +wheels seed --mode=convention +``` -**Say:** "Idempotent. I can run seeds on every deploy and never duplicate a -row." +4. Confirm the second run changes no Post/Tag/join counts and creates no duplicate + pairs. Counts depend on the implemented seed; the old one-Tag **1/0 → 0/1** + and **46-spec** results are historical, not the acceptance target. -**Browser:** `/tags` → **CFUG Demo**. +```bash +wheels reload +``` ```bash wheels test ``` -**See:** **46 passed**. - -**Why it matters.** The generated `CLAUDE.md` and `AGENTS.md` from Beat 1, -the `/wheels/ai` JSON docs, and the MCP server together mean an AI agent -working in this codebase gets *Wheels' conventions*, not its guess at them. -And the tests are the check on whatever it changes. - ---- +5. Record actual final test totals; expect the **36-test baseline plus meaningful + new tests**, not a predetermined count. Read failures and inspect committed + records; green scaffold specs alone do not prove a relationship feature. + +### 8.5 Browser finish line + +1. **Tags** navigation opens usable management; create/edit a Tag, show required, + maximum-length and duplicate-slug errors, and verify safe deletion behavior. +2. Create a Post with **two** existing Tags; refresh list/show and read their names. +3. Edit that Post, see the saved selection, change it, save and refresh. +4. Clear **every** Tag, save and refresh. Re-add a Tag after clearing. +5. Submit an invalid Post while tags are selected: errors appear and fields and + valid selections survive. The previously saved join set must not change. +6. Confirm Comments remain visible and usable; registration/login/logout and + CSRF handling still work. Verify permission behavior matches the actual app: + the temporary Post policy gate was removed in 5.9, not secretly restored. +7. Show the repeat-seeded Post/Tag associations from a new browser request. + +**Say:** “The agent added a feature to our existing app, not another disconnected +scaffold. Wheels supplied conventions and tools; persisted data, tests and the +browser tell us whether the change works.” + +
+If asked — clearing tags, validation failures and deleting relationships + +A browser can omit a multi-select field when nothing is selected. Our form's +contract treats that as **clear all**, not “leave the old tags alone.” On desktop, +use Cmd/Ctrl selection as appropriate to toggle options; then save and refresh. +A persisted empty set and successful re-add matter more than a dropdown that +merely looks empty before submission. + +An invalid edit keeps the user's fields and valid submitted selections visible, +while the database keeps the **old saved Post and join set**. These are different +states, deliberately: useful error recovery must not mean partial persistence. +Repeated IDs are deduplicated; malformed or nonexistent IDs are rejected. + +Tags and join edges in this implementation delete physically. Assigned Tags +refuse deletion; clearing an assignment removes its join, so re-adding cannot +collide with a hidden soft-deleted join. Posts keep their existing soft-delete +behavior and clean up their tag joins. We did not silently change how Comments +are deleted or reactivate the earlier policy gate. + +The original 36 tests are still present. The 61 additional tests made 97, but +**97 is a measured result, not a target to game**. Browser checks, database +constraints, repeat seeds and protected-file hashes are separate evidence. +Keep the later unexplained missing-WO 500 caveat distinct from these functional +passes and from deliberate invalid-CSRF probes. + +
+ +### Closing the demo — one sentence per result + +**Say:** “We started with a new app, generated a real resource, changed its rules, +connected Comments, demonstrated login and explicit policies, broke and repaired +a test, added a JSON API, and had Harness integrate Tags into the Post UI. The +conventions saved typing; the database, tests and browser told us what actually +worked.” + +If a beat was shortened or a prepared app was used, name that honestly. The final +app has public Post/Tag CRUD after the deliberate policy-test boundary; don't +call it production-ready. For follow-up questions, point to **guides.wheels.dev**, +**blog.wheels.dev**, and **github.com/wheels-dev/wheels**. The original title and +release slides are not required to deliver this demo or its explanation. ## If it goes sideways -| symptom | do this | -|---|---| -| `wheels start` says port in use | It already picked the next free shutdown port; read the line it printed. If 8080 itself is taken: `lsof -nP -iTCP:8080` and stop that process. | -| A page 500s after an edit | `wheels reload`. Lucee caches compiled templates. | -| Validation edit "didn't take" | You edited the model but didn't reload. `wheels reload`, refresh. | -| `wheels test` fails after Beat 5 | You left the `authorizePost` filter in. Remove the `filters()` line and the private method, reload. | -| Browser looks unstyled | Hard-refresh (⌘⇧R). | -| A seed count differs from this card | `created + skipped` should equal the number of `seedOnce` blocks. If it does, you're fine — say the real number. | -| Comments don't appear on `/posts/N` | Check `Posts.cfc` `show()` has `include="comments"`. If not, add it and reload. | -| Something you can't diagnose in 20 s | Say "that's one for the Q&A," move to the next beat. Every beat stands alone after Beat 2. | +
+Presenter recovery card — stay in the right app and preserve evidence ---- +**Recorded final rehearsal app:** `$HOME/GitHub/_demo/cfugdemo20260915040245`, +served at `http://localhost:8092` during validation. This is a reference to that +completed app, **not an instruction to replace the new app's exported name**. +Verify it before the meeting; its presence does not reserve a free port or prove +it is still running. If it occupies 8092, choose a different free port for the +fresh build. Use separate prepared terminals so directory and URL cannot drift. -## The one-line takeaways, in order +Useful landmarks in that recorded app—not IDs to assume in a fresh build: + +| Page | What was checked | +|---|---| +| `/posts/11` | Hello, Wheels with its three repeat-seeded Tags | +| `/posts/3` | An existing Post with two Comments; Hello, Wheels had none | +| `/posts/24` and its edit form | Reviewer's Tag assignment/clear/re-add and validation proof | +| `/tags` | Tag management | +| `/login` | Existing session login/logout | + +The final app is already beyond every generation step. Don't rerun the agent +prompt, blindly apply down migrations or claim its 97 tests are the fresh +27-test baseline. Switch to it to show a prepared result, and say that you did. +It also carries the unresolved stability caveat below. + +**Expected failures:** the body test goes red on purpose; missing SKU returns +422; the policy denies with 403; a deleted record returns 404. An unexpected +error is different. Preserve the first response, message, stack and timing before +refreshing it away. Use the error page's **Copy** JSON button when available; +redact credentials and sensitive request details before projecting or sharing. +For missing WO, also note recent reload/test requests and whether the client was +in test context. Neither a reload nor the later passing replay was verified as +a fix for that intermittent error. + +
+ +| Symptom | Check / recovery | +|---|---| +| HTTP port occupied | Choose a free port; update DEMO_PORT and DEMO_URL before start. Never kill an unrelated app or reset the registry. | +| Registration belongs to an older rehearsal | Use the new timestamped app name; keep the older app intact. | +| Page 500s after an edit | Read the error, check the named file and run `wheels reload`; don't treat every 500 as a cache issue. | +| Tests fail after policy demo | Remove ONLY the demo authorizePost filter/helper and any leftover one-action gate; retain requireRecord/auth/CSRF/comments. | +| First test fails | Investigate actual fixtures and test-app isolation. Do not delete the database or hide the failure with a warm-up run. | +| API ID capture fails | Inspect api-product.json and the POST status. Fix the request; do not fall back to `/1`. | +| Agent tools missing | Reconnect in the correct app and inspect discovery; disclose CLI fallback if needed. Setup writes config, not a live connection. | +| Seeds claim success but UI is empty | Check committed rows in a separate request and actual FK targets; command success is not persistence proof. | +| Tag form cannot clear all | Fix omitted/empty selection handling and test refresh plus re-add; do not omit this acceptance check. | +| Can't diagnose promptly on stage | Switch to the separately rehearsed app and disclose the fallback. Later beats depend on earlier schema/data; do not pretend all beats stand alone. | + +## Current end-to-end validation record + +**Observed 2026-09-15**, in a fresh app created by the current Homebrew install. +No framework/CLI source overlay was used. These are current measurements, not +inherited build-2488 counts. The independent agent's Tags implementation was +followed by a separate reviewer repeating the browser acceptance and the exact +Beat 8.4 commands, then checking persisted data and protected-source hashes. +The opening blocks and expandable explanations added afterward are commentary +only: all 87 existing fenced blocks, including the 67 Bash commands and exact +Harness prompt, were preserved. Optional Q&A is not additional executed coverage. + +| Checkpoint | Current result | +|---|---| +| CLI / framework / Java / engine / DB / URL | **PASS:** Homebrew CLI and packaged app framework **build 2500**; running server executable verified as **Homebrew OpenJDK 21.0.12.1**; **Lucee 7.0.0.395**, SQLite; **http://localhost:8092** (shutdown 8093). | +| Unique-name preflight and explicit HTTP port | **PASS:** fresh `cfugdemo20260915040245` in `$HOME/GitHub/_demo`; no reuse of the prior app/registry/test DB. | +| Dry-run, generation and routes | **PASS:** dry-run listed **11 paths**, wrote no Post model (base `Model.cfc` remained); **41** initial routes; **16** filtered Post routes after generation. | +| Post/Comment/auth/Product migrations before data | **PASS:** each new migration completed **latest → down → up** before its seed or browser/API writes. | +| Starter, local docs and debug UI | **PASS:** branded details page; Guides/API cards opened local `/wheels/guides` and `/wheels/api` tabs, **200** with expected titles; timing/params/routes/complexity panels opened. | +| Post CRUD and validation | **PASS:** first seed **10 Posts** with datetimes; Hello, Wheels created/edited and retained; separate throwaway deleted then **404**; blank form showed two presence errors without an exclusion error; Untitled rejected as reserved. | +| Comments and console | **PASS:** second seed **20 real rows**; **21 live Posts / 10 Comments / 0 orphan Comments** before the browser addition. Blank Comment retained selected Post 3; valid Comment appeared on its parent. All three console expressions passed, including **Post Title 3** and the Wheels title search. | +| Auth and five-step policy arc | **PASS:** register/logout/bad login/good login/logout; stored bcrypt **60 characters, `$2a$10$`**. Unused policy: all 200; one-action gate: **200/403/200**; all-action gate: **403**, missing record **404**; guest reads **200**, writes **403**; member new/edit **200**, actual edit persisted, delete **403** with row retained. Admin promotion was **not staged**. | +| Policy/test boundary | **PASS:** removed only the demo authorizePost filter/helper; retained requireRecord, Comments wiring and auth/CSRF behavior. Post CRUD returned to its generated public behavior; the retained policy alone is not enforcement. | +| Naturally fresh test DB; intentional red and restored green | **PASS:** first run **27 passed**; added body spec **28 passed**; removed only body presence **27 passed / 1 failed**, `requires a body`, exit **1**; restored rule **28 passed**. No test-DB deletion or hidden warm-up. | +| Info/diff/coverage | **PASS:** info identified this app/port; diff reported **no differences**; coverage reported **61 counters, 6/47 files** and its suite request returned **200**. | +| Product API, exact runbook commands | **PASS:** **422 → 201 → 200 → 200 → 422 → 404 → 204 → 404** for invalid create/create/list/update/invalid update/missing/delete/deleted GET. Actual ID captured from the **PRODUCT** wrapper; no assumed `/1`. | +| Pre-Tags baseline | **PASS: 36/36**. | +| Copyable-runbook static checks | **PASS, independently checked:** **67 Bash blocks**, one logical command each; Bash syntax valid, no destructive reset commands, and embedded/standalone Harness prompts identical. | +| setup agents creation/repeat | **PASS:** correct `.mcp.json` and `.opencode.json` generated in the app; repeat reported **Already configured**. | +| setup agents safety probes | **PASS in a separate marked fixture:** other servers and unrelated top-level values survived; wrong Wheels entry corrected; repeat left both files byte-stable. Malformed OpenCode JSON caused nonzero failure with **both files byte-unchanged**. Active app configuration was not modified by these probes. | +| Actual stdio MCP discovery | **PASS:** **19 advertised tools**; routes and info identified this app and its Post/Comment/Product routes on **8092**. This does **not** establish Harness GUI automatic config loading. | +| Tags implementation transport | **PASS via disclosed CLI fallback:** the independent agent executed the exact standalone prompt using the real Wheels CLI because native Wheels MCP tools were unavailable in its session. Separate stdio discovery passed; no native Harness GUI connection is claimed. | +| Tags schema and migrations | **PASS:** required name/max 30 and unique slug; persisted PostTag FKs, unique pair and reverse index; both association directions. Two new migrations completed **latest → down join → down Tags → up Tags → up join before seeding**, without rolling back earlier migrations. | +| Actual database constraints | **PASS:** dev/test JDBC `PRAGMA foreign_keys` returned **1**; raw orphan inserts were rejected; slug/pair uniqueness and Tag **RESTRICT** / physical Post **CASCADE** enforced. Final audit found **zero FK violations, orphan joins or duplicate pairs**. | +| Atomic assignment and deletion behavior | **PASS:** create/edit/clear/re-add and invalid IDs; injected failure on a second join rolled back both Post and joins. Invalid browser edits preserved entered fields/valid selections while retaining the exact old persisted Post fields and join identity. Tags/joins delete physically; Post soft-delete cleans its joins without changing existing Comment behavior. | +| Convention seeds | **PASS:** initial seed **6 created / 0 skipped** (3 Tags, 3 joins), immediate repeat **0 / 6**. After both browser passes, the reviewer executed each literal Beat 8.4 convention-seed command: **0 / 6**, then **0 / 6**; counts remained unchanged through seeds, reload and tests. Existing Posts were resolved by attributes, not hard-coded IDs. | +| Final durable row counts | **24 physical Posts (including 1 pre-existing soft-deleted row), 11 Comments, 1 User, 1 Product, 3 Tags, 6 PostTags**. The agent and reviewer each retained one browser-proof Post. Hello, Wheels has three Tags; the reviewer's **Presenter final tagging check** has CFUG Demo. No unrelated records were removed. | +| Tags final app tests | **PASS: 97/97**, repeated by the agent and then independently via the literal Beat 8.4 `wheels test` (**1.25 s**). Existing 36 specs retained; **61 new BDD examples: 38 model/service/DB + 23 controller**. | +| Agent browser verification | **PASS: 19 checks**, covering Tags management, escaped names, Post create/edit/clear/re-add, invalid/malformed/nonexistent selections, assignment preservation, assigned/unassigned deletion and slug reuse, existing Comments, login/logout and CSRF no-mutation checks. | +| Independent reviewer browser verification | **PASS:** navigation; required/max-30/duplicate validation (server length checked with 31 characters after bypassing HTML maxlength); two-Tag Post creation, preselection/replacement/clear/refresh/re-add; invalid Post preserves fields/selections and original DB state; list/show labels; Hello, Wheels seeded Tags; Post 3 Comments; existing login/logout and public guest edit. Screenshots inspected. These are separate checks, not added to the agent's count of 19. | +| Protected-source audit | **PASS:** all **10 protected existing app source files** and **1,717 vendor files** matched their pre-feature hashes. No framework/CLI upgrade, source overlay, commit or push was needed. | +| Later stability check | **UNRESOLVED:** one later `GET /posts/11` returned **500**, with server log at **04:46:32** reporting missing key `WO`; the initial response body was not retained. Immediate subsequent requests returned 200. Three planned full-test → fresh-HTTP replay cycles each passed **97/97**, with **9/9 replay requests returning 200**. The intermittent failure was not reproduced or fixed. | + +### Stability note — one unexplained intermittent HTTP 500 + +After the functional, test and source-hash checks passed, one extra request to +`/posts/11` returned **500**. The server log recorded `key [WO] doesn't exist` at +04:46:32; the original response body was discarded, so the available evidence is +limited. Immediate follow-up requests succeeded. Three further complete test runs +followed by fresh HTTP checks passed **97/97 each** and **9/9 HTTP 200** in total. + +A read-only audit found no direct deletion/reload of `application.wo` in the new +app code/specs, but **the cause remains unknown**. This does not establish whether +the fault belongs to the Tags feature, framework lifecycle, or something else. +No fix or reliable workaround was verified. Keep a rehearsed fallback available; +do not describe the later green runs as proof that the intermittent failure is +resolved. The functional passes above remain valid, with this stability caveat. + +### Scope and limitations of this run + +- **SQLite-specific implementation:** the join migration uses reversible explicit + SQLite DDL; app-only datasource configuration enables `foreign_keys=on` for dev + and test connections. Enforcement was checked through actual JDBC requests. + No portability claim is made for other databases or engines. +- **Tools:** implementation used the real CLI fallback. The independently verified + 19-tool stdio server is not evidence that Harness automatically loaded either + generated config file or exposed native MCP tools to the implementing agent. +- **Authorization:** Post and Tag CRUD are public after the deliberate removal of + the demo policy gate in 5.9. The retained policy is not active enforcement; no + role column/admin promotion or production authorization hardening is claimed. +- **CSRF:** invalid development-mode requests produced **HTTP 500 with + Wheels.InvalidAuthenticityToken**, with **no mutation**. The existing behavior + was preserved; this run does not claim a 403 response for those requests. +- **Implementation was iterative:** a transient seed-source compile error, an + HTML-encoding expectation in one new spec, and a browser assumption about which + existing Post had Comments were corrected before the final passes. Final green + does not mean every intermediate attempt succeeded. The intentional Beat 6 red + result remains a separate, expected demonstration. + +**Historical only:** build 2488's 2026-09-13 run reached 36 specs after the API and +46 after an independent Tag scaffold/convention seed. That did not implement +Post↔Tag assignment and is not evidence for this revised Beat 8. Earlier build +2482 failures and separately patched MCP/seed evidence remain in [demo.md](demo.md). + +## The one-line takeaways 1. **New:** a full app, docs included, from one command. -2. **Scaffold:** one declaration → migration + model + validation, in sync. -3. **Own it:** add a rule in one line; the form enforces it. -4. **Associations:** scaffolding the child wires the parent. Both sides. -5. **Auth:** bcrypt done right, once. **Policy:** deny by default, *you* say where the gate goes — then three tiers in one file: readers read, members write, admins delete. -6. **Tests:** generated with everything; caught a real break in seconds. -7. **API:** same models, same rules, one command. -8. **AI:** conventions the agent can read, tests that check what it did. +2. **Scaffold:** a declaration becomes schema, model, validation and routes. +3. **Own it:** add a business rule; the existing form enforces it. +4. **Associations:** scaffolding the child wires the parent too. +5. **Auth/policy:** bcrypt sessions; explicit gates; readers read, members write, + admins delete by policy design (no admin promotion claimed). +6. **Tests:** a real regression goes red; restoring the rule goes green. +7. **API:** same models and validation, JSON responses with meaningful status codes. +8. **AI:** verified tools plus a precise prompt deliver Tags on real Posts. diff --git a/docs/presentations/cfug-2026-09-15/slide-corrections.md b/docs/presentations/cfug-2026-09-15/slide-corrections.md index 59c566e70..b00e415f9 100644 --- a/docs/presentations/cfug-2026-09-15/slide-corrections.md +++ b/docs/presentations/cfug-2026-09-15/slide-corrections.md @@ -5,10 +5,12 @@ Slide numbers refer to that **25-slide** file, including its speaker notes. The binary has **not** been rewritten by the Markdown corrections. Apply these in PowerPoint, save, then inspect slide show and presenter view. -The aligned references are [`deck.md`](deck.md) and [`demo.md`](demo.md). +The current executable reference is [`runbook.md`](runbook.md), with one terminal +command per copyable Bash block, a unique app name and explicit HTTP port. +[`deck.md`](deck.md) is the outline; [`demo.md`](demo.md) preserves prior evidence. The demo remains eight beats, with Post `title:string{50} body:text publishedAt:datetime`, generated seeds, associations, auth/policy, tests, -API and MCP. The dated convention seeds are an explicit recovery option, +API and Harness-driven Post↔Tags assignment. The dated convention seeds are an explicit recovery option, not a silent replacement for the live generated-seed track. ## Must correct before presenting @@ -56,10 +58,13 @@ not a silent replacement for the live generated-seed track. Add the deliberate layout navigation edit from demo.md, including `buttonTo(route="logout", method="delete", text="Log out")` in both layout branches. The generator does not add the control itself. -- [ ] **Slide 17 — policy/test boundary.** Keep the 403 logged-out / 200 - logged-in demonstration. Preserve `include="comments"` in Posts.show. - Remove temporary `authorize(post)` and reload before unauthenticated - CRUD tests. Generating a policy alone does not enforce it. +- [ ] **Slide 17 — five-step policy/test boundary.** Keep the unused policy → + one-action gate → all-action filter → readers read/members write/admins delete + → member login arc from runbook.md. Admin promotion is optional discussion, + not validated staged work. Different 404/403 codes disclose record existence; + remove the anti-enumeration claim. Before tests remove ONLY the temporary + authorizePost filter/helper and any leftover one-action gate; keep requireRecord, + `include="comments"`, auth and CSRF. An unused policy alone enforces nothing. - [ ] **Slide 18 — remove warm-up workaround.** Delete the instruction to run the first failing suite off-screen. The current generated controller spec persists its parent and uses that ID. Rehearsal on build 2482 passed @@ -75,24 +80,20 @@ not a silent replacement for the live generated-seed track. Remove the blanket instruction not to run the suite after API generation. If another build regresses, report that result rather than hiding it. Info, migrate diff and coverage also passed in this rehearsal. -- [ ] **Slide 20 — current MCP evidence.** Keep `wheels mcp wheels` and - the app-scoped client config. Verify actual tools/list and the intended - project. Build 2482 returned 19 tools, Tag commands returned success, - `/tags` returned 200 and the suite passed 44/44. **The final row-count - audit nevertheless found no durable generated Tag/Product rows after - auth; the Tag page was empty.** Remove any “completed data loop” claim. - MCP generate had also ignored `attributes`: Tag's config was empty, - migration only ID/timestamps. Inspect requested fields before migrating. - Reported 30 created / 2 skipped was not proof of committed data. The - positional CLI + convention Tag fallback now has durable/browser proof - and a correctly shaped 46-spec suite; local Seeder patches have separate - row-count evidence. The separately patched CLI passed 1359 strict CLI - specs plus real stdio reordered-key generation, SQL schema/migration, - durable convention seed, HTTP content and 30 app specs. Its isolated MCP - client used aligned registry/cache paths with the normal app server. - Keep local-patch proof distinct from the failed installed build; there - was no global Homebrew module replacement. The explicit Product API - payload was independently verified. The stdio server is not a REPL. +- [ ] **Slide 20 — setup agents and a real feature.** Show `wheels setup agents` + in the demo app: it merges `.mcp.json` / `.opencode.json`, not a Harness install + or automatic live connection. Configure/import if necessary; reconnect/reload + tools and verify actual schemas/list against the intended project. The client + launches `wheels mcp wheels`; the protocol server is not a REPL. Disclose CLI + fallback if MCP is unavailable. Use [harness-tags-prompt.md](harness-tags-prompt.md) + to implement Tags **on existing Posts**: persisted many-to-many joins, constraints, + atomic multi-selection/edit/clear/re-add, validation preservation, Tags navigation/ + management and readable names. Preserve Comments/auth/CSRF, round-trip migrations + before seeding, resolve real IDs in repeat-safe seeds, test and inspect the browser. + Pre-Tags baseline is 36; final count must come from the current run, not a frozen + 46. Historical build-2482/2488 and local-patch evidence remains in demo.md; it did + not validate Post↔Tag assignment. A successful command/HTTP 200/green scaffold + suite is insufficient. Rehearse the implementation and disclose prepared results. - [ ] **Slide 21 — result claims.** Remove “versioned” from the `/api/products` description: an `/api` namespace alone does not version an API. Recap only the features actually shown, including any disclosed fallback.