Skip to content

feat(infra): optional leader-lease endpoint for pooled deploys - #869

Merged
mforce merged 1 commit into
mainfrom
feat/556-session-pinned-lease
Sep 14, 2026
Merged

mforce merged 1 commit into
mainfrom
feat/556-session-pinned-lease

Conversation

@mforce

@mforce mforce commented Sep 14, 2026

Copy link
Copy Markdown
Owner

Summary

  • Add an optional ConnectionStrings:LeaderLease config key that lets an operator point the advisory-lock leader lease at a session-pinned Postgres endpoint, independent of the app's request-traffic ConnectionStrings:Default (which may go through a transaction pooler).
  • When omitted, the lease falls back to Default (existing behavior, no breaking change).
  • The new key is normalized through the same PostgresConnectionString.NormalizeAndValidate pipeline (TLS floor, URI-form support, GSS default) as Default.

No session-affinity boot probe: the key is optional with a sensible fallback, and a reliable client-side pooling detection is not feasible (per the writing-a-guard decision).

Sim harness: documented the deliberate absence in docker-compose.sim.yml (direct sidecar, no pooler). AppHost: same reasoning, commented. .env.example: documented the new optional key with usage guidance. AGENTS.md: marked #556 closed in the deploy invariant section and removed it from Phase 1.6 remaining.

Closes #556

Test plan

  • dotnet build Cluckwork.sln — zero warnings
  • Domain tests (491), Application tests (291), AppHost tests (10) — all pass
  • Integration tests (PostgresLeaderLeaseTests) — behavioral; the lease itself is unchanged, only the config wiring that feeds it. No new test needed: the change is a config fork with fallback, not new lease behavior.

