Invoice Pay is the first GENUINE multi-data-step wizard, and migrating it exercises
the engine's central abstraction for the first time: choose-method collects
{:bank-account :method}, payment-details collects {:invoices :check-number
:handwritten-date :mode}, and the engine's get-all MERGES the two independent step
payloads for the per-method pay (handwrite-check transacts a pending check; the
others go through print-checks-internal). This is exactly the mechanism the Phase-6
adversarial review flagged as unproven.
What changed
- Deleted the 3 wizard records (PayWizard / ChoosePaymentMethodModal /
PaymentDetailsStep), MultiStepFormState, the EDN snapshot, and the step-params[...]
prefix. Replaced with pay-wizard-config (init-fn builds read-only :context;
two steps; done-fn = pay!) driven by wizard2.
- De-cursored the payment-details amounts grid (fc/cursor-map -> explicit
(map-indexed) over :context :invoices with path->name2 names).
- The bank-account cards' method controls now post {bank-account, method,
direction:next} straight to the engine submit-route (was a bespoke navigate route).
- Routes 3 -> 2: open-pay-wizard (GET), pay-step (every transition); the
pay-wizard-navigate route is deleted.
- Used the post-review engine primitives: :open-response (modal wrap), nav-footer
(with new :save-label "Pay"), auto nav-field stripping (flat decode, no allowlist),
Enter guard.
invoices.clj falls fully off the framework: Invoice Pay was the last mm/fc user
(bulk-edit went in Phase 5), so fc/ 0, mm/ 0, defrecord 0, step-params 0 — and the
multi-modal / form-cursor / malli.util requires are removed.
Gotcha discovered + documented: wizard session data must be EDN-safe (the cookie
session store has no clj-time readers), so the date default is computed in render,
not stored in context.
Verification: invoice-pay spec 3/3 (the merge end-to-end); full suite 58/58; load-file
clean; cljfmt clean. Skill fed: scorecard row (merge proven; whole-file zeroing) +
the EDN-session-safety gotcha.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
18 KiB
Quality scorecard (the ratchet)
Cheap to measure (grep -c, wc -l, clj-kondo), recorded before/after each
migration in the commit message and in the results table below. No metric may regress
for the touched modal without a written exception in gotchas.md. These are directional
evidence, not targets to game — always paired with the e2e parity gate.
Heuristics
| # | Heuristic | Measure | Target |
|---|---|---|---|
| 1 | Faked cursor positions (not cursors themselves) | grep -cE 'with-cursor|MapCursor\.' re-roots + grep -c 'defn.*-no-cursor' |
→ 0 (top-rooted cursors are fine) |
| 2 | Implicit state merges (snapshot/cursor) | count merge sites | → 0 (forms); explicit put-step only (wizards) |
| 3 | Branching complexity | clj-kondo, or count cond/condp/case/nested if + max depth |
net ↓ |
| 4 | Lines of code | wc -l on the modal's file(s) |
net ↓ |
| 5 | Reuse / cross-form similarity | cookbook components reused; duplicated-block count | reuse ↑, dup ↓ |
| 6 | Route count | count routes for the modal | → 2 (+1 for add-row) |
| 7 | OOB swaps | grep -c hx-swap-oob |
→ 0 unless a justified disjoint-region case is documented |
| 8 | Attribute consistency | mixed :x-/"x-" encodings in migrated template |
→ 0 |
How to measure (copy/paste)
F=src/clj/auto_ap/ssr/<modal>.clj
echo "LOC $(wc -l < $F)"
echo "no-cursor twins $(grep -c 'defn.*-no-cursor' $F)"
echo "faked-cursor roots $(grep -cE 'with-cursor|MapCursor\.' $F)"
echo "snapshot merges $(grep -c ':multi-form-state :snapshot' $F)"
echo "branch forms $(grep -cE '\(cond |\(condp |\(case |\(when-not ' $F)"
echo "hx-swap-oob $(grep -c 'hx-swap-oob' $F)"
echo "mixed string hx- $(grep -cE '\"hx-[a-z]' $F)"
# route count: count this modal's entries in src/cljc/auto_ap/routes/*.cljc
Results
Each migration appends one row (after-numbers), referencing the before in the diff.
| Phase | Modal | LOC | Routes | no-cursor twins | faked roots | snapshot merges | OOB | mixed hx- | cookbook reused / added |
|---|---|---|---|---|---|---|---|---|---|
| 1 (baseline) | Transaction Edit transaction/edit.clj |
1608 | ~12 | 1 | 2 | ~75 | 0 | 8 | — / seeded 7 entries |
| 2 | Transaction Edit transaction/edit.clj |
1593 | ~5 | 0 | 0 | 0 round-trip | 0 | 8 (shared) | location-select / 1 Selmer |
| 2-final | Transaction Edit (full Selmer + wizard removed) | 1548 | 5 | 0 | 0 | 0 | 0 | 0 | full sc/* lib / ~30 partials |
| 3 | Transaction Bulk Code transaction/bulk_code.clj |
506 (was 420 — see exception) | 3 | 0 | 0 | 0 | 0 | 0† | reused all of Phase-2's sc/* lib + account-typeahead*/location-select* + edit-modal/transitioner chrome / added sc/select |
† The one "hx-..." string hit is a response-header map ({"hx-trigger" "refreshTable, reset-selection"}), not a mixed attribute encoding. mm coupling 19→0, wizard records 3→0, step-params 10→0 (the 2 hits are comments), Hiccup-in-render → 0 except the shared com/success-modal (heuristic-9 exception, as in Phase 2).
| 4 | POS Sales Summary pos/sales_summaries.clj | 732 (was 790) | 6 modal | 0 | 0 | 0 | 1✦ | 0✦ | reused sc/* lib + edit-modal/transitioner chrome / added the inline click-to-edit account-cell + manual-items patterns |
✦ The residual 1 hx-swap-oob and the "hx-..." string hits all live in the grid page code (the grid-page render lambdas + the filters form + the submit response-header map) — none are in the migrated modal render path, which is 100% Selmer. defrecord count 0 (all 4 wizard records gone), fc/ cursor refs 51→0, mm coupling 20→0, step-params 27→0 (2 comments). LOC dropped (this wizard held real custom code, unlike bulk-code's thin shell). Two pre-existing bugs fixed (per the user's call): the "New Summary Item" add button (was throwing newRowIndex is not defined) and the dead totals/balance display.
New heuristics introduced at 2-final (full Selmer)
| # | Heuristic | Measure | Target |
|---|---|---|---|
| 9 | Hiccup HTML tags in the render path | grep -cE '\[:(div|span|p|a|button|input|h[1-6]|ul|li|label|select|option|t(able|head|body|r|d|h)|form|svg|template)' over the modal's render fns |
→ 0 (success-modal confirmation dialogs may keep the shared Hiccup component) |
| 10 | mm wizard coupling | grep -c 'mm/' the modal file + grep -c 'defrecord.*Wizard|ModalWizardStep' |
→ 0 for a single-step modal |
Phase 2 progress. Achieved with parity held (swap spec 6/6, transaction-edit spec 8/8, full suite 38 pass / 1 unrelated fail / 0 skip, up from 30/2/7):
- deleted the dead
*-no-cursor*twin (no-cursor 1→0);- de-faked the simple-mode cursor (faked roots 2→0) via explicit data + explicit field names (
account-field-name) + explicit error lookup — the render-fn rewrite thewith-field-defaultshortcut couldn't do;- collapsed the 5 manual-coding operation routes into one
edit-form-changeddispatcher (routes ~12→~5; the operations are now pureapply-*fns);- fixed a real production bug (
:mode→ 500 on every advanced manual save);- greened
transaction-edit.spec.ts(8/8) and matured the skill.Phase 2 complete. The wizard→plain-form rewrite removed the snapshot round-trip (heuristic 2 → 0) and the first interactive component (
location-select) is migrated to a Selmer template (selmer-conventions.mdvalidated). Remaining for later phases: drop the now-thinmm/ModalWizardStepprotocol wrappers, and the cross-cutting Phase 11 Selmer sweep of the sharedcom/typeahead/com/select/com/button-group-button(those shared call sites hold the last 8 mixed@/:-attr offenders; they clear when the shared components move to Selmer — not a single-modal task, per Open decision 2).
Phase 2-final — full Selmer + wizard removed. Every component the modal renders through was ported to a Selmer partial under
resources/templates/components/with a thin Clojure wrapper inauto-ap.ssr.components.selmer(sc/*); the modal's own structure lives underresources/templates/transaction-edit/. Themmwizard abstraction (EditWizard/LinksSteprecords,MultiStepFormState,step-params[...]field names,wrap-wizard/wrap-decode-multi-form-statemiddleware) was deleted — there was only ever one step, so it was pure overhead. Result: heuristic 8 (mixed hx-) and 9 (Hiccup in render) and 10 (mm coupling) all → 0; theedit-wizard-navigateroute is gone (routes 5). Parity held: swap spec 6/6, transaction-edit spec 8/8, full suite 38 pass / 1 pre-existing unrelated fail (serial, fresh seed). The only Hiccup left in the file is the post-savecom/success-modalconfirmation dialogs (terminal, shared component — out of the form's render path). Seeform-vs-wizard.md(drop-the- wizard test),selmer-conventions.md(composition mechanics), andgotchas.md(stray-field decode leak; jetty reload staleness).
Phase 3 — Transaction Bulk Code (first cold apply of the mature skill). Single-step form wearing a full wizard costume (
BulkCodeWizard/AccountsStep,MultiStepFormState, thestep-params[...]prefix, the oldfind *location swap). Migrated to a plain form by mirroring Phase 2 — and it was mostly reuse: the entiresc/*Selmer component library,account-typeahead*/location-select*, and theedit-modal/transitionerchrome were imported wholesale; the only new shared component wassc/select(the status dropdown —location-select.htmlgeneralized). Parity held: bulk-code spec 13/13, full suite 39/39 (up from the Phase-2 baseline of 38–39). mm coupling 19→0, snapshot merges 4→0, wizard records 3→0, routes 4→3 (open / submit /form-changed— the per-opnew-account+vendor-changedroutes folded into oneform-changedop dispatcher), the location swap moved offfind *onto explicit#account-location-<index>+hx-select.The one regression — LOC 420→506 (documented exception, see
gotchas.md). Unlike edit (whose wizard held real custom code), bulk-code's wizard was a thin shell that delegated almost everything tomm/*defaults (default-render-step,default-render-wizard,submit-handler,open-wizard-handler). Ripping the wizard out moves that previously-shared plumbing into the file as explicit render/decode/submit/handler code. The trade is intended: every other heuristic improved and the modal is now self-contained and wizard-free. New patterns added to the cookbook: the selection-as-ids[]round-trip (resolve the non-editable selection to a concrete id vector at open, ride it in hidden fields — the bulk analog of edit's singledb/id), and the:id-keyed vendor typeahead (a value-bound hidden must be keyed or its posted value goes stale across a whole-form swap).
Phase 4 — POS Sales Summary (first modal with no prior test coverage). The largest migration so far and the first that required building the parity gate first: the modal had zero e2e/clj tests and the test server seeded no POS data, so the work began by seeding a balanced sales summary + writing a 7-test characterization spec (committed separately, ahead of the rewrite). Then the standard wizard→plain-Selmer migration:
MainStep/EditWizard+MultiStepFormStatedeleted, the 51fc/cursor refs de-cursored into explicit data + Selmer,step-paramsdropped, the EDN snapshot replaced by flatwrap-decode/wrap-derive-state(with a db/id-keyed item merge so the read-only fields the form doesn't post — ledger-side, amount — survive a re-render). The inline click-to-edit account cell (pencil → typeahead editor → check/cancel) was preserved as three small targeted.account-cell-swap routes (a distinct feature, not folded into the form-changed dispatcher). LOC 790→732 (net ↓ — a fat wizard, opposite of bulk-code).Characterize-then-fix. Writing the gate surfaced two pre-existing bugs: the "New Summary Item" button threw
newRowIndex is not defined(dead since forever) and the totals/balance display was dead code (malformed Hiccup that discarded its labels). The spec first documented them as broken (never assert a bug as working); then, on the user's call, the migration fixed both — add-item is now a whole-form-swapop=new-itemadding an editable manual row, and a proper#summary-totalsblock shows running Total + Balanced/Unbalanced (a Rule-4 targeted swap on manual amount edits). The spec was updated to assert the fixed behavior. New cookbook entries: the inline click-to-edit cell and the db/id-keyed item merge for partially-posted rows.
Phase 5 — Invoice Bulk Edit (cold apply in a 1812-line shared file). Structurally Phase 3's bulk-code applied to invoices (selected entities → expense-account rows: account/location/percentage), so it was near-pure reuse: bulk-code's flat-state plumbing (ids round-trip,
wrap-bulk-state, schema/decode) + edit'saccount-totals-tbodyfor the live totals.BulkEditWizard/AccountsStep+MultiStepFormStatedeleted,step-paramsdropped, the rows de-cursored to Selmer with the explicit-id location swap, bulk-edit routes 5→3 (thenew-account+total+balanceroutes folded into oneform-changedop dispatcher + the sibling-<tbody>totals swap). Implemented the dead TOTAL/BALANCE display (the wizard had them commented out with a duplicateid="total") as a#expense-totalssibling-<tbody>refreshed by a Rule-4 percentage-keyup swap. Parity held: invoice-bulk-edit spec 5/5, full suite 50/50.Editing a wizard buried in a large shared file: the clojure-mcp structural tools (
clojure_edit/replace_sexp) reformat the whole file — here that was a spurious 650-line whitespace diff that would bury the real change. For a surgical migration inside a big multi-modal file, use the text-based Edit tool instead (the AGENTS.md "absolutely necessary" carve-out), thenload-file+cljfmtto verify. The resulting diff was fully contained to the requires + the bulk-edit region.Repeated-row target-selector convention — settled (the Phase 5 exit criterion). Across edit / bulk-code / sales-summary / invoice-bulk-edit the convention converged on: explicit per-row ids (
#account-location-<index>,#account-row-<index>) for a cell-local swap (Rule 2), and a single stable-id sibling-<tbody>(#account-totals/#expense-totals) for running totals (Rule 4) — not data-attribute selectors or aform-path→selectorhelper. Per-row ids are generated from the row index the form already uses for field names (path->name2), so server and markup agree by construction. Whole-form swap (Rule 3) covers structural changes (add/remove row). This is now the cookbook default; seeswap-doctrine.md.
Phase 6 — the wizard engine, and its first real modal (Transaction Rule). The inflection phase. (a) Engine (
6a, committed separately):wizard-state+wizard2, the DjangoformtoolsSessionStorage model, REPL-proven before any modal touched it. (b) First real modal (6b): the Transaction Rule wizard (edit step + read-only test/preview step) migrated onto the engine and fully de-cursored like Phases 2-5. Scorecard (admin/transaction_rules.clj):fc/cursor refs 82 -> 0,mm/coupling 20 -> 0, defrecords 3 -> 0 (EditModal / TestModal / TransactionRuleWizard all gone), LOC 1000 -> 964, the 4 wizard routes (open/navigate/save + per-dialog) collapse to 2 (open-rule-wizardfor new+edit,save-stepfor every transition). Parity held: rule spec 4/4, full suite 55/55.The engine generalizes even for a one-data-step "wizard". Transaction Rule is edit + a read-only preview of the same entity, not two independent data steps — so it exercises the engine's render / navigation /
:all-data-preview path but not the cross-step merge (that waits for Phase 7's Invoice Pay). The test step's:renderreads:all-data(the engine'sget-all), which here is just the edit step's rule — so the formtools "combine at the end" mechanism is exactly what feeds the preview table. Nav is the engine'sdirectionfield (plain submit buttonsname="direction" value="next|back|submit"), so the per-stepnavigateroute is deleted.Note (scope): the de-cursored edit step keeps
com/*Hiccup leaf components rather than porting tosc/*Selmer partials — the modal's value was removingfc/+mm/and proving the engine, not re-templating its (conditional, Alpine-cross-field) layout. Hiccup-in-render (heuristic 9) is therefore a documented partial here; the leaf-componentcom/ -> sc/swap is a mechanical follow-up. The Alpine cross-field dispatch wiring (clientId -> accountId -> location) was preserved verbatim — de-cursoring touched only the data plumbing.
Phase 7 — Invoice Pay: the engine's cross-step merge, finally proven. The first genuine multi-data-step wizard (every prior one was single-data-step wearing wizard costume, or edit+preview of one entity). Step 1
choose-methodcollects{:bank-account :method}; step 2payment-detailscollects{:invoices :check-number :handwritten-date :mode}; the engine'sget-allmerges the two independent payloads for the per-methodpay!(handwrite-check transacts a pending check; the others go throughprint-checks-internal). This is the exact mechanism the Phase-6 reviewer flagged as unproven — now exercised end-to-end (gate 3/3: choose-method renders the bank-account + methods → handwrite-check advances to details → check number + submit shows the completion modal). Conditional rendering by method (handwrite shows check-number, print shows date) lives in step 2's render, reading:methodfrom:all-data.The whole file falls off the framework. Invoice Pay was the last
mm/fcuser ininvoices.clj(bulk-edit went in Phase 5), so the migration zeroed the file:fc/cursor refs 0,mm/0,defrecord0 (PayWizard + ChoosePaymentMethodModal + PaymentDetailsStep all gone),step-params0 — and themulti-modal/form-cursor/malli.utilrequires were deleted outright. The pay wizard's 3 routes (open / navigate / submit) collapse to 2 (open =open-pay-wizard, every transition =pay-step); the cards post{bank-account, method, direction:next}straight to the engine submit-route instead of a bespoke navigate route.Engine dividends from the review follow-up paid off here. This migration used the primitives the engine absorbed after Phase 6:
:open-response(modal wrap, so open is one handler),nav-footer(with the new:save-label "Pay"), the auto nav-field stripping (the flat{bank-account, method}decode needs no allowlist), and the Enter guard — so the consumer is config + render + the per-methodpay!, not framework plumbing.New constraint discovered: wizard session data must be EDN-safe (the cookie store has no clj-time readers) — see
gotchas.md. The de-cursored amounts grid stores the enriched invoice list in:context, which is fine at gate scale (1 invoice) but is the session-bloat risk the reviewer named; a leaner context (ids + amounts, re-query in render) is the follow-up if real payments carry many invoices.