# Business rules — task-ledger-api

Evidence date: 2026-10-01.

Source: `git@bitbucket.org:overdrive/task-ledger-api.git`, `main` at `8ab244e` (2026-01-03).

There is no business-rules document in the source repository. Every rule below is taken from code, the EF model, tests, or `cursor.rules.md`. Rules the code enforces are **Implemented**. Rules that are a reasonable reading of that code, and are not written down as policy, are **Inferred**. Statements that exist only as coding-agent instructions are **Documented** and are not treated as product policy until someone confirms them.

`TaskService` comments label two behaviours as business rules: title is required, and `CreatedUtc` is set on the server. Those comments are evidence. They are still marked **Implemented**, because the code does those things.

## Access

### BR-001 — Any caller who can reach the process may create and list tasks

**Implemented.** `Program.cs` registers controllers and HTTPS redirection. It does not register authentication or authorization. No action has `[Authorize]`. `launchSettings.json` sets `anonymousAuthentication` to true and `windowsAuthentication` to false.

**Inferred:** there is no per-user task list. `GET /api/Tasks` returns every row.

**Unknown / Requires Confirmation:** whether a reverse proxy or private network is expected to be the only access control. The repository does not mention one.

### BR-002 — The database login is a Windows trusted connection

**Implemented** in both appsettings files: `Trusted_Connection=true` against `(localdb)\mssqllocaldb`. No SQL user or password is stored in git.

**Documented** in `cursor.rules.md`: "NEVER connect to or reference production databases" and "SQL Server is local only (localhost)". The committed server is LocalDB, which is local. The rule text says `localhost`; the connection string since `8ab244e` says `(localdb)\mssqllocaldb`.

## Creating a task

### BR-003 — Title is required

**Implemented.** `TaskService.CreateTaskAsync` throws `ArgumentException` with message "Title is required." when the title is null, empty, or whitespace. `TasksController.CreateTask` catches `ArgumentException` and returns HTTP 400 with `{ "error": "<message>" }`.

Tests: `CreateTaskAsync_WithNullTitle_ShouldThrowArgumentException`, `CreateTaskAsync_WithEmptyTitle_ShouldThrowArgumentException`, `CreateTaskAsync_WithWhitespaceTitle_ShouldThrowArgumentException`.

The comment in the service calls this a business rule.

`CreateTaskRequest` has no `[Required]` attribute. The check that returns 400 is the service, not model validation. A JSON body that omits `Title` binds the default `string.Empty` and fails the same check.

### BR-004 — Title and description are trimmed

**Implemented.** The stored title is `title.Trim()`. A non-null description is `description.Trim()`.

Test: `CreateTaskAsync_ShouldTrimTitleAndDescription` ("  Test Task  " and "  Test Description  " become "Test Task" and "Test Description").

**Inferred:** a description that is only whitespace is stored as an empty string, because `Trim()` of whitespace is empty and the code does not turn that into null. Null description stays null. Test `CreateTaskAsync_WithNullDescription_ShouldCreateTask` covers null only.

### BR-005 — Title length is at most 200 characters; description at most 1000

**Implemented** as EF configuration and as the migration columns `nvarchar(200)` and `nvarchar(1000)`. `Title` is required at the database. `Description` is nullable.

**Not implemented** in the service. `CreateTaskAsync` does not check length. The controller does not catch database update failures. A value past those limits is not the 400 path used for a missing title.

**Inferred:** EF Core will fail the save when the value exceeds the configured maximum. The HTTP status for that failure is the host's unhandled-exception response. This was not executed during documentation.

### BR-006 — Duplicate titles are allowed

**Inferred.** The model has no unique index on `Title`. Two creates with the same title are two rows. No test covers this.

### BR-007 — A new task is not completed

**Implemented.** `CreateTaskAsync` sets `IsCompleted = false`. The column is non-nullable. Test `CreateTaskAsync_WithValidTitle_ShouldCreateTask` asserts false.

No method sets the flag to true. The client cannot send `IsCompleted`; `CreateTaskRequest` has only `Title` and `Description`.

### BR-008 — Creation time is assigned by the server in UTC

