Skip to content

feat(web): prefill the farm code from ?farm= and remember it (#535) - #588

Merged
mforce merged 14 commits into
mainfrom
feat/535-spa-farm-code-prefill
Aug 24, 2026
Merged

mforce merged 14 commits into
mainfrom
feat/535-spa-farm-code-prefill

Conversation

@mforce

@mforce mforce commented Aug 23, 2026

Copy link
Copy Markdown
Owner

Closes #535. Slice T7 of epic #530 (Phase 1.6 — multi-farm tenancy). Frontend only — no API change, nothing under src/.

What ships

Prefill order on the login form, first match wins: ?farm=<slug> (validated) → the device's remembered codes (one prefills, several offer a picker) → empty.

web/src/auth/farmCodeCache.ts (new) — cluckwork.farmCodes, most-recent-first, capped at 10. Deliberately not account-namespaced: this list is the cross-farm roster, so keying it per account would be circular. Written only after apiLogin resolves, which is what makes "a typo is never remembered" true by control flow rather than by a check.

web/src/lib/accountStorage.ts (new) — per-account browser state, namespaced by the account GUID the server minted into the access token (getBoundAccountId()), not by the farm code the user typed. A read with no bound account returns null and deliberately does not fall back to the bare key — falling back is precisely how farm B inherits farm A's state. cluckwork.lastFlockId moves onto it; the pre-namespacing value is purged rather than migrated, because the app cannot attribute it to a farm.

Normalisation before validation. AccountRepository.cs:44 looks the slug up as (slug ?? "").Trim().ToLowerInvariant() and LoginRequestValidator carries no pattern rule by design. So Sunny-Acres and " sunny-acres " sign in successfully — and without normalising first they would have failed the client regex and been silently never remembered, with no error and a green suite. The picker would simply never have appeared for anyone whose keyboard capitalised the first letter.

Client-side slug validation mirrors Account.SlugPattern and is applied to ?farm= and to every value read back out of the cache. Anchored, so an over-long value is ignored rather than truncated. It deliberately does not mirror ReservedSlugs — that list stays server-side, a reserved code is rejected at login, and a success-only cache can never contain one. The claim is bounded to shape, not acceptability.

A visible notice when the code came from the URL. A same-origin ?farm=attacker-farm link would otherwise silently replace the operator's own farm code in a field they did not type, while the password manager autofills for the origin. Naming the farm makes the substitution visible. Not a complete fix — the autocomplete username is still not farm-qualified (#585).

Scope decisions

Two comments corrected

brand.ts:5-8 claimed the palette "is cleared on every path that ends a session, so farm A's colour never bleeds into farm B's login screen." Nothing clears it — AuthContext.tsx:118-121 says so explicitly, justified by a "single-farm deployment" premise this epic falsifies. The repo was asserting the exact isolation property this slice was built to provide. Both comments now state the truth.

Verification

Driver-run, not quoted from the implementer — its report was lost to a repetition loop, so nothing here is implementer-attested.

  • npm run test:coverage — 86 files, 1857 tests, exit 0, no threshold violations.
  • Suite ledger: 1817 → 1857 (+40), 84 → 86 files. Nothing shrank.
  • src/lib/accountStorage.ts and src/auth/farmCodeCache.ts both 100/100/100/100 (json-summary), so the src/lib/** high-water lock holds. No coverage floor was moved in either direction.
  • npm run build and npm run verify:sw clean.

Mutation checks — 8 run, 8 killed by their named test

# Mutant Named test that went red
M1 drop canonicalFarmCode from the ?farm= initialiser the 6 invalid-?farm= cases, on toHaveValue
M2 remove the urlFarmCode === null gate on the cache read "never consults the farm-code cache"
M3 picker onClick always fills rememberedCodes[0] the second-button click case
M4 delete write-side .slice(0, MAX_REMEMBERED) "write-side cap: most-recent 10 of 12 in raw storage"
M5 delete write-side dedupe .filter "write-side dedupe + most-recent-first in raw storage"
M6 delete try/catch from readAccountScoped "bound + getItem throws — spy called with the namespaced key"
M7 revert Daily Entry to the bare un-namespaced key "reads the namespaced key, never the bare key"
M8 move rememberFarmCode above the await "writes nothing when apiLogin rejects"

Each restored, touched, and re-run green.

M4 and M5 are the ones worth noting. readFarmCodes() independently re-caps and re-dedupes, so any write-side test that read back through it would have stayed green under both mutants — the cap and dedupe would have been freely deletable. Those two tests assert on the raw stored value specifically for that reason.

Likewise M7: DailyEntryPage.tsx:161 already membership-checks the remembered id against the farm-scoped flock list, so a UI-level assertion cannot see the namespacing at all. Confirmed empirically — all 58 pre-existing Daily Entry tests pass with the change applied. That test asserts on the storage key.

Known, not fixed in this PR

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 1e7648013a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread web/src/i18n/en.ts
Comment on lines +2222 to +2223
+ "the farm code and sign in. The sign-in page also remembers farm codes you have signed in with on this "
+ "device and offers them as a picker, and a ?farm= link prefills the field. No other farm's session is "

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Update the in-app glossary for farm-code remembering

This updates the Help text and product glossary for the picker and ?farm= prefill, but the in-app glossaryFarmCodeDef at lines 2865–2868 still describes only typing the code and omits both new behaviors. Update that glossary entry, including its translated counterparts, so the user-facing documentation remains consistent.

AGENTS.md reference: AGENTS.md:L198-L198

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 73f6379f. You were right and this was a miss against the repo's own rule, not an oversight in a grey area.

AGENTS.md's docs-in-sync directive covers specs/product/GLOSSARY.md and the SPA Help page and the in-app glossary. This PR updated the first two — the product glossary and the signingInMultiTabResync Help copy — and left glossaryFarmCodeDef describing only typing the code, while quoting that same directive in its own planning notes.

glossaryFarmCodeDef now covers both new behaviours: that the sign-in screen remembers each farm code successfully used on the device and offers them, and that a /login?farm=<code> link fills it in. Mirrored into es.ts and tl.ts with matching meaning, not just matching keys — the parity test enforces presence, not semantics, so the translations were checked by reading them.

Comment thread web/src/auth/farmCodeCache.ts Outdated
Comment on lines +79 to +81
const next = [code, ...readFarmCodes().filter((c) => c !== code)].slice(0, MAX_REMEMBERED);
try {
localStorage.setItem(KEY, JSON.stringify(next));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Serialize farm-roster updates across tabs

When two tabs complete successful logins for different farms concurrently, both can read the same existing roster here and then independently write their own updated array; the last setItem wins and silently drops the other successfully used farm code. Since multi-farm sessions explicitly coexist across tabs, make this read-modify-write operation cross-tab-safe rather than relying on separate atomic localStorage calls.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 73f6379f. Confirmed as a real defect, not mitigated — thank you.

rememberFarmCode now serialises its read-modify-write with the Web Locks API, following the existing AUTH_COOKIE_LOCK precedent at client.ts:402 rather than inventing a mechanism — same degradation stance for browsers without navigator.locks (older Safari, insecure origins), which fall back to the unsynchronised path: no cross-tab guarantee, never worse than before. The roster is re-read inside the lock callback, since a read taken before acquisition is the stale value the lock exists to prevent. It never rejects: AuthContext.login now awaits it after apiLogin has already succeeded, so a storage or lock failure must not turn a completed sign-in into a thrown login.

On testing it honestly: the true cross-tab lost update is not expressible in Vitest — it needs two JS contexts sharing one localStorage, and jsdom has one, where the read-modify-write is synchronous and cannot interleave. A test claiming to reproduce the race would pass with the lock removed. So the guard is structural, and the first draft of it was itself a false green: asserting "storage still holds the old value when the callback is entered" survives hoisting the next computation outside the lock, because with an unchanged seed the pre-computed array and the in-lock re-read are identical.

The shipped test instead injects a concurrent tab's write into localStorage after the lock is acquired but before the callback runs, so only a genuine re-read preserves it.

Verification (driver-run, not attested):

mutation result
remove the lock entirely, call write() directly red — "re-reads INSIDE the lock callback: a roster change that lands while the lock is held is not overwritten"
hoist next outside write (lock still held, read now stale) red — same named test

Both restored, touched, re-run green. Full gate: 86 files / 1866 tests, exit 0; src/auth/** at 98.75 / 94.64 / 100 / 100 against its 98 / 90 / 100 / 100 lock.

@mforce

mforce commented Aug 23, 2026

Copy link
Copy Markdown
Owner Author

Review round 1 — 4 reviewers, 2 confirmed guard defects in shipped code

codex is unavailable (credits exhausted), so this round ran three Claude reviewers scoped to one defect class each, plus a local pi pass. Findings below are dispositioned; fixes are in flight as one increment.

Merge-blocking — both CONFIRMED BY EXECUTION, not by prediction

The false-green reviewer predicted these statically; I applied each mutation and ran the whole suite.

1. purgeUnscopedAccountState()'s call site is untested. Deleting the call at AuthContext.tsx:88 outright leaves 86 files / 1857 tests green. accountStorage.test.ts proves the function works but nothing proves anyone calls it — so the migration behaviour this slice ships is deletable today while that test reads as safety.

purgeLegacyTokens() one line above has the identical untested-call-site shape. That's pre-existing, but it makes this the second instance of one shape, which per AGENTS.md means the method is wrong rather than the instance. One test pins both.

2. The remembered-flock prefill is untested. Deleting

else if (remembered && f.some((x) => x.id === remembered)) retarget(() => setFlockId(remembered));

while leaving the readAccountScoped call in place also leaves all 1857 tests green. The existing guard pins the storage keys — namespaced read, bare key never read, namespaced write — but never asserts the remembered id selects anything, so the feature is gone and the guard stays green.

This is the inverse of the masking problem the slice already handled: the test moved off the UI observable because DailyEntryPage.tsx:161's membership check masks it, and moving to the key-name observable dropped the behaviour claim entirely. The fix asserts both, plus a real cross-account isolation case (bind A, write, rebind to B, assert no inheritance) which was previously only inferred from the key string.

Wrong claims in comments — four, all mine

Stated plainly because it is the same defect class this slice exists to remove: brand.ts shipped a comment asserting an isolation property no line enforced, and in correcting it I introduced four new inaccurate claims.

Test quality — three, all real

  • accountStorage.test.ts calls spy.mockRestore() after its assertions, so a failing assertion skips the restore and leaks a throwing spy into later tests in the file. This is why one verification mutant produced two reds instead of one.
  • The URL-notice negative assertion is pinned to the English literal /Signing in to farm/ while its positive half uses i18n.t(...). Live today, but a reword of auth:farmFromLink makes it match nothing and the claim becomes unfalsifiable with no failure to announce it.
  • The picker's role="group" / aria-labelledby wiring ships with a justifying comment and no assertion; both attributes are deletable with the suite green.

Rejected, with evidence

  • pi: "isFarmCode is dead API production never calls." False — canonicalFarmCode calls it at farmCodeCache.ts:44.
  • pi: "a mid-session ?farm= change is silently ignored." The mechanism is real (a useState initialiser doesn't re-run), but the only in-app navigations to /login are AppLayout.tsx:61 and ProtectedRoute.tsx:15 and neither carries a query string. An external farm link is a full page load, which remounts. No current trigger.
  • pi: "picker poisoning via ?farm=." Requires the victim to complete a valid login at the attacker's farm, i.e. to already hold credentials there. The cache is success-only by construction.

Accepted as follow-up, not fixed here

rememberFarmCode runs only on the explicit-login path, so a session surviving on the refresh cookie while localStorage is purged loses the cached code until the next full login. Narrow and self-healing — and executeRefresh has no typed farm code to remember anyway.

Also confirmed by review

The invariants reviewer walked every localStorage/sessionStorage site in web/src independently rather than trusting the PR's list, found no namespacing violation, and confirmed no window where getBoundAccountId() is null on an authenticated render. It also verified every line citation in the new modules against source — Account.cs:36, AuthEndpoints.cs:203, AccountRepository.cs:44, and LoginRequestValidator's deliberate absence of a shape rule.

Round-1 count for the stop rule: 2 confirmed defects in product code.

@mforce

mforce commented Aug 23, 2026

Copy link
Copy Markdown
Owner Author

Round-1 fixes are written but not yet applied — the loop is paused on infrastructure, not on a decision.

The implementer backend for this slice (a self-hosted endpoint) went unreachable mid-round: the dispatch failed with API call failed after 3 retries: Connection error after 4 API calls and applied nothing. The branch is unchanged at 1e764801 and still green.

The fix increment for every item in the round-1 comment above is written and ready to dispatch; it is queued until that host returns, by owner decision. Nothing is dropped, and this pause should not be read as the round being finished.

Still outstanding, in priority order:

  1. Pin purgeUnscopedAccountState()'s call site (confirmed surviving mutant).
  2. Pin the remembered-flock prefill and add real cross-account isolation (confirmed surviving mutant).
  3. Correct four inaccurate claims in comments.
  4. Three test-quality items (spy restoration, i18n-pinned negative assertion, picker a11y assertion).

…le text (#535)

Round-2 mutation found the negative half of the notice test could not fail:
removing the `urlFarmCode !== null` gate so the notice always renders left the
whole suite green.

Testing Library normalises the rendered text but compares a string matcher
literally, so `i18n.t("auth:farmFromLink", { farmCode: "" })` — which ends in a
trailing space because the interpolated value is empty — matches nothing. Both
the original English literal and its round-1 i18n-pinned replacement were
unfalsifiable for that reason.

Asserting on the `.auth-farm-source` node is immune to text normalisation.
Verified: the gate-removal mutant now reddens this test by name.
@mforce

mforce commented Aug 23, 2026

Copy link
Copy Markdown
Owner Author

Round 1 fixes applied (7b1c01cc), plus a round-2 finding against them (82fbb5b5)

Both merge-blocking mutants are now dead — re-verified by me, not attested

Mutant Before Now
delete purgeUnscopedAccountState()'s call site survived (1857 green) red — "purges pre-namespacing browser state on mount"
delete purgeLegacyTokens()'s call site (pre-existing gap) survived red — same test
delete the remembered-flock prefill branch survived (1857 green) red — "actually selects the remembered flock"
delete the picker's role="group" / aria-labelledby survived red — "labels the recent-farms picker group with its heading"

The four claim corrections landed and were checked against source: both #586 references now say "the per-farm fix is tracked in #586" with no clearing claim, farmCodeCache.ts points at epic #530 decision 9 with the ADR noted as pending in #537, and the two line pins now read client.ts:144 / client.ts:242.

The implementer caught a factual error in my runbook

I specified a fixture asserting isFarmCode("sunny-acres\n") === true, on the reasoning that $ without the m flag matches before a trailing newline. That is Python's semantics, not JavaScript's — in JS, $ without /m matches only at true end of input, so it returns false. Verified:

JS  $ no m flag  → false      JS  $ with m flag → true      Python $ → True

I took the claim from a reviewer and propagated it without checking. Transcribing it verbatim would have committed a permanently-red test. The implementer refused to encode it, pinned the true behaviour both ways (isFarmCode rejects the newline; canonicalFarmCode trims it first, which is why every live caller is safe), and reported the deviation.

Round 2 — one finding, against round 1's own fix

Round 1 replaced the notice test's English literal /Signing in to farm/ with an i18n-pinned string. That was the right instinct and it did not work: removing the urlFarmCode !== null gate so the notice always renders still left the whole suite green.

Cause: Testing Library normalises the rendered text but compares a string matcher literally. i18n.t("auth:farmFromLink", { farmCode: "" }) ends in a trailing space, because the interpolated value is empty — so it can never match anything. Demonstrated:

queryByText("Signing in to farm: ")   → null      (trailing space: never matches)
queryByText("Signing in to farm:")    → found
queryByText(/Signing in to farm:/)    → found

So the assertion went from vacuous-and-obvious to vacuous-and-principled-looking. Both null and "" interpolate to the same string, so the i18n pinning was not the problem — the trailing space was. Fixed by asserting on the .auth-farm-source node, which is immune to text normalisation. The gate-removal mutant now reddens that test by name.

Applied directly rather than routed back: one mechanical line with a verified diagnosis. Driver fix budget: 1 of 2 spent.

Invariant re-enumerated before closing the loop

A clean round says the diff is clean and nothing about untouched code. All 20 localStorage/sessionStorage sites in web/src plus theme-init.js were re-walked and dispositioned: 3 namespaced, 2 deliberately cross-account (the roster, circular to namespace), 6 correctly device/user-scoped, 3 tab-scoped and cleared on login, 4 infrastructure, and one known gap — cluckwork.brand — deferred by owner decision and tracked in #586.

State

npm run test:coverage — 86 files, 1862 tests, exit 0. build and verify:sw clean. Suite ledger 1817 → 1862 (+45); nothing shrank. src/lib/** and src/auth/** still meet their high-water locks with no floor moved in either direction.

Stop-rule tally: round 1 = 2 confirmed product defects; round 2 = 0 (its single finding was a test guard, in round 1's own fix).

@mforce

mforce commented Aug 23, 2026

Copy link
Copy Markdown
Owner Author

Round 3 — 0 product defects. Stop rule fired; this is the last round.

Three reviewers against 1e764801..HEAD (the round-1/2 fix increment, which no earlier round had seen). Findings: two wrong claims in comments, zero defects in product code. Rounds 2 and 3 both yielded nothing but tests and comments, which is the documented stop condition, so I am not tagging for a round 4.

Correction to my own round-2 comment — I had the provenance backwards

I wrote above that "both the original English literal and its i18n-pinned replacement were unfalsifiable". That is wrong, and the same wrong claim is in 82fbb5b5's commit message. Verified against history:

commit assertion falsifiable?
1e764801 queryByText(/Signing in to farm/) — a regex, not a literal yes — regex matchers apply to the normalised text
7b1c01cc (round-1 fix) i18n.t("auth:farmFromLink", { farmCode: "" }) no — this is what introduced the hole
82fbb5b5 (round-2 fix) .auth-farm-source node query yes, and stronger than either

So round 1 did not fail to close a pre-existing hole — it broke a working guard, and round 2 repaired round 1's own regression. The original F4 finding was about future-proofing an assertion that already worked, and the fix I specified converted a hypothetical future vacuity into a real present one.

That is precisely the loop's convergence signal — a round whose findings are breakage the previous round introduced — and it is a second, independent reason to stop here rather than run a round 4.

The other finding: a wrong claim three lines from the wrong claims it was fixing

farmCodeCache.ts:13 says a drift between the JS regex and Account.SlugPattern is "fail-safe, costing a rejected-at-login code (never an accepted-invalid one)". True in one direction only. The pattern gates prefill, the picker filter and the post-login cache write — not what the operator types, since the input carries no pattern attribute:

  • JS looser than the server → an over-permissive cached/URL value reaches login and is rejected there. Matches the claim.
  • JS stricter than the server → a genuinely valid slug that just logged in successfully is silently never stored and never offered again. The login never fails. The cost is a permanently unofferable farm code, not a rejection.

Found independently by the contract-drift reviewer and by me on re-read. The security half ("never an accepted-invalid one") holds both ways; the stated cost does not.

Both corrections are dispatched as a comment-only increment.

Verified sound by real mutation (not prediction)

The false-green reviewer ran its checks as actual mutations in throwaway worktrees:

  • Case A is not a tautology — FLOCK (f1) is Active and first, FLOCK2 spreads it, so find(x => x.status === "Active") ?? f[0] resolves to f1 by default while the test asserts f2.
  • The purge test's "namespaced key survives" assertion is live — widening the purge to a blanket wipe reddens it, so a delete-everything purge cannot pass.
  • The cross-account isolation test is not redundant — a prefix-scan fallback in readAccountScoped reddens it and nothing else. Its noted limit: it asserts the default, so it stays green if the remembered read returns null for any reason. The positive test three lines above covers selection; neither substitutes for the other.
  • vi.restoreAllMocks() is not a no-op — vite.config.ts sets no restoreMocks, and setup.ts calls only unstubAllGlobals. The leak was real and is closed.
  • The JS-vs-Python regex correction is right, and adding /m to the pattern reddens the newline test by name.

pi: 3 findings, 0 valid

Claimed the newline assertion was "unfalsifiable by design" (adding /m reddens it); claimed the purge test passes with the call site deleted (that mutation was already run and went red); and raised the Case-A tautology question, which the mutation above settles.

Tally

round product defects
1 2 (both surviving mutants)
2 0 (one test guard, in round 1's fix)
3 0 (two wrong claims in comments)

Driver fix budget: 1 of 2 — both round-3 corrections were routed back to the implementer rather than hand-applied.

@mforce

mforce commented Aug 24, 2026

Copy link
Copy Markdown
Owner Author

@codex both of your findings are addressed in 73f6379f — please re-review at that head.

P2 (cross-tab roster race) — real, and fixed properly rather than mitigated. rememberFarmCode now serialises its read-modify-write with the Web Locks API, following the existing AUTH_COOKIE_LOCK precedent at client.ts:402 and its degradation stance for browsers without navigator.locks. The roster is re-read inside the lock callback, and the function never rejects because AuthContext.login awaits it after apiLogin has already succeeded.

P1 (in-app glossary) — a genuine miss against AGENTS.md's docs-in-sync directive. glossaryFarmCodeDef now describes the remembered-codes picker and ?farm= prefill, mirrored into es.ts and tl.ts with matching meaning.

Two things worth your attention on re-review, because both are places where a guard could read as safety without being one:

  1. The P2 guard is structural by necessity. The true cross-tab lost update is not expressible in Vitest — it needs two JS contexts sharing one localStorage. The first draft of the test was itself a false green: asserting "storage still holds the old value when the callback is entered" survives hoisting the next computation outside the lock, since with an unchanged seed the pre-computed array and the in-lock re-read are identical. The shipped test injects a concurrent tab's write after lock acquisition but before the callback, so only a real re-read preserves it. Both mutations (remove the lock; hoist the read out of it while keeping the lock) were run and both redden that named test.

  2. The navigator.locks-absent path is a real fallback, not dead code — it degrades to the unsynchronised write deliberately, so a browser without Web Locks is no worse off than before this PR. If you think that degradation is wrong for a multi-farm device, say so; it is a deliberate choice, not an oversight.

Full gate at 73f6379f: 86 files / 1866 tests, exit 0; src/auth/** at 98.75 / 94.64 / 100 / 100.


Process note, since it affects how much weight to give this PR's earlier comments. Your review of 1e764801 was posted at 09:48 and I did not read it until the owner asked whether you had reviewed. Three review rounds ran in the interim, and my round-3 comment above claims "0 product defects" and declares the loop closed — that tally is wrong, because it counted only the reviewers I dispatched myself and never enumerated this PR's review threads. P2 is a product defect. I have corrected the record here rather than editing the earlier comment.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. 🎉

Reviewed commit: 73f6379f58

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

The test claiming to guard `if (locks === undefined) { write(); return; }` was a
tautology: it created a `vi.fn()`, never wired it to anything, and asserted it
had not been called. Nothing could have called it.

Fixing the spy would not have helped. The branch is behaviourally REDUNDANT with
the catch beneath it — with `locks` undefined, the guard writes and returns;
delete the guard and `locks.request` throws a TypeError that the catch answers
with the identical write. Same storage, same call count, no externally
observable difference. Confirmed by running the mutation: deleting the guard
leaves the whole suite green, before and after that test existed.

The branch stays, because it states intent and relying on `undefined.request()`
throwing is accidental control flow that a later narrowing of the catch would
silently break. What changes is the claim: the test now asserts only what it can
actually observe (the lock-free path still writes), and a comment records why
there is no branch pin and that the previous attempt was a fake one.

The observable mutations remain covered: removing the lock, and hoisting the
re-read out of it, both redden the concurrent-write test.
@mforce

mforce commented Aug 24, 2026

Copy link
Copy Markdown
Owner Author

@codex please re-review at 522e59f4. Your clean verdict covered 73f6379f; two test-only commits have landed since.

235a1ef7 and 522e59f4 address two false greens my own review found in the tests that shipped with the lock fix. No production behaviour changed in either.

The one worth your attention

farmCodeCache.ts has a if (locks === undefined) { write(); return; } guard, and nothing pins it — deliberately, now documented in the test file.

The branch is behaviourally redundant with the catch beneath it: with locks undefined the guard writes and returns; delete the guard and locks.request throws a TypeError that the catch answers with the identical write. Same storage, same call count, no externally observable difference. A mutation deleting the guard leaves the whole suite green — verified by running it.

The first attempt to "fix" this shipped a tautology: a vi.fn() that was never wired to anything, asserted not to have been called. It read as safety and could not fail. That is now removed and replaced with a comment saying why no pin exists and that the previous attempt was fake, so nobody writes another one.

The branch stays because it states intent — relying on undefined.request() throwing is accidental control flow, and narrowing the catch later would silently break browsers without navigator.locks. If you think that trade is wrong, or you can see an observable difference I could not, say so: I would rather delete the branch than keep an untestable one on a false premise.

What is genuinely pinned

mutation result
remove the lock entirely red — concurrent-write test
hoist the re-read outside the lock (lock still held) red — same test
change ROSTER_LOCK's value red by name, in two tests
delete the locks === undefined guard survives — documented above, not hidden

Gate at 522e59f4: 86 files / 1867 tests, exit 0; src/auth/** 98.75 / 94.64 / 100 / 100 against its 98 / 90 / 100 / 100 lock.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Already looking forward to the next diff.

Reviewed commit: 522e59f4a5

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@mforce
mforce merged commit b7f5cc6 into main Aug 24, 2026
10 checks passed
@mforce
mforce deleted the feat/535-spa-farm-code-prefill branch August 24, 2026 06:14
@mforce mforce mentioned this pull request Aug 24, 2026
27 of 28 tasks
mforce pushed a commit that referenced this pull request Sep 12, 2026
🤖 I have created a release *beep* *boop*
---


## [0.1.0](v0.0.4...v0.1.0)
(2026-09-12)


### ⚠ BREAKING CHANGES

* log in by farm code, with per-account email identity
([#532](#532)) (#564)

### Features

* **accounts:** add Account.Slug (farm code), suspend/reactivate,
list-accounts verb
([#531](#531))
([3fe9754](3fe9754))
* **accounts:** provision additional farms
([#581](#581))
([006f298](006f298))
* add Aspire local development AppHost
([#567](#567))
([2c9e6b9](2c9e6b9))
* add configurable worker sale allocation
([#619](#619))
([0955095](0955095))
* add searchable entity pickers
([#642](#642))
([60d2053](60d2053))
* **api:** provision-account takes an optional --timezone at creation
([#603](#603))
([#694](#694))
([a0aee39](a0aee39))
* **audit:** show the sales-line audit payload as a readable Details
column ([#745](#745))
([#749](#749))
([d26d389](d26d389))
* **auth:** add ApplicationUser.StepUpLogoutEpoch column
([#338](#338))
([#554](#554))
([18306ee](18306ee))
* certify over-cap simulation fixture bands
([#633](#633))
([a67b2e1](a67b2e1)),
closes [#627](#627)
* **cli:** rename-account verb to change a farm code
([#732](#732))
([#733](#733))
([4b70559](4b70559))
* **customers:** edit existing customer details
([#625](#625))
([#626](#626))
([062a55c](062a55c))
* **jobs:** single-runner leader gate for the durable job worker
([#271](#271))
([#555](#555))
([4148f9b](4148f9b))
* let owners change user email addresses
([#605](#605))
([842347b](842347b))
* log in by farm code, with per-account email identity
([#532](#532))
([#564](#564))
([68adb62](68adb62))
* **ratelimit:** distributed IP-keyed auth limiters
([#544](#544))
([#558](#558))
([ec14972](ec14972))
* **ratelimit:** distributed per-account report concurrency cap with
local-ceiling fallback
([#545](#545))
([#559](#559))
([1522e4e](1522e4e))
* **sales:** mark discounted lines, total the discount, and show it in
the Orders list ([#723](#723),
[#724](#724))
([#741](#741))
([1a07441](1a07441))
* **sales:** record list, old and new price in the order-line audit
payload ([#722](#722))
([#742](#742))
([97c866f](97c866f))
* **sales:** refuse an over-ceiling confirm from a Sales user
([#727](#727))
([#766](#766))
([8c0792a](8c0792a))
* **sales:** show what each order still owes, and filter the list to
unpaid ([#771](#771))
([ca59d68](ca59d68))
* **sales:** snapshot the list price on the order line and show the
discount ([#734](#734))
([cffed5e](cffed5e))
* **sales:** snapshot the product name and unit in the order-line audit
payload ([#747](#747))
([#748](#748))
([0481c06](0481c06))
* scope Worker reads to assigned flocks
([#388](#388))
([#611](#611))
([5884a9a](5884a9a))
* shared-state ports with Redis + in-process fallback
([#543](#543))
([#552](#552))
([f767fa9](f767fa9))
* suspend-account / reactivate-account operator verbs
([#534](#534))
([#573](#573))
([d0be26c](d0be26c))
* **tenancy:** write-side tenant guard + single-assignment TenantContext
([#546](#546))
([#561](#561))
([f371f1d](f371f1d))
* **web:** dashboard rework — capture-status tiles, 14-day trend, stock
as a stacked bar
([#654](#654))
([396ba23](396ba23))
* **web:** date-range filters on audit and expenses, and the stock lot
filter gets its bounded toolbar
([#666](#666),
[#667](#667),
[#653](#653))
([94b188f](94b188f))
* **web:** elevation hierarchy and sentence-case labels
([#651](#651),
[#652](#652))
([#661](#661))
([28db4c7](28db4c7))
* **web:** Expenses and Audit keep a clear-filters control while rows
are still showing
([#679](#679))
([#697](#697))
([b859982](b859982))
* **web:** expenses filters by a date range like its sibling screens
([#667](#667))
([f13858f](f13858f))
* **web:** key the farm brand palette per farm
([#586](#586))
([#600](#600))
([7183a43](7183a43))
* **web:** let operators forget remembered farms
([#598](#598))
([577d94e](577d94e))
* **web:** one-line provenance, bounded date filters, and empty states
that invite action
([#653](#653),
[#655](#655))
([#668](#668))
([80b53f4](80b53f4))
* **web:** prefill the farm code from ?farm= and remember it
([#535](#535))
([#588](#588))
([b7f5cc6](b7f5cc6))
* **web:** split authenticated routes into lazy chunks
([#620](#620))
([5089271](5089271))
* **web:** the audit log filters by a date range, and says which window
is empty ([#666](#666))
([63027e0](63027e0))
* **web:** typeset numbers as numbers and refresh the Help glossary
([#650](#650),
[#657](#657))
([af4fe11](af4fe11))


### Bug fixes

* **api:** order same-instant audit events by a durable monotonic key
([#700](#700))
([8fcf084](8fcf084))
* **api:** print the farm code from bootstrap-admin
([#589](#589))
([#594](#594))
([34032ac](34032ac))
* **audit:** show the price a line sold for, not its list price
([#759](#759))
([e6b37d0](e6b37d0))
* **audit:** store catalog enums by name and guard the add-item
transaction shape
([#751](#751))
([23609ff](23609ff))
* **auth:** reject invalid account claims
([#622](#622))
([8d6c7fe](8d6c7fe))
* **auth:** require step-up for durable user access
([#360](#360))
([#607](#607))
([f767dce](f767dce))
* **ci:** bound the npm audit calls and give the web job room to finish
([#686](#686))
([153b7a8](153b7a8))
* **ci:** escalate the audit bound to SIGKILL, so it actually bounds
([#686](#686))
([a0c8f4e](a0c8f4e))
* **ci:** fail closed on invalid vulnerability config
([#621](#621))
([1690db8](1690db8))
* **ci:** lockfix covers the two AppHost lock files, derived from the
sln
([efb05e6](efb05e6))
* **ci:** lockfix covers the two AppHost lock files, derived from the
sln
([8986d77](8986d77))
* **ci:** remove invalid XML comment from nuget.lockfix.config
([#541](#541))
([5f1bc0a](5f1bc0a))
* **ci:** the advisory vuln gate no longer blocks on an unusable report
([#686](#686))
([aaf6934](aaf6934))
* **ci:** the advisory vuln gate no longer blocks on an unusable report
([#686](#686))
([64f1f53](64f1f53))
* **i18n:** tl help text names the saleable flag and unit-system setting
what their labels call them
([#688](#688))
([#696](#696))
([bfd24d7](bfd24d7))
* **infra:** AccountId must be a non-nullable Guid or both tenant write
layers refuse ([#673](#673))
([#695](#695))
([2470c4e](2470c4e))
* require step-up for flock scope changes
([#609](#609))
([4151f89](4151f89))
* **sales:** keep a line's discount markers agreeing while its price is
edited ([#752](#752))
([#753](#753))
([c159b4b](c159b4b))
* **sales:** say which kind of missing list price a line has
([#774](#774))
([489180e](489180e))
* scope legacy logout to selected farm
([#624](#624))
([fae8d82](fae8d82))
* **seed:** drain the daily-entry lock sweep so deep simulation fixtures
validate ([#644](#644))
([730fa23](730fa23)),
closes [#638](#638)
* **tenancy:** AccountId is a concurrency token, so the database refuses
a detached cross-tenant write
([#562](#562))
([4d1dfa3](4d1dfa3))
* **tenancy:** AspNetUserRoles carries a tenant column, so a role write
naming another farm's user is refused
([#670](#670))
([fc0552a](fc0552a))
* **tests:** bump the image-pin allow-list counts for the AppHost
LocalPorts tests
([#593](#593))
([58d3056](58d3056))
* **tests:** the OTLP collector survives a lost port race and ignores
traffic that is not an export
([#672](#672),
[#676](#676))
([#677](#677))
([965c737](965c737))
* **web:** a scoped audit view filtered to nothing names both the record
and the range ([#666](#666))
([41bbfe1](41bbfe1))
* **web:** an abandoned dialog attempt's success no longer hijacks the
replacement on Customers, Daily Entry, Flocks, Grades and Products
([#703](#703))
([#705](#705))
([85605db](85605db))
* **web:** an abandoned dialog attempt's success no longer hijacks the
replacement on Inventory, Expenses, History and Stock
([#703](#703))
([#706](#706))
([60a4997](60a4997))
* **web:** an abandoned edit's success no longer hijacks the dialog that
replaced it on Users
([#703](#703))
([#710](#710))
([778faab](778faab))
* **web:** an abandoned order attempt's success no longer hijacks the
dialog that replaced it
([#702](#702))
([522c699](522c699))
* **web:** capture screens open on the flock you last used, and
assigning one no longer guesses
([#646](#646))
([#699](#699))
([7f8f317](7f8f317))
* **web:** constrain dialog session helpers to declared scopes
([#715](#715))
([389e3c8](389e3c8))
* **web:** date validation gets one boundary table instead of one case
per review round
([#666](#666))
([215f830](215f830))
* **web:** keep a paged window and an item panel on the user's newest
intent ([#645](#645))
([d81bccf](d81bccf))
* **web:** keep Sales order panels closed after pending writes
([#711](#711))
([f0f7492](f0f7492))
* **web:** keep Sales panels closed after pending Open reads
([#716](#716))
([620411f](620411f))
* **web:** make login take the cross-tab cookie lock so a racing refresh
cannot restore the wrong session
([#648](#648))
([ff18beb](ff18beb))
* **web:** make the entity picker read as a search field and focus it on
open ([#736](#736))
([66ef667](66ef667)),
closes [#735](#735)
* **web:** page truncated customer and movement tables with usePagedList
([7cfe4d6](7cfe4d6))
* **web:** reconcile Sales line edits with refreshed orders
([#717](#717))
([d7dd2c9](d7dd2c9))
* **web:** the audit date filter accepts low-numbered years, and its
empty state covers every narrowing
([#666](#666))
([af52d25](af52d25))
* **web:** the audit date filter rejects impossible dates, and its
history guard actually guards
([#666](#666))
([8d51846](8d51846))
* **web:** the expense range bounds are not capped at today, which the
month-end default exceeds
([#667](#667))
([7e01864](7e01864))
* **web:** the help text calls the expiry field what the field calls
itself ([#666](#666))
([2fd1f3c](2fd1f3c))
* **web:** the stock lot date range sits in the bounded toolbar
([#653](#653))
([43dec5e](43dec5e))


### Refactoring

* **web:** extract SalesPage's dialog-write wrapper into a shared
useDialogAction hook
([#703](#703))
([#704](#704))
([60ee9d9](60ee9d9))


### Documentation

* add k6 preparation steps to the dev-database fixture runbook
([#643](#643))
([a4f1f09](a4f1f09))
* add runbook for loading the simulation fixture into a dev database
([#639](#639))
([2d143b8](2d143b8))
* **agents:** a PR closes its issue from the body, not the title
([#744](#744))
([39be13c](39be13c))
* **agents:** drop the commit and push gate, and require screenshots on
UI changes ([#757](#757))
([6225172](6225172))
* **agents:** find guards by grepping registry readers; amend issues a
PR overtakes ([#580](#580))
([fe3fde8](fe3fde8))
* **agents:** the Playwright specs have been in CI since 2026-08-08
([#768](#768))
([68ee612](68ee612))
* **aspire:** record the second local database and pin the AppHost
dashboard ports ([#623](#623))
([713b941](713b941))
* compress AGENTS.md to one paragraph per rule, and draw the two orders
that matter ([#551](#551))
([997ae8a](997ae8a))
* item 7 names each screen's actual initial filter value
([#666](#666))
([70a53d8](70a53d8))
* multi-farm tenancy decision record and AGENTS/GLOSSARY sync
([#537](#537))
([#601](#601))
([2c34771](2c34771))
* name the scoped filtered-empty key and state the
[#653](#653) relationship
plainly ([#666](#666))
([0e93dac](0e93dac))
* note that a PackageReference in Directory.Build.props is invisible to
the dependency graph
([4845724](4845724))
* **plans:** commit the
[#722](#722) and
[#745](#745) design records
([#754](#754))
([c942fcd](c942fcd))
* record [#579](#579) as
won't-fix — suspension is immediate for use, not issuance
([#582](#582))
([7a3be40](7a3be40))
* record the [#508](#508)
audit ordering key and the tracked-file guard lesson
([#701](#701))
([08964e9](08964e9))
* **runbooks:** add procedure to rename the default farm's code after
upgrade ([#731](#731))
([2f6e242](2f6e242))
* screenshots of the running SPA in the README
([#550](#550))
([711488a](711488a))
* **sim:** commit the dashboard screenshot, capture the palette matrix,
and record the
[#651](https://github.com/mforce/cluckwork/issues/651)/[#652](https://github.com/mforce/cluckwork/issues/652)
conventions ([#660](#660),
[#662](#662),
[#663](#663),
[#664](#664))
([#665](#665))
([930ea30](930ea30))
* specify searchable entity picker
([#641](#641))
([91d4300](91d4300))
* split the README into audience-scoped docs and adopt repo-template
scaffolding ([#548](#548))
([b3f3fcf](b3f3fcf))
* surface Aspire local development workflow
([#568](#568))
([a343baa](a343baa))
* **web:** record the per-screen idempotency-key policies and runWrite's
refresh contract
([#703](#703))
([#707](#707))
([8bee651](8bee651))
* **web:** the date-cap help text covers every stocked item, not only
feed ([#666](#666),
[#667](#667))
([c8433c5](c8433c5))
* **web:** the help text claims only what is true of recording, and says
nothing about filter caps
([#666](#666),
[#667](#667))
([e2f63d1](e2f63d1))
* **web:** the help text describes the date-range filters that shipped
([#666](#666),
[#667](#667))
([c3275b7](c3275b7))
* **web:** the help text stops describing a cap the filters no longer
have ([#666](#666),
[#667](#667))
([49654cd](49654cd))

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).

Co-authored-by: cluckwork-lockfix[bot] <309265648+cluckwork-lockfix[bot]@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

SPA: prefill the farm code from ?farm= and remember it

2 participants