Skip to content

feat: let owners change user email addresses - #605

Merged
mforce merged 5 commits into
mainfrom
feat/change-user-email
Aug 26, 2026
Merged

mforce merged 5 commits into
mainfrom
feat/change-user-email

Conversation

@mforce

@mforce mforce commented Aug 26, 2026

Copy link
Copy Markdown
Owner

Driver handout — #357 change a user's email

Driver-owned working artifact. The locked design, increment plan, runbook, verification evidence,
and review history will be added only after their corresponding feature-driver gates pass.

1. Dispatch contract (owner-approved 2026-08-25)

  • Mode: feature.
  • Implementer: isolated Codex cloud subagent, gpt-5.6-sol; it will receive a prescriptive
    runbook and an explicit file allow-list. Rationale: the implementation needs inference across
    Identity, credential revocation, tenant isolation, a barrier-controlled race, HTTP wiring, and SPA
    state; spec cost is comparable to diff cost, so a transcriber is not the right implementation tier.
  • Reviewers:
    • Claude Code: repository-rule compliance across the complete changed-file set; its brief will name
      AGENTS.md, the applicable decision records, and the final changed files, with a five-minute
      budget and a MERGE-BLOCKING/FOLLOW-UP verdict for every finding.
    • pi llamacpp/qwen3.8-27b-q5-xl-220k-q8kv: authentication, authorization, and tenant-isolation
      invariants in the Identity adapter, application handler, endpoint, and integration tests; five-minute
      budget and a MERGE-BLOCKING/FOLLOW-UP verdict for every finding.
    • pi vllm/deepseek-v4-flash-0731-nvfp4-dspark-ctx262144: concurrency/race safety and false-green
      tests, including credential-epoch, refresh-token, step-up, and mutation coverage; five-minute budget
      and a MERGE-BLOCKING/FOLLOW-UP verdict for every finding.
    • The SPA/API field-error, accessibility, help, glossary, and en/es/tl contract is explicitly
      included in the Claude and Qwen briefs rather than assigned an unchosen fourth backend.
  • Seat briefed on the repo's own written rules: Claude Code; AGENTS.md,
    docs/architecture.md, docs/decisions/269-transient-db-retry-boundary.md,
    docs/decisions/364-credential-epoch-revocation.md,
    docs/decisions/394-write-contract-callers.md, and
    docs/decisions/530-multi-farm-tenancy.md, plus any additional decision record the design walk finds.
  • Background visibility: concise progress updates in the driving conversation.
  • External review trigger: none; these are driver-dispatched reviewers, not a PR bot.
  • Escalation: the same increment failing twice returns to the owner for re-routing; no third attempt
    at the same tier.
  • Git/PR authority: the implementer may create the feature branch, commit, push, and open a draft PR.
  • Lessons log: /home/mforce/.local/state/cluckwork-feature-driver/357-lessons.md, deliberately
    outside the repository and excluded from reviewer artifacts.
  • Applied by, per increment: none yet.
  • Fix budget: 0 shipped fixes; driver-run Phase 11 mutations may be injected and reverted.
  • Direct applications spent: 0 of 0.
  • Correctness-critical findings so far: the pre-signoff Claude architecture pass required
    constraint-specific duplicate mapping and restoration of tracked identity fields on every
    pre-commit failure; both are design corrections, not product-code fixes.

Design-fix ledger

Fix ID Round Finding Invariant ID + one-line statement Enforcement sites (symbols) Prior entries compared Same area as / "new" + why SHA (filled after)

2. Review loop

Every selected reviewer returns a MERGE-BLOCKING or FOLLOW-UP verdict per finding; neither verdict is
preferred, and inflating a follow-up to be safe costs a fix round an issue would carry. A round is clean
only when all three reviewers answer on the same unchanged head. Stop after two consecutive rounds find
no product-code defect, or when all findings are regressions created by the preceding fix.

3. Locked design (owner-approved 2026-08-25)

