Skip to content
Merged
12 changes: 12 additions & 0 deletions Documentation/authorizing-commands-and-queries.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,10 +97,22 @@ The full order of stages is on [Command pipeline](commands/command-pipeline.md);
- 403 answers a known caller who does not meet a declaration, a per-request `authorize` that returned `false`, or `denied(...)`.
- Unparseable JSON is rejected with 400 before the role check, so an authenticated caller without the role gets 400 for a malformed body and 403 for a well-formed one.

## Queries: roles and ownership

A model-bound query takes the same decorators on its read-model class or on a `@query()` method, and a method's declaration replaces the class's. A denied caller never reaches the query method and gets `isAuthorized: false`.

A role answers "may this caller use the query at all", not "which rows may they see". `@roles('Planner')` on `allTasks` lets every planner read every task. When a read is owner-scoped, make ownership part of the query itself: read the caller's identity with `currentContext()` from `@cratis/arc.core` and put it in the data source's filter, next to the requested ID. When the caller has no identity, deny the query; never drop the owner filter to make it work. [Observable queries](queries/observable-queries.md#authorize-a-live-query) shows an owner-filtered live query.

For a live query, authorization runs once, when the subscription opens. A role removed later does not close a running subscription; use an [emission guard](queries/observable-query-emission-guards.md) when access must be re-checked on every emission.

## Keep security out of validators

Put every security and tenant check in authorization, never in a validator: a trusted direct caller can lower the blocking severity, but nothing lowers authorization.

## Test who may call

Authorization is easy to break silently: a moved decorator or a new command without one leaves an operation open, and nothing fails. Specify it like any other behavior. A scenario's `withContext({ principal })` sets the caller, and `shouldNotBeAuthorized()` and `shouldBeAuthorized()` assert the verdict. [Testing commands](testing/commands.md#test-authorization) shows the specs for an anonymous caller, a caller without the role, and a caller with it.

## Related

- [Authentication](core/authentication.md)
Expand Down
19 changes: 18 additions & 1 deletion Documentation/commands/command-filters.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@ title: Command filters
description: Validate low-level defineCommand and defineQuery definitions with validate callbacks and shared filters, and keep Zod schemas for shape only.
---

Low-level definitions made with `defineCommand` and `defineQuery` do not use validator classes. They take a `validate` callback and a list of `filters`, which run in the same pipeline stage as model-bound validators. Use a filter when one rule applies to many operations.
Low-level definitions made with `defineCommand` and `defineQuery` do not use validator classes. They take a `validate` callback and a list of `filters`, which run in the same pipeline stage as model-bound validators. Use a filter when one rule applies to many low-level operations: you write it once and list it on each definition that needs it.

:::caution[Filters apply only to low-level definitions]
A filter runs only for the `defineCommand` or `defineQuery` definitions that list it. There is no global filter that runs for every command, and `@command()` classes cannot take filters at all. This differs from Arc on .NET, where an `ICommandFilter` runs for every model-bound command. See [Cross-cutting rules for model-bound commands](#cross-cutting-rules-for-model-bound-commands) for what to use instead.
:::

## Validate and share a filter

Expand Down Expand Up @@ -40,6 +44,19 @@ Arc converts every schema to JSON Schema at startup. Types without a JSON repres

The caller gets 400 with one result: reason `validatorFailed`, message `Validation failed`, and no members. The exception text is never sent; the original error goes to the `logger` option. When the request was already cancelled, the failure is reported as an exception instead.

## Cross-cutting rules for model-bound commands

When a rule should apply to many `@command()` classes, pick the mechanism by what the rule is about:

| The rule is about | Use | Runs on `/validate` |
| --- | --- | --- |
| Who may call a group of commands | A named policy with `builder.addAuthorizationPolicy(name, policy)` and `@authorize({ policy: name })` on each command; see [Authorization policies](../core/authorization.md) | Yes |
| A value that appears in many commands | A [concept validator](../concepts.md#validate-a-concept-everywhere), which runs wherever the concept is a field | Yes |
| One command's input | A [`CommandValidator`](command-validation.md) per command | Yes |
| Wrapping every command's execution, such as ambient state or timing | `builder.addCommandExecutionRunner(...)`; see [Command execution scopes](command-execution-scopes.md) | No; it runs only after validation passes |

Each of these is opted into per command or per value, except the execution runner, which runs for every validated command. Nothing runs a validation rule for every command automatically, so a new command is not covered by a rule you wrote for the others until you declare it.

## Related

- [Low-level definitions](low-level-definitions.md)
Expand Down
95 changes: 74 additions & 21 deletions Documentation/commands/command-validation.md
Original file line number Diff line number Diff line change
@@ -1,18 +1,18 @@
---
title: Command validation
description: Keep shape checks at the wire boundary and give callers field-specific messages with CommandValidator rules, services, and asynchronous checks.
description: Give callers field-specific messages with CommandValidator rules, choose the phase where each rejection belongs, and read stored state in a rule with readModelForValidation.
---

A task title arrives as a string, but it must not be blank. The wire type goes on the command field; the business rule gets its own validator. Arc runs the validator before `handle()`, and on the command's `/validate` route, so a frontend can check input before submitting it.
A task title arrives as a string, but it must not be blank, and a rename to the same title is pointless. If those checks live inside `handle()`, a form cannot ask about them before the user presses Save, and every handler grows its own error format. Arc gives rules a place of their own: a validator runs before `handle()`, answers with a message for the exact field, and also runs on the command's `/validate` route, so a frontend can check input without changing anything.

## Add a command rule

The [Tasks sample](https://github.com/Cratis/Arc.TypeScript/blob/main/Samples/Tasks/Features/Tasks/Registration/Registration.ts) declares a validator beside `RegisterTask`:
The [Tasks sample](https://github.com/Cratis/Arc.TypeScript/blob/main/Samples/Tasks/Features/Tasks/Registration/Registration.ts) keeps its validator in the same file as `RegisterTask`, so the imports it needs are the Arc ones:

```typescript
import { CommandValidator, validator } from '@cratis/arc.core';
import { RegisterTask } from './RegisterTask.js';
```typescript title="Features/Tasks/Registration/Registration.ts (excerpt)"
import { command, CommandValidator, validator } from '@cratis/arc.core';

// RegisterTask is declared above in the same file.
@validator(RegisterTask)
export class RegisterTaskValidator extends CommandValidator<RegisterTask> {
constructor() {
Expand All @@ -31,16 +31,69 @@ Send `{ "id": "<valid task UUID>", "title": "" }` to `POST /api/tasks/registrati
{"severity":3,"message":"A title is required","members":["title"],"reason":"rule"}
```

`handle()` does not run. A type mismatch, missing required field, or malformed JSON fails earlier with `malformedRequest`, not a rule message.
Here is what happened. Arc bound the body to a `RegisterTask`, ran every validator for the command and for the concepts on its fields, and collected all their results. A failure does not stop the other rules, so a form can show every problem at once. Because a result was above the allowed severity, Arc answered 400 and never reached `handle()`. A type mismatch, a missing required field, or malformed JSON fails earlier with `malformedRequest` and no rule message.

## Shape or rule?
## Choose where to reject

| Put it on the field | Put it in a validator |
| --- | --- |
| Types, required and optional fields, defaults | Business rules a user can fix, with a message and the member it concerns |
| Anything where failure means the client sent the wrong shape | Rules that need services or asynchronous checks |
Validators are one of several places a command can say no. Each place sees different information and runs at a different moment, so the right one depends on what the decision needs:

| The decision depends on | Put it in | Runs on `/validate` | Caller sees |
| --- | --- | --- | --- |
| The request's shape: types, required and optional fields | `@field` declarations | Yes | 400 `malformedRequest`, no message or members |
| Who is calling | `@roles`, `@authorize`, or a policy | Yes | 401 or 403 |
| One value, wherever it appears (a title format) | A [concept validator](../concepts.md#validate-a-concept-everywhere) | Yes | 400 with your message and member |
| The command's own fields, or a service | A `CommandValidator` rule | Yes | 400 with your message and member |
| Stored state for the entity the command is about | A `CommandValidator` rule that calls `readModelForValidation` | Yes | 400 with your message and member |
| Data you load anyway to do the work (the task must exist, the caller must own it) | `provide()` returning `rejected(...)` or `denied(...)` | No | 400 or 403 |
| A decision only the handler can make | `handle()` returning `rejected(...)` or `denied(...)` | No | 400 or 403 |

Two rules of thumb pick the row:

- **Reject as early as the information allows.** Everything down to the readModelForValidation row runs on `/validate`, so a form gets the message before submitting. `provide()` and `handle()` run only on execution.
- **Keep access control out of validators.** A trusted direct caller can lower the blocking severity, which lets validation results through; nothing lowers authorization or `denied(...)`. See [Authorizing commands and queries](../authorizing-commands-and-queries.md).

A check against stored state tells you what was true when it ran. Another request can change that state before `handle()` runs, so enforce a rule that must hold under concurrency at the storage boundary that performs the change, not only in a validator. [Command outcomes](command-outcomes.md) covers `rejected` and `denied`; [Model-bound commands](model-bound/index.md#prepare-data-in-provide) shows `provide()`.

## Read stored state in a rule

"The task already has this title" needs the stored task. A rule can read the read model that belongs to the command's key with `readModelForValidation(Type)` from `@cratis/arc.core`:

```typescript title="RenameTask.ts"
import { field } from '@cratis/fundamentals';
import { command, CommandValidator, key, readModelForValidation, validator } from '@cratis/arc.core';
import { TaskView } from './TaskView.js';

@command()
export class RenameTask {
@field(String) @key() id!: string;
@field(String) title!: string;

handle(): void {
// Rename the task in your storage.
}
}

A shape failure produces one result with reason `malformedRequest`, no message a user can act on, and no members. A rule that applies to a value wherever it appears, such as a title format, belongs in a [concept validator](../concepts.md#validate-a-concept-everywhere).
@validator(RenameTask)
export class RenameTaskValidator extends CommandValidator<RenameTask> {
constructor() {
super();
this.ruleFor(command => command.title).mustAsync(async title => {
const current = await readModelForValidation(TaskView, { optional: true });
return current === null || current.title !== title;
}).withMessage('The task already has this title');
}
}
```

`TaskView` is your read model, with at least a string `title` field. Arc does not load it itself; a registered read-model resolver does. The [MongoDB](../mongodb/index.md) and experimental [Chronicle](../chronicle/read-models/index.md) integrations register resolvers for the models you configure, and `builder.addReadModelForCommandResolver(token)` adds your own, as described in [Command context](command-context.md#load-a-read-model-by-key).

What happens when the rule runs:

- Arc resolves the command key from the `@key()` field and asks the resolver that owns `TaskView` for that key. The resolver also receives the command context, with the caller's tenant, so it can scope the lookup.
- `{ optional: true }` returns `null` when nothing is stored, so the rule decides what absence means. Here, a task that does not exist yet has no title to repeat. Without `optional`, a missing model makes the rule throw, and the caller gets reason `validatorFailed` instead of your message.
- Renaming task `t-1` from `Old` to `Old` answers 400 with `The task already has this title` for `title`, on both the execute and `/validate` routes. Renaming it to `New` succeeds.

`readModelForValidation` works only inside a validator of a model-bound command, during validation. Called anywhere else, it throws. Validators do not receive read models through their constructors.

## Rule vocabulary

Expand All @@ -62,16 +115,16 @@ A shape failure produces one result with reason `malformedRequest`, no message a

A validator may declare constructor dependencies with `@injectable(Service)` or `static inject = [Service] as const`. Register the service with `builder.services`. Arc preflights the dependencies and constructs each validator once during build, which catches invalid selectors before any request. It then resolves fresh validators and services in each execution scope.

Read models loaded by command key are not injected into validators. Make an explicit, tenant-scoped lookup in a rule when validation needs stored state.
A validator that throws, or whose dependency cannot be resolved, never reports success. The caller gets 400 with reason `validatorFailed` or `dependencyUnavailable`, no exception text, and the error goes to the configured logger.

## Low-level definitions

## What validation is not
`defineCommand` and `defineQuery` do not use validator classes; they keep their `validate` and `filters` callbacks, which run in the same pipeline stage. See [Command filters](command-filters.md).

Authentication and authorization run before any rule, and a trusted direct caller can lower the blocking severity. Never put access control in a validator; see [Authorizing commands and queries](../authorizing-commands-and-queries.md). Which severities block is covered in [Validation severity filtering](validation-severity-filtering.md).
## Recap

Low-level `defineCommand` and `defineQuery` definitions keep their `validate` and `filters` callbacks; see [Command filters](command-filters.md).
Shape belongs on `@field`, a value's own rules on its concept, a command's rules in a `CommandValidator`, and access control in authorization. A rule that needs stored state reads it with `readModelForValidation`; a decision that needs the loaded data belongs in `provide()`. Everything up to validation also answers on `/validate`, which is what lets a form speak up before the user submits.

## Related
## Next step

- [Query validation](../queries/validation.md)
- [Concepts](../concepts.md)
- [Testing commands](../testing/commands.md)
[Validation severity filtering](validation-severity-filtering.md) explains which severities block and how a caller can let warnings through. To prove a rule in a spec, see [Testing commands](../testing/commands.md).
Loading
Loading