Skip to content

feat(api): provision-account takes an optional --timezone at creation (#603) - #694

Merged
mforce merged 1 commit into
mainfrom
feat/provision-account-timezone
Sep 5, 2026
Merged

mforce merged 1 commit into
mainfrom
feat/provision-account-timezone

Conversation

@mforce

@mforce mforce commented Sep 5, 2026 •

Copy link
Copy Markdown
Owner

Closes #603.

What

provision-account gains an optional --timezone. #537 closed assuming this flag existed; it did not, so a farm always started in UTC and its first day of data was recorded against the wrong zone until the Owner reached Settings.

provision-account --name "Example Farm" --slug example-farm \
  --owner-email owner@example.com --locale en-US --currency USD \
  --timezone Asia/Manila

Omitting the flag keeps the previous behaviour exactly: the farm starts in UTC and its Owner sets the zone in Settings.

Validation, and where

Validated in AccountProvisioner.Validate with FarmSettingsRules.IsKnownTimeZone — the same predicate UpdateFarmSettingsValidator uses, so the CLI cannot commit a zone Settings would reject. It runs before any write, which is what the issue asks for: #264's clock fails closed, so a farm committed with an unresolvable zone renders no dates at all.

The rule is stricter than a bare FindSystemTimeZoneById: it also requires a real IANA id, so PST and asia/manila are refused.

Account.DefaultTimeZoneId replaces the two "UTC" literals (the property initialiser and the provisioner default) that had to agree.

Tests

ProvisionAccountCommandTests: 11 pass.

  • --timezone Asia/Manila commits that zone, and the committed value resolves through TimeZoneInfo.FindSystemTimeZoneById.
  • No flag still commits UTC.
  • Three unresolvable zones write nothing and name Provision.TimeZoneInvalid.
  • The malformed-option theory keeps --time-zone (still not an option), a valueless --timezone, and a duplicated one.

The semantic cases live in their own test rather than the malformed-option theory, because that theory asserts the farm code was never echoed — true for option shape errors, which are caught before the echo, but not for a semantic rejection, which happens in the provisioner after it, exactly as an invalid --locale does today.

Four mutants, each red on the assertion that names it:

Mutant Red on
IsKnownTimeZone weakened to a blank check the three unresolvable zones
Flag ignored, always UTC commits-that-zone, plus the three
--timezone not forwarded from the CLI same four
--timezone dropped from AllowedOptions same four

A first attempt at mutant 1 (if (false)) did not compile under warnings-as-errors and its "green" came from a stale binary; redone with a weakening that compiles.

Guard that fired

TenantBypassRealTreeTests anchors allowlist entries on the full method signature, so adding a parameter made the ProvisionAsync entry both unexcused and stale. Entry updated. This is a live instance of the brittleness #632 proposes to remove.

Docs

The AGENTS #264 bullet and docs/decisions/264-farm-timezone.md both stated the zone is set in Settings and not at provisioning time. That is what this PR changes, so both are amended rather than left contradicting the code. The decision record also notes this does not reinstate the Seed:TimeZoneId config lever #283 retired, and why a per-farm CLI argument is a different thing from a boot-time config key.

Runbook: the example, guidance on when to state the zone, and a Provision.TimeZoneInvalid failure row.

Full suite

2284 pass. One unrelated flake in OtlpSubprocessExporterTests appeared in one full run: it passes on repeat, passes on the stashed tree, and shares nothing with this change.

Summary by CodeRabbit

  • New Features

    • Farm provisioning now supports an optional IANA timezone.
    • Supplied timezones are validated before account creation and saved to the new farm.
    • Provisioning without a timezone continues to default to UTC.
  • Documentation

    • Updated provisioning guidance and decision records with timezone configuration, validation, defaults, and troubleshooting steps.
  • Bug Fixes

    • Invalid or unresolvable timezones are rejected without writing changes.

#537 assumed this flag existed; it did not, so every farm started in UTC
and recorded its first day of data against the wrong zone until its Owner
reached Settings. An operator provisioning a farm in another region
already knows the zone.

The flag is optional and omitting it keeps the previous behaviour exactly.
The zone is validated in AccountProvisioner with
FarmSettingsRules.IsKnownTimeZone, the same predicate
UpdateFarmSettingsValidator uses, so the CLI cannot accept a zone Settings
would reject. Validation happens before any write: #264's clock fails
closed, so a farm committed with an unresolvable zone renders no dates at
all, and a provisioning run is the right place to fail loudly instead.

Account.DefaultTimeZoneId replaces the two "UTC" literals that had to
agree. The tenant-bypass allowlist keys on the full method signature, so
its ProvisionAsync entry moves with the new parameter.

Docs: the runbook gains the flag, guidance and a failure row; the AGENTS
bullet and decision 264 both said the zone is set in Settings and NOT at
provisioning time, which this changes, so both are amended rather than
left contradicting the code.
@coderabbitai

coderabbitai Bot commented Sep 5, 2026 •

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Team

Run ID: a495fce6-df75-4910-900b-61373e70e977

📥 Commits

Reviewing files that changed from the base of the PR and between 1b5d398 and de1cd43.

📒 Files selected for processing (8)
  • AGENTS.md
  • docs/decisions/264-farm-timezone.md
  • docs/runbooks/provisioning-a-new-farm.md
  • src/Cluckwork.Api/Cli/ProvisionAccountCliCommand.cs
  • src/Cluckwork.Domain/Accounts/Account.cs
  • src/Cluckwork.Infrastructure/Identity/AccountProvisioner.cs
  • tests/Cluckwork.Api.IntegrationTests/ProvisionAccountCommandTests.cs
  • tests/Cluckwork.Application.Tests/TenantBypass/Data/tenant-bypass-allowlist.json

Included review availability: Your plan provides up to 8 included reviews per hour; 7 remain after this review.


📝 Walkthrough

Walkthrough

Farm provisioning now accepts an optional IANA timezone. The CLI passes the value to account provisioning, which validates it before writing. Omitted values default to UTC. Tests and provisioning documentation cover validation, persistence, and verification.

Changes

Farm timezone provisioning

Layer / File(s) Summary
Timezone option and default contract
src/Cluckwork.Api/Cli/ProvisionAccountCliCommand.cs, src/Cluckwork.Domain/Accounts/Account.cs
The CLI accepts --timezone and passes it to provisioning. Account.DefaultTimeZoneId centralizes the UTC default.
Timezone validation and account persistence
src/Cluckwork.Infrastructure/Identity/AccountProvisioner.cs, tests/Cluckwork.Application.Tests/TenantBypass/Data/tenant-bypass-allowlist.json
Provisioning validates known IANA timezones before writes, defaults omitted values to UTC, and stores the validated timezone on the account. The recorded method signature includes the new parameter.
Integration coverage and operational documentation
tests/Cluckwork.Api.IntegrationTests/ProvisionAccountCommandTests.cs, docs/decisions/264-farm-timezone.md, docs/runbooks/provisioning-a-new-farm.md, AGENTS.md
Tests cover malformed options, invalid values, committed timezones, and the UTC default. Decision, runbook, and agent documentation describe the updated provisioning behavior.

Estimated code review effort: 2 (Simple) | ~15 minutes

Merge Risk: ⚪ Minimal · up to de1cd

Provisioning now accepts an optional validated IANA timezone while retaining UTC when omitted; invalid values are rejected before account creation. The documented and tested behavior is ready to merge.

Sequence Diagram(s)

sequenceDiagram
  participant Operator
  participant ProvisionAccountCliCommand
  participant AccountProvisioner
  participant Account
  Operator->>ProvisionAccountCliCommand: provide optional --timezone
  ProvisionAccountCliCommand->>AccountProvisioner: pass timeZoneId
  AccountProvisioner->>AccountProvisioner: validate or default timezone
  AccountProvisioner->>Account: persist TimeZoneId
Loading
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 23.08% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 13 functions across 4 files. (4 skipped: … Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the primary change: adding an optional --timezone argument to provision-account. It also uses a conventional commit format.
Description check ✅ Passed The description explains the change, motivation, validation behavior, tests, documentation updates, and full-suite results. It omits the template's explicit Checklist section, but the description is o…
Linked Issues check ✅ Passed The changes satisfy issue #603 by adding the optional --timezone flag, validating the timezone before writes, preserving the UTC default, storing the selected timezone at creation, and updating relate…
Out of Scope Changes check ✅ Passed The code, tests, documentation, runbook, decision record, and tenant-bypass allowlist changes directly support issue #603 and the stated provisioning objective. No unrelated changes are evident.
Full details: Docstring Coverage

Explanation

Docstring coverage is 23.08% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 13 functions across 4 files. (4 skipped: 4 unsupported.)

  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/provision-account-timezone

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

@mforce
mforce merged commit a0aee39 into main Sep 5, 2026
11 checks passed
@mforce
mforce deleted the feat/provision-account-timezone branch September 5, 2026 15:27
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.

provision-account: optional --timezone at creation (assumed by #537, never built)

1 participant