Scope and behavior

  • Add PUT /api/v1/users/{id}/email, returning 204 on success and on a true no-op. It remains under
    the Owner-only users group, requires Idempotency-Key, requires an unconditional step-up grant, and
    carries a 2048-byte request-body cap.
  • The request is { "email": "..." }. Validation mirrors create-user's required/email/256-character
    rules. The handler trims the value before comparing or writing it.
  • A trimmed value exactly equal to the stored Email is a true no-op: no stamp rotation, epoch bump,
    token revocation, or audit row. A case-only correction is a real change even when normalization yields
    the same indexed value.
  • A disabled target may have a typo corrected and remains disabled. The authenticated actor, not the
    target, must still be an active Owner at the point of mutation.
  • A self-change is allowed when another active Owner exists. A sole Owner's self-change fails with an
    actionable message that says to add a second Owner first.
  • A same-farm collision returns 409 Users.DuplicateEmail; the SPA attaches it to the email field. The
    same normalized address in another farm succeeds. A foreign user id returns 404.
  • No SMTP or verification flow is added. EmailConfirmed is preserved. Help and glossary copy explicitly
    say that an Owner's administrative change takes effect immediately and sends no confirmation email.

Application and HTTP shape

  • Add a ChangeUserEmailCommand, validator, and handler beside the existing user feature slices. The
    handler consumes the step-up grant before entering Identity and passes accountId, target id, acting
    user id, and the trimmed address through a new narrow IIdentityProvider.ChangeUserEmailAsync port.
  • Add the endpoint and request record to UserEndpoints, map validation failures with the existing
    ValidationResponse, map missing/stale proof and a stale actor to 403, foreign ids to 404, duplicate or
    concurrency conflicts to 409, Users.LastOwner to 422, and all remaining expected Identity failures
    to 422.
  • Register the handler and validator in
    src/Cluckwork.Api/Hosting/CluckworkFeatureServiceCollectionExtensions.cs, the current registration
    seam reached by Program.cs. Do not broaden UpdateUserAsync: display-name edit remains ungated and
    has none of this operation's credential semantics.

Identity transaction and concurrency contract

IdentityProvider.ChangeUserEmailAsync uses AmbientTransaction.RunAsync and this order:

  1. Take the account row lock unconditionally and verify it matches accountId.
  2. Load the target by (userId, accountId) and re-read the actor as an active Owner inside the lock.
  3. Return the exact-value no-op before any mutation.
  4. For a self-target, use CountOtherActiveOwnersAsync; zero returns Users.LastOwner with “add a second
    Owner first” wording.
  5. Snapshot every user scalar this operation can touch (Email, NormalizedEmail, UserName,
    NormalizedUserName, SecurityStamp, ConcurrencyStamp, and CredentialEpoch), together with the
    entry's incoming state/modified flags. Then assign the four email/login fields together using
    Identity's configured normalizer. Preserve EmailConfirmed, role, disabled state, password, and every
    other profile field.
  6. Rotate SecurityStamp through UserManager.UpdateSecurityStampAsync. This runs the configured
    AccountScopedUserValidator, invalidates outstanding step-up grants, and rotates Identity's
    concurrency stamp. Do not add a uniqueness pre-read.
  7. On a real change, increment CredentialEpoch, revoke every active refresh token, and append
    AuditActions.UserEmailChanged with { oldEmail, newEmail } details before the final save and commit.

An Identity result containing DuplicateEmail or DuplicateUserName maps to the one public
Users.DuplicateEmail conflict. A database race maps the same way only when the inner
PostgresException is SQLSTATE 23505 and its ConstraintName is exactly EmailIndex or
UserNameIndex; every other DbUpdateException propagates. Concurrency failures map to
Users.Conflict. Restore every snapshotted value and its incoming tracking state on every pre-commit
error return or caught failure, and detach only the audit row this operation added if the final save
failed; transaction rollback alone does not rewind EF's in-memory entity for a longer-lived non-HTTP
caller. Do not clear the whole change tracker or disturb entries that belonged to the caller.

There is no inner retry and no durability probe: the request-wide idempotency transaction already runs
under SingleAttemptExecution. The account lock order matches role/disable/enable and makes the
last-active-Owner decision race-safe.