…d deploys (#556)

The PostgresLeaderLease advisory lock requires a session-pinned Postgres
endpoint for its single-leader guarantee. Under a transaction-pooling proxy
the app's Default connection cannot provide that.

Add an optional ConnectionStrings:LeaderLease key that, when configured,
is normalised through the same TLS floor as Default and handed to the
lease instead. When omitted the lease continues to share Default, which
is correct for direct-to-Postgres and session-pooled topologies.

Closes #556
@coderabbitai

coderabbitai Bot commented Sep 14, 2026

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Advanced

Run ID: 4f219b41-e46a-4b51-bdc8-edf56f94dc6b


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@mforce
mforce merged commit e9bc6a7 into main Sep 14, 2026
17 checks passed
@mforce
mforce deleted the feat/556-session-pinned-lease branch September 14, 2026 15:23
mforce added a commit that referenced this pull request Sep 16, 2026
…one action rule (#823) (#871)

## Why

Slice 1 of the #674 SPA revamp, scoped by
`docs/designs/822-mui-revamp.md` D8 row 1. It is
mechanics, not looks: every screen still renders its own hand-built
markup, and this puts the
theme, the guard and the phone action rule underneath them so the screen
slices that follow
inherit a decided baseline instead of each deciding again.

**It also found the thing that blocks the rest of the epic, and that
finding outweighs the
slice.** #822 D6 decided to adopt `CssBaseline`; this branch adopted it,
and it does not apply.
The production CSP is `style-src 'self'`
(`src/Cluckwork.Api/Security/SecurityHeaders.cs`) and
Emotion delivers every style by injecting a stylesheet, so the browser
refuses all of them.
Measured against the sim harness at 390: the `<style data-emotion>` tag
is in the document,
`html` computes `box-sizing: content-box` instead of CssBaseline's
`border-box`, `/daily-entry`
lays out 419px wide inside a 390px frame, and the console reads
*"Applying inline style violates
the following Content Security Policy directive `style-src 'self'`"*.
**The same path carries
every `sx`, `styled()` and `styleOverrides` value #826 to #836 will
add**, so nothing MUI renders
will be styled until this is settled. Nobody could have seen it before
now: the app renders no
MUI component, so the theme had nothing to emit.

So this PR takes D6's own recorded alternative — keep `styles.css`'s
baseline, neutralise the
bare elements anyway — and the only thing it changes on screen is the
phone action rows, which
closes #740. The theme and its guard land as groundwork and are honest
about being inert.
**The CSP question wants its own decision** and I have not filed an
issue for it; see the bottom
of this description for what the three candidate answers cost.

## Scope

- `web/src/theme/FarmThemeProvider.tsx`. `createFarmTheme` grows the
elevation array, the
three-step radius scale, the typography variants the stylesheet already
renders, 8px spacing,
sentence case on `button` and `overline`,
`MuiPaper.defaultProps.elevation = 0`,
`MuiCard variant="outlined"`, the `MuiAutocomplete` paper shadow,
`MuiButton disableElevation`
plus its pill radius and phone-only 44px floor, and the
`MuiDialogActions` phone stack.
`CssBaseline` stays unrendered, now for a measured reason rather than a
deferred decision.
- `web/src/theme/farmTokens.ts`. `THEME_TOKENS` grows by `--surface-2`,
the five `--tint-*`,
`--stat-accent`, `--r-input`, `--r-panel`, `--r-pill`, `--shadow-bar`,
`--shadow-dialog`.
`farmTokens.test.ts` still proves every one is declared in `styles.css`.
- `web/src/theme/farmTheme.policy.test.ts`. G2, walked over all four
palettes and both modes.
- `web/src/styles.bare-elements.test.ts`. The postcss walk over every
rule whose selector names
an element, with the 11 that reach MUI's DOM anywhere in the app pinned
by selector and by
  declared property.
- `web/src/styles.css`. The bare-element rules wrapped in `:where()`;
the phone action rules
rewritten. `*` and `body` stay and are recorded in the guard as
deliberate: they are the
  baseline `CssBaseline` would have taken over.
- `tools/simulation/ui/specs/phone.spec.ts` and `src/mutants.ts`. The
action walk now covers the
Sales draft panel and asserts width share as well as ratio; the mutant
reverts the rule.
- `docs/decisions/674-ui-component-library.md`. The §9 amendment.

The six `.actions` sites, all unchanged in markup: `SalesPage.tsx:1492`
(the #740 reproduction)
and `:1612`, `ExpensesPage.tsx:700`, `InventoryPage.tsx:703`,
`SettingsPage.tsx:831`,
`DailyEntryPage.tsx:859`. The two dialog-foot rules changed are
`styles.css:3072-3075` and
`3077-3080`.

**Every component that reads `theme.shadows` by index**, walked with
`grep -rn "shadows\[" node_modules/@mui/material --include='*.js'`
before the map was written:
`Button` (2 rest, 4 hover, 6 focus, 8 press, 0 disabled — handled by
`disableElevation`),
`Paper` (dynamic, by `elevation` prop), `ButtonGroup` (2), `Chip` (1),
`Switch` (1),
`SpeedDialAction` (1), `Slider` (2), `Fab` (6 rest, 12 hover, 0),
`BreadcrumbCollapsed` (0).
Under this map every one of those except `Button` and `Fab` resolves to
`none`. None of them
renders in the app today; the ones worth knowing about when they arrive
are `Fab`, which would
carry the bar shadow at rest, and `Switch`, whose thumb loses its
shadow.

## Tradeoffs

**`:where()` demotion instead of per-property `components` overrides.**
The design asked for
theme overrides. They cannot do this job for the compound forms:
`button:hover:not(:disabled)`
is 0,2,1 and `.MuiButton-contained:hover` is 0,2,0, so any override
strong enough to beat the
stylesheet also beats MUI's own variant and has to decide the hover
colour for every variant and
colour rather than hand it back. `:where()` contributes no specificity,
so one Emotion class
outranks the whole family and MUI decides. Nothing is deleted and no raw
consumer changes;
`styles.bare-elements.test.ts` holds it, and mutation M10 (un-demote one
selector) reddens it.

**Nothing is deleted from `styles.css:299-315`, where D6 and D8 say to
delete it.** `*` and
`body` stay because their replacement does not apply (above). `h1, h2,
h3, h4` stays for a
second, independent reason: no screen renders `Typography` yet, so it
has no successor until
#829 to #833, and deleting it now would drop every heading in the app
from weight 800 with
-0.02em tracking to the browser default.

**`.empty-state` (`styles.css:1255-1274`) is not deleted**, though D8
row 1 lists it. Deleting
it needs `EmptyState`'s internals rendered as `Typography` and `Button`,
which is a visible
change. It moves to the slice that converts the component.

**The daily-entry footer pair stays side by side**, which is #822 §7's
owner-review row resolved
the other way from the design's default. The first version of this
branch stacked it, and
CodeRabbit caught that it then contradicted the #864 mockup, which keeps
the pair at 390
(`docs/designs/864-visual-language/daily-entry.html`,
`grid-template-columns: 1fr 1fr`). The
owner confirmed that mockup after seeing it, so the mockup is the
authority and the CSS follows
it: F134's pair is restored and `styles.css:2993-2996` keeps buying back
the 2.3rem of width
that makes it fit a thumb. Everything else stacks, including the Sales
draft panel, which is
where #740 actually reproduced.

## Blast Radius

Every screen at once, which is the point and the risk: `CssBaseline` and
the `:where()` demotion
are global. The demotion changes no cascade outcome that exists today,
because nothing in the
stylesheet declares these properties at zero specificity, so the only
rules that gain are
Emotion's — and Emotion renders nothing on any screen yet. The
line-height change is real and
app-wide, and the captures are where it is judged. Nothing server-side
moves: no `.cs`, no
migration, no config key, so the sim harness and the AppHost need no
teaching.

## Verification

**Web suite.** 3020 tests in 128 files pass, from 2998 in 126 at the
base: +22 across
`farmTheme.policy.test.ts` (G2) and `styles.bare-elements.test.ts`. `npm
run typecheck` clean.
`npm run test:coverage` at 94.16 lines / 91.23 statements / 86.77
functions / 87.77 branches,
every one above both the `vite.config.ts` floor and the 93.86 / 90.9 /
86.21 / 87.64 the design
measured at #860. Nothing was re-baselined. No `.cs` moved, so `dotnet
build` is not in play;
the two `ImagePin_IsOneIdenticalStringAcrossEveryTrackedFile` tests were
run before the record
amendment was committed and both pass.

**No test was retired.** `styles.elevation.test.ts`'s `declarationsFor`
now looks through a
`:where()` wrapper, so its `input` radius assertion keeps the reach it
had rather than losing it
to a selector rename. Every other `styles.*.test.ts` passes unchanged.

**Mutations, each run red before the claim was written.**

| # | Mutation | Result |
|---|---|---|
| M3 | `shadows[1] = --shadow-bar` | red — `aubergine/light shadows:
expected [ 'none', …(24) ] to deeply equal […]` |
| M4 | `typography.button.textTransform = "uppercase"` | red — `expected
'uppercase' to be 'none'` |
| M5a | `MuiPaper.defaultProps.elevation = 1` | red — `expected 1 to be
+0` |
| M5b | `MuiPaper.defaultProps.variant = "outlined"` | red — `expected {
elevation: +0, variant: 'outlined' } to not have property "variant"` |
| M6 | `shape.borderRadius` from `--r-card` | red — `aubergine/light
default radius: expected 16 to be 10` |
| M8 | remove the `MuiAutocomplete` paper override | red —
`MuiAutocomplete paper is missing: expected undefined to be defined` |
| M9 | remove `MuiButton disableElevation` | red — `expected undefined
to be true` |
| M10 | un-demote `button:hover:not(:disabled)` | red — the bare-element
pin |

M10 is not in the design's table. It was added because the demotion is
this slice's whole
neutralisation mechanism and nothing else would have noticed it being
undone.

**M7 and its mirror, against the live stack.** `bash mutation-check.sh
phone-action-label-wrapped phone-entry-foot-stacked`: both killed,
baseline green, restore green,
and each one's `MUST_STAY_GREEN_ON` re-run of the whole desktop project
came back green, which is
what makes the rules width-scoped rather than merely broken. M7 no
longer lengthens a label — a
full-width button cannot be made taller than it is wide, so the old
mutant would have survived —
it reverts the stacking rule instead. `phone-entry-foot-stacked` is new
and is the mirror: with
the daily-entry pair exempt, M7 can no longer reach that row, which left
the walk's side-by-side
branch an assertion nothing could falsify. It stacks that row and
reddens it alone.

**The bare-element guard's own mutation.** A global `fieldset { border
}` added to
`styles.css`: red on two assertions, naming `fieldset {border}`. The
control is the finding —
the same rule under the pre-fix fixed element list left the pin
**green**, and only the
non-vacuity floor moved.

**Playwright against the sim stack at this head.** `chromium-phone` 5 of
5 pass, including the
walk extended to `/sales` as `phone.spec.ts`'s own comment asked (that
comment is deleted with
it). `chromium` 41 pass, 1 skipped (the 15-minute boundary). The walk
now declares the layout
each row must have and asserts the width share in both directions,
because "full width
everywhere" would fail on the one row the design says must not be full
width.

**The #740 measurements, at 390.**

| Row | Before | After |
|---|---|---|
| Sales draft, `en` | `91.5 x 103.2`, `89.8 x 103.2`, `89.9 x 103.2` in
a 295.2 row | all three `295.2` wide, 46.2 and 44.2 tall |
| Sales draft, `tl` | — | all three `295.2` wide; the longest label
wraps to two lines at 65.2 and stays far wider than tall |
| Daily entry saves | `170.6 x 65.2` each, 48% of a 353.2 row |
unchanged, and deliberately so |
| Sales draft at 1280 | `285.7 x 40.1`, `98.8`, `50.8` | unchanged |

**Captures.** 1:1 at `deviceScaleFactor: 1`, 1280x800 and 390x844,
Dashboard, Daily entry and
Sales, plus the Sales draft-order panel because that is where `.actions`
lives and it renders
only while a draft is open. The before set is the sim stack built at
`16d0350`; the after set is
the same stack rebuilt at this branch's head. One `main` commit sits
between them (#869) and it
touches no file under `web/`.

With `CssBaseline` declined there should now be exactly one difference
anywhere: the phone action
rows stack. Every other pair should be identical. Anything else is a
leaked override.

**A note on the harness, because it cost a wrong conclusion.** The sim
stack is one shared docker
compose project (`cluckwork-sim`, hard-gated by `reset.sh`), and another
worktree rebuilt it
during the first after pass. Those captures showed clipped content at
390 and read exactly like a
leaked override. They were the stale-bytes failure AGENTS.md names,
arriving as a live race rather
than an old container. The capture run now asserts the served stylesheet
contains this branch's
own selector before it believes a pixel, and the same comparison was
reproduced independently
against two Vite dev servers, one at `origin/main` and one at this head,
which measured identical
`documentElement.scrollWidth` on all six screen-and-width pairs.

## The CSP question, for whoever picks it up

Three candidate answers, with what each costs, so nobody re-derives
them:

- **`'unsafe-inline'` on `style-src`.** One line, and a real relaxation
— it re-opens CSS
injection, including the attribute-selector exfiltration class. It is a
security decision, not
  a build one.
- **A per-request nonce threaded into `@emotion/cache`.** Keeps the
policy strict. It needs
`index.html` templated per request rather than served as a static file
from `wwwroot`, which
  changes how the SPA is served.
- **Build-time extraction**, so MUI's CSS lands in a real stylesheet the
CSP already allows.
Heaviest, and it constrains what the runtime theme can compute — which
matters here, because
this app's palette is read off the document at runtime by design (#674).

I have not opened an issue for it.

## Documentation

`specs/product/GLOSSARY.md` and the Help page do not change: no concept
appears or changes
meaning. No new user-facing string, so nothing to translate.

A deslop pass was run over the diff against `main` before each commit.

Closes #823
Closes #740


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Added dashboard and daily-entry design mockups with responsive desktop
and mobile layouts.
* Added light/dark themes and multiple brand color options for design
previews.
* Added dashboard views for egg counts, recent sales, inventory, and
14-day trends.
* Added daily-entry controls for egg counts, feed, water, and mortality,
with draft and submission actions.

* **Style**
* Improved theme consistency with updated typography, spacing,
elevation, borders, and component defaults.
* Mobile action controls now stack vertically and provide larger touch
targets.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
mforce pushed a commit that referenced this pull request Sep 16, 2026
🤖 I have created a release *beep* *boop*
---


## [0.1.2](v0.1.1...v0.1.2)
(2026-09-16)


### Features

* **data:** standardize business record chronology
([#820](#820))
([6231b31](6231b31))
* **infra:** optional leader-lease endpoint for pooled deploys
([#869](#869))
([e9bc6a7](e9bc6a7))
* **sim:** seed a second farm for the README dashboard capture
([#867](#867))
([de407c6](de407c6))
* **web:** adopt MUI, themed from the farm palette tokens
([#674](#674))
([#860](#860))
([6c83c5c](6c83c5c))
* **web:** convert Daily entry to MUI, field-first on the phone
([#888](#888))
([b66f8b8](b66f8b8))
* **web:** convert the Dashboard and app shell to MUI
([#829](#829))
([#883](#883))
([2e94277](2e94277))
* **web:** retire the Slack-blue link colour for ink + a rule underline
([#884](#884))
([c08f9d8](c08f9d8))
* **web:** serve a per-request CSP nonce so Emotion's styles apply under
style-src 'self'
([#874](#874))
([ba4e6f3](ba4e6f3))
* **web:** visual language theme overrides for the MUI revamp
([#864](#864))
([#882](#882))
([0bb6b73](0bb6b73))
* **web:** whole-app MUI baseline, theme policy guard and the
[#740](#740) phone action rule
([#823](#823))
([#871](#871))
([af565e4](af565e4))


### Bug fixes

* **auth:** fail closed on unresolved flock-scope actors
([#787](#787))
([#868](#868))
([16d0350](16d0350))
* **auth:** make farm configuration owner-only
([#870](#870))
([42f9036](42f9036))
* **e2e:** repoint the canary at the markup two PRs replaced
([#844](#844))
([18b45dc](18b45dc))
* **i18n:** tl glossary uses the standard passive of ilagay
([#813](#813))
([20dec10](20dec10)),
closes [#738](#738)
* **sim:** stop the k6-baseline EXIT trap masking a clean run as failed
([#838](#838))
([f5ec96f](f5ec96f))
* **web:** declare the rule tokens the Dashboard reads, and guard
undeclared custom properties
([#885](#885))
([5bead1f](5bead1f))


### Performance

* **ci:** start the serialized integration collection first
([#861](#861))
([1dcc7f6](1dcc7f6)),
closes [#839](#839)


### Documentation

* **auth:** record the OAuth 2.1 decision for MCP authentication
([#801](#801))
([0510854](0510854))
* **designs:** MUI revamp design doc, component map, layout system, IA
([#862](#862))
([da49481](da49481))
* **readme:** recapture the daily entry, reports and sales screenshots
([#865](#865))
([f18e336](f18e336))
* **specs:** correct the sales_order_items column list in §10.5
([#812](#812))
([afe4a02](afe4a02)),
closes [#737](#737)

---
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.

Leader lease: dedicated session-pinned endpoint for transaction-pooled deploys

1 participant