Repository navigation
feat: shared-state ports with Redis + in-process fallback (#543) - #552
Conversation
Increment 1 of #543 (Phase 1.6 scale-out): three capability-specific shared-state ports and their in-process fallbacks. - IClaimOnceStore / IFixedWindowCounter / ILease - In-process implementations: TimeProvider-driven, single-lock, collect-then-remove sweeps (matches the AccountLockout.Prune convention) - Contract suites (in-process) driven by a manual fake clock No Redis, config, boot guard, or DI wiring yet — that is increment 2. Drafted by a local model (pi/qwen), reviewed and verified here.
Increment 2 of #543: Redis-backed IClaimOnceStore / IFixedWindowCounter / ILease for the multi-replica case, behind the ports from increment 1. - Atomic by construction: native SET NX PX for claim-once and lease-acquire; single Lua scripts for the counter (server-time-bucketed INCR+PEXPIRE via redis TIME, so replicas agree without host-clock trust) and for lease renew/release (compare-and-extend, compare-and-delete). - Testcontainers-Redis contract tests with real short TTLs; the counter test aligns to a fresh window boundary to avoid a straddle flake. Redis image digest-pinned per repo convention. - Adds StackExchange.Redis (prod) + Testcontainers.Redis (test); lock files regenerated; clean on the #146 vuln gate. No config, boot guard, DI wiring, or resilient fallback wrapper yet - that is increment 3. Drafted by a local model (pi/qwen) with the Lua and the timing recipe supplied; reviewed and verified here.
Increment 3a of #543: per-capability resilience decorators over the Redis implementations, with the fallback policy the epic decided. - Grant replay FAILS CLOSED: a Redis error denies the claim (privileged operations refused when single-use can't be proven) — no per-replica fallback that would make a grant usable once per replica. - Auth limiter and report-concurrency lease FALL BACK to their in-process implementations and alarm — bounded degradation (N x budget) beats fail-open (unbounded, plus dummy-PBKDF2 CPU cost / DB-pool exhaustion). - Alarm = a stable SecurityEvents.SharedStateRedisUnavailable warning, so a limiter silently stuck in fallback is visible to alerting. - Catches only StackExchange.Redis.RedisException; stub-based unit tests assert both the return value and the alarm on each path (no Docker). Remaining in #543: config binding, the serving-only boot guard, DI wiring, and the sim-harness update (increment 3b). Drafted by a local model (pi/qwen) from a prescriptive spec; security policy authored here, reviewed and verified.
Increment 3b-i of #543: SharedStateOptions binding and DI registration of the three ports, plus Program.cs wiring. - Blank SharedState:Redis:ConnectionString => in-process implementations (Option B, single instance). - A configured connection string => Redis primary behind the resilient decorators (grant-replay fail-closed with no fallback; counter/lease with in-process fallback), and IConnectionMultiplexer with AbortOnConnectFail=false so an UNREACHABLE Redis degrades at runtime rather than failing the boot. - A MALFORMED connection string throws only for the serving role; a one-shot verb degrades to in-process with a stderr warning (#347 scope, mirrors AddCluckworkRateLimiting). - DI-resolution tests assert the branch types (no Docker). Registration helper lives in Infrastructure (sees the internal impls); the Api extension owns config binding + the role gate. The malformed-connstring serving guard still needs its ProcessRoleGuardTests row (3b-ii). Drafted by a local model (pi/qwen) from a prescriptive spec; reviewed and verified.
…string (#543) Increment 3b-ii of #543: make the malformed-connection-string check a DISCOVERABLE serving-only boot guard, proven by a test rather than by nothing (#347/#407 — a guard proven by nothing reads as safety but is not). - EnsureSharedStateConnectionValid: an Ensure* guard in Cluckwork.Api.Hosting (serving-only per #347 — a one-shot verb never uses shared state and must not be aborted by a Redis string it ignores; blank is fine, Option B). A set-but-malformed string fails the serving boot loudly instead of silently degrading to in-process. - Single 'well-formed' definition (SharedStateRegistration.IsWellFormedConnectionString) shared by the guard and the registration helper so they cannot drift. - ServingGuardCoverageTests maps the new Ensure* method; ProcessRoleGuardTests gains its row. Verified: 40/40 guard arms green — the serving boot dies naming this guard's token and no other, and migrate survives the same violation (the #331 direction). Remaining in #543: the sim-harness update (3b-iii). Drafted by a local model (pi/qwen) from a prescriptive spec; guard design authored here, arms verified.
Per #370, a new boot guard + new config keys must reach all three sim-harness files in the same PR. #543 added SharedState:Redis:ConnectionString / KeyNamespace and a serving-only guard that fails the boot on a set-but-malformed connection string. - docker-compose.sim.yml: add a digest-pinned redis sidecar (same image the Testcontainers RedisFixture uses), gate `app` on it via depends_on service_healthy, and pass the two SharedState__Redis__* keys. The stack runs Production config on purpose, so it now boots the real Redis-backed shared-state path (resilient decorators over a live ConnectionMultiplexer), not the in-process fallback. - bootstrap.sh: emit SharedState__Redis__ConnectionString=redis:6379 and KeyNamespace=cluckwork-sim into the .env.sim heredoc (plaintext, no auth — co-located sidecar on the stack's private network, same posture as the Postgres/OTLP sidecars). - verify-harness.sh: add a bounded well-formedness check that mirrors the app guard exactly — blank is legal (degrades to in-process), a set value must name an endpoint (>=1 comma-token without '='). Like the #510 check it only ever appends to `fail`. Verified: verify-harness.sh green; the new check goes red on an options-only value and stays green on blank; a full redis+db+app boot goes healthy (/health/ready 200), no fallback alarm, redis reachable on the compose network (PONG). Closes #543.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 9c2ffa3423
ℹ️ 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".
| local k = KEYS[1] .. ':' .. bucket | ||
| local c = redis.call('INCR', k) |
There was a problem hiding this comment.
Keep the Lua counter key in its declared cluster slot
When the configured backend is Redis Cluster, the script is routed using KEYS[1] (namespace:key) but actually increments the dynamically derived namespace:key:bucket, which normally hashes to a different slot. Redis Cluster therefore rejects the script with a cross-slot/non-local-key error; the resilient decorator then falls back to per-process counters and silently loses the multi-replica rate-limit guarantee. Store the bucket and count under the declared key, or otherwise ensure every accessed key is explicitly declared in the same hash slot.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in e94bd4e. The Lua counter key now wraps the routing part in a hash tag — {namespace:win:key} — so KEYS[1] and the derived KEYS[1]:bucket hash to the same slot and Redis Cluster accepts the script (inert braces on single-node). The class doc was changed from a "single-node only" constraint to documenting the hash-tag. Verified: full SharedState suite green against the Testcontainers Redis, including the counter contract tests that exercise the new key shape.
| shared = env.get("SharedState__Redis__ConnectionString") | ||
| if shared is not None and str(shared).strip(): |
There was a problem hiding this comment.
Reject a blank Redis endpoint in the simulation verifier
If an ambient override sets SharedState__Redis__ConnectionString= explicitly, Compose resolves it without an unset-variable warning and this condition skips validation, so the verifier reports success while the app selects the in-process fallback. That means the human-run Production harness can stop exercising the Redis-backed path this change added without any indication; require a nonblank endpoint here even though blank remains legal for ordinary serving deployments.
AGENTS.md reference: AGENTS.md:L68-L68
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in e94bd4e. verify-harness.sh now FAILS on an unset/blank SharedState__Redis__ConnectionString — a harness-specific policy, deliberately stronger than the app guard (blank stays legal for an ordinary serving deploy; the sim wires Redis on purpose, so a blank silently drops the Redis-backed path). Verified: green on the real redis:6379 value; SharedState__Redis__ConnectionString='' bash verify-harness.sh now exits 1 naming the drop. The rationale is recorded in the check's comment block.
| services.AddSingleton<IConnectionMultiplexer>(_ => ConnectionMultiplexer.Connect(parsed)); | ||
|
|
||
| // Grant replay: fail-closed decorator, NO in-process fallback. | ||
| services.AddSingleton<IClaimOnceStore>(sp => |
There was a problem hiding this comment.
Update the singleton state inventory
These new registrations add a connection multiplexer and three shared-state singleton services, including mutable in-process fallbacks, but the repository's deployment-invariant audit still records the previous singleton inventory and classifications. Future scale-out reviews are explicitly expected to enumerate and classify every singleton and in-memory primitive, so leaving these entries absent makes that checkpoint stale and risks treating the remaining single-instance blockers as closed without reviewing fallback behavior.
AGENTS.md reference: AGENTS.md:L159-L167
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in e94bd4e. AGENTS.md's deploy-invariant singleton inventory now records and classifies the four #543 registrations (IConnectionMultiplexer + the three ports). They are classified as the shared store that will close the in-process limiter blockers (#544 → IFixedWindowCounter, #545 → ILease, #338 grant-replay → IClaimOnceStore), not new blockers: Redis-backed they are multi-replica-safe, and their in-process fallbacks are a deliberate, alarmed degradation (fail-closed for claim-once). Noted that #543 only lands the ports — the blockers stay open until a caller is wired.
…es (#543) Four-reviewer pre-merge round (codex, security auditor, code reviewer, pi) on PR #552. Five confirmed product defects, each fix mutation-proven: - Resilient decorators caught only RedisException; RedisTimeoutException derives from TimeoutException, so a command timeout — the commonest transient Redis failure — bypassed fail-closed (claim-once threw) and fallback (counter/lease threw, no alarm). Catch widened to `when (ex is RedisException or RedisTimeoutException)`; RedisCommandException stays uncaught (our bug, must surface). - InProcessFixedWindowCounter threw DivideByZeroException for any sub-second window ((long)TotalSeconds == 0) and bucketed on seconds while Redis buckets on ms — a backend divergence. Now buckets on ms with a 1ms floor (both backends). - The counter computed the window before taking the lock, so a stalled thread could overwrite a newer window and lose increments; the clock is now read under the lock (same for the claim/lease in-process stores). - The counter sweep compared every entry's WindowStart to the caller's windowStart, evicting a still-live longer-window counter; it now drops on each entry's own WindowEnd. - ConfigurationOptions.Parse throws UriFormatException (a FormatException), which escaped IsWellFormedConnectionString's ArgumentException-only catch and crashed a one-shot verb that should degrade (#347); the catch now includes FormatException. - Claim-once and lease shared the {ns}:{key} keyspace and could collide; a per-capability infix (claim:/lease:/win:) isolates them. Also: ILease/ResilientLease docs reframed (report-concurrency capacity cap, not the #271 job single-runner, which uses a Postgres advisory lock); RedisFixedWindowCounter documents single-node-Redis + tonumber() on TIME; malformed-message string deduped via a shared const; verify-harness #543 check documents its endpoint-presence-only scope. Tests: RedisTimeoutException fallback variants, sub-second/1ms-floor/sweep, exact-expiry-instant edges (claim + lease), Redis capability isolation, and UriFormat one-shot-degrade/serving-throw. Each verified to fail without its fix. Full SharedState suite 38/38; build clean (warnings-as-errors).
…y, docs (#543) Second review round (my 4 reviewers + codex-bot on PR #552). All findings addressed; each code fix mutation-proven. Round-2 reviewer findings (latent edge cases in the round-1 fix): - InProcessFixedWindowCounter rejected windows below 1ms but silently truncated fractional-ms windows (e.g. 1.5ms) to a narrower bucket than their nominal length — early reset. Both backends now reject any window that is not a whole number of milliseconds, rather than truncate. - Storing WindowEnd = windowStart + window could overflow DateTimeOffset for a multi-millennia window or a clock near MaxValue. WindowEnd is now saturated in ticks (MaxValue.Ticks - window.Ticks stays in long range, so the guard itself never throws). codex-bot (PR #552) findings: - RedisFixedWindowCounter derived its bucket key outside KEYS[1]'s hash slot, which Redis Cluster rejects (CROSSSLOT) → permanent silent in-process fallback. The key now wraps the routing part in a hash tag ({namespace:win:key}) so KEYS[1] and KEYS[1]:bucket share a slot; cluster-safe, and inert on single-node. - verify-harness.sh treated a blank SharedState Redis string as legal (it is, for the app), but the sim wires Redis on purpose — an ambient blank would silently drop the Redis-backed path with no signal. The harness now requires a non-blank endpoint (a harness-specific policy, stronger than the app guard, documented as such). - AGENTS.md deploy-invariant singleton inventory updated: the four #543 shared-state registrations recorded and classified — they are the shared store that will CLOSE #544/#545/#338-grant-replay, not new blockers; Redis-backed they are multi-replica-safe, their in-process fallbacks a deliberate alarmed degradation. Tests: NonWholeMillisecondWindow_Throws (in-process + Redis), HugeWindow_- DoesNotOverflow — each verified to fail without its fix. verify-harness blank-rejection confirmed red on an ambient blank, green on a real value. Full SharedState suite 41/41; build clean.
|
Review loop wound down deliberately (owner decision). Two full pre-merge review rounds (codex + 2 Claude agents + pi each) plus this codex-bot round:
The live single-node deployment path is correct and every fix is pinned by a test verified to fail without it. Remaining review yield had moved to latent edges / Redis-Cluster (we deploy single-node) / dev tooling / docs, so per our stop rule the loop was stopped at this point rather than tagging for further rounds. The three thread replies above record each fix and its verification. Not tagging |
|
No further action taken. The trigger explicitly records the review loop as complete, all three findings as fixed in Checks
No changes were made, so no commit or follow-up pull request was created. |
…-redis-ports # Conflicts: # AGENTS.md # tests/Cluckwork.Api.IntegrationTests/packages.lock.json
…te (#543) The full deploy/docker-compose.yml now runs a co-located redis sidecar (same digest-pinned image as the Testcontainers fixture and the #243 sim harness) and wires SharedState__Redis__ConnectionString=redis:6379 into the app, gated on redis being healthy. The reference stack boots the real Redis-backed shared-state path, matching a scaled deploy's config shape, even though it runs a single instance. Blank stays legal (in-process fallback); this stack wires Redis on purpose. Not published, no auth — a co-located plaintext sidecar like the stack's Postgres; a real deploy uses managed Redis.
docker-compose.dev.yml now runs a loopback-published redis (:6379) alongside Postgres, so an IDE-run API can exercise the real Redis-backed shared-state path. Optional: leave SharedState:Redis:ConnectionString unset in user-secrets for the in-process fallback, or set localhost:6379 to use Redis.
…ig guard (#543) OneShotVerbMinimalConfigTests.EveryConfigSection_IsEitherProbedOrDeliberately- Excluded walks every SectionName const and requires each be probed or excluded; #543 added SharedStateOptions (section 'SharedState') without classifying it, so the guard correctly failed CI. SharedState is serving-only (the malformed-value boot guard is serving-only; no one-shot verb consumes the ports), so it joins the probed set with a hostile value ('abortConnect=false' — parses, names no endpoint) proving recover-admin degrades to in-process and survives, while a serving boot would reject it. OneShotVerbMinimalConfigTests 15/15 locally.
🤖 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>
Closes #543. First slice of the scale-out epic (#530): three capability-specific
shared-state ports with a Redis primary and an in-process fallback, so the
multi-instance blockers (#544/#545, #271/#338) have a shared store to build on.
No handler uses the ports yet — this slice is the seam.
Ports (capability-specific, not a generic "redis client")
IClaimOnceStore— single-use claim (SET NX PX). For step-up grant replay.IFixedWindowCounter— atomic INCR+PEXPIRE, server-time bucketed (LuaTIME,so replicas agree regardless of host clock skew). For the auth limiters.
ILease— renewable compare-and-swap lease. For the report-concurrency cap.Failure policy is per-capability, on purpose
RedisExceptiondenies the claim (cannot provesingle-use → refuse). It gates privileged operations; no fallback.
(
SecurityEvents.SharedStateRedisUnavailable— a deployment should alert on it).RedisExceptionis caught, never a barecatch.Config / boot (#347)
SharedState:Redis:ConnectionStringblank → in-process (Option B, singleinstance). Configured → Redis wrapped in the resilient decorators,
AbortOnConnectFail=falseso an unreachable Redis degrades at runtimerather than failing the boot.
never uses shared state, so it degrades with a stderr warning. Discoverable
serving-only guard (
ServingGuardCoverageTests) with itsProcessRoleGuardTestsrow proving it fires.
Sim harness (#370)
SharedState__Redis__*wired intobootstrap.sh+docker-compose.sim.yml,and a bounded well-formedness check in
verify-harness.sh(blank legal;set → must name an endpoint).
Verification
tests, resilient-fallback tests (throwing stub → fallback + one alarm), boot-guard
arms (serving fails naming its token; one-shot survives).
dotnet buildclean(warnings-as-errors); NuGet lock files committed (CI: dependency-vulnerability and SAST gates #146).
verify-harness.shgreen, new check red on an options-only value;a full redis+db+app boot went healthy (
/health/ready200), no fallback alarm,redis reachable on the compose network.