Skip to content
Merged
42 changes: 20 additions & 22 deletions .github/AGENTS.md

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -74,8 +74,8 @@ jobs:
# a skipped MATRIX job is the exception, handled by `image-summary` below.
#
# The changed paths come from plain `git diff`, not a third-party action:
# `.github/AGENTS.md` cites the 2025-03 `tj-actions/changed-files` compromise as the
# reason actions are SHA-pinned, and this needs no dependency at all.
# `docs/decisions/146-ci-security-gates.md` cites the 2025-03 `tj-actions/changed-files`
# compromise as the reason actions are SHA-pinned, and this needs no dependency at all.
# The classifier's own guard. It lives in its own job, and NOTHING depends on
# it, on purpose. It cannot sit inside `changes` below, because `changes` is
# `continue-on-error` and a guard that cannot fail the run is not a guard; and
Expand Down
183 changes: 57 additions & 126 deletions AGENTS.md

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ fails: [first admin provisioning](docs/runbooks/first-admin-provisioning.md).
| Document | For |
|---|---|
| [`CONTRIBUTING.md`](CONTRIBUTING.md) | Local development, tests, branches, commit messages |
| [`AGENTS.md`](AGENTS.md) | The canonical rule set: every invariant, for humans and coding agents |
| [`AGENTS.md`](AGENTS.md) | Rules for coding agents, split into scoped files by path |
| [`SECURITY.md`](SECURITY.md) | Reporting a vulnerability; what CI enforces |
| [`docs/releasing.md`](docs/releasing.md) | Cutting a release; deploying by digest |
| [`docs/architecture.md`](docs/architecture.md) | The request pipeline and the egg-loop state machine, drawn |
Expand Down Expand Up @@ -152,7 +152,7 @@ current. Work is tracked as GitHub issues (epics + slices).
## Contributing

[`CONTRIBUTING.md`](CONTRIBUTING.md) for humans, [`AGENTS.md`](AGENTS.md) for
coding agents and for the full rule set behind both.
coding agents.

## License

Expand Down
6 changes: 4 additions & 2 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ Elsewhere in the repo:
| [`../CONTRIBUTING.md`](../CONTRIBUTING.md) | Local development, tests, branches, commits |
| [`runbooks/aspire-local-development.md`](runbooks/aspire-local-development.md) | Local Aspire stack, persistence and safe reset |
| [`runbooks/simulation-fixture-on-a-dev-database.md`](runbooks/simulation-fixture-on-a-dev-database.md) | Bulk fixture data in a local debug database (Compose or Aspire) |
| [`../AGENTS.md`](../AGENTS.md) | The canonical rule set — every invariant, for humans and coding agents |
| [`../AGENTS.md`](../AGENTS.md) | Rules for coding agents, split into scoped files by path |
| [`../SECURITY.md`](../SECURITY.md) | Reporting a vulnerability; what CI enforces |
| [`../specs/product/`](../specs/product/) | Product & technical spec, phase plan, [glossary](../specs/product/GLOSSARY.md) |
| [`../deploy/README.md`](../deploy/README.md) | Compose topology, caching, rollout ordering |
Expand All @@ -34,7 +34,9 @@ screen, because that is where a search result drops you.

A rule lives in **one** place, compressed, and links to the rest:

- the **rule** and the consequence of breaking it → `AGENTS.md`;
- the **rule** and the consequence of breaking it → `AGENTS.md`; when a guard
already carries the rule, one line naming that guard in a scoped `AGENTS.md`'s
*Enforced rules and their records* list;
- the **narrative that earned it** — what shipped, which review round found it,
what the wrong fix was → a record in `decisions/`;
- the **procedure** a human follows → a `runbooks/` entry with a drill;
Expand Down
2 changes: 1 addition & 1 deletion docs/decisions/146-ci-security-gates.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# CI security gates, lock-file healing, Dependabot, action pinning (#146)

> **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).
> **Rule** — CI enforces the audit gates, and `CONTRIBUTING.md` and `SECURITY.md` document them, so no `AGENTS.md` restates them. The remaining compact constraint, pinning third-party Actions to a full commit SHA, lives in [`.github/AGENTS.md`](../../.github/AGENTS.md). This file is the rationale (what shipped and what not to break).


CI fails a PR when a dependency carries a known **high+** advisory:
Expand Down
5 changes: 3 additions & 2 deletions docs/decisions/260-proxy-trust.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
# Proxy-trust boot guard (#260)