SPA and documentation

  • Add changeUserEmail to the API client and a dedicated row action/dialog to UsersPage; do not fold
    identity changes into the display-name dialog.
  • The dialog identifies the target, explains that the new address becomes the login immediately and no
    confirmation email is sent, collects the new email and the acting Owner's current password, and uses
    the existing Dialog, BusyButton, useDialogErrors, usePendingAction, target-ref, step-up, and
    idempotency-key patterns. Clear the password from state immediately after sending it for step-up and on
    close/logout.
  • Render Users.DuplicateEmail through dialog-local emailFieldError state, not useDialogErrors (whose
    contract is page/dialog strings only). Bind the input with aria-invalid and described error text;
    clear the field error on a new attempt, input change, close, and target change, while activeEmail
    prevents a late response from attaching to a reopened dialog. Other failures remain dialog errors. On
    a self-change, the next authenticated request intentionally follows the existing
    credentials-superseded sign-in path; no replacement tokens are minted.
  • Add complete en, es, and tl catalog entries, Help-page guidance, the in-app glossary entry, and
    the corresponding product definition in specs/product/GLOSSARY.md.

Pre-signoff architecture review

Claude Code (claude-sonnet-5) returned two merge-blocking design findings after reading the
relevant Identity and persistence seams:

  1. A concurrent duplicate must be mapped to Users.DuplicateEmail only when the inner PostgreSQL
    exception is SQLSTATE 23505 and names EmailIndex or UserNameIndex; a blanket
    DbUpdateException catch would misreport unrelated database failures as an email conflict.
  2. Before mutating the four identity fields, snapshot Email, NormalizedEmail, UserName, and
    NormalizedUserName; restore them on every early-return or caught-failure path. Rolling back a
    database transaction does not repair EF's tracked in-memory entity in longer-lived non-HTTP callers.

Two non-blocking decisions are explicit in the revised design: preserve EmailConfirmed because the
product has no verification flow, and let a self-change invalidate the current session through the
existing credential-superseded UX rather than minting replacement credentials.

DeepSeek V4 Flash approved the locked design with two test-strengthening follow-ups, both incorporated:
the tracker-restoration test must reuse the same AppDbContext and perform a later save, and the refresh
race must present the late child after the email-change commit and assert 401 rather than merely inspect
revocation rows. The first Qwen design-review attempt reached its five-minute cap without a verdict; its
single permitted retry completed through a Herdr wrapper and approved with two follow-ups, also
incorporated: explicitly map Users.LastOwner to 422 and use dedicated accessible field-error state for
the SPA duplicate conflict.

4. Increment plan

Full checkbox plan: docs/superpowers/plans/2026-08-25-change-user-email.md.

  1. Baseline and branch: record backend/frontend baseline on the untouched base, verify create targets,
    and create feat/change-user-email only after owner plan signoff.
  2. Backend vertical slice: write the HTTP/Identity/race tests red, then add the command, validator,
    handler, narrow Identity port, account-locked mutation, exact duplicate/concurrency mapping, audit
    vocabulary/readers, endpoint, body cap, and DI registrations; commit one independently green backend
    unit because the port/provider/endpoint cannot compile honestly as separate partial commits.
  3. SPA + documentation slice: write Users/Help tests red, then add the client call, dedicated accessible
    dialog, field-specific duplicate handling, late-response protection, en/es/tl, Help/in-app
    glossary, and product glossary; commit one independently green user-facing unit.
  4. Verification and draft PR: run CI-equivalent backend/frontend gates in the foreground, verify no
    migration/schema or mutation debris, push, and open a draft PR before review. The driver—not the
    implementer—then performs the recorded mutation matrix and independent acceptance verification.

