Skip to content

feat(cli): rename-account verb to change a farm code (#732) - #733

Merged
mforce merged 17 commits into
mainfrom
feat/732-rename-account-verb
Sep 9, 2026
Merged

mforce merged 17 commits into
mainfrom
feat/732-rename-account-verb

Conversation

@mforce

@mforce mforce commented Sep 9, 2026

Copy link
Copy Markdown
Owner

Closes #732. Reverses epic #530 decision 10, which deferred the rename and recorded the farm code as immutable for that epic.

What ships

A rename-account --slug <current> --new-slug <new> [--reason <text>] one-shot verb, and the domain path behind it.

  • Account.Rename validates through the same TryValidateSlug provisioning uses, returns Result rather than throwing, and bumps Version on a real change. Renaming a farm to the code it already has is a no-op: success, no Version bump, no audit row — because Version is the token a Farm Settings save holds open against.
  • AccountRenameService resolves the code to an id, resolves the tenant, takes the tenant-keyed FOR UPDATE, then re-compares the locked row's slug against the code the operator named. The lookup and the lock are two separate statements, so without that fence a rename committed in between is silently overwritten. IX_Accounts_Slug is the authority on the destination code and the DbUpdateException catch is the guarantee; the pre-read in front of it is convenience only. The audit row is written inside the transaction, so it lands with the rename or not at all.
  • Audit vocabulary Account.Rename in all three locales, offered in the Audit filter.
  • Help and glossary in en/es/tl stop saying the code cannot change, and name the new (rename-account) system actor.

#731's SQL section is replaced

docs/runbooks/provisioning-a-new-farm.md § Renaming the default farm's code no longer teaches a hand-guarded UPDATE "Accounts" through psql. That procedure bumped Version by hand, validated neither the pattern nor the reserved set, wrote no audit row (it told the operator to record the change in the deployment repo instead), and needed a postgres: image literal in tracked prose to run the client at all. The section now documents the verb, with blast radius, prerequisites, verification, and one line per error code. docs/decisions/732-farm-code-rename.md records the reasoning.

Two accepted costs, both named in the decision record

  1. A retired code is immediately reusable. There is no retired-code list, so --slug targets whoever holds that code now — which may not be the farm you meant. The verb's help and the runbook both say to run list-accounts immediately before renaming, and AccountRenameServiceTests pins the reuse behaviour so it is a recorded fact. EPIC: Phase 1.6 — Multi-farm tenancy #530's sketch of this work assumed a retired-code list; shipping without one is the deliberate difference.
  2. Client-side caches go stale. The sign-in form's remembered code and the per-farm palette cache still name the old code until the next explicit sign-in. Both are cosmetic. Sessions are unaffected — tokens bind to the account id — and RenameVerb_LeavesAnExistingSessionWorking asserts it rather than reasoning about it.

Test counts

  • .NET: 2304 → 2333 (2304 + 29). Domain +11, Integration +18 (6 service, 10 CLI, 1 Version race, 1 minimal-config), Application and AppHost unchanged.
  • Web: 2706 → 2709 (2706 + 3).

Mutation ledger — all rows run this session

Every row below was planted, rebuilt with dotnet build Cluckwork.sln --configuration Release --no-restore, run, restored and rebuilt. None is implementer-attested-only; all were executed.

# Result Evidence
C GREEN (control) comment-only edit; git grep "the same accountability payload" -- tests prints nothing
1 RED Assert.Equal() Failure: Values differ / Expected: 1 / Actual: 0
1b RED Assert.IsType() Failure: Value is null — both stale snapshots saved
2 RED Expected: 0 / Actual: 1 — a no-op advanced Version
3 RED Assert.True() Failure on result.IsFailure in the reserved loop
4 RED Assert.False(outcome.Success) — without the post-lock fence the stale command overwrites the committed winner
5 RED Assert.False(outcome.Changed) fired first
6 RED Sequence contains no elements — committed with no trail
7 RED Audit events require a resolved actor… (#500 fail-closed)
8 RED 23505: duplicate key value violates unique constraint "IX_Accounts_Slug" escapes instead of Account.SlugTaken
9 GREEN, by design pre-read deleted; the friendly error still arrives from the index catch. Row 8 proves that catch with a race the pre-read cannot reach, so the two layers are independently pinned
10 RED expected success, got Accounts.NotFound — locked read selects WHERE "Id" = Guid.Empty
11a GREEN, by design the locked read is a real second layer
11b RED NullReferenceException at the stale-slug comparison once that layer is removed too
12 RED Collections differ naming "rename-account" missing
13 RED verb(s) classified OneShot but never run here: rename-account
14 RED Server emits but the client is missing: Account.Rename
15 RED parity expected [ 'auditAction.Account.Rename' ] to deeply equal [], and the locale test caught the English fallback masking it
surface 2 RED auditAction.Account.Rename should have a key mapping: expected undefined to be truthy

git grep -n -e MUTANT -e 'DEBUG-' -- src tests prints nothing.

Notes for the reviewer

  • One translation substitution. es uses "Código de granja cambiado", not the runbook's proposed "Se cambió el código de la granja": es.ts:48 establishes the noun as Código de granja (no article), and the three sibling audit labels are terse participial phrases. tl kept as proposed — it already uses tl.ts:54's Code ng bukid.
  • Four tenant-bypass rows, not two. The guard also flagged RenameAsync twice as a forwards-bypass caller of the two private reads. All four identities were pasted exactly as the guard printed them; the diff is 20 insertions(+) with no churn on rows I did not author, and no pre-existing row showed stale.
  • new { from = … } compiles, so the unescaped form is used, as 2c directed. nameof(Account) renders Account — asserted by Rename_ChangesTheCode_…'s EntityType check.
  • Flake observed, not fixed. One G5 run after npm ci showed Tests 2 failed | 2707 passed (2709) in StockPage.test.tsx on two async-supersession cases. Both passed in isolation (73 passed) and on a full G5 re-run (2709 passed). That file and every Stock source file are untouched by this branch.

Increments 2-5 land as one commit: AuditActions.AccountRename immediately
makes AuditVocabularyCoverageTests require the SPA value, and each new
IgnoreQueryFilters() site immediately makes TenantBypassRealTreeTests
require its registry row. Splitting them would commit a red tree.

- AccountRenameService: post-lock Account.SlugStale fence, friendly
  destination pre-read, IX_Accounts_Slug unique-violation translation to
  Account.SlugTaken, audit row inside the transaction.
- rename-account verb, registered once in CliDispatcher.Commands, with its
  OneShotVerbMinimalConfigTests case and the pinned dispatcher array.
- Audit vocabulary in en/es/tl plus the three enums.ts maps.
- Four tenant-bypass allowlist rows (two forwarding, two direct).
@coderabbitai

coderabbitai Bot commented Sep 9, 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: 24670035-ebad-4b7b-a421-a040df2d3b8e


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 mentioned this pull request Sep 9, 2026
27 of 28 tasks
Three findings from the operator review, all in increment 6's prose:

- The procedure invoked list-accounts and rename-account as bare
  executables. They are verbs of Cluckwork.Api.dll, and every other
  command block in this document uses the full digest-pinned image
  invocation. Restored that form.
- The failure list named Accounts.NotFound for an unknown source code.
  The verb short-circuits at AccountSlugLookup before the domain and
  prints the family's "No farm with code '<code>'." line instead.
  Documented what the operator actually sees.
- Step 1 still called the code immutable and framed the rename as a
  default-farm-only exception. The verb works on any farm.
…r code (#732)

Review round 1, F4. TryValidateSlug's description quotes the rejected value,
which is raw argv: `--new-slug "bad<newline>code"` printed a two-line stderr,
breaking the verb family's one-line contract and letting the second line forge
whatever an operator's terminal reads next.

The value now goes through ListAccountsCliCommand.SanitizeForDisplay -- the
same char.IsControl strip #560 applied to the tenant-controlled farm name.
Scope is this verb only: provision-account carries the same older defect and is
deliberately left alone rather than widened into this diff.
… record

Review round 1, F5 and F8.

F5: docs/decisions/732-farm-code-rename.md opened by saying its one-paragraph
version lives in AGENTS.md, and it did not -- neither the rule nor an index row
existed, so the half that loads into every session was missing. Added the
Data-and-correctness paragraph (rename-account only, never a raw UPDATE;
Version on a real change and not on a no-op; the post-lock slug recheck; the
unique index and its catch as the authority; retired codes immediately
reusable) and the decisions index row.

F8: the runbook's final `## Drill` sat under the rename failure guidance while
drilling provisioning. Renamed to `## Provisioning drill` and said so in its
first sentence.

RenameAccountDocsTests pins all three, plus f069469's executable examples and
the absence of immutability prose in that section.
)

Review round 1, F6. The decision record cites AccountRenameServiceTests as
where the retired-code reuse behaviour is pinned "so it is a recorded fact
rather than a surprise", and no test pinned it. This drives the whole sequence
-- farm A leaves a code, farm B takes it, a rename naming that code now moves
B -- and asserts B's audit row carries it as `from`.

Accepted behaviour, so this test is GREEN before and after: it is coverage the
record already claimed, not a regression.
… both codes

Review round 1, F7. Excuse matching is Any() over (file, symbol), so two rows
carrying the same key both excuse every occurrence under it: neither can go
stale on its own, and deleting one of the two sites the pair was written for
fires nothing. The rename service had exactly that shape -- four rows, two of
them a duplicated RenameAsync key covering its two forwarding occurrences.

Two changes, and both are needed:

- GuardScanner refuses a duplicate normalized file+symbol key outright, as a
  parse error so it rides the gate Evaluate already has. Fail-closed: the
  registry can no longer carry a row that excuses nothing on its own.
- AccountRenameService resolves the source code and the destination code in ONE
  unresolved-tenant read, so the service has one forwarding occurrence and one
  direct occurrence, each taking one row. Neither half becomes an authority:
  the post-lock slug fence still covers the source and IX_Accounts_Slug with
  its unique-violation catch still covers the destination.
Review round 2. Round 1 sanitized the rejected --new-slug description and left the
unknown-code line writing raw argv, which is the shape a per-line fix produces: the
second path was found by a reviewer, not by a test. Patching it independently would
leave a third.

RenameAccountCliCommand now writes every stderr message through one private
WriteErrorAsync that applies ListAccountsCliCommand.SanitizeForDisplay to the whole
assembled message. All five error paths (missing --slug, missing --new-slug, invalid
new code, unknown current code, service failure) and the catch-all go through it, so an
unsanitized stderr line cannot be added to this verb at all. Successful stdout keeps no
sanitizer: both echoed codes have passed slug validation or database equality.

Two subprocess tests hold it. The existing malicious --new-slug case stays, and
RenameVerb_UnknownCurrentCodeWithControlCharacters_StillWritesOneStderrLine covers the
line round 1 missed. Both go RED when WriteErrorAsync writes the message raw.
Review round 2, the mechanical half. Eight corrections, none changing behaviour, each
now pinned by RenameAccountDocsTests so reverting the value is a red test rather than a
document nobody re-reads.

- The ADR's enforcement list still said "two IgnoreQueryFilters reads" after 04a685d
  folded them into one. It is one combined read, discovered once directly and once
  through its forwarding caller.
- The operator runbook headed its section "Renaming the default farm's code" and
  excluded provision-account farms outright. Any farm may be renamed; it is an
  operator-controlled rebrand either way. Heading, anchor link and step-1 reference
  move together.
- The Verify step told the operator to expect Auth.UnknownFarmCode from the old code
  unconditionally. There is no retired-code list, so a reused code signs in and
  authenticates its current holder -- which is the reuse working, not the rename
  failing. list-accounts decides which case you are in.
- Three documents claimed both device caches "refresh on the next explicit sign-in".
  The palette does, under the new key. The remembered code does not: sign-in PREPENDS
  the new code and Forget (#587) is the roster's only exit, so the old code stays
  offerable and returns Auth.UnknownFarmCode unless reused.
- Three comments still called the slug immutable. The two fixture helpers keep their
  raw-SQL UPDATE -- they are setup paths establishing a precondition directly, not
  production renames, which go through rename-account / Account.Rename.
- The verb's happy-path test renamed to the literal "renamed-by-verb", a code every
  run of the suite competes for. It now derives the destination from its own seeded
  account id, like its siblings.
- The committed implementation handout carries a bold note that the review rounds
  supersede its code blocks and mutation rows; its 1600 historical lines stand.
Review round 2. 04a685d made a duplicate (file, symbol) allow-list key fail the gate,
and reported it through report.ParseErrors because that list already had a gate. The
classification is untrue: a parse error means Roslyn could not read a source file and
the walk cannot be trusted, so whoever hit this went looking for C# syntax that was
never wrong. The walk is sound; the registry is what contradicts itself.

GuardReport gains RegistryErrors beside ParseErrors, Scan collects duplicate keys into
it, and Evaluate appends its own failure beginning "allow-list registry error(s):".

Safety semantics are unchanged -- a duplicate key still fails the gate, still
fail-closed, still refused outright. Only the classification and the operator-facing
message move. DuplicateAllowListKeys_FailClosed now requires that phrase and asserts
ParseErrors empty with RegistryErrors non-empty, so the two cannot be folded back
together silently.
Review round 3, mechanical. No product behaviour changes: the verb, the service and the
domain are untouched.

Lost-output recovery. The verb prints one line and exits, so an operator whose terminal
or CI log ate it does not know whether the rename committed -- and the obvious move,
replaying the original --slug <old> --new-slug <new>, is the unsafe one. A retired code
is immediately reusable, so on a real deployment that replay can rename whoever holds
<old> now. The runbook now says so and gives the safe sequence: list-accounts, then the
Account.Rename audit row, then a replay only in the --slug <new> --new-slug <new> form.
RenameVerb_AfterSuccess_ReplayingTheOriginalCommandFails_ButTheNoOpReplayDoesNot pins
both halves as a subprocess -- exit 1 with the not-found line for the blind replay, exit
0 with no second audit row and no Version bump for the no-op -- so the prose cannot
drift from the verb. It was green on arrival: this is a pin, not a repair.

Narrowed round-2's own guards, which pinned vocabulary rather than claims. The cache
tests matched the loose words "prepends" / "Forget" / "UnknownFarmCode" anywhere in a
file, and the immutability test banned the bare word "immutable" across whole files --
which would fire on a future comment correctly describing an immutable value type, and
would pass a file that reintroduced the same wrong claim in different words. Each case
now names the exact corrected sentence and the exact false one it replaced.

The historical marker named "f069469..HEAD", a range whose right-hand end moves with
every commit. It names PR #733 instead, and the test forbids the range.

The sink comment claimed an unsanitized stderr line "cannot be added here at all", which
no comment can enforce. It now claims what is true -- all current stderr paths route
through WriteErrorAsync -- and
RenameVerb_HasExactlyOneStderrSink_AndItIsWriteErrorAsync reads the source, requiring
exactly one direct stderr write and requiring it to sit inside the sink.
…s code (#732)

The post-lock fence compared the locked row's slug against the code the
operator named. That is not an identity. A blocked writer that renames the
farm away and back again commits two renames and leaves the code the lookup
read, so the fence passed and the rename overwrote both of them.

The lookup now snapshots Slug AND Version in the same combined read, and the
locked row must match both before anything is written. Neither half is
redundant: Version catches the away-and-back schedule, and the slug comparison
catches a code change that never advanced Version, which is what a raw UPDATE
outside the domain does. The cost is deliberate and recorded. Version also
advances for a Farm Settings save or a suspend, so a rename racing one of those
now returns Account.SlugStale and the operator re-runs. SingleAttemptExecution
and the rename/audit atomicity are unchanged.

Two integration tests pin the fence, one per shape a blocked writer can commit,
and deleting either half of the fence reds exactly one of them.

Also in this increment:

- The verb's stderr guard counted one member name, so the synchronous sibling
  of that member passed it. It now counts every direct use of the stderr stream
  and asserts the single occurrence sits inside the sanitized sink.
- Recorded that uppercase stored codes are out of scope, with the evidence:
  commit 2f6e242 constrained #731's raw-SQL procedure to the domain's lowercase
  syntax and warned that an uppercase code there was one nobody could sign in
  with. No bypass is added.
- The glossary names six system actors and includes the rename verb's.
- The decision's enforcement inventory names RenameAccountDocsTests and no
  longer claims the runbook prose and glossary wording are unenforced.
- Three new documentation guards cover the three items above; each was mutated
  red and restored.
@mforce

mforce commented Sep 9, 2026

Copy link
Copy Markdown
Owner Author

@coderabbitai review please

@coderabbitai

coderabbitai Bot commented Sep 9, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

git diff --stat HEAD^ HEAD
git diff --name-status HEAD^ HEAD
git diff --find-renames --find-copies --unified=80 HEAD^ HEAD

Length of output: 89795


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '=== Commit identity and parent ==='
git show -s --format='%H%n%P%n%s' HEAD

echo '=== Changed files, excluding known cache metadata ==='
git diff --name-status HEAD^ HEAD -- \
  ':(exclude).ast-grep*' \
  ':(exclude)**/.ast-grep*' \
  ':(exclude).claude/settings.local.json'

echo '=== Rename-related paths ==='
git diff --name-status HEAD^ HEAD -- \
  'src/Cluckwork.Api/Cli/RenameAccountCliCommand.cs' \
  'src/Cluckwork.Infrastructure/Identity/AccountRenameService.cs' \
  'src/Cluckwork.Domain/Accounts/Account.cs' \
  'tests/Cluckwork.Api.IntegrationTests/AccountRenameServiceTests.cs' \
  'tests/Cluckwork.Api.IntegrationTests/AccountLifecycleCommandTests.cs' \
  'tests/Cluckwork.Application.Tests/Documentation/RenameAccountDocsTests.cs'

echo '=== Implementation outlines ==='
ast-grep outline src/Cluckwork.Api/Cli/RenameAccountCliCommand.cs --items all
ast-grep outline src/Cluckwork.Infrastructure/Identity/AccountRenameService.cs --items all
ast-grep outline src/Cluckwork.Domain/Accounts/Account.cs --items all

echo '=== Focused implementation diffs ==='
git diff --unified=100 HEAD^ HEAD -- \
  src/Cluckwork.Api/Cli/RenameAccountCliCommand.cs \
  src/Cluckwork.Infrastructure/Identity/AccountRenameService.cs \
  src/Cluckwork.Domain/Accounts/Account.cs

Length of output: 3742


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '=== Pull request metadata ==='
gh pr view 733 --repo mforce/cluckwork --json number,title,baseRefName,baseRefOid,headRefName,headRefOid,mergeCommit,commits,files

echo '=== Local refs and recent commit graph ==='
git branch -a --contains HEAD
git log --oneline --decorate --graph -n 40
git status --short

Length of output: 27127


🏁 Script executed:

#!/bin/bash
set -euo pipefail

base=2f6e242f3062cf54f23c2b55c67f611c4190a2cb
head=edc91591af8f9be4d71571d7a1139b94771a069b

echo '=== Runtime implementation diff ==='
git diff --unified=120 "$base" "$head" -- \
  src/Cluckwork.Domain/Accounts/Account.cs \
  src/Cluckwork.Infrastructure/Identity/AccountRenameService.cs \
  src/Cluckwork.Api/Cli/RenameAccountCliCommand.cs \
  src/Cluckwork.Api/Cli/CliDispatcher.cs \
  src/Cluckwork.Api/Hosting/CluckworkIdentityServiceCollectionExtensions.cs \
  src/Cluckwork.Application/Common/AuditActions.cs \
  src/Cluckwork.Application/Common/SystemActors.cs

echo '=== Focused integration and domain tests ==='
git diff --unified=80 "$base" "$head" -- \
  tests/Cluckwork.Domain.Tests/Accounts/AccountSlugTests.cs \
  tests/Cluckwork.Api.IntegrationTests/AccountRenameServiceTests.cs \
  tests/Cluckwork.Api.IntegrationTests/AccountLifecycleCommandTests.cs \
  tests/Cluckwork.Api.IntegrationTests/AccountSlugRaceTests.cs \
  tests/Cluckwork.Api.IntegrationTests/OneShotVerbMinimalConfigTests.cs \
  tests/Cluckwork.Api.IntegrationTests/CliDispatcherTests.cs

Length of output: 109596


🏁 Script executed:

#!/bin/bash
set -euo pipefail

base=2f6e242f3062cf54f23c2b55c67f611c4190a2cb
head=edc91591af8f9be4d71571d7a1139b94771a069b

echo '=== Diff integrity ==='
git diff --check "$base" "$head"

echo '=== Supporting code diffs ==='
git diff --unified=100 "$base" "$head" -- \
  src/Cluckwork.Api/Cli/ProvisionAccountCliCommand.cs \
  src/Cluckwork.Api/Hosting/CluckworkTelemetryServiceCollectionExtensions.cs \
  src/Cluckwork.Api/Hosting/ServingBootGuards.cs \
  tests/Cluckwork.Application.Tests/TenantBypass/GuardScanner.cs \
  tests/Cluckwork.Application.Tests/TenantBypass/TenantBypassWalkTests.cs \
  tests/Cluckwork.Application.Tests/TenantBypass/Data/tenant-bypass-allowlist.json \
  web/src/i18n/en.ts web/src/i18n/es.ts web/src/i18n/tl.ts web/src/i18n/enums.ts \
  web/src/routes/AuditPage.test.tsx web/src/routes/HelpPage.test.tsx

echo '=== Comparable account lock and slug lookup APIs ==='
ast-grep outline src/Cluckwork.Infrastructure/Identity --items all --match 'AccountSlugLookup|AccountSuspensionService|AccountProvisioner'
rg -n -C 8 'GetCurrentLockedAsync|class AccountSlugLookup|ResolveAsync\(' \
  src/Cluckwork.Infrastructure src/Cluckwork.Api/Cli

Length of output: 169344


🏁 Script executed:

#!/bin/bash
set -euo pipefail

base=2f6e242f3062cf54f23c2b55c67f611c4190a2cb
head=edc91591af8f9be4d71571d7a1139b94771a069b

echo '=== Changed non-document code paths ==='
git diff --name-status "$base" "$head" -- \
  'src/**' 'tests/**' 'web/**' \
  ':(exclude)docs/**' \
  ':(exclude)graphify-out/**'

echo '=== Tenancy guard and registry diff ==='
git diff --unified=120 "$base" "$head" -- \
  tests/Cluckwork.Application.Tests/TenantBypass/GuardScanner.cs \
  tests/Cluckwork.Application.Tests/TenantBypass/TenantBypassWalkTests.cs \
  tests/Cluckwork.Application.Tests/TenantBypass/Data/tenant-bypass-allowlist.json

echo '=== Hosting and command-adjacent diffs ==='
git diff --unified=100 "$base" "$head" -- \
  src/Cluckwork.Api/Hosting/CluckworkTelemetryServiceCollectionExtensions.cs \
  src/Cluckwork.Api/Hosting/ServingBootGuards.cs \
  src/Cluckwork.Api/Cli/ProvisionAccountCliCommand.cs \
  tests/Cluckwork.Api.IntegrationTests/BootstrapAdminCommandTests.cs \
  tests/Cluckwork.Api.IntegrationTests/RecoverAdminCommandTests.cs

Length of output: 101183


@mforce Reviewed PR #733.

I found no actionable issues in the static review.

The rename flow validates the new code, preserves no-op Version behavior, fences the source row with both Slug and Version, maps the unique-index race to Account.SlugTaken, and writes the audit row in the transaction.

I also checked command registration, minimal-config coverage, audit filter mappings, locale entries, and the tenant-bypass registry changes.

git diff --check reported no whitespace errors. I could not run the build or tests because this review environment does not execute repository code.

You are interacting with an AI system.

@mforce
mforce merged commit 4b70559 into main Sep 9, 2026
11 checks passed
@mforce
mforce deleted the feat/732-rename-account-verb branch September 9, 2026 07:10
mforce added a commit that referenced this pull request Sep 10, 2026
A one-paragraph rule in `AGENTS.md`'s **Git / PR workflow** section,
beside the existing PR-title rule — same subject, title versus body.

## Why

PR #742 carried `(#722)` in its title and no closing keyword in its
body. GitHub treats a title reference as a reference: **the issue would
have survived the merge.** The repo's convention already is the keyword
— #733 opens `Closes #732`, #736 opens `Closes #735` — but nothing in
`AGENTS.md` said so, so a driver-authored runbook that specified the PR
title verbatim and the body's required paragraph verbatim simply never
mentioned it. Caught by the owner reading the PR one step from the merge
ask.

The note also records two mechanics that cost time to rediscover: verify
the linkage with the `closingIssuesReferences` GraphQL query rather than
by re-reading the body, and patch a PR body with `gh api -X PATCH`
because `gh pr edit` fails on this repo with a Projects-classic
deprecation error.

## Scope

One line replaced by two in `AGENTS.md`. No code, no behaviour change.

Split out of #742 at the owner's direction rather than riding along, so
#742 stays at the exact head its two review rounds and its full
verification ran on.

Co-authored-by: mforce <mforce@users.noreply.github.com>
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.

CLI: rename-account verb to change a farm code (replaces the raw-SQL runbook path)

1 participant