> **Rule** — the one-paragraph version lives in [`src/AGENTS.md`](../../src/AGENTS.md);
> this file is the relocated rationale. Why the guard is scoped to the serving
> **Rule** — a guard, a verb or the Dockerfile carries this rule, so [`src/AGENTS.md`](../../src/AGENTS.md)
> only lists it under *Enforced rules and their records*;
> this file is the rationale. Why the guard is scoped to the serving
> process and not to the CLI verbs is in [#347](347-process-role.md).

**Status:** accepted · **Date:** 2026-07
Expand Down
3 changes: 2 additions & 1 deletion docs/decisions/261-postgres-tls-floor.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# Production Postgres TLS floor + libpq mapping (#261/#262)

> **Rule** — the one-paragraph version lives in [`src/AGENTS.md`](../../src/AGENTS.md); this file is the relocated rationale (what shipped, why the short version was insufficient, what not to break).
> **Rule** — a guard, a verb or the Dockerfile carries this rule, so [`src/AGENTS.md`](../../src/AGENTS.md)
> only lists it under *Enforced rules and their records*; this file is the rationale (what shipped and what not to break).

**Production Postgres TLS floor (#261/#262):** `ConnectionStrings:Default` accepts libpq URI form (`postgresql://…`, translated to Npgsql key-value; a param Npgsql supports under a different spelling — `channel_binding`, `target_session_attrs`, `gssencmode` — is **mapped**, and only a genuinely unsupported one — `sslcompression` — skips-with-warning; **`ContainsKey("<libpq name>") == false` does NOT mean Npgsql lacks the setting** — it usually means the keyword is spelled differently (`keepalives` → `Tcp Keepalive`, `client_encoding` → `Client Encoding`, `gssencmode` → `GSS Encryption Mode`), so search the keyword list for the concept before concluding a param is unmappable; that exact false negative is what shipped #332, and the first fix for it re-made the same mistake with `keepalives`. Still unmapped and worth a follow-up: the `keepalives*` family and `client_encoding` — `keepalives` needs a *value* translation (`1` → a bool keyword), not just a keyword map) as well as key-value. In **Production** the effective `sslmode` is validated once at boot as a fail-closed **allow-list**: `VerifyCA`/`VerifyFull` silent, `Require` warns (prefer VerifyFull), and **everything else — `Disable`/`Allow`/`Prefer`, unset (Npgsql defaults to `Prefer`), or any undefined `SslMode` — fails the boot**. Never auto-injected. `Database:AllowInsecureConnection=true` is the explicit opt-out for the co-located plaintext compose stack (set on both `app` and `migrate`; a real deploy never sets it). The `VerifyFull` CA + per-host `sslmode` are deploy config (in the separate deployment/ops repo). Integration tests opt out of this **and** the #260 guard in `CluckworkWebApplicationFactory` (plaintext Testcontainers, no proxy) — a Production-env test that skips those opt-outs will fail the boot.
3 changes: 2 additions & 1 deletion docs/decisions/263-migrate-command.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# Migrate command + prod migration split (#263)

> **Rule** — the one-paragraph version lives in [`src/AGENTS.md`](../../src/AGENTS.md); this file is the relocated rationale (what shipped, why the short version was insufficient, what not to break).
> **Rule** — a guard, a verb or the Dockerfile carries this rule, so [`src/AGENTS.md`](../../src/AGENTS.md)
> only lists it under *Enforced rules and their records*; this file is the rationale (what shipped and what not to break).

**Migrate command + prod migration split (#263):** `dotnet Cluckwork.Api.dll migrate` is a third run-then-exit CLI verb (same shape as `seed`) that applies EF migrations then exits — the **pre-deploy-job entrypoint**. In **Production** (`appsettings.Production.json`, loaded because the container leaves `ASPNETCORE_ENVIRONMENT` unset → Production; setting it to Development silently re-enables boot-migration) **`Database:MigrateOnStartup=false`**, so the request-serving process never runs schema DDL. The **actual guarantee is ordering** — migrate runs *before* serve: `deploy/docker-compose.yml` runs a one-shot `migrate` service first and `app` waits on `service_completed_successfully` (any other orchestrator runs the `migrate` job as a pre-deploy step). `DatabaseReadyHealthCheck` is the **backstop**: `/health/ready` returns **503 while any migration is pending**, so an orchestrator's readiness gate won't route traffic to a stale schema. This is the app-side of #263's privilege separation. The **remaining host-specific deploy step**: create a least-privilege runtime role (`GRANT USAGE ON SCHEMA public` + DML/sequence privileges, **no DDL**; plus **`ALTER DEFAULT PRIVILEGES`** for the migrator role so objects future migrations create are auto-granted to the runtime role) and point the migrate job at a separate owner/migrator credential. This PR **does not** close #263 on its own — it references it; the role split closes it.
3 changes: 2 additions & 1 deletion docs/decisions/265-break-glass-recovery.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# Break-glass recovery: `recover-admin` (#265)

> **Rule** — the one-paragraph version lives in [`src/AGENTS.md`](../../src/AGENTS.md);
> **Rule** — a guard, a verb or the Dockerfile carries this rule, so [`src/AGENTS.md`](../../src/AGENTS.md)
> only lists it under *Enforced rules and their records*;
> the **procedure and its verification drill** are in
> [`docs/runbooks/break-glass-account-recovery.md`](../runbooks/break-glass-account-recovery.md).
> This file is the design rationale.
Expand Down
3 changes: 2 additions & 1 deletion docs/decisions/266-container-health-probe.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# Container health probe: the `healthcheck` verb (#266)

> **Rule** — the one-paragraph version lives in [`src/AGENTS.md`](../../src/AGENTS.md); this file is the relocated rationale (what shipped, why the short version was insufficient, what not to break).
> **Rule** — a guard, a verb or the Dockerfile carries this rule, so [`src/AGENTS.md`](../../src/AGENTS.md)
> only lists it under *Enforced rules and their records*; this file is the rationale (what shipped and what not to break).

**Container health probe (#266):** the runtime image **and** the compose `app` service carry a `HEALTHCHECK` that runs `dotnet Cluckwork.Api.dll healthcheck` — a **fourth run-then-exit CLI verb**, but **dispatched *before* host build** (`Program.cs`, `args is [HealthCheckCliCommand.Verb, ..]`), not via `CliDispatcher`: unlike `migrate`/`seed`/`recover-admin` — which operate on the built host — this one needs no host/DI/DB/config, so a 30s probe must not re-run the whole app startup or re-log boot warnings on every tick. The hardened image ships **no curl/wget** (#267), so the probe is **in-process**: it GETs `/health/ready` over loopback (port derived from `ASPNETCORE_URLS`, default `8080`) and exits `0` on a 2xx, `1` on any other status **or** an unreachable server (refused/timeout) — never a false green. So an instance whose `/health/ready` is 503 (DB down, migrations pending per #263) reports **unhealthy** and compose/an orchestrator stops routing to it. Its `ProbeAsync`/`DefaultReadyUrl` are unit-tested directly (no Docker). The CI `image` job (#267) now also **boots** the built image against a throwaway Postgres and asserts `/health/ready` goes green + the verb exits `0`, so an unbootable image fails CI instead of at deploy time — this is the **app-side of #266**. Deploy-side (the host's readiness path, wait-for-CI gate, post-deploy smoke) lives in the separate deployment/ops repo.
5 changes: 3 additions & 2 deletions docs/decisions/267-container-hardening.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
# Container image hardening (#267)

> **Rule** — the one-paragraph version lives in [`AGENTS.md`](../../AGENTS.md);
> this file is the relocated rationale.
> **Rule** — a guard, a verb or the Dockerfile carries this rule, so [`src/AGENTS.md`](../../src/AGENTS.md)
> only lists it under *Enforced rules and their records*;
> this file is the rationale.

**Status:** accepted; the CI scan gate was reversed 2026-10-02 (see below) · **Date:** 2026-07

Expand Down
3 changes: 2 additions & 1 deletion docs/decisions/283-first-run-admin-provisioning.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# First-run admin provisioning: the `bootstrap-admin` verb (#283)

> **Rule** — the one-paragraph version lives in [`src/AGENTS.md`](../../src/AGENTS.md); this file is the relocated rationale (what shipped, why the short version was insufficient, what not to break).
> **Rule** — a guard, a verb or the Dockerfile carries this rule, so [`src/AGENTS.md`](../../src/AGENTS.md)
> only lists it under *Enforced rules and their records*; this file is the rationale (what shipped and 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.

Expand Down
5 changes: 3 additions & 2 deletions docs/decisions/318-design-time-migration-connection.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
# Design-time migration connection, fail-closed (#318)

> **Rule** — the one-paragraph version lives in [`src/Cluckwork.Infrastructure/Persistence/AGENTS.md`](../../src/Cluckwork.Infrastructure/Persistence/AGENTS.md);
> this file is the relocated rationale.
> **Rule** — a guard, a verb or the Dockerfile carries this rule, so [`src/Cluckwork.Infrastructure/Persistence/AGENTS.md`](../../src/Cluckwork.Infrastructure/Persistence/AGENTS.md)
> only lists it under *Enforced rules and their records*;
> this file is the rationale.

**Status:** accepted · **Date:** 2026-07

Expand Down
3 changes: 2 additions & 1 deletion docs/decisions/332-gss-kerberos.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# GSS/Kerberos negotiation off by default (#332)

> **Rule** — the one-paragraph version lives in [`src/AGENTS.md`](../../src/AGENTS.md); this file is the relocated rationale (what shipped, why the short version was insufficient, what not to break).
> **Rule** — a guard, a verb or the Dockerfile carries this rule, so [`src/AGENTS.md`](../../src/AGENTS.md)
> only lists it under *Enforced rules and their records*; this file is the rationale (what shipped and what not to break).

**GSS/Kerberos negotiation off by default (#332):** Npgsql's `GssEncryptionMode` defaults to `Prefer`, so every connector probes the GSSAPI stack before authenticating. The runtime image carries no `libgssapi-krb5-2` (#267 keeps it minimal), so that probe made .NET's native security shim print two **unstructured, pre-Serilog** lines to stderr per connecting process (`Cannot load library libgssapi_krb5.so.2`) — non-fatal but it reads like a failure during deploys and lands badly in an OTLP sink. `PostgresConnectionString` now appends `GSS Encryption Mode=Disable` **unless the operator specified it**, detected by **presence, not value** (`prefer` is Npgsql's own default, so a value comparison would silently override an operator who asked for it) — via the *base* `DbConnectionStringBuilder`, since `NpgsqlConnectionStringBuilder.ContainsKey` returns true for every keyword it knows, defaults included. Appended **textually** after the TLS floor runs: a round-trip through the Npgsql builder would reorder/requote the operator's own string and throw on any keyword this Npgsql version doesn't know. Orthogonal to `sslmode` (#262 untouched) and to GSS *authentication* (a separate Npgsql path). Fixing the image instead was rejected — it adds package surface to the image. **Verified by loader trace** (`LD_DEBUG=libs` against a scram-sha-256 Postgres): `Prefer` dlopens `libgssapi_krb5.so.2`/`libkrb5.so.3`/`libkrb5support.so.0` even though the server never offers GSS; `Disable` produces **zero** gssapi/krb5 loader activity and connects identically — so under password auth there is no residual GSS-*auth* probing either. End-to-end confirmation on the managed cluster (deploy repo) is the only step left.
5 changes: 3 additions & 2 deletions docs/decisions/510-jwt-key-boot-check.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
# Both JWT keys are checked at boot, and the check is serving-only (#510)

> **Rule** — the one-paragraph version lives in [`src/AGENTS.md`](../../src/AGENTS.md);
> this file is the relocated rationale.
> **Rule** — a guard, a verb or the Dockerfile carries this rule, so [`src/AGENTS.md`](../../src/AGENTS.md)
> only lists it under *Enforced rules and their records*;
> this file is the rationale.

**Status:** accepted · **Date:** 2026-08

Expand Down
2 changes: 1 addition & 1 deletion docs/decisions/565-aspire-local-orchestration.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Aspire is local orchestration, and it is a second database (#565)

> **Rule** — the one-paragraph version lives in [`AGENTS.md`](../../AGENTS.md);
> **Rule** — the one-paragraph version lives in [`src/AGENTS.md`](../../src/AGENTS.md);
> this file is the relocated rationale (what shipped, why the short version was
> insufficient, what not to break).

Expand Down
2 changes: 1 addition & 1 deletion docs/decisions/776-backend-coverage.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Backend test coverage measurement, report only (#776)

> **Rule** — the one-paragraph version lives in [`AGENTS.md`](../../AGENTS.md);
> **Rule** — the one-paragraph version lives in [`src/AGENTS.md`](../../src/AGENTS.md);
> this file is the relocated rationale (what shipped, why the short version was
> insufficient, what not to break).

Expand Down
2 changes: 1 addition & 1 deletion docs/decisions/782-ci-job-gating.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ usual workaround is a shim workflow declaring a same-named job on the inverse
filter, which is a second copy that silently drifts. A job skipped by `if:`
reports as skipped, which satisfies a required check.

**Use `tj-actions/changed-files` or `dorny/paths-filter`.** `.github/AGENTS.md` names the
**Use `tj-actions/changed-files` or `dorny/paths-filter`.** [146](146-ci-security-gates.md) names the
2025-03 `tj-actions/changed-files` compromise as the reason third-party actions
are pinned to a full commit SHA here. `git diff --name-only` plus a 70-line Node
module needs no dependency at all.
Expand Down
5 changes: 3 additions & 2 deletions docs/decisions/852-flock-contract.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
# Put flock lifecycle behind a Flock Management contract with two peer ports (#852)

> **Rule** — the one-paragraph version lives in [`src/AGENTS.md`](../../src/AGENTS.md),
> beside the #849 contract rule; this file records why Flock Management has three
> **Rule** — the remaining compact constraint, that `IMortalityLedger.AppendAsync` never saves, lives in
> [`src/AGENTS.md`](../../src/AGENTS.md) with the other transaction-joining ports;
> this file records why Flock Management has three
> contract interfaces and what the contract leaves alone.

**Status:** accepted
Expand Down
Loading