Increment-plan review disposition

  • Qwen found one accepted blocker: the duplicate classifier was specified private while its named
    precedent-style test requires the Infrastructure assembly's existing internal test seam. The plan now
    specifies internal static, matching AccountProvisioner.IsSlugConflict.
  • Qwen's claimed ApplicationUser global-filter blocker was rejected: ApplicationUser is deliberately
    filter-free in AppDbContext. The plan nevertheless names WithTenantScopeAsync plus an explicit
    (Id, AccountId) predicate so the test cannot drift into IgnoreQueryFilters or an unscoped lookup.
  • Qwen's stamp-snapshot ordering concern was already satisfied by the locked sequence; the plan now says
    “before any assignment and before UpdateSecurityStampAsync” so it cannot be misread. Its 413,
    audit-registry, active-target, and normalizer suggestions were accepted as clarity improvements.
  • Claude reached its eight-turn limit with no result; DeepSeek reached the five-minute cap with no output.
    Neither silence is counted as approval.
  • Qwen's focused confirmation approved the corrected plan. Its only note was the mechanical registry-name
    typo AUDIT_ACTION_ENTITY_TYPES; the plan now names the real singular AUDIT_ACTION_ENTITY_TYPE.
  • The user-authorized fresh Claude retry approved the corrected plan with no blockers. Its one follow-up
    is incorporated: the endpoint must match Users.DuplicateEmail literally for 409 because the code does
    not satisfy the siblings' .Conflict suffix test.

5. Standing repo rules that bite here

  • AccountScopedUserValidator remains the sole friendly uniqueness check; the two composite database
    indexes remain the race authority.
  • AuditActions.UserEmailChanged must be passed directly at the IAuditWriter.WriteAsync call site, and
    every reader of the audit-action registry (including web/src/i18n/enums.ts and its parity guards)
    must be updated.
  • Adding the handler/validator updates CluckworkFeatureServiceCollectionExtensions.cs registrations
    and their registration guards.
  • The endpoint must use the existing body-cap syntax before tests are written, because the guard inspects
    call-site syntax. Its write contract requires review of API client and non-CI callers; no seeder or k6
    caller currently invokes an email-change route.
  • This changes user-visible behavior, so the Help page, in-app glossary/locales, and
    specs/product/GLOSSARY.md move in the same PR.
  • No migration or generated schema-doc change is expected: all credential and uniqueness storage shipped
    in Users: disable / re-enable a user #356/Auth: login by farm code, per-account email identity, immediate suspension #532.

6. Acceptance criteria

  1. A valid Owner with fresh step-up changes any same-account user's login email and receives 204.
  2. All four identity columns contain the configured-normalizer result; a case-only edit is not treated as
    a no-op, while an exact trimmed match has no credential or audit side effect.
  3. A cross-account normalized duplicate succeeds; a same-account duplicate returns
    409 Users.DuplicateEmail, including when two writes race at the unique index.
  4. The target's pre-change access token fails on its next request, all old refresh tokens are unusable,
    and an in-flight refresh cannot leave a usable child. Outstanding step-up grants are invalidated.
  5. The new address can log in, the old address cannot, and the old address is released for another user
    in the same farm.
  6. A sole Owner cannot change their own address and the response says to add a second Owner first; with a
    second active Owner, the self-change succeeds and follows the existing signed-out UX.
  7. A foreign target id returns 404. A disabled target can be corrected without becoming enabled.
  8. The audit event contains old and new addresses and occurs only for a real successful change.
  9. The dedicated accessible SPA dialog performs step-up, clears password state, updates the row for a
    non-self target, localizes all copy in en/es/tl, and binds duplicate conflict copy to the email
    field.
  10. Help and both glossaries state that no confirmation email is sent. Relevant backend, integration,
    frontend, catalog-parity, typecheck, and build tests pass.
  11. A friendly duplicate failure followed by an unrelated save on the same AppDbContext does not flush
    the rejected email/user-name values or rotate either stamp. A final concurrency failure leaves neither
    a changed epoch nor this operation's pending audit entry in the tracker.
  12. The barrier-controlled refresh race presents the child minted on the far side of the change commit
    and receives 401; inspecting RevokedAt alone is insufficient evidence.

7. Merge evidence

