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
42 changes: 42 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# Editor defaults, honoured natively by most editors and IDEs with no toolchain,
# no dependency and no CI job. Its job is to stop whitespace-only diffs from
# burying real changes in review.
#
# It is a convenience, NOT a gate — nothing enforces it. Do not cite it as the
# reason a formatter is unnecessary.
root = true

[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
indent_style = space
indent_size = 2

# Markdown: two trailing spaces are a hard line break, so trimming them changes
# the rendered output.
[*.md]
trim_trailing_whitespace = false

# .NET
[*.{cs,csx}]
indent_size = 4

[*.{csproj,props,targets,sln}]
indent_size = 2

# Generated — do not reformat. Schema docs are byte-compared by CI (#417) and a
# lock file is written by the tool that owns it.
[docs/schema/**]
insert_final_newline = unset
trim_trailing_whitespace = unset

[**/packages.lock.json]
insert_final_newline = unset

[**/package-lock.json]
insert_final_newline = unset

[Makefile]
indent_style = tab
33 changes: 33 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# Line endings are normalised to LF in the repo and checked out as LF
# everywhere, Windows included.
#
# Why this file exists at all: `.githooks/*` are POSIX `sh` scripts that git
# executes via their shebang. A CRLF checkout appends `\r` to the shebang line,
# and the kernel then looks for an interpreter literally named `/bin/sh\r` —
# which fails with a message that names neither the hook nor the cause. The same
# `\r` silently ends up inside every string a workflow's `run:` block compares,
# and inside every value `tools/simulation/*.sh` reads back out of an env file.
#
# `core.autocrlf` is a per-machine setting, so it cannot be relied on. This file
# ships with the repo and applies to every clone.
* text=auto eol=lf

# Executable text files where a stray CR is fatal rather than cosmetic. Listed
# explicitly so the intent survives someone relaxing the line above.
*.sh text eol=lf
.githooks/* text eol=lf

# Generated. `linguist-generated` keeps them out of language stats and collapses
# them in pull-request diffs; they are still tracked, still reviewed by
# regenerating, never by hand-editing (#417 for the schema docs).
docs/schema/** linguist-generated=true
**/packages.lock.json linguist-generated=true
**/package-lock.json linguist-generated=true

# Binary — never diffed, never line-ending-normalised.
*.png binary
*.jpg binary
*.ico binary
*.woff binary
*.woff2 binary
*.dump binary
32 changes: 32 additions & 0 deletions .github/CODEOWNERS
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# Required reviewers per path. Gitignore syntax; the LAST matching rule wins, so
# put the narrowest rules last.
#
# Covers the one gap no gate in this repo can: a merge to `main` that rewrites
# the workflows themselves. A backdoored `ci.yml` on `main` produces a build
# attestation that is genuinely valid — right signer workflow, right source ref
# — because `--source-ref` records which ref built the image, not whether that
# ref's content is trustworthy. Review of changes to `main` is the only control
# that closes it. → the "Deploy by digest" bullet in AGENTS.md.
#
# SHIPPED INERT, deliberately. Today `main` requires a pull request with
# `required_approving_review_count: 0` and a single owner with write access, so
# "Require review from Code Owners" would be either a deadlock (nobody else can
# approve) or theatre (the owner approves their own PR). An entry naming an
# unresolvable user is silently ignored by GitHub, so a plausible-looking broken
# file is worse than an empty one.
#
# To activate, all three are required:
# 1. Uncomment and replace @OWNER with a user or team that has write access —
# someone other than the person who normally opens these PRs.
# 2. Branch protection → tick "Require review from Code Owners", and raise
# "Required approving reviews" above 0.
# 3. Open a PR touching .github/ and confirm the owner is auto-requested.

# * @OWNER

# Paths where a self-merge rewrites what every other check asserts.
# /.github/ @OWNER

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Use recursive patterns before activating CODEOWNERS

If the documented activation steps are followed, /.github/, /.githooks/, and /docs/decisions/ do not recursively own files under those directories in CODEOWNERS syntax; directory contents require patterns such as /.github/**. Consequently workflow changes such as .github/workflows/ci.yml would remain ownerless even though this file says activation closes the self-merge control gap.

AGENTS.md reference: AGENTS.md:L236-L242

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rejected, with evidence — a trailing-slash CODEOWNERS pattern is recursive.

GitHub's own CODEOWNERS syntax examples state it twice:

In this example, @doctocat owns any files in the build/logs directory at the root of the repository and any of its subdirectories. — /build/logs/ @doctocat

In this example, @doctocat owns any file in the /docs directory in the root of your repository and any of its subdirectories. — /docs/ @doctocat

So /.github/ would own .github/workflows/ci.yml once activated; /.github/** is not required. Leaving the patterns as they are.

(Restating for the record: the file ships inert — every pattern is commented out — because main currently has required_approving_review_count: 0 and one owner with write access, so activating it would be a deadlock or self-approval. The claim in the header is about what activation would close, not about anything this PR turns on.)

# /.githooks/ @OWNER
# /.github/CODEOWNERS @OWNER
# /docs/decisions/ @OWNER
# /AGENTS.md @OWNER
43 changes: 43 additions & 0 deletions .github/pull_request_template.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<!--
The PR TITLE is the release note. Squash-merge takes the commit subject from it,
and release-please parses that for the changelog and the bump — so a
non-conventional prefix silently costs the bump, with a green run. No local hook
sees the PR title.

feat(scope): … fix(scope): … docs|chore|test|ci|build|style(scope): …
feat!: … or a BREAKING CHANGE: footer
-->

## What and why

<!-- What changed and the problem it solves, in a few lines. Link the issue.
This body becomes the squashed commit in main — keep it short, and mind the
`word((` parser trap: see CONTRIBUTING.md#commit-messages. -->

## How it was verified

<!--
Commands run and what they printed — not "tests pass". For a guard, name the
mutation you ran and which test went red. → docs/decisions/407-writing-a-guard.md
-->

## Checklist

Delete any line that does not apply — an inapplicable ticked box is noise.

- [ ] The **PR title** is a conventional commit, and is the release note I want.
- [ ] Tests cover the change, and I watched the new ones fail first.
- [ ] Any aggregate mutation bumps `Version` and has a parallel-race test.
- [ ] Any new guard was **mutation-checked**: green baseline, mutant red on that
guard's own named assertion, baseline green after restoring.
- [ ] A new migration ships with regenerated `docs/schema/` in this PR (#417).
- [ ] A new Production boot guard or config key also updates the sim harness (#370).
- [ ] Docs updated in this PR — `specs/product/GLOSSARY.md` and the SPA Help page
for anything user-visible.
- [ ] The slice is on its phase epic's checklist (#14 Phase 1.1, #15 Phase 1.5).
- [ ] A non-obvious decision is recorded in `docs/decisions/`, or there is none.
- [ ] No hardcoded credential, in application **or** test code.
- [ ] No hardcoded hosting-provider name in code, config, or a committed doc.
- [ ] Any new third-party Action is pinned to a **full commit SHA** with a
trailing `# vX.Y.Z` comment.
- [ ] A package add or bump commits the regenerated lock file in the same commit.
10 changes: 7 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@

Poultry egg-farm management system. Backend: **.NET 10** (C#), layered DDD. Frontend: **React 19 + Vite** SPA in `web/`. Postgres via EF Core.

This file is the shared brief for any coding agent (Claude Code, Codex, etc.).
This file is the shared brief for any coding agent (Claude Code, Codex, etc.) and
the **canonical rule set** for the repo. Humans usually want the short path first:
[`CONTRIBUTING.md`](CONTRIBUTING.md) (develop, test, commit),
[`docs/`](docs/README.md) (runbooks, decision records, releasing),
[`SECURITY.md`](SECURITY.md).

## Communicating

Expand Down Expand Up @@ -208,7 +212,7 @@ Full rationale — the transitive-graph submission, the shared GitHub App and it

## Releases and image publishing (#351)

Two stages, deliberately separate: **CI publishes an image per merge; the release PR turns one into a version.** `README.md`'s "Releases & container images" is the **how-to**; this section is the **invariants — what not to break**; the full mechanism (release-please twice per push, the `groom` boundary probe, the commit-body parser, the App-token reasoning, the repair path) is in [`docs/decisions/351-releases.md`](docs/decisions/351-releases.md).
Two stages, deliberately separate: **CI publishes an image per merge; the release PR turns one into a version.** [`docs/releasing.md`](docs/releasing.md) is the **how-to**; this section is the **invariants — what not to break**; the full mechanism (release-please twice per push, the `groom` boundary probe, the commit-body parser, the App-token reasoning, the repair path) is in [`docs/decisions/351-releases.md`](docs/decisions/351-releases.md).

- **Every merge to `main`** publishes `ghcr.io/<owner>/<repo>:sha-<commit>` from the `publish` job (Trivy-scanned, boot-tested). **Merging the "Release vX.Y.Z" PR** drafts the release, **promotes** that commit's image to `:vX.Y.Z`, then publishes.
- **Promotion is a server-side retag of the existing digest** (`docker buildx imagetools create --prefer-index=false`), **never a rebuild** — a rebuild yields different bytes no scan ever examined. `--prefer-index=false` is load-bearing: the default `true` wraps a new top-level digest.
Expand Down Expand Up @@ -237,7 +241,7 @@ Two stages, deliberately separate: **CI publishes an image per merge; the releas
is open today and no flag on the verify command closes it; review of changes to
`main` is the only control that does.

**The paragraph directly above is the canonical statement of the boundary.** `README.md` and the `ci.yml` comment carry a summary and point here rather than restating it, because successive corrections to this claim repeatedly updated one copy and left the others contradicting it. If you correct it, correct it in all three and check they agree. Full derivation of the two gates is in [`docs/decisions/351-releases.md`](docs/decisions/351-releases.md).
**The paragraph directly above is the canonical statement of the boundary.** [`docs/releasing.md`](docs/releasing.md) and the `ci.yml` comment carry a summary and point here rather than restating it, because successive corrections to this claim repeatedly updated one copy and left the others contradicting it. If you correct it, correct it in all three and check they agree. Full derivation of the two gates is in [`docs/decisions/351-releases.md`](docs/decisions/351-releases.md).
- Package visibility and the host's pull credential are **deploy-side** concerns (cluckwork-deploy#6), not this repo's.

## Git / PR workflow
Expand Down
187 changes: 187 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
# Contributing

Humans start here. Coding agents start at [`AGENTS.md`](AGENTS.md), which is the
canonical rule set — this file is the short human-facing path through it and
links there rather than restating rationale.

## Once per clone

```bash
git config core.hooksPath .githooks
```

Enables two fast hooks: **pre-commit** (unit tests for staged .NET changes,
`npm run typecheck` for staged `web/` changes) and **commit-msg** (rejects a
message release-please would silently drop from the changelog). Integration tests
are deliberately excluded — Docker, slow; CI is the authority. Skip once with
`--no-verify`.

## Local development

Prerequisites: **.NET 10 SDK**, **Docker**, **Node 26+**.

**Frontend:**

```bash
cd web
npm install
npm run dev # http://localhost:5173, proxies /api to the API
```

**API from the IDE / CLI** — start just the database, then run the API (config
comes from user-secrets, never files):

```bash
docker compose -f deploy/docker-compose.dev.yml up -d # Postgres on :5432
dotnet run --project src/Cluckwork.Api
```

`ASPNETCORE_ENVIRONMENT` matters: unset means Production, which fails the boot
against a plaintext local Postgres (the #261/#262 TLS floor).

A fresh database has no admin user — base data is migration-baked, credentials
never are. Provision one: [first admin provisioning](docs/runbooks/first-admin-provisioning.md).

### Resetting the dev database

Always wipe through Compose:

```bash
docker compose -f deploy/docker-compose.dev.yml down -v
docker compose -f deploy/docker-compose.dev.yml up -d
```

Do **not** `docker volume rm` a volume name from memory. The live volume is
`cluckwork-dev_cluckwork-dev-pg18`, and an older `…-pg` volume may still be lying
around from before the Postgres 18 bump — removing that one succeeds, prints the
name back, and leaves the real database untouched, so a wipe can look like it
worked when nothing happened.

You need a wipe (not a migration) if boot fails with
`42P07: relation "Accounts" already exists`. That database still carries the
pre-squash migration history: the 34 migrations were replaced by a single
`InitialCreate`, which EF then sees as pending and tries to apply over tables
that already exist. Such a database cannot migrate forward — recreate it.

## Tests

```bash
dotnet test Cluckwork.sln # integration tests spin up Postgres via Docker
cd web && npm test # Vitest + Testing Library
```

Expectations, enforced at review like a missing feature:

- every change under `src/` ships with tests in the same PR;
- every change under `web/` ships with Vitest tests in the same PR;
- **every aggregate mutation bumps `Version`** and gets a parallel-race
integration test — see the `Version` bullet in [`AGENTS.md`](AGENTS.md#conventions-follow-these);
- a new **guard** (a test whose job is to fail when someone later does the wrong
thing) is mutation-checked before you claim it catches anything —
[`docs/decisions/407-writing-a-guard.md`](docs/decisions/407-writing-a-guard.md).

## Changing the database schema

**Add a migration. Never edit `InitialCreate`.**

```bash
# Against the local dev Postgres. The connection is fail-closed (#318): there is
# no default, and every target is held to the same TLS floor as a Production
# boot — the loopback opt-out below is what permits plaintext, and only for
# localhost/127.0.0.1/::1.
CLUCKWORK_MIGRATIONS_CONNECTION='Host=localhost;Port=5432;Database=cluckwork;Username=…;Password=…' \
CLUCKWORK_MIGRATIONS_ALLOW_INSECURE_LOOPBACK=true \
dotnet ef migrations add <Name> \
-p src/Cluckwork.Infrastructure -s src/Cluckwork.Api
```

EF never re-runs an applied migration, so a column hand-folded into
`InitialCreate` **silently does not exist** on any booted database. It surfaces as
broken behaviour — a failing login, a missing field — not as a migration error,
which is what makes it worth a rule. `InitialCreate` is also not regenerable: it
carries four `lower("Name")` expression indexes EF cannot model plus the guarded
reference-data SQL, and re-adding it mints a new timestamp that desynchronises
`__EFMigrationsHistory` everywhere. `MigrationSecurityReviewTests` fails if it
stops being the first migration or loses its recorded id.

**Same PR:** regenerate the schema docs (`tools/schema-docs/generate.sh`) and
commit them — CI runs `generate.sh --check` and fails a stale PR (#417).

Full reasoning: [`docs/decisions/407-migration-freeze.md`](docs/decisions/407-migration-freeze.md).

## Branches and PRs

- **`main` is protected.** Branch, push, open a PR — never commit to `main`.
- Branches: `feat/…`, `fix/…`, `chore/…`, `docs/…`, `spec/…`. PRs squash-merge.
- The PR body is prefilled from
[`pull_request_template.md`](.github/pull_request_template.md). Delete lines
that do not apply rather than ticking them.
- **Keep the phase epic in sync**: a new slice issue goes on the epic's checklist
(epic #14 = Phase 1.1, #15 = Phase 1.5); tick it when the PR merges. Milestone
assignment alone is not enough — the epics are how work is navigated.

## Commit messages

[Conventional Commits](https://www.conventionalcommits.org/). **Subject:**
`type(scope): summary`, lowercase type, no space before the colon. The scope is
free-form — the area touched.

| type | changelog section | example |
|---|---|---|
| `feat` | Features | `feat(eggs): make cracked and dirty eggs sellable stock via condition grades` |
| `fix` | Bug fixes | `fix(sales): reject fractional order-line quantities` |
| `perf` | Performance | `perf(reports): stream the CSV export instead of buffering it` |
| `refactor` | Refactoring | `refactor(api): extract service registration from Program` |
| `docs` | Documentation | `docs(agents): record the guard-writing rules #407 paid five rounds for` |
| `ci` | *hidden* | `ci(e2e): workflow_dispatch job for the Playwright smoke suite` |
| `test` | *hidden* | `test(e2e): Playwright smoke suite for the SPA over the #243 sim fixture` |
| `build` | *hidden* | `build(deps): bump Npgsql to 10.0.2` |
| `chore` | *hidden* | `chore(web): drop the unused date-fns dependency` |
| `style` | *hidden* | `style(web): apply Prettier to the untouched settings screens` |

Add `!` for a breaking change — `feat(api)!: drop the v0 endpoints` — or a
`BREAKING CHANGE:` footer. *Hidden* types stay out of the changelog text but
**still bump the patch digit**; they cost a number, not a deploy. What each type
does to the version number is in [`docs/releasing.md`](docs/releasing.md#what-decides-the-version).

Two traps, both producing a **green run with no changelog entry and no bump**:

**1. The PR title is the release note.** On a multi-commit PR the squashed subject
comes from it, and no local hook can see it. A non-conventional title silently
costs the bump.

**2. A body line starting with `word(` that has another `(` inside it** breaks the
parser — and an unparseable commit is dropped *entirely*, not just that line. Two
commits have already been lost this way. Backticks do not protect it:

```text
Assert.Single(AllMigrations()) ← breaks the parser
Assert.Single(AllMigrations()) ← fine (indented)
- Assert.Single(AllMigrations()) ← fine (list item)
see Assert.Single(AllMigrations()) ← fine (word in front)
```

Only line *starts* matter, so `see foo(x) and bar(y())` mid-sentence was never a
problem. `.githooks/commit-msg` catches this in your own message and prints the
rewrite; it cannot see a PR title.

## Reviewing

Block on these like a missing test:

- a hardcoded credential, in application **or** test code (GitGuardian scans PRs);
- a hardcoded hosting-provider name in code, config, or a committed doc — see
**Host-agnostic repo** in [`AGENTS.md`](AGENTS.md#host-agnostic-repo-deployment-boundary);
- a third-party Action pinned to a tag rather than a full commit SHA;
- an aggregate mutation with no `Version++`;
- a new Production boot guard that does not also update the sim harness (#370);
- a user-visible change with no matching doc update — `specs/product/GLOSSARY.md`
when a concept appears or changes meaning, plus the SPA Help page and in-app
glossary.

## Dependencies

- A package add or bump commits the regenerated `packages.lock.json` **in the same
commit** — CI restores `--locked-mode` and otherwise fails with `NU1004`.
- A known-vulnerable production dependency fails CI. The only mute is a dated
entry in `.github/security-exceptions.json` — see [`SECURITY.md`](SECURITY.md).
Loading
Loading