Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,8 @@ dotnet test Cluckwork.sln # 688 tests as of 2026-07; integratio
- **Migrations: `InitialCreate` frozen, one migration per change (#407).** `Persistence/Migrations/` is one squashed `InitialCreate` (recorded id `20260801190854_InitialCreate`); it is **frozen and never regenerated**, and **every schema change gets its own `dotnet ef migrations add`**. EF never re-runs an applied migration, so a column **hand-folded into `InitialCreate` silently does not exist** on any booted DB — it surfaces as broken behaviour (a failed login, #399), not a migration error. `InitialCreate` also carries four un-regenerable `lower("Name")` expression indexes + the base-reference SQL; regenerating mints a new timestamp that desynchronises `__EFMigrationsHistory` everywhere. Pre-squash or pre-#407 dev DBs can't migrate forward — drop and recreate. `MigrationSecurityReviewTests` freezes the id + a portable operation digest. → [`docs/decisions/407-migration-freeze.md`](docs/decisions/407-migration-freeze.md)
- **Schema docs are generated, committed, and regenerated with every migration (#417).** `docs/schema/` (mermaid ERD + full catalog: every column, constraint, and index — including the raw-SQL expression indexes and partial predicates EF reflection can't see) is produced by `tools/schema-docs/generate.sh` from an ephemeral migrated Postgres via digest-pinned tbls. **Every PR that adds a migration runs the generator and commits the result in the same PR** — CI's `build-and-test` runs `generate.sh --check` (byte-diff against a fresh generation) and fails a stale PR. Generated files under `docs/schema/` are **never hand-edited**, and a conflict there after a rebase is resolved by regenerating, never by hand-merging. Pinned from three sides by `SchemaDocsTests` (postgres-pin uniformity across every tracked file, no environment leakage, completeness against the live catalog). → [`docs/decisions/417-schema-docs.md`](docs/decisions/417-schema-docs.md)
- **`AuditEvents` is not time-partitioned, on purpose (#505).** The dominant read (`GetProvenanceAsync`, run on every Flocks/Egg grades/Daily entries/Sales/Expenses page load) filters on `AccountId`+`EntityType`+`EntityId` with **no date predicate**, so partitioning by month would convert one index lookup into one per partition — added per-partition overhead that only grows as months pass, for no pruning benefit — plus a PK change, a new INSERT-fails-without-next-month's-partition failure mode inside the same transaction as the audited mutation (#93), and no partition-maintenance job to prevent it. At current growth (~20 MB/year at 100 flocks) this is not close to needed; if it ever is, partition by `AccountId` (the column the query actually filters on) once genuinely multi-tenant, not by time. → [`docs/decisions/505-audit-events-no-time-partition.md`](docs/decisions/505-audit-events-no-time-partition.md)
- **Seed / simulation data is never boot-seeded (#280, #284, #279).** Run it explicitly against an **already-base-seeded, non-Production** database: `ASPNETCORE_ENVIRONMENT=Development dotnet Cluckwork.Api.dll seed --profile demo|simulation` (an unset env → Production → blocked). The verb migrates, seeds, then exits — **authoritative** (ignores config) and **fail-loud** (a real exit code, never a silent no-op). `simulation` additionally records a durable date anchor + completion marker, so a clean re-run — even across a UTC-midnight rollover — converges to `AlreadySeeded`; a polluted account fails the exact-count validation **closed**. → [`docs/decisions/280-seed-and-simulation.md`](docs/decisions/280-seed-and-simulation.md)
- **Seed / simulation data is never boot-seeded (#280, #284, #279).** Run it explicitly against an **already-base-seeded, non-Production** database: `ASPNETCORE_ENVIRONMENT=Development dotnet Cluckwork.Api.dll seed --profile demo|simulation` (an unset env → Production → blocked). The verb migrates, seeds, then exits — **authoritative** (ignores config) and **fail-loud** (a real exit code, never a silent no-op). `simulation` additionally records a durable date anchor + completion marker, so a clean re-run — even across a UTC-midnight rollover — converges to `AlreadySeeded`; a polluted account fails the exact-count validation **closed**. **Both profiles now require an Owner and attribute what they seed to real people (#500)** — see the next bullet. → [`docs/decisions/280-seed-and-simulation.md`](docs/decisions/280-seed-and-simulation.md)
- **Nothing writes an audit event without an actor (#500).** `AuditWriter` **throws** on an unresolved `ICurrentUser`, symmetric with the tenant guard beside it. The old fallback stamped `"(unresolved)"` — silent, reachable only from non-HTTP callers, and it shipped ~256 such rows into every demo farm, visible on five screens once #494 rendered provenance. Every non-HTTP caller therefore declares who it is: `bootstrap-admin` and `recover-admin` declare **system actors** (`SystemActors.BootstrapAdmin` / `.BreakGlass`, via `CurrentUserContext.ResolveSystemActor`), and **both seeders now require an Owner** — demo exits `PrerequisitesMissing` naming `bootstrap-admin`, a deliberate break with the old "demo needs nothing but a connection string" contract. Simulation goes further and attributes **per persona**: managers create flocks/products/expenses, sales staff book orders, a rotating worker pool records the daily entries, and roughly one submission in three is a manager signing off somebody else's draft (both #494 provenance shapes). **`ICurrentUser` is an AUTHORIZATION input, not an audit label** — `FlockScopeGuard` reads `Roles`/`UserId`, so which persona the seeder acts as decides what it is *allowed* to write: picking the flock-restricted worker for a foreign flock returns `FlockScope.NotAssigned` and fails the entire seed. Never resolve an actor carrying roles it does not actually hold; build one from `UserManager.GetRolesAsync`, never from a literal.
- **Break-glass recovery (#265):** `recover-admin` is a second one-off CLI verb on the same binary, same run-then-exit shape as `seed` — but deliberately **NOT** environment-gated (it must work against a real Production database): `dotnet Cluckwork.Api.dll recover-admin --email <e> [--account <guid>] [--reason <text>]`. For a locked-out account (the sole-Owner-lost-password case, no email/SMTP reset path) it, in one transaction, resets to a **freshly generated** temporary password (never one passed on the command line), rotates the security stamp, revokes every refresh token, and writes a conspicuous `User.BreakGlassReset` audit row carrying `--reason`. The temp password is printed to **stdout only** (never the logger/OTLP) and exit `0`; failures go to stderr with exit `1` and change nothing. `AdminRecoveryService` orchestrates; `IdentityProvider.BreakGlassResetAsync` shares the reset/revoke core with `SetUserPasswordAsync`. Full procedure + verification drill: `docs/runbooks/break-glass-account-recovery.md`.
- **First-run admin provisioning: `bootstrap-admin` (#283).** `dotnet Cluckwork.Api.dll bootstrap-admin --email <e>` (run-then-exit, always available in Production) migrates, then — **only if the default account has no Owner** — creates one with a **freshly generated password (stdout only, never the logger/OTLP)** and `MustChangePassword=true`; a re-run is a **silent no-op**. While the flag is set the JWT carries `must_change_password`, `MustChangePasswordMiddleware` 403s every endpoint except `auth/change-password`+`auth/logout` (before `UseAuthorization` and before idempotency), and the SPA renders **Set your password**. Any successful reset clears the flag (one invariant in `IdentityProvider`). **Deliberately a separate credential type from #265/#308** — never conflated. → [`docs/decisions/283-first-run-admin-provisioning.md`](docs/decisions/283-first-run-admin-provisioning.md)
- **Farm timezone + tzdata/ICU image constraint (#264):** the default account (a fixed `"UTC"` migration literal since #283) is provisioned once; a real farm sets its actual IANA zone via **Settings → timezone** after first login (`Account.UpdateSettings`), not at provisioning time (the `Seed:TimeZoneId` config lever from before #283 is retired along with the runtime seeder it fed). The farm clock resolves IANA zones via `TimeZoneInfo.FindSystemTimeZoneById` for every safety-sensitive local-day decision and **fails closed**, so the runtime **image MUST carry tzdata + ICU** (the Ubuntu 24.04 (Noble) `aspnet:10.0` base does; **never** an Alpine/chiseled base without tzdata, and **never** `InvariantGlobalization=true`). `AddCluckworkPersistence` still asserts a representative IANA canary resolves at boot (`TimeZoneAvailability.EnsureResolvable`), for **both** process roles — a bad image fails the boot loudly instead of surfacing later as a per-request `FarmTimeZoneException`.
Expand Down
8 changes: 8 additions & 0 deletions deploy/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,14 @@ Demo sample data (#280/#284) is **command-only** — there is no `Seed__Demo` bo
toggle. Against an already base-seeded, non-Production database, run
`dotnet Cluckwork.Api.dll seed --profile demo` (see `AGENTS.md`).

**Run `bootstrap-admin` first (#500).** The demo profile signs every record it
seeds with the default account's Owner, so it refuses to run when there is none —
exit `1`, with a message naming the command to run. On a database whose Owner was
provisioned by an earlier `bootstrap-admin`, that generated password was printed
once and is never reprinted; recover it with the `recover-admin` break-glass
procedure rather than re-running `bootstrap-admin`, which silently no-ops once an
Owner exists.

## Container health check (#266)

The runtime image **and** the compose `app` service both declare a `HEALTHCHECK`
Expand Down
12 changes: 12 additions & 0 deletions docs/decisions/280-seed-and-simulation.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,15 @@
> **Rule** — the one-paragraph version lives in [`AGENTS.md`](../../AGENTS.md); this file is the relocated rationale (what shipped, why the short version was insufficient, what not to break).

**Seed command (#280, #284):** demo sample data is NOT boot-seeded — there is no `Seed__Demo` flag anymore. Run it explicitly, against a database that has **already been booted once** so the base seed above exists (default account, `Admin` role, default egg grades — the demo seed needs all three and fails fast with a clear message if they're missing): `ASPNETCORE_ENVIRONMENT=Development dotnet Cluckwork.Api.dll seed --profile demo` (an unset `ASPNETCORE_ENVIRONMENT` defaults to Production, which the guard below blocks — always set it for a local/dev/CI run). The command migrates the schema, runs `DemoDataSeeder`, then exits (Kestrel never starts); it is **authoritative** (seeds regardless of any config) and **fail-loud** (a real exit code: `0` only when data was actually seeded or was already present, non-zero with an stderr message otherwise — never a silent no-op). `DemoDataSeeder` is registered only when `!IsProduction()` (defense-in-depth): running the command against a Production-env process fails with a clear message instead of writing fake data. Intended flow: the serving process for a real farm stays Production; a non-Production process (dev box, CI, or a sim/load-test harness) runs `seed --profile demo` against its own already-base-seeded database. The **`simulation` profile** (#279) sits in the same `switch` with the same authoritative + fail-loud + Production-guarded contract (`SimulationDataSeeder`); it additionally records its date anchor + a completion marker in a durable `SimulationSeedState` row (anchor written before the first fixture mutation; completion stamped only after the manifest succeeds). A clean re-run — even one straddling a UTC-midnight rollover — reuses that anchor and converges to `AlreadySeeded` with an unchanged manifest fingerprint instead of writing a shifted duplicate set; `AlreadySeeded` is reported only when the counts are unchanged, so a `SimulationOptions` change that adds fixtures re-runs as `Seeded`. The durable anchor can't be poisoned by a mid-seed crash or by foreign daily entries a load test writes into the account (a data-derived anchor could); but a polluted account still fails the exact count validation **closed** (`Failed`) rather than certifying a fixture that no longer matches the definition.

## Both profiles require an Owner, and sign what they seed (#500)

**The prerequisite list above is out of date for `demo`, deliberately.** As of #500 the demo profile needs a fourth thing the migrations do not provide: an existing **Owner-role user** in the default account, which comes only from `dotnet Cluckwork.Api.dll bootstrap-admin --email <e>`. Without one it exits `PrerequisitesMissing` (exit `1`) naming that command, before writing a single row.

That is a real break with what this file used to promise. `seed --profile demo` against a bare migrated database no longer works in one step, and `SeedCommandTests` carried a comment asserting it "needs nothing but a connection string". The trade was made knowingly: a demo fixture exists to be looked at, looking requires a login, and a login requires an Owner — so requiring one up front converts a later surprise into an immediate, clearly-worded failure. Two alternatives were rejected: attributing to a `(demo-seed)` placeholder when no Owner exists (which is the very thing #500 removes, on ~256 rows across five screens), and having the seeder mint its own Owner (`bootstrap-admin` only provisions when there is **no** Owner, so it would then silently no-op and leave the developer with an admin whose password nobody knows).

**The operational seam worth knowing.** `bootstrap-admin` prints its generated password **only on first provisioning**; a re-run against an already-provisioned account prints "already provisioned … nothing to do" and names nobody. On a persistent dev database, a reused CI container or a shared demo host, the demo fixture is therefore signed by an Owner whose credentials may be lost. The route back is the `recover-admin` break-glass procedure, not another `bootstrap-admin`.

**What each profile signs with.** Demo attributes the whole fixture to that Owner — a one-person farm is what the demo represents. Simulation attributes **per persona**: managers place the flocks and create the products, categories and expenses; sales staff book the orders; a rotating pool of workers records the daily entries; the Owner does account administration (creating the cast, assigning a flock, setting the farm timezone). Roughly one submission in three is a manager signing off a worker's draft, so the fixture carries **both** #494 provenance shapes rather than only "created and last changed by the same person".

**Attribution is an authorization decision here, not a label.** `FlockScopeGuard` reads the same `ICurrentUser` the audit row does, so the persona a phase acts as decides what it may write. One worker is deliberately restricted to one flock; selecting that worker for any other flock returns `FlockScope.NotAssigned` and fails the **entire** seed rather than misattributing quietly. Anything that changes who the seeder acts as must be checked against that guard, not just against the expected audit text.
25 changes: 25 additions & 0 deletions docs/decisions/283-first-run-admin-provisioning.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,28 @@
> **Rule** — the one-paragraph version lives in [`AGENTS.md`](../../AGENTS.md); this file is the relocated rationale (what shipped, why the short version was insufficient, what not to break).

**First-run admin provisioning (#283):** `bootstrap-admin` is a fourth one-off CLI verb, same run-then-exit shape as `seed`/`migrate`, always available in Production (same posture as `recover-admin`): `dotnet Cluckwork.Api.dll bootstrap-admin --email <e>`. It migrates the schema (idempotent, like the other verbs), then — **only if the default account has no Owner yet** — creates one with a **freshly generated** password and `ApplicationUser.MustChangePassword = true`, printed to **stdout only** (never the logger/OTLP, identical rule to `recover-admin`). A re-run against an already-provisioned account is a silent no-op: no second Owner, no password reprinted. `FirstRunAdminService` orchestrates (mirrors `AdminRecoveryService`'s CLI-wrapper-calls-a-service shape); `IIdentityProvider.CreateUserAsync` gained an optional `mustChangePassword` parameter (default `false` — an ordinary Users-page-created user is never gated) that this is the only caller to pass `true`. While the flag is set: the JWT carries a `must_change_password` claim (`JwtTokenService`), `MustChangePasswordMiddleware` refuses **every** endpoint except `auth/change-password` and `auth/logout` with 403 (placed before `UseAuthorization`, so it applies uniformly regardless of an endpoint's `AuthPolicies` tier, and before the idempotency middleware, so a blocked write burns no key), and the SPA's `ProtectedRoute` renders a **"Set your password"** screen (`SetPasswordPage`, reusing `/auth/change-password` — the operator already knows the printed password as their "current" one) instead of the app shell, on every route. Any successful password reset (self-service change, an Owner's `SetUserPasswordAsync`, or break-glass) clears the flag — a single invariant in `IdentityProvider`, not re-derived per path. **Deliberately a separate credential type** from `recover-admin`'s temp password and from #308's browser step-up re-confirmation grant — different audience (pre-auth shell access vs. an authenticated Owner), different lifetime, never conflated; do not treat any of #283/#265/#308 as covering another.

## The verb's own audit row names a system actor (#500)

`bootstrap-admin` creates the first Owner, so there is no human to attribute that
creation to — not even the user being created, who does not exist yet. Since #500
`AuditWriter` refuses an event with no resolved actor rather than falling back to
`"(unresolved)"`, so `FirstRunAdminService` declares what it is:
`currentUser.ResolveSystemActor(SystemActors.BootstrapAdmin)`, immediately after
its existing `tenant.Resolve`. The `User.Create` row therefore carries
`ActorEmail = "(bootstrap-admin)"` and `ActorUserId = Guid.Empty` — the same id
those rows always had; what changed is that the label is now chosen deliberately
instead of defaulted by a fallback nobody could see.

This is still a parenthetical placeholder, which is the shape #500 complains
about. It is kept because it is honest, it is not *"unresolved"*, and the row
never renders on any of the five #494 provenance screens (a user's creation is
not one of them). `recover-admin` does the same with `SystemActors.BreakGlass`.

**`bootstrap-admin` is now also a prerequisite of `seed --profile demo`** — the
demo fixture is signed by the Owner this verb provisions, so demo refuses to run
without one. Note the seam that creates: this verb prints its generated password
**only on first provisioning**, so on a re-used database the demo fixture is
attributed to an Owner whose credentials may be lost, and the route back is
`recover-admin` rather than another `bootstrap-admin` (which no-ops silently).
See [`280-seed-and-simulation.md`](280-seed-and-simulation.md).
Loading
Loading