correctness fixes from the first real folio dispatch, and spec authoring skills (#82)

* docs: add the apache 2.0 license text

package.json has declared Apache-2.0 since the first release and .goreleaser.yaml
globs LICENSE* into the archives, so that glob has been matching nothing. npm
only picks up a license from the package directory, hence the copy under
pkg/spec.

* fix(sidecar): close the soft keyboard after typing on android

* test(sidecar): pin the guarded ime dismissal

* fix(sidecar): treat a failed ime probe as no keyboard open

* ci(folio): let the ios leg clear state for itself

* ci(folio): drop the stale frontboard note from the ios job

* docs(ci): record what the ios calibration assumes and where it was measured

* fix(ios): replace the session when a launch blows its bound

a launch the simulator refuses is never reported: xctest records it as a test
failure the runner cannot see, then holds the session's main thread for about
four minutes on a diagnostic chain. so the only signal is the expired bound,
and every later call queues behind the same wedge. restart the session once and
launch again, bounded so the launch path stays inside testrun's backstop.

* test(ios): cover the session replacement a wedged launch needs

* fix(ios): share one deadline across the restart and the second launch

the recovery a blown bound triggers now costs at most launchRecoveryTimeout
whatever it spends it on, so the launch path tops out at 150s and testrun's
three minute backstop stays a backstop.

* test(ios): the restart a blown launch triggers has to be bounded

* docs(ci): the ios leg does not convict on the runner, and a seed cannot fix it

seed 28 reproduced its walk on macos-15 and reached the bug at the step it
convicts at locally. it still could not be judged: the run did not return
home between step 19 and step 136, so the counting invariant saw a rise of
15 against a window of 37 submits.

* docs(sidecar): record why the stale ime flag stays out of reach

* test(folio): add commonTest source sets to core and shared

* fix(folio): reject amounts parseCents cannot represent

* fix(folio): cap a transaction at one million dollars

* test(spec): give the fake dom a real tree and a walking querySelectorAll

* style(sidecar): make ktlint clean, formatting only

ktlint -F over every kotlin file except DriverBackend.kt, then hand
fixes where the reflow read worse and for the long lines ktlint cannot
break. No behaviour changes.

DriverBackend.kt is left untouched to avoid a conflict with concurrent
work; its three over-long lines still fail fmt-kotlin.

* fix(spec): deepQueryAll returns matches in document order

* ci(folio): say what the android gate found, not why

The gate proves only that AddTransactionScreen is absent from the trace.
Claiming the run never got past login was an inference it cannot make: the
run that produced it had logged in and was stuck on the new account screen.
Name the routes the trace does record instead.

* test(runner): a relaunch must not convict the submit counting property

* test(browser): compare ax.find across both hosts on one page

* test(spec): name the shadow match in the grammar both hosts parse

* ci: move every action off the node20 runtime

checkout v4->v7, setup-go v5->v7, setup-node v4->v7, setup-java v4->v5,
upload-artifact v4->v7, cache v4->v6, upload-pages-artifact v3->v5,
deploy-pages v4->v5, setup-chrome v1->v2, setup-android v3->v4,
goreleaser-action v6->v7. setup-bun and android-emulator-runner are
already node24; buf-setup-action stays on its deliberate SHA pin.

setup-chrome v2 resolves stable from Chrome for Testing rather than the
official installer, so the ci.yml comment about the action's default no
longer held.

* feat(verifier): report a relaunch on state.lastAction

* test(verifier): pin the relaunch field on both hosts

* fix(runner): keep the action the app was relaunched after

* test: pin that a nested undefined does not survive the wire

* fix(folio): uninstall before installing in just ios

folio's signed-in session lives in the data container, which an install
over the top keeps, so a local run started right after just ios opened on
the previous run's Home screen and diverged at step 1. On CI's fresh
simulator the uninstall is a no-op, so the ios leg is unchanged.

* style(sidecar): bring the last three lines under the line limit

* fix(ios): the runner must not answer ok for a launch that failed

XCTest records a refused launch as a test issue that never throws, so the
companion returned ok for an app that never started. Check the state the
app actually reached and report the refusal instead.

* test(ios): a refusal the runner names costs no session restart

The session restart is for a launch that never answers. A launch that
reports the app's state has already said what a fresh session would.

* fix(folio): attribute a created account by its whole key, not a suffix

createdAccountHasNonZeroBalance matched the created card with endsWith, so
an older account whose name ends with the typed one ("Emergency Fund" for a
typed "Fund") was judged instead whenever the new card was clipped out of
the reading. Build both keys the card can carry, the plain name and web's
initials + name, and compare them whole.

* fix(hierarchy): object selectors resolve by the same rule as string ones

* test(verifier): both ax.find selector forms resolve the same element

* test(browser): the cross-host fixture uses the object selector form

* fix(replay-ui): wrap the tab strip so its last tabs stay clickable

* test(replay-ui): drive the fuzzer onto a violating step with a panel

* feat(folio): judge a submit against the account's own balance

The counting invariant can only close its window on Home, and the iOS run
in #78 went 117 steps between two Home readings: 37 submits against a rise
of 15 transactions is no evidence about the double tap sitting inside it.
The ledger and the add-transaction screen both show the account's own
balance, and an accepted submit pops back to the ledger, so a window
bounded by those readings holds one action.

The bound is an upper one: a balance that has not moved is a commit still
in flight, a rejected submit or a tap that never landed, and none of those
is a violation. Moving by more than the one submit in the window typed is.

* test(runner): an overlay dismissal must not convict the counting property

* docs(runner): point the guard comment at the renamed test

* docs(replay-ui): name the viewport the tab overflow was measured at

* feat(folio): close the submit window on the account's own screens

submitCommitsOneTransactionPerAction now states its rule over two windows:
the Home counts it already compared, and the account balance the ledger and
the add-transaction screen redraw on nearly every frame of the transaction
flow. Same rule, and the second window is usually one action wide.

* fix(sidecar): reach the adb server the environment names

buildDadb hardcoded localhost:5037, so a serial-addressed device always
resolved through this machine's adb server and ADB_SERVER_SOCKET was
ignored. Read the endpoint the way the adb CLI does instead.

Fixes #79

* test(sidecar): pin the adb server endpoint parsing

* fix(sidecar): close a keyboard standing in the snapshot

A tap on a text field raises the keyboard and nothing closed it, so the
tree the picker chooses from was missing every app node underneath it,
the submit control included. Close it before the read rather than after
the tap: the picker only ever sees snapshots, and the keyboard is still
on its way up when the tap returns.

Fixes #78

* test(sidecar): pin the tree-guarded keyboard dismissal

* chore(make): a target that runs folio's unit tests

* chore(folio): a just recipe for the unit tests

* ci: run folio's unit tests on every pr

* ci: switch to jdk 21 only for the folio step

* docs(sidecar): put the measured read cost in the dismissal bound

* fix(folio): decline the two demanding properties across a relaunch

The runner now keeps lastAction and marks it relaunched: true where it used
to report nothing at all, so the two properties that demand an effect judge
a step whose process may have died before the write landed.
submitChangesBalanceByTypedAmount and createdAccountHasNonZeroBalance both
decline there. The counting bound does not: a relaunch cannot manufacture a
transaction, and the submit is counted, so declining would throw away the
detection the runner fix restored.

* docs(folio): say why the merged card key cannot be made injective

Folio rejects a duplicate account name, so the twin the drop rule guards
against is two names the web key cannot tell apart, not two accounts
sharing a name. State what closing the rest would cost and what the tree
would have to carry to close it properly.

* test(folio): pin that two accounts can render the same card text

The proof behind the comment: "Travel1" holding 25 transactions and
"Travel12" holding 5 merge to the same string, so no identity key read off
a web card can tell them apart.

* fix(sidecar): bound the diagnostic adb reads

adbOutput and readLogcat read to EOF and then waited with no timeout, so
a wedged adb held the step for as long as it liked; one stall over a
remote adb server measured ~100s. The bound has to sit on the read, not
on waitFor: a wedged adb never reaches EOF, so a bounded waitFor after
the read is a line that never runs.

* test(sidecar): pin the bound on a wedged adb read

* fix(sidecar): an unreadable animation count is not idle

Defaulting the count to zero made a dumpsys that said nothing mean
nothing is animating, so a degraded link broke out of the settle early
and handed the runner a frame caught mid-animation. Unknown now waits,
inside the deadline waitForIdle already holds.

* test(sidecar): unknown animation state must not read as idle

* fix(folio): stop spending the submit budget on taps the app refused

The window is an upper bound on the transactions an interval could hold, and
a bound inflated by taps that commit nothing is a bound the app can never
exceed: #78 read a rise of 15 transactions against 37 submits. TxnSubmit is
clickable(enabled = amount.isNotBlank()) and parseCents refuses anything its
regex misses, so a tap whose landing frame shows a refused amount cannot have
committed. Over four recorded android runs that is 19, 11, 25 and 25 of 35,
26, 42 and 42 submit taps.

A relaunch is excepted: a fresh process draws an empty field whatever was
submitted.

* feat(folio): read the amount field into every submit window

Each of the three windows asks whether the tap could have committed, off the
field as the landing frame shows it.

* fix(sidecar): a foreground read that fails degrades the typing guard

An unreadable dumpsys passed a null owner to typeChunks, which switches
the mid-type focus guard off outright and lets the rest of the string
spray into whatever holds the foreground. Fall back to the launched
bundle instead: the guard stays armed, typing still happens, and the
degradation is said out loud rather than assumed away.

* test(sidecar): pin the degraded typing guard both ways

* test(runner): answer Snapshot and Hierarchy off one tree in the fakes

* feat(runner): skip a step whose tree changed between two reads

* test(runner): cover the reread's cost to the existing snapshot rules

* fix(ios): clear app state before the automation session attaches

New performs the clear-state reset, so the uninstall and reinstall no
longer land underneath a live XCTest session that is already bound to
the app. Launch refuses a clear-state request the driver was not built
for rather than reinstalling under its own session.

* fix(ios): the device path clears before its runner session too

* fix(testrun): thread clear-data into the ios drivers

* test(runner): a skipped step must not swallow the action before it

* fix(runner): hold the action back on a step nothing verified

* refactor(runner): drop the empty branch from the hold path

* docs(runner): describe both modes of the composing test driver

* fix(sidecar): erase a field by selecting it, not one delete per character

maestro's eraseText sends one delete per character through its
instrumentation, measured 29.6 ms/char on the API 34 emulator. The
4096-character string the corpus types cost ~121s to clear, a fifth of a
20 minute run spent on one step, and it recurred every time that field
was typed into again.

Select the content and delete the selection instead: two key events at
any length, measured 0.15s to 1.16s for 4096 characters across API 34,
35 and 36. The result is read back off the tree, and a field that is not
empty, or that the tree cannot report on, is finished off per character
in batches rather than assumed clear.

Fixes #80

* test(sidecar): pin the constant-cost erase and its residue check

* fix(sidecar): find the erased field by class, past the keyboard's own focus

The check that decides whether the select-all worked looked for an
"editable" attribute maestro's tree does not carry, so it answered
"cannot tell" every time and every erase paid the per-character
fallback. Worse, an open keyboard puts a second focused node in the
tree, one of the IME's own keys, carrying no text: taking the first
focused node would read a field still holding 4096 characters as empty,
which is the one answer that stops the erase early.

Match the text field by class instead. Measured against the real
backend, 4096 characters now clear in 385ms on API 34, 409ms on API 35
and 870ms on API 36, verified empty, where the fallback took ~4s.

* test(sidecar): use the tree the device really returns

* docs(ci): the android step number describes a local emulator, not ci

the leg disables animations and the number was measured with them on. the
first real dispatch carries 4 transitional steps over 200, so the cross-fade
wait does still fire in ci, just far less often.

* fix(android): say what the sdk lookup checked, not just to set ANDROID_HOME

* fix(doctor): resolve adb and emulator the way a run does

* docs(cli): the android doctor checks are not path-only

* fix(testrun): preflight resolves adb through the sdk, not just PATH

* docs(skills): add a spec review skill and the skills index

* fix(testrun): report a sidecar that dies at startup as the exit it was

* test(testrun): cover the sidecar shutdown path after an early exit

* docs(skills): add a property patterns catalogue skill

* fix(folio): bound the total-balance move instead of demanding it exactly

The write finishes before AddTransactionViewModel navigates, but nothing
establishes that Home's total has re-rendered before the frame is read, and
an equality convicts a healthy app for a total one frame behind. A delta of
zero is exactly the shape nine of the eleven measured android false
convictions had. 2x still exceeds x, so all four recorded convictions
survive, checked against the traces.

The trade is real: a balance that moves by LESS than the amount typed is no
longer judged anywhere in this spec.

* docs(folio): say what property 2 demands now that it is a bound

* docs(skills): add a spec authoring skill

covers hooks, extractors, selectors, properties, actions and the order to write them in, with a complete sample spec that typechecks against the real export surface.

* docs(skills): ground the property patterns catalogue in the merged specs

* docs(skills): name the selector keys that still substring match

* docs(skills): add a setup skill for adopting sanderling

* docs(skills): add a run triage skill

* docs(skills): point the setup skill at its siblings

* docs(manual): correct the flags the cli reference gets wrong

--launcher-activity does not exist in cmd/sanderling/main.go. --device,
--android-app-path and --arm do and were undocumented. runs.md still listed
--max-steps and --exit-on-violation as unshipped, and described --clear-data
as opt-in when the default is already true, contradicting itself ten lines on.

* test(folio): pin that a commit stays in the window until Home reads it

The interaction that keeps a stale Home card list from ever banking counts
the budget has already forgotten: a submit lands on the ledger, so the
reading that resets the window is a whole action later and the submit is
still in it. Characterization, not a regression: no code changed and it
cannot go red first.

* docs(folio): record why a banked card reading can be trusted as current

The freshness rule rests on the app popping one entry back to the ledger,
not on anything the frame carries, so the assumption and the measurements
behind it belong next to it.

* refactor(folio): name the balance property for the bound it asserts

it stopped being an equality and became |delta| <= typed, so the old name
demanded more than the property does. renamed with the ci gate's
GATED_PROPERTIES in the same commit so the gate never sees a name it does
not know.

* fix(android): a refused uninstall must not pass for clear-state

adb uninstall answers Failure [DELETE_FAILED_INTERNAL_ERROR] both when the package was never installed and when it refuses to remove one, so the failure text cannot say which happened and the old code installed over the top either way, keeping the data clear-state was asked to drop. Ask pm path instead, and fall back to pm clear when the app is still there.

* fix(ios): a failed simctl uninstall must fail the reinstall

simctl install over an installed app carries its data container across, so discarding the uninstall error reported a clear-state that never happened. Uninstalling an app that is not installed exits 0 on a booted simulator, so every failure here is a real one.

* fix(ios): a failed devicectl uninstall must fail the reinstall

same hole as the simulator path: devicectl install over an app keeps its data, and the discarded uninstall error hid it. Uninstalling a bundle id that is not installed exits 0 with 'App uninstalled.' on a paired iPhone, so a failure here is always real.

* docs(android): say why the uninstall text cannot be read

* test(android): name the uninstall failure for what it says, not why

* docs(ci): the ios leg convicts on the runner now, and why it did not before

* docs(ci): the cross-fade wait does not fire on ci, say so

* fix(runner): a bounded hold puts the swallow back one step later

the hold carries one action; letting the runner act again while the verifier
is still skipped overwrites it, so the carried action reaches no spec. hold
for as long as the verifier is skipped, and settle on a held step so the
reread pair is not tighter than the window the detector was measured over.

* test(runner): pin what the two reads are compared on

structuralShape excluding text and bounds is the decision separating this
feature from a run that verifies nothing, and only prose held it. adding
either field back now turns a case red.

* fix(ci): close shell injection into the npm publish job

A refname is attacker-controlled and git permits backtick, $, (, ; and |
in it. Three sites substituted it into a run: block, and NODE_AUTH_TOKEN
sat at job level, so a pushed tag ran arbitrary commands with the publish
credential in reach.

The tag now goes through env:, is validated against an anchored version
pattern before anything consumes it, and reaches the other jobs as a job
output. The token is scoped to the publish step. release-npm declares
contents: read instead of inheriting the repo default.

* fix(ios): recognise every shape a blown launch bound arrives in

The runner transport reports a blown budget two ways, its own comment says
so: the context's error once cancellation has landed, and the connection's
i/o timeout when the deadline armed from that context fires first. The
legacy transport reports it as a gRPC status. errors.Is against
context.DeadlineExceeded only matches the first, so the session restart
never fired for the other two and a wedged session stayed wedged.

* test(ios): drive the launch recovery with what the transports return

The wedged-session fake answered with ctx.Err() raw, which is the one
shape the guard already matched. The recovery now runs against the error
each transport really produces for the same expiry, taken from a runner
and a legacy companion that never answer.

* test(runner): pin both guard writes to what the spec reads

deleting lastAction.Relaunched or lastAction.Applied left the whole suite
green, so the only producer of the two fields every spec-side guard reads
had nothing holding it. both now assert the value out of the trace.

* fix(testrun): a run that judged nothing is not a green run

every step skipped means no property ever evaluated, so no violations is the
absence of a verdict rather than a clean one. the hold makes that reachable
now, so the run says it instead of exiting 0.

* fix(ios): stop the app before clearing its state

Launch terminated and then cleared; the clear moved to construction and
left nothing stopping the app first. The container wipe deletes files a
live app still holds open, and the CI ios leg passes no app path so the
wipe is the path it takes. simctl stops it, since the clear now runs
before any automation session exists. On a device the uninstall that is
its only clear takes the running app with it.

* test(ios): pin the stop that has to precede a clear

The ordering probe now records the stop, and a scripted xcrun holds what
reaches the tool: terminate before get_app_container, with the previous
run's files gone after. A simctl terminate that finds nothing to stop
still leaves the clear a success.

* docs(spec): an unbounded eventually is violated at run end

* docs(skills): an unreached eventually convicts at run end

* docs(skills): noUncaughtExceptions only fires on web

* fix(ios): a device clear-state that cannot happen must fail

--clear-data on a physical device with no --ios-app-path warned and then
ran anyway, so the run started on the previous run's data while the flag
said it started clean. There is no data-container wipe on a device, so
there is nothing to fall back to.

* test(ios): a device clear-state without an app path ends the run

* docs(skills): the stock properties each cover one platform

* docs(ci): the balance property demands a bound, not an equality

* fix(ios): the clear-state guard checks the bundle that was cleared

A bool only said that something was cleared, so Launch(ctx, otherBundle,
clearState=true) passed the guard and reported a reset that had reached a
different app. Record what was cleared and compare against the bundle
being launched.

* test(ios): a clear-state launch for an uncleared bundle is refused

* docs(manual): the flagship property is a bound, and say what that costs

* fix(ios): one address picker for every bring-up

bringUpRunner reads the picker from a field, and NewDevice only ever set
the device one, so a device driver that reached bringUpRunner would call
nil. The two fields held the same function; keeping one leaves no path
that can be wired without it.

* test(ios): a device driver can bring a runner up

* docs(skills): both shipped balance forms are bounds now

* docs(skills): name the balance predicate that still exists

* docs(skills): quote the doctor the binary actually prints

* docs(skills): screen= is the chrome driver's url, web only

* docs(skills): substring selector matching is native only

* docs(skills): web selectors are exact, native ones are substrings

* fix(ci): a run that wrote no trace is not evidence about folio

run_dir is empty when the run produced no output directory, and the
fallback made trace ./trace.jsonl. A stray trace in the working directory
was then read as this run's, so a run that wrote nothing reported 'found
the submit bug' and exited 0, defeating the missing-trace check below it.

* fix(ci): fail folio when a gated property is not in the spec

Nothing tied GATED_PROPERTIES to the spec it gates. Renaming a property
left the classifier matching nothing: ios and web blamed the spec for
finding a different bug, and android silently reclassified a real
conviction as 'judging health only' and stayed green.

replay-ui-summary.sh already makes this check for its own list. The spec
path becomes SPEC-overridable the same way, so the check is testable.

* test(ci): cover the folio classifier's verdicts

21 cases through a stubbed sanderling: every exit path, the drift check,
a missing trace, a zero-byte trace, an empty glob and a truncated line.
Asserts the flags that reached the binary, not just the exit code.

Invoked as bash -eo pipefail -c, which is what a run: block does. Running
folio-run.sh itself under -e would kill it at the first non-zero
sanderling test, which is the exit code it exists to read.

* docs(skills): defaultActions bundles five of the eight generators

* test(ios): name the picker test for what it covers

* docs(skills): three of the replay-ui properties are cross-panel

* docs(manual): state.exceptions is web only and reportError does not exist

* fix(folio): the bound carries no unconfirmed-submit guard

deleting confirmedApplied here broke 0 of 355 tests: under a bound a submit
that may not have landed moves the balance by 0, which the bound already
permits, so the guard could only ever drop the double commit it exists to
catch. the relaunch guard stays for a reason the bound does not cover, and
both tests now assert a verdict that changes when their guard does.

* docs(ci): three of the replay-ui properties are cross-panel

* docs(manual): the starter property only fires on web

* test(folio): judge the conjunct on the landings a real run produces

three of the 18 frames the recorded ios run drove it down, each with the
second commit the bound is there to catch. neutering the comparison reddens
it: a second commit on 357900 went unjudged.

* test(folio): the walk drives the composition the spec runs

countSubmitsInWindow never saw an amountText here, so every walk test counted
submits the app must have refused. with the field passed, a refused submit no
longer buys a later double tap an alibi: without it the window reads 3, not 1.

* docs(folio): say which double submit the conjunct can see, and which it cannot

the home landing is the counting invariant's, three of three in the recorded
ios run; this one gets the interleaving whose second pop is cancelled. it is
still the only judge on the 18 ledger landings that run produced.

* docs(folio): the narrow window is not where the detection comes from

the double taps land on home, so the counting form convicts them; what turned
0 convictions into 4 on the recorded ios run is submitCouldCommit, which drops
the windows at those three steps from 5/4/7 to 2/1/2.

* docs(skills): folio drives three platforms from one spec

* fix(folio): an amount over the app's cap spends no window budget

the corpus reaches TxnSubmit with 999999999999999999999, AMOUNT_REGEX takes it
and AddTransactionViewModel refuses it against MAX_TRANSACTION_AMOUNT_CENTS, so
counting it was budget a double submit could hide behind.

* docs(folio): say which form judged one step, not which node was read once

* docs: a bound still needs the relaunch guard, and eventually does convict

* fix(sidecar): the hierarchy rpc serves the tree the snapshot reads

the runner compares the two per step, but snapshot settles and closes a
keyboard while hierarchy was a bare contentDescriptor. measured on emulator
-5556 (api 34) with an ime open: 489 nodes against the snapshot's 134. both
now come off snapshotTree under the same lock; the reread still costs ~75ms
when no keyboard is up.

* docs(runner): say what makes the two reads comparable

the reread's comment claimed the round trip was the only interval between
them; what it left out is that the two rpcs have to read the same way, which
the repo's own android backend did not do.

* refactor(runner): name the settle predicate for what it means

* ci: add a headless-chrome composite action

The setup-chrome / apparmor sysctl / launch-check trio is copied across
three jobs. The old comment described setup-chrome v1 semantics: under v2
stable is the default and the alternative is Chrome for Testing latest,
not a dev Chromium, so it is restated for what the pin actually does.

* ci(examples): add the folio setup actions

folio-app holds the per-platform toolchain and app build, so a caller
guards one step instead of eight. folio-simulator boots the simulator,
installs folio and leaves the app stopped.

* ci(examples): add the replay-ui fixture action

Records a trace and serves it with sanderling replay. The step page URL
is a composite output rather than GITHUB_ENV, so it is scoped to the one
step that drives it.

* ci(examples): one dispatch workflow for every example

folio.yml and replay-ui.yml ran the same operation: build sanderling for
a platform, bring a target up, run a spec against it, classify the trace,
upload the run. They are now one matrix over four examples, each naming
its own runner.

The job is named for what it fuzzes. 'dogfood' named why we run it, not
what runs, the same error as a diagnostic that reports a motivation
instead of an observation.

The matrix is computed by a plan job because jobs.<id>.if cannot read the
matrix context, so a static matrix has no way to leave a leg out. Seeds,
budgets, timeouts, runners and artifact names are unchanged.

* ci: reuse the headless-chrome action in the browser job

Same three steps the examples workflow needs, and the comment explaining
the AppArmor sysctl now lives in one place.

* ci: move the folio jdk step to setup-java v5

The only setup-java left on v4; every other one moved.

* ci: pin third-party actions to commit shas

buf-setup-action was already pinned with a comment saying why; the other
five rode mutable major tags, so a tag move is an unreviewed change to
what runs. Each major currently resolves to the release named in the
comment, so this freezes today's behaviour rather than changing it.

actions/* stay on major tags: they are first-party to the runner.

* ci(replay-ui): name the run directory for what it fuzzes

runs/dogfood and the '### replay-ui dogfood' heading carried the same
naming error as the job name: dogfooding is why the run exists, not what
it fuzzes.

* docs(driver): state the log level scale on LogEntry

Claude-Session: https://claude.ai/code/session_01ShuAy8q8ZfPi8KHxwc8JpQ

* docs(sidecar): name the device the node counts came off

* fix(chrome): keep a log entry the level scale cannot rank

Claude-Session: https://claude.ai/code/session_01ShuAy8q8ZfPi8KHxwc8JpQ

* fix(chrome): record console levels on the logcat scale

Claude-Session: https://claude.ai/code/session_01ShuAy8q8ZfPi8KHxwc8JpQ

* docs(ci): say why upload-pages-artifact needs no include-hidden-files

v3 to v5 crossed v4's change to exclude dot-files. build/site has none,
so nothing was dropped, and the underscore directory is not hidden.

* test(browser): drive a console error through to the spec

Claude-Session: https://claude.ai/code/session_01ShuAy8q8ZfPi8KHxwc8JpQ

* docs(ioscompanion): name the vacuity behind the empty log slice

Claude-Session: https://claude.ai/code/session_01ShuAy8q8ZfPi8KHxwc8JpQ

* ci: run the folio classifier's test in make test-ci-scripts

* fix(chrome): keep the message of an object console argument

Claude-Session: https://claude.ai/code/session_01ShuAy8q8ZfPi8KHxwc8JpQ

* test(browser): cover console.error with an error object

Claude-Session: https://claude.ai/code/session_01ShuAy8q8ZfPi8KHxwc8JpQ

* feat(spec): let the runner install state.logs in the page

Claude-Session: https://claude.ai/code/session_01ShuAy8q8ZfPi8KHxwc8JpQ

* feat(verifier): encode state.logs for the web host

Claude-Session: https://claude.ai/code/session_01ShuAy8q8ZfPi8KHxwc8JpQ

* feat(chrome): install the step's logs in the page

Claude-Session: https://claude.ai/code/session_01ShuAy8q8ZfPi8KHxwc8JpQ

* test(ci): pin three real folio traces from run 31902501859

ios convicted on submitCommitsOneTransactionPerAction, web on both gated
properties, android ran its full 200 steps healthy. Every step is kept;
of each step only step, violations, witnesses and residuals survive.

hierarchy is replaced by the quoted "...Screen" resource ids it held, in
order. It cannot just be dropped: it is 95% of the bytes and also the
only place the android route gate's grep can match, so dropping it flips
that leg from healthy to 'never reached'. 8.4MB to 59KB.

* test(ci): drive the classifier over the real traces

Four cases on real data: each leg's real verdict, plus the android trace
cut before it reached the transaction screen, which is what proves the
route gate reads a real hierarchy dump.

Also corrects the hand-written fixtures. They set is_error to false on a
plain violation; internal/trace/writer.go tags that field omitempty, so a
real trace omits it entirely. Harmless to the classifier, but a fixture
that does not look like reality is the thing that hides drift.

* test(runner): teach the web fakes to take the step's logs

Claude-Session: https://claude.ai/code/session_01ShuAy8q8ZfPi8KHxwc8JpQ

* fix(runner): install the step's logs before the page extracts

On web every extractor reading is replaced by the one the page computed,
and the page answered logs: [], so noLogcatErrors counted an empty array
however full of errors the console was.

Claude-Session: https://claude.ai/code/session_01ShuAy8q8ZfPi8KHxwc8JpQ

* test(runner): cover the logs reaching the page and failing to

Claude-Session: https://claude.ai/code/session_01ShuAy8q8ZfPi8KHxwc8JpQ

* test(spec): cover the host pushing state.logs into the page

Claude-Session: https://claude.ai/code/session_01ShuAy8q8ZfPi8KHxwc8JpQ

* test(browser): drive console.error through to a fired property

Claude-Session: https://claude.ai/code/session_01ShuAy8q8ZfPi8KHxwc8JpQ

* fix(runner): report a log fetch the driver could not make

The comment claimed the failure was warned about; nothing warned, so a
device whose log fetch failed every step held noLogcatErrors on evidence
nobody collected.

Claude-Session: https://claude.ai/code/session_01ShuAy8q8ZfPi8KHxwc8JpQ

* test(runner): cover the silently dropped log fetch

Claude-Session: https://claude.ai/code/session_01ShuAy8q8ZfPi8KHxwc8JpQ

* fix(ci): read the route the spec reports, not the hierarchy dump

The android health gate grepped the trace for "AddTransactionScreen",
which occurs in exactly one place: the hierarchy dump, as a resource-id.
That is a debug artifact standing in for a fact the spec already reports,
and it was wrong in both directions. Against the real 8.4MB trace with
hierarchy stripped, the old gate failed a healthy 200-step run; against a
trace carrying the marker on a transition frame without the route ever
being reported, it passed and called it healthy.

It reads extractor_changes.route now, whose values come from SCREENS in
the spec, so the gate and the app agree on what being on a screen means.
routeOf answers null on a frame showing two screens, which is exactly the
frame the marker was matching.

The drift check grows to cover both new names: extract("route") and the
SCREENS key. The fixtures are re-derived keeping the route entry and no
hierarchy at all; the full artifacts and the fixtures give byte-identical
verdicts, which is what proves the coupling is gone.

Script and fixtures move together: either alone leaves the suite red.

* ci: check that the workflow references resolve

actionlint reads a local action's inputs but never checks its path
exists: uses: ./.github/actions/typo lints clean and fails only when the
job runs. Covers composite action paths, make targets including the ones
the examples matrix builds from $SANDERLING, and the scripts a run: step
invokes plus their executable bit.

Fails when it parses fewer references out of a file than that file
mentions, because a checker that matches nothing reports a safety it
never looked for.

* ci: lint the workflows on every pr

The workflows that fuzz the examples are dispatch-only, and GitHub will
not dispatch a workflow that is not on the default branch, so their first
real run is after merge. actionlint and the reference checker are the
only things that can fail before that.

actionlint is pinned by commit, and its tool version is pinned too so a
new release cannot change what CI enforces.

* ci: collapse the four workflows into one

Nine jobs written out one by one, each with its own steps and its own
calibrated seed and budget as literals. Triggers are pull requests, master
and v* tags, and a dispatch with no inputs.

Claude-Session: https://claude.ai/code/session_01ShuAy8q8ZfPi8KHxwc8JpQ

* ci: inline the two composite actions with one caller each

Both existed to give the matrix a per-target hook. folio-app and
headless-chrome stay: three and three callers.

Claude-Session: https://claude.ai/code/session_01ShuAy8q8ZfPi8KHxwc8JpQ

* ci: check that no run: block interpolates an expression

A ${{ }} lands in the script text before bash reads the line, and
actionlint only flags the contexts it already knows are attacker
controlled. Nothing enforced the rule the workflow follows. Also drops the
matrix table lookup, which has no table to read now.

Claude-Session: https://claude.ai/code/session_01ShuAy8q8ZfPi8KHxwc8JpQ

* ci(folio): name the run, not the fuzzer, in the clean-run message

Claude-Session: https://claude.ai/code/session_01ShuAy8q8ZfPi8KHxwc8JpQ

* docs: point at the workflow that holds the release secrets now

Claude-Session: https://claude.ai/code/session_01ShuAy8q8ZfPi8KHxwc8JpQ

* ci: name every job Category (variant), and gate the lot on one check

Follows the convention in antithesishq/bombadil: the display name is what
groups a run in the Actions UI, so Check (tests), Check (browser),
Check (workflows), Folio (android), Folio (ios), Folio (web), Replay UI,
Release and Docs. Every job carries a name, so none of them falls back to
its kebab-case id.

All checks passed needs all nine and runs with if: always(), so branch
protection has one check to point at and a skipped job cannot read as a
pass. Release and docs now gate on startsWith(github.ref, 'refs/tags/v')
alongside master, which is the form the trigger filter already uses.

Claude-Session: https://claude.ai/code/session_01ShuAy8q8ZfPi8KHxwc8JpQ

* ci: split the release job back in two

Collapsing them left the npm publish steps in a job holding contents:
write, because GoReleaser needs it, so npm ci ran its dependency lifecycle
scripts with a write-capable GITHUB_TOKEN in reach of the same job as a
live NPM_TOKEN. Release (npm) is back on contents: read and Release (cli)
keeps contents: write, which is what they each had before.

Each validates the tag from its own copy of the pattern rather than
waiting on a job that exists only to pass a string. Release (cli) is tags
only: there is no CLI to cut on a merge.

Claude-Session: https://claude.ai/code/session_01ShuAy8q8ZfPi8KHxwc8JpQ

* ci: run folio on pull requests

folio was skipped on pull requests, so ios, android and web only ever ran
after a merge. The three legs are 3 to 19 minutes and run in parallel, and
a superseded pull request run already cancels itself.

Claude-Session: https://claude.ai/code/session_01ShuAy8q8ZfPi8KHxwc8JpQ

* ci: draw each group as its own box in the run graph

The run graph boxes jobs together when they share the same dependencies and
the same dependents. All ten jobs fed only all-checks-passed, so all ten drew
as one pile. A gate per group gives each group a dependent that is exactly
that group.

Release and docs now need the checks, which they should have all along: npm
publish and the pages deploy ran on a merge without waiting for the test job.
Folio stays unblocked so a 20 minute leg does not wait on a 3 minute one.

Claude-Session: https://claude.ai/code/session_01ShuAy8q8ZfPi8KHxwc8JpQ
This commit is contained in:
pj authored and GitHub committed 2026-08-16 13:23:53 +05:30
1 parent 11f72a722a
commit 9fb121e9d0
117 files changed
+11108 -1262

No files matched your search

+3 -1
View File
@@ -37,7 +37,9 @@ IOS_DEVICE="iPhone 15" just ios # pick a different simulator
`just ios` regenerates `app/iosApp/iosApp.xcodeproj` from `app/iosApp/project.yml`,
builds the KMP framework (`Shared.framework` from `:app:shared`), links it
into the SwiftUI host, installs, and launches.
into the SwiftUI host, uninstalls any previous copy, installs, and launches.
The uninstall matters: folio's signed-in session survives an install over the
top, so without it a run opens on the last run's Home screen.
## Web
@@ -49,6 +49,9 @@ kotlin {
implementation(libs.lifecycle.viewmodel.compose)
implementation(libs.navigation.compose)
}
commonTest.dependencies {
implementation(kotlin("test"))
}
androidMain.dependencies {
implementation(libs.androidx.activity.compose)
}
@@ -3,6 +3,7 @@ package app.folio.feature.ledger
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.folio.core.data.Account
import app.folio.core.data.MAX_TRANSACTION_AMOUNT_CENTS
import app.folio.core.data.Repository
import app.folio.core.data.TxnType
import app.folio.navigation.Navigator
@@ -87,6 +88,10 @@ class AddTransactionViewModel(
form.update { it.copy(error = "Amount must be greater than zero") }
return
}
if (cents > MAX_TRANSACTION_AMOUNT_CENTS) {
form.update { it.copy(error = "Amount is too large (max \$1,000,000.00)") }
return
}
viewModelScope.launch {
try {
repository.createTransaction(accountId, s.type, cents, s.note)
@@ -54,9 +54,8 @@ fun parseCents(input: String): Long? {
val fracPadded = (frac + "00").substring(0, 2)
val wholeLong = whole.toLongOrNull() ?: return null
val fracLong = fracPadded.toLongOrNull() ?: return null
val total = wholeLong * 100 + fracLong
if (total < 0) return null
return total
if (wholeLong > (Long.MAX_VALUE - fracLong) / 100) return null
return wholeLong * 100 + fracLong
}
fun signedAmount(t: Transaction): Long = if (t.type == TxnType.credit) t.amount else -t.amount
@@ -0,0 +1,38 @@
package app.folio.util
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
class ParseCentsTest {
@Test
fun parsesEverydayAmountsExactly() {
assertEquals(1L, parseCents("0.01"))
assertEquals(1234L, parseCents("12.34"))
assertEquals(1250L, parseCents("12.5"))
assertEquals(1200L, parseCents("12.0"))
assertEquals(100000L, parseCents("1000"))
assertEquals(123456L, parseCents("1,234.56"))
}
@Test
fun rejectsEighteenDigitWholeThatWrapsToAPositiveLong() {
assertNull(parseCents("999999999999999999"))
}
@Test
fun rejectsSeventeenDigitWholeThatWrapsToANegativeLong() {
assertNull(parseCents("99999999999999999"))
}
@Test
fun rejectsNineteenDigitWholeThatNoLongerFitsALong() {
assertNull(parseCents("9999999999999999999"))
}
@Test
fun acceptsTheLargestRepresentableAmountAndRejectsOneCentMore() {
assertEquals(Long.MAX_VALUE, parseCents("92233720368547758.07"))
assertNull(parseCents("92233720368547758.08"))
}
}
+4
View File
@@ -35,6 +35,10 @@ kotlin {
api(libs.sqldelight.coroutines.extensions)
api(libs.sqldelight.async.extensions)
}
commonTest.dependencies {
implementation(kotlin("test"))
implementation(libs.kotlinx.coroutines.test)
}
androidMain.dependencies {
implementation(libs.sqldelight.android.driver)
}
@@ -6,6 +6,8 @@ import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.SingleIn
import kotlinx.coroutines.flow.StateFlow
const val MAX_TRANSACTION_AMOUNT_CENTS = 100_000_000L
@SingleIn(AppScope::class)
@Inject
class Repository(private val store: LedgerStore) {
@@ -27,6 +29,7 @@ class Repository(private val store: LedgerStore) {
suspend fun createTransaction(accountId: String, type: TxnType, amount: Long, note: String): Transaction {
require(amount > 0) { "Amount must be greater than zero" }
require(amount <= MAX_TRANSACTION_AMOUNT_CENTS) { "Amount is too large (max \$1,000,000.00)" }
requireNotNull(getAccount(accountId)) { "Account not found" }
val txn = Transaction(
id = Platform.makeId(),
@@ -0,0 +1,66 @@
package app.folio.core.data
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertTrue
private class FakeLedgerStore : LedgerStore {
override val accounts = MutableStateFlow<List<Account>>(emptyList())
override val transactions = MutableStateFlow<List<Transaction>>(emptyList())
override val session = MutableStateFlow<Session?>(null)
override suspend fun accountExistsByName(name: String): Boolean =
accounts.value.any { it.name == name }
override suspend fun insertAccount(id: String, name: String, createdAt: Long) {
accounts.value = accounts.value + Account(id, name, createdAt)
}
override suspend fun insertTxn(
id: String,
accountId: String,
type: TxnType,
amount: Long,
note: String,
createdAt: Long,
) {
transactions.value = transactions.value + Transaction(id, accountId, type, amount, note, createdAt)
}
override suspend fun upsertSession(user: String, loggedInAt: Long) {
session.value = Session(user, loggedInAt)
}
override suspend fun clearSession() {
session.value = null
}
}
class RepositoryTest {
@Test
fun rejectsAmountAboveOneMillionDollars() = runTest {
val repository = Repository(FakeLedgerStore())
val account = repository.createAccount("Checking")
assertFailsWith<IllegalArgumentException> {
repository.createTransaction(account.id, TxnType.credit, 100_000_001L, "")
}
assertFailsWith<IllegalArgumentException> {
repository.createTransaction(account.id, TxnType.credit, Long.MAX_VALUE, "")
}
assertTrue(repository.transactions.value.isEmpty())
}
@Test
fun acceptsAmountAtOneMillionDollars() = runTest {
val repository = Repository(FakeLedgerStore())
val account = repository.createAccount("Checking")
repository.createTransaction(account.id, TxnType.credit, 100_000_000L, "rent")
assertEquals(listOf(100_000_000L), repository.transactions.value.map { it.amount })
}
}
+1
View File
@@ -14,6 +14,7 @@ sqlite-wasm = "3.53.0-build1"
[libraries]
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" }
kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" }
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" }
sqldelight-runtime = { module = "app.cash.sqldelight:runtime", version.ref = "sqldelight" }
+11
View File
@@ -78,6 +78,13 @@ _ensure-device:
echo "emulator did not finish booting in time (see /tmp/folio-emulator.log)" >&2
exit 1
# Run folio's own unit tests. Named test-unit because `test` is the fuzz run.
test-unit:
#!/usr/bin/env bash
set -euo pipefail
export ANDROID_HOME="$(just _android-home)"
./gradlew :core:testDebugUnitTest :app:shared:testDebugUnitTest
# Build the folio debug APK without installing it.
build:
#!/usr/bin/env bash
@@ -130,6 +137,10 @@ ios:
-destination 'platform=iOS Simulator,name={{ios_device}}' \
-derivedDataPath app/iosApp/build \
build | tail -5
# Installing over the top keeps the data container, and folio's signed-in
# session with it, so a run started straight after would open on the last
# run's Home screen instead of Login and diverge at step 1.
xcrun simctl uninstall booted app.folio || true
xcrun simctl install booted "{{ios_app}}"
xcrun simctl launch booted app.folio
+289 -25
View File
@@ -106,6 +106,21 @@ export function readHomeTotalBalance(args: {
//
// Callers pass null for a reading they could not take, so an empty list and an
// empty map are one case here rather than two.
//
// A non-empty list is trusted as current, and that rests on the app's shape
// rather than on anything in the frame. `fresh` also resets the submit window,
// so a card list drawn before the store caught up with a commit would bank
// stale counts, start the next window empty, and leave the rise arriving with
// no budget to cover it: a healthy app convicted of a double submit. Folio
// cannot serve that frame. AddTransactionViewModel.submit pops ONE entry, so a
// commit lands back on the ledger it came from and the first Home reading is a
// whole action and settle later. Two pops do reach Home, but two pops means two
// Submit events, which is the double submit itself, and a verdict there is
// late rather than wrong. Measured over four recorded android runs: 51
// commit-capable single taps landed on the ledger or the transaction screen and
// none on Home, 49 of 49 commits already showed their new balance in the frame
// read at the same step, and no rise ever arrived against an empty budget in
// 1303 steps. A submit that navigated straight to Home would reopen this.
export interface HomeCardReading<T> {
value: T | null;
carrier: T | null;
@@ -123,6 +138,51 @@ export function readHomeCards<T>(args: {
return { value: reading, carrier: reading, fresh: true };
}
// The balance of the ONE account the ledger and the add-transaction screen are
// showing, and the window it closes.
//
// It exists because the Home readings above close their window only when the
// walk goes back to Home, and a walk inside the transaction flow does not: a
// submit pops back to the ledger it came from, so the fuzzer can add
// transactions all day without Home ever being redrawn. The iOS run in #78 went
// 117 steps between two Home readings and accumulated 37 submits against a rise
// of 15 transactions, which is no evidence about any single action. Both
// screens here carry the account's own balance (LedgerBalance,
// TxnCurrentBalance), so the window between two readings holds one action.
//
// WHICH account is never asked, because inside a run of these two routes it
// cannot change. Route.Ledger is pushed only by tapping a card on Home,
// Route.AddTransaction only by the ledger's own button for its own account, and
// an accepted submit pops back to that same ledger. Reaching another account's
// ledger means passing through Home, and one action reads one frame, so a frame
// that is neither of these two routes always sits in between. Dropping the
// carrier on every such frame, transition frames included, is what makes the
// two compared numbers two readings of one account.
export interface AccountBalanceReading {
value: number | null;
carrier: number | null;
fresh: boolean;
}
function showsOneAccount(route: string | null): boolean {
return route === "ledger" || route === "add-transaction";
}
export function readAccountBalance(args: {
route: string | null;
balanceText: string | undefined;
previousCarrier: number | null;
}): AccountBalanceReading {
const { route, balanceText, previousCarrier } = args;
if (!showsOneAccount(route)) return { value: null, carrier: null, fresh: false };
const balance = parseAccountBalance(balanceText);
// Unreadable is unknown, not a new value: the balance node scrolls off the
// viewport like anything else. The account still cannot have changed, so the
// last number we read is carried across and the window stays open.
if (balance === null) return { value: previousCarrier, carrier: previousCarrier, fresh: false };
return { value: balance, carrier: balance, fresh: true };
}
// state.lastAction as the two hosts build it (internal/verifier/marshal.go
// lastActionFields), read defensively: every field is what a Go struct decided
// to emit, not something this file can trust a compile-time shape for.
@@ -136,6 +196,7 @@ export interface ObservedAction {
kind?: string;
on?: string | object;
applied?: true | null;
relaunched?: true | null;
}
function isTapOn(lastAction: ObservedAction | null, target: string): boolean {
@@ -171,6 +232,19 @@ export function confirmedApplied(lastAction: ObservedAction | null): boolean {
return lastAction != null && lastAction.applied === true;
}
// The runner reports this when its foreground guard had to relaunch the app
// after the action. The action still happened, so it still counts toward how
// many submits a window could hold; what nobody can promise across it is that
// the process survived long enough to commit, or that Home is showing the same
// slice of the account list it was.
//
// `true | null` for the same reason `applied` is: web and iOS cannot read the
// foreground at all, so "no relaunch reported" is not "the app never
// restarted", and only an explicit true licenses declining.
export function acrossRelaunch(lastAction: ObservedAction | null): boolean {
return lastAction != null && lastAction.relaunched === true;
}
// Counts the submit actions inside the window the balance property compares
// over: from the last Home total we read to this step, inclusive of this step's
// action.
@@ -190,16 +264,62 @@ export function confirmedApplied(lastAction: ObservedAction | null): boolean {
// well have landed. Leaving it out is what convicted a healthy app:
// committedTransactionsExceedSubmits saw a transaction rise of one against a
// window of zero and called it a double submit.
//
// A submit the app must have refused does not count, for the mirror reason: it
// cannot have committed anything, so the bound it would raise is slack the app
// can hide a real double submit behind. See submitCouldCommit for what "must
// have refused" is allowed to mean.
export function countSubmitsInWindow(args: {
previousCount: number;
lastAction: ObservedAction | null;
amountText?: string;
fresh: boolean;
}): { reported: number; next: number } {
const { previousCount, lastAction, fresh } = args;
const reported = previousCount + (isTxnSubmitTap(lastAction) ? 1 : 0);
// A relaunch is the one thing that can put a form state on screen other than
// the one the tap read, so the field it draws proves nothing about it.
const refused = !acrossRelaunch(lastAction) && !submitCouldCommit(args.amountText);
const reported = previousCount + (isTxnSubmitTap(lastAction) && !refused ? 1 : 0);
return { reported, next: fresh ? 0 : reported };
}
// Folio's own cap, in cents (core/data/Repository.kt).
const MAX_TRANSACTION_AMOUNT_CENTS = 100_000_000;
// Could the app have committed anything for that submit? The amount field as
// the LANDING frame shows it is the form state the tap read: the tap changes
// nothing about it, and one action runs per step, so nothing else could have.
// Off the transaction screen there is no field to read, and undefined is
// unknown, which counts.
//
// False only where Folio's own code must have refused. parseCents takes
// `^\d+(\.\d{1,2})?$` with commas stripped and refuses everything else, and
// AddTransactionViewModel refuses a parsed zero on top of that. An empty field
// never even reaches the parser: TxnSubmit is
// clickable(enabled = amount.isNotBlank()), so the click does not fire.
//
// This is the difference between a bound and a useless one. The window is an
// upper bound on the transactions the interval could hold, and a bound inflated
// by taps that commit nothing is a bound the app can never exceed: the iOS run
// in #78 read a rise of 15 transactions against a window of 37 submits and had
// nothing to say. Measured over four recorded android runs, 19, 11, 25 and 25
// of 35, 26, 42 and 42 submit taps landed with the amount field empty.
//
// An amount over Folio's cap is refused before any coroutine starts
// (MAX_TRANSACTION_AMOUNT_CENTS, checked in both AddTransactionViewModel.submit
// and Repository.createTransaction), and the fuzzer's corpus reaches the button
// with one: "999999999999999999999" passes AMOUNT_REGEX, so the field takes it.
// Float is precise enough to say which side of the cap an amount is on. The cap
// is 1e8, every integer cent up to 2^53 is exact, and an amount far enough above
// it to be inexact is far enough above it to be refused.
export function submitCouldCommit(amountText: string | undefined): boolean {
if (amountText === undefined) return true;
const trimmed = amountText.trim().replace(/,/g, "");
if (!/^\d+(\.\d{1,2})?$/.test(trimmed)) return false;
if (!/[1-9]/.test(trimmed)) return false;
return Number(trimmed) * 100 <= MAX_TRANSACTION_AMOUNT_CENTS;
}
// Parses formatCents output like "$5.00", "-$1,234.56", "+$0.50" back to
// integer cents. Anything that is not a complete amount is null, not 0: a
// balance we could not read is unknown, and reading it as zero silently moves
@@ -240,6 +360,15 @@ export function cardBalanceText(args: {
return match ? match[0].trim() : undefined;
}
// One account's own balance, off the node whose whole text it is: bare on the
// ledger ("$196.00"), labelled in the add-transaction header
// ("Balance: $196.00"). Anchored at the end for the same reason as above, so
// the label cannot be read as part of the amount.
export function parseAccountBalance(text: string | undefined): number | null {
const match = text?.match(TRAILING_BALANCE);
return match ? parseDollarCents(match[0].trim()) : null;
}
// The result is an identity key, not a display name: off web it is the
// AccountName text, on web it is whatever the merged card text leaves in front
// of the count label, initials and all ("T2Travel" for "Travel 2024"). Its only
@@ -259,6 +388,24 @@ export function cardAccountName(args: {
return head.slice(0, label.index).trim();
}
// The avatar text that opens a merged card's identity key, mirroring Folio's
// initialsOf (app/shared/.../util/Format.kt).
//
// A mirror because the alternative is a suffix test, and a suffix test cannot
// say which card a name belongs to. Drift can only cost a detection: the result
// is compared whole against a card's key, so initials that stop matching the
// app match no card rather than the wrong one.
export function initialsOf(name: string): string {
// Java's \s, which is what Kotlin's Regex("\\s+") compiles to. JS's \s also
// matches the unicode spaces, and would split names the app keeps whole.
const parts = name.trim().split(/[ \t\n\v\f\r]+/).filter(part => part !== "");
const first = parts[0];
const last = parts[parts.length - 1];
if (first === undefined || last === undefined) return "?";
if (parts.length === 1) return first.slice(0, 2).toUpperCase();
return (first.slice(0, 1) + last.slice(0, 1)).toUpperCase();
}
// One card's transaction count, in the strongest form its SOURCE supports. The
// two forms are the whole reason this is not just a number:
//
@@ -331,13 +478,26 @@ export function homeAccountsOf(cards: readonly CardReading[]): Account[] | null
//
// A name carried by more than one card is left out for the same reason, the
// rule createdAccountHasNonZeroBalance applies with `matches.length === 1`:
// nothing here can say which of them a count came from. Folio accepts the same
// account name twice and Home lists whatever fits the viewport, so a reading
// that saw one Travel card and a later one that saw two would otherwise
// subtract two DIFFERENT accounts' counts and convict a healthy app of
// double-submitting. The twin does not have to be readable to spoil the
// identity, so duplicates are counted over every card, not just the usable
// ones. Dropping a card can only ever cost a detection.
// nothing here can say which of them a count came from. Two accounts never
// share a NAME (Accounts.name is UNIQUE and Repository.createAccount rejects
// one already taken, NOCASE), but they can share a KEY, because web's key is
// the card text in front of the digit run, which is the name with any trailing
// digits shaved off it: "Travel1" holding 25 transactions and "Travel12"
// holding 6 both key to "TRTravel" and both read a three-digit run, so
// subtracting one from the other subtracts two unrelated counting series. The
// twin does not have to be readable to spoil the identity, so duplicates are
// counted over every card, not just the usable ones. Dropping a card can only
// ever cost a detection.
//
// What this cannot see is a twin that never shares a reading with its pair, and
// Home lists only what fits the viewport. Nothing computed from the card text
// can: the two cards' text is identical character for character ("TRTravel1"
// followed by "25 transactions" and "TRTravel12" followed by "6 transactions"
// are one string), so no key derived from it separates them. Refusing every run
// that could hide a name's own digits would, at the price of the evidence web
// convicts on today, whose measured witness is a count of 12 rising to 14.
// Separating them needs something the tree does not carry: the account's id on
// the card, or a separator in front of the count.
export function homeTxnCountsOf(cards: readonly CardReading[]): Record<string, TxnCount> | null {
const cardsPerName = new Map<string, number>();
for (const card of cards) cardsPerName.set(card.name, (cardsPerName.get(card.name) ?? 0) + 1);
@@ -381,15 +541,30 @@ export function createdAccountHasNonZeroBalance(args: {
// card to: the card that turned up may be an older account of the same name
// scrolling into view.
if (!confirmedApplied(lastAction)) return false;
// A relaunch draws Home from the top again, so the card that carries the
// typed name may be an older account of that name laid out where the new one
// used to be, and the create may not have reached sqlite at all.
if (acrossRelaunch(lastAction)) return false;
if (before === null || after === null) return false;
const typed = (args.typedName ?? "").trim();
if (typed === "") return false;
// Web merges the card into one node whose text opens with the avatar
// initials, so the identity key is "INInvestments" where android and iOS give
// "Investments"; endsWith covers both. Two cards answering to the same typed
// name (a second "Travel", or a card the tree exposed twice) leave the
// appearance unattributable, so nothing is judged.
const matches = after.filter(account => account.name.endsWith(typed));
// "Investments". Both forms are built from the name that was typed and
// compared whole. A suffix test covered both too, and it also let any OTHER
// account ending in those letters answer for the created one: type "Fund"
// next to an existing "Emergency Fund", have the new card clipped out of the
// reading the way Home clips any card, and the old account is convicted for
// money it has held all along. It cost detections as well, because a typed
// name that two cards end with is judged as unattributable rather than as the
// one card that carries it.
//
// Two cards answering to one key stay unattributable: Accounts.name is UNIQUE
// and Repository.createAccount rejects a name already taken, so that pair is
// a card the tree exposed twice, or two names the merged key cannot tell
// apart.
const mergedKey = initialsOf(typed) + typed;
const matches = after.filter(account => account.name === typed || account.name === mergedKey);
const created = matches.length === 1 ? matches[0] : undefined;
if (created === undefined) return false;
if (before.some(account => account.name === created.name)) return false;
@@ -431,6 +606,70 @@ export function committedTransactionsExceedSubmits(args: {
return committed > submitsInWindow;
}
// The same rule as above, measured in money over the account's own window: one
// submit action can commit one transaction, so the account's balance cannot
// move by more than the amount that submit typed.
//
// An UPPER BOUND, the same one submitChangesBalanceByAtMostTypedAmount applies
// to Home's total, and that is what makes a one-action window safe. A balance
// that has not moved is a commit still in flight (createTransaction runs in a
// coroutine), a submit the app rejected, or a tap that never landed, and none
// of those is evidence of anything; an equality would convict all three. Moving
// by MORE than one submit's worth is not something a correct app can do: only
// createTransaction moves this number, only a TxnSubmit tap reaches it, and the
// window holds exactly one such tap.
//
// What it can convict, and what it cannot. The double tap sends two Submit
// events, and where they land decides who judges them. Two commits and two pops
// reach Home, where this reads no balance at all and
// committedTransactionsExceedSubmits does the convicting: that is the shape the
// recorded iOS run at runs/folio-ios/20260815-102711 produced, three double taps
// out of three, all on Home. Two commits with the second pop cancelled by the
// first stop on the account's own ledger, and only this sees them. So this is
// not the check that fixed #78, and widening its route gate would not make it
// one: readAccountBalance drops the carrier off these two screens, so a Home
// landing has nothing to compare.
//
// It is not idle either. Over that same run it judged 18 of 240 steps against
// real readings, every one a submit landing back on the ledger with the balance
// moved by exactly what was typed. A commit for more than the amount typed, on
// any of those 18, had nowhere else to be caught: the total-balance form got
// past its own gates on one step in the whole run.
//
// A submit the runner could not confirm needs no case of its own: if it never
// landed the balance did not move, which is under the bound.
// countSubmitsInWindow counts it either way, so it cannot smuggle a second
// commit into a window that looks like one.
//
// typedAmount is the amount the app parsed for THIS submit (parseTypedAmount
// mirrors parseCents), so every rejected amount and every amount too large to
// hold exactly arrives here as 0 and is vacuous.
//
// The float guards are the ones submitChangesBalanceByAtMostTypedAmount explains:
// each balance and the typed amount lose precision on their own past
// Number.MAX_SAFE_INTEGER. Their difference needs none, because a difference
// that is really within a safe typedAmount is itself safe and comes out exact.
export function committedAmountExceedsOneSubmit(args: {
route: string | null;
lastAction: ObservedAction | null;
submitsInWindow: number;
typedAmount: number;
prevAccountBalance: number | null;
currAccountBalance: number | null;
}): boolean {
const { route, lastAction, submitsInWindow, typedAmount } = args;
const { prevAccountBalance, currAccountBalance } = args;
if (!showsOneAccount(route)) return false;
if (!isTxnSubmitTap(lastAction)) return false;
if (submitsInWindow !== 1) return false;
if (typedAmount <= 0) return false;
if (prevAccountBalance === null || currAccountBalance === null) return false;
if (!Number.isSafeInteger(prevAccountBalance)) return false;
if (!Number.isSafeInteger(currAccountBalance)) return false;
if (!Number.isSafeInteger(typedAmount)) return false;
return Math.abs(currAccountBalance - prevAccountBalance) > typedAmount;
}
// How far one account's count rose between two readings, or null when the pair
// is not comparable. Not comparable is not zero: the account drops out of the
// sum entirely, which can only cost a detection.
@@ -478,12 +717,27 @@ export function parseTypedAmount(text: string | undefined | null): number {
}
// When the last action is a tap (or double-tap) on the transaction Submit
// button, the absolute change in total balance must equal the amount the
// user typed. A double-submit lands two transactions and shifts the balance
// by 2x the typed amount, tripping this check. The route gate skips steps
// whose landing screen is not Home: totalBalance is only freshly read from
// Home's own TOTAL BALANCE node, so off-Home comparisons would read a stale
// carrier value and false-fire.
// button, the absolute change in total balance cannot EXCEED the amount the
// user typed. A double-submit lands two transactions and shifts the balance by
// 2x the typed amount, tripping this check. The route gate skips steps whose
// landing screen is not Home: totalBalance is only freshly read from Home's own
// TOTAL BALANCE node, so off-Home comparisons would read a stale carrier value
// and false-fire.
//
// A bound rather than the equality this used to be, and the same bound
// committedAmountExceedsOneSubmit applies to the account's own balance. The
// write finishes before AddTransactionViewModel navigates, but nothing
// establishes that Home's total has re-rendered before the frame is read: the
// store's flow re-emits on its own schedule. A total that has not caught up has
// not moved at all, and an equality convicts a healthy app for it.
//
// The cost is real and is not covered anywhere else in this spec: a balance
// that moves by LESS than the amount typed, a transaction silently dropped or
// committed for the wrong amount, is a bug this no longer judges. It cannot be
// told apart from a total one frame behind, and a check that fires on both is
// evidence about neither. What it keeps is the bug it exists for: every one of
// the four recorded android convictions is a 6400 move against 3200 typed, and
// 2x still exceeds x.
//
// submitsInWindow is what keeps the comparison honest. prevTotalBalance is the
// last total we READ, not the total as of the previous transaction, so the two
@@ -493,7 +747,7 @@ export function parseTypedAmount(text: string | undefined | null): number {
// evidence about the amount typed into any one submit, so anything other than
// exactly one submit action in the window is vacuous. Exactly one still catches
// the bug: the double-tap is a single action.
export function submitChangesBalanceByTypedAmount(args: {
export function submitChangesBalanceByAtMostTypedAmount(args: {
route: string | null;
lastAction: ObservedAction | null;
submitsInWindow: number;
@@ -506,10 +760,20 @@ export function submitChangesBalanceByTypedAmount(args: {
if (route !== "home") return true;
if (!isTxnSubmitTap(lastAction)) return true;
// The whole rule is that the delta belongs to THIS submit. A submit the
// runner could not confirm may have committed nothing, and a balance that
// did not move is then exactly what a healthy app looks like.
if (!confirmedApplied(lastAction)) return true;
// The two totals were read from two different processes. SqlLedgerStore
// starts each one on stateIn(Eagerly, emptyList()) and HomeScreen composes
// formatCents(total) off whatever the flow holds, so a restarted app draws
// $0.00 into TotalBalance until sqlite answers, and that number is as far
// from the last one as the accounts are rich. There is no submit anywhere
// that explains it.
//
// A submit the runner could not confirm needs no guard of its own: it may
// have committed nothing, and a balance that did not move is under any bound.
// countSubmitsInWindow counts it exactly like a confirmed one, so a total
// that moved by more than one typed amount is the same double commit either
// way. Under the equality this used to be, that case had to be excused; a
// guard for it here now only drops the convictions it exists to make.
if (acrossRelaunch(lastAction)) return true;
if (submitsInWindow !== 1) return true;
if (typedAmount === 0) return true;
// An unknown total on either side is not evidence of anything. Comparing one
@@ -520,7 +784,7 @@ export function submitChangesBalanceByTypedAmount(args: {
// transaction at whatever fits a Kotlin Long, so a balance of ~1e18 cents is
// one accepted amount away, and up there the gap between representable
// values is 128 cents: a real 1600-cent move reads back as something else
// entirely. The equality below is then false for a healthy single submit
// entirely. The comparison below is then false for a healthy single submit
// exactly as readily as for a double one, and a check that cannot pass is not
// a check that failed.
//
@@ -535,5 +799,5 @@ export function submitChangesBalanceByTypedAmount(args: {
if (!Number.isSafeInteger(prevTotalBalance)) return true;
if (!Number.isSafeInteger(currTotalBalance)) return true;
if (!Number.isSafeInteger(typedAmount)) return true;
return Math.abs(currTotalBalance - prevTotalBalance) === typedAmount;
return Math.abs(currTotalBalance - prevTotalBalance) <= typedAmount;
}
+92 -13
View File
@@ -17,6 +17,7 @@ import {
cardAccountName,
cardBalanceText,
cardTxnCount,
committedAmountExceedsOneSubmit,
committedTransactionsExceedSubmits,
countSubmitsInWindow,
createdAccountHasNonZeroBalance,
@@ -25,10 +26,11 @@ import {
oncePerFrame,
parseDollarCents,
parseTypedAmount,
readAccountBalance,
readHomeCards,
readHomeTotalBalance,
routeOfFrame,
submitChangesBalanceByTypedAmount,
submitChangesBalanceByAtMostTypedAmount,
} from "./predicates";
import type { Account, CardReading, TxnCount } from "./predicates";
@@ -102,6 +104,11 @@ const homeCards = oncePerFrame((s: State): CardReading[] =>
// the last-read Home total so `previous` and `current` stay on the same scale.
const homeTotalText = (s: State) => on("home", "TotalBalance")(s)?.text;
// The amount field as the frame a submit landed on shows it, which is the form
// state that submit read. Every window below asks, because a submit the app
// must have refused raises no bound: see submitCouldCommit.
const txnAmountText = oncePerFrame((s: State) => on("add-transaction", "TxnAmountField")(s)?.text);
let lastHomeTotal: number | null = null;
const totalBalance = extract<number | null>("totalBalance", s => {
const reading = readHomeTotalBalance({
@@ -127,6 +134,7 @@ const submitsInWindow = extract("submitsInWindow", s => {
const window = countSubmitsInWindow({
previousCount: submitsSinceHomeTotal,
lastAction: s.lastAction,
amountText: txnAmountText(s),
fresh,
});
submitsSinceHomeTotal = window.next;
@@ -175,12 +183,52 @@ const submitsSinceCounts = extract("submitsSinceCounts", s => {
const window = countSubmitsInWindow({
previousCount: submitsSinceHomeCards,
lastAction: s.lastAction,
amountText: txnAmountText(s),
fresh,
});
submitsSinceHomeCards = window.next;
return window.reported;
});
// The account's own balance, off whichever of its two screens is up. The routes
// are exclusive, so at most one of these resolves and the reading is always one
// account's number. Its carrier is dropped on every other route, which is what
// keeps two readings from spanning two accounts: see readAccountBalance.
const accountBalanceText = (s: State) =>
on("ledger", "LedgerBalance")(s)?.text ?? on("add-transaction", "TxnCurrentBalance")(s)?.text;
let lastAccountBalance: number | null = null;
const accountBalance = extract<number | null>("accountBalance", s => {
const reading = readAccountBalance({
route: routeOf(s),
balanceText: accountBalanceText(s),
previousCarrier: lastAccountBalance,
});
lastAccountBalance = reading.carrier;
return reading.value;
});
// A third window, for the same reason the counting invariant has its own: it
// closes on this reading's freshness, which is a different event again. The
// transaction flow redraws this balance on nearly every frame, so this window
// is the narrow one, usually a single action wide.
let submitsSinceAccountBalance = 0;
const submitsSinceBalance = extract("submitsSinceAccountBalance", s => {
const fresh = readAccountBalance({
route: routeOf(s),
balanceText: accountBalanceText(s),
previousCarrier: null,
}).fresh;
const window = countSubmitsInWindow({
previousCount: submitsSinceAccountBalance,
lastAction: s.lastAction,
amountText: txnAmountText(s),
fresh,
});
submitsSinceAccountBalance = window.next;
return window.reported;
});
const lastAction = extract("lastAction", s => s.lastAction);
const loginEmailField = extract("loginEmailField", on("login", "LoginEmail"));
@@ -210,13 +258,18 @@ const newAccountBalanceIsZero = always(
),
);
// Property 2: a tap on TxnSubmit must move the total balance by exactly the
// typed amount. A double-submit lands two transactions, so the balance shifts
// by twice the typed amount and the check fires. The route gate inside the
// Property 2: a tap on TxnSubmit cannot move the total balance by more than the
// typed amount. A double-submit lands two transactions, so the balance shifts by
// twice the typed amount and the check fires. The route gate inside the
// predicate skips off-Home landings where totalBalance.current is the carrier.
const submitMovesBalanceByTypedAmount = always(
//
// The name is the property's, and the gate keys on it, so it stays; what it
// demands is the bound, for the reason submitChangesBalanceByAtMostTypedAmount gives:
// a total that has not re-rendered yet has not moved, and an equality convicts a
// healthy app for a frame that has not caught up.
const submitMovesBalanceByAtMostTypedAmount = always(
next(() =>
submitChangesBalanceByTypedAmount({
submitChangesBalanceByAtMostTypedAmount({
route: route.current,
lastAction: lastAction.current,
submitsInWindow: submitsInWindow.current,
@@ -232,13 +285,39 @@ const submitMovesBalanceByTypedAmount = always(
// stays sound however wide the window between two Home readings gets, because
// both sides of the comparison accumulate over the same window. It is the
// double-submit stated directly: one tap, two rows.
//
// One rule, two windows, and they judge different frames rather than the same
// frame twice. The counting form compares two Home readings, which is where a
// double tap lands: submit() pops one entry per commit, so two commits walk the
// stack back past the ledger to Home. What used to defeat it there was the
// width of the window, not the window's screen, and what fixed it is
// submitCouldCommit refusing to count submits the app cannot have accepted.
// Replaying the iOS run at runs/folio-ios/20260815-102711 through this file
// measures it: the three double taps sit in windows of 2, 1 and 2 submits with
// that rule and 5, 4 and 7 without, and the run convicts 4 times with it and 0
// times without.
//
// The second form says the same thing in money about the one account whose
// screen the walk is on, and that window is usually a single action. It judges
// the frames the counting form cannot see between two Home visits, and catches
// the double submit whose second pop never ran: see
// committedAmountExceedsOneSubmit.
const submitCommitsOneTransactionPerAction = always(
next(() =>
!committedTransactionsExceedSubmits({
countsBefore: homeTxnCounts.previous ?? null,
countsAfter: homeTxnCounts.current,
submitsInWindow: submitsSinceCounts.current,
}),
next(
() =>
!committedTransactionsExceedSubmits({
countsBefore: homeTxnCounts.previous ?? null,
countsAfter: homeTxnCounts.current,
submitsInWindow: submitsSinceCounts.current,
}) &&
!committedAmountExceedsOneSubmit({
route: route.current,
lastAction: lastAction.current,
submitsInWindow: submitsSinceBalance.current,
typedAmount: parseTypedAmount(txnAmountField.previous?.text),
prevAccountBalance: accountBalance.previous ?? null,
currAccountBalance: accountBalance.current,
}),
),
);
@@ -298,7 +377,7 @@ const addTxn = whenRoute(route, ["home", "ledger", "add-transaction"], () => {
export const properties = {
newAccountBalanceIsZero,
submitMovesBalanceByTypedAmount,
submitMovesBalanceByAtMostTypedAmount,
submitCommitsOneTransactionPerAction,
};