Slot Value
PR number + head SHA pending
Driver-verified pending
Implementer-attested, NOT driver-verified pending
Reviewers run / rounds / stop-rule count pending
Review threads: all dispositioned? pending
Reviewers that never answered, and why pending
Deferred findings + issue numbers pending
Documentation surfaces rendered — locales + SHA pending
Acceptance criteria: delivered or filed pending
Retrospective pending
Applied by, per increment + budget spent pending

8. First moves

Pending the locked design and increment plan.

@mforce
mforce marked this pull request as ready for review August 26, 2026 13:57

@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: 1c8b898434

ℹ️ 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".

user.UserName = email;
user.NormalizedUserName = userManager.NormalizeName(email);

var stampRotated = await userManager.UpdateSecurityStampAsync(user);

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 Preserve unrelated entries across the Identity save

When a non-HTTP caller uses a scoped AppDbContext that already contains another modified entity, UpdateSecurityStampAsync invokes Identity's store update and SaveChangesAsync on this shared context, accepting every pending entry. If the later audit/final save fails, the transaction rolls those unrelated writes back, but RestoreTracker restores only the target user and new audit, leaving the unrelated entries Unchanged; a subsequent save silently loses those pending edits. Preserve the incoming state of all entries affected by this nested save, or perform the Identity update without flushing unrelated tracked work.

Useful? React with 👍 / 👎.

@mforce
mforce marked this pull request as draft August 26, 2026 14:30
@mforce

mforce commented Aug 26, 2026

Copy link
Copy Markdown
Owner Author

@codex review

@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: ef30142539

ℹ️ 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/routes/UsersPage.tsx Outdated
const password = emailStepUpPassword;
setEmailStepUpPassword("");
const grant = await stepUp(password);
if (activeEmail.current !== targetId) return;

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 Track email-dialog instances, not only target IDs

If an Owner submits, cancels while stepUp is pending, and reopens Change email for the same user, activeEmail.current again equals targetId, so the abandoned callback passes this guard and sends the old email change using the old password proof. The row action is not disabled by busy, making this sequence reachable; use a per-open attempt/generation token so reopening the same target cannot revive a canceled mutation or attach its result to the new dialog.

Useful? React with 👍 / 👎.

user.UserName = email;
user.NormalizedUserName = userManager.NormalizeName(email);

var stampRotated = await userManager.UpdateSecurityStampAsync(user);

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 Keep caller-owned changes pending after a successful update

When a non-HTTP caller has unrelated dirty entries in this shared AppDbContext, UpdateSecurityStampAsync saves them together with the user and the successful path subsequently commits the transaction, even if the caller never intended to save those entries. The new snapshot fix only invokes RestoreTracker on failure, so it does not address this successful path; isolate the Identity save or temporarily remove and restore caller-owned pending entries so the operation commits only its own changes.

Useful? React with 👍 / 👎.

@mforce

mforce commented Aug 26, 2026

Copy link
Copy Markdown
Owner Author

@codex review

@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: 05c2e8654c

ℹ️ 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 on lines +691 to +692
var user = await db.Users.FirstOrDefaultAsync(
u => u.Id == userId && u.AccountId == accountId, token);

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 Reload self-targets after acquiring the account lock

When two self-email requests overlap and another active Owner permits the self-change, StepUpGrantService.ValidateAsync has already tracked the acting user before this request waits for the account lock, so this tracked query returns the stale instance rather than the now-committed row. If the queued request asks for the pre-race email, the no-op check at line 700 returns success without exercising the concurrency stamp, leaving the first request's email in the database despite the second PUT returning 204. Load the target from fresh persisted state inside the lock and cover this ordering with a parallel-race test.

AGENTS.md reference: AGENTS.md:L75-L75

Useful? React with 👍 / 👎.

@mforce

mforce commented Aug 26, 2026

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

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

Reviewed commit: 6252faef2d

ℹ️ 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 marked this pull request as ready for review August 26, 2026 15:33
@mforce
mforce merged commit 842347b into main Aug 26, 2026
5 checks passed
@mforce
mforce deleted the feat/change-user-email branch August 26, 2026 16:23
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.

1 participant