- Release skill, step 2: quote the refusal cargo actually prints, the
same under cargo 1.94.1 and 1.99.1 (`cannot update the lock file ...
because --locked was passed to prevent this`, code 101).
- docs/architecture.md: the relay of --locked is proven for both runners
on a lockfile out of step with Cargo.toml: cargo-xwin 0.23.1 runs
`cargo build ... --locked ...`, and the other cargo calls of the Tauri
CLI and cargo-xwin are `cargo metadata --no-deps`, which never touch
the lockfile.
Resolves#449
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Release skill, step 2: right after the hand edit of Cargo.lock and
before the windows-sys guard, `cargo metadata --format-version 1
--locked` must exit 0, since the tag builds with -- --locked and the
release commit goes to main without a pull request. Step 0 runs cargo
check and cargo test with --locked, like CI, so the pre-flight cannot
fold a lockfile rewrite into the release commit.
- ADR 0023: the windows-sys guard reads the committed lockfile, cargo
check having already refused one it would have had to rewrite.
- CLAUDE.md, REVIEW.md and docs/architecture.md: CI commands with
--locked, release builds on the committed lockfile, cargo-xwin pinned
(REVIEW.md said it was installed unpinned), the stale REVIEW.md
sentence about #449 removed, and why the dormant .github mirror is
not aligned.
Resolves#449
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On desktop, tauri-plugin-dialog 2.6.0 replaces window.confirm with an async
function: confirm(...) returned a Promise, always truthy, so every guard
`if (!confirm(...)) return;` let the action run before the user answered.
Deleting a secondary profile removed its database even on "Cancel".
- confirmAction() (src/services/confirmService.ts) calls the plugin's ask()
with a warning icon and buttons labelled in the app language
(common.confirm / common.cancel); a failed call resolves false and is
logged (fail closed). @tauri-apps/plugin-dialog 2.6.0 added, the crate's
version.
- The six sites await it: profile, category, adjustment, budget template and
Balance category deletions, and both confirmations of the license key
removal (performRemoveLicense takes an async confirm).
- native-dialog-guard.test.ts fails on any native confirm/alert in src/ and
on any confirmation not awaited (TypeScript does not flag `!promise`).
- Tests: confirmService, the deletion of a profile in ProfileFormModal, and
the license removal tests with an async confirm (test: reproduces #421:
six of them were red on the old code).
Refs #421
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Follow-ups of the review of PR #418.
- check-latest-json.jq wants exactly one document and release.yml calls it
with -s: under jq 1.6, the release container's, an empty file passed the
check (no output, status 0) and two documents were judged on the last one,
so the job would have deleted the published manifest and uploaded the bad
one. Two tests cover both cases.
- build-latest-json.test.mjs runs the text of release.yml's "Publish
latest.json to package registry" step against a fake curl: DELETE then PUT
on a valid manifest, exit 1 and no request on a refused one.
- check-frontend no longer ignores .claude/** and release.yml (decision of
Max): the test reads verify-release.sh and runs the Publish step, so "on
every pull request" holds. REVIEW.md, CLAUDE.md and architecture.md follow.
- release.yml: stale ".deb fallback" comment fixed.
- qa-update-cycle.md: a tauri dev build on Linux shows TargetsNotFound,
expected since #418.
Refs #461Resolves#419
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Clears RUSTSEC-2026-0367, RUSTSEC-2026-0368 and RUSTSEC-2026-0369 on
zip 4.6.1, which turned the daily audit.yml red at run 558 (2026-10-17)
without any lockfile change. zip was compiled only for Windows, through
the default `zip` feature of tauri-plugin-updater 2.10.0. It is fixed
only in zip >= 9.0.0, and tauri-plugin-updater 2.14.0 still requires
zip ^4, so no cargo update can reach a fix.
That feature only unpacks archived update packages (.nsis.zip,
.msi.zip, .AppImage.tar.gz, produced by createUpdaterArtifacts
"v1Compatible"); this app ships createUpdaterArtifacts: true, whose
packages are never archives. The updater is now declared without its
default features, keeping rustls-tls. The lockfile loses zip, arbitrary
and derive_arbitrary (666 -> 663 packages, nothing added), and tar
leaves the Linux graph.
tar was the canary of the suppression guard in check-rust.yml. It is
replaced by webkit2gtk, Tauri's Linux webview, which keeps the property
that made tar useful: a dependency present on x86_64-unknown-linux-gnu
only, so finding it also proves that --target resolves conditional
edges. ADR 0018, architecture.md, qa-update-cycle.md and the release
skill are updated to match.
Verified locally: cargo-audit 0.22.2 exits 1 on the old lock with the
three IDs and 0 on the new one (663 crates, the same 12 allowed
warnings); the suppression guard is green, and red with the canary
swapped for tar or zip; windows-sys guard 9/9; cargo check and cargo
test (267 passed) on Linux; cargo xwin check for x86_64-pc-windows-msvc
is green on main (zip compiled) and on this branch (zip gone).
Refs #455
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Répartition (split) button of the Transactions page now belongs to
the `adjustments` feature, as the unlock screen already promised
(upsell.features.adjustments). On the Free edition the button keeps its
place and its icon, carries a lock and opens the locked screen in a
dialog instead of the split window, for every row, split or not. While
the license is not ready it shows no lock and does nothing. Existing
splits stay in the data, counted by the reports and flagged by the
orange icon.
- splitGate.ts: pure splitAccessFor(gate) -> open | locked | pending,
stricter than profileGate: `allowed` does not short-circuit `ready`.
- SplitButton: stateless button that decides what a click opens.
- UpsellDialog: UpsellGate in a named modal dialog, closed by its X and
by "I already have a key".
- TransactionTable: required splitAccess prop. The split window is not
rendered once the edition resolves to Free, and stays mounted through
a license refresh; the upsell is not rendered once it resolves to Base.
- TransactionsPage reads useEntitlement("adjustments").
- ADR 0017 amendment and matrix, user guide, in-app docs (FR, EN),
architecture, entitlements header comment, both changelogs.
Tests: the predicate, the button's markup and click, the dialog, the
table's wiring through the shared hook harness (#409), the page under
the three gate states, and the new key in both locales.
Fixes#369
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Guide and in-app docs name the five card pages, "Got it" on the two
visit cards and the Trends card that also checks the reports step;
architecture lists the new models and targets; the QA list gets the
steps 5 and 8 of the guided walk, the Free padlock variant and the
Trends click.
Resolves#432
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Guide and docs.gettingStarted / docs.dashboard keys (FR, EN): eight
steps, the two visit steps in the quick-start list, "Got it", 3/8 and
7/7, a finished path finds them done.
- ADR 0022: acknowledgeStep and initializeVisitSteps do not notify (§2),
ACKNOWLEDGE among the local actions (§4), the one-time initialization
admitted like rule D9 (§5).
- architecture.md: eight rows, the amended "never by a click" invariant,
the two new preferences, the provider's new action and initializer.
- QA list: step numbers moved to eight, five cases for the visit steps.
- CHANGELOG: Added entry in both files.
Resolves#425
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Import empty state now shows an "Expected structure" box in both of its
variants (no folder, folder without sources): the parent folder to choose,
one subfolder per account, and files at its root ignored, as
scan_import_folder reads them. New ImportFolderStructure component, a nested
list with decorative icons.
The getting-started cards ask for the parent folder and one subfolder per
account instead of one per bank. The preview card says that an expense must
count among the outflows and names "Flip the signs" next to "Check
duplicates": WIZARD_ROWS rows now carry every label their sentence names.
Every Import card sentence quotes the controls it names, in French and in
English, pinned by a test that reads the locale files.
Help tips, docs.import.steps, the user guide, the QA list and architecture.md
follow; CHANGELOG entry under Changed.
Resolves#422
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Written before the code, from spec-decisions-mcp-server.md and the
reviewed spec-plan-mcp-server.md (local, git-ignored). Status Proposed;
to pass Accepted when the spec-mcp-server milestone merges. Indexed in
docs/architecture.md.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- renderCategoryLabel.test.ts passes markedT itself, its assertions in
the marked form (`t(balance.category.cash[defaultValue=cash])`): the
second fake `t` of the repository is gone.
- i18n-mock-guard: a fake `t` is also caught as a binding (`t = (`,
`t = vi.fn(`) or as a `t: vi.fn(` property, and a file that mocks
react-i18next must call `i18nMock(`, comments dropped before the
search. Each new rule has its own case in the guard's tests; the
binding rule found renderCategoryLabel.test.ts before its migration.
- docs/architecture.md: the "only fake t" sentence is scoped to test
files and says the guard only refuses the forms it recognises.
Refs #389
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- useOnboardingChecklist.wiring.test.ts: a data event, then a route
change 100 ms later, still gets its deferred re-read (two LOAD_START).
Pins the follower's deps: with `pathname` among them, the cleanup drops
the pending timer and the test goes red (mutation M1 of the review). The
comment that claimed "one subscription only" now points to this test.
- hookHarness.test.ts: a dependency array that shrinks, or grows by
`undefined`, recomputes the memo (mutation M8, length branch of
depsChanged).
- NOT SIMULATED, in hookHarness.ts and docs/architecture.md: the dispatch
of a useReducer keeps the reducer of the first render, where React
applies the latest one.
Refs #409
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- close() on a getDb() handle runs as a "connection close" section and
checks its generation: a handle kept from a previous profile rejects
with DbConnectionClosedError instead of closing the current profile.
- closeUnlocked never rejects: the state is cleared and the generation
bumped before close(), a failing close of the old pool is logged with
logWarn, connectToProfile still opens the next profile and closeDb()
resolves without a connection.
- The blocking alert names a getDb() call in a callback as a possible
cause only: a long restore also waits 15 s.
- The generation check of the transaction handle is documented as a
defence, not reachable today.
- Tests: the "behind the queue" alert (pins the reset of lockHolder),
close() on a stale and on the current handle, a failing close during a
switch and in closeDb().
Refs #408
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- ProfilePreparation.catch_up_backup carries the file name of the copy as
soon as it exists, so the Journal error line of a catch-up that stops
afterwards (history_write_failed, pragma_failed, apply_pending) names it.
- Error lines and the JS fallback message keep only the last segment of a
path (withoutFolders): no error line contains a slash or a backslash.
- The copy is taken again at every catch-up, written as .tmp, synced and
renamed over a same-name copy; after a successful catch-up only, the
other copies of that profile are pruned (remove_backups keep argument).
- Pins: a pre-existing foreign key violation in the profile without
category 31 (RV4, RV7) and the Journal line that reports it (RT2).
- Ignored test measuring prepare_at on 100 000 transactions at floor 6:
306 ms in release, so STARTUP_TIMEOUT_MS stays 10 s (ADR 0021).
- Guide, docs.profiles.tips, ADR 0021, architecture and changelogs.
Resolves#395
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
src/__integration__/i18n-mock-guard.test.ts reads every test file of src
and fails on a useTranslation: or t: ( property (word-bounded) outside
src/test/i18nMock.ts, or on a react-i18next mock that does not go
through i18nMock. docs/architecture.md gets the convention: never a raw
t, always i18nMock.
Resolves#389
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Strict lock, transaction handle, single section for a profile switch,
connection generation and blocking alert.
Resolves#408
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review follow-up of PR #417. CLAUDE.md and docs/architecture.md said
nothing re-evaluates `npm audit` in CI. No job fails on it, but the
`npm ci` step of check-frontend audits on every run and prints the count
(run 520: found 0 vulnerabilities; runs 512 and 519: 8), and the
Defenseur reports advisories on its side, which is where #358 came from.
Refs #358
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Max's decision on PR #416, after its review measured who still read the
key. tauri-plugin-updater looks up "{os}-{arch}-{installer}" first, and
every version that ships the updater (v0.2.1 and later, plugin 2.10.0,
tauri-utils 2.8.2 in each tag's Cargo.lock) carries its bundle-type
marker, so .deb and .rpm installs never reach the generic key. Its last
readers were the AppImage installs v0.4.3 to v0.4.5 (v0.4.2 targets a
route that answers 404, earlier versions read GitHub) and binaries run
outside a package; none of them can install the .deb it served, and the
updater wrote that .deb over the running file. They now get
TargetsNotFound when checking: an error on the Updates card, nothing
installed.
The key leaves the script, scripts/check-latest-json.jq and
verify-release.sh together; the test compares the three lists. Docs fix
the readers named by #416 (v0.4.2 is not one) and the CHANGELOG entry
says what changes for those AppImage installs.
Refs #320
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review follow-ups of PR #416. The check that release.yml runs before
deleting the published manifest moves from an inline jq program to
scripts/check-latest-json.jq, which release.yml applies with
`jq -e --arg version ... -f`. scripts/build-latest-json.test.mjs now runs
that same filter on every pull request: on the script's output, on a
wrong version, on nine broken manifests and on the shape published up to
v0.17.1. It also compares the key lists of the script, of the filter and
of verify-release.sh, so a list left behind shows before the tag.
Two new cases pin the version argument: 0.18.0 must come out as 0.18.0
(the other fixtures pass 0.17.1, the version the bundle names carry, so a
script that ignored its argument passed them all), and an empty version
must fail. Both mutations survived the review's pass.
No change to what the manifest holds: still four keys.
Refs #320
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
npm audit reported 8 vulnerabilities (5 high, 3 moderate) on the lock
of main: react-router and react-router-dom (one advisory,
GHSA-qwww-vcr4-c8h2), vitest and @vitest/mocker (one advisory,
GHSA-82fw-gwwq-j7x9), browserslist, baseline-browser-mapping, nanoid
and source-map-js. Every fix sits within the range package.json
already declares, so only the lock moves; package.json is untouched.
Lock generated with npm 11.21.0 exactly:
npx --yes npm@11.21.0 update react-router-dom react-router vitest \
@vitest/mocker browserslist baseline-browser-mapping nanoid \
source-map-js --package-lock-only --ignore-scripts
259 entries before and after, 20 changed, each resolved from
registry.npmjs.org with a new sha512 integrity. npm ci, under npm 10
and under npm 11.19.0, leaves the lock as is; npm audit: 0.
Only react-router 7.18.1 -> 7.18.4 changes the built app: a build from
a lock where only react-router moved equals the full build byte for
byte. Since 7.18.3, useNavigate() and <Navigate> reject a navigation
to another origin; the app only navigates to internal paths.
CLAUDE.md and docs/architecture.md no longer describe 2 high
advisories accepted for good: npm audit is at 0 on 2026-10-10, a
dated reading that nothing re-runs in CI.
Resolves#358Resolves#317
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The fallback key is also read by the AppImage installs of v0.4.2 to
v0.4.5 (bundled until v0.7.3, published on Forgejo for v0.4.2-v0.4.3
and on the GitHub mirror for v0.4.3-v0.4.5, all reading the registry),
which look up linux-x86_64-appimage, absent, then linux-x86_64. Like a
marker-less binary, they cannot install the .deb it serves: the updater
writes its bytes over the running AppImage. State since v0.4.2, not
changed by this PR; the key stays as decided.
Also quote the error the update card really shows for a refused
package (invalid updater binary format).
Refs #320
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
latest.json only had linux-x86_64, built from the .deb. An .rpm install
looks up linux-x86_64-rpm first, then fell back on linux-x86_64,
downloaded the .deb and refused it (InvalidUpdaterFormat).
- scripts/build-latest-json.sh writes linux-x86_64-deb,
linux-x86_64-rpm, linux-x86_64 (the .deb, as a fallback) and
windows-x86_64 from the release assets. It fails unless it finds
exactly one .deb, one .rpm and one -setup.exe, each with a non-empty
.sig. The JSON comes from jq alone, notes read with --rawfile, file
names encoded with @uri like the upload step.
- scripts/build-latest-json.test.mjs (vitest) runs it under
bash -e -o pipefail on a temporary assets dir; check-frontend
installs jq.
- release.yml calls the script, writes the notes output under a random
delimiter, checks the manifest with jq before the registry DELETE,
and names the .rpm instead of an AppImage in the release notes.
- verify-release.sh requires the four keys, the extension of each URL
and the minisign file: of each signature, and exits 1 on any failure.
- Docs: release skill, qa-update-cycle section 3 (rpm procedure, keys,
reason and removal condition of the fallback), architecture, README,
CHANGELOG, STATE.
Resolves#320
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Seven crates in Cargo.lock accept windows-sys through a wide bound
(colored, dirs-sys, errno, rustix, rustls-platform-verifier, tempfile,
winapi-util), so cargo may point them at any windows-sys version already in
the graph. All seven point at 0.61.2, the state every Windows release has
shipped with since v0.14.0. Some updates re-resolve them (cargo update -p
hyper, -p h2, -p rustls-webpki -p tar did) down to 0.48/0.52/0.59 while
cargo only reports the packages it bumped: the Windows build then compiles
five windows-sys versions instead of three, and nothing says so.
A new blocking step, "Verify windows-sys pinning", reads the lockfile with
awk right after the suppression guard and fails if any edge of the seven
crates leaves 0.61.2 (a bare edge only passes when the lockfile holds a
single windows-sys, at 0.61.2), if a guarded crate has no block, or if the
set of windows-sys versions changes. awk's exit code is tested apart from
its output, every check is logged with a summary line, and a canary
(tempfile) must be read and checked by the loop.
The step text was extracted from the YAML and run as is, with the runner's
bash options, on copies of the lockfile: main's and the five release tags'
locks pass (9 checks, exit 0); the hyper-bumped lock, an added
windows-sys 0.53.0, a renamed guarded crate, a missing lockfile and a
guarded list without the canary all exit 1.
ADR 0023 records why 0.61.2, the measured cost (3 versions compiled for
Windows instead of 5), the triggers, the exit condition (windows-sys 0.62,
or convergence on a single version) and the repair when the step fails.
CLAUDE.md and docs/architecture.md cite the guard next to the suppression
check. Cargo.lock and .cargo/audit.toml are untouched.
Resolves#345
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Node 20 reached end of life on 2026-04-30. check-frontend.yml, audit.yml
and release.yml now install Node.js 24 through NodeSource's setup_24.x,
as check-rust.yml has done since #375. Each step still prints
`node --version` in the job log.
The concurrency comment of check-rust.yml claimed the key cancels
obsolete runs on force-push. la-compagnie-maximus docs/forgejo-ops.md
measured the opposite on Forgejo 8.0.3: no pull_request run was
cancelled by a later one, and push runs are cancelled without the key.
The comment now states that measurement; the keys stay (harmless,
meaningful where honoured), and check-frontend.yml and audit.yml point
to it.
release.yml names the trust it places in NodeSource: the script runs as
root in the job container, whose Node.js then runs `npx tauri build`
with TAURI_SIGNING_PRIVATE_KEY. Kept on purpose, also documented in
docs/architecture.md, whose release.yml section described two jobs
(windows-latest, ubuntu-22.04) where there is one containerised job
cross-compiling Windows with cargo-xwin.
release.yml only runs on a tag, so Node 24 there is proven at the next
release: the /release skill now reads `node --version` from the release
run's log (step 9), and STATE.md lists it under the checks due at the
next release.
No CHANGELOG entry: CI only.
Resolves#376
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Guide (FR, EN, in-app keys and `.md`) and CHANGELOG: the Import card
never says "Done." for an import that added no transaction, and the
step waits for a file that brought one; a Fixed entry, since v0.17.0
already ticked the step after such an import.
- CHANGELOG: the Free edition shows the Budget step locked, it does not
lack one.
- architecture.md: the import counter's query, and D9's flag read
outside any transaction, its log line written after the COMMIT.
- ADR 0022: `applyMigration` and `deleteTransaction` named among the
writes that do not notify, with their reason.
- QA list (44 cases): the noop import at rest, the focus ring on the
highlighted control, "Skip" on the folder step without a folder, the
Transactions and Budget cards on a profile without transactions, the
focus after "Hide" and "Close", the doubled counter of the unfolded
list, and a month-agnostic note on the off-screen cell.
Reviews of #402 to #407.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- architecture.md: the Onboarding section describes the guided path as
delivered (provider and useOnboarding(), the data-change channel of
ADR 0022, the three onboarding preferences, rule D9, the pure models of
onboardingPath.ts, the banner, the card latch and CARD_PAGES, the step
cards, the highlight by props and CSS); the utils count and the
user_preferences row follow the stack.
- qa-onboarding.html: the phase 1 cases that still hold, without the
compact bar ones, and four guided-path groups, including the manual
checks the five links left for a release build (WebKitGTK and WebView2,
Orca and NVDA, reduced motion, keyboard, Free edition, rule D9).
- CHANGELOG (EN and FR): one entry for what every step card does, one per
page, the path hidden on active profiles covers the banner too, the
skipped state, and the guide's new sub-section.
- STATE.md: manual checks due on the next release.
Resolves#401
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Added: the getting-started card of the Transactions page and the guided
entry its step link opens. architecture.md gets the lines this change
touches: useTransactions' start filters and single fallback, and the
i18next `lowercase` formatter in the Internationalisation section.
Resolves#399
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Added: the getting-started card of the Import page. Fixed: the "No import
folder" empty state no longer flashes while the folder is read.
architecture.md gets the lines this change made stale: the component
count, the onboarding folder, useImportWizard's `importFolderLoaded` and
the Import row of the empty states table.
Resolves#398
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CHANGELOG (EN and FR): the banner under Added; the compact bar replaced
by the banner, the states read out by the list, and the re-show button
of Settings → Users under Changed. docs/architecture.md: the lines this
change made false (component count, the onboarding/ folder, the compact
mode and the re-show sentences). The user guide is rewritten by the
last issue of the milestone.
Resolves#397
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ADR 0022 records the channel: its scope, the writes that notify and when,
the 150 ms wait on the subscriber side, stale answers ignored, and who may
subscribe. architecture.md gets its row in the ADR table, plus the rows this
change made wrong (onboardingService, useOnboardingChecklist, the new
dataEvents service, the contexts folder). The changelogs record rule D9: a
profile that already had transactions hides the getting-started checklist
the first time it is opened with this version.
Resolves#396
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ADR 0021 records the catch-up rules and amends ADR 0003 and 0005, whose
claims about secondary profiles were false. architecture.md, CLAUDE.md
and the migration rule describe the two migration paths; the guide tells
where the pre-update copy lives and how to remove a database a deletion
on Linux left behind.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A data directory written by v0.15.0 or earlier can hold a valid key next
to the activation token of another license, typically a refunded hence
revoked one. The License card read "Activated", market prices were
refused, and "Activate" was never offered: the folder could not recover
without a manual file removal.
- Prices: a 403 license_revoked drops the refused token, and only while
the file still holds it, so an activation that lands while the refused
request is in flight keeps its new token (CWE-367). The key's revoked
state is never written from this route. The token is read through
ACTIVATION_FILE by the directory-based read_stored_activation_token_in,
in fetch_price_in, testable without AppHandle.
- Machine list: list_activated_machines returns { machines,
token_dropped }. A complete list that names this machine under neither
identifier drops a token that activates this machine, when the OS id is
readable and the token is the one on disk when the list was asked for.
An error, a revoked key or a rate limit drops nothing.
machines_from_verify counts the rows it skips, which the list still
tolerates. This also gives "Activate" back to a machine deactivated from
another computer.
- Front: the activation status is read again after every list load; a
fixed log line (ACTIVATION_TOKEN_LOG) is written on token_dropped and on
a price refused as revoked; the revoked-license price message invites to
activate the machine again.
- Docs: architecture, ADR 0017 (#365 amendment), ADR 0020 (rule 4), user
guide and in-app tips, changelogs.
Resolves#367
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
"Remove the key" on the License card removes license.key, then the
activation token, and keeps license.check, so a revoked key removed then
entered again stays Free. Removing the key stops the revocation check,
which until now only storing another key could stop.
When this machine is activated with a valid key, its slot is freed on
the license server first (remove_license with release_slot). A release
that fails removes nothing; on network, server, rate_limit or
invalid_response the card asks whether to remove the key anyway, the
slot then staying occupied. no_active_activation, license_not_found and
license_revoked (recorded for the key sent) mean the slot is free.
- Rust: remove_license / remove_license_in, release_this_machine_slot,
this_machine_token_id, and the shared remove_activation_token_in
helper, now also used by store_license_in and the deactivation.
- Front: removeLicense service; performRemoveLicense in
useLicenseMachines (confirmations by state, error table, in-app log
lines); removing and removeError in the machines reducer, which lock
key entry, Activate and Deactivate; the button for any stored key,
valid or revoked.
- Docs: user guide, in-app tips, architecture, ADR 0017 amendment,
changelogs.
Resolves#370
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A license key entered during a session was only checked against the
license server at the next start: a revoked key kept its edition until
the app restarted, or until a machine activation was refused (#365
checked at start only).
Front: performSubmitKey launches the revocation check after
VALIDATE_DONE, without waiting for it, and never after a refused key.
SubmitKeyDeps gets a single launcher, startRevocationCheck(wasRevoked),
built once by the provider with revocationCheckLauncher and shared with
the start check, whose starter is unchanged.
Rust: the command runs the checks one at a time. A static
tauri::async_runtime::Mutex (REVOCATION_CHECK_TURN) is held by
check_revocation_in_turn over the whole check, request and verdict
included, and the time is read once the turn has come. A key entered
before the first ready state, checked by the entry and by the start,
sends one request; the check of a key entered while another one is in
flight waits for its verdict instead of overwriting license.check.
check_revocation_in is unchanged, and so are Cargo.toml and Cargo.lock.
Docs: ADR 0017 (amendment #365, point 5), user guide, in-app docs
(docs.settings.tips, docs.editions.tips), architecture, and both
changelogs under [Unreleased] (Fixed / Corrigé).
Resolves#373
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Closes phase 1 of the onboarding wave (#351-#356) on the documentation side.
- CHANGELOG.md / CHANGELOG.fr.md: Added (guided empty states, getting-started
checklist, sample folder, bank CSV export guide) and Changed (re-show button
in Settings > Users, Import "?" popover linking to the guide) under
[Unreleased] / [Non publié], same entries in both languages.
- docs/architecture.md: create_sample_import_folders (fs_commands 6 -> 7,
total 36 -> 37) with its contract, onboardingService, useOnboardingChecklist,
the onboarding_checklist_hidden preference, the samples/ corpus next to csv/,
and an "Onboarding" section (four empty states and their signal, source of
truth of the six checklist rows, sample-folder flow). Tree counts measured
for the directories the wave touched.
- docs/guide-utilisateur.md chapters 3 and 11 + docs.dashboard.* and
docs.settings.* keys (FR/EN). Hand-off from #355: docs.gettingStarted.steps
aligned with the six-step quick start of the markdown, docs.import.steps
mentions the sample folder, so the three guide copies agree.
- docs/qa-onboarding.html: interactive manual-test checklist, French only,
static, no network dependency, state in localStorage, plain-text report to
the clipboard with a visible fallback, reset button.
Resolves#357
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Applies the three suggestions of the review of #375:
- The runner's cleanup keeps one entry per key, whatever its scope: a PR
run that saves under the shared entry's key evicts it for every PR until
the next Monday, and two PRs on the same non-main lockfile evict each
other. Added to the list of cold runs.
- The cache key also hashes the job's CARGO*/RUST*/CC*/CFLAGS*/CXX*/CMAKE*
variables, and ignores the app's own version: changing env: means a cold
run until Monday, a release does not.
- A Monday restore that fails with a 404 after a quiet week is a known
runner race (forgejo/runner#1741), not #234 coming back.
Refs #234
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CLAUDE.md and docs/architecture.md still said the Actions cache was
removed until #234 was fixed. They now describe rust-cache, the per-PR
isolation of what a pull_request run saves, the weekly run on main that
writes the shared cache, the expected cold runs, and why check-frontend
has no npm cache. The audit.yml paragraph no longer quotes the outdated
"1 PR in 40" figure.
Refs #234
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- A state file that cannot be written now has a test on both of its paths:
the attempt (nothing is sent, or a read-only data directory would send a
request at every start) and the verdict (the key is not reported revoked
when nothing on disk says so). The first path was correct but unguarded:
the review's mutation left all 185 tests green.
- The storage outcome no longer claims that nothing was sent: it also covers
a verdict that could not be written. Reworded in licenseService.ts, the
in-app log line and docs/architecture.md.
- The Updates card names a revoked key in its Free branch, between the
message and the CTAs: a refunded Base buyer first meets the loss of
automatic updates there (Max, 2026-10-03). The error page stays as it is.
- A revoked key keeps being checked once per 24 h while it stays stored, so
that a correction made on the license server is picked up (Max,
2026-10-03). Declared in the user guide, the in-app docs, the changelogs
and the ADR 0017 amendment.
Refs #365
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A refunded license was revoked on the server but kept working in the app:
the edition is resolved offline from the key alone, and the flag added by
#347 lasted the session only and never touched the edition.
Rust
- license.check, next to the key, holds what the license server last said
about it: {key_sha256, attempted_at, revoked}. Never the key itself. A
missing, unreadable or invalid file, or a state about another key, reads
as "no state": not revoked, check due.
- entitlements_from_dir resolves a revoked key to ("free", []), which also
locks auto-update. read_license and store_license report the state.
- check_license_revocation: POST /v1/licenses/verify with the product and
the key only. Nothing is sent without a stored key that validates, nor
within 24 h of the last attempt, which is written before the request.
Fail-open: only "valid" lifts the state, only "revoked" sets it.
- Activation, machine list and deactivation record a license_revoked answer
under the key they sent.
- fetch_price reads error.code of a 403 and names license_revoked instead of
premium_required. It records nothing: the price route judges the license
of the activation token, which the client cannot tie to the stored key,
and 0.15.0 left the token of a previous key next to a new one (#367).
Front
- LicenseContext takes the revoked state from Rust (info.revoked); the
session flag and markRevoked are gone. The check starts once per app
start, writes its outcome to the in-app log, and reloads the context only
when the state changed. A revoked key entered again stays Free.
- UpsellGate and the License card name the revocation.
Docs: ADR 0017 amendment (D13 replaced, the first unsolicited request and
its bounds), architecture, user guide, price contract annex, changelogs.
Refs #365
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The review approved the previous head with five suggestions; this commit
applies the four that touch the repository (the fifth is the manual check
in the pull request description).
- Tests: a valid token issued for another machine goes with any row of
this machine. Two rows added to the deactivation table: the reviewer's
mutation that kept such a token stayed green before them.
- ADR 0020, removal condition: counted from the last raw-claim token the
server issued, not from a client version. A Free 0.15.0 no longer
updates and can still activate with the raw id months from now.
- ADR 0020: names what the transition leaves behind when a machine is
activated again while its raw row is still active (a second row, one
more slot), and what a public key does not cover (whoever already holds
the OS id can recompute the derived one).
- "No command hands the OS id to the webview" was wider than the code: the
server's list still carries it for a row from before the derivation.
Bounded in the ADR, architecture.md and the licenseService.ts comment.
No behaviour change. Rust 168, vitest 1314.
Refs #350
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Activation sent `machine_uid::get()` as is: /etc/machine-id on Linux,
MachineGuid on Windows. machine-id(5) calls that id confidential and asks
applications to send a keyed hash under a fixed, application-specific key
instead. The license server stores what it receives next to the license
email and the host name, lists it back and signs it into the activation
token.
- derive_machine_id: HMAC-SHA256 under a frozen key, 64 lowercase hex
characters, pinned by two reference vectors. An empty OS id is refused,
never hashed.
- Activation sends the derived identifier and requires the issued token to
carry it. Nothing else originates a machine identifier.
- A machine activated before this change stays activated: a valid token
whose claim is the raw id of this machine still reads "Activated" (D1).
- The machine list flags `is_this_machine` in Rust, for a row under either
identifier (D3). `ActivationStatus` no longer carries an identifier and
the unused `get_machine_id` command is removed: no command hands the OS
id to the webview any more.
- After a deactivation the local token is dropped when it stood for the
freed slot: any row of this machine takes it, except the old raw row
while the token stands for the derived one.
- ADR 0020 records the derivation, the transition and the condition for
removing the raw-id compatibility.
Behaviour changes beyond the issue text: the machine list and a
deactivation no longer fail on an unreadable OS id (best effort), the
activation status then reads "not activated" instead of failing, and the
unused `store_activation_token` command refuses a token issued for the
raw id.
No new dependency (hmac and sha2 were already declared); Cargo.lock is
untouched. Rust 151 -> 168 tests, vitest 1311 -> 1314.
Refs #350
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- store_license_in drops activation.token when the stored key changes.
The token names the previous license to the price server (its sub is
the license id, the key carries the email), so the card showed the
machine as activated while prices were refused for the old license.
The same key entered again keeps its token; a refused key changes
nothing.
- get_activation_status goes through activation_status_in(dir,
machine_id, key), tested in a temporary directory: a missing,
expired, foreign-machine, foreign-key or unreadable token reads as
not activated. It alone decides "Not activated" and "Activate".
- LicenseCard is keyed by licenseIdentity(info): another key remounts
the body, which re-reads the activation and drops the previous key's
machine list.
- invalidSignature asks to check the copy first. jsonwebtoken checks
the signature before decoding the payload, so a damaged copy often
reads as a signature failure (characterization test). User guide and
changelogs aligned.
- Render tests: the other errors stay visible under Machines, and the
edition's green check gives way to an alert for a revoked key.
Refs #347
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Rust (license_commands.rs)
- LicenseError gains InvalidPrefix, InvalidSignature, Malformed and
UnknownEdition. validate_with_key returns it (ExpiredSignature maps
to LicenseExpired), and store_license, read_license and
validate_license_key reject with the same JSON as the network
commands. store_license_in and read_license_in take a directory, so
the Storage mapping is tested at the source.
- D12: the edition comes from the license key alone.
resolve_license_entitlements no longer takes the activation token,
and current_entitlements reads only license.key
(entitlements_from_dir). A token that had expired (2-year server
lifetime) or came from another machine (restored data) used to drop
a valid key to free, while a key copied without its token kept its
edition. get_activation_status and persist_activation_token keep the
strict token validation, and the key keeps its strict validation
(CWE-613).
Front
- LicenseCard names the load error, and LicenseKeyForm (extracted so a
static render reaches it) names a refused key, through
parseLicenseError: no raw backend string reaches the card.
- D13: a license_revoked answer is recorded in LicenseContext for the
whole session, because the card unmounts when the user leaves
Settings > Users. Only a different key clears it. Card-level
message, alert icon, Activate disabled through selectCanActivate.
- Machine counter n/limit, "Not activated", activating and
deactivating labels, dates in fr-CA/en-CA, and "No expiry date"
beyond 50 years (licenseExpiry.ts, for the 100-year keys of
maximus-api#63).
- license.removeLicense and license.machines.activateError removed;
the price authFailed message points to activating the machine.
Tests: 15 new Rust tests (resolution from a data directory, named
variants, the 100-year key of #363, a key without exp) replace 3
machine-binding tests; vitest covers the card and form renders, the
context revocation, the hook wiring and the locales.
test: reproduces #347 (an expired token of this machine resolved a
valid Base key to free on 5d549d6).
Docs: CHANGELOG FR/EN, guide section 12 and in-app docs,
architecture.md, ADR 0017 amendment for #347, qa-update-cycle.md,
api-contract-prices.md.
Fixes#347Fixes#360
Refs #363
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- upsellCtaFor(tier) decides what a locked screen offers: Base is
purchasable, Premium stays disabled with "Premium isn't sold online
yet". Pure and tested on the whole entitlements matrix.
- Shared CTAs, one component per file: PurchaseButton ("Get Base") and
DownloadLatestButton open the localized product page (purchaseUrl) and
need no router; HaveKeyButton renders only inside BrowserRouter.
- UpsellGate uses them; the purchase link never calls onNavigate, so
modal hosts stay open. ProfileFormModal reuses UpsellGate instead of
its compact panel, keeps its Cancel button, and closes after "I
already have a key" (onNavigate={onClose}).
- In Free, the Updates card offers Get Base, I already have a key and
Download the latest version; the error page (outside the router)
offers Get Base and the download link. No update check, no network
call.
- i18n FR/EN: Account card text, Premium note, Updates notEntitled text,
download label, editions guide step and tip; the "coming soon" note
key is gone. Tests pin the upsell key set and the decided texts.
- Docs: ADR 0017 amendment, architecture (UpsellGate row, shared CTAs,
upsellCtaFor, multi-profile row, counts), user guide section 12;
CHANGELOG FR/EN.
Proven red: Premium made purchasable 2, balance sold as Base in the
matrix 4, the old note key back in a locale 1, Account text altered 1,
download label altered 1.
Fixes#346
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- Activation, deactivation and verify POST to /v1/licenses/* with
"product":"simpl-resultat" instead of the legacy /licenses/* paths,
which cost a 308 on every call.
- New LicenseError enum, serialized as {code} like FetchPriceError and
read from error.code of the /v1 envelope. A 200 {valid:false} from
verify is now a coded refusal instead of an empty machine list, and an
HTML error page on a non-2xx maps to server instead of
invalid_response. Deactivation is idempotent: no_active_activation
counts as done.
- The three calls live in *_inner functions without AppHandle, tested
against a mockito server that only declares the /v1 routes.
- useLicenseMachines hook with an exported reducer. The activation error
survives the list load it triggers (machine_limit_reached opens the
list), a deactivation removes the machine locally without re-running
verify (4 requests on the machine-limit path, under the server's
5/min/IP limit), and refresh() follows every successful activation or
deactivation.
- license.errors.* in FR and EN; purchaseUrl() opens
/{fr|en}/produits/simpl-resultat.
test: reproduces #270. Each original defect, reintroduced on the final
tree, turns tests red: legacy activate path 5, missing product 2,
envelope read as a string 3, verify valid:false as an empty list 2,
strict body parse on a non-2xx 2, untrimmed key 1, list load clearing
the activation error 4, verify re-run after deactivation 2, empty state
despite a list error 1, no refresh after activation 3, old purchase
URL 4.
Fixes#270
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Last link of the ten-link import-format stack. Links 1-9 deliberately wrote
no changelog and no documentation, to avoid a conflict at every level of a
linear pile; this link owes all of it.
- ADR 0019 records the structuring decision: the import format is a fully
persisted value, never re-inferred. It documents the three independent paths
by which it used to be lost (hardcoded restore, header drift, data export),
and why a single codec with a completeness test closes the class rather than
a composed type -- the two carriers are structurally incompatible
(`has_header` is boolean on one and number on the other). `template_id` is
recorded as a provenance label, never re-read as format.
- `docs/architecture.md` gains a dedicated "Import CSV" section covering the
codec, the lexical detection layer and its separate dictionary module, the
bank signatures, the now-mandatory preview step, and sources/templates in
the SREF envelope. Migration v17 and its four CHECK-guarded columns are
listed in the migrations table.
- Stale counts corrected against the tree, not by arithmetic: 16 -> 17
migrations (both files), `src/components/import/` 13 -> 14, `src/utils/`
4 -> 13. Tables (20) and indexes (24) were re-measured and are NOT stale --
v17 is an ALTER TABLE only -- so they are left as they are, with the reason
written down. ADR 0018, missing from the ADR table, is added.
- The user guide and the `docs.*` keys in both locales carry the same new
import journey: automatic detection, confidence score, mandatory preview
with its signed recap, sign inversion, recognised bank formats, drift panel,
and the safe repair path.
- Both changelogs carry the same entries, translated, verified section by
section including issue references. The `public/` copies sync automatically
via `syncChangelogs()`.
1176 vitest green, build clean, cargo check clean. No DB migration.
Resolves#332
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
npm update postcss moves it 8.5.13 -> 8.5.23, clearing GHSA-r28c-9q8g-f849
(path traversal in previous-source-map auto-loading via a sourceMappingURL
comment, arbitrary .map disclosure, 7.5 high). No overrides entry needed,
unlike #241: vite declares postcss ^8.5.3 and 8.5.23 is published, so the
existing range already permitted the fix and only the lockfile carried a
stale resolution. nanoid 3.3.11 -> 3.3.16 comes along as postcss's own
dependency, within its declared range.
postcss IS the CSS pipeline, so a green build only proves compilation. The
emitted stylesheet was diffed across the bump and is byte-for-byte identical
(same content hash, same asset filename).
The remaining react-router advisory (GHSA-qwww-vcr4-c8h2, RSC Mode CSRF
bypass) is accepted rather than fixed. It targets React Server Components,
which a Tauri desktop app never runs — App.tsx mounts a client-only
BrowserRouter and src/ has no createStaticHandler, StaticRouter or server
rendering. There is also nothing to move forward to: react-router-dom is
frozen at 7.18.1 since v8 merged the package into react-router, so npm's
proposed "fix" is a downgrade to 7.11.0, and leaving the affected range
means migrating to react-router v8. Re-evaluation trigger tracked in #317.
Unlike the Rust side, no CI gate is involved: check-frontend.yml runs no
npm audit step, so nothing turns red. That expectation is now written down
in docs/architecture.md and CLAUDE.md so the two permanent high findings do
not read as a regression.
npm audit: 3 findings -> 2 (high 3 -> 2), postcss cleared.
npm ci + npm run build + 871 vitest green.
Resolves#311
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>