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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ dotnet test Cluckwork.sln # 2295 tests as of 2026-09; integrati
- **Migrate command + prod migration split (#263).** `dotnet Cluckwork.Api.dll migrate` applies migrations then exits — the pre-deploy-job entrypoint. Production sets `Database:MigrateOnStartup=false`, so the serving process never runs DDL; the guarantee is **ordering**, and `DatabaseReadyHealthCheck` is the backstop (`/health/ready` 503s while any migration is pending). → [`263-migrate-command.md`](docs/decisions/263-migrate-command.md)
- **Container health probe: the `healthcheck` verb (#266).** Dispatched before host build (no DI, DB or config), it GETs `/health/ready` over loopback and exits `0` only on a 2xx — the hardened image ships no curl/wget, and the probe must never report a false green. → [`266-container-health-probe.md`](docs/decisions/266-container-health-probe.md)
- **Container image hardening (#267).** Runtime stage runs non-root (`USER $APP_UID`), all three base images are digest-pinned, and Trivy fails the build on a fixable HIGH/CRITICAL. Keep the full glibc base per #264. → [`267-container-hardening.md`](docs/decisions/267-container-hardening.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` (unset env → Production → blocked). The verb migrates, seeds, exits — authoritative and fail-loud. Both profiles require an Owner (#500); `simulation` additionally requires `Simulation:CastPassword` and fails closed without it. → [`280-seed-and-simulation.md`](docs/decisions/280-seed-and-simulation.md) · [runbook](docs/runbooks/simulation-fixture-on-a-dev-database.md) for loading the simulation fixture into a local debug database
- **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` (unset env → Production → blocked). The verb migrates, seeds, exits — authoritative and fail-loud. Both profiles require an Owner (#500); `simulation` additionally requires `Simulation:CastPassword` and fails closed without it. **`demo` also takes `--farm-code <slug>`**, which seeds that farm instead of the default one — resolved by slug like the account lifecycle verbs, exit 1 naming `list-accounts` on an unknown code; `simulation` refuses the flag, because its manifest, cast emails and pinned counts are all default-farm facts. The sim harness uses it to demo-seed a second farm for the README dashboard capture, which the simulation fixture cannot produce (its ~100 never-filing catalog flocks leave every day partial, so the trend strip has no complete day to scale against). → [`280-seed-and-simulation.md`](docs/decisions/280-seed-and-simulation.md) · [runbook](docs/runbooks/simulation-fixture-on-a-dev-database.md) for loading the simulation fixture into a local debug database
- **A write-contract change must update its non-CI callers (#394).** Coverage is not uniform, and it is not what it was when this rule was written. The seeders are covered only to the handler layer, so a **validator-only** tightening is invisible to every seeder test. `tools/simulation/k6/` is **genuinely uncovered** — `k6-baseline.yml` is `workflow_dispatch` only, and a tolerated-status list means a green run does not mean a landing write. The **Playwright specs are no longer uncovered**: since 2026-08-08 `e2e-smoke.yml` runs the quick suite on every `pull_request` touching `src/**`, `web/**`, `tools/simulation/**` or `deploy/**` — which is every write-contract change by definition — so CI does catch a broken spec there. A docs-only PR still skips it, and `slow`/`canary` stay dispatch-only. **Verify the request/status contract by reading the callers, not by running** — the reading is what still finds the k6 case and what tells you whether a caller's *intent* changed. → [`394-write-contract-callers.md`](docs/decisions/394-write-contract-callers.md)
- **SPA E2E lives in `tools/simulation/ui/` (#277/#385).** Playwright drives the real built SPA over the same `seed --profile simulation` fixture k6 uses; `web/` stays Vitest. Three enforced rules, each of which has caught something: never hardcode a credential, never hardcode English, respect the farm clock. Anything reasoning about `inert` or the accessibility tree must go through CDP (`src/ax.ts`) — Playwright's own APIs do not model `inert` (#501). The suite runs in two projects partitioned by the `@phone` tag (#814) — `chromium` at 1280 and `chromium-phone` at 390, where the sidebar is `display: none`, so the `nav` fixture throws and a phone test uses `phone` instead. → [`277-spa-e2e.md`](docs/decisions/277-spa-e2e.md)
- **Any PR that changes what a user sees attaches screenshots, captured from a stack rebuilt at the head under review (#662).** Not only a PR whose *purpose* is visual: a new dialog, a new field, a changed label or an altered state all qualify, because the trigger is what the reader can no longer check from the diff. **Before and after wherever a before exists**, the same viewport and the same scenario in both; after-only where the UI is net-new and there is nothing to compare against. It is the only check in this repo that reads the *rendered* result: on #661 it found the one product defect that four review seats, three CI Playwright runs and the driver's own verification all passed. Three mechanics matter — capture at 1:1, because downscaling destroys exactly the 1px hairlines and 4–5% alpha shadows a styling change is about; rebuild first, because a long-running sim stack serves the bytes it was built from, not the branch under review; and attach the images to the PR with `gh pr comment --attach` (or `gh pr create --attach`), **never** by committing them to a branch — a branch is deletable, so routine cleanup silently breaks the images on a merged PR while the prose around them still claims they exist.
Expand Down
Binary file modified docs/images/dashboard.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 5 additions & 1 deletion docs/runbooks/simulation-fixture-on-a-dev-database.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,11 @@ production history, in the database your IDE-run API is already pointed at.
- [`first-admin-provisioning.md`](first-admin-provisioning.md) — the database
has no Owner. Do that first; this runbook needs one.
- `seed --profile demo` ([280](../decisions/280-seed-and-simulation.md)) — the
small, look-at-it fixture. Prefer it when you do not need volume.
small, look-at-it fixture. Prefer it when you do not need volume. It also
takes `--farm-code <slug>`, which seeds that farm instead of the default one
(unknown code: exit 1 naming `list-accounts`). `--profile simulation` refuses
the flag — every command form in this runbook writes the **default** account,
and there is no supported way to point this fixture elsewhere.

**Blast radius:** the target database's default account, permanently. The seed
writes ~100 flocks, ~100 customers and `2 × HistoryDays` daily entries with
Expand Down
86 changes: 73 additions & 13 deletions src/Cluckwork.Api/Cli/SeedCliCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,24 @@ namespace Cluckwork.Api.Cli;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;

// `seed --profile <name>` (#280) — a one-off command on the same binary, not a
// serving-process code path: it migrates the schema, runs the requested
// profile's seeder(s), then EXITS (Kestrel and the hosted services never start).
// `seed --profile <name> [--farm-code <slug>]` (#280) — a one-off command on the
// same binary, not a serving-process code path: it migrates the schema, runs the
// requested profile's seeder(s), then EXITS (Kestrel and the hosted services
// never start).
//
// `--farm-code` points the DEMO profile at a farm other than the default one,
// resolved by slug exactly as the account lifecycle verbs resolve theirs. It
// exists because the README's dashboard screenshot cannot be captured from the
// simulation fixture: that fixture seeds 100 catalog flocks which never file, so
// every day is a partial day and the trend strip has no complete day to scale
// against. The sim harness therefore provisions a second, small farm and seeds
// it with the demo profile (tools/simulation/reset.sh).
//
// `--profile simulation` deliberately does NOT accept it. SimulationDataSeeder
// writes the default account throughout — its manifest, its cast emails and the
// counts k6 and the e2e suite pin are all default-farm facts — so honouring the
// flag there would need a second decision, not a parameter. It exits 1 instead
// of silently seeding the wrong farm.
public sealed class SeedCliCommand : ICliCommand
{
public string Name => "seed";
Expand All @@ -18,15 +33,21 @@ public async Task<int> RunAsync(WebApplication app, string[] args)
using var seedScope = app.Services.CreateScope();
var sp = seedScope.ServiceProvider;
var profile = CliDispatcher.ArgValue(args, "--profile");
// Folded like every other verb's --slug: an operator typing README-FARM at
// a shell means readme-farm. Null when the flag is absent OR blank, which
// is what makes "absent" and "--farm-code ''" behave the same way.
var farmCode = AccountSlugLookup.Normalize(CliDispatcher.ArgValue(args, "--farm-code"));

// #284 review — validate the profile AND its availability in this
// environment (the DI-registration/prod-guard check) BEFORE touching the
// database at all. Previously MigrateAsync ran first, so an unknown
// profile or a Production-blocked "demo" still mutated the schema (or
// threw raw) before the guard below ever ran. Nothing under this switch
// may write to the database — it only resolves services and picks which
// seed delegate to run once validation has passed.
Func<Task<SeedResult>>? runSeed;
// seed delegate to run once validation has passed. The slug LOOKUP is
// deliberately not here either: it is a read, but against a schema this
// command's own MigrateAsync may not have created yet.
Func<Guid?, Task<SeedResult>>? runSeed;
switch (profile)
{
case "demo":
Expand All @@ -38,48 +59,87 @@ public async Task<int> RunAsync(WebApplication app, string[] args)
var demoSeeder = sp.GetService<DemoDataSeeder>();
if (demoSeeder is null)
{
await Console.Error.WriteLineAsync(
await WriteErrorAsync(
"Demo seeding is not available in Production (DemoDataSeeder is not registered).");
return 1;
}
runSeed = () => demoSeeder.SeedAsync();
runSeed = accountId => demoSeeder.SeedAsync(accountId);
break;
}
case "simulation":
{
if (farmCode is not null)
{
await WriteErrorAsync(
"seed --profile simulation does not accept --farm-code: the simulation fixture is " +
"default-farm-only by design (its manifest, its cast emails and the counts k6 and " +
"the e2e suite pin are all facts about that farm). Use --profile demo to seed " +
"another farm.");
return 1;
}

// SimulationDataSeeder is registered only outside Production — same
// GetService guard as demo.
var simSeeder = sp.GetService<SimulationDataSeeder>();
if (simSeeder is null)
{
await Console.Error.WriteLineAsync(
await WriteErrorAsync(
"Simulation seeding is not available in Production (SimulationDataSeeder is not registered).");
return 1;
}
runSeed = () => simSeeder.SeedAsync();
runSeed = _ => simSeeder.SeedAsync();
break;
}
default:
await Console.Error.WriteLineAsync(
await WriteErrorAsync(
$"Unknown or missing --profile '{profile}'. Known: demo, simulation.");
return 1;
}

await sp.GetRequiredService<AppDbContext>().Database.MigrateAsync();

// Resolved AFTER the migrate, because the Accounts table may be something
// this command has only just created; resolved BEFORE the seed, so an
// unknown code costs nothing. A null here means "the default farm" — the
// seeder's own default — and never "resolution failed", which returns.
Guid? targetAccountId = null;
if (farmCode is not null)
{
targetAccountId = await AccountSlugLookup.ResolveAsync(sp, farmCode);
if (targetAccountId is null)
{
await WriteErrorAsync(
$"No farm with code '{farmCode}'. Run `list-accounts` to see the codes this database " +
"holds, or `provision-account` to create that farm first.");
return 1;
}
}

// Fail-loud (#284 review): SeedAsync reports what happened instead of
// swallowing a no-op or an internal failure into a silent exit 0. Only
// Seeded/AlreadySeeded (the seeder's own idempotency guard) are success —
// everything else is a clear stderr message + non-zero exit.
var result = await runSeed();
var result = await runSeed(targetAccountId);
if (!result.IsSuccess)
{
await Console.Error.WriteLineAsync(result.Message);
await WriteErrorAsync(result.Message);
return 1;
}

app.Logger.LogInformation(
"Seed command complete (profile={Profile}): {Message}", profile, result.Message);
"Seed command complete (profile={Profile}, farm={FarmCode}): {Message}",
profile, farmCode ?? "default", result.Message);
return 0;
}

// EVERY stderr path in this verb routes through here, which is where the
// #560 control-character strip lives. Two of the messages quote raw argv —
// the unresolved --farm-code and the unknown --profile — and a value carrying
// a newline would otherwise let an operator's terminal read a second, forged
// line as this command's own output. Routing only those two is the shape a
// per-line fix produces and the shape rename-account was corrected out of
// (#732 round 1): the messages that are already safe pay nothing, and the
// next message added here cannot be the one that was missed.
private static Task WriteErrorAsync(string message) =>
Console.Error.WriteLineAsync(ListAccountsCliCommand.SanitizeForDisplay(message));
}
31 changes: 21 additions & 10 deletions src/Cluckwork.Infrastructure/Persistence/DemoDataSeeder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,17 @@ public sealed class DemoDataSeeder(
ConfirmSaleHandler confirmSale,
ILogger<DemoDataSeeder> logger)
{
public async Task<SeedResult> SeedAsync(CancellationToken ct = default)
// `targetAccountId` is the farm to seed, and it is an explicit parameter
// rather than ambient state on purpose: `tenant` is single-assignment, so a
// seeder that read the tenant instead of setting it could only ever run
// where somebody else had already resolved one. Null means the default farm,
// which is every caller but `seed --profile demo --farm-code <slug>` (#865
// follow-up — the README dashboard is captured from a second, demo-seeded
// farm on the sim stack, because the simulation fixture's 100 catalog flocks
// make every day a partial day).
public async Task<SeedResult> SeedAsync(Guid? targetAccountId = null, CancellationToken ct = default)
{
var accountId = SeedDefaults.AccountId;
var accountId = targetAccountId ?? SeedDefaults.AccountId;

// Preflight the base prerequisite (#284 review): demo needs the default
// account, the Admin role, and the default egg grades (FK dep for the
Expand All @@ -68,10 +76,11 @@ public async Task<SeedResult> SeedAsync(CancellationToken ct = default)
if (missingBaseData)
{
const string message =
"Demo seed prerequisites missing: the base data (default account, Admin role, default egg " +
"grades) is not present. It ships as part of the EF migrations (#283) — run `migrate` (or " +
"let this command's own migrate-first step apply it) against a current schema, then re-run " +
"`seed --profile demo`.";
"Demo seed prerequisites missing: the base data (the account, the Admin role, its default " +
"egg grades) is not present. For the default farm it ships as part of the EF migrations " +
"(#283) — run `migrate` (or let this command's own migrate-first step apply it) against a " +
"current schema. A farm created by `provision-account` gets the same reference data at " +
"provisioning time. Then re-run `seed --profile demo`.";
logger.LogError(message);
return SeedResult.PrerequisitesMissing(message);
}
Expand All @@ -97,17 +106,19 @@ public async Task<SeedResult> SeedAsync(CancellationToken ct = default)
// the same message. AdminRecoveryService documents that trap; this
// is the second place it bites.
var message = disabledOwners > 0
? "Demo seed prerequisites missing: the default account's Owner role is held only by DISABLED " +
? "Demo seed prerequisites missing: the account's Owner role is held only by DISABLED " +
"user(s), so the seeded records would be signed by an account that cannot log in. There is " +
"no in-product repair for this state today, so do not go looking for one: `bootstrap-admin` " +
"counts Owner role rows without checking DisabledAt and reports 'already provisioned'; " +
"`recover-admin` refuses a disabled target; and the Users screen that could re-enable them " +
"is Owner-only, which nobody can now reach. Clear BOTH DisabledAt and DisabledBy for that " +
"user directly in the database — they describe one fact, and EnableUserAsync always " +
"clears them together — then re-run `seed --profile demo`."
: "Demo seed prerequisites missing: the default account has no user in the Owner role, so the " +
"seeded records would have no author. Run `dotnet Cluckwork.Api.dll bootstrap-admin --email " +
"<e>` against this database, then re-run `seed --profile demo`.";
: "Demo seed prerequisites missing: the account has no user in the Owner role, so the " +
"seeded records would have no author. The default farm's Owner comes from `dotnet " +
"Cluckwork.Api.dll bootstrap-admin --email <e>`; any other farm gets one from " +
"`provision-account`. Run the right one against this database, then re-run `seed " +
"--profile demo`.";
logger.LogError(message);
return SeedResult.PrerequisitesMissing(message);
}
Expand Down
Loading
Loading