**Implemented.** `CreatedUtc = DateTime.UtcNow`. The client cannot send it. The service comment calls this a business rule.

Tests: `CreateTaskAsync_ShouldSetCreatedUtcToUtcNow` asserts the value lies between the times taken before and after the call, and that `Kind` is `Utc`. The valid-create test asserts the timestamp is within the last minute and not after `UtcNow`.

**Inferred:** two tasks created in the same tick can share a `CreatedUtc`. The list sort has no tie-break.

## Reading tasks

### BR-009 — The list is every task, oldest creation time first

**Implemented.** `GetAllTasksAsync` returns `Tasks.OrderBy(t => t.CreatedUtc)`. There is no skip, take, or where clause.

Test: three creates separated by a 10 ms delay come back as first, second, third, and each `CreatedUtc` is less than or equal to the next.

### BR-010 — An empty ledger returns an empty list

**Implemented** in `GetAllTasksAsync_WithNoTasks_ShouldReturnEmptyList`. The controller returns `Ok(tasks)`, so the HTTP status is 200. The test does not call the controller.

## Data that is stored and never updated

### BR-011 — Rows are insert-only on this API

**Implemented.** The only write is `Tasks.Add` followed by `SaveChangesAsync`. There is no update or delete in `TaskService` or in the controller.

**Discrepancy.** Commit `8f0658b` describes update and delete methods. Those methods are not in source. Do not treat the commit message as a rule.

### BR-012 — There is no updated-at value

**Implemented** on `main`. The current entity has `CreatedUtc` only.

**Changed.** At `e12e94a` the entity required `UpdatedAt` as well as `CreatedAt`. That pair was removed in `310c137`. No current rule sets an update time, because nothing updates a row.

## Database process rules

These are instructions in `cursor.rules.md`. The API does not check them at runtime.

### BR-013 — Migrations are generated and not applied by the application

**Documented.** `cursor.rules.md` says migrations must be generated and never applied automatically, and it denies `dotnet ef database update`.

**Implemented** in `Program.cs`: the host does not call `Database.Migrate` or `EnsureCreated`.

**Unknown / Requires Confirmation:** whether a person has applied `Migrations/InitialCreate.sql` or updated the database by hand.

### BR-014 — Schema scripts should be idempotent and non-destructive

**Documented.** `cursor.rules.md` says to generate idempotent SQL and forbids `DROP DATABASE` and `TRUNCATE`.

**Implemented** in `Migrations/InitialCreate.sql`: each step checks `__EFMigrationsHistory` before creating the table or inserting the history row. The script does not drop a database or truncate a table.

The EF `Down` method in `20260101150955_InitialCreate.cs` drops table `Tasks`. That method is not the idempotent script. Nothing in `Program.cs` calls `Down`.

### BR-015 — Transient SQL errors are retried

**Implemented.** `EnableRetryOnFailure(maxRetryCount: 5, maxRetryDelay: 30 seconds, errorNumbersToAdd: null)` on the SQL Server options.

**Inferred:** only errors EF Core already treats as transient are retried. The call passes no extra error numbers. A failure after the retries still leaves the controller, which only catches `ArgumentException`.

## HTTP behaviour that is not a domain rule

### BR-016 — HTTPS redirection is always on

**Implemented.** `app.UseHttpsRedirection()` is outside the Development check. Swagger is inside that check.

### BR-017 — Host filtering allows any host

**Implemented.** `appsettings.json` sets `AllowedHosts` to `*`. `appsettings.Development.json` does not override it.

### BR-018 — The 201 Location points at the list action

**Implemented.** `CreateTask` returns `CreatedAtAction(nameof(GetAllTasks), new { id = task.Id }, task)`.

**Inferred:** because `GetAllTasks` has no `id` parameter, the generated location carries `id` as a query string, and the list action ignores it. There is no get-by-id resource to point at.

## Rules that are not enforced

- A maximum title or description length as an HTTP 400 (BR-005 is a column constraint only).
- Uniqueness of titles (BR-006).
- A caller identity (BR-001).
- Changing `IsCompleted` (BR-007).
- Automatic migration on startup (BR-013).
