# j17 Documentation

Last updated: 2026-10-06T22:41:39Z
Generated: 2026-10-14T00:50:25Z
Source: https://j17.dev/docs

---

---


# Getting Started

Get up and running with j17 in under five minutes.

## Prerequisites

You'll need:
- A j17 account (sign up at [j17.dev](https://j17.dev))
- An API key for your instance
- `curl` or any HTTP client

## 1. Create your spec

Define your aggregate types and events in a JSON file. The spec goes inside a `"spec"` key:

```json
{
  "spec": {
    "aggregate_types": {
      "user": {
        "events": {
          "was_created": {
            "schema": {
              "type": "object",
              "properties": {
                "name": { "type": "string" },
                "email": { "type": "string", "format": "email" }
              },
              "required": ["name", "email"]
            },
            "handler": [
              { "set": { "target": "", "value": "$.data" } },
              { "set": { "target": "created_at", "value": "$.metadata.timestamp" } }
            ]
          },
          "had_email_updated": {
            "schema": {
              "type": "object",
              "properties": {
                "email": { "type": "string", "format": "email" }
              },
              "required": ["email"]
            },
            "handler": [
              { "merge": { "target": "", "value": "$.data" } }
            ]
          }
        }
      }
    },
    "agent_types": ["user", "admin"]
  }
}
```

The spec defines:
- **aggregate_types**: Kinds of entities in your system
- **events**: What can happen to those entities
- **handlers**: How events transform state (using the [Tick language](/docs/reference/tick))
- **agent_types**: Who can trigger events

Save this as a `.json` file and upload via the dashboard or [Admin API](/docs/api/admin-api).

## 2. Post an event

```bash
curl -X POST https://myapp.j17.dev/user/550e8400-e29b-41d4-a716-446655440000/was_created \
  -H "Authorization: Bearer $J17_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "data": { "name": "Alice", "email": "alice@example.com" },
    "metadata": {
      "actor": { "type": "admin", "id": "550e8400-e29b-41d4-a716-446655440001" }
    }
  }'
```

Every event needs:
- **URL path**: `/{aggregate_type}/{aggregate_id}/{event_type}`
- **data**: The event payload (validated against your schema)
- **metadata.actor**: Who performed the action

## 3. Query the aggregate

```bash
curl https://myapp.j17.dev/user/550e8400-e29b-41d4-a716-446655440000 \
  -H "Authorization: Bearer $J17_API_KEY"
```

Response:

```json
{
  "ok": true,
  "data": {
    "name": "Alice",
    "email": "alice@example.com",
    "created_at": 1705312800
  },
  "metadata": {
    "length": 1,
    "created_at": 1705312800,
    "updated_at": 1705312800
  }
}
```

That's it. You're event sourcing.

## What just happened?

1. You defined the shape of your data (the spec)
2. You recorded a fact (the event)
3. j17 computed current state by applying the handler

The event is stored forever. The aggregate is derived on demand. This is the core trade-off: you give up simple SQL queries, you get immutable history.

## Next steps

- Learn the [core concepts](/docs/concepts): events, aggregates, handlers
- Build something real with [AI-assisted development](/docs/guides/ai-development)
- Explore the [full API reference](/docs/api)
- Read the [spec reference](/docs/reference/spec) to model complex domains

## Common gotchas

**Events are immutable.** There's no DELETE. To "undo," write a compensating event.

**IDs matter.** Use UUIDv4 for most things. For human-readable codes (booking references, promo codes), use 9-character humane codes.

**Actors are required.** Every event must include `metadata.actor` with `type` and `id`. This isn't bureaucracy -- it's your audit trail.

---


# CLI Overview

The `j17` command-line client manages your j17 account, instances, API keys, and spec deployments from a terminal. It talks to the control plane at `https://console.j17.dev`; application data still goes through your instance's HTTP API.

## Install

The CLI is currently available as part of the private alpha. Email
[hello@j17.dev](mailto:hello@j17.dev) to request either a binary for your
platform or a source checkout.

If you receive a binary, put it somewhere on `PATH`, then verify it:

```bash
j17 version
```

If you receive the source, install Go 1.22 or newer and run:

```bash
cd j17
go build -o j17 .
install -m 0755 j17 ~/.local/bin/j17
```

## First session

Log in, choose an instance and environment, then work against that saved context:

```bash
j17 login
j17 instance list
j17 use my-app --env test
j17 key list
```

`j17 login` opens a browser for approval. On a remote machine or when you want to open the link yourself, use:

```bash
j17 login --no-browser
```

See [Login and Credentials](/docs/cli/authentication) for session storage, profiles, and environment overrides.

## Commands

| Command | Purpose |
|---------|---------|
| `j17 login` | Authorize this machine through your browser |
| `j17 logout` | Revoke this machine's stored session |
| `j17 whoami` | Show the signed-in user |
| `j17 instance list` | List instances you can access |
| `j17 instance create NAME` | Create an instance |
| `j17 instance show NAME_OR_ID` | Show one instance |
| `j17 instance update NAME_OR_ID` | Change an instance's name and/or tier |
| `j17 use NAME_OR_ID --env ENV` | Save the current instance and environment |
| `j17 key list` | List API keys |
| `j17 key create NAME --scope SCOPE` | Create an API key |
| `j17 key rotate KEY_ID` | Rotate an API key |
| `j17 key revoke KEY_ID` | Revoke an API key |
| `j17 spec validate [PATH]` | Check that a spec file is valid JSON |
| `j17 spec deploy [PATH]` | Validate and deploy a spec on the server |

Run `j17 --help` to print the command summary. Unknown flags are rejected instead of being silently ignored.

## Common flags

Flags are accepted only by commands that use them:

| Flag | Used by | Meaning |
|------|---------|---------|
| `--json` | Leaf commands | Emit result data as JSON where the command has a result |
| `--instance NAME_OR_ID` | Key commands, `spec deploy` | Override the saved instance for this invocation |
| `--env prod\|staging\|test` | `use`, key commands, `spec deploy` | Select or override the environment |
| `--yes`, `-y` | `key revoke`, `spec deploy` | Skip an interactive confirmation |

Flags may appear before or after positional arguments:

```bash
j17 key list --env staging
j17 key create deploy-bot --scope deploy --instance my-app --env test
```

## What the CLI does not do

The CLI manages control-plane resources. It does not write events, read aggregates, query projections, or deploy projection definitions. Use the [HTTP API](/docs/api) and an environment-scoped API key for those operations.

## Next steps

- [Choose an instance and environment](/docs/cli/instances)
- [Create and rotate API keys](/docs/cli/api-keys)
- [Validate and deploy specs](/docs/cli/specs)
- [Use the CLI in scripts and CI](/docs/cli/automation)

# Login and Credentials

The CLI uses OAuth device authorization. Your password and browser session never pass through the CLI.

## Log in

```bash
j17 login
```

The CLI requests a short-lived code, opens the canonical j17 approval page, and waits for you to approve or deny the request. It prints the URL and code as well, so login still works if the browser cannot be launched.

Use `--no-browser` on a server, over SSH, or whenever you want to open the approval URL on another device:

```bash
j17 login --no-browser
```

The login attempt waits for up to 15 minutes. After approval, `j17` stores a refreshable session for the active profile and confirms the account with `/api/me`.

```bash
j17 whoami
j17 whoami --json
```

## Session refresh

Access tokens are short-lived. Before an authenticated command, the CLI refreshes a token that is expired or close to expiring. Refresh tokens rotate on every use.

Credential refresh is serialized across processes, so two `j17` commands launched at the same time do not race and invalidate the session. If the server rejects or revokes the session, the CLI removes the unusable local credentials and asks you to run `j17 login` again.

Each stored session is bound to the control-plane URL that issued it. If `J17_CONTROL_URL` or the active profile later selects another server, the CLI refuses to send either token and asks you to select the original profile or log in again. It also fails closed when `config.json` is malformed instead of silently falling back to the production control plane.

## Log out

```bash
j17 logout
```

Logout revokes the stored refresh session and removes its local credential bundle. If the server cannot confirm revocation, the CLI still removes the local credentials and reports the remote error.

## Profiles

Profiles keep credentials and the selected instance/environment separate. The profile is chosen with `J17_PROFILE`; the default profile name is `default`.

```bash
J17_PROFILE=work j17 login
J17_PROFILE=work j17 use billing --env prod
J17_PROFILE=work j17 whoami

export J17_PROFILE=local
export J17_CONTROL_URL=http://localhost:4000
j17 login
```

`J17_PROFILE` is evaluated for each process. Set it consistently for every command that should use the same session and saved selection.

Credential files written by CLI versions before issuer binding do not contain enough information to identify their issuing server safely. After upgrading, run `j17 login` once for each profile you still use; the new login replaces that profile's old bundle.

## Environment variables

| Variable | Purpose |
|----------|---------|
| `J17_PROFILE` | Select a named credentials and configuration profile |
| `J17_CONTROL_URL` | Override `https://console.j17.dev`, normally for local development or self-hosting |
| `J17_TOKEN` | Use a bearer token for this process without reading, refreshing, or persisting a login session |

`J17_TOKEN` is intended for ephemeral automation. The CLI never saves or refreshes it. `j17 logout` revokes only the session stored for the active profile; it does not revoke an externally supplied `J17_TOKEN`.

## Files on disk

The CLI uses the operating system's user configuration directory. Common locations are `~/.config/j17` on Linux, `~/Library/Application Support/j17` on macOS, and `%AppData%\j17` on Windows.

| File | Contents |
|------|----------|
| `config.json` | Profile metadata and the selected instance/environment |
| `credentials.json` | Access and refresh tokens, grouped by profile |
| `credentials.lock` | Advisory lock used to serialize credential changes |

Credential writes are atomic. On Unix-like systems, `credentials.json` and refresh-attempt files are created with mode `0600`. Do not commit or copy the credential files into a project.

## Troubleshooting login

**The browser did not open.** Copy the printed URL into any browser and enter the displayed code, or rerun with `--no-browser`.

**The code expired.** Run `j17 login` again. Device codes are deliberately short-lived and single-use.

**A command says `not logged in`.** The active profile has no usable session, or its refresh session was revoked. Check `J17_PROFILE`, then log in again.

**A command says the credentials belong to another server.** Restore the `J17_PROFILE` and `J17_CONTROL_URL` used at login, or run `j17 login` to replace that profile's credential bundle for the currently selected server. The CLI does not send the old tokens to the new server.

**A command says the credentials predate server binding.** Run `j17 login` once for that profile. The CLI deliberately does not guess which server issued an older credential bundle.

**A local development login points at production.** Export `J17_CONTROL_URL` for both login and later commands, and use a distinct `J17_PROFILE` to avoid mixing environments.

# Instances and Environments

An instance contains three isolated environments: `test`, `staging`, and `prod`. The CLI saves one instance/environment pair as your current context, while still allowing explicit overrides for individual commands.

## List instances

```bash
j17 instance list
```

The table includes each instance's name, ID, tier, and status. For structured output:

```bash
j17 instance list --json
```

## Create an instance

```bash
j17 instance create my-app
j17 instance create my-app --tier hobby
```

The default tier is `free`. Supported user-facing tier values are `free`, `hobby`, and `pro`.

Creating an instance does not select it automatically. Use `j17 use` after creation.

## Show an instance

Names and UUIDs are both accepted:

```bash
j17 instance show my-app
j17 instance show 550e8400-e29b-41d4-a716-446655440000
```

## Update an instance

Change the name, tier, or both. The instance in the first argument is resolved
before a rename, so it may be either the current name or UUID:

```bash
j17 instance update my-app --tier alpha
j17 instance update my-app --name customer-api
j17 instance update my-app --name customer-api --tier pro
```

At least one of `--name` or `--tier` is required. The server checks whether the
tier is active and available to your account; the CLI deliberately does not
keep its own list of plans. Updating an instance requires owner or administrator
access.

## Select a working context

```bash
j17 use my-app --env test
```

`j17 use` verifies that you can access the instance, then stores both its immutable ID and its display name with the environment. Subsequent API-key and spec commands address the saved instance by ID, so renaming it—or later reusing its old name—cannot redirect those commands. The environment is required and must be `test`, `staging`, or `prod`.

The saved selection is used by API-key and spec commands:

```bash
j17 key list
j17 spec deploy
```

## Override the selection

Use `--instance` and `--env` when a command should not use the saved context:

```bash
j17 key list --instance another-app --env staging
j17 spec deploy ./specs/next.json --instance another-app --env test
```

Explicit flags take precedence over the saved profile. The override applies only to that invocation and does not change the selection saved by `j17 use`.

Commands that mutate an environment, such as `key create` and `spec deploy`, refuse to guess an environment. They require either a saved environment or an explicit `--env`; they never silently choose production.

## Profiles have separate selections

Each `J17_PROFILE` stores its own current context:

```bash
J17_PROFILE=work j17 use billing --env prod
J17_PROFILE=local j17 use sandbox --env test
```

This is useful when the same machine works with multiple accounts or control planes. See [Login and Credentials](/docs/cli/authentication#profiles).

# API Keys

API keys authenticate requests to an instance's data API. They are scoped to one environment and one capability set.

## Key scopes

| Scope | Use |
|-------|-----|
| `read` | Read aggregates, events, and projections |
| `write` | Read data and write events |
| `deploy` | Deploy specs and projections; no application-data access |

Use the narrowest scope that fits the job. See [API authentication](/docs/api/authentication#key-scopes) for the complete authorization model.

## List keys

```bash
j17 key list
j17 key list --env staging
j17 key list --instance my-app --env prod
```

Without an explicit override, the command uses the instance and environment saved by `j17 use`. When an environment is selected, the CLI filters the result to that environment.

The list shows key metadata and prefixes, never raw key secrets.

## Create a key

```bash
j17 key create local-backend --scope write
j17 key create ci-deploy --scope deploy --env test
```

Creation requires an instance and environment, either from the saved selection or from `--instance` and `--env`.

The raw key is returned exactly once. In normal output mode it is the only value written to stdout; progress and the copy warning go to stderr. Save it immediately in your secret manager:

```bash
J17_API_KEY="$(j17 key create local-backend --scope write)"
export J17_API_KEY
```

The CLI does not store generated API keys in its configuration or credentials files.

For a structured response containing both metadata and `raw_key`:

```bash
j17 key create ci-deploy --scope deploy --env test --json
```

## Rotate a key

Rotation creates a new key and schedules the old key for revocation:

```bash
j17 key rotate KEY_ID
j17 key rotate KEY_ID --grace 4
```

`--grace` is a non-negative number of hours. If omitted, the server's default grace period applies. A grace period of `0` revokes the old key without an overlap window.

The new raw key is shown exactly once and is not persisted by the CLI. Update consumers during the overlap window, then allow the old key to expire.

## Revoke a key

```bash
j17 key revoke KEY_ID
```

The command asks for confirmation. In non-interactive automation, acknowledge the destructive action explicitly:

```bash
j17 key revoke KEY_ID --yes
# or
j17 key revoke KEY_ID -y
```

Rotation and revocation use a key ID, not a key prefix or raw secret. Obtain IDs with `j17 key list`.

## Keep raw keys out of logs

Human-readable create and rotate commands put the raw key on stdout so it can be captured separately from diagnostics. `--json` also includes the raw key; treat that entire JSON response as a secret and do not archive it as ordinary CI output.

# Specs

The CLI can perform a quick local JSON check and deploy a spec through the authenticated control plane.

## Spec file shape

`j17 spec deploy` reads the spec object itself. The CLI adds the HTTP request's outer `"spec"` field for you, so do not wrap the file in a second `"spec"` object.

```json
{
  "aggregate_types": {
    "order": {
      "events": {
        "was_placed": {
          "schema": {
            "type": "object",
            "properties": {
              "total": { "type": "number" }
            },
            "required": ["total"]
          },
          "handler": [
            { "set": { "target": "", "value": "$.data" } }
          ]
        }
      }
    }
  },
  "agent_types": ["user"]
}
```

By default, both spec commands read `./j17.spec.json`. Pass a path to use another file.

## Local validation

```bash
j17 spec validate
j17 spec validate ./specs/app.json
```

Local validation checks only that the file exists and contains valid JSON. It does not validate the j17 spec grammar, handlers, schemas, or compatibility with an already deployed spec. A successful local check is useful for a fast syntax failure, but it does not guarantee deployment will succeed.

Full semantic and compatibility validation runs on the server during `spec deploy`.

## Deploy

```bash
j17 use my-app --env test
j17 spec deploy
```

Or provide the target explicitly:

```bash
j17 spec deploy ./specs/app.json --instance my-app --env staging
```

The command reports the deployed spec hash and any server warnings. With `--json`, the response includes the instance, `spec_hash`, and `warnings` when present.

## Production confirmation

A production deployment prompts before making the request:

```text
Deploy j17.spec.json to instance my-app (env=prod)? [y/N]
```

For reviewed, non-interactive deployment pipelines, acknowledge that step with `--yes`:

```bash
j17 spec deploy --instance my-app --env prod --yes
```

## Incompatible changes

The server rejects incompatible changes by default.

- `--force` waives the broad backward-compatibility check on `test` and `staging`. The CLI refuses `--force` on production.
- `--confirm-breaking` acknowledges the narrower class of changes that alter how already-stored events are interpreted. It is available in every environment, including production.

```bash
j17 spec deploy ./specs/next.json --env test --force
j17 spec deploy ./specs/next.json --env prod --confirm-breaking --yes
```

These flags answer different questions; one does not imply the other. Review the server's validation error before using either. See [Schema Deployment](/docs/api/schema-deployment#incompatible-changes-and-force) and [The Spec](/docs/reference/spec) for the underlying rules.

## Projections

The current CLI deploys specs only. Deploy projection definitions through the [projection HTTP API](/docs/api/schema-deployment#deploy-a-projection).

# Scripting and CI

The CLI keeps result data on stdout and progress or diagnostics on stderr. Scripts can capture a value or pipe JSON without scraping human-facing status messages.

## JSON output

Add `--json` to a data-producing command:

```bash
j17 whoami --json
j17 instance list --json
j17 key list --json
j17 spec deploy --json
```

For example, with `jq`:

```bash
instance_id="$(j17 instance list --json | jq -r '.[] | select(.name == "my-app") | .id')"
raw_key="$(j17 key create ci --scope deploy --env test --json | jq -r '.raw_key')"
```

Action-only commands such as `logout`, `use`, and successful local `spec validate` have no result body, so `--json` produces no stdout on success.

## Non-interactive authentication

For an ephemeral job, provide a control-plane bearer token without writing credentials:

```bash
J17_TOKEN="$CONTROL_PLANE_TOKEN" \
  j17 instance list --json
```

`J17_TOKEN` is used only by that process. It is neither persisted nor refreshed.

For a persistent runner, use a dedicated profile and run the browser-assisted login once:

```bash
J17_PROFILE=ci j17 login --no-browser
J17_PROFILE=ci j17 use my-app --env staging
```

## Confirm destructive commands explicitly

Non-interactive input defaults to "no." Pass `--yes` only where the pipeline has already made the decision:

```bash
j17 key revoke "$KEY_ID" --yes
j17 spec deploy --instance my-app --env prod --yes
```

`--force` is never accepted for production spec deployment, even with `--yes`.

## Exit codes

The CLI uses stable exit codes so automation does not need to parse error strings:

| Code | Meaning |
|------|---------|
| `0` | Success |
| `1` | Generic local or transport failure |
| `2` | Invalid usage, unknown flag, missing argument, or declined confirmation |
| `3` | Login required or HTTP 401 |
| `4` | Forbidden (HTTP 403) |
| `5` | Validation/not-found failure, including HTTP 404 or 422 |
| `6` | Conflict (HTTP 409) |
| `7` | Other HTTP client error |
| `8` | Server error (HTTP 5xx) |

Errors are written to stderr. Server errors include the request ID when one is returned; include that ID when contacting support.

```bash
if output="$(j17 spec deploy --instance my-app --env staging --json)"; then
  printf '%s\n' "$output"
else
  status=$?
  echo "deploy failed with exit code $status" >&2
  exit "$status"
fi
```

## Environment isolation

Use explicit profile and target variables in CI rather than depending on a developer's default selection:

```bash
export J17_PROFILE=ci
export J17_CONTROL_URL=https://console.j17.dev

j17 spec deploy ./j17.spec.json \
  --instance "$J17_INSTANCE" \
  --env staging \
  --json
```

For API-key creation and spec deployment, the CLI refuses to invent an environment. If neither `--env` nor the active profile contains one, the command exits with code `2` before making a request.

---


# Core Concepts

Event sourcing is simple: store facts, derive state. But simple doesn't mean obvious. These concepts explain the mental model.

## Events are facts

An event says "this happened." It's not a command ("do this") or a state ("this is"). It's history:

- `was_placed` not `place_order`
- `had_email_updated` not `update_email`

Events are immutable. Once written, they never change. If you need to "undo," write a compensating event.

## Aggregates are derived

An aggregate is current state computed from all its events. There's no "update the user record." There's "write a `was_updated` event, compute state fresh."

This seems inefficient. It's not:
- j17 caches aggressively
- Events are small (hundreds of bytes)
- Computation is fast (Zig handlers)
- You get history for free

## The spec defines everything

Your spec is JSON that declares:
- What aggregate types exist
- What events can happen
- How events transform state (handlers)
- Who can trigger events (agents)

No code deployment needed. Upload a new spec, behavior changes immediately.

## Handlers are pure functions

A handler takes (state, event) and returns new state. No side effects, no external calls. Just data transformation.

```
state + event -> handler -> new_state
```

Tick provides 25 declarative operations in five categories: basic (set, merge, append, remove, increment, decrement), array (filter, map, update_where, upsert, append_unique), dynamic key (set_at, merge_at, remove_at, increment_at, decrement_at), monotonic and additive (max, min, max_at, min_at, merge_max, merge_min, merge_add), and control flow (conditional, let).

This makes them:
- Testable (input -> expected output)
- Cacheable (same input, same output)
- Parallelizable (no shared state)

## Time is explicit

Every event has a timestamp. Query historical state:

```bash
GET /order/123?at=1705312800  # State on Jan 15
```

Audit trails are free. Just look at the events.

## Consistency is optional

j17 supports three OCC modes: internal (default) for automatic request-time safety, external (via `previous_length`) for read-modify-write consistency, and disabled (via `skip_occ`) for append-only patterns. Pick the right model for your use case.

## Learn more

- [Events](/docs/concepts/events) - Writing immutable facts
- [Aggregates](/docs/concepts/aggregates) - Computing current state
- [Handlers](/docs/concepts/handlers) - Transforming state
- [Atomicity](/docs/concepts/atomicity) - Concurrency and consistency

# Events

Events are immutable facts about something that happened in your system.

## Writing an event

Events are written via HTTP POST to `/{aggregate_type}/{aggregate_id}/{event_type}`:

```bash
curl -X POST https://myapp.j17.dev/order/550e8400-e29b-41d4-a716-446655440000/was_placed \
  -H "Authorization: Bearer $J17_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "data": {
      "items": [{ "sku": "WIDGET-1", "quantity": 2 }],
      "total": 59.98
    },
    "metadata": {
      "actor": { "type": "user", "id": "550e8400-e29b-41d4-a716-446655440001" }
    }
  }'
```

Every event requires:

- **aggregate_type** - In the URL path (e.g., `order`)
- **aggregate_id** - In the URL path (UUIDv4 or humane code)
- **event_type** - In the URL path (e.g., `was_placed`)
- **data** - The event payload
- **metadata.actor** - Who or what caused this event

## Metadata structure

Every event includes metadata:

| Field | Required | Description |
|-------|----------|-------------|
| `actor` | Yes | `{ "type": "user", "id": "..." }` - who caused this event |
| `target` | No | `{ "type": "item", "id": "..." }` - what the action targeted |
| `previous_length` | No | Expected event count for external OCC |

Actor and target types must be declared in your spec's `agent_types` and `target_types` arrays.

## Events are immutable

Once written, an event can never be changed or deleted. This is the core principle of event sourcing.

To "undo" something, write a new event representing the reversal:

```bash
curl -X POST https://myapp.j17.dev/order/550e8400-.../was_cancelled \
  -H "Authorization: Bearer $J17_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "data": { "reason": "Customer request" },
    "metadata": {
      "actor": { "type": "user", "id": "..." }
    }
  }'
```

The order isn't deleted -- both `was_placed` and `was_cancelled` exist in history. The aggregate state reflects the cancellation because the handler for `was_cancelled` updates the status.

## Naming conventions

Use snake_case past-tense verbs that describe what happened — not commands requesting future action. Use verb voice to signal who caused the change:

### Active voice — the aggregate itself was the change agent

```
placed_order              # e-commerce: customer placed it
submitted_application     # hiring: candidate submitted it
attacked_target           # game: player chose to attack
checked_in                # field service: technician arrived
```

The entity did something. Reading the event stream, active voice events are the aggregate's own actions.

### Passive voice — something external changed the aggregate

```
was_created               # account lifecycle
was_assigned_to_route     # dispatch system assigned the driver
had_price_adjusted        # admin changed the price
had_inventory_reserved    # checkout saga reserved stock
```

Use whichever passive form reads naturally — `was_` and `had_` both indicate an external cause (the system, an implication, an admin, another user). The distinction is grammatical, not semantic.

### Why this matters

When reading an event stream, the voice tells you the causal direction:

```
order:abc123
  was_created                 ← system (checkout flow)
  placed_order                ← customer did this
  had_inventory_reserved      ← saga reserved stock
  had_payment_charged         ← payment processor confirmed
  was_fulfilled               ← warehouse shipped it
```

```
character:hero1
  was_created                 ← system (new game)
  equipped_sword              ← player did this
  attacked_target             ← player did this
  was_damaged                 ← combat system applied damage
  leveled_up                  ← player crossed XP threshold
  was_awarded_loot            ← loot system (implication)
```

```
vehicle:truck42
  was_created                 ← fleet management
  was_assigned_to_route       ← dispatch system
  checked_in                  ← driver arrived at stop
  reported_issue              ← driver flagged a problem
  had_maintenance_scheduled   ← ops team responded
```

Without the convention, every event looks the same and you can't tell at a glance whether the aggregate acted or was acted upon.

# Aggregates

An aggregate is the current state derived from replaying all events for a given type and ID.

## How aggregates work

When you query an aggregate, j17 replays all events and applies each handler in order:

```
Event 1: was_created { name: "Alice" }        -> State: { name: "Alice", created_at: 1705312800 }
Event 2: had_email_updated { email: "..." }    -> State: { name: "Alice", email: "...", created_at: ... }
Event 3: had_name_updated { name: "Alicia" }   -> State: { name: "Alicia", email: "...", created_at: ... }
```

Each event type has a handler defined in your spec that determines how it transforms state. For example:

```json
{
  "was_created": {
    "handler": [
      { "set": { "target": "", "value": "$.data" } },
      { "set": { "target": "created_at", "value": "$.metadata.timestamp" } }
    ]
  },
  "had_name_updated": {
    "handler": [
      { "merge": { "target": "", "value": "$.data" } }
    ]
  }
}
```

## Querying aggregates

```bash
curl https://myapp.j17.dev/user/550e8400-e29b-41d4-a716-446655440000 \
  -H "Authorization: Bearer $J17_API_KEY"
```

Response:

```json
{
  "ok": true,
  "data": {
    "name": "Alicia",
    "email": "alice@example.com",
    "created_at": 1705312800
  },
  "metadata": {
    "length": 3,
    "created_at": 1705312800,
    "updated_at": 1705399200
  }
}
```

`metadata.length` is the number of events applied to this aggregate. Pass it as `previous_length` when writing to ensure no concurrent changes occurred between your read and write. `created_at` and `updated_at` are Unix timestamps from the first and most recent events.

## Aggregate IDs

The aggregate ID is the second segment of the URL path when writing or reading events:

```
POST /{aggregate_type}/{aggregate_id}/{event_type}
GET  /{aggregate_type}/{aggregate_id}
```

You supply the ID -- j17 never generates one for you. The ID determines which event stream the event is appended to.

### Accepted formats

| Format | Example | Use case |
|--------|---------|----------|
| UUID v4 | `550e8400-e29b-41d4-a716-446655440000` | Default for all entities |
| UUID v5 | `a4339497-daa0-5c39-a0d3-8e894750d2b0` | Deterministic IDs derived from external data |
| Tagged UUID | `uuid:2026` | Time-partitioned streams (fiscal years, quarters) |
| Humane code | `ABC123XYZ` | Human-readable 9-character codes (promos, bookings) |
| `global` | `global` | Built-in singleton (no spec changes needed) |
| Custom singleton | `all` | Named singletons (requires spec config) |

Use UUIDs by default. The other formats exist for specific use cases described below.

### UUIDs (v4 and v5)

Most aggregates should use UUIDs. v4 (random) is the most common. v5 (deterministic, namespace-based) is useful when you need to derive a stable ID from external data -- for example, generating a consistent aggregate ID from an external user's email address.

### Humane codes

9-character identifiers using Crockford base32 (e.g. `ABC123XYZ`). Useful when humans need to type or read aggregate IDs, such as booking codes or promo codes. j17 normalizes ambiguous characters at the edge: `I` becomes `1`, `L` becomes `1`, `O` becomes `0`, `U` becomes `V`, and lowercase is uppercased.

### Tagged UUIDs

A tagged UUID is a UUID followed by a colon and a short tag (1-10 lowercase alphanumeric characters): `{uuid}:{tag}`. This creates a separate aggregate stream for each tag while keeping the same entity identity.

The primary use case is **time-partitioned aggregates** -- when you need to close out one period and start fresh without losing history:

```bash
# Write to the current fiscal year's ledger
curl -X POST https://myapp.j17.dev/ledger/a433...d2b0:2025/entry_posted \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"data": {"amount": 500, "account": "revenue"}, "metadata": {"actor": {"type": "system", "id": "..."}}}'

# Close the year, start a new stream
curl -X POST https://myapp.j17.dev/ledger/a433...d2b0:2026/was_opened \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"data": {"carried_forward": 12500}, "metadata": {"actor": {"type": "system", "id": "..."}}}'
```

Each tagged variant is a fully independent aggregate with its own event stream, length, and state. The old period's aggregate remains readable and immutable while the new one starts from zero.

**Projections cover the main stream only by default.** A projection with `for_each: "ledger"` matches bare `ledger:uuid` aggregates. To include tagged variants (`ledger:uuid:2025`, `ledger:uuid:2026`), add `"include_substreams": true` to the projection definition. Each variant gets its own row; the `_key_id` column holds the full id including the tag (e.g., `abc123:2025`).

Common tag patterns:

| Tag | Example | Use case |
|-----|---------|----------|
| `2026` | `uuid:2026` | Fiscal year partitioning |
| `202603` | `uuid:202603` | Monthly partitioning |
| `q1` | `uuid:q1` | Quarterly periods |
| `v2` | `uuid:v2` | Schema versioning / migrations |

Tags must be 1-10 characters, lowercase letters and digits only. The UUID portion must be a valid v4 or v5 UUID.

## Singleton aggregates

A singleton is an aggregate type where you only need one instance -- app-wide config, a global counter, a shared audit log. Instead of generating a UUID, you use a fixed, well-known ID. See [Identifiers § Singletons](/docs/reference/identifiers#singletons) for the canonical reference.

A heads-up before you reach for one: every write to a singleton contends on the same event stream and OCC counter, so under high concurrency you'll see 409 conflicts. Singletons are great for config and low-write-rate state, less so for things that take a write per request.

### The `global` keyword

Every aggregate type automatically supports the keyword `global` as an ID, with no spec changes required:

```bash
# Write to a singleton config aggregate
curl -X POST https://myapp.j17.dev/config/global/was_updated \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"data": {"dark_mode": true}, "metadata": {"actor": {"type": "admin", "id": "..."}}}'

# Read it back
curl https://myapp.j17.dev/config/global \
  -H "Authorization: Bearer $API_KEY"
```

This is the simplest way to create a singleton. Use it for settings, feature flags, rate limit config, or any "one per type" aggregate.

### Custom singletons

If you need more than one named singleton per type, or want a more descriptive name than `global`, add custom singletons to your spec:

```json
{
  "singletons": ["all", "daily_summary"],
  "aggregate_types": {
    "company_audit": {
      "events": {
        "RecordCreated": {
          "schema": { "type": "object" },
          "handler": [
            { "increment": { "target": "total_events" } },
            { "set": { "target": "last_activity", "value": "$.metadata.timestamp" } }
          ]
        }
      }
    }
  }
}
```

Then use the singleton name as the aggregate ID:

```bash
curl -X POST https://myapp.j17.dev/company_audit/all/RecordCreated \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"data": {"action": "user_login"}, "metadata": {"actor": {"type": "system", "id": "..."}}}'
```

Custom singletons are defined at the top level of the spec (not per aggregate type) and are available to all aggregate types in the instance. The same name is also valid as a `metadata.actor.id` or `metadata.target.id` -- the singletons list works wherever an identifier is required. Use sparingly -- `global` handles most singleton cases.

### Singletons as actor or target ids

A common case for custom singletons is naming a non-user actor -- a webhook source, an integration, a scheduled job. Declare it once and use it directly:

```json
{
  "agent_types": ["user", "webhook"],
  "singletons": ["twilio_webhook", "stripe_webhook"]
}
```

```bash
curl -X POST https://myapp.j17.dev/call_log/<uuid>/was_received \
  -H "Authorization: Bearer $API_KEY" \
  -d '{"data": {...}, "metadata": {
    "actor": {"type": "webhook", "id": "twilio_webhook"}
  }}'
```

This was UUID-only prior to 0.7.11; actor and target ids now follow the same rule as aggregate keys.

### Don't fake singletons with UUIDs

If you need a singleton, use `global` or a custom singleton name. Don't generate a fixed UUID (like `00000000-0000-5000-8000-000000000000`) and use it as a pseudo-singleton -- it works, but it's fighting the system. The same advice applies to actor and target ids: a webhook actor doesn't need a fake UUIDv5 derived from `"twilio"`; declare `twilio_webhook` as a singleton and use it directly. Singletons are a first-class concept with a cleaner API and clearer intent.

### Singletons vs. tagged UUIDs

Both look like "well-known" identifiers but answer different questions:

- **Tagged UUIDs partition** a UUID-keyed space by a short suffix. The id is still mostly random; the suffix names a variant (year, version, region). You'd write a `for tag in tags` loop over them.
- **Singletons name** a fixed instance with no random component. The whole id is a known constant.

If the set of valid ids is unbounded but partitioned, that's tagged UUIDs. If the set is one well-known name, that's a singleton.

## Aggregate sizing

Reads are served from a cached state that the platform keeps current in the background, and
that cache is updated by folding only the events added since it was last written. Read cost
therefore does not grow with an aggregate's history. Aggregates are still designed to
represent individual entities rather than unbounded collections: a long stream is more
expensive to rebuild from scratch, and rebuilding is what a
[forced fresh read](/docs/api/reading-aggregates#consistency) asks for.

If an aggregate grows unboundedly, you have several options:

- **[Tagged UUIDs](#tagged-uuids)** -- partition by time period. A ledger aggregate that accumulates entries all year can be split into `uuid:2025`, `uuid:2026`, etc. Each period gets its own stream that stops growing when the period closes.
- **[Projections](/docs/guides/projections)** -- maintain a queryable view across aggregates. An audit log with one event per action can sometimes be better modeled as many small aggregates (one per audited entity) with a projection that provides the cross-cutting query view.

Use whatever is most natural to your domain.

# Atomicity and Concurrency

Event sourcing makes strong consistency simple -- events append to a stream in order. But when multiple clients write concurrently, you need a strategy.

## The problem

Two users update the same order simultaneously:

```
User A: GET order/123 (sees length=5)
User B: GET order/123 (sees length=5)

User A: POST was_shipped (expects length=5, creates event 6)
User B: POST was_delivered (expects length=5, creates event 6)
```

Without protection, both succeed. The order has two events at position 6. Data loss.

## Optimistic Concurrency Control (OCC)

j17 uses OCC to prevent this. There are three modes: internal (default), external, and disabled.

### Internal OCC (default)

When you don't provide `previous_length`, j17 uses internal OCC:

1. Read current stream length
2. Validate and prepare your event
3. Atomically write if length hasn't changed

```json
{
  "data": { "status": "shipped" },
  "metadata": {
    "actor": { "type": "user", "id": "user-456" }
  }
}
```

Internal OCC catches races during request processing (another write arriving in the same millisecond window). On conflict, j17 automatically retries once with fresh length. If retry also fails (sustained contention), you get 409.

This is the right default for most workloads. You don't need to think about concurrency unless you have read-modify-write patterns.

### External OCC

When you provide `previous_length`, j17 uses external OCC:

```json
{
  "data": { "status": "shipped" },
  "metadata": {
    "actor": { "type": "user", "id": "user-456" },
    "previous_length": 5
  }
}
```

j17 checks: does this aggregate currently have 5 events?

- Yes: Write succeeds, returns new length (6)
- No: Write fails with 409 Conflict

External OCC catches everything internal OCC catches, plus modifications since your read. No automatic retry -- the conflict is meaningful because your client's view was stale.

Use external OCC for:
- Read-modify-write patterns (read aggregate, decide, write)
- Long-running user sessions where data might change
- Critical operations where you need guaranteed consistency

### Disabling OCC (skip_occ)

For append-only patterns where conflicts don't matter:

```json
{
  "data": { "entry": "user logged in" },
  "metadata": {
    "actor": { "type": "system", "id": "auth-service" },
    "skip_occ": true
  }
}
```

Requires spec opt-in per event type:

```json
{
  "aggregate_types": {
    "audit": {
      "events": {
        "entry_was_added": {
          "allow_skip_occ": true,
          "schema": { "..." : "..." }
        }
      }
    }
  }
}
```

Safe for append-only logs, analytics, activity feeds. Dangerous for state machines, balances, or anything with check-then-act logic.

### OCC summary

| Mode | `previous_length` | Catches | Auto-retry | Use case |
|------|-------------------|---------|------------|----------|
| Internal | Not provided | Request-time races | Yes (once) | Default safety net |
| External | Provided | All modifications since read | No | Read-modify-write |
| Disabled | `skip_occ: true` | Nothing | N/A | Append-only logs |

## Handling conflicts

When you get a 409:

```json
{
  "ok": false,
  "error": {
    "code": "conflict",
    "message": "Optimistic concurrency check failed",
    "details": {
      "expected": 5,
      "actual": 6
    }
  }
}
```

Options:

1. **Refetch and retry**: Get current state, reapply your change, try again
2. **Reject**: Tell the user someone else modified the data
3. **Merge**: Combine your change with the new state

### Refetch and retry

```javascript
async function writeWithRetry(aggregateType, id, eventType, data, maxRetries = 3) {
  for (let attempt = 0; attempt < maxRetries; attempt++) {
    // Get current state
    const current = await fetchAggregate(aggregateType, id);

    try {
      return await writeEvent(aggregateType, id, eventType, {
        data,
        metadata: {
          actor: { type: 'user', id: 'user-456' },
          previous_length: current.length
        }
      });
    } catch (err) {
      if (err.code === 'conflict' && attempt < maxRetries - 1) {
        continue; // Retry
      }
      throw err;
    }
  }
}
```

### Reject

Show the user a message: "Someone else updated this. Please refresh and try again."

Safer than silent retries that might overwrite important changes.

### Merge (advanced)

For non-conflicting changes, merge automatically:

```javascript
// User A changes shipping_address
// User B changes status
// These don't conflict -- apply both

const current = await fetchAggregate('order', id);
const newData = mergeChanges(current.state, userInput);

await writeEvent('order', id, 'was_updated', {
  data: newData,
  metadata: { previous_length: current.length }
});
```

Only safe for orthogonal changes. Don't merge conflicting status updates.

## Batch writes (same aggregate)

j17 provides atomic batch writes for multiple events targeting the same aggregate. All events succeed or none are written:

```bash
POST /order/550e8400-e29b-41d4-a716-446655440000
Content-Type: application/json
Authorization: Bearer $J17_API_KEY

{
  "events": [
    {"type": "was_created", "data": {"customer_id": "abc"}},
    {"type": "had_item_added", "data": {"sku": "WIDGET-1", "qty": 2}},
    {"type": "had_item_added", "data": {"sku": "GADGET-3", "qty": 1}}
  ],
  "metadata": {
    "actor": {"type": "admin", "id": "system"},
    "previous_length": 0
  }
}
```

Response:

```json
{
  "ok": true,
  "stream_ids": ["1706789012345-0", "1706789012345-1", "1706789012345-2"],
  "count": 3,
  "implied_count": 5
}
```

The `previous_length` applies to the batch as a whole. Each event in the batch can trigger implications; all implications see the pre-batch state.

Good use cases for batch writes:
- Initial entity creation with multiple setup events
- Complex state transitions that are logically atomic
- Bulk imports or data migrations

## Cross-aggregate consistency

OCC protects single aggregates. What about operations across multiple?

Example: Place an order AND reserve inventory.

```
order:123  -> was_placed
inventory:456 -> was_reserved
```

If the first succeeds but the second fails, you have an order without reserved inventory. With N independent writes, you have 2^N possible success/failure combinations.

### The solution: implications

Instead of submitting multiple events from the client, submit ONE trigger event. j17 automatically emits derived events atomically:

```json
{
  "aggregate_types": {
    "order": {
      "events": {
        "was_placed": {
          "schema": { "..." : "..." },
          "handler": [
            { "set": { "target": "", "value": "$.data" } }
          ],
          "implications": [
            {
              "emit": {
                "aggregate_type": "inventory",
                "id": "$.data.product_id",
                "event_type": "was_reserved",
                "data": {
                  "quantity": "$.data.qty",
                  "order_id": "$.key"
                }
              }
            }
          ]
        }
      }
    }
  }
}
```

Now your client code is simple:

```javascript
const result = await writeEvent('order', orderId, 'was_placed', {
  data: orderData,
  metadata: { actor: { type: 'user', id: userId } }
});
// Either everything succeeded, or nothing was written
// result.implied_count tells you how many derived events were created
```

The trigger event contains all the context needed for derived events. Placing an order is the business fact; inventory reservation follows from that fact.

### When implications don't fit

Implications work when derived events follow deterministically from the trigger event data. They don't fit when:

- You need external data at decision time (inventory check from external system)
- Steps take time or can fail externally (payment processing, shipping)
- Different authorization contexts are required

For these cases, use the saga pattern: trigger event starts the workflow, external processes respond asynchronously, confirmation/failure events drive next steps, and compensation events handle rollback.

## Read-modify-write cycles

Common pattern:

1. GET aggregate
2. Modify state in memory
3. POST event with `previous_length`

This is safe with external OCC. If someone else writes between 1 and 3, your POST fails and you retry.

Alternative: **Calculated events**

Instead of sending the new state, send the operation:

```json
{
  "data": { "amount": 50 },
  "metadata": {
    "actor": { "type": "user", "id": "user-456" },
    "previous_length": 5
  }
}
```

Handler computes new balance: `state.balance + event.data.amount`

This is naturally mergeable. If someone else also adds $50, both succeed and balance becomes $200.

## Performance trade-offs

| Mode | Latency | Consistency | Use case |
|------|---------|-------------|----------|
| Internal | Baseline | Request-time safety | Default for most writes |
| External | Baseline | Strong | Read-modify-write, critical ops |
| Disabled | Baseline | None | Append-only logs |

The overhead is minimal. OCC adds a single integer comparison.

## Debugging conflicts

High conflict rates indicate:
- Hotspots (everyone updating the same aggregate)
- Long gaps between GET and POST
- Missing calculated events (sending absolute values instead of deltas)

Fix hotspots by sharding (add sub-aggregates) or using calculated events.

## See also

- [Writing events](/docs/api/writing-events) - OCC in practice
- [Handlers](/docs/concepts/handlers) - How events transform state
- [Events](/docs/concepts/events) - Event structure and metadata

# Handlers

Handlers transform aggregate state when events occur. They're pure functions: (state, event) -> new_state.

## What handlers do

When you query an aggregate, j17:
1. Loads all events for that aggregate
2. Applies handlers in chronological order
3. Returns the final state

```
Event 1: was_created { name: "Alice" }
  -> Handler: set name -> State: { name: "Alice" }

Event 2: had_email_updated { email: "alice@example.com" }
  -> Handler: merge email -> State: { name: "Alice", email: "..." }

Final state: { name: "Alice", email: "alice@example.com" }
```

## Declarative vs imperative

**Imperative** (traditional code):
```javascript
function applyEvent(state, event) {
  if (event.type === 'was_created') {
    state.name = event.data.name;
    state.created_at = Date.now();
  } else if (event.type === 'had_email_updated') {
    state.email = event.data.email;
  }
  return state;
}
```

Problems:
- Hidden logic (Date.now() is a side effect)
- Hard to test (mock Date)
- Runtime errors (undefined properties)

**Declarative** (j17 Tick):
```json
{
  "was_created": {
    "handler": [
      { "set": { "target": "", "value": "$.data" } },
      { "set": { "target": "created_at", "value": "$.metadata.timestamp" } }
    ]
  }
}
```

Benefits:
- No side effects (timestamp comes from event metadata)
- Easy to test (input -> output)
- Validated at upload (typos caught early)

## Tick operations

Tick provides 17 declarative operations in four categories:

### Basic operations

The foundation. Cover most use cases.

- `set` - Replace value at a target path
- `merge` - Shallow merge an object into the target
- `append` - Add an element to an array
- `remove` - Remove an element from an array
- `increment` - Add to a numeric value
- `decrement` - Subtract from a numeric value
- `max` - Raise a value, never lower it
- `min` - Lower a value, never raise it

### Array operations

Work on arrays of objects (e.g., line items, members, tags).

- `filter` - Keep only elements matching a predicate
- `map` - Transform each element in an array
- `update_where` - Update elements that match a predicate
- `upsert` - Update a matching element or append if none match
- `append_unique` - Append only if no matching element exists

### Dynamic key operations

Operate on keys determined at runtime from event data.

- `set_at` - Set a value at a dynamic key path
- `merge_at` - Merge into a dynamic key path
- `remove_at` - Remove a dynamic key
- `increment_at` - Increment a value at a dynamic key path
- `decrement_at` - Decrement a value at a dynamic key path
- `max_at` / `min_at` - Raise (or lower) a value at a dynamic key path, one direction only
- `merge_max` / `merge_min` - Apply `max` (or `min`) key-by-key across an object
- `merge_add` - Add a whole object of numbers into the target, key by key

### Control flow

- `conditional` - If/then/else branching with predicates
- `let` - Bind a variable (e.g., find an element) for use in subsequent operations

See [Tick reference](/docs/reference/tick) for complete syntax.

## Handler context

Handlers access:

| Path | Meaning |
|------|---------|
| `$.data` | Event payload |
| `$.metadata.timestamp` | Event timestamp |
| `$.metadata.actor` | Who triggered the event |
| `$.state` | Current aggregate state (before this event) |
| `$.key` | Aggregate key (type/id) |

## Common patterns

### Initialize state

```json
{
  "was_created": {
    "handler": [
      { "set": { "target": "", "value": "$.data" } },
      { "set": { "target": "status", "value": "active" } },
      { "set": { "target": "created_at", "value": "$.metadata.timestamp" } }
    ]
  }
}
```

### Update field

```json
{
  "had_email_updated": {
    "handler": [
      { "set": { "target": "email", "value": "$.data.email" } }
    ]
  }
}
```

### Conditional update

```json
{
  "had_status_changed": {
    "handler": [
      { "set": { "target": "status", "value": "$.data.status" } },
      {
        "if": { "eq": ["$.data.status", "completed"] },
        "then": [
          { "set": { "target": "completed_at", "value": "$.metadata.timestamp" } }
        ]
      }
    ]
  }
}
```

### Append to array

```json
{
  "had_item_added": {
    "handler": [
      {
        "append": {
          "target": "items",
          "value": {
            "$merge": [
              "$.data",
              { "added_at": "$.metadata.timestamp" }
            ]
          }
        }
      }
    ]
  }
}
```

`$merge` shallow-merges a sequence of items left to right. Each item is either a **bare path string** like `"$.data"` (which spreads the resolved object's keys into the result) or a **literal object** whose values may be JSONPaths the engine dereferences before merging. Wrapping a path in an object — `{"$": "$.data"}` — is *not* spread; the validator rejects that shape. See the [`$merge` reference](/docs/reference/tick#merge-expressions) for full semantics.

## Handler ordering

Handlers execute in the order defined:

```json
{
  "handler": [
    { "set": { "target": "status", "value": "processing" } },
    { "increment": { "target": "processing_count", "value": 1 } },
    { "append": { "target": "log", "value": { "action": "processed" } } }
  ]
}
```

Each operation sees the result of previous operations.

## Validation

j17 validates handlers when you upload your spec:

- Target paths are valid
- Array operations target arrays
- Numeric operations target numbers
- JSONPath expressions parse
- No circular references

Invalid specs are rejected with line numbers.

## Testing handlers

Test locally before deploying:

```bash
# Test specific handler
j17 handler test spec.json --aggregate user --event was_created --state '{}' --event-data '{"name":"Alice"}'
```

Or use the CLI dry-run mode:

```bash
j17 events dry-run spec.json events.jsonl
```

## Performance

Tick handlers compile to Zig. Typical throughput: 300k+ operations/second per core.

## See also

- [Tick reference](/docs/reference/tick) - Complete syntax
- [Events](/docs/concepts/events) - What triggers handlers
- [Aggregates](/docs/concepts/aggregates) - What handlers build

---


# API Overview

j17's API is HTTP-first and minimal. POST events, GET aggregates. That's the core. Everything else -- batching, admin, implications -- is built on these primitives.

## Base URLs

```
Production:  https://{instance}.j17.dev
Staging:     https://{instance}-staging.j17.dev
Test:        https://{instance}-test.j17.dev
```

All requests require HTTPS.

## Authentication

Two methods depending on what you're doing:

| Method | Header | Use for |
|--------|--------|---------|
| API Key | `Authorization: Bearer {key}` | Instance data (events, aggregates) |
| JWT | `Authorization: Bearer {token}` | Admin operations, dashboards |

See [Authentication](/docs/api/authentication) for details.

## Core operations

### Write an event

```bash
POST /{aggregate_type}/{aggregate_id}/{event_type}
```

```bash
curl -X POST https://myapp.j17.dev/order/abc123/was_placed \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "data": { "items": [...], "total": 59.99 },
    "metadata": {
      "actor": { "type": "user", "id": "550e8400-e29b-41d4-a716-446655440000" }
    }
  }'
```

Returns the stream ID of the written event.

### Read an aggregate

```bash
GET /{aggregate_type}/{aggregate_id}
```

```bash
curl https://myapp.j17.dev/order/abc123 \
  -H "Authorization: Bearer $API_KEY"
```

Returns current state derived from all events.

### Batch operations

```bash
POST /{aggregate_type}/{aggregate_id}
```

Atomic multi-event writes to a single aggregate. See [Batch operations](/docs/api/batch-operations).

## Request format

### Event POST body

```json
{
  "data": { ... },           // Required. Validated against event schema
  "metadata": {              // Required
    "actor": {               // Required. Who performed the action
      "type": "user",        // Must be in spec's agent_types
      "id": "550e8400-e29b-41d4-a716-446655440000"
    },
    "target": {              // Optional. What was affected
      "type": "order",
      "id": "target-id"
    },
    "previous_length": 5     // Optional. For OCC
  }
}
```

Idempotency is handled via the `X-Idempotency-Key` request header, not in metadata. See [Writing events](/docs/api/writing-events) for details.

### Content-Type

Always `application/json`. j17 rejects other content types with 400.

## Response format

### Success (201 Created) -- single event

```json
{
  "ok": true,
  "stream_id": "1234567890123-0",
  "implied_count": 2
}
```

| Field | Type | Description |
|-------|------|-------------|
| `ok` | boolean | Always true on success |
| `stream_id` | string | Opaque event identifier; use as a cursor when listing events |
| `implied_count` | integer | Number of implied events (omitted if 0) |

### Success (201 Created) -- batch

```json
{
  "ok": true,
  "stream_ids": ["1234567890123-0", "1234567890123-1"],
  "count": 2,
  "implied_count": 3
}
```

### Error (4xx/5xx)

```json
{
  "ok": false,
  "error": "Event data failed schema validation",
  "path": "data.email"
}
```

Common error codes:

| HTTP | Meaning |
|------|---------|
| 400 | Unparseable request (malformed JSON, JSONL parse error, empty body) |
| 401 | Invalid or missing API key |
| 403 | Valid auth, but not allowed (wrong environment, read key on write, prod-only restrictions) |
| 404 | Aggregate type or event type not in spec, or aggregate has no events written |
| 405 | Wrong method on a read-only path (`POST /{type}/{id}/length` etc.) — response includes `Allow: GET` |
| 409 | OCC conflict (stale `previous_length`) |
| 413 | Event data too large |
| 422 | Validation error (bad schema, missing/invalid fields, idempotency key reused with different body) |
| 429 | [Rate limited](/docs/api/rate-limits) |
| 500 | Internal error |

## Query parameters

### GET /{type}

| Parameter | Type | Description |
|-----------|------|-------------|
| `resolve` | flag | Return full aggregate data instead of IDs |
| `synchronous` | boolean | Skip cache when resolving, read from event stream |
| `limit` | integer | Page size (default 50, max 200) |
| `cursor` | string | Last ID from previous page for pagination |

### GET /{type}/{id}

| Parameter | Type | Description |
|-----------|------|-------------|
| `synchronous` | boolean | Bypass cache, compute fresh (`?synchronous=true`) |

### GET /{type}/{id}/events

| Parameter | Type | Description |
|-----------|------|-------------|
| `start` | string | Cursor — exclusive in both directions |
| `count` | integer | Events per page. Defaults to 500, capped at 5,000. |
| `direction` | string | `forwards` (default) or `backwards`. Selects the window; the array is always oldest-first. |

Responses carry `count_limit`, `truncated`, and `next_start` when there is another page. See [reading aggregates](/docs/api/reading-aggregates#page-size).

## Rate limits

Rate limits are per-instance, tiered by billing plan (Free 10/s, Hobby 100/s, Pro 200/s, Scale 1000/s for writes). See [rate limits](/docs/api/rate-limits) for full details.

```http
X-RateLimit-Remaining: 5400
X-RateLimit-Scope: instance
```

Exceeding limits returns 429 with tier context.

## Idempotency

POST events support idempotency via the `X-Idempotency-Key` request header:

```bash
curl -X POST https://myapp.j17.dev/order/abc123/was_placed \
  -H "X-Idempotency-Key: order-123-was_placed-20240101" \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"data": {...}, "metadata": {"actor": {...}}}'
```

Same key + same body = cached response replayed (with `X-Idempotency-Replayed: true` header).

Same key + different body = 422 error.

Keys expire after 24 hours.

## CORS

j17 supports cross-origin requests from browser clients:

```http
Access-Control-Allow-Origin: *
Access-Control-Allow-Methods: GET, POST, OPTIONS
Access-Control-Allow-Headers: Authorization, Content-Type
```

## Webhooks (Listeners)

j17 delivers events to your HTTP endpoints via listeners defined in your spec. When a matching event is written, j17 POSTs a signed JSON payload to your URL.

Delivery is guaranteed with retry: failed deliveries are retried with exponential backoff (5s, 25s, 125s) up to 3 attempts. Payloads are signed with HMAC-SHA256 using your listener secret, delivered in the `X-J17-Signature` header.

**Signature behavior:** The listener secret is read from your spec at delivery time (not stored with the delivery). This means you can rotate secrets by redeploying your spec — in-flight retries will use the new secret. If j17 cannot read your spec at delivery time (e.g., during a brief cache miss), the payload is delivered unsigned (no `X-J17-Signature` header). Your endpoint should treat a missing signature as an error if you require signed payloads.

## Admin API

Separate endpoints for instance management, on the console API:

```
POST   /api/instances/:id/spec    # Deploy spec
GET    /api/instances/:id/keys    # List API keys
POST   /api/instances/:id/keys    # Create API key
```

See [Admin API](/docs/api/admin-api) for details.

## Testing

Use staging or test environments:

```bash
# Staging
curl https://myapp-staging.j17.dev/order/abc123/was_placed ...

# Production
curl https://myapp.j17.dev/order/abc123/was_placed ...
```

Different API keys, isolated data. Test freely in staging.

## See also

- [Writing events](/docs/api/writing-events) - Deep dive on POST
- [Reading aggregates](/docs/api/reading-aggregates) - Deep dive on GET
- [Authentication](/docs/api/authentication) - Keys, JWT, environments
- [Batch operations](/docs/api/batch-operations) - Atomic multi-event writes
- [Admin API](/docs/api/admin-api) - Instance management

# Authentication

j17 uses different auth methods depending on what you're doing. API keys for data operations, JWT for admin tasks. Each environment (prod/staging/test) has isolated credentials.

## API Keys

Use API keys for instance data: writing events, reading aggregates, batch operations.

```bash
curl https://myapp.j17.dev/order/abc123 \
  -H "Authorization: Bearer j17_0_prod_abc123xyz"
```

### Key format

```
j17_{version}_{environment}_{random}
```

| Prefix | Environment | Works on |
|--------|-------------|----------|
| `j17_0_prod_*` | Production | `*.j17.dev` |
| `j17_0_staging_*` | Staging | `*-staging.j17.dev` |
| `j17_0_test_*` | Test | `*-test.j17.dev` |

Keys are environment-scoped. A staging key won't work on production. This prevents accidents.

### Creating keys

Via dashboard: Instance settings page, API Keys section.

Via API (requires JWT):

```bash
curl -X POST https://console.j17.dev/api/instances/$INSTANCE_ID/keys \
  -H "Authorization: Bearer $JWT_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Production Backend",
    "scope": "write",
    "environment": "prod"
  }'
```

### Key scopes

| Scope | Permissions |
|-------|-------------|
| `read` | GET aggregates, query events, read projections |
| `write` (read/write) | All read permissions + POST events |
| `deploy` | Deploy specs and projections — no data access |
| `admin` | Read + write + deploy |

Use `read` keys for frontend clients that only display data. Use `write` keys for backend services that need to write events. Write keys include full read access.

`deploy` is orthogonal to `write`: a key that writes events cannot redefine the schema, and a deploy key cannot write data. Use a `deploy` key in CI to push your spec and projections with just the API key — see [Schema Deployment](/docs/api/schema-deployment).

### Rotating keys

```bash
curl -X POST https://console.j17.dev/api/keys/$KEY_ID/rotate \
  -H "Authorization: Bearer $JWT_TOKEN"
```

You can also schedule revocation with a grace period:

```bash
curl -X POST https://console.j17.dev/api/keys/$KEY_ID/schedule_revocation \
  -H "Authorization: Bearer $JWT_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"revoke_at": "2024-02-01T00:00:00Z"}'
```

### Storing keys

**Don't** commit keys to git. Use environment variables:

```bash
# .env
J17_API_KEY=j17_0_prod_abc123xyz
```

```javascript
// Read from env, not hardcoded
const apiKey = process.env.J17_API_KEY;
```

**Never** expose write keys in frontend code. Browser clients should use read-only keys or go through your backend.

## JWT Tokens

Use JWT for admin operations: managing API keys, deploying specs, configuring instances. All JWT-authenticated endpoints are on the console API (`console.j17.dev`).

```bash
curl https://console.j17.dev/api/instances \
  -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIs..."
```

### Getting a token

Login with email/password:

```bash
curl -X POST https://console.j17.dev/api/login \
  -H "Content-Type: application/json" \
  -d '{
    "email": "admin@example.com",
    "password": "..."
  }'
```

Response:

```json
{
  "token": "eyJhbGciOiJIUzI1NiIs...",
  "expires_at": 1705312800
}
```

Tokens expire after 24 hours. Refresh by logging in again.

### Token contents

j17 JWTs contain:

```json
{
  "sub": "user-uuid",
  "email": "admin@example.com",
  "role": "instance_admin",
  "instance_id": "instance-uuid",
  "iat": 1705226400,
  "exp": 1705312800
}
```

Don't decode JWTs client-side to make permission decisions. Always verify server-side.

## Environment isolation

Each environment is completely isolated:

| Environment | URL | Credentials |
|-------------|-----|-------------|
| Production | `myapp.j17.dev` | `j17_0_prod_*` keys |
| Staging | `myapp-staging.j17.dev` | `j17_0_staging_*` keys |
| Test | `myapp-test.j17.dev` | `j17_0_test_*` keys |

Data doesn't flow between environments. A user created in staging doesn't exist in production.

### Testing against staging

```bash
# Always hit staging first
curl https://myapp-staging.j17.dev/user/abc123/was_created \
  -H "Authorization: Bearer $STAGING_KEY" \
  -d '{...}'

# Verify it works, then switch to production
curl https://myapp.j17.dev/user/abc123/was_created \
  -H "Authorization: Bearer $PROD_KEY" \
  -d '{...}'
```

## Rate limits

Rate limits are per-instance, tiered by billing plan. See [rate limits](/docs/api/rate-limits) for the full table.

Platform admins are exempt from rate limiting.

Rate limit headers on responses:

```http
X-RateLimit-Limit: 500
X-RateLimit-Remaining: 499
X-RateLimit-Scope: api-key
```

## Error responses

### Invalid key

```json
{
  "ok": false,
  "error": "Invalid API key"
}
```

HTTP 401. Check the key is correct and not revoked.

### Wrong environment

```json
{
  "ok": false,
  "error": "Staging key cannot access production"
}
```

HTTP 403. You're using a staging key on production (or vice versa).

### Insufficient scope

```json
{
  "ok": false,
  "error": "Read-only key cannot write events"
}
```

HTTP 403. Get a write-scoped key or use a different endpoint.

### Expired JWT

```json
{
  "ok": false,
  "error": "Token expired"
}
```

HTTP 401. Log in again to get a fresh token.

## Best practices

**Use separate keys per service.** Don't share one key across frontend, backend, and admin tools. If one leaks, you only rotate that one.

**Read keys for frontend.** If your browser code needs to fetch aggregates, use a read-only key. Better yet, proxy through your backend.

**Monitor usage.** Dashboard shows which keys are active. Disable unused keys.

**Rotate regularly.** Set a calendar reminder. Quarterly rotation catches leaked keys before they're exploited.

**Never log keys.** If you debug HTTP requests, redact the Authorization header.

## See also

- [Writing events](/docs/api/writing-events) - POST with API keys
- [Reading aggregates](/docs/api/reading-aggregates) - GET with API keys
- [Admin API](/docs/api/admin-api) - Requires JWT

# Writing Events

POST events to `/{aggregate_type}/{aggregate_id}/{event_type}`. That's the fundamental operation in j17. Everything else -- batching, implications, projections -- builds on this.

## URL structure

```
POST /{aggregate_type}/{aggregate_id}/{event_type}
```

| Component | Description | Example |
|-----------|-------------|---------|
| `aggregate_type` | Defined in your spec | `order`, `user`, `task` |
| `aggregate_id` | Any j17 [identifier](/docs/reference/identifiers) — UUID v4/v5, humane code, singleton, or tagged UUID | `550e8400-e29b-41d4-a716-446655440000` |
| `event_type` | Event name from spec | `was_placed`, `had_email_updated` |

## Request body

```json
{
  "data": {
    "items": [
      { "sku": "WIDGET-1", "quantity": 2, "price": 29.99 }
    ],
    "shipping_address": {
      "street": "123 Main St",
      "city": "Austin",
      "zip": "78701"
    }
  },
  "metadata": {
    "actor": {
      "type": "user",
      "id": "550e8400-e29b-41d4-a716-446655440001"
    },
    "previous_length": 0
  }
}
```

### data (required)

The event payload. Validated against the JSON Schema in your spec.

```json
{
  "data": {
    "email": "alice@example.com",
    "name": "Alice Smith"
  }
}
```

Validation failures return 422 with details:

```json
{
  "ok": false,
  "error": "Event data failed schema validation",
  "path": "data.email"
}
```

### metadata (required)

#### actor (required)

Who performed the action.

```json
{
  "actor": {
    "type": "user",
    "id": "550e8400-e29b-41d4-a716-446655440001"
  }
}
```

The `type` must match an entry in your spec's `agent_types`. Common types: `user`, `admin`, `webhook`, `integration`.

The `id` must be a valid j17 identifier — a v4/v5 UUID, a humane code, a declared singleton, or a tagged UUID. See [Identifiers](/docs/reference/identifiers) for the full rule. Prior to 0.7.11 the actor id rule was UUID-only.

For named non-user actors (a webhook source, an external integration), declare a singleton and use it as the actor id:

```json
{
  "actor": { "type": "webhook", "id": "twilio_webhook" }
}
```

This requires `"singletons": ["twilio_webhook"]` in your spec.

#### target (optional)

What was affected, if different from the aggregate.

```json
{
  "target": {
    "type": "order",
    "id": "550e8400-e29b-41d4-a716-446655440002"
  }
}
```

The `id` follows the same rule as actor and aggregate keys — see [Identifiers](/docs/reference/identifiers). Useful for cross-aggregate queries: "show me all events targeting this order."

#### previous_length (optional)

For [optimistic concurrency control](/docs/concepts/atomicity). The expected number of events in the aggregate before this write.

```json
{
  "previous_length": 5
}
```

If the aggregate has a different length (concurrent write), j17 returns 409:

```json
{
  "ok": false,
  "error": "Concurrent write detected. Stream has 6 events, expected 5."
}
```

Retry with the new length or handle the conflict.

#### skip_occ (optional)

For append-only patterns where conflicts don't matter. Requires `allow_skip_occ: true` in the event's spec definition.

```json
{
  "skip_occ": true
}
```

#### timestamp (optional, non-production only)

Override the event's recorded timestamp. By default j17 stamps each event with the current Unix time in seconds; supplying `metadata.timestamp` replaces that stamp.

```json
{
  "actor": { "type": "user", "id": "..." },
  "timestamp": 1700000000
}
```

Rules:

- **Non-production only** — accepted in `staging`, `test`, and any other environment that is not `prod`. In `prod` the request is rejected with **422** and `error: "metadata.timestamp is only accepted in non-production environments"`. This prevents accidental clock drift between staging and prod when test fixtures leak into production code paths.
- **Format** — must be a non-negative integer (Unix seconds). Non-integers, negatives, floats, and other shapes return **422** with `error: "metadata.timestamp must be a non-negative integer (Unix seconds)"`.
- **Scope** — applies to single-event writes (`POST /:type/:id/:event_type`) and batch writes (`POST /:type/:id`). In a batch, the timestamp from the request's top-level `metadata` is applied to every event in the batch.
- **For per-event timestamps or backdated bulk loads**, use the admin import endpoints instead (`POST .../import`, `POST .../import_jsonl`, `POST .../cold_start`).

This is the same field the engine has always recorded under `metadata.timestamp` on stored events — it was just not previously settable from the write endpoint.

#### No other fields — unknown metadata is refused

The five fields above (`actor`, `target`, `previous_length`, `skip_occ`,
`timestamp`) are the complete client-writable metadata surface. Anything else
in `metadata` is rejected with **422** rather than silently dropped:

- **Platform-stamped fields** (`idempotency_key`, `implied_by`, `scheduled`,
  `geo`, `saga`) return `error: "metadata.<field> is platform-stamped and
  cannot be set on writes"`. These appear in *stored* events — the platform
  writes them — but cannot be supplied on the wire. In particular,
  `metadata.idempotency_key` is not a write channel: sending it returns a 422
  pointing you at the `X-Idempotency-Key` header, which is the only way to
  request idempotent handling.
- **Unknown fields** return `error: "Unknown metadata field: <field>"`. This
  catches typos that would otherwise fail silently — a misspelled
  `previous_length` used to be an OCC guard that never engaged.

## Idempotency

Use the `X-Idempotency-Key` request header to prevent duplicate writes. This is a header, not a metadata field. Idempotency is supported on both single-event writes (`POST /:type/:id/:event_type`) and batch writes (`POST /:type/:id`).

```bash
curl -X POST https://myapp.j17.dev/order/abc123/was_placed \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -H "X-Idempotency-Key: order-abc123-was_placed-20240115" \
  -d '{"data": {...}, "metadata": {"actor": {...}}}'
```

### How it works

On success (2xx), j17 caches the response body, status code, a small set of headers (`x-request-id`, `x-error-id`), and a SHA-256 hash of your request body. The cache lasts **24 hours** (rolling, per key).

- **Retry with same key + same body**: Returns the cached response — same status code, same body, with `X-Idempotency-Replayed: true` set. No duplicate event is written and no handlers re-run.
- **Retry with same key + different body**: Returns 422 with `error: "Idempotency key reused with different request body"`. This catches accidental key reuse bugs.
- **Failed requests (4xx/5xx)**: Not cached. The same key can be retried freely until a 2xx response is received.

The body hash uses **deterministic JSON encoding** (keys sorted alphabetically before hashing), so reordering fields in your JSON object does not register as a different body. Equivalent JSON shapes (e.g. `{"a":1,"b":2}` vs. `{"b":2,"a":1}`) hash the same.

### Provenance

On a successful keyed write, the platform stamps the key into the stored
event as `metadata.idempotency_key`. It is visible when reading events and is
part of the event's stored bytes (and therefore its audit chain hash). The
field is output-only — supplying it in write metadata is a 422 (see
[metadata](#no-other-fields--unknown-metadata-is-refused) above).

### Layered protection

Response replay is backed by a second, independent check in the engine: at
append time, each keyed write atomically claims its key. A duplicate that
slips past the response cache — a response that failed to cache, or a retry
racing the original request's completion — resolves in one of two ways:

- **The original already finished**: the duplicate is acked with the original
  `stream_id` and `X-Idempotency-Replayed: true`, though the body may omit
  fields like `implied_count` that only the original response carried.
- **The original is still executing**: the duplicate gets **409** with
  `Retry-After: 1` and an error saying the key is being processed. Retry
  after the original completes and you receive its replayed response.

Either way, at most one append happens per key — truly simultaneous
duplicates (a double-submitted form racing itself) cannot both write.

### Ordering vs. concurrency control

The idempotency ledger is evaluated **before** OCC. A write carrying both a
recognized key and a `previous_length` guard returns the original cached
response — the guard is never re-compared. This is what makes a keyed,
guarded create a safe retry unit: if your first `previous_length: 0` write
succeeded but the response was lost to a timeout, the retry gets the
original 201 back (marked `X-Idempotency-Replayed: true`), not a 409 you
would have to interpret.

A 409 on a keyed write therefore always means the conflict was real at
execution time — the key was not in the ledger (never seen, expired, or the
original attempt failed) and the guard did not hold. Since failed responses
are not cached, retrying after the conflict is resolved re-executes normally.

### Scoping

Idempotency keys are scoped to **instance + environment**. The same key can be reused independently in `prod`, `staging`, and `test` without colliding. Keys from one instance never collide with keys from another instance.

**Within one instance and environment, the scope is the whole instance — not the stream.** A key used on `order/abc123` is the same key on `user/def456`: the second write matches the first as a replay, returns the first write's cached response, and writes no event. This is the opposite of what most people assume, and it fails quietly. Prefix keys with something that distinguishes the operation — the aggregate type and ID, as in the examples here — rather than reusing a bare request or job ID across different streams.

### Generating keys

Any string from **1 to 255 printable ASCII characters** (`0x20`–`0x7E`). What matters is that keys are unique per distinct operation.

Good patterns:
- `{operation}-{entity_id}-{timestamp}` -- `order-placement-abc123-1705312800`
- `{user_id}-{action}-{nonce}` -- `user-789-checkout-a1b2c3d4`
- UUID v4 -- `550e8400-e29b-41d4-a716-446655440000`

The idempotency key represents the *intent*, not the data. If a user clicks "Submit Order" twice, both clicks should use the same key. But if they add an item and click again, that's a new operation with a new key.

#### Validation errors (422)

| Condition | Error |
|-----------|-------|
| Empty string | `Idempotency key cannot be empty` |
| Longer than 255 bytes | `Idempotency key exceeds 255 character limit` |
| Non-printable / non-ASCII characters | `Idempotency key contains invalid characters (must be printable ASCII)` |
| More than one `X-Idempotency-Key` header on a request | `Multiple X-Idempotency-Key headers not allowed` |

### Failure semantics

If the idempotency store is unreachable when checking a key, the request **fails open** — it proceeds as if the key had not been seen, and the response is not cached for replay. This means a retry during an outage may write a duplicate event; clients that need strict at-most-once delivery should pair idempotency keys with OCC (`previous_length`).

### When to use idempotency keys

**Always use them for:**
- Payment processing
- Order placement
- Any operation with real-world side effects (sending emails, charging cards)
- Batch operations
- Any client that retries on network errors

**Not needed for:**
- GET requests (inherently idempotent)
- Operations already protected by your own deduplication logic

## Response

Success returns 201 Created:

```json
{
  "ok": true,
  "stream_id": "1234567890123-0",
  "implied_count": 2
}
```

| Field | Description |
|-------|-------------|
| `stream_id` | Opaque event identifier; use as a cursor when listing events |
| `implied_count` | Number of implied events triggered (omitted if 0) |

## Example: Complete flow

Creating an order:

```bash
# 1. Place the order
curl -X POST https://myapp.j17.dev/order/ord_123/was_placed \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -H "X-Idempotency-Key: order-ord_123-was_placed-20240115" \
  -d '{
    "data": {
      "customer_id": "550e8400-e29b-41d4-a716-446655440003",
      "items": [
        { "sku": "WIDGET-1", "name": "Widget", "price": 29.99, "quantity": 2 }
      ]
    },
    "metadata": {
      "actor": { "type": "user", "id": "550e8400-e29b-41d4-a716-446655440003" }
    }
  }'

# Response: {"ok": true, "stream_id": "...", "implied_count": 0}

# 2. Add an item (with OCC)
curl -X POST https://myapp.j17.dev/order/ord_123/had_item_added \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "data": {
      "sku": "GADGET-2",
      "name": "Gadget",
      "price": 19.99,
      "quantity": 1
    },
    "metadata": {
      "actor": { "type": "user", "id": "550e8400-e29b-41d4-a716-446655440003" },
      "previous_length": 1
    }
  }'

# Response: {"ok": true, "stream_id": "..."}

# 3. Mark as paid
curl -X POST https://myapp.j17.dev/order/ord_123/was_paid \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "data": {
      "payment_id": "pay_789",
      "amount": 79.97
    },
    "metadata": {
      "actor": { "type": "system", "id": "550e8400-e29b-41d4-a716-446655440004" },
      "previous_length": 2
    }
  }'

# Response: {"ok": true, "stream_id": "..."}
```

## Aggregate IDs

Three formats accepted:

### UUIDv4 (recommended)

Standard UUIDs:

```
550e8400-e29b-41d4-a716-446655440000
```

Use for most entities. Generate with any UUID library.

### Humane codes

Human-readable identifiers:

```
ABC123XYZ
```

9 characters, Crockford base32. Good for:
- Booking references
- Promo codes
- Support tickets
- Anything customers type

### global

Singleton aggregates:

```bash
curl https://myapp.j17.dev/config/global
```

One per aggregate type. Use for app-wide settings, feature flags.

## Error handling

### 400 Bad Request

Validation error. Schema mismatch, missing actor, invalid aggregate ID.

### 404 Not Found

```json
{
  "ok": false,
  "error": "Event type 'was_placed' not found in spec for aggregate 'order'"
}
```

Your spec doesn't define this event type. Check for typos or update your spec.

### 409 Conflict

OCC check failed. Retry with the current length.

### 413 Request Entity Too Large

Event data exceeds size limit.

### 422 Unprocessable Entity

Idempotency key reused with a different request body.

### 429 Rate Limited

You've exceeded your instance's burst rate limit or your plan's event allocation. Check `X-RateLimit-Scope` (`instance` or `ip`) and `X-RateLimit-Tier`. See [rate limits](/docs/api/rate-limits) for details.

## Retry strategies

For 5xx errors or network failures:

```javascript
async function writeEventWithRetry(path, data, options = {}) {
  const idempotencyKey = options.idempotencyKey || crypto.randomUUID();
  const maxRetries = options.maxRetries || 3;

  for (let attempt = 0; attempt < maxRetries; attempt++) {
    try {
      const response = await fetch(`https://myapp.j17.dev${path}`, {
        method: 'POST',
        headers: {
          'Authorization': `Bearer ${apiKey}`,
          'Content-Type': 'application/json',
          'X-Idempotency-Key': idempotencyKey,
        },
        body: JSON.stringify(data),
      });

      if (response.ok) return response.json();
      if (response.status === 422) throw new Error('Idempotency key mismatch');
      if (response.status < 500) throw new Error(`Client error: ${response.status}`);
      // 5xx: retry
    } catch (e) {
      if (attempt === maxRetries - 1) throw e;
      await sleep(Math.pow(2, attempt) * 100);
    }
  }
}
```

Always use idempotency keys when retrying to avoid duplicates.

## Error lookup

When a write fails with a 5xx, the response includes an `x-error-id` header with a short opaque ID (e.g. `err_a1b2c3d4e5f6g7h8`). You can use this ID to retrieve full error context — the error type, the event that triggered it, and diagnostic details about what went wrong.

```
GET /_errors/:error_id
```

Authentication: API key (read scope). Errors are scoped to the instance and environment — a key for `myapp` prod cannot read errors from `myapp` staging.

```bash
curl https://myapp.j17.dev/_errors/err_a1b2c3d4e5f6g7h8 \
  -H "Authorization: Bearer $API_KEY"
```

```json
{
  "ok": true,
  "error": {
    "error_id": "err_a1b2c3d4e5f6g7h8",
    "error_type": "ValueNotFound",
    "message": "Path $.data.quantity not found",
    "context": {
      "event_key": "order:abc123",
      "event_type": "was_placed",
      "operation": "set"
    },
    "request_id": "req_...",
    "occurred_at": "2026-05-27T12:34:56Z"
  }
}
```

Errors are retained for **24 hours** from when they are stored. After that, the ID returns 404. Use error IDs for debugging, not as durable references.

## See also

- [Reading aggregates](/docs/api/reading-aggregates) - GET to query state
- [Batch operations](/docs/api/batch-operations) - Atomic multi-event writes
- [Atomic writes](/docs/api/atomic-writes) - One write across several aggregates
- [Atomicity concepts](/docs/concepts/atomicity) - OCC and consistency

# Reading Aggregates

GET aggregates to fetch current state. j17 replays events and applies handlers on demand. Handlers are declarative rules defined in your spec -- for example, a `was_created` event might use `{"set": {"target": "", "value": "$.data"}}` to set initial state, and a `had_name_updated` event might merge a new name into it.

## Basic query

```bash
GET /{aggregate_type}/{aggregate_id}
```

```bash
curl https://myapp.j17.dev/order/ord_123 \
  -H "Authorization: Bearer $API_KEY"
```

Response:

```json
{
  "ok": true,
  "data": {
    "items": [
      { "sku": "WIDGET-1", "quantity": 2, "price": 29.99 }
    ],
    "status": "paid",
    "customer_id": "550e8400-e29b-41d4-a716-446655440003",
    "total": 79.97,
    "placed_at": 1705312800,
    "paid_at": 1705312900
  },
  "metadata": {
    "length": 5,
    "created_at": 1705312800,
    "updated_at": 1705312900
  }
}
```

| Field | Description |
|-------|-------------|
| `data` | Current aggregate state after applying all events through handlers |
| `metadata.length` | Total number of events in this aggregate |
| `metadata.created_at` | Timestamp of the first event (Unix seconds) |
| `metadata.updated_at` | Timestamp of the most recent event (Unix seconds) |

### Status codes

| Status | When |
|--------|------|
| **200 OK** | Aggregate exists (`metadata.length > 0`); or the id is a singleton |
| **404 Not Found** | Aggregate id is well-formed but no events have ever been written to it |
| **422 Unprocessable Entity** | Aggregate id is malformed — see [Identifiers](/docs/reference/identifiers) |

**Singletons always return 200**, even before the first write — they're conceptually "one well-known instance, possibly empty." Branch on `metadata.length > 0` rather than status code to detect missingness on a singleton.

## Stream length (for OCC)

Get just the stream length without computing the full aggregate. This is an O(1) operation, useful for optimistic concurrency control.

```bash
GET /{aggregate_type}/{aggregate_id}/length
```

```bash
curl https://myapp.j17.dev/order/ord_123/length \
  -H "Authorization: Bearer $API_KEY"
```

Response:

```json
{
  "ok": true,
  "length": 5
}
```

Use this length as `previous_length` in your next write:

```javascript
const { length } = await fetch('/order/ord_123/length').then(r => r.json());
await writeEvent('/order/ord_123/was_updated', {
  data: { ... },
  metadata: { actor: { ... }, previous_length: length }
});
```

If another write happened between your read and write, you'll get a 409 Conflict.

## Query parameters

### Synchronous reads

By default, j17 may serve cached aggregate state. To bypass the cache and compute fresh from events:

```bash
curl "https://myapp.j17.dev/order/ord_123?synchronous=true"
```

Use when you need guaranteed-fresh state, such as immediately after a write.

A synchronous read rebuilds the aggregate from its whole event log, so it is the one read
that gets more expensive as an aggregate grows. Requests that would have to read more than
32 MB of events are refused with a [422](#422-unprocessable-entity) rather than served; the
default cached read has no such limit.

### Get events

Get the raw event stream for an aggregate:

```bash
GET /{aggregate_type}/{aggregate_id}/events
```

```bash
curl "https://myapp.j17.dev/order/ord_123/events" \
  -H "Authorization: Bearer $API_KEY"
```

Response:

```json
{
  "ok": true,
  "data": [
    {
      "id": "1234567890123-0",
      "hash": "9f2c...64 hex chars...a1",
      "event": {
        "key": "order:ord_123",
        "type": "was_placed",
        "data": { ... },
        "metadata": {
          "actor": { "type": "user", "id": "..." },
          "timestamp": 1705312800
        }
      }
    },
    {
      "id": "1234567890124-0",
      "event": {
        "key": "order:ord_123",
        "type": "had_item_added",
        "data": { ... },
        "metadata": { ... }
      }
    }
  ],
  "count_limit": 500,
  "truncated": true,
  "truncated_by": "count",
  "next_start": "1234567890124-0"
}
```

Each element of `data` pairs the stream ID with the event itself. The `id` is the cursor value to pass back as `start`; the event body lives under `event`; `hash` is the event's [chain hash](/docs/api/audit) — with it, every entry on a page is verifiable against its predecessor.

Query parameters:

| Parameter | Type | Description |
|-----------|------|-------------|
| `start` | string | Cursor — exclusive in both directions |
| `count` | integer | Events per page. Defaults to **500**, capped at **5,000**. |
| `direction` | string | `forwards` (default) or `backwards` |
| `raw` | boolean | `raw=true` returns each event's **stored bytes** as a JSON string under `raw`, in place of the decoded `event`. |

### Verifying events as you read them

Chain hashes are computed over the event's bytes **as stored**. A decoded JSON object re-serialized by your language's encoder will not, in general, reproduce those bytes (key order differs), so hashing a decoded `event` back into the chain is a trap. `raw=true` exists for exactly this:

```bash
curl "https://myapp.j17.dev/order/ord_123/events?raw=true" \
  -H "Authorization: Bearer $API_KEY"
```

```json
{
  "id": "1234567890123-0",
  "hash": "9f2c...a1",
  "raw": "{\"key\":\"order:ord_123\",\"type\":\"was_placed\",...}"
}
```

To verify: the first event's hash is `SHA-256(raw)`; every later event's is `SHA-256(previous_hash <> raw)` (hex hash string concatenated with the raw bytes). Any page whose hashes recompute is untampered relative to itself, and the last hash ties into the [Merkle proofs and signed anchors](/docs/api/audit) for verification against a signed root.

Response fields alongside `data`:

| Field | Description |
|-------|-------------|
| `count_limit` | The page size actually applied |
| `truncated` | `true` when there are more events in the direction you are reading |
| `truncated_by` | `"count"` or `"bytes"` — which limit ended the page. Absent when not truncated. |
| `next_start` | Cursor for the next page. Absent when there is none, so its absence is how you know you have reached the end. |
| `count_clamped` | `true` when your `count` exceeded the maximum and was reduced. Absent otherwise. |

**Cursor semantics:**

- Events are **always returned oldest-first**, in stream order, whichever direction you ask for
- `start` is **exclusive** — an event whose `id` equals `start` is never returned
- **There is always a page size.** Omitting `count` gives you 500 events, not the whole stream. Follow `next_start` to read the rest.
- `truncated` is exact, not inferred: a page that ends precisely on the last event of the stream reports `truncated: false`, so you never have to fetch an empty page to discover you were done
- `id` values are opaque — treat them as cursors, not as data to parse. The format may change, and differs between the managed platform and local development tools.
- An unusable `count` (`0`, negative, non-numeric) or an unrecognised `direction` is a `400`, not a silently ignored parameter

### Page size

A request that names no `count` gets **500 events**. A `count` above **5,000** is reduced to 5,000 and the response sets `count_clamped: true` — your request is answered, just not at the size you asked for.

There is also a **4 MB ceiling on a single page**, and whichever limit binds first ends the page. Event size varies enormously between specs, so a count on its own is a poor description of what a page costs: 500 small events and 500 fat ones are the same number and wildly different responses. When the byte ceiling is what stopped the page, `truncated_by` reads `"bytes"` and you will get fewer events than you asked for. Follow `next_start` exactly as you would otherwise — an event larger than the entire ceiling is still returned on its own, so a stream of large events stays walkable.

**Changed in 0.15.0.** There was previously no page size at all: omitting `count` returned the entire stream, and `count` limited the response without limiting the work behind it. A client that relied on a single unparameterized call returning everything now needs to follow `next_start`.

### Direction

`direction` selects **which window** of the stream you get. It does not change the order of the returned array — that is always oldest-first.

| Request | Returns |
|---------|---------|
| `?count=3` | The **oldest** 3 events |
| `?direction=backwards&count=3` | The **newest** 3 events, still oldest-first |
| `?start=ID&count=3` | The 3 events **after** `ID` |
| `?direction=backwards&start=ID&count=3` | The 3 events **before** `ID` |

Keeping the array oldest-first in both directions is deliberate. An event stream is a fold — state is rebuilt by applying events in order — so a page that arrives reversed is a page you cannot replay, and the bug it causes surfaces far from its origin. Direction moves the window; order stays fold order.

```bash
# The five most recent events
curl "https://myapp.j17.dev/order/ord_123/events?direction=backwards&count=5"
```

**Paging forwards** (catch-up consumer): keep the last `id` you processed, pass it as `start`, and you resume exactly where you stopped. `next_start` gives you that cursor directly.

```bash
curl "https://myapp.j17.dev/order/ord_123/events?count=50"
curl "https://myapp.j17.dev/order/ord_123/events?count=50&start=1705312800999-0"
```

Read a whole stream by following `next_start` until it stops coming back:

```bash
cursor=""
while :; do
  page=$(curl -s "https://myapp.j17.dev/order/ord_123/events?count=500${cursor:+&start=$cursor}")
  echo "$page" | jq -c '.data[]'
  cursor=$(echo "$page" | jq -r '.next_start // empty')
  [ -z "$cursor" ] && break
done
```

**Paging backwards** (walking history from the present): take the **first** `id` of each window as the next cursor, since the array is oldest-first. `next_start` already accounts for direction, so following it works either way.

```bash
curl "https://myapp.j17.dev/order/ord_123/events?direction=backwards&count=50"
curl "https://myapp.j17.dev/order/ord_123/events?direction=backwards&count=50&start=1705312800111-0"
```

```bash
# First page
curl "https://myapp.j17.dev/order/ord_123/events?count=50"

# Next page — pass the last id from the previous response
curl "https://myapp.j17.dev/order/ord_123/events?count=50&start=1705312800999-0"
```

### List aggregates

```bash
GET /{aggregate_type}
```

```bash
curl https://myapp.j17.dev/order \
  -H "Authorization: Bearer $API_KEY"
```

Returns known aggregate IDs for a type with cursor-based pagination.

| Parameter | Type | Description |
|-----------|------|-------------|
| `resolve` | flag | Return full aggregate data instead of IDs (`?resolve` or `?resolve=true`) |
| `synchronous` | boolean | Skip cache when resolving, read from event stream (`?synchronous=true`) |
| `limit` | integer | Page size (default 50, max 200 — a lowered limit is reported via `limit_clamped: true`). Malformed values are a 400, not silently defaulted. |
| `start` | string | Return IDs after this value (exclusive, lexicographic). `cursor` is accepted as a legacy alias. |

The response speaks the same pagination vocabulary as `/events`: the applied
`limit` is echoed back, `truncated` says whether there is more, and `next_start`
is present exactly when there is — its absence alone answers "am I done". (The
legacy `cursor` response field carries the same value as `next_start`.)

**Cursor semantics:**

- IDs are returned in lexicographic (byte-wise) order
- The cursor is **exclusive** — an ID equal to the cursor value is not returned
- To page through, pass `next_start` from the previous response back as `start`
- When the response omits `next_start`, you've reached the end
- The cursor is stable across requests as long as no aggregate IDs are deleted; new aggregates with IDs greater than your current cursor will appear on subsequent pages
- All four [identifier kinds](/docs/reference/identifiers) — UUIDs, humane codes, singletons (including `global`), tagged UUIDs — appear in the list once any event has been written to them. They sort lexicographically by raw string, so singleton names (lowercase letters) appear before UUIDs (hex digits) in the natural order.

**IDs only (default):**

```json
{"ok": true, "data": ["order-123", "order-456", "order-789"], "limit": 50, "truncated": false}
```

**With `?resolve` — full aggregate data:**

```json
{
  "ok": true,
  "data": [
    {"id": "order-123", "data": {"status": "pending", ...}, "metadata": {...}},
    {"id": "order-456", "data": {"status": "shipped", ...}, "metadata": {...}}
  ]
}
```

Resolved aggregates use the cache when available, falling back to sync reads from the event stream. Use `?synchronous=true` to always read from the stream. Resolved entries are exactly as current as a `GET` of the same aggregate — a listing and a single read never disagree.

If one aggregate in the page cannot be read (for example, recomputing it would exceed the fold byte budget), its entry carries an `error` field instead of `data` — the rest of the page is unaffected:

```json
{"id": "order-789", "error": "fold_budget_exceeded"}
```

**Pagination:**

```bash
# First page
curl "https://myapp.j17.dev/order?limit=25"
# Response includes cursor: {"ok": true, "data": [...], "cursor": "order-xyz"}

# Next page
curl "https://myapp.j17.dev/order?limit=25&cursor=order-xyz"
```

### Projections

Request a named projection:

```bash
curl "https://myapp.j17.dev/_projections/user_dashboard/cust_456" \
  -H "Authorization: Bearer $API_KEY"
```

Projections are pre-computed views maintained by the platform. Use `?synchronous=true` for a fresh computation instead of the cached value.

Export as CSV: `GET /_projections/:name/export.csv`

Query with SQL: `POST /_projections/query` with `{"sql": "SELECT ...", "params": [...]}`

See the [projections guide](/docs/guides/projections) for full details including limits and restrictions.

## Singleton aggregates

Use the literal `global` as the aggregate ID for one-per-type aggregates:

```bash
curl https://myapp.j17.dev/config/global \
  -H "Authorization: Bearer $API_KEY"
```

If you've defined custom singletons in your spec (`"singletons": ["all"]`), use them the same way:

```bash
curl https://myapp.j17.dev/company_audit/all \
  -H "Authorization: Bearer $API_KEY"
```

Reads of singletons return **200 OK** even before the first write — branch on `metadata.length > 0` if you need missingness detection.

See [Identifiers § Singletons](/docs/reference/identifiers#singletons) for the full reference and [Aggregates: Singleton aggregates](/docs/concepts/aggregates#singleton-aggregates) for the conceptual overview.

## Response codes

### 200 OK

Aggregate exists. Returns state.

### 404 Not Found

Aggregate doesn't exist (no events yet) or aggregate type not in spec.

```json
{
  "ok": false,
  "error": "Aggregate not found"
}
```

An aggregate with no events returns 404, not empty state.

### 422 Unprocessable Entity

The request is valid, but answering it this particular way would cost more than a single
request is allowed to spend. Returned by `?synchronous=true` on an aggregate whose event
log exceeds the rebuild budget.

```json
{
  "ok": false,
  "error_type": "fold_budget_exceeded",
  "error": "Recomputing this aggregate from its event log would read 41943040 bytes, over the 33554432 byte budget for a single request. ...",
  "bytes": 41943040,
  "limit": 33554432
}
```

The aggregate itself is fine. Drop `?synchronous=true` to read the cached state, or use
`GET /:type/:id/events` to page through the log — neither is affected by this budget.

### 401 Unauthorized

Invalid or missing API key.

### 403 Forbidden

Valid key, but wrong environment (staging key on prod) or insufficient scope.

## Performance

A default read is served from cached state and costs the same regardless of how many
events an aggregate has. The cache is kept current in the background by folding on only
the events added since it was last written, so neither reads nor writes get slower as an
aggregate accumulates history.

Rebuilding from the full log is the operation that scales with size, at roughly 5–7 µs per
event. It happens on `?synchronous=true`, and in the background after a spec change:

- 1,000 events: ~5ms
- 10,000 events: ~60ms
- 100,000 events: ~600ms

Requests that would rebuild more than 32 MB of events are refused
([422](#422-unprocessable-entity)); background rebuilds are not, because they run in
bounded chunks. [Checkpoints](/docs/api/admin-api) snapshot state periodically and are
still useful for recovery and export.

## Optimistic reads

For optimistic UI patterns:

```javascript
// 1. Get stream length (O(1))
const { length } = await fetch('/order/ord_123/length').then(r => r.json());

// 2. Fetch current state
const { data } = await fetch('/order/ord_123').then(r => r.json());

// 3. Render UI
renderOrder(data);

// 4. User makes change -- write with OCC
try {
  await writeEvent('/order/ord_123/was_shipped', {
    data: { ... },
    metadata: { actor: { ... }, previous_length: length }
  });
} catch (err) {
  // 409 Conflict -- refetch and retry
  const fresh = await fetch('/order/ord_123').then(r => r.json());
  renderOrder(fresh.data);
}
```

## Comparing to traditional databases

| Operation | SQL | j17 |
|-----------|-----|-----|
| Read by ID | `SELECT * FROM orders WHERE id = ?` | `GET /order/ord_123` |
| Read length | `SELECT COUNT(*) FROM events WHERE key = ?` | `GET /order/ord_123/length` |
| Read full history | Multiple queries/joins | `GET /order/ord_123/events` |
| List/filter | `SELECT * FROM orders WHERE status = 'paid'` | Not directly (use projections) |

The trade-off: you lose ad-hoc queries, you gain immutable history.

## Best practices

**Store aggregate IDs.** When you create an order, save the ID in your database. You'll need it to query later.

**Use projections for lists.** Don't try to "list all orders." Create a projection that maintains the list.

**Use `?synchronous=true` sparingly.** Default reads may use cached state, which is faster. Only force synchronous when you need guaranteed freshness.

**Handle 404 gracefully.** A 404 just means no events yet. It's not an error condition.

## See also

- [Writing events](/docs/api/writing-events) - POST to modify state
- [Batch operations](/docs/api/batch-operations) - Atomic multi-event writes
- [Admin API](/docs/api/admin-api) - Checkpoints, backups, data export

# Batch Operations

Batch writes submit multiple events to the same aggregate atomically. All events succeed or none are written. A single OCC check protects the entire batch.

## When to batch

**Good for:**
- Initial entity creation with multiple setup events
- Complex state transitions that are logically atomic
- Bulk imports and data migrations

**Not for:**
- Events to different aggregates — use [implications](/docs/concepts/implications) when the second write is a consequence of the first, or an [atomic write](/docs/api/atomic-writes) when it can veto the first
- Single events (no benefit, adds complexity)
- Operations needing immediate per-event feedback

## Batch write

```bash
POST /{aggregate_type}/{aggregate_id}
```

```json
{
  "events": [
    { "type": "was_created", "data": { "name": "Alice" } },
    { "type": "had_email_updated", "data": { "email": "alice@example.com" } },
    { "type": "had_role_assigned", "data": { "role": "admin" } }
  ],
  "metadata": {
    "actor": { "type": "admin", "id": "550e8400-e29b-41d4-a716-446655440000" },
    "previous_length": 0
  }
}
```

Each event needs:
- `type`: Event type from spec
- `data`: Event payload (optional, depends on event schema)

Metadata is shared across all events in the batch (actor, target, previous_length).

## Response format

```json
{
  "ok": true,
  "stream_ids": ["1706789012345-0", "1706789012345-1", "1706789012345-2"],
  "count": 3,
  "implied_count": 5
}
```

| Field | Description |
|-------|-------------|
| `stream_ids` | Opaque event identifiers, one per written event; use as cursors when listing events |
| `count` | Number of events written |
| `implied_count` | Total implied events across all triggers (omitted if 0) |

## OCC in batches

The `previous_length` in metadata applies to the batch as a whole:

- If you specify `previous_length: 0`, the stream must be empty before writing
- All events are written atomically after the OCC check passes
- If OCC fails, none of the events are written

### Getting stream length for OCC

Use the length endpoint for an O(1) check:

```bash
curl https://myapp.j17.dev/order/ord_123/length \
  -H "Authorization: Bearer $API_KEY"

# Response: {"ok": true, "length": 5}
```

Then pass it as `previous_length` in your batch write.

## Implications in batches

Each event in a batch can trigger implications. All implications see the **pre-batch state**, not intermediate states:

```
Submit: [A, B, C] to order:123

S0 (pre-batch state)
+-- Implications for A see S0
+-- Implications for B see S0
+-- Implications for C see S0

After commit: state = S0 + A + B + C
```

## Idempotency

Batch writes support the `X-Idempotency-Key` header just like single writes:

```bash
curl -X POST https://myapp.j17.dev/user/user_123 \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -H "X-Idempotency-Key: user-setup-user_123-20240115" \
  -d '{
    "events": [
      { "type": "was_created", "data": { "name": "Alice" } },
      { "type": "had_role_assigned", "data": { "role": "admin" } }
    ],
    "metadata": {
      "actor": { "type": "system", "id": "550e8400-e29b-41d4-a716-446655440001" }
    }
  }'
```

If you retry with the same key and same body, the cached response is returned with `X-Idempotency-Replayed: true`. Same key with different body returns 422.

## Error handling

### 400 Bad Request

Validation error on one or more events. No events written.

```json
{
  "ok": false,
  "error": "Event data failed schema validation",
  "event_index": 1,
  "path": "data.email"
}
```

The `event_index` field tells you which event in the array failed (0-based).

### 409 Conflict

OCC check failed. No events written. Refetch the length and retry.

### Empty or missing events

```json
{
  "ok": false,
  "error": "Missing 'events' array in request body"
}
```

Or:

```json
{
  "ok": false,
  "error": "Events array cannot be empty"
}
```

## Bulk import example

Migrating from an existing database:

```javascript
const batchSize = 50;

for (const user of users) {
  const events = [
    { type: 'was_created', data: { name: user.name, email: user.email } },
    { type: 'had_profile_updated', data: { bio: user.bio, avatar: user.avatar } }
  ];

  await fetch(`https://myapp.j17.dev/user/${user.uuid}`, {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${apiKey}`,
      'Content-Type': 'application/json',
      'X-Idempotency-Key': `migration-user-${user.uuid}`,
    },
    body: JSON.stringify({
      events,
      metadata: {
        actor: { type: 'system', id: '550e8400-e29b-41d4-a716-446655440002' }
      }
    })
  });
}
```

Use idempotency keys so you can resume if interrupted.

## When not to batch

**Events to different aggregates**: Batches only target a single aggregate. For cross-aggregate writes, use implications -- define them in your spec so one trigger event atomically creates derived events in other aggregates.

**Events that are truly independent**: If event A failing shouldn't block event B, use separate POSTs.

**Interactive user actions**: Individual POSTs give clearer error feedback than batch failures.

## See also

- [Writing events](/docs/api/writing-events) - Single event API
- [Atomicity concepts](/docs/concepts/atomicity) - OCC and consistency
- [Reading aggregates](/docs/api/reading-aggregates) - GET to query state

# Atomic Writes

Atomic writes commit events to **several aggregates at once**, each with its own concurrency check. Either every event lands or none do.

**You probably don't need this.** The aggregate is your consistency boundary — if you find yourself reaching for atomic writes often, the boundaries are drawn in the wrong place. Most applications never use this endpoint. Read [when to use it](#when-to-use-it) before reaching for it.

## When to use it

The deciding question is: **can the second write fail in a way that should mean the first never happened?**

| Answer | Use |
|--------|-----|
| No — the second thing follows from the first | An [implication](/docs/concepts/implications) |
| No, but it must eventually happen and may need retrying | A [saga](/docs/guides/sagas) |
| **Yes — the second thing can veto the first** | **An atomic write** |

An implication is a *consequence*. The parent event is already true, and the implied event follows from it. Implications expand after the parent has been written, so a failing implication cannot un-write its parent.

An atomic write is a *guard*. The parent isn't true until the other writes are possible.

### The case it exists for

A booking needs two calendar slots. If either slot is already taken, **the booking must not exist**.

Written as separate calls, whichever you write first can survive the failure of the second. Written as an implication, the booking lands and then the hold fails, leaving a booking for a slot you don't hold. Written atomically, the question doesn't arise.

### Cases it does *not* fit

Invoicing is the common near-miss. When voiding an invoice, the state change must win the transition and the ledger entry follows — but the ledger write **cannot veto the void**. A void that already won shouldn't be undone because bookkeeping hiccupped. That is a consequence, not a guard, and retry is the right tool.

## Request

```
POST /_atomic-write
```

```json
{
  "targets": [
    {
      "aggregate_type": "booking",
      "id": "550e8400-e29b-41d4-a716-446655440001",
      "previous_length": 0,
      "events": [
        { "type": "was_created", "data": { "slot": "0900" } }
      ]
    },
    {
      "aggregate_type": "room_calendar",
      "id": "550e8400-e29b-41d4-a716-446655440002",
      "previous_length": 7,
      "events": [
        { "type": "had_slot_held", "data": { "0900": "550e8400-e29b-41d4-a716-446655440001" } }
      ]
    }
  ],
  "metadata": {
    "actor": { "type": "user", "id": "550e8400-e29b-41d4-a716-446655440099" }
  }
}
```

### targets (required)

Between 1 and 10 targets. Each has:

| Field | Required | Description |
|-------|----------|-------------|
| `aggregate_type` | yes | Must exist in your spec |
| `id` | yes | Any j17 [identifier](/docs/reference/identifiers) |
| `events` | yes | Non-empty array of `{ "type", "data" }` |
| `previous_length` | no | This target's own concurrency check |

**`previous_length` is per target.** Each aggregate is checked against the length *you* read for it. Omit it to mean "however long it is now", which cannot conflict.

The same aggregate may not appear twice — two targets naming one stream have no unambiguous order, and picking one would silently drop events.

### metadata (required)

Applies to every event in the write. Same contract as [writing events](/docs/api/writing-events#metadata-required): `actor` is required, `target` is optional, and unknown fields are refused.

## Response

```json
{
  "ok": true,
  "written": 2,
  "stream_ids": {
    "booking:550e8400-e29b-41d4-a716-446655440001": ["1738000000000-0"],
    "room_calendar:550e8400-e29b-41d4-a716-446655440002": ["1738000000001-0"]
  }
}
```

`201 Created`. `stream_ids` is keyed by aggregate; `written` is the total event count.

## Concurrency conflicts

If any target's `previous_length` doesn't match, the whole write is refused with `409 Conflict` and **nothing is written**:

```json
{
  "ok": false,
  "error": "occ_conflict",
  "key": "room_calendar:550e8400-e29b-41d4-a716-446655440002",
  "current_length": 8,
  "expected_length": 7
}
```

`key` names the aggregate that moved, so you know what to re-read. Since nothing was written, retry the whole set: re-read the aggregates, re-evaluate your decision, and submit again.

### Per-aggregate retry has to go

If you're converting code that wrote these aggregates one at a time, it probably has a retry helper that re-reads *one* aggregate on conflict and tries again. **Delete it rather than adapt it.**

Retrying a single target is meaningless here. The conflict says the set you decided against is no longer the set that exists, and a sibling target's `previous_length` may have gone stale in the same instant — so re-reading only the aggregate named in `key` can produce a request that's already wrong when you send it. Worse, the decision itself may no longer hold: the point of the guard is that the other writes are possible, and a conflict is exactly the evidence that they might not be.

Retry belongs to whoever holds the whole set, which is the caller that assembled the targets. Re-read all of them, re-run the decision, and submit again — or don't, if the decision no longer comes out the same way.

## What the log records

Each conditioned target's **first event** carries `metadata.previous_length` — the value that target was checked against. Events of an unconditioned target carry **no** `previous_length` field at all.

Absent and zero are deliberately distinguishable: `0` is itself a meaningful precondition ("this stream must be empty"), so an event whose metadata says `previous_length: 0` was arbitrated against an empty stream, and an event without the field was written unconditioned. Anyone auditing a stream later can tell which writes were guarded without reconstructing the request that made them.

## Idempotency

Send `X-Idempotency-Key` exactly as you would for a [single write](/docs/api/writing-events#idempotency). A repeated key returns the original response rather than writing again.

This matters more here than elsewhere. If your request times out, you cannot tell a refusal from a lost reply — and retrying blind either books twice or conflicts against a check your own first attempt already consumed. **Send a key.**

## Limits

**Ten targets.** An atomic write holds your instance's storage for its duration, so the set has to stay small. If you need more than ten aggregates in one decision, that's a strong sign the aggregates are drawn too finely.

## Restrictions

**Events that declare implications are refused.** An implication writes further events inside the same request that are *not* covered by the all-or-nothing guarantee, so you would get two different promises from one call with nothing telling you which events had which. Rather than hand back a weaker guarantee than the one you asked for, the write is refused:

```json
{
  "ok": false,
  "error": "implications_in_multi_write",
  "target_index": 1,
  "message": "student_calendar.was_archived declares implications, which a multi-aggregate write does not expand..."
}
```

Either write those events individually, or list the implied aggregates as explicit targets so they're covered by the same guarantee.

**Sagas are unaffected.** A saga writes nothing during your request — it's picked up afterwards, exactly as for an ordinary write — so events written atomically trigger sagas normally.

## Errors

| Status | Meaning |
|--------|---------|
| `409` | A target's `previous_length` didn't match. Nothing written; re-read and retry. |
| `422` | Validation failed, more than 10 targets, a duplicate aggregate, or an event declaring implications. `target_index` points at the offending target when one is identifiable. |
| `429` | [Rate limited](/docs/api/rate-limits). |

Validation runs across every target before anything is written, so a `422` never leaves a partial write behind.

## Billing

Atomic writes are metered per event, like every other write. A three-target write of one event each counts as three events. Grouping writes together doesn't change what you're charged.

## See also

- [Writing events](/docs/api/writing-events) - the ordinary single-event write
- [Batch operations](/docs/api/batch-operations) - many events, one aggregate
- [Implications](/docs/concepts/implications) - consequences, not guards
- [Atomicity concepts](/docs/concepts/atomicity) - OCC and consistency

# Rate Limits

j17 applies rate limits at two layers: per-instance limits to ensure fair sharing between tenants, and plan-based limits to enforce your billing tier. Both return `429 Too Many Requests` when exceeded, with headers and response bodies that tell you exactly what happened.

## Per-instance limits

Every instance has a burst rate limit determined by its billing tier. This limit applies across all API keys for the instance — creating more keys doesn't increase your throughput.

### Write limits

| Tier | Limit | Per second |
|------|-------|------------|
| Free | 600/min | 10/s |
| Hobby | 6,000/min | 100/s |
| Pro | 12,000/min | 200/s |
| Scale | 60,000/min | 1,000/s |

### Read limits

Read limits are 5x write limits. Reads are cheaper but a runaway polling loop can still affect performance for other tenants.

| Tier | Limit | Per second |
|------|-------|------------|
| Free | 3,000/min | 50/s |
| Hobby | 30,000/min | 500/s |
| Pro | 60,000/min | 1,000/s |
| Scale | 300,000/min | 5,000/s |

### Response

When you exceed your instance limit:

```http
HTTP/1.1 429 Too Many Requests
X-RateLimit-Limit: 6000
X-RateLimit-Remaining: 0
X-RateLimit-Scope: instance
X-RateLimit-Tier: hobby

{
  "ok": false,
  "error": "Rate limit exceeded",
  "scope": "instance",
  "limit": 6000,
  "window": "1m",
  "tier": "hobby"
}
```

Successful responses include `X-RateLimit-Remaining` so your client can self-throttle before hitting the limit.

### What to do

- **Batch writes.** Use `POST /:type/:id` with an array of events instead of individual requests. One batch counts as one request.
- **Upgrade your tier.** Tier changes take effect within 30 seconds.
- **Backoff.** When you get a 429, wait before retrying:

```javascript
const delay = Math.min(1000 * 2 ** attempt, 30000) + Math.random() * 1000;
await new Promise(r => setTimeout(r, delay));
```

## Per-IP safety net

A global per-IP limit of 30,000 requests/minute acts as a safety net. You should never hit this under normal use — if you do, the `X-RateLimit-Scope` header will say `ip` instead of `instance`.

## Plan limits

Your plan includes a monthly allocation for events written. As you approach and exceed it:

| Usage | Behavior |
|-------|----------|
| Under 80% | Normal operation |
| 80-100% | Requests succeed but include a warning header |
| Over 100% | Requests rejected with 429 |

### Warning (approaching limit)

```http
HTTP/1.1 200 OK
X-J17-Limit-Warning: Approaching events_written limit: 800/1000
```

Watch for the `X-J17-Limit-Warning` header and alert before you hit the hard limit.

### Hard limit (exceeded)

```http
HTTP/1.1 429 Too Many Requests
X-J17-Limit-Exceeded: events_written: 1250/1000

{
  "ok": false,
  "error": "Limit exceeded",
  "metric": "events_written",
  "usage": 1250,
  "limit": 1000,
  "message": "You have exceeded your plan's events_written limit. Please upgrade your plan or wait until the next billing period."
}
```

Plan limits count monthly totals. Rate limits count per-minute bursts. They are independent — you can be within your monthly allocation but exceed your burst limit, or vice versa.

## Read operations

Read operations (`GET /:type/:id`, `GET /_projections/*`) count against your instance's read rate limit but are **not** counted against your plan's event allocation. Only writes count toward plan limits.

## Size limits

Separate from rate limits, j17 enforces payload size limits:

| Limit | Value |
|-------|-------|
| Max event data size | 100 KB |
| Max batch size | 10,000 events per request |
| Max spec size | 1 MB |

Exceeding these returns `413 Request Entity Too Large`.

## See also

- [Writing events](/docs/api/writing-events) - Retry strategies for 429 responses
- [Batch operations](/docs/api/batch-operations) - Reduce request count with batching
- [Authentication](/docs/api/authentication) - API key scopes and environments

# Admin API

Manage specs, API keys, backups, and configure your instance via the Admin API. All admin endpoints use JWT authentication on the console API.

## Authentication

All admin endpoints require JWT authentication on the console API:

```bash
curl https://console.j17.dev/api/instances \
  -H "Authorization: Bearer $JWT_TOKEN"
```

Get a JWT via login:

```bash
curl -X POST https://console.j17.dev/api/login \
  -H "Content-Type: application/json" \
  -d '{"email": "admin@example.com", "password": "..."}'
```

## Spec management

### Deploy spec

```bash
POST /api/instances/:instance_id/spec
Authorization: Bearer $JWT
Content-Type: application/json

{
  "environment": "prod",
  "spec": {
    "aggregate_types": { ... },
    "agent_types": [ ... ]
  }
}
```

The spec goes inside the `"spec"` key. The dashboard upload expects the same format.

Specs are validated before deployment. Invalid specs return 422 with error details. Incompatible changes (removing types, changing required fields) are rejected unless `"force": true` is passed (non-prod environments only).

This is the JWT path on the console API. To deploy from CI with just an API key — no JWT, same host as your reads and writes — use a `deploy`-scoped key against `POST /_spec` on your instance. See [Schema Deployment](/docs/api/schema-deployment).

### Get current spec

The current spec is available through the dashboard UI.

## API key management

All key management is on the console API.

### List keys

```bash
GET /api/instances/:instance_id/keys
Authorization: Bearer $JWT
```

### Create key

```bash
POST /api/instances/:instance_id/keys
Authorization: Bearer $JWT

{
  "name": "Production Backend",
  "scope": "write",
  "environment": "prod"
}
```

Response includes the key (shown once):

```json
{
  "id": "key-uuid",
  "name": "Production Backend",
  "key": "j17_0_prod_xyz789...",
  "scope": "write",
  "environment": "prod",
  "created_at": "2024-01-15T10:00:00Z"
}
```

### Rotate key

```bash
POST /api/keys/:id/rotate
Authorization: Bearer $JWT
```

Creates a new key and revokes the old one.

### Schedule revocation

```bash
POST /api/keys/:id/schedule_revocation
Authorization: Bearer $JWT

{
  "revoke_at": "2024-02-01T00:00:00Z"
}
```

### Revoke key

```bash
DELETE /api/keys/:id
Authorization: Bearer $JWT
```

Revoked keys fail immediately on next use.

## Instance operations

These endpoints are all under `/api/instances/:id/ops/:environment/`.

### Checkpoints

Checkpoints snapshot aggregate state for faster replay.

| Method | Endpoint | Description |
|--------|----------|-------------|
| `GET` | `/ops/:env/checkpoints` | List checkpoints |
| `POST` | `/ops/:env/checkpoints` | Create checkpoint |
| `POST` | `/ops/:env/checkpoints/:checkpoint_id/restore` | Restore checkpoint |
| `DELETE` | `/ops/:env/checkpoints/:checkpoint_id` | Delete checkpoint |

Root anchors capture Merkle roots for cryptographic verification:

| Method | Endpoint | Description |
|--------|----------|-------------|
| `GET` | `/ops/:env/root_anchors` | List root anchors |
| `POST` | `/ops/:env/root_anchors` | Create root anchor |
| `DELETE` | `/ops/:env/root_anchors/:id` | Delete root anchor |
| `PUT` | `/ops/:env/anchor_schedule` | Update auto-anchor schedule |

### Backups

| Method | Endpoint | Description |
|--------|----------|-------------|
| `GET` | `/ops/:env/backups` | List backups |
| `POST` | `/ops/:env/backups` | Create backup |
| `DELETE` | `/ops/:env/backups/:backup_id` | Delete backup |

Backup settings (offsite S3 configuration):

| Method | Endpoint | Description |
|--------|----------|-------------|
| `GET` | `/api/instances/:id/backup_settings` | Get backup settings |
| `PUT` | `/api/instances/:id/backup_settings` | Update backup settings |

### Blobs

Binary data storage (e.g., WASM modules, config files).

| Method | Endpoint | Description |
|--------|----------|-------------|
| `GET` | `/ops/:env/blobs` | List blobs |
| `DELETE` | `/ops/:env/blobs/:name` | Delete blob |

### Scheduled events

| Method | Endpoint | Description |
|--------|----------|-------------|
| `POST` | `/ops/:env/scheduled/:event_id/cancel` | Cancel scheduled event |
| `POST` | `/ops/:env/scheduled/:event_id/retry` | Retry failed event |

### Sagas

| Method | Endpoint | Description |
|--------|----------|-------------|
| `POST` | `/ops/:env/sagas/:saga_id/retry` | Retry failed saga |

### Tombstones (GDPR erasure)

Tombstone operations are managed through the dashboard. See the [tombstones reference](/docs/reference/tombstones) for how tombstoning works and how to configure `onTombstone` cascade rules in your spec.

### Listener deliveries (webhooks)

Listeners deliver events to HTTP endpoints with HMAC-SHA256 signed payloads. Failed deliveries retry with exponential backoff (5s, 25s, 125s) up to 3 attempts. Delivery history is viewable in the dashboard.

Listeners are configured in your spec, not via API. The delivery system handles:
- Automatic retry with exponential backoff
- HMAC-SHA256 payload signing (`X-J17-Signature` header)
- Delivery cleanup (delivered > 7 days, failed > 30 days)

### Audit

Cryptographic verification of event integrity, on your instance with API key auth:

| Method | Endpoint | Description |
|--------|----------|-------------|
| `GET` | `/:type/:id/audit/merkle-root` | Get Merkle root for aggregate |
| `GET` | `/:type/:id/audit/merkle-proof/:index` | Get Merkle proof for event |
| `GET` | `/:type/:id/audit/verify-chain` | Verify hash chain integrity |

### Data loading

| Method | Endpoint | Description |
|--------|----------|-------------|
| `POST` | `/ops/:env/inject` | Inject test data (staging/test only) |
| `POST` | `/ops/:env/import` | Import historical events (`mode=append` or `mode=replace`; `replace` is non-prod only) |
| `POST` | `/ops/:env/cold_start` | Seed a fresh environment once, refused if it already holds data |

`import` changes data at any time (append, or `replace` to wipe first);
`cold_start` seeds an **empty** environment exactly once and then latches it —
a second call returns 409. Both take `{ "events": [...] }`. Use `cold_start` to
provision an environment from an export, `import` to change data that already
exists. The same "seed a fresh environment" verb is available to a `deploy`-scoped
API key (no console login) — see [Schema Deployment](/docs/api/schema-deployment#seed-a-fresh-environment-cold-start).

### Data reset

Wipe all events, aggregates, projections, sagas, and scheduled events for an environment. Restricted to `staging` and `test` — calling this on `prod` returns 403.

```bash
POST /api/instances/:id/ops/:environment/reset
Authorization: Bearer $JWT
```

No request body. Returns `{ "ok": true }` on success. Requires the `instance_admin` role.

This is destructive and irreversible — there is no undo. Use it to recycle non-prod environments between test runs or after spec changes that aren't backwards-compatible with existing event data.

### Error lookup

```bash
GET /api/instances/:id/ops/:environment/errors/:error_id
Authorization: Bearer $JWT
```

Returns details for a specific error, including the full error context and stack trace.

## Usage and billing

Usage and billing endpoints are on the console API.

| Method | Endpoint | Description |
|--------|----------|-------------|
| `GET` | `/api/instances/:id` | Instance details including plan/tier |

Usage metrics are tracked per instance.

## Projections

| Method | Endpoint | Description |
|--------|----------|-------------|
| `POST` | `/api/instances/:id/projections` | Deploy a projection definition |
| `GET` | `/api/instances/:id/projections` | List configured projections |
| `DELETE` | `/api/instances/:id/projections/:name` | Remove a projection |

The deploy body wraps the definition:

```json
{
  "name": "order_summary",
  "environment": "prod",
  "projection": {
    "for_each": "order",
    "sources": {"order": "$key"},
    "select": {"total": "$.order.total"}
  }
}
```

See the [projections guide](/docs/guides/projections) for full details on sources, select transforms, and SQL queries.

These are the JWT endpoints on the console API. To deploy or delete projections with a `deploy`-scoped API key on your instance, see [Schema Deployment](/docs/api/schema-deployment).

## See also

- [Authentication](/docs/api/authentication) - JWT and API keys
- [Writing events](/docs/api/writing-events) - Instance data API
- [Reading aggregates](/docs/api/reading-aggregates) - GET aggregates

# Schema Deployment

Deploy your spec and projections with a `deploy`-scoped API key — same host, same bearer token as reads and writes. No JWT, no separate console login. This is the self-serve path built for CI: push `spec.json` and your projection definitions with just `J17_API_KEY`.

The [Admin API](/docs/api/admin-api) still offers a JWT-based path on the console API. Use whichever fits — the deploy key collapses everything to one host and one credential.

## Deploy keys

A `deploy` key is a fourth [scope](/docs/api/authentication#key-scopes), alongside `read`, `write`, and `admin`:

| Scope | Can deploy schema? | Can read/write data? |
|-------|--------------------|----------------------|
| `read` | No | Read only |
| `write` | No | Read + write |
| `deploy` | Yes | No |
| `admin` | Yes | Read + write |

`deploy` is deliberately orthogonal to `write`: a key that posts events cannot redefine the schema those events are validated against, and a deploy key cannot write into live event streams. It does own environment *provisioning* — seeding a fresh environment ([cold start](#seed-a-fresh-environment-cold-start)) and [resetting](#reset-a-non-production-environment) one — but only on `staging` and `test`, never production. Use a dedicated deploy key in CI; use `admin` only when one credential genuinely needs both.

Create one from the dashboard (Instance settings → API Keys → scope "Deploy"), or via the console API:

```bash
curl -X POST https://console.j17.dev/api/instances/$INSTANCE_ID/keys \
  -H "Authorization: Bearer $JWT_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "CI Deploy",
    "scope": "deploy",
    "environment": "test"
  }'
```

Deploy keys are environment-scoped like any other key. A `test` key only works on `*-test.j17.dev` and can only ever deploy the test environment — the environment is fixed by the host you call, not by the request body, so a test key cannot touch production.

## Deploy a spec

```bash
POST /_spec
Authorization: Bearer $DEPLOY_KEY
Content-Type: application/json

{
  "spec": {
    "aggregate_types": { ... },
    "agent_types": [ ... ]
  }
}
```

```bash
curl -X POST https://myapp-test.j17.dev/_spec \
  -H "Authorization: Bearer $DEPLOY_KEY" \
  -H "Content-Type: application/json" \
  -d @spec.json
```

The spec goes inside the `"spec"` key — the same envelope the dashboard upload and the JWT endpoint expect. The environment comes from the host, not the body.

Response:

```json
{ "ok": true, "environment": "test" }
```

The deploy takes effect immediately: your next read or write on that host sees the new spec. It reaches that host as a notification from the control plane, and if one is ever lost the host re-checks on its own within five minutes — so the worst case is a delay, not a deploy that silently never applies.

### Warnings

A successful deploy (and a successful validate) may carry a `warnings` array — advisory, never blocking:

```json
{
  "ok": true,
  "environment": "test",
  "warnings": [
    "aggregate type 'trip' appends to state in every event type (26 of 26). Its cached state grows with its stream, and keeping it current costs ~2x the state size in Redis traffic on every write. If it is written more often than it is read, list it in modules.never_cache."
  ]
}
```

Currently the one warning is the append-everywhere shape: an aggregate type whose every event appends to state grows without bound, and its cache entry costs more to maintain than it saves unless the type is read at least as often as it is written. Listing the type in [`modules.never_cache`](/docs/guides/caching) resolves it — reads replay the stream instead, which for this shape transfers roughly the same bytes anyway. The field is absent when there is nothing to say.

### Validate without deploying

`POST /_spec/validate` checks a spec and writes nothing:

```bash
curl -X POST https://myapp-test.j17.dev/_spec/validate \
  -H "Authorization: Bearer $DEPLOY_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "spec": { ... } }'
```

```json
{ "ok": true, "environment": "test", "valid": true }
```

It runs exactly what a real deploy runs — structural validation and the
backward-compatibility check against the environment's current spec — and returns the
identical error body on failure. Nothing is written, and your deployed spec and its cache
are untouched.

A separate endpoint rather than a flag on `POST /_spec`, so that no spelling of a
parameter can turn a check into a deploy.

This exists because validation otherwise happens only on the deploy path, which makes a
spec that cannot ship the one class of error your test suite cannot see: everything
passes locally and the release fails. Wire it into a precommit hook or CI step against a
non-production environment and the failure moves to where you can fix it cheaply.

### Incompatible changes and `force`

Specs are validated before deployment, and backward-incompatible changes (removing an aggregate or event type, tightening a required field) are rejected. Pass `"force": true` to waive the compatibility check:

```json
{ "spec": { ... }, "force": true }
```

`force` is honored on **non-production** environments only. On production it is ignored — a prod spec can never be clobbered by a forced deploy.

## Deploy a projection

The projection name is in the URL; the definition is the body.

```bash
POST /_projections/:name
Authorization: Bearer $DEPLOY_KEY
Content-Type: application/json

{
  "projection": {
    "for_each": "order",
    "sources": { "order": "$key" },
    "select": { "total": "$.order.total" }
  }
}
```

```bash
curl -X POST https://myapp-test.j17.dev/_projections/order_summary \
  -H "Authorization: Bearer $DEPLOY_KEY" \
  -H "Content-Type: application/json" \
  -d '{"projection": {"for_each": "order", "sources": {"order": "$key"}, "select": {"total": "$.order.total"}}}'
```

Response:

```json
{ "ok": true, "environment": "test", "name": "order_summary" }
```

Projections are validated against the deployed spec, so deploy the spec first. See the [projections guide](/docs/guides/projections) for sources, select transforms, and SQL queries.

The name `query` can't be used for a projection — `/_projections/query` is the projection SQL endpoint.

## Delete a projection

```bash
DELETE /_projections/:name
Authorization: Bearer $DEPLOY_KEY
```

```bash
curl -X DELETE https://myapp-test.j17.dev/_projections/order_summary \
  -H "Authorization: Bearer $DEPLOY_KEY"
```

Response:

```json
{ "ok": true, "environment": "test", "name": "order_summary" }
```

Deleting a projection that doesn't exist returns 404 `projection_not_found`.

## Seed a fresh environment (cold start)

A deploy key can also **seed** an environment — write a batch of starting events
into an environment that has no data yet — with one call. This is how you
provision a non-production environment from an export without a console login or
a human in the loop.

```bash
POST /_cold_start
Authorization: Bearer $DEPLOY_KEY
Content-Type: application/json

{ "events": [ … ] }
```

```bash
curl -X POST https://myapp-test.j17.dev/_cold_start \
  -H "Authorization: Bearer $DEPLOY_KEY" \
  -H "Content-Type: application/json" \
  -d @events.json
```

Response:

```json
{
  "ok": true,
  "environment": "test",
  "events_written": 128,
  "aggregates": 40,
  "consistency": {
    "estimated_seconds": 2,
    "note": "Projections and read caches materialize in the background. Queries against projection tables may be incomplete for about 2s after this call."
  }
}
```

Each event is a `{ "key", "type", "data", "metadata" }` object — the same wire
shape you get from an export, so an exported event stream feeds straight back in.
Deploy the spec first: the events are validated against it, and the whole batch
is rejected if any event fails (no partial seed).

**Materialization is asynchronous.** The events land synchronously, but
projection tables and read caches are built by a background sweeper. The
response's `consistency.estimated_seconds` is a rough guide to how long a query
against a projection table may return partial results — scaled to the number of
aggregates seeded. For a large seed the field also carries a `warning`; wait that
long (or poll a projection count until it settles) before treating the
environment as ready. This matters most right after a `_reset` + `_cold_start`
cycle, when every projection table starts empty.

Cold start is **once-only and non-production-only**:

- It writes only into an **empty** environment. If the environment already holds
  data it is refused with 409 — cold start never overwrites. To re-seed, reset
  first (below), then cold start again.
- With a deploy key it is limited to `staging` and `test`; production returns
  403. This mirrors reset: a deploy key can seed or recycle a non-prod
  environment, but never touch prod data. (An operator with a console login can
  cold-start any environment — see the [Admin API](/docs/api/admin-api#data-loading).)

### Reset a non-production environment

`POST /_reset` wipes an environment's event data and projections (the deployed
spec is kept), so a deploy key can recycle a non-prod environment between runs:

```bash
curl -X POST https://myapp-test.j17.dev/_reset \
  -H "Authorization: Bearer $DEPLOY_KEY"
```

Like cold start, it is `staging`/`test` only — production returns 403. The pair
gives CI a full provision cycle: `_reset` to clear, `_cold_start` to seed.

## Errors

| Status | Body | Meaning |
|--------|------|---------|
| 403 | `{"ok": false, "error": "Insufficient permissions"}` | Key lacks `deploy` scope |
| 403 | `{"ok": false, "error": "cold_start with a deploy key is limited to non-production environments"}` | `_cold_start` on prod |
| 404 | `{"ok": false, "error": "instance_not_found"}` | No such instance |
| 404 | `{"ok": false, "error": "projection_not_found"}` | Deleting an absent projection |
| 409 | `{"ok": false, "error": "Environment already initialized"}` | `_cold_start` on an environment that already holds data |
| 422 | `{"ok": false, "error": "validation_failed", "details": [...]}` | Spec or projection rejected — `details` explains why |
| 422 | `{"ok": false, "error": "No spec deployed for this environment"}` | `_cold_start` before a spec is deployed |
| 502 | `{"ok": false, "error": "deploy_unavailable"}` | j17 couldn't record the deploy; retry |

Validation failures return structured `details`, so a bad spec tells you exactly what's wrong — the surface a CI job can act on.

## A CI deploy

```bash
#!/usr/bin/env bash
set -euo pipefail

host="https://myapp-test.j17.dev"

# Check before you change anything: a rejected spec fails here rather than
# halfway through the deploy.
curl -fsS -X POST "$host/_spec/validate" \
  -H "Authorization: Bearer $J17_API_KEY" \
  -H "Content-Type: application/json" \
  -d "{\"spec\": $(cat j17/spec.json)}"

# Spec first — projections validate against it.
curl -fsS -X POST "$host/_spec" \
  -H "Authorization: Bearer $J17_API_KEY" \
  -H "Content-Type: application/json" \
  -d "{\"spec\": $(cat j17/spec.json)}"

# Then each projection.
for f in j17/projections/*.json; do
  name=$(basename "$f" .json)
  curl -fsS -X POST "$host/_projections/$name" \
    -H "Authorization: Bearer $J17_API_KEY" \
    -H "Content-Type: application/json" \
    -d "{\"projection\": $(cat "$f")}"
done
```

## See also

- [Authentication](/docs/api/authentication) - Key scopes and environments
- [Admin API](/docs/api/admin-api) - The JWT deploy path on the console API
- [Projections](/docs/guides/projections) - Defining projections
- [Spec reference](/docs/reference/spec) - Spec structure

# Audit & Attestation

Every event you write to j17 is hash-chained: each event's SHA-256 hash includes the hash before it, per stream, so any tampering breaks the chain from that point forward. On top of the chains sit Merkle trees — trees of hashes whose single root commits to every leaf — with per-stream roots, an instance root over your stream roots, and a fleet-wide platform root. The instance and platform roots are **Ed25519-signed**. These endpoints let you retrieve proofs and signed anchors so you can verify your own data offline, with the `j17-verify` CLI or ~50 lines of any language's standard crypto library.

The chain a proof walks:

```
event
  │ inclusion proof
  ▼
stream root
  │ streams map
  ▼
instance root ── Ed25519 signature (check against your pinned key)
  │ platform proof
  ▼
platform root ── OTS / RFC 3161 external timestamp
```

All endpoints below use your instance host and API key. Per-stream proofs and anchors need `read` scope; creating an anchor on demand needs `write`.

## Per-stream proofs

Prove a single event is in a stream without exposing the rest of the stream.

```bash
GET /{aggregate_type}/{aggregate_id}/audit/merkle-root
GET /{aggregate_type}/{aggregate_id}/audit/merkle-proof/{index}
GET /{aggregate_type}/{aggregate_id}/audit/verify-chain
```

The index is the event's 0-based position in the stream — the same order `/events` returns.

```bash
curl https://myapp.j17.dev/order/ord_123/audit/merkle-proof/2 \
  -H "Authorization: Bearer $API_KEY"
```

```json
{
  "ok": true,
  "root": "9f2c…",
  "event_hash": "1a2b…",
  "siblings": ["c3d4…", "e5f6…"],
  "directions": ["right", "left"],
  "index": 2,
  "event_count": 7
}
```

To check it: leaf-hash `event_hash` (`SHA-256(0x00 || bytes)`), then hash your way up — combine with each sibling on the given side as `SHA-256(0x01 || left || right)` — and compare the result to `root`. The `0x00`/`0x01` prefixes are RFC 6962 domain separation, present since audit format 2 (platform 0.16.3); responses carry `"format": 2`, and artifacts without a `format` field are format 1 (no prefixes, no leaf-hash step). Two further mechanics matter if you're implementing this yourself: hashes combine over the **raw bytes** (decode the hex before concatenating), and when a tree level has an odd count, the last node is duplicated. The complete normative rules — including the canonical signed anchor payload that binds an anchor's instance, environment, and timestamp — are in the [audit format specification](/docs/reference/audit-format), written to be implementable without reference to j17's source. `event_hash` itself is the event's chain hash — SHA-256 over the previous event's hash concatenated with the event's canonical JSON (a stream's first event has no predecessor, so its hash is SHA-256 of the event JSON alone) — so it can be recomputed from exported event data, not just taken on faith. [`GET /{type}/{id}/events?raw=true`](/docs/api/reading-aggregates) supplies both ingredients per event: the chain hash and the stored bytes it was computed over.

`merkle-root` returns the stream's current root and event count; `verify-chain` re-walks the stream's hash chain server-side and reports whether it's intact.

A per-stream root proves inclusion, but on its own it's unsigned. To make it attestable, chain it to a **signed root anchor**.

## Root anchors

A root anchor is a signed snapshot of your entire environment: every stream's Merkle root, combined into one instance-level root, signed with your instance's Ed25519 key.

```bash
GET /_audit/root-anchors            # all anchors, newest first
GET /_audit/root-anchors/latest     # the most recent one
GET /_audit/root-anchors/{id}       # a specific one
```

```bash
curl https://myapp.j17.dev/_audit/root-anchors/latest \
  -H "Authorization: Bearer $API_KEY"
```

```json
{
  "ok": true,
  "root_anchor": {
    "id": "8e86…",
    "environment": "prod",
    "merkle_root": "c005…",
    "signature": "a5aa…",
    "public_key": "a5fe…",
    "event_count": 41230,
    "stream_count": 318,
    "streams": {
      "order:ord_123": { "merkle_root": "9f2c…", "event_count": 7 }
    },
    "created_at": "2026-07-27T21:55:17Z"
  }
}
```

This is exactly the shape `j17-verify` consumes as `verification.json`. To verify one event end to end:

1. Check the event's inclusion proof against its stream root.
2. Find that stream root in the anchor's `streams` map.
3. Rebuild the instance root: take the stream roots **sorted by stream key** and build the Merkle tree — it must equal `merkle_root`.
4. Verify `signature` with `public_key` (Ed25519). Format 2 anchors sign the [canonical anchor payload](/docs/reference/audit-format) — rebuilt from the anchor's own fields, so its instance, environment, counts, and `created_at` are all bound; substituting any of them fails. (Format 1 anchors, minted before 0.16.3, signed only the bare `merkle_root` string.)

Anchors are created on a schedule you control from the dashboard (your instance's Snapshots tab) — and on demand:

## Anchoring on demand

```bash
POST /_audit/root-anchors
```

```bash
curl -X POST https://myapp.j17.dev/_audit/root-anchors \
  -H "Authorization: Bearer $API_KEY"
```

Returns `201` with the new anchor. Mint one right after a critical batch, before an export, or on your own compliance cadence — then submit the anchor's `merkle_root` to [OpenTimestamps](https://opentimestamps.org) or any RFC 3161 timestamping authority yourself. That gives you third-party-verifiable timestamps at whatever frequency you need, without waiting on the platform schedule.

Anchor creation scans every stream in the environment, so it's guarded: one creation at a time per environment (`409` while one is running), a short cooldown between anchors (`429` with a `Retry-After` header), and a `413` past 100,000 streams. `422` means there are no events to anchor yet.

## Your signing key

```bash
GET /_audit/signing-key
```

```json
{
  "ok": true,
  "public_key": "a5fe…",
  "history": [
    { "event": "was_created", "public_key": "a5fe…", "backfilled": 0, "created_at": "2026-07-01T09:12:44Z" }
  ]
}
```

Fetch this once, **pin it**, and check every anchor's `public_key` against it. The endpoint works before your first anchor exists — the key is created on first read — so you can pin before you have anything to verify.

Third parties don't need your API key to check the pin: your instance also publishes its keys **unauthenticated** at `GET /.well-known/j17-attestation` (instance key + platform key, each with history). Point a verifier there — keys they fetch from your instance's own address never depend on the evidence bundle they're checking. The public endpoint is strictly read-only and returns `404` until the authenticated read above has created the key.

The `history` list is the key's lifecycle, recorded as events in the same tamper-evident log the key protects: a key that changes is a key that left a record. A `backfilled: 1` entry means the record was reconstructed after the fact; its timestamp reads "this key existed since at least then," not the key's birth time.

The key never changes on its own. A damaged key file halts anchor creation rather than silently generating a replacement identity — a signature that can be quietly re-keyed isn't worth verifying.

## Platform proofs

Daily, j17 combines the latest production anchor from every instance into one fleet-wide platform root, signs it, and submits it for external timestamping (OpenTimestamps and RFC 3161). Your instance can prove its inclusion:

```bash
GET /_audit/platform-proof
```

```json
{
  "ok": true,
  "platform_anchor": {
    "merkle_root": "77aa…",
    "signature": "31c9…",
    "public_key": "0be2…",
    "instance_count": 214,
    "cutoff_at": "2026-07-27T00:00:12Z",
    "ots_proof": "AE9w…",
    "ots_status": "submitted",
    "rfc3161_response": "MIIC…",
    "rfc3161_status": "submitted",
    "created_at": "2026-07-27T00:00:42Z"
  },
  "instance_root": "c005…",
  "proof": { "index": 12, "siblings": ["4d1e…"], "directions": ["right"] }
}
```

Verify `proof` from your `instance_root` up to the platform `merkle_root`, then the platform signature — and you've chained a single event in your instance to an externally timestamped, fleet-wide root. The response contains nothing about other tenants: a Merkle proof needs only sibling hashes, so that's all you get.

### Per-event attestation bundles

`GET /{type}/{id}/audit/attestation/{index}?anchor={anchor_id}` assembles the whole story for one event in one round trip: the event's stored bytes and chain hash, its proof into the stream root, the stream root's proof into the signed instance anchor, and the anchor itself — redacted to exactly the fields its signature covers, never the environment-wide streams map. A few kilobytes, verifiable offline, safe to hand to a third party. Pin `?anchor=` when attesting a batch so every document proves against the same anchor; the [format spec §6](/docs/reference/audit-format) defines the shape, the verification recipe, and the error contract (notably `beyond_anchor_coverage`, which means "wait for the next anchor", not "failure").

`ots_status` moves `pending → submitted → confirmed`: a fresh submission is a calendar receipt, and within a day it's upgraded automatically to a complete `.ots` file (base64 in `ots_proof`) that the standard OpenTimestamps client verifies against Bitcoin as-is. For court-ready evidence, use a `confirmed` anchor. RFC 3161 tokens are complete at issuance — `submitted` is final there.

Platform anchors cover **production** anchors only. Before your first daily inclusion — or if you have no production anchors at all — this returns `404`. For test and staging evidence, anchor on demand and timestamp the roots yourself.

## Verifying offline

Everything above can be verified with no j17 access at all. You need three things:

1. **Your events** — [`GET /{type}/{id}/events`](/docs/api/reading-aggregates) for a stream, or a verifiable export (a backup bundle that includes every event with its chain hash, plus a `verification.json` in the root-anchor shape shown above).
2. **A signed anchor** — from `/_audit/root-anchors/latest`, stored wherever you keep evidence.
3. **Your pinned public key** — from `/_audit/signing-key`, fetched once over a channel you trust.

Then it's math anyone can re-run: recompute the hash chain, rebuild the Merkle roots (domain-separated per the [format spec](/docs/reference/audit-format), odd counts duplicate the last node, stream roots sorted by stream key), and check the Ed25519 signature over the canonical anchor payload. The `j17-verify` CLI does all three against an export directory in one command; the same checks are a short afternoon in any language with SHA-256 and Ed25519 in the standard library.

## See also

- [Reading Aggregates](/docs/api/reading-aggregates) — the `/events` endpoint that returns a stream's raw events
- [Authentication](/docs/api/authentication) — key scopes and environment locking
- [Schema Deployment](/docs/api/schema-deployment) — the other self-serve key scope, for shipping your spec from CI

---


# Projections

Projections are read-only views that combine data from multiple aggregates into a single response. Your frontend needs an order with customer name, product details, and shipping info -- that's four aggregates. Without projections, you either make 4 API calls or build a custom endpoint. Projections solve this: define the shape you need in JSON, and the platform composes it for you.

## When to use projections

**Good for:**
- Dashboards combining multiple data sources
- API responses needing denormalized data
- Complex queries that would need multiple round-trips

**Not for:**
- Simple single-aggregate reads (use GET directly)
- Write operations (projections are read-only)

## Defining projections

Projections are deployed via the admin API. Define them in JSON:

```json
{
  "name": "order_with_details",
  "for_each": "order",
  "sources": {
    "order": "$key",
    "customer": {"type": "customer", "id": "$.order.customer_id"},
    "products": {"type": "product", "id": "$.order.line_items[*].product_id", "mode": "array"}
  },
  "include": {
    "pricing": "pricing_rules:global"
  },
  "select": {
    "order_id": "$.order.id",
    "total": "$.order.total",
    "customer_name": "$.customer.name",
    "tax_rate": "$.pricing.default_tax_rate",
    "items": {
      "$map": "$.order.line_items",
      "as": "$item",
      "to": {
        "product_id": "$.item.product_id",
        "product_name": {
          "$lookup": "$.products",
          "where": {"id": "$.item.product_id"},
          "select": "$.name"
        }
      }
    }
  }
}
```

### Fields

| Field | Required | Purpose |
|-------|----------|---------|
| `name` | Yes | Projection identifier (lowercase, alphanumeric with underscores) |
| `for_each` | Yes | Trigger aggregate type -- one projection instance per aggregate of this type (bare streams only by default) |
| `include_substreams` | No | If `true`, also include tagged UUID variants (e.g., `ledger:uuid:2025`). Default: `false`. |
| `sources` | Yes | Aggregates derived from the trigger's state tree |
| `include` | No | Global singletons via `type:global` syntax |
| `fan_out` | No | Emit one row per element of a collection instead of one row per aggregate. See [Fan-out projections](#fan-out-projections). |
| `select` | Yes | Output shape with JSONPath expressions or typed column objects |

These seven are the whole vocabulary. A definition carrying any other top-level field is
rejected at deploy, naming the field — a definition that deployed and quietly ignored
half of what it asked for would still rebuild the table and report success.

## Sources

Sources define which aggregates to fetch. The first source can use `"$key"` shorthand to reference the trigger aggregate:

```json
"sources": {
  "order": "$key",
  "customer": {"type": "customer", "id": "$.order.customer_id"}
}
```

### Source options

| Option | Default | Description |
|--------|---------|-------------|
| `type` | Required | Aggregate type to fetch |
| `id` | Required | ID expression (`$key`, literal, or JSONPath) |
| `mode` | `"single"` | `"single"` or `"array"` for fetching multiple |

### Array mode

Use `mode: "array"` when the ID path returns multiple values:

```json
"products": {
  "type": "product",
  "id": "$.order.line_items[*].product_id",
  "mode": "array"
}
```

This fetches all products referenced by the order's line items.

## Include

The `include` field is for global singleton aggregates that are not in the trigger's state tree:

```json
"include": {
  "config": "settings:global",
  "pricing": "pricing_rules:global"
}
```

`include` is restricted to `:global` IDs to guide users toward proper patterns. For non-global aggregates, use `sources` with appropriate ID resolution.

## Select expressions

The `select` field defines the output shape using JSONPath expressions.

### Paths in `select` are not handler paths

This is the one thing to get right. A `select` path is evaluated as plain **JSONPath** against the resolved `sources` and `include` bindings. It is *not* the path language used by handlers, implications, and sagas, even though the two look identical and live in the same spec file.

The practical differences:

| | Handlers / implications / sagas | Projection `select` |
|---|---|---|
| `$.foo.bar` | Resolved | Resolved |
| `$.foo.bar?` (optional suffix) | Optional — operation becomes a no-op when absent | **Rejected at deploy time** |
| `@.*` (state prefix) | Current aggregate state | Not available — name the source instead (`$.order.status`) |
| `$.data.*`, `$.metadata.*` | Event payload and metadata | Not available — projections read aggregate *state*, not events |
| Path resolves to nothing | Error, or no-op with `?` | Column is `null`, row is still emitted |

The `?` optional suffix is a handler feature. It is not valid JSONPath, so a projection path ending in `?` never resolves — the column would read `null` for every row, including rows where the field is present. Deploying such a path is rejected:

```
projection 'order_summary', column 'memo': '?' is not valid in a projection path.
Optional paths are a handler feature; see /docs/guides/projections#paths-in-select-are-not-handler-paths
```

Any `select` path that fails to parse as JSONPath is rejected the same way. Before this check existed, a malformed path silently produced a permanently-null column.

To project a field that only some events set, default it in the handler and then overwrite it, so the field always exists:

```json
[
  { "set": { "target": "memo", "value": "" } },
  { "set": { "target": "memo", "value": "$.data.memo?" } }
]
```

Then use a plain path in the projection: `"memo": "$.order.memo"`.

### Missing paths produce null, not a missing row

A column whose path doesn't resolve is `null`. The row is still emitted and the other columns are unaffected.

This makes schema evolution cheap: **adding a column for a field only newer events set is safe and needs no backfill.** Aggregates that predate the field read `null`; everything else is unaffected. Conversely, it means a null column is not evidence that a row failed — see [Consistency](#consistency) before concluding a projection is broken.

### Basic paths

Reference any source or include binding:

```json
"select": {
  "customer_name": "$.customer.name",
  "order_total": "$.order.total"
}
```

### $map transform

Map over arrays to transform each element:

```json
"items": {
  "$map": "$.order.line_items",
  "as": "$item",
  "to": {
    "name": "$.item.name",
    "qty": "$.item.quantity"
  }
}
```

`as` names the binding with a `$` prefix (`"$item"`), but paths that *read* it are ordinary JSONPath against the bindings map — `"$.item.name"`, with the dot. The dotless `"$item.name"` is rejected at deploy time, because it is not a path and would otherwise be stored as the literal text `"$item.name"`.

### $lookup transform

Find and extract from arrays:

```json
"product_name": {
  "$lookup": "$.products",
  "where": {"id": "$.item.product_id"},
  "select": "$.name"
}
```

`where` values are resolved the same way, so a binding reference needs the dot form. A literal comparison value is written plainly: `"where": {"role": "lead"}`.

Both `$lookup` and typed path entries accept an optional `"type"` annotation for SQL materialization:

```json
"price": {
  "$lookup": "$.products",
  "where": {"id": "$.item.product_id"},
  "select": "$.price",
  "type": "real"
}
```

## Fan-out projections

By default a projection emits exactly one row per `for_each` aggregate. `fan_out` emits **one row per element of a collection** on that aggregate instead — the standard read model for a many-to-many relationship.

The case it exists for: an `org` aggregate holds `members` as a map of `user_id → role`. The membership rules belong on the org, because deciding whether an actor may change a member's role means comparing ranks across the whole set — a single-aggregate invariant. But the common query runs the other way: *which orgs is this user in?* Without fan-out that's a full scan of every org.

```json
{
  "name": "org_members",
  "for_each": "org",
  "sources": { "org": "$key" },
  "fan_out": { "path": "$.org.members", "as": "$m" },
  "select": {
    "org_id":   { "path": "$id",        "type": "text" },
    "org_name": { "path": "$.org.name", "type": "text" },
    "user_id":  { "path": "$.m.key",   "type": "text" },
    "role":     { "path": "$.m.value", "type": "text" }
  }
}
```

```sql
SELECT org_id, org_name, role FROM org_members
WHERE user_id = ?1 AND role IS NOT NULL
```

One query over one narrow table, instead of folding every org to find the ones a user belongs to. Note that `user_id` itself is not indexed — see [What is indexed](#what-is-indexed).

### Fan-out fields

| Field | Required | Purpose |
|-------|----------|---------|
| `path` | Yes | JSONPath to the collection. Must resolve to an object or an array. |
| `as` | Yes | Binding name for the current element, `$`-prefixed. |

The binding exposes two fields, whether the collection is a map or an array:

| Binding | Map source | Array source |
|---------|-----------|--------------|
| `$.m.key` | The map key | The array index, as a string (`"0"`, `"1"`, …) |
| `$.m.value` | The value at that key | The element |

Use `$.m.value.field` to reach into an element that is itself an object. Paths outside the binding (`$.org.name`, `$key`) resolve normally and repeat on every row.

Note the dot: `as` names the binding `"$m"`, but paths that *read* it are ordinary JSONPath against the bindings — `$.m.key`, not `$m.key`. The dotless form is rejected at deploy.

### Aggregate identity in `select`

Three bare paths are available alongside ordinary JSONPath, matching the vocabulary handlers and sagas use:

| Path | Resolves to | Example |
|------|-------------|---------|
| `$key` | The full aggregate key | `org:a1b2c3…` |
| `$id` | The bare aggregate ID — the same value as the automatic `_key_id` column | `a1b2c3…` |
| `$fan_key` | The current fan-out element's key; `null` without `fan_out` | `alice` |

Anything else beginning with `$` followed by a letter or underscore is a mistyped binding reference and is rejected — write `$.m.key`, not `$m.key`.

### Row identity

A fan-out projection's rows are identified by `(_key_id, _fan_key)`, not by `_key_id` alone. Both are present as columns:

| Column | Contents |
|--------|----------|
| `_key_id` | The `for_each` aggregate ID — repeated across every row from that aggregate |
| `_fan_key` | The map key or array index for this row |

`_fan_key` is only present on fan-out projections. Everything that assumes one row per `_key_id` has to account for this:

- **`GET /_projections/:name/:id`** returns a JSON **array** of rows for a fan-out projection, not a single object. The array is ordered by `_fan_key`, and is empty when the aggregate's collection is empty.
- **CSV export** emits `_key_id`, `_fan_key`, then the `select` columns alphabetically.
- **SQL queries** need `WHERE _key_id = ?1 AND _fan_key = ?2` to address a single row.

### Removal and tombstones

When an aggregate changes, **every row for that `_key_id` is deleted and rebuilt** from the current collection. Elements that disappeared from the collection therefore disappear from the table. There is no incremental path that could leave a stale row behind.

Entries whose value is `null` are still emitted as rows, with `null` in the columns that read from them. This is deliberate: the common map-patch idiom keeps a removed key with a `null` value rather than deleting it, so the history stays legible, and skipping those rows would make the two representations of "removed" behave differently. Filter them in SQL:

```sql
WHERE role IS NOT NULL
```

If you want them gone from the table entirely, remove the key from the aggregate (`remove_at`) rather than nulling it.

### Empty collections produce no rows

An aggregate whose collection is empty, missing, or not a collection contributes **zero rows**. A fan-out projection therefore cannot double as "the list of all orgs" — an org with no members is absent from `org_members` entirely. Keep a separate non-fan-out projection when you need the aggregate list.

### Limits

- One `fan_out` per projection. Nested fan-out (a row per element of a collection inside a collection) is not supported; use `$map` to nest the inner collection into a `json` column.
- `fan_out` and `$map` are unrelated. `$map` transforms a collection into a JSON value inside **one** column of **one** row; `fan_out` changes how many rows the projection has.
- The 100,000-row CSV export limit and the 10,000-row SQL query cap count fanned-out rows, not aggregates.

## API endpoints

### Admin (console API, JWT authentication)

```
POST   /api/instances/:id/projections        # Deploy definition
GET    /api/instances/:id/projections        # List all
DELETE /api/instances/:id/projections/:name  # Remove
```

The deploy body wraps the definition under a `projection` key:

```json
{
  "name": "order_summary",
  "environment": "prod",
  "projection": {
    "for_each": "order",
    "sources": {"order": "$key"},
    "select": {"total": "$.order.total"}
  }
}
```

The dashboard upload expects the same format — use the same JSON file you'd use with curl.

### Data (API key authentication)

```
GET /_projections/:name/:id              # Read cached
GET /_projections/:name/:id?synchronous  # Compute fresh
```

Use `?synchronous` when you need guaranteed fresh data. Otherwise, projections automatically refresh when any source aggregate changes — see [Consistency](#consistency).

For a [fan-out projection](#fan-out-projections), `GET /_projections/:name/:id` returns an array of rows ordered by `_fan_key` rather than a single object.

## Example: User dashboard

```json
{
  "name": "user_dashboard",
  "for_each": "user",
  "sources": {
    "user": "$key",
    "org": {"type": "organization", "id": "$.user.org_id"},
    "recent_orders": {"type": "order", "id": "$.user.recent_order_ids", "mode": "array"}
  },
  "include": {
    "features": "feature_flags:global"
  },
  "select": {
    "user_name": "$.user.name",
    "org_name": "$.org.name",
    "orders": "$.recent_orders",
    "dark_mode_enabled": "$.features.dark_mode"
  }
}
```

Request:

```bash
curl https://myapp.j17.dev/_projections/user_dashboard/abc123 \
  -H "Authorization: Bearer $API_KEY"
```

Response:

```json
{
  "ok": true,
  "data": {
    "user_name": "Alice",
    "org_name": "Acme Corp",
    "orders": [],
    "dark_mode_enabled": true
  },
  "cached_at": 1706745600
}
```

## Deployment example

```bash
# Deploy a projection (console API, JWT)
curl -X POST "https://console.j17.dev/api/instances/$INSTANCE_ID/projections" \
  -H "Authorization: Bearer $JWT" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "order_summary",
    "environment": "prod",
    "projection": {
      "for_each": "order",
      "sources": {
        "order": "$key",
        "customer": {"type": "customer", "id": "$.order.customer_id"}
      },
      "select": {
        "id": "$.order.id",
        "total": "$.order.total",
        "customer_name": "$.customer.name"
      }
    }
  }'

# Read a projection (API key)
curl https://myapp.j17.dev/_projections/order_summary/ord_12345 \
  -H "Authorization: Bearer $API_KEY"
```

## Consistency

**Projections are asynchronous.** A background refresher notices that a source aggregate changed and re-materializes the affected rows shortly afterwards. A query issued immediately after a write may not see it yet.

This matters more than it sounds, because **a row that hasn't materialised yet looks exactly like a row that was dropped.** That ambiguity is the most common way people reach a wrong conclusion about projection behaviour — a short result gets read as "my path is broken" or "unresolvable paths drop rows" when it was only lag. When testing a projection, retry before concluding anything.

Two escapes when you need a guarantee:

- `GET /_projections/:name/:id?synchronous` recomputes that projection from live aggregate state and returns the fresh result.
- Read the aggregate directly with `GET /:type/:id?synchronous=true` when you need read-after-write on a single aggregate.

SQL queries against materialized tables are always served from the materialized data — there is no synchronous variant.

### Don't gate an action on a projection

Reading a projection to decide whether to *allow* something can fail in either direction. A newly granted permission may not have materialized yet, incorrectly refusing access. A revoked permission may still appear in the table, incorrectly allowing it.

A projection describes a past state, not necessarily the current one. For authorization and other mutable conditions, verify the authoritative aggregate and enforce the condition when committing the action; a separate read alone does not prevent a concurrent change. Only an explicitly monotonic fact — one that cannot become false — can safely use a projected positive as a shortcut. Treat a projected negative for such a fact as unproven and confirm it against the aggregate before refusing.

**Tests must exercise projection lag.** Mocks that refresh projections inline can hide this behavior, but local tests using asynchronous materialization can reproduce it. Test both newly granted and revoked access while deliberately holding the projection behind the authoritative state.

### Changing a definition rebuilds the table

Deploying a changed definition for an existing projection **drops its table and recreates it**, then re-materializes rows as the refresher works through the aggregates. Until it catches up, the table is empty or partial.

That is normal — a projection is a derived, disposable view, and rebuilding is the expected way to change one. But it means a definition change is not a zero-downtime operation for readers of that table. For a projection on a hot path, deploy the new shape under a new name, let it populate, then switch reads and delete the old one.

Adding, removing, or retyping a column all count as definition changes. So does changing `sources`, `fan_out`, or `include`.

## Performance

Projections automatically cache and refresh when source data changes. No manual invalidation required.

Guidelines:
- Keep to 3-4 sources max
- Use array mode sparingly (each ID is a separate fetch)
- For high-traffic projections, use the cached endpoint (default) rather than `?synchronous`

### What is indexed

A projection table gets exactly one index, and it is not configurable:

| Projection | Index |
|------------|-------|
| Normal | `_key_id` — primary key |
| Fan-out | `(_key_id, _fan_key)` — composite primary key |

**Every other column is unindexed.** A `WHERE` on one is a full scan of the table, which
holds one row per aggregate — or per fanned-out element — not one per event. That is
usually fine for operational queries, and it is the wrong shape for a hot single-row
lookup by token, email or handle: those scan the whole table on every call. If a lookup
like that is on your request path, cache the result rather than repeating the query.

`_fan_key` on its own is not indexed either. It is the second column of the composite, so
`WHERE _fan_key = ?1` without `_key_id` cannot use the index.

There is currently no way to declare additional indexes, and a definition that tries —
an `"indexes"` key, or any other field outside the [seven the vocabulary
allows](#fields) — is rejected at deploy rather than accepted and ignored.

## Limitations

- No joins across instances
- No aggregation across all aggregates (use analytics export)
- Max 10 sources per projection
- One index per table, not configurable — see [What is indexed](#what-is-indexed)
- `include` is restricted to `:global` IDs

## Projections, and the read models you build yourself

Classical event sourcing builds read models by subscribing to the event stream and folding every event into a database you operate yourself. Built-in projections are that idea with the plumbing removed: declare the shape, and the platform maintains the table next to your data and answers SQL against it.

**You can still do it the classical way, and nothing in j17 gets in the way.** Materialize into Postgres, ClickHouse, Elasticsearch, DuckDB, a search index, a graph database — whatever your stack already runs. Two ways to feed it:

- **Push** — [listeners](/docs/api#webhooks-listeners) POST matching events to your endpoint as they're written, signed and retried.
- **Pull** — [`GET /:type/:id/events`](/docs/api/reading-aggregates#get-events) pages forward from a cursor you keep, so a loop or a cron job can catch up from wherever it left off and replay from the beginning whenever you change the shape.

Events are the durable record, and a read-scoped API key reaches all of them. Projections are a convenience layered on top, not a gate in front of your data.

Built-in projections are, like [Tick handlers](/docs/reference/tick), an 85% solution: they cover the common shapes without a second system to run, they live on the same node as the events so reads are fast and no network hop is involved, and they refresh themselves. When your read model fits, that's a lot of infrastructure you never have to think about. When it doesn't, drop to the stream and build exactly what you need — that isn't an escape hatch bolted on the side, it's the same thing every event-sourced system does.

### Where the built-in ends

The boundary is worth knowing up front. A projection is derived from **current aggregate state**, not from the event stream — it's a function of the aggregate, which is itself the fold of the events. It can compute anything recoverable from current state, and nothing that isn't.

So path-dependent facts have no home in a projection unless the aggregate already carries them:

| Question | Built-in projection | Your own consumer |
|----------|--------------------|-------------------|
| "Which orgs is this user in?" | Yes — [fan-out](#fan-out-projections) | — |
| "What is this order's total?" | Yes | — |
| "How many times has this been rejected?" | Only with a counter on the aggregate | Natural — count the events |
| "How long did this sit in `pending`?" | Only with a timestamp on the aggregate | Natural — read the transitions |
| "What did this look like in March?" | No | Fold the stream to that point |
| Full-text search, graph traversal, OLAP | No | The right tool for each |

Reconstructing a historical view means replaying events in any event-sourced system — that part isn't a j17 limitation. The narrower difference is that a built-in projection can't *accumulate* history as it goes, so a fact you want continuously queryable either lives on the aggregate or lives in a read model you fold yourself.

Built-in projections are also not for analytics. For heavy cross-aggregate queries over large datasets, pipe events into a warehouse.

## CSV Export

Download all rows of a projection as CSV:

```bash
curl https://myapp.j17.dev/_projections/order_summary/export.csv \
  -H "Authorization: Bearer $API_KEY" \
  -o orders.csv
```

Columns: `_key_id` (aggregate ID), then `_fan_key` on [fan-out projections](#fan-out-projections), then `select` keys alphabetically. Nested values are JSON-serialized. A trailer comment reports row counts for completeness verification.

- 100,000 row limit
- Subject to instance [read rate limits](/docs/api/rate-limits)
- Stale-cache rows omitted (reported in trailer)

## SQL Queries

Projection data is automatically materialized into queryable tables. Query them with read-only SQL:

```bash
curl -X POST https://myapp.j17.dev/_projections/query \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "sql": "SELECT name, email FROM user_summary WHERE status = ?1 ORDER BY name LIMIT 50",
    "params": ["active"]
  }'
```

```json
{
  "ok": true,
  "columns": ["name", "email"],
  "rows": [["Alice", "alice@example.com"], ["Bob", "bob@example.com"]],
  "types": ["text", "text"],
  "truncated": false,
  "row_limit": 10000
}
```

Each row is a positional array aligned with `columns` (and `types`). `columns`
and `types` are always present — including when the query matches no rows — so
you can read a projection's shape from an empty result.

### Truncation

Results are capped at `row_limit` rows. `truncated` is `true` when the query had more rows
to give than were returned.

**Check it before treating a result as complete.** A query that returns exactly
`row_limit` rows looks identical to one that returned everything, so without this flag a
clipped result reads as a total — a `SUM` over a truncated set is simply a wrong number,
with nothing in the response to say so.

The cap applies to the rows the query produces, so it also bounds a query whose own
`LIMIT` is larger, absent, or bound to a negative value. For bulk reads use the
[CSV export](#csv-export), which streams.

### Column types

Every projection table has two metadata columns plus your `select` columns:

| Column | SQLite type | `types` value | Notes |
|--------|-------------|---------------|-------|
| `_key_id` | TEXT | `"text"` | The aggregate ID. Unique per row except on [fan-out projections](#fan-out-projections), where it repeats. When `include_substreams` is true, tagged variants appear as separate rows with the full ID including the tag (e.g., `abc123...:2025`). |
| `_fan_key` | TEXT | `"text"` | **Fan-out projections only.** The map key or array index this row came from. Together with `_key_id` it identifies the row. |
| `_updated_at` | INTEGER | `"integer"` | Unix seconds when the row was last upserted — returned as a JSON number |
| unannotated `select` columns | TEXT | `"text"` | Default for plain string paths |
| annotated `select` columns | per declaration | declared type | See type annotations below |

### Type annotations

By default all user columns are TEXT. To use a native SQLite type, replace the plain path string with an object:

```json
"select": {
  "name":      "$.user.name",
  "age":       {"path": "$.user.age",    "type": "integer"},
  "score":     {"path": "$.user.score",  "type": "real"},
  "active":    {"path": "$.user.active", "type": "boolean"},
  "tags":      {"path": "$.user.tags",   "type": "json"}
}
```

Valid types:

| Type | SQLite affinity | `types` value | Notes |
|------|-----------------|---------------|-------|
| `text` | TEXT | `"text"` | Default. `to_string()` coercion. |
| `integer` | INTEGER | `"integer"` | Numeric comparisons and `ORDER BY` work correctly. Values that can't parse as integer are stored `NULL`. |
| `real` | REAL | `"real"` | Same as integer for floats. |
| `boolean` | INTEGER | `"boolean"` | Stored as `0`/`1`. Truthy: `true`, `"true"`, `1`. Falsy: `false`, `"false"`, `0`. Anything else → `NULL`. |
| `json` | TEXT | `"json"` | Maps and arrays JSON-encoded. Signals the client to `JSON.parse()`. `$map` columns default to this type. |

The `types` array in the query response is parallel to `columns` — one entry per selected column. Use it to decode the columns your client needs:

```json
{
  "ok": true,
  "columns": ["name", "tags", "age"],
  "rows": [
    ["Alice", "[\"admin\",\"billing\"]", 32],
    ["Bob",   "[\"viewer\"]",            28]
  ],
  "types": ["text", "json", "integer"]
}
```

`tags` arrives as the string `"[\"admin\",\"billing\"]"` — parse it with `JSON.parse()` / `Jason.decode!()`. `age` arrives as a JSON number (no parsing needed). An unannotated date field arrives as whatever string the source event used (typically ISO 8601).

### Range queries on text columns

Text columns sort lexicographically. That's chronological for two common shapes:

- **ISO 8601 strings** (`"2026-05-04T13:22:11Z"`) sort chronologically by string comparison: `WHERE started_at >= '2026-05-01T00:00:00Z'`
- **Fixed-width Unix seconds** (`"1714521600"`) sort numerically because all values have the same digit count for any timestamp from 2001 to 2286

If you control the event shape, prefer ISO 8601 — it's the default JSON Schema `format: "date-time"` representation and stays human-readable in query results.

The `_updated_at` column is INTEGER, so it takes numeric comparison directly: `WHERE _updated_at >= 1714521600`.

### Other restrictions

SELECT only, no semicolons, a hard 10,000-row cap ([not a default](#truncation) — your own larger `LIMIT` does not raise it), 5-second timeout. Subject to instance [read rate limits](/docs/api/rate-limits).

**Not for OLAP**: For analytical queries across large datasets, pipe events into ClickHouse, DuckDB, or BigQuery. SQL queries cover operational needs — active users, orders by status, recent activity.

## Substreams and tagged UUIDs

By default, `for_each` only matches bare aggregate streams — `ledger:uuid`, not `ledger:uuid:2025`. Set `"include_substreams": true` to include tagged variants:

```json
{
  "name": "ledger_summary",
  "for_each": "ledger",
  "include_substreams": true,
  "sources": { "ledger": "$key" },
  "select": { "total": "$.ledger.total", "period": "$.ledger.period" }
}
```

Each tagged variant becomes its own row. Filter by period in SQL: `WHERE _key_id LIKE '%:2025'`.

**When to use `include_substreams`:** the events in the substream share the same aggregate type, the same schema, and the same handlers as the base stream. A yearly ledger partition (`ledger:uuid:2026`) that carries forward balances and posts entries is the right use case.

**When not to use `include_substreams`:** if you're tagging to store related-but-different data under a user's ID — a webhook log, an audit trail, an inbox — those events have different schemas and likely no meaningful handlers. That's not a substream, that's a separate aggregate type. Use a deterministic v5 UUID instead:

```js
// Stable ID derived from the user — no colons, genuinely separate type
const emailLogId = uuidv5(userId, EMAIL_LOG_NAMESPACE)
await j17.post(`email_log/${emailLogId}/webhook_received`, { ... })
```

This keeps the `user` projection clean and gives the `email_log` type its own spec, schema, and handlers.

## See also

- [Spec reference](/docs/reference/spec) - Projection definition syntax
- [JSONPath reference](/docs/reference/jsonpath) - The handler path language, and how it differs from `select` paths
- [Caching guide](/docs/guides/caching) - How caching interacts with projections

# Implications

Implications are reactive: when one event happens, they trigger another. All implied events are written atomically with the trigger event. Order placed? Reserve inventory. Low stock? Reorder. User upgraded? Grant features.

This guide covers practical patterns. For full syntax details, see [Implications reference](/docs/reference/implications-reference).

## Basic implication

Define in your spec:

```json
{
  "aggregate_types": {
    "order": {
      "events": {
        "was_placed": {
          "schema": {"type": "object", "properties": {"items": {"type": "array"}}},
          "handler": [{"set": {"target": "", "value": "$.data"}}],
          "implications": [
            {
              "emit": {
                "aggregate_type": "notification",
                "id": "admin",
                "event_type": "was_queued",
                "data": {"message": "New order received"}
              }
            }
          ]
        }
      }
    }
  }
}
```

When `order.was_placed` fires, j17 emits `notification.admin.was_queued` with the given data.

## Conditional implications

Only trigger when conditions are met:

```json
{
  "was_paid": {
    "schema": {"type": "object", "properties": {"amount": {"type": "number"}}},
    "handler": [{"set": {"target": "", "value": "$.data"}}],
    "implications": [
      {
        "condition": {"gte": ["$.data.amount", 100]},
        "emit": {
          "aggregate_type": "loyalty",
          "id": "$.metadata.actor.id",
          "event_type": "had_points_credited",
          "data": {"points": 10}
        }
      }
    ]
  }
}
```

Orders of $100 or more credit loyalty points. Conditions use the same predicate syntax as handlers.

## Cross-aggregate implications

Implications can target different aggregate types:

```json
{
  "user": {
    "events": {
      "had_tier_upgraded": {
        "schema": {"type": "object", "properties": {"new_tier": {"type": "string"}}},
        "handler": [{"set": {"target": "tier", "value": "$.data.new_tier"}}],
        "implications": [
          {
            "condition": {"equals": ["$.data.new_tier", "premium"]},
            "emit": {
              "aggregate_type": "features",
              "id": "$.metadata.actor.id",
              "event_type": "had_premium_enabled",
              "data": {"enabled_at": "$.metadata.timestamp"}
            }
          }
        ]
      }
    }
  }
}
```

User upgrade triggers a feature flag event on a different aggregate.

## Dynamic target IDs

Implications have access to all [standard event paths](/docs/reference/jsonpath#common-event-paths) (`$.key`, `$.id`, `$.type`, `$.data.*`, `$.metadata.*`, `@.*`). Use them to route implied events dynamically:

```json
{
  "emit": {
    "aggregate_type": "user_timeline",
    "id": "$.metadata.actor.id",
    "event_type": "had_activity_added",
    "data": {"event": "$.key", "type": "$.type"}
  }
}
```

## State-based conditions

Access the source aggregate's current state in conditions:

```json
{
  "condition": {"equals": ["@.notifications_enabled", true]},
  "emit": {
    "aggregate_type": "notification",
    "id": "$.metadata.actor.id",
    "event_type": "was_queued",
    "data": {}
  }
}
```

When accessing `@.*` in implications, you get the aggregate's state **before any events in the current request** are applied (S0). In a batch write, all implications in the batch see the same pre-batch state. The trigger event's data is available via `$.data.*`. (`$.state.*` is a deprecated alias for `@.*`.)

## Fan-out with map

Emit one event per item in an array:

```json
{
  "map": {
    "in": "$.data.items",
    "as": "$item",
    "emit": {
      "aggregate_type": "inventory",
      "id": "$item.product_id",
      "event_type": "was_reserved",
      "data": {
        "quantity": "$item.qty",
        "order_id": "$.key"
      }
    }
  }
}
```

`as` names the binding; reference the current element's fields with it (`$item.product_id`, `$item.qty`) — the same `$item` convention used by tick's `find`/`every`/`some`. The trigger event's own paths (`$.data.*`, `$.key`, `$.metadata.*`, `@.*`) are available too.

You get exactly one implied event per array element, whether they target the **same** aggregate or different ones. Elements are never merged: even two identical elements each emit their own event. `map`-emitted events do not participate in implied-event de-duplication at all (see [Safety limits](#safety-limits)) — neither against each other nor against events from other implications — so a `map` always emits exactly `len(array)` events.

### Map with condition filter

Only emit for items matching a condition:

```json
{
  "map": {
    "in": "$.data.items",
    "as": "$item",
    "condition": {"equals": ["$item.priority", "high"]},
    "emit": {
      "aggregate_type": "warehouse",
      "id": "$item.product_id",
      "event_type": "needs_urgent_pick",
      "data": {"qty": "$item.qty"}
    }
  }
}
```

## Data template operators

For complex data transformations, use DSL operators in your `data` template.

### concat - String concatenation

```json
{
  "data": {
    "message": {"concat": ["Order ", "$.key", " was placed"]}
  }
}
```

### coalesce - First non-null value

```json
{
  "data": {
    "name": {"coalesce": ["$.data.display_name", "$.data.name", "Unknown"]}
  }
}
```

### merge - Shallow object merge

```json
{
  "data": {"merge": [
    "@.defaults",
    {"override": "value", "timestamp": "$.metadata.timestamp"}
  ]}
}
```

Later values override earlier ones. Operators can be nested (e.g., a `merge` element can contain a `coalesce`), up to **32 levels** deep.

## Scheduled implications

Delay the triggered event. See the [scheduled events guide](/docs/guides/scheduled-events) for full details.

```json
{
  "was_placed": {
    "implications": [
      {
        "schedule": {
          "delay": "24h",
          "emit": {
            "aggregate_type": "notification",
            "id": "$.metadata.actor.id",
            "event_type": "cart_abandonment_reminder",
            "data": {"cart_id": "$.key"}
          },
          "cancel_on": [
            {
              "aggregate_type": "cart",
              "id": "$.key",
              "event_type": "was_checked_out"
            }
          ]
        }
      }
    ]
  }
}
```

If the cart is checked out within 24 hours, the reminder is automatically canceled.

## Safety limits

Implications have built-in protection against runaway chains:

| Limit | Default | Description |
|-------|---------|-------------|
| `max_depth` | 5 | Maximum chain depth (A implies B implies C implies D implies E) |
| `max_total` | 100 | Maximum total implied events per trigger |
| Template nesting | 32 | Maximum depth of nested template operators (`merge`/`concat`/`coalesce`) |

Exceeding limits returns an error; the entire transaction (trigger + implied) is rejected.

The engine detects cycles at spec validation time. A spec with `A:e1` implying `B:e2` implying `A:e1` will be rejected.

Within a single trigger's expansion, implied events are also de-duplicated by `(target, event_type, data)` — if two implication paths would produce the byte-identical event, it is written once. This is duplicate protection, not merging: events differing in any data field are all kept. `map` fan-out is fully exempt: `map`-emitted events never enter the de-duplication set (neither among themselves nor against other implications), so every array element emits.

## Audit trail

All implied events include metadata for traceability:

```json
{
  "implied_by": {
    "key": "order:abc123",
    "event_type": "was_placed",
    "depth": 1
  }
}
```

## Ordering and consistency

Implied events are written within microseconds of the trigger event, but they do **not** guarantee they will be the next event on the target aggregate's stream. Another write to the same target aggregate could land between the trigger and the implied event. Design your implied event handlers to be additive (set fields, append to arrays) rather than dependent on being exactly the N+1th event.

The hash chain on each aggregate stream remains intact regardless of interleaving — chain hashes are computed at write time from the actual previous event, not from what the implication expected.

## Error handling

Implication failures don't block the original event. The parent event succeeds, implications are retried separately.

## Common patterns

### Inventory management

```json
{
  "aggregate_types": {
    "order": {
      "events": {
        "was_placed": {
          "handler": [{"set": {"target": "", "value": "$.data"}}],
          "implications": [
            {
              "map": {
                "in": "$.data.items",
                "as": "$item",
                "emit": {
                  "aggregate_type": "inventory",
                  "id": "$item.sku",
                  "event_type": "had_reservation_requested",
                  "data": {
                    "order_id": "$.key",
                    "quantity": "$item.quantity"
                  }
                }
              }
            }
          ]
        }
      }
    },
    "inventory": {
      "events": {
        "had_reservation_requested": {
          "handler": [{"decrement": {"target": "available", "value": "$.data.quantity"}}],
          "implications": [
            {
              "condition": {"gte": ["@.available", "$.data.quantity"]},
              "emit": {
                "aggregate_type": "inventory",
                "id": "$.key",
                "event_type": "was_reserved",
                "data": {"quantity": "$.data.quantity"}
              }
            },
            {
              "condition": {"lt": ["@.available", "$.data.quantity"]},
              "emit": {
                "aggregate_type": "order",
                "id": "$.data.order_id",
                "event_type": "had_backorder_created",
                "data": {"sku": "$.key", "quantity": "$.data.quantity"}
              }
            }
          ]
        }
      }
    }
  }
}
```

### Notifications

```json
{
  "was_posted": {
    "handler": [{"set": {"target": "", "value": "$.data"}}],
    "implications": [
      {
        "emit": {
          "aggregate_type": "notification",
          "id": "$.data.post_author_id",
          "event_type": "was_created",
          "data": {
            "type": "new_comment",
            "post_id": "$.data.post_id",
            "commenter": "$.metadata.actor.id"
          }
        }
      }
    ]
  }
}
```

### Audit logging

```json
{
  "was_created": {
    "handler": [{"set": {"target": "", "value": "$.data"}}],
    "implications": [
      {
        "emit": {
          "aggregate_type": "audit",
          "id": "global",
          "event_type": "had_entry_added",
          "data": {
            "action": "create",
            "target": "$.key",
            "actor": "$.metadata.actor",
            "timestamp": "$.metadata.timestamp"
          }
        }
      }
    ]
  }
}
```

## Testing implications

Write events in staging, verify implications fire:

```bash
# Place order
curl -X POST https://myapp-staging.j17.dev/order/123/was_placed \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"data": {"items": [{"sku": "widget_1", "quantity": 2}]}}'

# Check inventory reservation fired
curl https://myapp-staging.j17.dev/inventory/widget_1 \
  -H "Authorization: Bearer $API_KEY"
```

## Compared to sagas

| | Implications | Sagas |
|--|-------------|-------|
| Trigger | Automatic on event | Explicit (trigger event) |
| Scope | Single event chain | Multi-step workflow |
| Compensation | Manual (write compensating events) | Built-in |
| Use case | Simple reactions | Complex business processes |

Use implications for simple if-this-then-that. Use [sagas](/docs/guides/sagas) for multi-step workflows with compensation.

## When not to use implications

**You need immediate confirmation** -- Implications are atomic with the trigger, but if you need the implied event's response before returning to the client, use a saga.

**Complex conditional logic** -- More than 3-4 conditions? Consider a [WASM handler](/docs/reference/wasm) or saga.

**External calls needed** -- Both Tick and WASM implications are pure data transformations with no network calls or I/O. Fetch external data before writing the trigger event.

## See also

- [Implications reference](/docs/reference/implications-reference) - Full syntax details
- [Sagas guide](/docs/guides/sagas) - Complex workflows
- [Scheduled events](/docs/guides/scheduled-events) - Delayed execution

# Sagas

Sagas orchestrate long-running workflows across multiple aggregates. Unlike implications (which react automatically), sagas are explicit: a trigger event starts them, they execute steps in order, and they handle failure with compensation.

## When to use sagas

**Good for:**
- Multi-step business processes (checkout, onboarding, provisioning)
- Operations spanning external services (payment APIs, email, webhooks)
- Processes needing human approval
- Anything where partial failure requires rollback

**Not for:**
- Simple single-aggregate updates (use regular events)
- Automatic reactions to events (use [implications](/docs/guides/implications))
- Single delayed actions (use [scheduled events](/docs/guides/scheduled-events))

## You might not need a saga

If it's a single delayed action -- send a reminder in 24 hours, expire a token next week -- use a scheduled event. Sagas are for multi-step workflows where steps depend on each other and failures need to unwind previous work.

## Defining sagas

Sagas are defined in `modules.sagas` in your spec:

```json
{
  "modules": {
    "sagas": {
      "checkout": {
        "trigger": {
          "aggregate_type": "cart",
          "event_type": "checkout_started"
        },
        "steps": [
          {
            "name": "reserve_inventory",
            "emit": {
              "aggregate_type": "inventory",
              "id": "$.data.product_id",
              "event_type": "reservation_requested",
              "data": {
                "quantity": "$.data.quantity"
              }
            },
            "await": {
              "aggregate_type": "inventory",
              "event_types": ["was_reserved", "reservation_failed"]
            },
            "compensate": {
              "aggregate_type": "inventory",
              "id": "$.data.product_id",
              "event_type": "reservation_released",
              "data": {}
            },
            "timeout_ms": 30000
          },
          {
            "name": "charge_payment",
            "condition": {"equals": ["$prev.type", "was_reserved"]},
            "emit": {
              "aggregate_type": "payment",
              "id": "$.data.payment_id",
              "event_type": "charge_requested",
              "data": {"amount": "$.data.total"}
            },
            "await": {
              "aggregate_type": "payment",
              "event_types": ["was_charged", "charge_failed"]
            },
            "compensate": {
              "aggregate_type": "payment",
              "id": "$.data.payment_id",
              "event_type": "refund_requested",
              "data": {}
            }
          },
          {
            "name": "notify",
            "condition": {"equals": ["$prev.type", "was_charged"]},
            "emit": {
              "aggregate_type": "notification",
              "id": "$.data.customer_id",
              "event_type": "order_confirmation_sent",
              "data": {
                "transaction_id": "$prev.data.transaction_id"
              }
            }
          }
        ],
        "on_complete": {
          "aggregate_type": "order",
          "id": "$.data.order_id",
          "event_type": "checkout_completed",
          "data": {}
        },
        "on_failed": {
          "aggregate_type": "order",
          "id": "$.data.order_id",
          "event_type": "checkout_failed",
          "data": {"error": "$error.message"}
        }
      }
    }
  }
}
```

### Trigger

```json
"trigger": {
  "aggregate_type": "cart",
  "event_type": "checkout_started"
}
```

When `checkout_started` is written to any cart, this saga starts.

### Steps

Each step has a name, an event to emit, and optionally:
- `await` - event types to wait for (step completes when one arrives)
- `compensate` - event to emit if a later step fails
- `timeout_ms` - how long to wait (required for steps with `await`)
- `condition` - only run if true

### Referencing data

Templates pull data from the trigger event and previous steps. Sagas have access to all [standard event paths](/docs/reference/jsonpath#common-event-paths) (`$.key`, `$.id`, `$.type`, `$.data.*`, `$.metadata.*`, `@.*`) plus saga-specific context:

| Template | Description |
|----------|-------------|
| `$prev.type` | Previous step's response type |
| `$prev.data.*` | Previous step's response data |
| `$context.<step>.*` | Earlier step's result by name |
| `$error.message` | Error message (in `on_failed`) |
| `$error.step` | Failed step name (in `on_failed`) |

`@.*` gives you the trigger aggregate's computed state at the moment the saga was created. This is a snapshot — if the aggregate changes after the saga starts, steps still see the original state. Use this when a saga step needs data from the aggregate that wasn't part of the trigger event (e.g., a `customer_id` that was set at creation time but isn't in the `had_followup_converted` event). (`$.state.*` is a deprecated alias for `@.*`.)

**Type preservation.** Templates pass values through without type coercion. If `@.customer_id` resolves to an object but the target schema expects `"type": "string"`, the step will fail with a type mismatch. Make sure the data types in your aggregate state match what the target event schema declares.

**Optional fields.** Append `?` to any template path to make it optional. If the path resolves to null, the key is omitted from the emitted event data instead of being set to null. This is useful when the trigger event may or may not include a field:

```json
"data": {
  "customer_id": "$.data.customer_id",
  "is_tax_exempt": "$.data.is_tax_exempt?"
}
```

If `is_tax_exempt` isn't present in the trigger event, it's simply left out of the emitted event rather than being set to `null` (which would fail a `"type": "boolean"` schema check).

The `condition` on `charge_payment` checks that inventory was actually reserved before charging. If the previous step returned `reservation_failed`, this step is skipped.

## How sagas execute

1. **Trigger event** is written, saga starts
2. **Execute step 1**: Emit `inventory.reservation_requested`
3. **Wait**: For a matching `await` event or timeout
4. **On success**: Proceed to step 2
5. **On failure**: Run compensations in reverse order, mark saga failed

Each step emits its event, then waits for a response matching one of the `await.event_types`. If no response arrives within `timeout_ms`, the step fails.

### Steps without await

Steps without `await` succeed immediately after emitting. Use this for fire-and-forget actions like logging:

```json
{
  "name": "audit_log",
  "emit": {
    "aggregate_type": "audit",
    "id": "global",
    "event_type": "checkout_attempted",
    "data": {"cart_id": "$.key"}
  }
}
```

## Compensation

When a step fails, the saga walks backward through completed steps and emits their `compensate` events:

```
Step 3 fails
-> Compensate step 2
-> Compensate step 1
-> Saga marked failed
```

Steps without `compensate` are skipped during rollback (like `notify` -- nothing to undo).

If compensation itself fails after 3 attempts (`max_compensation_attempts`), the saga is dead-lettered for manual review.

### Lifecycle events

`on_complete` fires when all steps succeed. `on_failed` fires after compensation completes. Both are optional.

## Saga state

Query saga status via the admin API:

```http
GET /_admin/sagas/:id
Authorization: Bearer <operator-jwt>
```

Response:

```json
{
  "saga_id": "saga_abc123",
  "type": "checkout",
  "status": "running",
  "current_step": "charge_payment",
  "started_at": 1705312800,
  "steps": [
    {"name": "reserve_inventory", "status": "completed", "completed_at": 1705312801},
    {"name": "charge_payment", "status": "waiting", "waiting_since": 1705312802}
  ]
}
```

## Admin API

```http
GET  /_admin/sagas?status=running     # List sagas
GET  /_admin/sagas/:id                # Get saga details
POST /_admin/sagas/:id/retry          # Retry failed saga
```

Filter by `status` (running, completed, failed, dead_lettered), `environment`, `limit`, `offset`.

Retry picks up where it left off, restarting from the failed step. The saga runner polls for work every 1000ms.

## Dead letters

If compensation fails 3 times, the saga is dead-lettered. This requires manual intervention:

1. Check the dashboard (dead letters appear prominently on the home page)
2. Fix the underlying issue
3. Retry via the admin API

## Example: User onboarding

```json
{
  "modules": {
    "sagas": {
      "user_onboarding": {
        "trigger": {
          "aggregate_type": "user",
          "event_type": "was_created"
        },
        "steps": [
          {
            "name": "send_welcome_email",
            "emit": {
              "aggregate_type": "email",
              "id": "$.data.user_id",
              "event_type": "was_queued",
              "data": {"template": "welcome", "email": "$.data.email"}
            }
          },
          {
            "name": "provision_trial",
            "emit": {
              "aggregate_type": "subscription",
              "id": "$.data.user_id",
              "event_type": "trial_was_started",
              "data": {"plan": "starter"}
            },
            "compensate": {
              "aggregate_type": "subscription",
              "id": "$.data.user_id",
              "event_type": "trial_was_cancelled",
              "data": {}
            }
          }
        ]
      }
    }
  }
}
```

## Compared to implications

| | Implications | Sagas |
|--|-------------|-------|
| Trigger | Automatic on event | Trigger event in spec |
| Scope | Event chain (atomic) | Multi-step workflow |
| Compensation | Manual | Built-in |
| Human interaction | No | Yes |
| Failure handling | Retry | Compensation + retry |
| Use case | Simple reactions | Complex processes |

Use implications for if-this-then-that. Use sagas for processes requiring coordination and rollback.

## Limitations

- Max 50 steps per saga
- Max 100 concurrent sagas per instance
- Max 3 compensation attempts before dead-lettering
- **Saga chain depth limit: 3.** A saga step can emit an event that triggers another saga, which can trigger a third. Beyond depth 3, saga and scheduled event hooks are skipped and an audit log entry is recorded. If you see `saga_depth_exceeded` in your audit log, simplify your saga chain or consolidate steps. Listener/webhook hooks are unaffected by the depth limit.

## Best practices

**Keep steps small.** Each step should do one thing. Easier to compensate, easier to debug.

**Design compensations first.** Before writing the action, know how to undo it.

**Set realistic timeouts.** Account for external API latency, human response time.

**Monitor compensation rate.** High compensation means the process design needs rethinking.

## Troubleshooting

### emit_failed: type mismatch

The most common saga step failure. The step error looks like:

```json
{
  "message": "emit_failed",
  "type": "validation_error",
  "detail": "type mismatch: expected string, got object",
  "path": "data.customer_id"
}
```

This means the value resolved by the template doesn't match the target event's schema type. The `path` tells you which property failed, and `detail` tells you what was expected vs. what was provided.

**Common causes:**
- A `merge` handler stored an object where a string was expected. For example, if `@.customer_id` resolves to `{"name": "Alice", "id": "abc"}` instead of `"abc"`, a schema expecting `"type": "string"` will reject it.
- An integer in state is being passed to a schema expecting `"type": "string"`. Templates do not coerce types.
- A `set` handler with `"value": "$.data"` copied the entire event payload into a field, creating an object where a scalar was expected.

**To diagnose:**
1. Check the step error via the admin API: `GET /_admin/sagas/:id`
2. Look at `error.path` to identify which field failed
3. Look at `error.detail` for the expected vs. actual type
4. Check the trigger aggregate's state to see what the template actually resolved to

**To fix:** adjust the handler that builds the source aggregate state so the field has the correct type, or adjust the target event schema to accept the type being provided.

### emit_failed: missing required property

```json
{
  "message": "emit_failed",
  "type": "validation_error",
  "detail": "missing required property",
  "path": "data"
}
```

A template resolved to `null` because the path doesn't exist in the source data. For example, `@.email` returns null if the trigger aggregate's state doesn't have an `email` field, and the resulting event data omits the key entirely.

**To fix:** verify that the trigger event or aggregate state always includes the fields your saga steps reference, or make the field optional in the target schema.

### General debugging

1. **Inspect the saga:** `GET /_admin/sagas/:id` shows each step's status and error details
2. **Check step errors:** failed steps include `type`, `detail`, and `path` fields that identify exactly what went wrong
3. **Verify template sources:** use the aggregates endpoint to confirm what `@.*` templates resolve to at runtime
4. **Check schema alignment:** ensure the data types in your source (trigger event data, aggregate state) match the declared types in the target event schema

## See also

- [Implications guide](/docs/guides/implications) - Simple reactive chains
- [Scheduled events](/docs/guides/scheduled-events) - Delayed execution

# Scheduled Events

Schedule events to fire in the future. Reminders, expirations, delayed notifications -- anything that shouldn't happen immediately.

## How it works

When an event is written, your spec can schedule a future event:

```
User adds item to cart -> Schedule "send reminder" in 24 hours
                          (unless cart is checked out first)
```

If the cart is checked out within 24 hours, the reminder is automatically canceled. If not, the reminder event fires.

## Spec syntax

Add a `schedule` block to any event's implications:

```json
{
  "aggregate_types": {
    "cart": {
      "events": {
        "had_item_added": {
          "schema": {"type": "object", "properties": {"product_id": {"type": "string"}}},
          "handler": [{"append": {"target": "items", "value": "$.data"}}],
          "implications": [
            {
              "schedule": {
                "delay": "24h",
                "emit": {
                  "aggregate_type": "notification",
                  "id": "$.metadata.actor.id",
                  "event_type": "cart_abandonment_reminder",
                  "data": {
                    "cart_id": "$.key"
                  }
                },
                "cancel_on": [
                  {
                    "aggregate_type": "cart",
                    "id": "$.key",
                    "event_type": "was_checked_out"
                  }
                ]
              }
            }
          ]
        }
      }
    }
  }
}
```

### Fields

| Field | Required | Description |
|-------|----------|-------------|
| `delay` | Yes | How long to wait before firing. See [delay formats](#delay-formats). |
| `emit` | Yes | The event to emit when the delay expires. |
| `cancel_on` | No | Events that cancel this scheduled event before it fires. |

### Delay formats

| Format | Duration | Example |
|--------|----------|---------|
| `"Ns"` | N seconds | `"30s"` = 30 seconds |
| `"Nm"` | N minutes | `"30m"` = 30 minutes |
| `"Nh"` | N hours | `"24h"` = 24 hours |
| `"Nd"` | N days | `"7d"` = 7 days |

You can also use `delay_ms` as an integer (milliseconds) instead of the string format.

**Minimum delay: 5 minutes.** Scheduled events are designed for coarse-grained business logic, not real-time operations. Delays under 5 minutes are rejected at spec validation time.

### The emit block

The emit block defines what event fires when the delay expires:

```json
{
  "emit": {
    "aggregate_type": "notification",
    "id": "$.metadata.actor.id",
    "event_type": "reminder_sent",
    "data": {
      "original_cart": "$.key",
      "user_name": "$.data.user_name"
    }
  }
}
```

- `aggregate_type` - Target aggregate type (string literal)
- `id` - Target aggregate ID (JSONPath or string literal)
- `event_type` - Event type to emit (string literal)
- `data` - Event payload (values can be JSONPath expressions)

JSONPath expressions are resolved at scheduling time against the triggering event.

### Cancel conditions

Cancel conditions specify which future events should cancel this scheduled event:

```json
{
  "cancel_on": [
    {
      "aggregate_type": "order",
      "id": "$.key",
      "event_type": "was_completed"
    },
    {
      "aggregate_type": "order",
      "id": "$.key",
      "event_type": "was_cancelled"
    }
  ]
}
```

When any matching event is written, the scheduled event is canceled. All three fields must match for cancellation to occur.

### Race condition warning

There is an inherent race between firing and cancellation. If a cancel event arrives at nearly the same moment the scheduled event is due to fire, the cancellation may not prevent the scheduled event from firing.

For most use cases (reminders, follow-ups, soft deadlines), this is fine -- a redundant reminder is harmless. But if your domain requires strict either-or semantics (payment processed OR refund issued, never both), scheduled events alone are not sufficient. Use full [saga patterns](/docs/guides/sagas) for those cases.

## Fired event metadata

When a scheduled event fires, it includes metadata for audit trails:

```json
{
  "key": "notification:user123",
  "type": "cart_abandonment_reminder",
  "data": {"cart_id": "cart:abc"},
  "metadata": {
    "timestamp": 1705312800,
    "actor": {
      "type": "system_agent",
      "id": "event_scheduler"
    },
    "implied_by": {
      "key": "cart:abc",
      "event_type": "had_item_added",
      "depth": 1
    },
    "scheduled": {
      "scheduled_id": "a1b2c3d4-e5f6-...",
      "fire_at": "2024-01-15T12:00:00Z"
    }
  }
}
```

- `actor.type` is `"system_agent"` -- a reserved actor type for system operations that bypasses normal `agent_types` validation
- `actor.id` is `"event_scheduler"` -- identifies this as a scheduled event
- `implied_by` references the original triggering event
- `scheduled` contains scheduling metadata

## Use cases

### Trial expiration

```json
{
  "was_activated": {
    "handler": [{"set": {"target": "", "value": "$.data"}}],
    "implications": [
      {
        "schedule": {
          "delay": "14d",
          "emit": {
            "aggregate_type": "subscription",
            "id": "$.data.user_id",
            "event_type": "trial_should_expire",
            "data": {"user_id": "$.data.user_id"}
          },
          "cancel_on": [
            {
              "aggregate_type": "subscription",
              "id": "$.data.user_id",
              "event_type": "was_converted"
            }
          ]
        }
      }
    ]
  }
}
```

User converts to paid? Expiration cancels automatically.

### Subscription renewal

```json
{
  "was_activated": {
    "handler": [{"set": {"target": "", "value": "$.data"}}],
    "implications": [
      {
        "schedule": {
          "delay": "30d",
          "emit": {
            "aggregate_type": "billing",
            "id": "$.metadata.actor.id",
            "event_type": "renewal_due",
            "data": {
              "subscription_id": "$.key",
              "plan": "$.data.plan"
            }
          },
          "cancel_on": [
            {
              "aggregate_type": "subscription",
              "id": "$.key",
              "event_type": "was_cancelled"
            }
          ]
        }
      }
    ]
  }
}
```

### Recurring events

j17 doesn't natively support cron-like recurring events. Build recurrence by rescheduling -- each event schedules the next:

```json
{
  "weekly_report_sent": {
    "handler": [{"set": {"target": "last_sent", "value": "$.metadata.timestamp"}}],
    "implications": [
      {
        "schedule": {
          "delay": "7d",
          "emit": {
            "aggregate_type": "reports",
            "id": "global",
            "event_type": "should_send_weekly",
            "data": {}
          }
        }
      }
    ]
  }
}
```

## Admin API

### List pending events

```http
GET /_admin/scheduled?status=pending&limit=50
Authorization: Bearer <operator-jwt>
```

Query params:
- `status` - Filter by status: `pending`, `fired`, `canceled`, `dead_letter`
- `limit` - Max results (default 100)
- `offset` - Pagination offset

### Cancel a scheduled event

```http
DELETE /_admin/scheduled/:scheduled_id
Authorization: Bearer <operator-jwt>
```

### List dead letters

```http
GET /_admin/scheduled/dead_letters
Authorization: Bearer <operator-jwt>
```

Events that failed after max retries (10 attempts).

### Retry dead letter

```http
POST /_admin/scheduled/:scheduled_id/retry
Authorization: Bearer <operator-jwt>
```

Reset a dead-lettered event to pending for another attempt.

## Error handling

If a scheduled event fails to fire (e.g., spec not deployed, validation error), it is retried on the next poll cycle. After 10 failures, it moves to `dead_letter` status.

Dead-lettered events remain in the database for inspection, include `last_error` with the failure reason, and can be manually retried via the admin API.

| Error | Cause | Resolution |
|-------|-------|------------|
| `no_spec_deployed` | No spec in target environment | Deploy a spec |
| Schema validation | Emitted data doesn't match schema | Fix emit data or schema |

## Telemetry events

| Event | When |
|-------|------|
| `[:j17, :scheduled, :created]` | Scheduled event stored |
| `[:j17, :scheduled, :fired]` | Event successfully fired |
| `[:j17, :scheduled, :canceled]` | Canceled by matching event |
| `[:j17, :scheduled, :dead_lettered]` | Max retries exceeded |

## Precision

Scheduled events fire within about a minute of the target time. This is intentional -- scheduled events are for business logic like "send a reminder tomorrow" or "expire this offer in 7 days" where the exact second doesn't matter.

## Environment behavior

Scheduled events fire in the same environment they were created in. An event scheduled in staging fires in staging, and an event scheduled in prod fires in prod. This allows testing scheduled event logic in staging without affecting production.

## Limits

| Limit | Value |
|-------|-------|
| Min delay | 5 minutes |
| Max delay | 1 year |
| Max scheduled per aggregate | 100 |
| Max total per instance | 100,000 |

## Best practices

**Use cancellation liberally.** Always cancel if the triggering condition changes. It's cheaper to cancel than to handle no-op events.

**Short delays in implications.** Use scheduled events for delays over 5 minutes. For sub-minute coordination, use implications or sagas.

**Monitor pending count.** Too many scheduled events indicates a leak -- something that should cancel isn't.

**All times are UTC.** Convert in your application before scheduling.

## See also

- [Implications guide](/docs/guides/implications) - Trigger scheduled events from implications
- [Sagas guide](/docs/guides/sagas) - Complex workflows where race conditions must be explicitly resolved

# Caching

j17 caches aggregate state in the background, giving you fast reads and helping you stay within your tier's usage limits. It is on by default for every aggregate type.

## The trade-off

Without caching, every `GET /{type}/{id}` replays all events to compute the current state. This is accurate but uses resources.

With caching:
- **Faster responses**: Reads return instantly from pre-computed state
- **Lower resource usage**: Helps you stay within tier limits
- **Eventually consistent**: There's a brief window (typically under 1 second, worst case ~10 seconds) where reads might be slightly stale after a write

Cached reads are **always current**: the platform checks the cache entry against the
stream on every read and folds in anything the background refresh hasn't caught up to
yet, so a read after a write sees the write. `?synchronous=true` still exists, but its
meaning is "re-derive everything from the event log, trusting nothing derived" — an
audit posture, not a freshness requirement.

## Configuration

All aggregate types are cached by default. No configuration needed for most apps.

To exclude specific types from caching, use `modules.never_cache`:

```json
{
  "aggregate_types": {
    "user": { "events": { "..." : {} } },
    "order": { "events": { "..." : {} } },
    "audit_log": { "events": { "..." : {} } }
  },
  "modules": {
    "never_cache": ["audit_log"]
  }
}
```

Types in `never_cache` are always computed fresh from events. Everything else is cached.

## Types the platform reads without either

An aggregate type whose every event handler is a single root `set` from `$.data` — "the
state is whatever the last event said" — is served directly from the stream's last
event, in constant time, no cache and no replay. This is automatic, applies to
`?synchronous=true` as well, and means such types have **no staleness window at all**:
a read is always as fresh as the newest write. If your types have this shape, neither
caching configuration nor the fold byte budget affects them.

## Reading cached data

`GET /{type}/{id}` returns cached state automatically:

```bash
curl https://myapp.j17.dev/user/abc123 \
  -H "Authorization: Bearer $API_KEY"
# Returns cached aggregate - fast
```

### When you need fresh data

Use `?synchronous=true` to bypass the cache and compute from events:

```bash
curl https://myapp.j17.dev/user/abc123?synchronous=true \
  -H "Authorization: Bearer $API_KEY"
# Computes from events, guaranteed fresh
```

### Cache invalidation

Caches invalidate automatically when:
- New events are written to the aggregate
- Spec is redeployed (cache clears automatically)

You do not manage invalidation. The system handles it.

## When to cache

**Good candidates:**
- User profiles, settings
- Product catalogs
- Dashboards and lists
- Anything read more often than written

**Skip caching for:**
- Audit logs (append-only, rarely read as aggregates)
- Aggregates where even brief staleness is unacceptable (financial balances, inventory counts during checkout)

## Staleness expectations

- **Typical**: Under 1 second after a write
- **Worst case**: ~10 seconds for very large deployments
- **After spec deploy**: Cache automatically invalidates

## Optimistic UI pattern

Often you don't need to read after a write at all:

1. User clicks "Update Profile"
2. You POST the event to j17
3. On success, immediately update the UI optimistically
4. Don't bother reading the aggregate back

```javascript
async function updateProfile(userId, changes) {
  await fetch(`https://myapp.j17.dev/user/${userId}/had_profile_updated`, {
    method: "POST",
    headers: {
      "Authorization": `Bearer ${apiKey}`,
      "Content-Type": "application/json"
    },
    body: JSON.stringify({
      data: changes,
      metadata: { actor: { type: "user", id: userId } }
    })
  });

  // Update UI immediately - don't wait for read
  updateLocalState(changes);
  showToast("Profile updated!");
}
```

**Why this works**: Event writes are atomic. If the POST succeeds, the event is persisted. The cache will catch up. Your user sees instant feedback.

**Handling the rare failure**: If the write fails (validation error, network issue), show an error. For conflicts, refetch and let the user retry. Most UIs can show optimistic updates and handle the rare failure gracefully.

### Optimistic updates with OCC

For cases where conflicts matter (like adding items to a shared order):

```javascript
async function addItem(orderId, item) {
  const current = await fetch(
    `https://myapp.j17.dev/order/${orderId}?synchronous=true`,
    { headers: { "Authorization": `Bearer ${apiKey}` } }
  ).then(r => r.json());

  // Optimistically update UI
  renderOrder({
    ...current.state,
    items: [...current.state.items, item]
  });

  // Write event with OCC
  try {
    await fetch(`https://myapp.j17.dev/order/${orderId}/had_item_added`, {
      method: "POST",
      headers: {
        "Authorization": `Bearer ${apiKey}`,
        "Content-Type": "application/json"
      },
      body: JSON.stringify({
        data: item,
        metadata: {
          actor: { type: "user", id: userId },
          previous_length: current.length
        }
      })
    });
  } catch (err) {
    // Conflict - someone else wrote. Refetch and retry.
    const fresh = await fetch(
      `https://myapp.j17.dev/order/${orderId}?synchronous=true`,
      { headers: { "Authorization": `Bearer ${apiKey}` } }
    ).then(r => r.json());
    renderOrder(fresh.state);
  }
}
```

Note the use of `?synchronous=true` to bypass cache when fresh data is needed for conflict detection.

## Performance comparison

| | Without Cache | With Cache |
|--|---------------|------------|
| Read speed | Slower (replays events) | Fast (pre-computed) |
| Resource usage | Higher | Lower |
| Consistency | Always fresh | Eventually consistent |
| Best for | Critical reads | Most reads |

## Best practices

**Leave caching on.** It is the default and the right choice for most types. Reach for `never_cache` only when you have a reason.

**Opt out of the right types.** High-read, low-write aggregates benefit most from caching. Types that change every second, or where staleness is unacceptable, are the ones to list in `never_cache`.

**Use `?synchronous=true` sparingly.** It bypasses the cache entirely -- use it for critical reads where staleness matters, not as a default.

**Monitor your usage.** Caching reduces compute usage, which helps you stay within tier limits.

**Don't cache user-specific data in shared CDN caches.** If you put j17 behind a CDN, use `Vary: Authorization` to avoid leaking data between users.

## See also

- [Reading aggregates](/docs/api/reading-aggregates) - Query parameters
- [Projections guide](/docs/guides/projections) - Multi-aggregate views that also benefit from caching

# AI-Assisted Development

j17 is designed to work well with AI coding assistants. Your entire backend -- spec, docs, and patterns -- fits comfortably in a context window.

## Why j17 works with AI

| Property | Why It Matters |
|----------|----------------|
| **JSON specs** | LLMs parse JSON perfectly. No compilation errors. |
| **HTTP API** | Universal, no SDK lock-in. Every language, every framework. |
| **Declarative handlers** | No hidden state. Predictable behavior. |
| **Small surface area** | Docs + spec + examples fit in ~50k tokens. |
| **Consistent patterns** | Once the AI learns one aggregate, it knows them all. |

## The workflow

1. **Load the docs** -- Download the full docs as a single file from [j17.app/j17-docs.md](/j17-docs.md) and add it to your AI context, or point Claude Code / Cursor at the j17 docs directory.

2. **Describe your domain** -- "I'm building an invoicing system. Invoices can be created, have line items added, be sent, and be paid."

3. **Get a spec** -- The AI generates your `spec.json` with event types, schemas, and handlers.

4. **Refine** -- "Add a `was_overdue` event that fires when payment is 30 days late."

5. **Ship** -- Upload the spec, start POSTing events.

## Example prompt

```
I'm using j17 for a task management app. I need:
- Tasks with title, description, due date, status
- Tasks can be created, updated, completed, deleted
- Tasks belong to projects
- Projects can be created and archived

Generate a j17 spec.json with appropriate event types and Tick handlers.
```

## No Rust required. WASM when you need it.

Other "logic on the server" platforms *require* writing Rust or TypeScript, compiling to WASM, and debugging opaque runtime errors -- for everything, even simple operations.

j17 specs are JSON. Your AI already speaks JSON fluently. The six declarative Tick operations (set, merge, append, remove, increment, decrement) cover 80% of use cases without any code.

When Claude writes a j17 handler, it's writing this:

```json
{
  "handler": [{"merge": {"target": "", "value": "$.data"}}]
}
```

Not this:

```rust
#[spacetimedb::reducer]
fn update_task(ctx: &ReducerContext, id: u64, title: String) -> Result<(), String> {
    if let Some(mut task) = Task::filter_by_id(&id) {
        task.title = title;
        Task::update_by_id(&id, task);
        Ok(())
    } else {
        Err("Task not found".to_string())
    }
}
```

One of these is easy for an AI to get right. One is a minefield of type errors, borrow checker fights, and WASM compilation failures.

**But if you need custom logic**, j17 supports WASM handlers. Write complex validation, call external services, implement business rules that don't fit declarative patterns. The escape hatch exists -- you just don't have to use it for simple operations.

## Reference applications

We maintain reference applications that demonstrate j17 patterns in real-world contexts. Load these into your AI's context for better results:

| App | Description | Good for learning |
|-----|-------------|-------------------|
| **gather** | Social event planning | Multi-aggregate relationships, invitations, RSVPs |
| **invoicer** | Simple invoicing | Line items, state machines, calculated fields |
| **taskboard** | Kanban-style tasks | Projects, ordering, status transitions |

### Loading into Claude Code

```bash
# Add a reference app to your context
claude --add-dir /path/to/j17-examples/gather

# Or fetch directly
claude "Read the gather app spec at https://github.com/17jewels/examples/gather/spec.json and use it as a reference for my project"
```

### Loading into Cursor / Copilot

Add the examples directory to your workspace, or paste the relevant `spec.json` into your conversation.

The AI will pick up patterns like:
- How to structure multi-step workflows (sagas)
- When to use `append` vs `set`
- How to model relationships between aggregates
- Naming conventions for events (`was_created`, `had_item_added`, etc.)

## Tips for AI-assisted j17 development

**Be specific about your domain** -- "Users have profiles with name and email" gives better results than "I need user management."

**Ask for the spec first** -- Get the data model right before asking for frontend code or API calls.

**Iterate on handlers** -- "That `had_item_added` handler should append to `items` array, not replace it."

**Request test events** -- "Generate 5 example events I can POST to test this spec."

## Testing AI-generated output

If you're just exploring, POST events directly to your instance and see what happens. But for real projects:

### Use staging environments

Every j17 instance comes with separate staging and production environments. Different URLs, different API keys, isolated data.

```bash
# Staging
curl -X POST https://myapp-staging.j17.dev/user/123/was_created \
  -H "Authorization: Bearer $STAGING_KEY" \
  -H "Content-Type: application/json" \
  -d '{"data": {"name": "Alice"}, "metadata": {"actor": {"type": "user", "id": "123"}}}'

# Production
curl -X POST https://myapp.j17.dev/user/123/was_created \
  -H "Authorization: Bearer $PROD_KEY" \
  -H "Content-Type: application/json" \
  -d '{"data": {"name": "Alice"}, "metadata": {"actor": {"type": "user", "id": "123"}}}'
```

Let your AI generate specs and events against staging. Promote to production when you're confident.

### Automated testing

Your spec is JSON. Your events are JSON. Test them like any other data:

```bash
# Validate spec schema
j17 spec validate spec.json

# Dry-run events against a spec
j17 events validate spec.json events.json

# Run your test suite against staging
j17 test --env staging
```

### Seeding and test data

Ask your AI to generate seed data:

```
Generate 50 realistic test events for my invoicing spec:
- 10 customers created
- 20 invoices with 2-5 line items each
- Mix of paid, pending, and overdue states
```

### Mass event ingress

For coldstart or migrating existing data, use batch ingestion:

```bash
# Import from JSON lines file
j17 events import events.jsonl --env staging

# Pipe from another source
your-export-script | j17 events import - --env staging
```

Full documentation for each of these is coming. For now, the CLI help (`j17 --help`) covers the basics.

## Coming soon

- Claude Code custom commands for j17
- Prompt templates for common patterns
- One-click "Generate spec from description"

---


# The Spec

The spec is a JSON document that defines your entire data model: aggregate types, events, handlers, validation schemas, and platform features. It's declarative, version-controlled, and the single source of truth for your backend.

## Top-level structure

Your spec file wraps the definition in a `"spec"` key:

```json
{
  "spec": {
    "aggregate_types": {},
    "agent_types": [],
    "target_types": [],
    "modules": {},
    "geo_types": {},
    "singletons": []
  }
}
```

This is the format expected by both the dashboard upload and the [Admin API](/docs/api/admin-api). Only `aggregate_types` and `agent_types` are required inside `spec`. Everything else is optional.

When using [`j17 spec deploy`](/docs/cli/specs), the file contains the inner spec object (`aggregate_types`, `agent_types`, and so on) without this HTTP/upload envelope. The CLI adds the outer `"spec"` field to its request.

### Annotations: `$schema`, `title`, `description`, `examples`

Spec documents accept a small set of documentation-only keys, and nothing else beyond the defined vocabulary. A top-level `"$schema"` string is accepted and ignored, so you can point your editor at a JSON Schema and get completion and inline validation while writing a spec:

```json
{
  "spec": {
    "$schema": "https://example.com/my-j17-spec.schema.json",
    "title": "Orders",
    "description": "Order lifecycle for the storefront",
    "aggregate_types": {},
    "agent_types": []
  }
}
```

Alongside it, the documentation subset of [JSON Schema 2020-12's meta-data annotation vocabulary](https://json-schema.org/draft/2020-12/json-schema-validation#section-9) — `title`, `description`, and `examples` — is accepted at the spec root, on each aggregate type, and on each event type. All are pure annotations: j17 never reads them, they carry into the stored spec and its hash like any other key, and they exist so specs can document themselves in the place people already expect.

The rest of that vocabulary is deliberately **not** accepted: `default` (annotation-only per the standard, but enough of the ecosystem applies defaults that accepting it would suggest j17 defaults your event data — it does not), and `deprecated`/`readOnly`/`writeOnly` (they imply tooling and API semantics j17 doesn't provide). `version` is **reserved for future platform use**. Every other unrecognised key is [rejected at deploy](#validation-at-spec-deploy), including other JSON Schema keywords such as `$id`, `$defs`, and `definitions`.

(Inside each event's `schema` block you are writing actual JSON Schema, where the full annotation vocabulary is legal as usual.)

j17 does not publish a schema for the spec format and does not read `$schema`'s value — it exists purely so editor tooling has somewhere to look.

## aggregate_types

The core of your spec. Each aggregate type defines what events can happen to it, a JSON Schema for each event's data, and tick handlers that transform state.

```json
{
  "aggregate_types": {
    "order": {
      "events": {
        "was_placed": {
          "schema": {
            "type": "object",
            "properties": {
              "items": { "type": "array" },
              "total": { "type": "number" }
            },
            "required": ["items", "total"]
          },
          "handler": [
            { "set": { "target": "", "value": "$.data" } }
          ]
        },
        "had_item_added": {
          "schema": {
            "type": "object",
            "properties": {
              "item": { "type": "object" }
            },
            "required": ["item"]
          },
          "handler": [
            { "append": { "target": "items", "value": "$.data.item" } }
          ]
        }
      }
    }
  }
}
```

### Event definitions

Each event needs:

| Field | Type | Required | Description |
|-------|------|----------|-------------|
| `schema` | JSON Schema | Yes* | Validates event data |
| `handler` | Tick array | Yes | Transforms aggregate state |
| `implications` | Array | No | Reactive events to emit |

*Reserved event types (see below) must not define a schema.

The handler can be a single operation or an array of operations. Use `[]` for events that don't modify aggregate state (audit-only or projection-only events). See [Tick reference](/docs/reference/tick) for all available operations.

### Schema design

Declare every field your events will carry, even fields your handlers don't reference. This ensures typos and unexpected data are caught at write time rather than silently stored. If your handler references `$.data.email` but an event arrives with `$.data.emai`, the schema rejects it immediately.

If you don't set `additionalProperties: false`, events may include fields beyond what's declared in `properties`. This can be useful for attaching extra context (audit metadata, analytics tags) that handlers don't process but is available when querying the event stream directly. Undeclared fields are stored as-is but aren't validated.

See [JSON Schema reference](/docs/reference/json-schema) for supported keywords.

### Schema evolution

Events are immutable, but your understanding changes. Use optional fields and sensible defaults:

```json
{
  "was_placed": {
    "schema": {
      "type": "object",
      "properties": {
        "items": { "type": "array" },
        "shipping_address": { "type": "object" },
        "gift_message": { "type": "string" }
      },
      "required": ["items", "shipping_address"]
    }
  }
}
```

Old events without `gift_message` validate fine. New events can include it. This approach lets you evolve your schema without breaking existing event streams.

### Reserved event types

Event types starting with `_` are reserved for system-generated events (e.g., `_was_tombstoned` for GDPR erasure). These events:

- Cannot be written via the API -- writes with `_*` event types are rejected
- Must not define a schema -- the system owns the event format
- May define a handler to update aggregate state when replayed, or omit it (the reader skips events with no handler)
- Can use `[]` as a no-op handler if you want the event registered but don't need state changes

```json
{
  "user": {
    "events": {
      "was_created": {
        "schema": { "type": "object", "required": ["name"] },
        "handler": [{ "set": { "target": "", "value": "$.data" } }]
      },
      "_was_tombstoned": {
        "handler": []
      }
    }
  }
}
```

## agent_types

Defines who can trigger events. Every event requires an actor, and the actor's `type` must appear in this list.

```json
{
  "agent_types": ["user", "admin", "staff", "webhook"]
}
```

When writing events, the actor must have a `type` from your `agent_types` list and an `id` that is a valid j17 identifier — a v4/v5 UUID, a humane code, a declared singleton, or a tagged UUID. See [Identifiers](/docs/reference/identifiers) for the full rule (the same one applied to aggregate keys and target ids).

```json
{
  "metadata": {
    "actor": {
      "type": "user",
      "id": "550e8400-e29b-41d4-a716-446655440000"
    }
  }
}
```

Named non-user actors (webhooks, integrations) can use a declared singleton as the actor id — see [Singletons as actor ids](#singletons-as-actor-ids) below.

Common patterns:
- `user` -- end users of your application
- `admin` -- staff and administrators
- `webhook` -- external services calling your webhooks
- `integration` -- third-party API integrations

### System agents

System agents handle platform operations. They use the reserved `system_agent` type and are not part of your `agent_types` list.

| Agent ID | Purpose | Details |
|----------|---------|---------|
| `event_scheduler` | Fires scheduled events | Adds `metadata.scheduled` block for audit trail |
| `test_data_importer` | Test data injection (non-prod only) | Used when injecting test data without an explicit actor |

When a scheduled event fires:

```json
{
  "metadata": {
    "actor": { "type": "system_agent", "id": "event_scheduler" },
    "scheduled": {
      "scheduled_id": "a1b2c3d4-...",
      "fire_at": "2024-01-15T12:00:00Z"
    }
  }
}
```

### Reserved prefixes

| Reserved | Description |
|----------|-------------|
| `system*` | Actor type prefix, reserved for platform operations |
| `_*` | Event type prefix, reserved for system-generated events |
| `global` | Aggregate key ID for singletons (one per type) |

Your `agent_types` cannot include any type starting with `system_` -- this is enforced at spec deploy time.

## target_types

Optional. Used when events affect something other than the primary aggregate.

```json
{
  "target_types": ["user", "order", "product"]
}
```

Include in event metadata:

```json
{
  "metadata": {
    "actor": { "type": "user", "id": "..." },
    "target": { "type": "order", "id": "..." }
  }
}
```

The `target.id` follows the same rule as aggregate keys and actor ids — see [Identifiers](/docs/reference/identifiers). Useful for cross-aggregate queries: "show me all events where the target was this order."

## singletons

Custom singleton identifiers beyond the implicit `global`.

```json
{
  "singletons": ["config", "rate_limits", "feature_flags", "twilio_webhook"]
}
```

Every aggregate type automatically supports the `global` key for a singleton instance. Custom singletons let you declare additional named identifiers that act as valid ids — usable as aggregate keys, actor ids, or target ids — for any aggregate type in the instance.

### Singletons as actor ids

A declared singleton can also stand in as a non-user actor identity. This is the recommended pattern for named non-user actors (webhooks, external services, scheduled jobs):

```json
{
  "agent_types": ["user", "webhook"],
  "singletons": ["twilio_webhook"]
}
```

```bash
curl -X POST https://myapp.j17.dev/call_log/<uuid>/was_received \
  -H "Authorization: Bearer $API_KEY" \
  -d '{"data": {...}, "metadata": {
    "actor": {"type": "webhook", "id": "twilio_webhook"}
  }}'
```

This avoids fabricating a UUIDv5 from a name like `"twilio"`. Prior to 0.7.11 the actor id rule was UUID-only; it now accepts the full identifier set.

### Naming guidance

Singleton names should not equal `global` (already implicit) and should not collide with another identifier kind (valid UUID, valid 9-character humane code, or tagged-UUID form). These rules are conventions today — the spec parser accepts any list of strings — but following them avoids ambiguity in tooling and logs. See [Identifiers § Naming guidance](/docs/reference/identifiers#naming-guidance).

### Concurrency

Singletons name a single aggregate, which means every write contends on the same event stream and OCC counter. Under high write concurrency this becomes a hot key. Singletons are great for config and low-write-rate state; for high-throughput counters consider [tagged UUIDs](/docs/concepts/aggregates#tagged-uuids) or a [projection](/docs/guides/projections) over many small aggregates.

### Reads of singletons

`GET /:type/:singleton` returns 200 with empty data even before the first write — singletons "always exist, possibly empty." Use `metadata.length > 0` rather than status code to detect missingness on a singleton. (Regular UUID-keyed aggregates return 404 before the first write.)

See [Identifiers](/docs/reference/identifiers) for the canonical reference.

## modules

Optional configuration for platform features.

```json
{
  "modules": {
    "never_cache": ["audit_log"],
    "sagas": {
      "order_fulfillment": { ... }
    }
  }
}
```

| Module | Type | Description |
|--------|------|-------------|
| `never_cache` | Array of strings | Aggregate types excluded from caching (all others cached by default) |
| `sagas` | Object | Long-running process definitions |

All aggregate types are cached by default. Use `never_cache` to exclude specific types. See the [caching guide](/docs/guides/caching) for details.

See [Implications guide](/docs/guides/implications) for saga definitions.

## geo_types

Define geographic shapes for geospatial queries.

```json
{
  "geo_types": {
    "delivery_zone": {
      "type": "polygon",
      "properties": ["name", "delivery_fee"]
    }
  }
}
```

Attach to aggregates for location-based lookups:

```json
{
  "store": {
    "events": {
      "was_registered": {
        "schema": {
          "type": "object",
          "properties": {
            "name": { "type": "string" },
            "location": { "type": "object" }
          },
          "required": ["name", "location"]
        },
        "handler": [
          { "set": { "target": "", "value": "$.data" } }
        ],
        "geo": {
          "type": "point",
          "path": "$.data.location"
        }
      }
    }
  }
}
```

Query stores near a location:

```bash
curl "https://myapp.j17.dev/store/nearby?lat=40.7&lng=-74.0&radius=5000"
```

## projections

Projections are **not** defined in the spec. They are deployed separately via the Admin API (or dashboard) as their own JSON documents with a `name`, `for_each`, `sources`, and `select` field.

The spec determines what aggregate types and events exist — projections are built on top of that. Deploying a projection that references an aggregate type not in the spec will be rejected at deploy time.

See [Projections guide](/docs/guides/projections) for the definition format and deployment instructions.

## Validation at spec deploy

When you deploy a spec, j17 validates it in full before accepting it:

1. **Structure** -- required fields present, correct types, no unknown keys (except a top-level [`$schema`](#schema-for-editor-support), which is ignored)
2. **agent_types** -- non-empty array, no `system*` prefix
3. **JSON Schema** -- only [supported keywords](/docs/reference/json-schema), valid Draft 2020-12 syntax
4. **Handlers** -- valid [tick operations](/docs/reference/tick) and JSONPath expressions
5. **Implications** -- valid emit templates and JSONPath expressions

Invalid specs are rejected with detailed error messages pointing to the exact location of the problem.

Note: singleton names are accepted as-is; conventions live in [Identifiers § Naming guidance](/docs/reference/identifiers#naming-guidance).

### Backward compatibility

When deploying a spec update to an environment that already has a spec, j17 checks that the new spec is backward-compatible with the existing one. This prevents changes that would make existing events unreadable or fail validation.

**Blocked changes** (checked recursively through nested object schemas):
- Removing an aggregate type (existing event streams would become unreadable)
- Removing an event type (existing events would fail aggregation)
- Adding a new required field to an existing event schema (existing events may not have it)
- Changing the type of an existing property (existing events have the old type)
- Setting `additionalProperties: false` on a schema that previously allowed them (existing events with extra fields would fail)

**Always allowed:**
- Adding new aggregate types
- Adding new event types to existing aggregates
- Adding optional fields to existing event schemas
- Changing handlers (aggregates are recomputed from events, not stored)

If you need to make an incompatible change during early development, use `"force": true` in the request body to bypass the compatibility check. This is only available on staging and test environments -- production always enforces compatibility.

**Deprecating event types:** If you need to consolidate or rename event types in production, keep the old types in your spec with permissive schemas (no `required` fields) and no-op handlers (`[]`). Your application writes only the new types going forward. Old events remain readable and aggregate computation still works. This is the recommended pattern for production schema evolution.

### Cache invalidation

When a spec is deployed, j17 automatically invalidates cached aggregates that were computed with the old spec. This ensures handler changes take effect immediately without manual cache flushing.

## Validation at event write

When an event is written, j17 validates six things:

1. **Aggregate type** -- must exist in spec's `aggregate_types`
2. **Event type** -- must exist for that aggregate type
3. **Actor** -- `type` must be in `agent_types` (or be `system_agent`), `id` must be a valid UUID
4. **Target type** -- if present, must be in `target_types`
5. **Event data** -- must validate against the event's JSON Schema
6. **Key ID** -- must be a valid UUID, humane code, or configured singleton

All six checks must pass before the event is persisted. Failures return a structured error response with the specific check that failed.

## Complete example

A task management app with projects, tasks, system events, and implications:

```json
{
  "spec": {
    "aggregate_types": {
    "project": {
      "events": {
        "was_created": {
          "schema": {
            "type": "object",
            "properties": {
              "name": { "type": "string", "minLength": 1 },
              "description": { "type": "string" }
            },
            "required": ["name"]
          },
          "handler": [
            { "set": { "target": "", "value": "$.data" } },
            { "set": { "target": "status", "value": "active" } },
            { "set": { "target": "created_at", "value": "$.metadata.timestamp" } }
          ]
        },
        "was_archived": {
          "schema": { "type": "object" },
          "handler": [
            { "set": { "target": "status", "value": "archived" } },
            { "set": { "target": "archived_at", "value": "$.metadata.timestamp" } }
          ]
        }
      }
    },
    "task": {
      "events": {
        "was_created": {
          "schema": {
            "type": "object",
            "properties": {
              "project_id": { "type": "string", "format": "uuid" },
              "title": { "type": "string", "minLength": 1 },
              "description": { "type": "string" }
            },
            "required": ["project_id", "title"]
          },
          "handler": [
            { "set": { "target": "", "value": "$.data" } },
            { "set": { "target": "status", "value": "todo" } },
            { "set": { "target": "created_at", "value": "$.metadata.timestamp" } }
          ]
        },
        "had_status_changed": {
          "schema": {
            "type": "object",
            "properties": {
              "status": { "enum": ["todo", "in_progress", "done"] }
            },
            "required": ["status"]
          },
          "handler": [
            { "set": { "target": "status", "value": "$.data.status" } },
            {
              "if": { "eq": ["$.data.status", "done"] },
              "then": [
                { "set": { "target": "completed_at", "value": "$.metadata.timestamp" } }
              ]
            }
          ]
        },
        "had_assignee_changed": {
          "schema": {
            "type": "object",
            "properties": {
              "assignee_id": { "type": "string", "format": "uuid" }
            },
            "required": ["assignee_id"]
          },
          "handler": [
            { "set": { "target": "assignee_id", "value": "$.data.assignee_id" } }
          ]
        },
        "_was_tombstoned": {
          "handler": []
        }
      }
    }
  },
  "agent_types": ["user", "admin"],
  "target_types": ["project", "task"],
  "modules": {
    "never_cache": ["audit_log"]
    }
  }
}
```

## Best practices

**Start small.** Define one aggregate type with 2-3 events. Add complexity as needed.

**Use past tense for event names.** `was_created`, not `create`. Events are facts about the past. Use snake_case: `had_profile_updated`, `was_placed`, `had_item_added`.

**Declare complete schemas.** Include every field in your schema, even ones your handlers don't use. This catches typos and malformed data at write time.

**Keep handlers simple.** If you need more than 3-4 operations per handler, consider whether you're modeling the right thing. Complex logic might need a saga or multiple events.

**Version in git.** The spec is code. Track it, review it, deploy it like any other code change.

**Validate before deploying.** Use the CLI to catch errors early:

```bash
j17 spec validate spec.json
```

## See also

- [Tick reference](/docs/reference/tick) -- handler operations
- [JSON Schema reference](/docs/reference/json-schema) -- validation keywords
- [Projections guide](/docs/guides/projections) -- multi-aggregate views
- [Implications guide](/docs/guides/implications) -- reactive event chains

# Identifiers

j17 has a single rule for what counts as an identifier — whether it's an aggregate key, an actor id, or a target id. This page is the canonical reference.

## The four identifier kinds

| Kind | Example | When to use |
|------|---------|-------------|
| **UUID** (v4 or v5) | `a0000000-0000-4000-a000-000000000001` | The default. Use for entities you mint (users, orders, sessions) or derive deterministically (UUIDv5 from a stable namespace+name). |
| **Humane code** | `ABC123XYZ` | Short, human-typable ids (referral codes, invite tokens, ticket numbers). 9 characters, Crockford base32. |
| **Singleton** | `global`, `twilio_webhook` | Named, fixed instances. The implicit `global` works for any aggregate type; custom names must be declared in the spec. |
| **Tagged UUID** | `a0000000-0000-4000-a000-000000000001:2026` | A UUID with a short suffix tag, for partitioning by period, version, or shard. |

## Where each kind is valid

All four kinds are accepted as **aggregate keys**, **`metadata.actor.id`**, and **`metadata.target.id`**. The same `isValidKeyId` check runs in all three contexts, so anything you can use as an aggregate key you can also use as an actor or target.

| Field | UUID v4/v5 | Humane code | Singleton | Tagged UUID | Notes |
|-------|:----------:|:-----------:|:---------:|:-----------:|-------|
| Aggregate key (`{id}` in URL) | ✓ | ✓ | ✓ | ✓ | |
| `metadata.actor.id` | ✓ | ✓ | ✓ | ✓ | `system*` actor types use a separate hardcoded id allowlist (see [Reserved tokens](#reserved-tokens)) |
| `metadata.target.id` | ✓ | ✓ | ✓ | ✓ | |
| Projection primary keys | ✓ | ✓ | ✓ | ✓ | Same rule when keys come from `$.key` or `$.id` template paths |

If you have a use case where one kind should be allowed in one context but not another, that's a design conversation — but as of today, the rule is uniform.

## UUIDs

j17 accepts **v4** and **v5** UUIDs. Other versions (v1 time-based, v3 MD5-based, v7 time-ordered) are rejected.

```
xxxxxxxx-xxxx-{4|5}xxx-[89ab]xxx-xxxxxxxxxxxx
```

- The version nibble (15th hex digit) must be `4` or `5`.
- The variant nibble (20th hex digit) must be `8`, `9`, `a`, or `b` (RFC 4122 variant).
- 36 characters including hyphens.

**Case handling:** UUIDs are accepted in any case (lower, upper, or mixed). j17 canonicalizes them to **lowercase** at the HTTP edge before any routing or storage, so `A0000000-…`, `a0000000-…`, and `A0000000-…` all key the same aggregate stream. Subsequent reads return the lowercase canonical form. Tagged UUIDs follow the same rule for their UUID half; the tag half stays as-supplied (and must be lowercase to begin with — see Tagged UUIDs below).

**Use v4 when** the id is randomly generated at write time.

**Use v5 when** the id should be **deterministic** from a stable name. v5 is `SHA-1(namespace_uuid || name)` and gives you the same UUID every time for the same input. This is the right tool for "I want a stable id for an external entity I don't control" — a third-party webhook source, an external account id, a known integration. Pick a stable namespace UUID once and reuse it.

## Humane codes

A 9-character identifier using the Crockford base32 subset, designed to be unambiguous when read aloud or typed.

**Canonical character set:** `0-9`, `A-Z` excluding `I`, `L`, `O`, `U` (32 characters total — the standard Crockford base32 alphabet). These exclusions avoid confusion with `1`, `1`, `0`, and `V` respectively.

**Normalization at the edge:** when humane codes arrive in URLs or request bodies, j17 normalizes them before validation:

| Input | Normalized to |
|-------|---------------|
| `I` | `1` |
| `L` | `1` |
| `O` | `0` |
| `U` | `V` |
| lowercase | uppercase |

After normalization, the code must consist only of canonical characters. Generated codes never contain ambiguous characters.

**Use humane codes when** the identifier needs to be entered by a human (typed from a card, dictated over the phone, scanned imperfectly) and you can tolerate a smaller id space than UUIDs (`32^9 ≈ 3.5 × 10^13` distinct codes vs. UUIDv4's `2^122`).

## Singletons

A singleton is a fixed, well-known identifier for an aggregate that exists in exactly one instance per aggregate type.

### `global` (implicit)

Every aggregate type automatically supports the keyword `global` as an id — no spec changes required.

```bash
curl https://myapp.j17.dev/config/global \
  -H "Authorization: Bearer $API_KEY"
```

Use `global` for app-wide config, feature flags, a shared counter, or any "one per type" aggregate.

### Custom singletons (declared)

If you need more than one named singleton, or want a more descriptive name than `global`, declare them at the top level of the spec:

```json
{
  "singletons": ["all", "twilio_webhook", "stripe_webhook"]
}
```

Custom singletons are **instance-wide**, not per-type. Once declared, the name is accepted as an identifier for any aggregate type in that instance — and also as an actor id or target id.

```bash
# As an aggregate key
curl -X POST https://myapp.j17.dev/webhook_log/twilio_webhook/was_received \
  -H "Authorization: Bearer $API_KEY" \
  -d '{"data": {...}, "metadata": {"actor": {"type": "webhook", "id": "twilio_webhook"}}}'

# As an actor (in the same call above)
# As a target
curl -X POST https://myapp.j17.dev/audit/<uuid>/was_recorded \
  -H "Authorization: Bearer $API_KEY" \
  -d '{"data": {...}, "metadata": {
    "actor": {"type": "user", "id": "<uuid>"},
    "target": {"type": "integration", "id": "twilio_webhook"}
  }}'
```

### Naming guidance

The spec parser accepts any list of strings as `singletons`, so j17 does not enforce naming rules today — but the conventions below avoid runtime ambiguity and should be followed:

- 1 character or longer
- Don't redeclare `global` (it's already implicit)
- Don't pick a name that's also a valid identifier of another kind — i.e. not a valid v4/v5 UUID, not a valid 9-character humane code (`[0-9A-Z]` minus `I/L/O/U`), and not a tagged-UUID form (`<uuid>:<tag>`). A collision works at write time but makes the id ambiguous in tooling and logs.
- Treat the name as a URL-safe slug — letters, digits, and underscores. Avoid characters that need URL encoding.

A future spec-deploy validation pass may enforce these rules; for now they're conventions.

### Concurrency caveat

Singletons name a single aggregate, which means every write to that singleton contends on the same event stream and the same OCC counter. Under high write concurrency this becomes a hot key — many writers will see 409 conflicts and need to retry. Singletons are great for config and low-write-rate state, less so for things that take a write per request.

If you find yourself wanting a singleton for a high-throughput counter, consider [tagged UUIDs](#tagged-uuids) (partition by time period) or a [projection](/docs/guides/projections) over many small aggregates instead.

### Reads of singletons always succeed

`GET /:type/:id` returns **404 Not Found** when an aggregate has never had an event written. Singletons are the exception — `GET /:type/global` and any custom-singleton id return **200 OK** with `data: {}` and `metadata.length: 0` even before the first write. The conceptual contract is "singletons always exist, possibly empty." If you need missingness detection on a singleton, branch on `metadata.length > 0` rather than status code.

### Don't fake singletons with UUIDs

If you need a singleton, use `global` or declare a custom singleton. Don't generate a fixed UUID (like `00000000-0000-5000-8000-000000000000`) and use it as a pseudo-singleton — it works, but it's fighting the system, and the same advice applies to actor ids: a webhook actor doesn't need a fake UUIDv5 derived from `"twilio"`; declare `twilio_webhook` as a singleton and use it directly.

## Tagged UUIDs

A UUID followed by a short tag suffix, separated by a colon.

```
<v4-or-v5-uuid>:<tag>
```

- The UUID portion must be a valid v4 or v5 UUID.
- The tag is 1-10 characters, lowercase letters and digits only (`[a-z0-9]+`).

**Common uses:**

| Pattern | Example | Purpose |
|---------|---------|---------|
| Time partition | `uuid:2026` | Roll a long-running aggregate (audit log, ledger) into per-period instances |
| Schema version | `uuid:v2` | Pin an aggregate to a schema generation during migration |
| Shard / region | `uuid:us`, `uuid:eu` | Co-locate data with regional tenancy |

The UUID half identifies the entity; the tag half names a variant. The same underlying UUID can have multiple tagged variants without collision (`uuid:2026`, `uuid:2025`, and the bare `uuid` are three different identifiers).

## Tagged UUIDs vs singletons

Both name "well-known" identifiers, but they answer different questions:

- **Tagged UUIDs partition** a UUID-keyed space. The full id is still mostly random; the suffix is the variant. You'd have one tagged-UUID id per (entity, period) pair.
- **Singletons name** a fixed instance with no random component. The whole id is a known constant.

If you'd write a `for tag in tags` loop, that's a tagged UUID. If you'd write `the_one_and_only`, that's a singleton.

## Reserved tokens

| Token | Meaning |
|-------|---------|
| `global` | Implicit per-type singleton; works as a key, actor id, or target id without spec configuration |
| `system*` (actor type prefix) | Reserved for platform-internal actors. User specs cannot declare an `agent_type` starting with `system` (with or without an underscore). |
| `system_agent` id allowlist | When `actor.type` is `system*`, `actor.id` must be one of: `event_scheduler`, `test_data_importer`, `saga_coordinator`, `saga_compensator`. The general identifier rule does not apply. |
| `_*` (event type prefix) | Reserved for system-generated events (e.g., `_was_tombstoned`). User specs cannot declare event types starting with `_`. |

## Identifier validation summary

Two steps run on every incoming id, in order:

| Step | Where it runs | Behavior |
|------|---------------|----------|
| Normalize | On ingest | UUIDs (and the UUID half of tagged UUIDs) downcased; mixed-case humane codes uppercased and ambiguous chars (I, L, O, U) substituted. Other ids pass through unchanged. |
| Validate | On write | Canonicalized id must match one of the four kinds. |

Validation failures:

| Check | Returned on failure |
|-------|---------------------|
| Aggregate key format | 422 with `path: "key"` |
| `metadata.actor.id` format | 422 with `path: "metadata.actor.id"` |
| `metadata.target.id` format | 422 with `path: "metadata.target.id"` |
| Tag format on tagged UUIDs | 422 with the path of the offending field |
| Singleton naming conventions | _Not enforced today_ — see [Naming guidance](#naming-guidance) |

## See also

- [Spec reference](/docs/reference/spec) — declaring `singletons`, `agent_types`, `target_types`
- [Aggregates](/docs/concepts/aggregates) — when to use each identifier kind for aggregate keys
- [Writing events](/docs/api/writing-events) — `actor` and `target` metadata fields

# Tick - Declarative Handlers

Tick is j17's declarative handler language. Seventeen operations organized into four categories let you build aggregate state from events using pure JSON — no code to compile, no runtime to manage.

## Philosophy

Imperative code has hidden state. Declarative operations have none. When you read a Tick handler, you know exactly what it does — no side effects, no surprises.

Compare:

**Imperative (what you might write by hand):**
```javascript
function applyEvent(state, event) {
  if (event.type === 'had_item_added') {
    if (!state.items) state.items = [];
    state.items.push({
      ...event.data,
      added_at: Date.now()
    });
  }
}
```

**Declarative (Tick):**
```json
{
  "append": {
    "target": "items",
    "value": {
      "$merge": [
        "$.data",
        { "added_at": "$.metadata.timestamp" }
      ]
    }
  }
}
```

The Tick version is explicit, testable, and optimizable. The imperative version has bugs waiting to happen (what if `items` isn't an array? what if `data` is null?).

## Handler structure

A handler is an array of operations applied sequentially to aggregate state:

```json
{
  "was_created": {
    "schema": { ... },
    "handler": [
      { "set": { "target": "", "value": "$.data" } },
      { "set": { "target": "status", "value": "active" } }
    ]
  }
}
```

Each operation reads from the event (via [JSONPath](/docs/reference/jsonpath)) and writes to aggregate state. A handler is required for every event type. Use `[]` (empty array) for events that don't modify aggregate state — useful for audit-only or projection-only events.

## Collections must have a provable shape

Most operations require their target to be a particular kind of value. `append`, `remove`, `filter`, `update_where`, `upsert` and `append_unique` need an array. `merge`, `set_at`, `remove_at`, `increment_at`, `decrement_at`, `max_at`, `min_at`, `merge_at`, `merge_add`, `merge_max` and `merge_min` need an object.

A field no handler ever assigns is fine — an operation creates a missing field as the container it needs. The problem is a field assigned one way and operated on another: `append` cannot work on something holding an object, and because aggregates are computed by replaying every event, that failure is permanent rather than momentary. Replay restarts from the beginning each time, so it fails at the same event forever and a later corrective event is never reached.

**Deploying a spec therefore requires each such field to be provably the right shape.** A field is proven when any one of these holds:

- No handler assigns it, so operations create it as the container they need.
- The last assignment in the handler is an empty literal — `{"set": {"target": "tags", "value": []}}`.
- The event schema declares its type where the handler assigns it from the payload.

The last case matters most with a whole-payload write. `{"set": {"target": "", "value": "$.data"}}` replaces the entire aggregate with the event payload, and `{"merge": {"target": "", "value": "$.data"}}` writes each key the payload carries — either way, whatever the payload holds lands in state. If a later handler appends to `tags`, declare it:

```json
{
  "was_created": {
    "schema": {
      "type": "object",
      "properties": {
        "name": { "type": "string" },
        "tags": { "type": "array" }
      }
    },
    "handler": [{ "set": { "target": "", "value": "$.data" } }]
  }
}
```

**Declare the container, not the contents.** `{"type": "array"}` with no `items` places no constraint on elements — it only says the field is a list.

Seeding after the assignment works equally well, and makes the starting state explicit:

```json
"handler": [
  { "set": { "target": "", "value": "$.data" } },
  { "set": { "target": "tags", "value": [] } }
]
```

Order decides this. Operations run top to bottom, so a seed *after* the whole-payload write proves the shape, while the same seed *before* it does not — the write replaces it. A seed inside an `if` branch proves nothing either, since the branch may not run.

Note that a whole-payload write in *any* event reaches the collection, not just a creation event. An `update` event that merges `$.data` at the root can land a wrong-shaped value in a collection some other event builds, so it needs the same treatment.

A merge is easy to miss when auditing by eye. `{"merge": {"target": "", "value": "$.data"}}` reads like "update these fields", and the calling code usually looks like it too — nothing at that line suggests an extra key in the payload reaches the aggregate root. It does.

`set_at` and `merge_at` write *below* their target, so they reach one level down. A `set_at` on the root whose key comes from the payload can land on any top-level field, and a `merge_at` on a map can land inside any entry of it. Both are checked the same way.

### Keeping a payload open

If an event deliberately accepts arbitrary keys, you have two ways to stay compliant without giving that up:

- `"additionalProperties": false` proves the collection can never arrive through that event, because the payload cannot carry it at all. This is often the better answer on its own merits: if the collection already has its own event, a general update event has no business writing it, and closing the schema fixes that too. Pinning the type would make the failure impossible while leaving the field reachable by an event that should never touch it.
- Declare only the keys that are not free-form. A corrections payload whose values are all scalar counters except one keyed map can declare just that one:

```json
"corrections": {
  "type": "object",
  "properties": { "feature_firings": { "type": "object" } }
}
```

Every other key stays free, and the one that would brick the aggregate is pinned.

### An aggregate with no creation event is fine

A collection that no handler ever assigns needs nothing. If `reservations` only ever appears as the target of `upsert` and `remove`, those operations create it as an array and nothing else can put anything else there — it is proven by the absence of any other writer. You never need to add a creation event to satisfy this check.

This is checked when a spec is deployed, not while one is running: upgrading j17 never stops a deployed spec from serving.

## Accessing event data

Operations reference event data using JSONPath expressions:

| Path | Resolves to |
|------|-------------|
| `$.data` | Event payload |
| `$.data.field` | Specific field from payload |
| `$.data.field?` | Optional field (no-op if missing) |
| `$.metadata.timestamp` | Event timestamp (Unix epoch) |
| `$.metadata.actor` | Actor object |
| `$.metadata.actor.id` | Actor ID |
| `$.key` | Event key (e.g., `user:abc123`) |
| `$.type` | Event type (e.g., `was_created`) |
| `@.field` | Current aggregate state |

## Basic operations

### set

Replace the value at a path.

```json
{ "set": { "target": "status", "value": "active" } }
```

The `target` is a dot-separated path in aggregate state. The `value` can be a literal or a JSONPath expression:

```json
{ "set": { "target": "profile.name", "value": "$.data.name" } }
```

Target `""` (empty string) means the root — use this to initialize state from event data:

```json
{ "set": { "target": "", "value": "$.data" } }
```

Creates intermediate objects automatically. Setting `{ "target": "a.b.c", "value": 1 }` on an empty state produces `{ "a": { "b": { "c": 1 } } }`.

### merge

Shallow merge an object into state at a path.

```json
{ "merge": { "target": "profile", "value": "$.data" } }
```

Given state `{ "profile": { "name": "Alice" } }` and event data `{ "email": "alice@example.com" }`, the result is `{ "profile": { "name": "Alice", "email": "alice@example.com" } }`.

Shallow only — nested objects are replaced, not recursively merged.

### append

Add an item to an array.

```json
{ "append": { "target": "items", "value": "$.data.item" } }
```

Creates the array if it doesn't exist.

### remove

Remove elements from an array. Three forms:

**By value** (primitive arrays):
```json
{ "remove": { "target": "tags", "value": "$.data.tag" } }
```

**By field match** (object arrays):
```json
{ "remove": { "target": "items", "where": { "id": "$.data.item_id" } } }
```

**By predicate** (complex conditions):
```json
{
  "remove": {
    "target": "sessions",
    "match": {
      "expired": {
        "timestamp": "$item.created_at",
        "maxAgeSeconds": 86400,
        "now": "$.metadata.timestamp"
      }
    }
  }
}
```

Within `match`, use `$item` to reference the current array element being tested.

### increment

Add to a numeric field.

```json
{ "increment": { "target": "balance", "by": "$.data.amount" } }
```

Creates the field with value `0` if it doesn't exist, then adds `by`. The `by` field accepts a literal number or a JSONPath expression.

### decrement

Subtract from a numeric field.

```json
{ "decrement": { "target": "stock", "by": "$.data.quantity" } }
```

Same behavior as `increment` but subtracts.

### max

Advance a field to a new value, but never backwards.

```json
{ "max": { "target": "hobbs", "value": "$.data.reading" } }
```

Sets `target` to the greater of its current value and `value`. If the field doesn't exist, it is created with `value`. If `value` is missing, the operation is a no-op.

Use this for quantities that are physically or logically monotonic — instrument readings, high-water marks, version counters. Recording a reading that arrives late and is lower than one already recorded leaves the field untouched, so the event still writes and the field still tells the truth:

```
State:  { "hobbs": 1240 }
Event:  { "data": { "reading": 1234 } }    // logged late, from an earlier flight
Result: { "hobbs": 1240 }                  // unchanged
```

Because `max` is order-independent, events applying it can arrive in any order and produce the same result. That means no [OCC](/docs/concepts/atomicity) retry loop is needed for a monotonic field, and the event can carry the raw reading rather than a value the client computed from the current state.

### min

The counterpart — retreat only.

```json
{ "min": { "target": "best_lap", "value": "$.data.lap_time" } }
```

Sets `target` to the lesser of its current value and `value`, with the same creation and missing-value behavior as `max`.

### Comparison mode

`max` and `min` compare numbers by default. To compare strings instead, declare it:

```json
{ "max": { "target": "times_as_of", "value": "$.data.as_of", "compare": "string" } }
```

| `compare` | Comparison | Use for |
|-----------|------------|---------|
| `"number"` (default) | Numeric | Counters, readings, timestamps stored as integers |
| `"string"` | Lexicographic (byte order) | ISO 8601 timestamps, zero-padded fixed-width identifiers |

The mode is explicit rather than inferred from the value's type, because inferring it is a trap: `"10"` sorts before `"9"` lexicographically, so a silently string-compared version or count goes backwards exactly where you most wanted it not to. Declaring the mode also makes the operation fail loudly when a value arrives with the wrong type, instead of comparing it under the wrong rules.

Lexicographic comparison is only chronological for formats where it happens to be — ISO 8601 with a fixed offset (`2026-08-02T14:30:00Z`) sorts correctly; mixed offsets and variable-width formats do not. If you control the event shape and want ordering guarantees, store an integer.

## Array operations

### filter

Keep only array elements that match a predicate, removing the rest.

```json
{
  "filter": {
    "target": "sessions",
    "keep": {
      "not": {
        "expired": {
          "timestamp": "$item.created_at",
          "maxAgeSeconds": 1209600,
          "now": "$.metadata.timestamp"
        }
      }
    }
  }
}
```

The `keep` field takes any [predicate](#predicates). Use `$item` to reference the current element.

### map

Transform every element in an array. Each element becomes a temporary state that the `apply` operations act on.

```json
{
  "map": {
    "target": "stages",
    "as": "$stage",
    "apply": [
      { "set": { "target": "reviewed", "value": true } },
      { "set": { "target": "reviewed_at", "value": "$.metadata.timestamp" } }
    ]
  }
}
```

The `as` field (default: `$item`) names the binding for use in nested expressions. Operations inside `apply` treat the array element as their state.

### update_where

Merge fields into array elements that match a condition.

**By field match:**
```json
{
  "update_where": {
    "target": "addresses",
    "match": { "id": "$.data.address_id" },
    "merge": { "verified": true, "verified_at": "$.metadata.timestamp" }
  }
}
```

**By predicate:**
```json
{
  "update_where": {
    "target": "line_items",
    "match": { "equals": ["$item.status", "pending"] },
    "merge": { "status": "confirmed" }
  }
}
```

The field match shorthand `{ "id": "$.data.address_id" }` is equivalent to `{ "equals": ["$item.id", "$.data.address_id"] }`. Use the shorthand for simple field equality (the common case) and explicit predicates for complex conditions.

### upsert

Replace a matching element or insert if not found. Effectively "replace if exists, insert if not" for object arrays keyed by a field.

```json
{
  "upsert": {
    "target": "addresses",
    "match": { "id": "$.data.address.id" },
    "value": "$.data.address"
  }
}
```

The `match` field takes a single key-value pair: the field name and the value to match. Removes any existing item where the field equals the value, then appends the new `value`.

### append_unique

Add to an array only if the value isn't already present.

**Primitive arrays:**
```json
{ "append_unique": { "target": "tags", "value": "$.data.tag" } }
```

**Object arrays** (check uniqueness by a specific field):
```json
{
  "append_unique": {
    "target": "members",
    "value": "$.data.member",
    "uniqueField": "id"
  }
}
```

No-op if the value (or an object with a matching `uniqueField`) already exists.

## Dynamic key operations

These operations work on objects where the key is determined at runtime — useful for maps, lookup tables, and per-key counters.

### set_at

Set a value at a dynamic key within an object.

```json
{ "set_at": { "target": "preferences", "key": "$.data.setting_name", "value": "$.data.setting_value" } }
```

Sets `target[key] = value`. Creates the target object if it doesn't exist. The `key` must resolve to a string.

### merge_at

Shallow merge into a dynamic key within an object.

```json
{
  "merge_at": {
    "target": "members",
    "key": "$.data.user_id",
    "value": { "role": "$.data.role", "joined_at": "$.metadata.timestamp" }
  }
}
```

Preserves existing fields at that key. If the key doesn't exist, creates it. Both the existing value and the new `value` must be objects — use `set_at` to replace non-object values.

**Example:**
```
State:   { "members": { "u1": { "role": "viewer", "status": "pending" } } }
Event:   { "data": { "user_id": "u1" } }
Op:      merge_at target="members" key="$.data.user_id" value={"status": "active"}
Result:  { "members": { "u1": { "role": "viewer", "status": "active" } } }
```

### remove_at

Remove a key from an object.

```json
{ "remove_at": { "target": "permissions", "key": "$.data.permission_name" } }
```

No-op if the key or target doesn't exist.

### increment_at

Increment a numeric value at a dynamic key.

```json
{ "increment_at": { "target": "vote_counts", "key": "$.data.option_id", "by": 1 } }
```

Creates the key with value `0` if it doesn't exist, then adds `by`.

### decrement_at

Decrement a numeric value at a dynamic key — the counterpart to `increment_at`. Use this when the amount is a template value (`$.data.amount`) you need to subtract, since there is no way to negate a resolved value inline.

```json
{ "decrement_at": { "target": "balances", "key": "$.data.account", "by": "$.data.amount" } }
```

Creates the key with value `0` if it doesn't exist, then subtracts `by`. No clamping — the result may go negative.

### max_at / min_at

Advance (or retreat) the value at a dynamic key, never the other way.

```json
{ "max_at": { "target": "clocks", "key": "$.data.clock_name", "value": "$.data.reading" } }
```

The dynamic-key counterparts to [`max`](#max) and [`min`](#min): same monotonic behavior, same `compare` option, applied to `target[key]` instead of a fixed path. Creates the key with `value` if it doesn't exist.

### merge_max / merge_min

Apply `max` (or `min`) key-by-key across a whole object.

```json
{ "merge_max": { "target": "clocks", "value": "$.data.clocks" } }
```

For each key in `value`, store the greater of the existing value and the incoming one. Keys absent from `value` are untouched, exactly like [`merge`](#merge). Keys present in `value` but not in the target are created.

**Example:**
```
State:   { "clocks": { "hobbs": 1240, "tach": 980, "left_tach": 410 } }
Event:   { "data": { "clocks": { "hobbs": 1234, "tach": 991 } } }
Op:      merge_max target="clocks" value="$.data.clocks"
Result:  { "clocks": { "hobbs": 1240, "tach": 991, "left_tach": 410 } }
```

`hobbs` held — the incoming reading was lower. `tach` advanced. `left_tach` wasn't in the event, so it's unchanged.

This is the shape to reach for when a single event carries several monotonic readings at once. Doing it with `merge` would mean reading the aggregate, computing each maximum client-side, writing under `previous_length`, and retrying on conflict — an OCC loop per write, and an event that records derived values instead of what was actually observed.

Both accept the same `compare` option as `max`, applied to every key.

### merge_add

Add a whole object of numbers into the target, key by key.

```json
{ "merge_add": { "target": "balances", "value": "$.data.legs" } }
```

The additive sibling of `merge_max`. For each key in `value`, add it to the existing value at that key in `target`. Keys absent from `value` are untouched, exactly like [`merge`](#merge). Keys present in `value` but not in the target are created, starting from zero.

**Example:**
```
State:   { "balances": { "cash": 1000, "revenue": -500, "equity": 250 } }
Event:   { "data": { "legs": { "cash": -300, "expenses_fuel": 120, "expenses_hangar": 180 } } }
Op:      merge_add target="balances" value="$.data.legs"
Result:  { "balances": { "cash": 700, "revenue": -500, "equity": 250,
                         "expenses_fuel": 120, "expenses_hangar": 180 } }
```

Numeric only, so there is no `compare` option — there is no sensible lexicographic addition. A non-number on either side is an **error**, not a coercion: treating a stray string as zero would balance and be wrong, which is the one failure a ledger cannot absorb.

Reach for this when one event carries a whole delta map whose size is a property of the data rather than of the handler. [`increment_at`](#increment_at) adds to *one* key named by the op, so expressing a four-legged journal entry with it means four ops — and a handler that has to know in advance how many legs an entry can have. `merge_add` applies the whole map in one operation regardless of how many keys it holds.

## Control flow

### Conditional (if/then/else)

Execute operations conditionally based on a predicate.

```json
{
  "if": { "equals": ["$.data.status", "completed"] },
  "then": [
    { "set": { "target": "completed_at", "value": "$.metadata.timestamp" } }
  ],
  "else": [
    { "set": { "target": "status", "value": "$.data.status" } }
  ]
}
```

The `else` branch is optional. Both `then` and `else` take arrays of operations.

Conditionals nest:

```json
{
  "if": { "equals": ["$.data.action", "escalate"] },
  "then": [
    {
      "if": { "equals": ["@.priority", "critical"] },
      "then": [
        { "set": { "target": "escalation_level", "value": 2 } }
      ],
      "else": [
        { "set": { "target": "escalation_level", "value": 1 } }
      ]
    }
  ]
}
```

### let

Bind a variable by finding an element in an array. The variable is available to all subsequent operations in the handler.

```json
{
  "let": {
    "name": "$address",
    "find": {
      "in": "addresses",
      "where": { "id": "$.data.address_id" }
    }
  }
}
```

The `find` clause searches the array at the given state path. The `where` clause takes a single key-value pair: the field to match and the value to match against.

Reference the bound variable with its name:

```json
{ "set": { "target": "primary_street", "value": "$address.street" } }
```

Variables work with `$merge` expressions:

```json
{
  "set": {
    "target": "snapshot",
    "value": {
      "$merge": [
        "$address",
        { "verified": true, "verified_at": "$.metadata.timestamp" }
      ]
    }
  }
}
```

## Predicates

Predicates are boolean conditions used in `if/then/else`, `filter`, `remove` (with `match`), `update_where` (with predicate match), `every`, and `some`.

### equals

Compare two values for equality.

```json
{ "equals": ["$.data.status", "active"] }
{ "equals": ["$item.id", "$.data.item_id"] }
```

Both arguments can be JSONPath expressions, literals, or item references.

### includes

Check if a value exists in an array.

```json
{ "includes": { "array": "@.approved_ids", "value": "$.data.user_id" } }
```

### min_items

Check that an array has at least N elements.

```json
{ "minItems": { "array": "@.items", "min": 1 } }
```

### max_items

Check that an array has at most N elements.

```json
{ "maxItems": { "array": "@.tokens", "max": 5 } }
```

### expired

Check if a timestamp has exceeded a maximum age.

```json
{
  "expired": {
    "timestamp": "$item.created_at",
    "maxAgeSeconds": 1209600,
    "now": "$.metadata.timestamp"
  }
}
```

Returns true if `now - timestamp > maxAgeSeconds`. Useful in `filter` and `remove` to clean up stale data.

### every

Check that ALL elements in an array match a predicate.

```json
{ "every": { "in": "@.stages", "match": { "equals": ["$item.status", "done"] } } }
```

Within `match`, `$item` refers to the current element.

### some

Check that at least one element matches.

```json
{ "some": { "in": "@.approvals", "match": { "equals": ["$item.role", "admin"] } } }
```

### subset_of

Check that every item in one array exists in another.

```json
{ "subset_of": { "items": "@.required_steps", "array": "@.completed_steps" } }
```

Returns true if every element in `items` exists in `array`. Useful for checking completeness (e.g., all required steps finished).

### Logical operators

Combine predicates with `not`, `and`, and `or`:

```json
{ "not": { "equals": ["$.data.status", "deleted"] } }

{ "and": [
  { "minItems": { "array": "@.items", "min": 1 } },
  { "equals": ["@.status", "pending"] }
] }

{ "or": [
  { "equals": ["$.data.role", "admin"] },
  { "equals": ["$.data.role", "owner"] }
] }
```

### Predicates summary

| Predicate | JSON key | Description |
|-----------|----------|-------------|
| equals | `equals` | Two values are equal |
| includes | `includes` | Value exists in array |
| min_items | `minItems` | Array has >= N elements |
| max_items | `maxItems` | Array has <= N elements |
| expired | `expired` | Timestamp exceeds max age |
| every | `every` | All array items match |
| some | `some` | Any array item matches |
| subset_of | `subset_of` | All items in A exist in B |
| not | `not` | Negate a predicate |
| and | `and` | All predicates true |
| or | `or` | Any predicate true |

Predicates can be nested — `not`, `and`, `or`, `every`, and `some` all accept predicates as arguments, so you can build complex conditions like `{"and": [{"not": {"equals": ...}}, {"or": [...]}]}`. Nesting is limited to **32 levels**, which is far beyond any practical use case.

## $merge expressions

`$merge` composes objects dynamically anywhere a value is expected.

```json
{
  "append": {
    "target": "activity_log",
    "value": {
      "$merge": [
        "$.data",
        { "action": "item_added" },
        { "timestamp": "$.metadata.timestamp" },
        { "actor_id": "$.metadata.actor.id" }
      ]
    }
  }
}
```

`$merge` takes an array of items and shallow-merges them left to right. Each item must be one of:
- A **literal object** whose values may include JSONPath strings — the engine resolves the values before merging, e.g. `{ "actor_id": "$.metadata.actor.id" }`. The keys are taken verbatim.
- A **bare path string** like `"$.data"` or `"$found"` — the engine dereferences it; if it resolves to an object, that object's keys are spread into the result. Non-objects are silently skipped.

Later items overwrite earlier ones for the same key, just like `Object.assign` in JavaScript.

> **Common pitfall.** Wrapping a path in an object — `{ "$": "$.data" }` — looks like spread but is **not**. The engine treats it as a literal object whose key is the string `"$"`, so the merged result ends up as `{"$": <data>, ...}` instead of the data's fields. The spec validator now rejects this shape with a clear error pointing at the offending field. Use the bare-string form (`"$.data"`) for spread, or list fields explicitly inside a literal object.

If an element in the `$merge` array cannot be resolved (the path doesn't exist, a variable is unbound), that element is **silently skipped** rather than causing an error. This makes `$merge` forgiving by design — you can merge defaults with optional overrides without worrying about which fields are present. This differs from operations like `set` or `append`, where a missing required path (`$.data.foo` without `?`) is an error.

`$merge` expressions can be nested (a `$merge` element can itself resolve to an object containing another `$merge`), but nesting is limited to **16 levels**. Beyond that, the value resolves as not-found and the operation fails. In practice, you should never need more than 2-3 levels of nesting.

## Automatic timestamps

The engine automatically injects two fields into every aggregate:

- **`created_at`** — timestamp of the first event (set once, never overwritten)
- **`updated_at`** — timestamp of the most recent event (updated on every event)

These are Unix epoch integers (same format as `$.metadata.timestamp`). You do not need handlers for these — they are injected after all handlers run. If your handlers also set `created_at` or `updated_at`, the engine's values overwrite them.

> **Reserved field names**: Do not use `created_at` or `updated_at` as handler targets unless you intend the engine to overwrite them.

## Complete example: order management

This spec defines an `order` aggregate with five event types demonstrating most operation categories:

```json
{
  "aggregate_types": {
    "order": {
      "events": {
        "was_placed": {
          "schema": {
            "type": "object",
            "properties": {
              "customer_id": { "type": "string" },
              "items": {
                "type": "array",
                "items": {
                  "type": "object",
                  "properties": {
                    "sku": { "type": "string" },
                    "name": { "type": "string" },
                    "price": { "type": "number" },
                    "quantity": { "type": "integer", "minimum": 1 }
                  },
                  "required": ["sku", "name", "price", "quantity"]
                }
              }
            },
            "required": ["customer_id", "items"]
          },
          "handler": [
            { "set": { "target": "", "value": "$.data" } },
            { "set": { "target": "status", "value": "pending" } },
            { "set": { "target": "placed_at", "value": "$.metadata.timestamp" } }
          ]
        },

        "had_item_added": {
          "schema": {
            "type": "object",
            "properties": {
              "sku": { "type": "string" },
              "name": { "type": "string" },
              "price": { "type": "number" },
              "quantity": { "type": "integer", "minimum": 1 }
            },
            "required": ["sku", "name", "price", "quantity"]
          },
          "handler": [
            {
              "upsert": {
                "target": "items",
                "match": { "sku": "$.data.sku" },
                "value": "$.data"
              }
            }
          ]
        },

        "had_item_removed": {
          "schema": {
            "type": "object",
            "properties": {
              "sku": { "type": "string" }
            },
            "required": ["sku"]
          },
          "handler": [
            {
              "remove": {
                "target": "items",
                "where": { "sku": "$.data.sku" }
              }
            }
          ]
        },

        "had_status_changed": {
          "schema": {
            "type": "object",
            "properties": {
              "status": { "type": "string", "enum": ["confirmed", "shipped", "delivered", "cancelled"] }
            },
            "required": ["status"]
          },
          "handler": [
            { "set": { "target": "status", "value": "$.data.status" } },
            {
              "if": { "equals": ["$.data.status", "shipped"] },
              "then": [
                { "set": { "target": "shipped_at", "value": "$.metadata.timestamp" } }
              ]
            },
            {
              "if": { "equals": ["$.data.status", "delivered"] },
              "then": [
                { "set": { "target": "delivered_at", "value": "$.metadata.timestamp" } }
              ]
            }
          ]
        },

        "had_note_added": {
          "schema": {
            "type": "object",
            "properties": {
              "text": { "type": "string" }
            },
            "required": ["text"]
          },
          "handler": [
            {
              "append": {
                "target": "notes",
                "value": {
                  "$merge": [
                    "$.data",
                    { "added_at": "$.metadata.timestamp" },
                    { "author": "$.metadata.actor.id" }
                  ]
                }
              }
            }
          ]
        }
      }
    }
  }
}
```

After placing an order and adding a note, the aggregate state looks like:

```json
{
  "customer_id": "cust_001",
  "items": [
    { "sku": "WIDGET-A", "name": "Widget A", "price": 29.99, "quantity": 2 }
  ],
  "status": "pending",
  "placed_at": 1710000000,
  "notes": [
    { "text": "Rush delivery requested", "added_at": 1710000060, "author": "op_123" }
  ],
  "created_at": 1710000000,
  "updated_at": 1710000060
}
```

## Limitations

| Limitation | Workaround |
|------------|------------|
| No type coercion (`"123"` != `123`) | Ensure event data has correct types via schema |
| No arithmetic (`price * quantity`) | Use WASM handler |
| No string operations (concat, split) | Use WASM handler |
| No date/time math (add days) | Use WASM handler |
| No cross-aggregate reads | Use [implications](/docs/reference/implications-reference) |

## When to escape to WASM

Tick covers the vast majority of state-building patterns. Use [WASM handlers](/docs/reference/wasm) when you need:

- Arithmetic beyond increment/decrement
- String manipulation
- Date/time calculations
- Complex business logic
- Cryptographic operations

## Performance

Tick handlers are interpreted by the Zig engine and run in-process. No VM overhead, no garbage collection. Typical throughput: 300k+ operations/second per core.

## Validation

j17 validates handlers when you upload your spec:

- JSONPath expressions are syntactically valid
- Target paths can be created or already exist
- Required fields are present for each operation
- Predicate structures are well-formed

Invalid handlers are rejected with error details and hints.

## See also

- [JSONPath reference](/docs/reference/jsonpath) — Path syntax for accessing event and state data
- [WASM reference](/docs/reference/wasm) — Escape hatch for complex logic
- [Spec reference](/docs/reference/spec) — Full spec structure including handler placement
- [Implications reference](/docs/reference/implications-reference) — Cross-aggregate side effects

# JSON Schema

j17 validates event data against JSON Schema Draft 2020-12. Each event type in your spec can have a `schema` that validates the event's `data` field. Events with invalid data are rejected before the handler runs.

## Supported keywords

### Type validation

```json
{
  "type": "object"
}
```

Types: `object`, `array`, `string`, `number`, `integer`, `boolean`, `null`

Multi-type syntax is also supported:

```json
{
  "type": ["string", "null"]
}
```

### Enumerations

```json
{
  "enum": ["pending", "processing", "completed", "failed"]
}
```

Or a single fixed value:

```json
{
  "const": "active"
}
```

### Object properties

```json
{
  "type": "object",
  "properties": {
    "name": { "type": "string" },
    "age": { "type": "integer" },
    "email": { "type": "string", "format": "email" }
  },
  "required": ["name", "email"],
  "additionalProperties": false
}
```

| Keyword | Description |
|---------|-------------|
| `properties` | Define fields and their schemas |
| `required` | Array of required field names |
| `additionalProperties` | `false` to reject unknown fields, or a schema to validate them against. Excludes keys matched by `properties` and `patternProperties` |
| `minProperties` | Minimum number of properties |
| `maxProperties` | Maximum number of properties |

### patternProperties

Validate properties whose names match a regex pattern:

```json
{
  "type": "object",
  "properties": {
    "name": { "type": "string" }
  },
  "patternProperties": {
    "^x-": { "type": "string" }
  },
  "additionalProperties": false
}
```

Properties matching `^x-` (like `x-custom`, `x-label`) are validated against the given schema. Properties matched by either `properties` or `patternProperties` are excluded from `additionalProperties` checks.

### propertyNames

Constrain the names of properties (not their values):

```json
{
  "type": "object",
  "propertyNames": {
    "pattern": "^[a-z_]+$",
    "maxLength": 30
  }
}
```

Every property name in the object must match the given schema. Useful for enforcing naming conventions.

### dependentRequired

If a property exists, other properties become required:

```json
{
  "type": "object",
  "properties": {
    "credit_card": { "type": "string" },
    "billing_address": { "type": "string" },
    "cvv": { "type": "string" }
  },
  "dependentRequired": {
    "credit_card": ["billing_address", "cvv"]
  }
}
```

If `credit_card` is present, `billing_address` and `cvv` must also be present.

### dependentSchemas

If a property exists, the entire object must also match an additional schema:

```json
{
  "type": "object",
  "properties": {
    "type": { "enum": ["personal", "business"] },
    "name": { "type": "string" }
  },
  "dependentSchemas": {
    "type": {
      "if": {
        "properties": { "type": { "const": "business" } }
      },
      "then": {
        "properties": {
          "tax_id": { "type": "string" }
        },
        "required": ["tax_id"]
      }
    }
  }
}
```

### String constraints

```json
{
  "type": "string",
  "minLength": 3,
  "maxLength": 100,
  "pattern": "^[a-zA-Z0-9]+$",
  "format": "email"
}
```

| Keyword | Description |
|---------|-------------|
| `minLength`/`maxLength` | Character count bounds |
| `pattern` | Regex pattern match (see [regex engine note](#regex-engine)) |
| `format` | Predefined format validation (see below) |

Supported formats:

- `email` — Email address
- `date-time` — ISO 8601 date-time
- `date` — YYYY-MM-DD
- `time` — HH:MM:SS
- `uri` — Absolute URI
- `uri-reference` — URI or relative reference
- `uuid` — RFC 4122 UUID (any version)
- `hostname` — Hostname (includes IDNA/punycode A-label validation per RFC 5891)
- `ipv4` / `ipv6` — IP addresses
- `regex` — ECMA-262 regular expression
- `json-pointer` — JSON Pointer (RFC 6901)

**Note:** `format` is treated as an assertion (validator), not an annotation. Invalid formats are rejected. See [compliance testing](#compliance-testing) for details.

### Numeric constraints

```json
{
  "type": "number",
  "minimum": 0,
  "maximum": 1000,
  "exclusiveMaximum": 1000,
  "multipleOf": 0.01
}
```

| Keyword | Description |
|---------|-------------|
| `minimum`/`maximum` | Inclusive bounds |
| `exclusiveMinimum`/`exclusiveMaximum` | Exclusive bounds |
| `multipleOf` | Number must be a multiple of this value |

### Array constraints

```json
{
  "type": "array",
  "items": { "type": "string" },
  "minItems": 1,
  "maxItems": 10,
  "uniqueItems": true
}
```

| Keyword | Description |
|---------|-------------|
| `items` | Schema applied to all array items |
| `prefixItems` | Schemas for specific array positions (tuple validation) |
| `minItems`/`maxItems` | Array length bounds |
| `uniqueItems` | All items must be distinct |
| `contains` | At least one item must match the given schema |
| `minContains` | Minimum number of items matching `contains` |
| `maxContains` | Maximum number of items matching `contains` |

### prefixItems (tuple validation)

Validate specific positions in an array:

```json
{
  "type": "array",
  "prefixItems": [
    { "type": "string" },
    { "type": "integer" },
    { "type": "boolean" }
  ],
  "items": false
}
```

With `"items": false`, the array must have exactly three elements matching the positional schemas. Without it, additional items are allowed and unconstrained (or validated by the `items` schema).

### contains

Require that at least one array item matches:

```json
{
  "type": "array",
  "contains": { "type": "string", "minLength": 1 }
}
```

Combined with `minContains`/`maxContains` for range checks:

```json
{
  "type": "array",
  "contains": { "type": "integer", "minimum": 100 },
  "minContains": 2,
  "maxContains": 5
}
```

At least 2 and at most 5 items must be integers >= 100.

## Composition

### allOf

Must match all schemas:

```json
{
  "allOf": [
    { "type": "object" },
    {
      "properties": {
        "id": { "type": "string" }
      }
    },
    {
      "properties": {
        "created_at": { "type": "integer" }
      }
    }
  ]
}
```

### anyOf

Must match at least one:

```json
{
  "anyOf": [
    { "type": "string" },
    { "type": "integer" }
  ]
}
```

### oneOf

Must match exactly one:

```json
{
  "oneOf": [
    {
      "properties": { "type": { "const": "email" } },
      "required": ["email"]
    },
    {
      "properties": { "type": { "const": "sms" } },
      "required": ["phone"]
    }
  ]
}
```

### not

Must not match:

```json
{
  "not": { "type": "null" }
}
```

## Conditionals

### if/then/else

```json
{
  "properties": {
    "shipping_method": { "enum": ["standard", "express"] }
  },
  "if": {
    "properties": {
      "shipping_method": { "const": "express" }
    }
  },
  "then": {
    "properties": {
      "express_fee": { "type": "number", "minimum": 0 }
    },
    "required": ["express_fee"]
  }
}
```

If `shipping_method` is `express`, then `express_fee` is required.

## References

### $defs and $ref

Define reusable schemas:

```json
{
  "$defs": {
    "address": {
      "type": "object",
      "properties": {
        "street": { "type": "string" },
        "city": { "type": "string" },
        "zip": { "type": "string" }
      },
      "required": ["street", "city", "zip"]
    }
  },
  "type": "object",
  "properties": {
    "shipping_address": { "$ref": "#/$defs/address" },
    "billing_address": { "$ref": "#/$defs/address" }
  }
}
```

Only local `$ref` within `$defs` is supported. Remote URLs (`$ref: "https://..."`) are not supported.

## Common patterns

### Timestamp

```json
{
  "timestamp": {
    "type": "integer",
    "description": "Unix epoch seconds"
  }
}
```

### Money

```json
{
  "amount": {
    "type": "number",
    "minimum": 0,
    "multipleOf": 0.01,
    "description": "Amount in dollars"
  }
}
```

### UUID

```json
{
  "id": {
    "type": "string",
    "format": "uuid"
  }
}
```

### Email

```json
{
  "email": {
    "type": "string",
    "format": "email"
  }
}
```

### URL

```json
{
  "website": {
    "type": "string",
    "format": "uri"
  }
}
```

### Nested object

```json
{
  "profile": {
    "type": "object",
    "properties": {
      "name": { "type": "string" },
      "avatar": { "type": "string", "format": "uri" }
    },
    "required": ["name"]
  }
}
```

### Array of objects

```json
{
  "items": {
    "type": "array",
    "items": {
      "type": "object",
      "properties": {
        "sku": { "type": "string" },
        "quantity": { "type": "integer", "minimum": 1 }
      },
      "required": ["sku", "quantity"]
    }
  }
}
```

### Nullable field

```json
{
  "properties": {
    "middle_name": {
      "anyOf": [
        { "type": "string" },
        { "type": "null" }
      ]
    }
  }
}
```

Or using multi-type syntax:

```json
{
  "properties": {
    "middle_name": {
      "type": ["string", "null"]
    }
  }
}
```

### Conditional required fields

```json
{
  "type": "object",
  "properties": {
    "payment_type": { "type": "string", "enum": ["card", "bank", "crypto"] },
    "card_number": { "type": "string" },
    "bank_account": { "type": "string" },
    "wallet_address": { "type": "string" }
  },
  "required": ["payment_type"],
  "allOf": [
    {
      "if": { "properties": { "payment_type": { "const": "card" } } },
      "then": { "required": ["card_number"] }
    },
    {
      "if": { "properties": { "payment_type": { "const": "bank" } } },
      "then": { "required": ["bank_account"] }
    },
    {
      "if": { "properties": { "payment_type": { "const": "crypto" } } },
      "then": { "required": ["wallet_address"] }
    }
  ]
}
```

### Reusable definitions

```json
{
  "$defs": {
    "money": {
      "type": "object",
      "properties": {
        "amount": { "type": "integer", "minimum": 0 },
        "currency": { "type": "string", "pattern": "^[A-Z]{3}$" }
      },
      "required": ["amount", "currency"]
    }
  },
  "type": "object",
  "properties": {
    "subtotal": { "$ref": "#/$defs/money" },
    "tax": { "$ref": "#/$defs/money" },
    "total": { "$ref": "#/$defs/money" }
  },
  "required": ["subtotal", "total"]
}
```

## Regex engine

The `pattern` keyword uses the [mvzr](https://github.com/mnemnion/mvzr) regex engine (a Zig-native implementation). j17 carries a patched version that fixes a bug with `(group)*` zero-matches after greedy quantifiers. Track the upstream repository for fixes — if a new release includes the patch, the vendored copy can be updated.

mvzr supports standard regex syntax. If you encounter unexpected behavior with complex patterns, test against the mvzr engine directly or simplify the pattern.

## Unsupported keywords

Using these keywords will reject the spec at upload time:

| Keyword | Reason |
|---------|--------|
| `unevaluatedProperties` | Requires cross-subschema evaluation tracking |
| `unevaluatedItems` | Requires cross-subschema evaluation tracking |
| `$dynamicRef` / `$dynamicAnchor` | Dynamic reference resolution not supported |
| `$anchor` | Named schema anchors not supported (use `$defs` + `$ref` instead) |
| `$vocabulary` | Vocabulary declarations not supported |

Remote `$ref` (URLs like `$ref: "https://..."`) is also not supported. All schemas must be inline or defined in `$defs`.

### Annotation-only keywords

These keywords are accepted but have no validation effect (parsed and ignored):

| Keyword | Description |
|---------|-------------|
| `contentEncoding` | Content encoding hint (e.g., `base64`) |
| `contentMediaType` | Content media type hint (e.g., `application/json`) |
| `contentSchema` | Schema for decoded content |

Per the JSON Schema spec, these are annotations — they describe content but do not constrain it.

## Validation errors

When validation fails, the API returns a 400 error with details:

```json
{
  "ok": false,
  "error": {
    "code": "validation_failed",
    "message": "Event data failed schema validation",
    "details": {
      "field": "email",
      "constraint": "format",
      "expected": "email",
      "received": "not-an-email"
    }
  }
}
```

Common errors:

| Error | Fix |
|-------|-----|
| `required` | Add missing field |
| `type` | Wrong data type (string vs number) |
| `format` | Invalid format (malformed email) |
| `additionalProperties` | Remove unexpected field |
| `enum` | Value not in allowed list |

## Compliance testing

The implementation is tested against the [official JSON Schema Test Suite](https://github.com/json-schema-org/JSON-Schema-Test-Suite) for Draft 2020-12. The test harness embeds the official fixture files directly.

### Keyword tests

44 of the 46 required keyword test files from the suite's `tests/draft2020-12/` directory are run. Tests for unsupported keywords (`unevaluatedProperties`, `unevaluatedItems`, `$dynamicRef`, `$anchor`, `$vocabulary`) are skipped at parse time rather than failing.

Within test files that are run, individual test groups using unsupported features (e.g. remote `$ref`, `$dynamicRef`) are also skipped. The harness tracks these as "skipped" rather than "failed".

### Format tests

The official suite has two sets of format tests:

- **`tests/draft2020-12/format.json`** tests format as a pure annotation (the Draft 2020-12 default). Under annotation semantics, `"format": "uuid"` never rejects invalid values.

- **`tests/draft2020-12/optional/format/*.json`** tests format as a validator (assertion semantics). These expect invalid formats to be rejected.

j17 uses the `optional/format/` suite. Our implementation treats `format` as an assertion — invalid formats are rejected — which is the behavior users expect from a validation layer. This matches the `format-assertion` vocabulary from the spec and is explicitly supported by Draft 2020-12 as an implementation choice.

### Known limitations

One known test failure remains: the `vocabulary` test suite includes a test for custom metaschemas with no validation vocabulary, which requires remote `$schema` resolution (not supported).

All hostname format tests pass, including IDNA/punycode A-label validation (RFC 5891). The implementation includes a full punycode decoder (RFC 3492) and IDNA2008 codepoint classification with contextual rules.

## Best practices

**Be strict.** Use `additionalProperties: false` to catch typos.

**Add descriptions.** Help future you (and your team) understand the schema.

**Version carefully.** Add fields as optional first. Make required after confirming no old events lack them.

**Validate early.** Use the CLI to check schemas before deploying:

```bash
j17 spec validate spec.json
```

**Keep schemas small.** Large nested schemas are hard to reason about. Break into referenced components with `$defs`.

## Limitations summary

| Limitation | Impact |
|------------|--------|
| No remote `$ref` | All schemas must be inline or in `$defs` |
| No dynamic refs | `$dynamicRef`/`$dynamicAnchor` not supported |
| No `$anchor` | Named anchors not supported (use `$defs` + `$ref` instead) |
| No unevaluated* | Cannot catch unvalidated properties/items across composition |
| mvzr regex engine | `pattern` uses mvzr, not PCRE or ECMA-262 (see [regex engine note](#regex-engine)) |

For complex validation beyond JSON Schema capabilities, validate in your application before submitting events.

## Open source

j17's JSON Schema validation is powered by [zig-jsonschema](https://github.com/whiskeytuesday/zig-jsonschema), an open source Zig implementation of Draft 2020-12.

## See also

- [Spec reference](/docs/reference/spec) — Event schema definition
- [JSONPath reference](/docs/reference/jsonpath) — Accessing schema data in handlers

# Implications Reference

Complete reference for the implications system. See [Implications guide](/docs/guides/implications) for practical patterns.

## Overview

Implications are j17's reactive event system: when event A happens, automatically create event(s) B. All implied events are written atomically with the trigger event -- the trigger and its implications succeed or fail as a unit.

Implications are defined per event type in your spec:

```json
{
  "aggregate_types": {
    "order": {
      "events": {
        "was_placed": {
          "schema": { ... },
          "handler": [ ... ],
          "implications": [
            { ... },
            { ... }
          ]
        }
      }
    }
  }
}
```

There are seven implication types, from pure-data declarative to full custom code:

| Type | Key | Purpose |
|------|-----|---------|
| [tick](#tick) | `emit` | Declarative condition + emit |
| [map](#map) | `map` | Fan-out: one event per array item |
| [scheduled](#scheduled) | `schedule` | Delayed emission with cancel conditions |
| [pipeline](#pipeline) | `pipeline` | Chained steps |
| [wasm](#wasm) | `wasm` | WASM blob for complex logic |
| [binary_container](#binary-container) | `binary_container` | Pre-built executable image |
| [runtime_container](#runtime-container) | `runtime_container` | Code + lockfile (you write code, we build) |

## Tick

The most common type. Optionally test a condition, then emit an event to a target aggregate.

### Basic emit

```json
{
  "emit": {
    "aggregate_type": "notification",
    "id": "admin",
    "event_type": "was_queued",
    "data": {"message": "New order received"}
  }
}
```

| Field | Required | Description |
|-------|----------|-------------|
| `aggregate_type` | yes | Target aggregate type |
| `id` | yes | Target aggregate ID -- literal string or JSONPath expression |
| `event_type` | yes | Event type to emit on the target |
| `data` | no | Event data -- literals, JSONPath expressions, or template operators |

### Conditional emit

Add a `condition` to gate the implication on a predicate. Conditions use the same predicate syntax as tick handlers (`equals`, `not_equals`, `gt`, `gte`, `lt`, `lte`, `in`, `not_in`, `exists`, `and`, `or`, `not`).

```json
{
  "condition": {"equals": ["$.data.priority", "urgent"]},
  "emit": {
    "aggregate_type": "alert",
    "id": "ops-team",
    "event_type": "was_triggered",
    "data": {"source": "$.key"}
  }
}
```

This implication only fires when `$.data.priority` equals `"urgent"`.

### Dynamic target ID

Use a JSONPath expression for the `id` field to route implied events dynamically. Any string starting with `$` is treated as a path expression:

```json
{
  "emit": {
    "aggregate_type": "user_timeline",
    "id": "$.metadata.actor.id",
    "event_type": "had_activity_added",
    "data": {
      "source_key": "$.key",
      "source_type": "$.type"
    }
  }
}
```

### Accessing event data

All JSONPath expressions resolve against a context object containing:

| Path | Description |
|------|-------------|
| `$.key` | Trigger event key (e.g., `"order:abc123"`) |
| `$.type` | Trigger event type (e.g., `"was_placed"`) |
| `$.data.*` | Trigger event payload |
| `$.metadata.*` | Trigger event metadata (actor, timestamp, etc.) |
| `@.*` | Source aggregate's current state (see [State access semantics](#state-access-semantics)) |
| `$.state.*` | Deprecated alias for `@.*` (will be removed at or before 1.0) |

```json
{
  "condition": {"gte": ["$.data.amount", 100]},
  "emit": {
    "aggregate_type": "loyalty",
    "id": "$.metadata.actor.id",
    "event_type": "had_points_earned",
    "data": {
      "order_key": "$.key",
      "amount": "$.data.amount",
      "customer_tier": "@.tier"
    }
  }
}
```

## Map

Fan-out pattern: emit one event per item in an array. Useful for order line items, batch operations, and similar one-to-many patterns.

```json
{
  "map": {
    "in": "$.data.items",
    "as": "$item",
    "emit": {
      "aggregate_type": "inventory",
      "id": "$item.product_id",
      "event_type": "was_reserved",
      "data": {
        "quantity": "$item.qty",
        "order_id": "$.key"
      }
    }
  }
}
```

| Field | Required | Description |
|-------|----------|-------------|
| `in` | yes | JSONPath to the array to iterate |
| `as` | yes | Binding name for the current item (e.g., `$item`); reference its fields as `$item.field` |
| `emit` | yes | Emit template -- can use both event paths (`$.data.*`) and the item binding (`$item.*`) |
| `condition` | no | Predicate to filter items before emitting |

One event is emitted per array element -- to the same target aggregate or different ones. Elements are never merged; two identical elements each emit their own event. `map`-emitted events do **not** participate in the `(target, event_type, data)` de-duplication that collapses byte-identical implied events -- neither among themselves nor against other implications -- so a `map` always emits exactly one event per array element.

### Map with condition filter

Only emit for items matching a condition:

```json
{
  "map": {
    "in": "$.data.items",
    "as": "$item",
    "condition": {"equals": ["$item.requires_shipping", true]},
    "emit": {
      "aggregate_type": "warehouse",
      "id": "$item.warehouse_id",
      "event_type": "had_pick_requested",
      "data": {
        "product_id": "$item.product_id",
        "qty": "$item.qty",
        "order_id": "$.key"
      }
    }
  }
}
```

### Object iteration

The `map` construct works with arrays only. To iterate over object keys, use `$entries` to convert an object to an array of `{key, value}` pairs:

```json
{
  "map": {
    "in": "$.data.role_changes.$entries",
    "as": "$entry",
    "emit": {
      "aggregate_type": "user",
      "id": "$entry.key",
      "event_type": "had_role_updated",
      "data": {"role": "$entry.value"}
    }
  }
}
```

## Scheduled

Delayed implications emit events at a future time, with optional cancel conditions. This is a saga-lite pattern for coarse-grained business logic delays.

```json
{
  "schedule": {
    "delay": "24h",
    "emit": {
      "aggregate_type": "notification",
      "id": "$.metadata.actor.id",
      "event_type": "cart_abandonment_reminder",
      "data": {"cart_id": "$.key"}
    },
    "cancel_on": [
      {
        "aggregate_type": "cart",
        "id": "$.key",
        "event_type": "was_checked_out"
      }
    ]
  }
}
```

| Field | Required | Description |
|-------|----------|-------------|
| `delay` | yes | Duration string before firing |
| `emit` | yes | Event to emit after delay |
| `cancel_on` | no | Array of event patterns that cancel this scheduled event if they occur before the delay expires |

### Delay format

The `delay` field accepts duration strings with a numeric value and unit suffix:

| Suffix | Unit | Example | Milliseconds |
|--------|------|---------|--------------|
| `s` | seconds | `"300s"` | 300,000 |
| `m` | minutes | `"30m"` | 1,800,000 |
| `h` | hours | `"24h"` | 86,400,000 |
| `d` | days | `"7d"` | 604,800,000 |

**Minimum delay is 5 minutes.** Any delay shorter than `"5m"` (300,000ms) is rejected at spec validation time.

### Cancel conditions

Each cancel condition matches on a specific event pattern. If any matching event occurs during the delay window, the scheduled event is cancelled.

```json
{
  "cancel_on": [
    {
      "aggregate_type": "order",
      "id": "$.key",
      "event_type": "was_completed"
    },
    {
      "aggregate_type": "order",
      "id": "$.key",
      "event_type": "was_cancelled"
    }
  ]
}
```

| Field | Required | Description |
|-------|----------|-------------|
| `aggregate_type` | yes | Aggregate type to watch |
| `id` | yes | Aggregate ID -- JSONPath expression resolved at schedule time |
| `event_type` | yes | Event type that triggers cancellation |

## Pipeline

Chain multiple steps where transform steps enrich context for downstream emit steps. Pipelines can mix any implication type as steps.

```json
{
  "pipeline": [
    {"wasm": {"blob_name": "enrich.wasm", "mode": "transform"}},
    {
      "emit": {
        "aggregate_type": "notification",
        "id": "$.enriched.recipient",
        "event_type": "was_queued",
        "data": "$.enriched.payload"
      }
    }
  ]
}
```

Pipeline steps are executed in order. A step in `transform` mode returns enriched context that becomes available to subsequent steps. A step in `emit` mode produces implied events.

Valid step types: `tick`, `map`, `wasm`, `binary_container`, `runtime_container`, `scheduled`.

## Wasm

For logic that exceeds what declarative tick can express, use a WASM blob. The blob receives the trigger event and context as JSON, and returns an array of events to emit.

### Short form

```json
{
  "wasm": "order-notifier.wasm"
}
```

### Long form

```json
{
  "wasm": {
    "blob_name": "order-processor.wasm",
    "entrypoint": "compute_implications",
    "mode": "emit"
  }
}
```

| Field | Required | Default | Description |
|-------|----------|---------|-------------|
| `blob_name` | yes | -- | Name of the WASM blob (uploaded via admin API) |
| `entrypoint` | no | `"compute_implications"` | Exported function name |
| `mode` | no | `"emit"` | `"emit"` returns events; `"transform"` returns enriched context (for pipelines) |

The WASM function receives JSON input:

```json
{
  "event": {
    "key": "order:abc123",
    "type": "was_placed",
    "data": { ... },
    "metadata": { ... },
    "state": { ... }
  }
}
```

And returns an array of events to emit (in `emit` mode), or an enriched context object (in `transform` mode).

## Binary Container

Ship a pre-built executable image. Same interface as WASM (receives event JSON on stdin, returns events on stdout) but runs as a sandboxed container.

```json
{
  "binary_container": {
    "image": "myregistry/order-processor:v2",
    "mode": "emit"
  }
}
```

| Field | Required | Default | Description |
|-------|----------|---------|-------------|
| `image` | yes | -- | Container image reference |
| `mode` | no | `"emit"` | `"emit"` or `"transform"` |

## Runtime Container

Ship your code and lockfile; j17 builds and runs the container. Supported runtimes: `node`, `elixir`, `ruby`, `python`.

```json
{
  "runtime_container": {
    "runtime": "node",
    "entrypoint": "implications/order.js",
    "mode": "emit"
  }
}
```

| Field | Required | Default | Description |
|-------|----------|---------|-------------|
| `runtime` | yes | -- | One of: `node`, `elixir`, `ruby`, `python` |
| `entrypoint` | yes | -- | Path to your handler file |
| `mode` | no | `"emit"` | `"emit"` or `"transform"` |

## Data Template Operators

The `data` field in emit templates supports three DSL operators for constructing complex event payloads. Operators are recognized as single-key objects where the key is the operator name.

### `concat` -- string concatenation

Concatenates an array of values into a single string. JSONPath expressions are resolved first. Numbers and booleans are coerced to strings.

```json
{
  "data": {
    "message": {"concat": ["Order ", "$.key", " was placed by ", "$.metadata.actor.id"]}
  }
}
```

Result: `"Order order:abc123 was placed by user:456"`

### `coalesce` -- first non-null value

Returns the first resolved value that is not null. Useful for fallback chains.

```json
{
  "data": {
    "display_name": {"coalesce": ["$.data.display_name", "$.data.name", "Anonymous"]}
  }
}
```

If `$.data.display_name` is null or missing, falls back to `$.data.name`, then to `"Anonymous"`.

### `merge` -- shallow object merge

Merges an array of objects. Later values override earlier ones. Non-object values are skipped.

```json
{
  "data": {"merge": [
    "@.defaults",
    {
      "updated_by": "$.metadata.actor.id",
      "timestamp": "$.metadata.timestamp"
    }
  ]}
}
```

### Nesting operators

Operators can be nested inside each other:

```json
{
  "data": {"merge": [
    {"message": {"concat": ["Order ", "$.data.order_id", " processed"]}},
    {"name": {"coalesce": ["$.data.display_name", "$.data.email"]}},
    {"status": "pending"}
  ]}
}
```

## State Access Semantics

### Pre-batch state (S0)

When accessing `@.*` in implications, you get the aggregate's state **before any events in the current request** are applied. This is called S0 (state zero).

**Single event write:**
- You submit event A
- Implications for A see S0 (state before A)
- After commit: state is S0 + A = S1

**Batch event write:**
- You submit events A, B, C in one request
- Implications for A see S0
- Implications for B see S0 (not S0 + A)
- Implications for C see S0 (not S0 + A + B)
- After commit: state is S0 + A + B + C = S3

### Why S0?

S0 plus the trigger event data contains the same information as intermediate states. The trigger event data is available via `$.data.*`, so implications can reference both current state and the event payload without ambiguity.

All implications in a batch see the same state -- no coupling between sibling events, and no ordering dependencies within a batch.

### Best practices

Reference stable fields from state and changed fields from the event:

```json
{
  "emit": {
    "aggregate_type": "audit",
    "id": "log",
    "event_type": "was_recorded",
    "data": {
      "customer_name": "@.customer_name",
      "new_email": "$.data.email"
    }
  }
}
```

Avoid designing implications that expect to see state changes from sibling events in the same batch. If event A sets a field and event B's implication needs that field, either include the value in event B's data, use a single combined event, or make event B a separate request after A completes.

## Audit Trail

All implied events automatically include `implied_by` metadata for traceability:

```json
{
  "implied_by": {
    "key": "order:abc123",
    "event_type": "was_placed",
    "depth": 1
  }
}
```

| Field | Description |
|-------|-------------|
| `key` | Key of the event that triggered this implication |
| `event_type` | Type of the trigger event |
| `depth` | How many levels deep in the implication chain (1 = direct implication, 2 = implied by an implied event, etc.) |

## Safety Limits

Implications have built-in protection against runaway chains:

| Limit | Default | Description |
|-------|---------|-------------|
| `max_depth` | 5 | Maximum implication chain depth (A implies B implies C implies D implies E) |
| `max_total` | 100 | Maximum total implied events from a single trigger event |

Exceeding either limit returns an error and the entire transaction (trigger event plus all implied events) is rejected. Nothing is written.

### Cycle detection

The engine detects static cycles at spec validation time. A spec where aggregate type A's event implies an event on aggregate type B, which in turn implies the same event back on A, will be rejected when the spec is submitted.

For example, if `order:was_placed` implies `inventory:was_reserved`, and `inventory:was_reserved` implies `order:was_placed`, the spec will fail validation with a cycle error.

## API Response

When implications fire, the write API response includes the count of implied events:

```json
{
  "stream_id": "1706789012345-0",
  "implied_count": 3
}
```

## Limitations

1. **Atomic only.** Tick, map, and pipeline implications are written atomically with the trigger event. There is no async retry for these types. Scheduled implications are the mechanism for deferred execution.

2. **No external calls.** All implication types that run in the write path (tick, map, wasm, containers) are pure data transformations. No network calls, no I/O. If you need external data, fetch it before writing the trigger event and include it in the event data.

3. **Arrays only for map.** The `map` construct iterates arrays. Use `$entries` to convert objects to `[{key, value}, ...]` arrays before mapping.

4. **Depth limits compound with fan-out.** If a map emits 10 events and each of those has implications that emit 10 more, you hit 100 events at depth 2. Design fan-out chains carefully against the `max_total` limit.

## See Also

- [Implications guide](/docs/guides/implications) -- practical patterns and recipes
- [Handlers reference](/docs/reference/handlers-reference) -- tick predicate syntax (shared with implication conditions)
- [WASM reference](/docs/reference/wasm) -- custom logic via WebAssembly

# JSONPath

j17 uses a subset of JSONPath for accessing values in events, state, and bindings. This document covers the syntax, resolution contexts, and extensions available in handlers and implications.

**Scope.** Everything on this page applies to **handlers, implications, and sagas** — the three places that evaluate j17's path language. It does *not* apply to projection `select` paths, which are plain JSONPath evaluated against source bindings and support none of the extensions below. The syntax looks identical and the two often sit a few lines apart in the same spec file, so the boundary is worth knowing: see [Paths in `select`](/docs/guides/projections#paths-in-select-are-not-handler-paths).

## Basic syntax

All paths start with `$` representing the root of the document being resolved against.

### Field access

```jsonpath
$.name                   // Top-level field
$.data.profile.name      // Nested field access
```

Given this JSON:

```json
{"name": "Alice", "profile": {"email": "alice@example.com"}}
```

| Path | Result |
|------|--------|
| `$` | Entire object |
| `$.name` | `"Alice"` |
| `$.profile.email` | `"alice@example.com"` |
| `$.missing` | `null` (not found) |

### Array indexing

```jsonpath
$.items[0]               // First element
$.items[2]               // Third element
$.items[-1]              // Last element
$.items[-2]              // Second to last
```

Given `{"items": ["a", "b", "c", "d"]}`:

| Path | Result |
|------|--------|
| `$.items[0]` | `"a"` |
| `$.items[-1]` | `"d"` |
| `$.items[-2]` | `"c"` |
| `$.items[99]` | `null` (out of bounds) |

### Mixed access

Dot notation and array indexing can be combined freely:

```jsonpath
$.data.items[0].name
$.users[-1].profile.email
$.matrix[0][1]
```

## Resolution contexts

JSONPath resolves against different data depending on where it appears.

### Common event paths

These paths are available in all three handler contexts — handlers, implications, and sagas. They are **not** available in projection `select`, which has no event to resolve against:

| Path | Description | Example value |
|------|-------------|---------------|
| `$.key` | Aggregate key (type:id) | `"user:a1b2c3d4-..."` |
| `$.id` | Aggregate ID (the ID portion after the colon — typically a UUID, [humane code](/docs/concepts/aggregates), or `global`) | `"a1b2c3d4-..."` |
| `$.type` | Event type | `"was_created"` |
| `$.data.*` | Event data payload | `$.data.user_id` |
| `$.metadata.*` | Event metadata | `$.metadata.timestamp` |
| `$.metadata.actor.*` | Who performed the action | `$.metadata.actor.id` |
| `@.*` | Aggregate state | `@.status` |

### In handlers

In Tick handlers, `$` resolves against the **event** and `@` resolves against the **current aggregate state**:

| Path prefix | Resolves against |
|-------------|------------------|
| `$.*` | Event |
| `@.*` | Current aggregate state (mutates as operations run) |
| `@` | Entire state object |

- **`target` fields** use state paths (no prefix): `"profile.name"`
- **`@.*` paths** read from the current aggregate state (mutates as operations run)
- **`$.*` paths** read from the event

```json
{
  "if": { "equals": ["@.status", "pending"] },
  "then": [
    {"set": {"target": "status", "value": "$.data.new_status"}}
  ]
}
```

Here `@.status` reads from the current aggregate state, `$.data.new_status` reads from the event, and `"status"` in the target writes to state.

Note: `@` in handlers reflects the *accumulating* state — if an earlier operation in the same handler changes `status`, a later `@.status` sees the updated value.

### In implications

In implications, `@` resolves against the **source aggregate state** (S0 snapshot — the state at the time the event was written, before implications run):

| Path prefix | Resolves against |
|-------------|------------------|
| `$.*` | Trigger event |
| `@.*` | Source aggregate state (S0 snapshot) |

`$.state.*` is a deprecated alias for `@.*`. It will be removed at or before 1.0.

```json
{
  "condition": {"equals": ["@.notifications_enabled", true]},
  "emit": {
    "aggregate_type": "notification",
    "id": "$.metadata.actor.id",
    "event_type": "was_sent",
    "data": {"placed_by": "@.user_name"}
  }
}
```

### In sagas

Sagas have the same event paths plus additional context from the saga workflow:

| Path | Description |
|------|-------------|
| `$prev.type` | Previous step's response event type |
| `$prev.data.*` | Previous step's response data |
| `$context.<step>.*` | Earlier step's result by name |
| `$error.message` | Error message (in `on_failed`) |
| `$error.step` | Failed step name (in `on_failed`) |

`@.*` in sagas resolves against the trigger aggregate's state **at saga creation time** — a frozen snapshot that doesn't change as the saga progresses.

## State paths vs JSONPath

Handlers use two kinds of paths:

| Type | Prefix | Used in | Resolves against |
|------|--------|---------|------------------|
| **State path** | None | `target` fields | Aggregate state |
| **JSONPath** | `$` | `value` fields | Event data |

```json
{"set": {"target": "profile.name", "value": "$.data.name"}}
```

- `profile.name` — state path (where to write in the aggregate)
- `$.data.name` — JSONPath (what value to read from the event)

State paths support nested access with dots (`profile.address.zip`) and are used with `set`, `merge`, `append`, `remove`, `increment`, `decrement`, and `filter` targets.

## Optional paths

Append `?` to any JSONPath to make it optional. When the path does not exist, the operation becomes a no-op instead of failing.

This is a handler-language extension, not standard JSONPath. It works in handlers, implications, and sagas. **It is rejected at deploy time in projection `select` paths** — see [Paths in `select`](/docs/guides/projections#paths-in-select-are-not-handler-paths), where an unresolvable path already yields `null` and `?` would mean nothing.

```json
{"set": {"target": "memo", "value": "$.data.memo?"}}
```

| Condition | Behavior |
|-----------|----------|
| Field missing | No-op (field not added to state) |
| Field is `null` | Sets to `null` |
| Field present | Sets value normally |

Optional paths work with bindings too:

```json
{"set": {"target": "note", "value": "$found.note?"}}
```

This is particularly useful for events where some fields are only sometimes present, avoiding the need for conditional wrappers.

## Variable bindings

Operations that iterate or look up values create named bindings accessible via `$name` syntax.

### `$item` — iteration binding

Created by `map`, `every`, `some`, and `filter` operations. Defaults to `$item` but can be renamed with the `as` field:

```json
{
  "every": {
    "in": "items",
    "match": {"equals": ["$item.active", true]}
  }
}
```

```json
{
  "map": {
    "target": "items",
    "as": "$entry",
    "apply": [
      {"set": {"target": "updated_at", "value": "$.metadata.timestamp"}}
    ]
  }
}
```

### `$found` — lookup binding

Created by `let` operations that find an item in a state array:

```json
{
  "let": {
    "name": "$address",
    "find_target": "addresses",
    "find_field": "id",
    "find_value": "$.data.address_id"
  }
}
```

After this, `$address.street` resolves to the `street` field of the found address. Bindings created by `let` are available to all subsequent operations in the same handler.

### Binding sub-paths

Bindings work like JSONPath against the bound value:

```json
"$item.name"          // field access on bound value
"$found.profile.email" // nested field access
"$item.tags[0]"       // array index on bound value
```

If the sub-path does not exist, `?` can be appended to make it optional:

```json
"$item.optional_field?"
```

## `$entries` — object to array

Append `.$entries` to a path to convert an object into an array of `{key, value}` pairs:

```jsonpath
$.data.settings.$entries
```

Given `{"settings": {"theme": "dark", "lang": "en"}}`, this produces:

```json
[{"key": "theme", "value": "dark"}, {"key": "lang", "value": "en"}]
```

Use with `map` in implications to iterate over object keys:

```json
{
  "map": {
    "in": "$.data.config.$entries",
    "as": "$entry",
    "emit": {
      "aggregate_type": "audit",
      "id": "$entry.key",
      "event_type": "config_changed",
      "data": {"key": "$entry.key", "value": "$entry.value"}
    }
  }
}
```

`$entries` returns `null` if the value at the base path is not an object.

## `$merge` — object composition

Combine multiple objects into one using `$merge`. Objects are merged left to right, with later values overriding earlier ones:

```json
{
  "set": {
    "target": "result",
    "value": {"$merge": ["$found", {"updated": true}, "$.data.extra"]}
  }
}
```

Each item in the `$merge` array can be:
- A JSONPath resolving to an object (`"$.data.profile"`)
- A binding resolving to an object (`"$found"`)
- A literal object (`{"status": "active"}`)

Non-object items and optional-missing items are silently skipped.

## Predicate paths

Predicates in `if`/`then`/`else` conditionals and array operations use JSONPath for comparisons:

```json
{
  "if": {"equals": ["$.data.status", "active"]},
  "then": [
    {"set": {"target": "status", "value": "$.data.status"}}
  ]
}
```

Array predicates (`every`, `some`) resolve their `in` field against state for simple paths and against the event for JSONPaths:

```json
{
  "every": {
    "in": "items",
    "match": {"equals": ["$item.done", true]}
  }
}
```

Here `"items"` resolves against the current aggregate state (no `$` prefix), while `"$item.done"` accesses each array element via the iteration binding.

## Practical examples

### Set from event data

```json
{"set": {"target": "name", "value": "$.data.name"}}
{"set": {"target": "created_at", "value": "$.metadata.timestamp"}}
{"set": {"target": "created_by", "value": "$.metadata.actor.id"}}
```

### Deep property access

```json
{"set": {"target": "shipping_zip", "value": "$.data.shipping.address.zip_code"}}
```

### Array element access

```json
{"set": {"target": "first_item", "value": "$.data.items[0].name"}}
{"set": {"target": "last_item", "value": "$.data.items[-1].name"}}
```

### Optional fields

```json
{"set": {"target": "note", "value": "$.data.note?"}}
{"set": {"target": "priority", "value": "$.data.priority?"}}
```

### Using bindings with `$merge`

```json
[
  {"let": {"name": "$found", "find_target": "items", "find_field": "id", "find_value": "$.data.item_id"}},
  {"set": {"target": "selected", "value": {"$merge": ["$found", {"flag": true}]}}}
]
```

### Implication routing

```json
{
  "emit": {
    "aggregate_type": "user",
    "id": "$.metadata.actor.id",
    "event_type": "had_order_placed",
    "data": {"order_key": "$.key", "customer": "@.customer_name"}
  }
}
```

### Iterating object keys

```json
{
  "map": {
    "in": "$.data.settings.$entries",
    "as": "$setting",
    "emit": {
      "aggregate_type": "audit",
      "id": "$setting.key",
      "event_type": "setting_changed",
      "data": {"value": "$setting.value"}
    }
  }
}
```

## Not supported

j17 uses a focused subset of JSONPath. The following standard JSONPath features are not implemented:

| Feature | Syntax | Alternative |
|---------|--------|-------------|
| Wildcard | `$.*`, `$.items[*]` | Use explicit paths or `map` |
| Recursive descent | `$..name` | Use explicit nested paths |
| Filter expressions | `$.items[?(@.active)]` | Use `filter` operation with predicates |
| Slice | `$.items[0:3]` | Use individual indices |
| Union | `$.items[0,2]` | Use individual indices |
| Script expressions | `$.items[(@.length-1)]` | Use `[-1]` for last element |
| Bracket notation for fields | `$.data["name"]` | Use dot notation `$.data.name` |

For complex data transformation, use Tick operations (`filter`, `map`, `every`, `some`) or [WASM handlers](/docs/reference/wasm).

## Quick reference

### Event paths (handlers, implications, sagas)

| Syntax | Description |
|--------|-------------|
| `$.key` | Aggregate key (`user:abc123`) |
| `$.id` | Aggregate ID (`abc123`) |
| `$.type` | Event type |
| `$.data.*` | Event data |
| `$.metadata.*` | Event metadata (actor, timestamp, target) |
| `@.*` | Aggregate state |
| `$.path?` | Optional (no-op if missing) |

### Handler-specific

| Syntax | Description |
|--------|-------------|
| `"field"` (no prefix) | State path (handler targets, predicate `in`) |
| `$binding.field` | Variable binding (after `let`, in `map`/`every`/`some`) |
| `$.path.$entries` | Object to `{key, value}` array |
| `{"$merge": [...]}` | Shallow object merge |

### Saga-specific

| Syntax | Description |
|--------|-------------|
| `$prev.type` | Previous step's response type |
| `$prev.data.*` | Previous step's response data |
| `$context.<step>.*` | Step result by name |
| `$error.message` | Error message (in `on_failed`) |
| `$error.step` | Failed step name (in `on_failed`) |

### Deprecated

| Syntax | Use instead |
|--------|-------------|
| `$.state.*` | `@.*` |

### Not available in projection `select`

| Syntax | Why |
|--------|-----|
| `$.path?` | Handler extension; rejected at deploy time in projections |
| `@.*` | No "current state" — name the source binding instead (`$.order.status`) |
| `$.data.*`, `$.metadata.*`, `$.key`, `$.type` | Projections resolve against aggregate state, not an event |
| `$.path.$entries`, `{"$merge": [...]}` | Handler extensions |

Projection `select` has its own transforms (`$map`, `$lookup`) and its own row-level `fan_out`. See the [projections guide](/docs/guides/projections#select-expressions).

## See also

- [Tick reference](/docs/reference/tick) — using JSONPath in handlers
- [Implications guide](/docs/guides/implications) — using JSONPath in implications
- [Sagas guide](/docs/guides/sagas) — saga-specific templates
- [Projections guide](/docs/guides/projections#paths-in-select-are-not-handler-paths) — the different path language used by `select`

# WASM Handlers

Tick covers 80% of use cases. For the rest -- arithmetic, string manipulation, complex business rules -- you can escape to WebAssembly for full programmatic control.

## When to use WASM

**Use Tick when you can.** Declarative handlers are simpler to reason about, easier to audit, and have no build step.

**Escape to WASM when you need:**

- Arithmetic calculations (`price * quantity * (1 - discount)`)
- String manipulation (formatting, parsing, concatenation)
- Date/time logic (add days, calculate durations)
- Complex business rules (multi-condition logic trees)
- Loops with complex termination conditions
- Data transformations Tick can't express

## What WASM can and cannot do

**Can do:**
- Any pure computation (math, string ops, data transforms)
- Read event data and current aggregate state
- Return new state (handlers) or emit events (implications)
- Complex conditional logic

**Cannot do:**
- Network calls or I/O
- Access filesystem
- Call external services
- Persist data outside the aggregate

WASM handlers are pure functions. No side effects, no external access.

## The contracts

WASM is used in two places: **handlers** (computing aggregate state) and **implications** (reactive event creation). Each has a different contract.

### Handler contract

Handlers receive the current aggregate state and the event, and return the new state.

**Input:**
```json
{
  "state": { "items": [], "total": 0 },
  "event": {
    "key": "order:abc123",
    "type": "had_item_added",
    "data": { "sku": "WIDGET-1", "price": 9.99, "quantity": 2 },
    "metadata": { "actor": { "type": "user", "id": "u001" } }
  }
}
```

**Output (success):**
```json
{
  "state": {
    "items": [{ "sku": "WIDGET-1", "price": 9.99, "quantity": 2, "line_total": 19.98 }],
    "total": 19.98
  }
}
```

The returned `state` replaces the aggregate's state entirely. This is the key difference from implications -- handlers transform state, implications emit new events.

**Output (error):**
```json
{
  "error": {
    "type": "validation_failed",
    "message": "Quantity must be positive",
    "code": "INVALID_QUANTITY",
    "context": { "field": "quantity", "value": -5 }
  }
}
```

Error fields:
- `type` (required) -- Error category like `"validation_failed"`, `"business_rule"`, `"precondition"`
- `message` (required) -- Human-readable description
- `code` (optional) -- Application-specific code for programmatic handling
- `context` (optional) -- Structured data about the error

Handler errors are returned to the client with a 422 status.

### Implication contract

Implications receive the triggering event and context, and return actions (events to emit).

**Input:**
```json
{
  "event": {
    "key": "order:abc123",
    "type": "was_placed",
    "data": { "items": [{ "product_id": "p-123", "quantity": 2 }] },
    "metadata": { "actor": { "type": "user", "id": "u001" } }
  },
  "context": {}
}
```

**Output:**
```json
{
  "actions": [
    {
      "emit": {
        "aggregate_type": "inventory",
        "id": "p-123",
        "event_type": "was_reserved",
        "data": { "quantity": 2, "order_id": "abc123" }
      }
    }
  ]
}
```

Each action's `emit` object requires:
- `key` (as `"type:id"`) **or** both `aggregate_type` and `id`
- `event_type` -- The event type to create
- `data` -- Event payload (optional, defaults to `{}`)

Implications can also return errors using the same error format as handlers.

## Spec configuration

### Handler

Short form -- blob name only, uses default entrypoint `apply_handler`:

```json
{
  "aggregate_types": {
    "order": {
      "events": {
        "had_item_added": {
          "schema": { "..." : "..." },
          "handler": { "wasm": "order-handler.wasm" }
        }
      }
    }
  }
}
```

Long form -- custom entrypoint and timeout:

```json
{
  "handler": {
    "wasm": {
      "blob_name": "order-handler.wasm",
      "entrypoint": "handle_item_added",
      "timeout_ms": 100
    }
  }
}
```

Handler config fields:
- `blob_name` (required) -- Name of the uploaded WASM blob
- `entrypoint` (optional) -- Function to call, defaults to `"apply_handler"`
- `timeout_ms` (optional) -- Execution timeout, defaults to `100`

### Implication

Short form:

```json
{
  "implications": [
    { "wasm": "order-notifier.wasm" }
  ]
}
```

Long form:

```json
{
  "implications": [
    {
      "wasm": {
        "blob_name": "order-enricher.wasm",
        "entrypoint": "compute_notifications",
        "mode": "emit"
      }
    }
  ]
}
```

Implication config fields:
- `blob_name` (required) -- Name of the uploaded WASM blob
- `entrypoint` (optional) -- Function to call, defaults to `"compute_implications"`
- `mode` (optional) -- `"emit"` (return events) or `"transform"` (enrich context for pipeline chaining), defaults to `"emit"`

WASM implications can be used in [pipelines](/docs/reference/implications-reference) alongside tick implications:

```json
{
  "implications": [
    {
      "pipeline": [
        { "wasm": { "blob_name": "enrich.wasm", "mode": "transform" } },
        {
          "emit": {
            "aggregate_type": "notification",
            "id": "$.metadata.actor",
            "event_type": "was_queued",
            "data": {}
          }
        }
      ]
    }
  ]
}
```

## Memory interface

j17 uses a simple memory protocol for passing data between the host and your WASM module. Your module must export three functions:

| Export | Signature | Purpose |
|--------|-----------|---------|
| `malloc` | `(size: i32) -> i32` | Allocate a buffer, return pointer |
| `free` | `(ptr: i32)` | Free a buffer (optional, not called by j17) |
| `apply_handler` | `(ptr: i32, len: i32) -> i64` | Process input, return packed result |

The entrypoint (default `apply_handler`) returns a packed i64: `(output_ptr << 32) | output_len`.

Data flow:
1. j17 calls `malloc(input_size)` to allocate a buffer in WASM memory
2. j17 copies the input JSON into that buffer
3. j17 calls `apply_handler(ptr, len)` with the buffer location
4. Your function reads the input, computes the result, allocates an output buffer, and returns the packed pointer+length
5. j17 reads the output JSON from WASM memory

## Building WASM

Any language that compiles to WASM works. The module must export `malloc` and `apply_handler` (or your custom entrypoint name).

### AssemblyScript

TypeScript-like syntax, compiles directly to WASM with no separate toolchain.

```typescript
import { JSON } from "assemblyscript-json";

export function malloc(size: i32): i32 {
  return heap.alloc(size) as i32;
}

export function free(ptr: i32): void {
  heap.free(ptr);
}

export function apply_handler(ptr: i32, len: i32): i64 {
  // Read input JSON from memory
  const input = String.UTF8.decodeUnsafe(ptr, len);
  const json = <JSON.Obj>JSON.parse(input);

  const event = json.getObj("event")!;
  const state = json.getObj("state")!;

  // Your logic here...
  const output = '{"state": {"processed": true}}';

  // Write output to memory and return packed pointer+length
  const outBytes = String.UTF8.encode(output);
  const outPtr = changetype<i32>(outBytes);
  const outLen = outBytes.byteLength;

  return (i64(outPtr) << 32) | i64(outLen);
}
```

Build:

```bash
asc handler.ts -o handler.wasm --optimize
```

### Zig

```zig
const std = @import("std");

var allocator = std.heap.wasm_allocator;

export fn malloc(size: i32) i32 {
    const slice = allocator.alloc(u8, @intCast(size)) catch return 0;
    return @intCast(@intFromPtr(slice.ptr));
}

export fn free(ptr: i32) void {
    _ = ptr;
    // wasm_allocator doesn't support individual frees
}

export fn apply_handler(ptr: i32, len: i32) i64 {
    const input = @as([*]u8, @ptrFromInt(@intCast(ptr)))[0..@intCast(len)];

    // Parse input JSON, compute result...
    const parsed = std.json.parseFromSlice(
        std.json.Value, allocator, input, .{}
    ) catch return 0;
    defer parsed.deinit();

    // Build output
    const output = "{\"state\": {\"computed\": true}}";
    const out_buf = allocator.alloc(u8, output.len) catch return 0;
    @memcpy(out_buf, output);

    const out_ptr: i64 = @intCast(@intFromPtr(out_buf.ptr));
    const out_len: i64 = @intCast(out_buf.len);
    return (out_ptr << 32) | out_len;
}
```

Build:

```bash
zig build-lib handler.zig -target wasm32-freestanding -O ReleaseSmall
```

### Rust

```rust
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};

#[derive(Deserialize)]
struct Input {
    event: Value,
    state: Value,
}

#[derive(Serialize)]
struct Output {
    state: Value,
}

#[no_mangle]
pub extern "C" fn malloc(size: i32) -> *mut u8 {
    let mut buf = Vec::with_capacity(size as usize);
    let ptr = buf.as_mut_ptr();
    std::mem::forget(buf);
    ptr
}

#[no_mangle]
pub extern "C" fn free(ptr: *mut u8, size: i32) {
    unsafe {
        let _ = Vec::from_raw_parts(ptr, 0, size as usize);
    }
}

#[no_mangle]
pub extern "C" fn apply_handler(ptr: *const u8, len: i32) -> i64 {
    let input_bytes = unsafe {
        std::slice::from_raw_parts(ptr, len as usize)
    };
    let input: Input = serde_json::from_slice(input_bytes).unwrap();

    // Your logic here...
    let output = Output {
        state: json!({"processed": true}),
    };

    let output_bytes = serde_json::to_vec(&output).unwrap();
    let out_ptr = output_bytes.as_ptr() as i64;
    let out_len = output_bytes.len() as i64;
    std::mem::forget(output_bytes);

    (out_ptr << 32) | out_len
}
```

Build:

```bash
cargo build --target wasm32-unknown-unknown --release
```

### C

```c
#include <stdlib.h>
#include <string.h>

__attribute__((export_name("malloc")))
void* wasm_malloc(int size) {
    return malloc(size);
}

__attribute__((export_name("free")))
void wasm_free(void* ptr) {
    free(ptr);
}

__attribute__((export_name("apply_handler")))
long long apply_handler(char* ptr, int len) {
    // Parse input JSON (use a JSON library like cJSON)
    // ...

    // Build output
    const char* output = "{\"state\": {\"done\": true}}";
    int out_len = strlen(output);
    char* out_buf = malloc(out_len);
    memcpy(out_buf, output, out_len);

    long long result = ((long long)(size_t)out_buf << 32)
                     | (long long)out_len;
    return result;
}
```

Build:

```bash
clang --target=wasm32 -O2 -nostdlib \
  -Wl,--no-entry -Wl,--export-all \
  -o handler.wasm handler.c
```

## Uploading WASM blobs

Upload your compiled `.wasm` file via the admin API:

```bash
curl -X POST https://myapp.j17.dev/_admin/blobs \
  -H "Authorization: Bearer $OPERATOR_JWT" \
  -F "name=order-handler.wasm" \
  -F "file=@./dist/order-handler.wasm"
```

Blobs are versioned. The spec references by name; j17 uses the latest version. You can pin a specific version with `"blob_name": "order-handler.wasm:v2"`.

## Output format

When using WASM as a handler, the output is a complete state replacement:

```json
{ "state": { "status": "processed", "total": 42.50 } }
```

When using WASM as an implication, the output contains actions:

```json
{
  "actions": [
    { "emit": { "key": "user:u001", "event_type": "was_notified", "data": { "message": "Order placed" } } },
    { "emit": { "aggregate_type": "inventory", "id": "p-123", "event_type": "was_reserved", "data": { "quantity": 2 } } }
  ]
}
```

## Error handling

Both handlers and implications can return structured errors:

```json
{
  "error": {
    "type": "business_rule",
    "message": "Cannot add items to a completed order",
    "code": "ORDER_COMPLETED",
    "context": { "order_status": "completed" }
  }
}
```

j17 distinguishes between:
- **Handler errors** -- returned to the caller as a 422 response
- **Runtime errors** -- WASM traps, timeouts, memory violations -- returned as 500 responses with diagnostic hints

## Testing WASM locally

Test your handler before uploading:

```bash
# Run your WASM with test input using any WASM runtime
echo '{"state": {}, "event": {"key": "order:123", "type": "had_item_added", "data": {"sku": "W1", "price": 9.99}}}' \
  | your-wasm-runner handler.wasm

# Or upload to a test environment and exercise via the API
curl -X POST https://myapp-test.j17.dev/order/test-123/had_item_added \
  -H "Authorization: Bearer $TEST_KEY" \
  -d '{"data": {"sku": "W1", "price": 9.99, "quantity": 1}}'
```

For unit testing in your language's native test framework, extract your business logic into testable functions and test the JSON-in/JSON-out contract independently of the WASM memory interface.

## Performance

j17 executes WASM via the **wasm3 interpreter** (not a JIT). This is a deliberate choice for security and predictability.

Performance characteristics:
- **Cold start**: Microseconds (module parse + instantiate)
- **Warm execution**: Sub-millisecond for typical handlers
- **Default timeout**: 100ms per invocation
- **Stack size**: 64KB default

Compared to Tick handlers (~0.01ms), WASM has measurable overhead. The difference adds up at scale, so use WASM only when Tick can't express your logic.

Tips for fast WASM handlers:
- Keep modules small (<1MB recommended)
- Avoid heavy allocations in hot paths
- Reuse buffers where possible
- Pre-compute what you can at build time

## Debugging

If your WASM handler fails, check these common issues in order:

1. **Missing exports** -- Are `malloc` and your entrypoint (default `apply_handler`) exported? Use `wasm-objdump -x handler.wasm` or `wasm-tools print handler.wasm` to inspect exports.

2. **Wrong return value** -- The entrypoint must return an i64 with `(ptr << 32) | len`. A common mistake is returning just a pointer or just a length.

3. **Invalid JSON** -- Is your output valid JSON matching the contract? Missing `"state"` key (for handlers) or `"actions"` key (for implications) will produce an `invalid_output` error.

4. **Panics** -- Unhandled errors in Rust/Zig abort the WASM instance, surfacing as a trap. Handle all errors gracefully and return an error JSON instead.

5. **Memory bounds** -- Writing past the end of allocated memory causes a trap. Ensure your output buffer is large enough.

Enable debug tracing to see WASM input/output in the j17 logs:

```bash
curl -X POST https://myapp-test.j17.dev/_admin/debug \
  -H "Authorization: Bearer $JWT" \
  -d '{"wasm_trace": true}'
```

## Security

WASM handlers run with defense-in-depth isolation:

| Layer | Protection |
|-------|------------|
| WASM | Memory sandboxing, no system calls |
| wasm3 | Resource limits, interpreter-based (no JIT = no JIT bugs) |
| Zig NIF | Crash isolation via BEAM NIF semantics |
| BEAM | Process isolation, fault tolerance |

Constraints enforced at runtime:
- 64KB stack (configurable)
- 100ms execution timeout (configurable per handler)
- No filesystem access
- No network access
- Memory isolated per invocation
- No access to other instances' data

Exceeding limits returns a 500 with a diagnostic error (timeout, OOM, or trap).

## Example: Order line total calculation

A handler that computes line totals -- something Tick can't do because it requires multiplication:

```typescript
// AssemblyScript
import { JSON } from "assemblyscript-json";

export function malloc(size: i32): i32 {
  return heap.alloc(size) as i32;
}

export function free(ptr: i32): void {
  heap.free(ptr);
}

export function apply_handler(ptr: i32, len: i32): i64 {
  const input = String.UTF8.decodeUnsafe(ptr, len);
  const json = <JSON.Obj>JSON.parse(input);

  const event = json.getObj("event")!;
  const data = event.getObj("data")!;
  const state = json.getObj("state")!;

  const price = data.getNum("price")!.valueOf();
  const quantity = data.getInteger("quantity")!.valueOf();
  const line_total = price * f64(quantity);

  // Get current total from state, add line total
  const current_total = state.getNum("total")
    ? state.getNum("total")!.valueOf()
    : 0.0;

  const output = `{"state": {"total": ${current_total + line_total}}}`;

  const outBytes = String.UTF8.encode(output);
  const outPtr = changetype<i32>(outBytes);
  const outLen = outBytes.byteLength;
  return (i64(outPtr) << 32) | i64(outLen);
}
```

## Example: Fraud detection implication

An implication that checks order amount and emits a review event for high-value orders:

```typescript
export function apply_handler(ptr: i32, len: i32): i64 {
  const input = String.UTF8.decodeUnsafe(ptr, len);
  const json = <JSON.Obj>JSON.parse(input);

  const event = json.getObj("event")!;
  const data = event.getObj("data")!;
  const metadata = event.getObj("metadata")!;
  const actor = metadata.getObj("actor")!;

  const amount = data.getNum("amount")!.valueOf();
  const user_id = actor.getString("id")!.valueOf();

  let output: string;

  if (amount > 1000.0) {
    output = `{
      "actions": [{
        "emit": {
          "aggregate_type": "review",
          "id": "${user_id}",
          "event_type": "was_flagged",
          "data": {"amount": ${amount}, "reason": "high_value_order"}
        }
      }]
    }`;
  } else {
    output = '{"actions": []}';
  }

  const outBytes = String.UTF8.encode(output);
  const outPtr = changetype<i32>(outBytes);
  const outLen = outBytes.byteLength;
  return (i64(outPtr) << 32) | i64(outLen);
}
```

## See also

- [Tick reference](/docs/reference/tick) -- Declarative handlers (use these first)
- [Implications reference](/docs/reference/implications-reference) -- Reactive event creation
- [Spec reference](/docs/reference/spec) -- Using WASM in specs

# Tombstones (GDPR Erasure)

j17 provides built-in tombstone support for GDPR Article 17 ("Right to Erasure", also known as the "Right to be Forgotten") and similar data deletion requirements. Tombstoning replaces event payloads with tombstone markers while preserving stream structure, hash chain integrity, and an audit trail.

## Why tombstones

Event sourcing and GDPR sit in tension: event stores are append-only, but data subjects can request erasure. Deleting events would break hash chains and lose the audit history that regulators expect.

Tombstones resolve this by replacing event payloads in place. The stream keeps its structure and length, the hash chain is recomputed, and the original content hash is preserved so you can prove an event existed without retaining its contents.

## How it works

1. **Request** a tombstone for an aggregate (e.g., `user:alice`).
2. **Grace period** (minimum 72 hours) allows cancellation.
3. **Execution** replaces all events in the stream with `_was_tombstoned` markers.
4. **Direct cascade** optionally tombstones streams that directly reference the subject at execution time (e.g., direct messages authored by Alice).
5. **Audit record** captures what was deleted, when, by whom, and the legal basis.

After tombstoning, the stream still exists with the same number of events, but every event's payload is replaced with a tombstone marker containing the original content hash (for auditability) and no PII.

## Spec configuration

Configure direct tombstone cascade behavior per aggregate type using `onTombstone` in your spec:

```json
{
  "aggregate_types": {
    "user": {
      "events": { "..." : "..." }
    },
    "message": {
      "events": { "..." : "..." },
      "onTombstone": {
        "actor": "cascade",
        "target": "preserve"
      }
    },
    "order": {
      "events": { "..." : "..." },
      "onTombstone": {
        "actor": "preserve",
        "target": "preserve"
      }
    }
  }
}
```

### Cascade rules

Each role (`actor`, `target`) can be set to:

| Value | Behavior |
|-------|----------|
| `"cascade"` | Tombstone the stream if the deleted entity appears in this role |
| `"preserve"` | Leave the stream untouched |

In the example above, when `user:alice` is tombstoned:

- **`message:m1`** where Alice is the **actor** (sender) -- tombstoned (`actor: "cascade"`)
- **`message:m2`** where Alice is the **target** (recipient) -- preserved (`target: "preserve"`)
- **`order:o1`** where Alice is the **actor** (buyer) -- preserved (`actor: "preserve"`)

Aggregate types without an `onTombstone` configuration are never scanned during cascade discovery.

### How direct discovery works

When a tombstone executes, j17 scans all aggregate types that have at least one `"cascade"` role in their `onTombstone` config. For each stream of those types, it reads all events and checks whether the tombstoned entity appears as `metadata.actor` or `metadata.target`. If it does, the cascade rule for that role determines whether the stream is tombstoned or preserved.

This is a one-hop, point-in-time operation: j17 follows actor/target relationships that directly name the tombstoned entity when execution is planned. It does not recursively follow references from one cascaded stream to another.

Cascade is **stream-granular**, not event-granular. If one event in a matching stream directly names the subject in a cascaded role, the entire aggregate stream is tombstoned. Model independently erasable material as its own aggregate: a `comment:*` stream authored by Alice can be safely cascaded by itself, while configuring `post.actor` to cascade would erase Bob's whole `post:*` stream if Alice authored even one comment event inside it. Shared or container aggregates should normally use `"preserve"`.

## Tombstone lifecycle

```
create ──→ pending ──→ executing ──→ completed
               │             │
               │             └──→ failed ──→ executing
               └──→ cancelled
```

| Status | Meaning |
|--------|---------|
| `pending` | Created, within grace period or awaiting manual execution |
| `executing` | Tombstone execution in progress (streams being rewritten) |
| `completed` | All streams rewritten, caches cleared, scheduled work cancelled |
| `cancelled` | Tombstone cancelled during grace period |
| `failed` | Execution stopped safely; the persisted plan can be resumed |

### Grace period

Every tombstone has a minimum grace period of **72 hours** (enforced server-side). You can request a longer grace period with the `grace_period_days` parameter. If you pass a value that results in less than 72 hours, the minimum is applied.

During the grace period, the tombstone can be cancelled. After the grace period elapses, the tombstone can be executed.

To execute before the grace period elapses, pass `force=true` to the execute endpoint. This is logged as a warning in the server logs.

`force` accepts only `true` or `false`. Any other value returns `400` rather than being read as "don't force" — so a misspelled flag tells you it was misspelled, instead of failing later with a grace-period error you did not expect.

## Tombstone event format

After tombstoning, each original event in the stream is replaced with:

```json
{
  "key": "user:alice",
  "type": "_was_tombstoned",
  "data": {
    "original_hash": "7f3a2b9c8d1e...",
    "chain_hash": "a4b2c1d3e5f6...",
    "original_type": "was_created",
    "tombstone_id": "550e8400-...",
    "tombstoned_at": "2026-03-08T14:30:00Z"
  },
  "metadata": {
    "actor": {"type": "system", "id": "system"},
    "timestamp": 1741444200
  }
}
```

| Field | Description |
|-------|-------------|
| `original_hash` | SHA-256 of the original event JSON (proves an event existed without retaining its contents) |
| `chain_hash` | The event's hash chain position before tombstoning (allows reconstruction of the pre-tombstone Merkle root) |
| `original_type` | The event type that was replaced (e.g., `was_created`, `had_profile_updated`) |
| `tombstone_id` | Links back to the tombstone record for audit purposes |
| `tombstoned_at` | ISO 8601 timestamp of when the tombstone was executed |

The hash chain is recomputed after rewriting. The `chain_hash` field preserves the pre-tombstone hash so the original Merkle root can be verified against external anchors.

## Execution details

When a tombstone executes, the following steps occur in order:

1. **Persist the execution plan** -- all aggregate types with `onTombstone` cascade rules are scanned. Directly referencing streams are classified as `cascade` or `preserve`, and the complete plan and one fixed execution timestamp are stored before any stream changes.

2. **Snapshot root anchor** -- after the plan is fixed, a Merkle root is computed and stored as `pre_tombstone_root`. This proves the state immediately before erasure; writes appended after planning are included in the root but preserved by the prefix rewrite.

3. **Rewrite streams** -- the primary stream and all cascade-marked directly referencing streams are rewritten. Planned events are replaced with `_was_tombstoned` markers and the hash chain is recomputed. Events appended after the point-in-time plan are preserved. Progress is recorded after every stream, so a retry or restart resumes the same plan instead of rediscovering an incomplete one.

4. **Clear caches** -- cached aggregates for all tombstoned streams are deleted.

5. **Cancel scheduled work** -- any pending scheduled events targeting tombstoned streams are cancelled.

6. **Mark completed** -- the tombstone record is updated with `completed` status, `affected_streams` summary, and `executed_at` timestamp.

If execution fails after all retries, the tombstone moves to `failed`, retains its plan and progress, and can be reattempted. Creation, cancellation, execution claims, and completion are coordinated through authoritative event streams with optimistic concurrency; the dashboard database is only a read projection.

## Audit trail

Each completed tombstone records:

| Field | Description |
|-------|-------------|
| `pre_tombstone_root` | Merkle root computed immediately before erasure (cryptographic proof of prior state) |
| `affected_streams` | List of streams that were rewritten, with event counts and actions |
| `executed_at` | When the tombstone was executed |
| `legal_basis` | The legal justification provided at creation time |
| `request_id` | External request tracking ID |
| `requested_by` | Who requested the erasure |

The `affected_streams` field contains entries like:

```json
[
  {"key": "user:alice", "event_count": 12, "action": "tombstoned"},
  {"key": "message:m1", "event_count": 3, "action": "tombstoned"},
  {"key": "message:m2", "event_count": 5, "action": "tombstoned"}
]
```

## Writes after tombstoning

j17 does **not** block writes to a tombstoned aggregate key. After tombstoning `user:alice`, your application can still write new events to `user:alice`.

This is deliberate:

- **j17 stores opaque event payloads.** The platform does not know whether your events contain PII. Whether a post-tombstone write reintroduces personal data is a concern for your application layer, not the event store.
- **The audit trail is preserved.** The tombstone record, pre-tombstone Merkle root, and original content hashes remain regardless of subsequent writes. New events append after the tombstone markers.
- **Your application controls the boundary.** If you need to prevent writes to tombstoned aggregates, check tombstone status in your application before submitting events.

If re-creating a tombstoned entity reintroduces PII (e.g., because your system still maps the aggregate ID to a real person), that is a data controller responsibility under GDPR, not a storage platform concern.

## Best practices

**Record the legal basis.** Always provide `legal_basis` and `request_id` when creating tombstones. These fields are stored in the audit trail and demonstrate compliance to regulators.

**Use meaningful `requested_by` values.** Include the operator or system that initiated the request so the audit trail is traceable.

**Respect the grace period.** The 72-hour minimum exists so accidental tombstone requests can be caught and cancelled. Avoid routine use of `force=true`.

**Design your spec's `onTombstone` rules carefully.** Think through which aggregate types should cascade and which should be preserved. A direct message sent by a user contains PII and should cascade; an order placed by a user might need to be retained for financial records. The right answer depends on your domain and legal requirements.

**Tombstoning erases the user, not their footprint.** When a user is tombstoned, events they authored on other aggregates (e.g., a `was_commented_upon` event on an article) remain intact -- only the user's own aggregate stream is erased (plus any cascade-configured streams). The actor reference in those events will point to a tombstoned aggregate, so your application should handle missing actors gracefully (e.g., display "Deleted User"). This is the standard pattern used by GitHub, Reddit, and similar platforms.

**Aggregate-level, not event-level.** Tombstones erase entire streams, not individual events. If you need to erase specific content (e.g., a single comment's text) without erasing the whole aggregate, store that content in a mutable store outside the event stream and reference it by ID. Then you can delete the content without touching the event history.

**Check tombstone status before re-creating entities.** If your application allows re-registration with the same aggregate ID after tombstoning, the new events will append after the tombstone markers. The aggregate will re-compute from the tombstone markers (which produce no state) plus the new events. This works correctly, but you should be aware of it.

# Audit Format

This is the normative specification of j17's audit artifacts — hash chains,
Merkle trees, proofs, and signed anchors — as produced by format version 2. It
is written to be implementable without reading j17's source: a verifier built
from this page alone, in any language with SHA-256 and Ed25519, must accept
every artifact the platform produces and reject every forgery this format
defends against.

Format 2 differs from format 1 in exactly two ways: Merkle hashing is
domain-separated (leaves and internal nodes use distinct prefixes, per RFC
6962), and anchor signatures cover a canonical serialization of the anchor's
claims rather than the bare root hash. Event chain hashes are **unchanged** —
anything verifying chains against `GET /{type}/{id}/events?raw=true` is
unaffected by the version bump.

## Version signaling

Every artifact that carries hashes or signatures carries a `format` field:

- `"format": 2` — this specification.
- Field **absent** — format 1 (produced before platform 0.16.3). Format 1
  trees use no domain separation (leaves are used raw; internal nodes are
  `SHA-256(left || right)`), and format 1 anchor signatures cover the
  lowercase hex `merkle_root` string only.

A verifier must key on this field. New artifacts are always format 2; format 1
artifacts remain valid as format 1 and are never re-signed.

## Definitions

- `SHA256(x)` — SHA-256 over the bytes `x`, yielding 32 bytes.
- `hex(x)` — lowercase hexadecimal encoding of bytes `x`.
- `unhex(s)` — the bytes of a hex string (case-insensitive on input; j17
  always emits lowercase).
- `||` — byte concatenation.
- All hashes on the wire are 64-character lowercase hex strings.

## 1. Event chain hashes (unchanged from format 1)

Events in a stream are hash-chained. For a stream's events `e_1 .. e_n`, with
`bytes_i` the event's JSON **exactly as stored** (retrievable via
`GET /{type}/{id}/events?raw=true` as the `raw` field):

```
chain_1 = hex( SHA256( bytes_1 ) )
chain_i = hex( SHA256( chain_(i-1) || bytes_i ) )      for i > 1
```

Note the preimage concatenates the previous hash's **hex string** (64 ASCII
bytes) with the raw event bytes. The chain hash of an event is returned as
`hash` by `/events`, and is the input leaf value for its stream's Merkle tree.

## 2. Merkle trees

All j17 Merkle trees — per-stream (over chain hashes), per-instance (over
stream roots), and platform (over instance roots) — use the same
construction. The input is an ordered list of 64-hex strings `v_1 .. v_n`
(n ≥ 1; an empty input produces no tree and no anchor).

### 2.1 Leaf hashing

```
leaf_i = SHA256( 0x00 || unhex(v_i) )
```

A single `0x00` byte, then the 32 raw bytes of the input value. Leaves are
hashed **even when n = 1**: a single-leaf tree's root is `hex(leaf_1)`, not
`v_1`.

### 2.2 Internal nodes

Levels are built bottom-up. If a level has an odd number of nodes, its last
node is duplicated (so the level being paired always has even length).
Adjacent pairs combine as:

```
parent = SHA256( 0x01 || left || right )
```

where `left` and `right` are the 32-byte child hashes. The root of the last
level is the tree's root, emitted as lowercase hex.

The `0x00`/`0x01` prefixes are RFC 6962's domain separation: no internal node
can be reinterpreted as a leaf or vice versa, so second-preimage arguments do
not depend on what leaf values look like.

**On duplicate-last padding.** This construction is known to give `[A, B, C]`
and `[A, B, C, C]` the same root (the CVE-2012-2459 class). It is harmless
here because no j17 tree can legitimately contain equal adjacent inputs:
stream-tree inputs are chain hashes, and `chain_i == chain_(i-1)` requires
`SHA256(chain_(i-1) || bytes_i) == chain_(i-1)` — a SHA-256 break; instance-
and platform-tree inputs are roots over byte streams that embed their own
stream key or instance id, so equal inputs across entries likewise require a
collision. Duplication has one further consequence a verifier MUST handle:
the duplicated node exists at a real index, so an inclusion proof for a
**phantom index** — index 3 in a padded `[A, B, C, C]` tree — folds to the
signed root and verifies. A verifier MUST therefore reject any proof whose
claimed index is not below the signed count for its tree (`event.index <
stream.event_count`; a stream's index below the anchor's `stream_count`):
the format-2 anchor signs those counts precisely so this bound is available
offline. The j17 endpoints enforce it server-side; an independent verifier
consuming exported bundles must enforce it itself or it will accept a proof
of a nonexistent event. A verifier that wants no argument at all may
additionally reject any proof or tree whose adjacent inputs are equal.
(RFC 6962's unbalanced construction avoids the property entirely; j17 keeps
duplication for construction continuity with format 1, on the argument
above. The `0x00`/`0x01` prefixes are the part of RFC 6962 this format
adopts; the tree shape is not RFC 6962's.)

(Format 1 differs here only in having no prefixes and no leaf hashing: leaves
were used raw and parents were `SHA256(left || right)`.)

### 2.3 Which values feed which tree

| Tree | Input values `v_i` | Order |
|------|--------------------|-------|
| Stream | the stream's event chain hashes | event order |
| Instance | each non-empty stream's root | stream key, lexicographic ascending |
| Platform | each instance's anchored root | instance id, lexicographic ascending |

## 3. Inclusion proofs

A proof for input value `v` at index `i` (0-based) consists of `siblings`
(hex strings) and `directions` (`"left"` / `"right"`), one entry per tree
level below the root. To verify against a claimed root:

```
current = SHA256( 0x00 || unhex(v) )            # the leaf hash
for (sibling, direction) in zip(siblings, directions):
    if direction == "right":                     # sibling is on the right
        current = SHA256( 0x01 || current || unhex(sibling) )
    else:                                        # sibling is on the left
        current = SHA256( 0x01 || unhex(sibling) || current )
accept iff hex(current) == root
```

A single-leaf tree's proof has **empty** `siblings` and `directions` — this is
legal and expected: verification is then just the leaf hash itself, so
`accept iff hex(SHA256(0x00 || unhex(v))) == root`. A verifier must not
reject empty proof arrays.

Before the fold, a verifier must check `len(siblings) == len(directions)` and
reject on mismatch — a zip that silently truncates the longer list would turn
a malformed proof into a shorter fold that can still "verify".

Proof responses carry `"format": 2`. A format 1 proof verifies the same way
minus both prefixes and minus the leaf-hash step (start with
`current = unhex(v)`; a format 1 single-leaf proof accepts iff `v == root`).

## 4. Anchors and signatures

An anchor is a signed commitment to a tree root at a point in time. Format 2
signatures cover a **canonical payload** — a JSON object serialized with the
exact field order given below, no whitespace, integers in plain decimal — so
the anchor's claims (whose data, which environment, when) are bound by the
signature and cannot be relabelled.

All values in the payload are platform-generated (UUIDs, environment names,
hex digests, ISO 8601 UTC timestamps, integers) and contain no characters
requiring JSON escaping; a verifier can reconstruct the payload by direct
string interpolation.

### 4.0 Snapshot cut

An instance anchor describes one coherent cut across all of the instance's
event streams. The platform atomically captures the chain-tip hash of every
non-empty public stream, then reads each stream only through its captured tip.
Events appended after that cut are excluded even if their stream is read later
in the Merkle scan. An atomic write spanning several streams is therefore
included in full or excluded in full; an anchor never includes only some of its
events.

The full scan does not hold a write lock. If a destructive stream rewrite makes
a captured tip unavailable before it can be read, anchor construction abandons
that attempt and starts again from a fresh cut rather than signing a mixed
view.

`created_at` is the signed time at which the completed anchor is constructed.
It is not an event timestamp or a claim that every captured stream tip was
written at that instant.

### 4.1 Instance anchor payload

```
{"format":2,"scope":"instance","instance_id":"<uuid>","environment":"<env>","merkle_root":"<hex>","event_count":<int>,"stream_count":<int>,"created_at":"<iso8601>"}
```

### 4.2 Platform anchor payload

```
{"format":2,"scope":"platform","merkle_root":"<hex>","instance_count":<int>,"cutoff_at":"<iso8601>","created_at":"<iso8601>"}
```

### 4.3 Signature

```
signature = hex( Ed25519_sign( private_key, payload_bytes ) )
```

The anchor record stores each payload field individually, plus `signature`
and `public_key` (32-byte Ed25519 public key, hex). To verify: reconstruct
the payload from the record's fields exactly as specified, and check the
signature over those bytes with the record's public key. **Any field
substitution — a different environment, instance, timestamp, or root — must
fail.**

The `created_at` in the payload is generated by the builder *before* signing
and stored verbatim; it is the anchor's one authoritative timestamp.

(Format 1 signatures are `Ed25519_sign(private_key, merkle_root_hex_string)` —
they bind the root and nothing else.)

### 4.4 Key identity

Instance anchors are signed with the instance's key; platform anchors with
the platform key. `GET /_audit/signing-key` returns the instance's current
public key; the platform public key is published in the platform anchor
records themselves and pinned by external timestamps (OpenTimestamps /
RFC 3161) — see the [audit API guide](/docs/api/audit) for the trust chain.

## 5. Verification recipes

**A page of events is untampered relative to itself**: fetch
`/events?raw=true`, fold §1 over the entries, compare each computed hash to
the entry's `hash` where served. (The fold is self-sufficient — the served
hash is a cross-check, not an input, so verification works even where `hash`
is not served.)

**An event is in its stream's anchored root**: recompute its chain hash (§1),
verify its stream proof (§3) against the stream root.

**A stream is in a signed instance anchor**: verify the stream root's
inclusion proof against the anchor's `merkle_root`, then verify the anchor
signature (§4.3) with the anchor's public key.

**An instance root is in a signed platform anchor**: same shape one level up,
using the platform payload (§4.2). The platform anchor's external timestamps
prove the root existed at a point in time independent of j17.

## 6. Attestation bundles

`GET /{type}/{id}/audit/attestation/{index}?anchor={anchor_id}` returns, in one
response, everything §5's recipes need for one event — logarithmic in size and
disclosing nothing about other streams:

```json
{
  "ok": true,
  "format": 2,
  "event":          { "index": 2, "id": "...", "raw": "{...}", "hash": "...", "prev_hash": "..." },
  "stream":         { "key": "order:...", "merkle_root": "...", "event_count": 4 },
  "stream_proof":   { "siblings": ["..."], "directions": ["right", "left"] },
  "instance_proof": { "index": 0, "stream_count": 3, "siblings": ["..."], "directions": ["right"] },
  "anchor":         { "format": 2, "id": "...", "instance_id": "...", "environment": "...",
                      "merkle_root": "...", "signature": "...", "public_key": "...",
                      "event_count": 9, "stream_count": 3, "created_at": "..." },
  "anchored_at": "..."
}
```

To verify a bundle offline:

0. **Bind the bundle to its claimed stream**: decode `event.raw` and check its
   `"key"` field equals the stream you expect — and, belt-and-suspenders,
   equals `stream.key` in the response. This check is not redundant and must
   not be deleted as such: a sibling-only instance proof shows
   `stream.merkle_root` is in the anchor's tree *somewhere*, but nothing
   positional binds that root to a stream key the way the (redacted) streams
   map once did. The bytes are the binding — the chain hash covers
   `event.raw`, and `event.raw` embeds the key — but only if the verifier
   reads them. Without this step, a valid bundle for document A passes every
   other check when presented as a bundle for document B.
1. Check the coordinates: `event.index` equals the index you requested, and
   `instance_proof.stream_count` equals `anchor.stream_count` (a signed
   total — a free check that the proof was built against the signed tree,
   not a differently-sized one). Proof arrays obey §3's length rule.
2. Recompute the chain hash from `raw` and `prev_hash` (§1); compare to
   `event.hash`.
3. Verify `stream_proof` from `event.hash` to `stream.merkle_root` (§3).
4. Verify `instance_proof` from `stream.merkle_root` to `anchor.merkle_root`
   (§3).
5. Reconstruct the instance anchor payload from `anchor`'s fields (§4.1) and
   verify `signature` with `public_key`.

The anchor is redacted: it never includes the anchor's `streams` map — an
inclusion proof needs only siblings.

`anchored_at` is a convenience echo of `anchor.created_at` and `event.id` is
the storage-level stream id; **both are advisory**. The authoritative
timestamp is `anchor.created_at` — it is inside the signed payload; nothing
else in the envelope is, so nothing else should be treated as load-bearing.

Semantics:

- `anchor` (query parameter) pins the anchor by id; omitted means the latest
  anchor for the key's environment. Batch workflows should pin: resolve one
  anchor, then request every bundle against it.
- The bundle attests the stream **as of the anchor**: proofs are built over
  the first `stream.event_count` events, and events written after the anchor
  are not covered by it.
- Only format-2 anchors can back bundles.

Error contract (machine-readable `error_type`, always):

| `error_type` | Status | Meaning | Caller's move |
|---|---|---|---|
| `beyond_anchor_coverage` | 409 | The anchor predates this event (`"reason": "index"`) or this stream (`"reason": "stream"`). Carries `anchor_id`, `anchor_created_at`, `covered_event_count`. | Wait for, or mint, the next anchor. Not a fault. |
| `invalid_index` | 422 | Malformed or negative index. | Fix the request. |
| `anchor_format_unsupported` | 409 | A format-1 anchor cannot back a format-2 bundle. | Mint a fresh anchor. |
| `anchor_stream_mismatch` | 409 | The stream no longer reproduces the root the anchor signed (e.g. rewritten by a GDPR tombstone). | Mint a fresh anchor; the old one cannot attest this stream. |

## Change log

- **v2** (platform 0.16.3): domain-separated Merkle hashing (§2.1, §2.2);
  canonical signed anchor payload (§4). Event chain hashing unchanged.
  Attestation bundles (§6) added in 0.16.4.
- **v1** (through platform 0.16.2): raw leaves, unprefixed internal nodes,
  signature over bare root hex. Identified by the absence of `format`.

---


# FAQ

Common questions about j17. Not finding what you need? Email support@j17.dev

## Getting started

**How do I get an API key?**

Sign up at j17.dev, create an instance, then generate keys in Settings -> API Keys.

**Is there a free tier?**

Yes. 1,000 events/month, 1GB storage, 100 req/min. No credit card required.

**What's the difference between staging and production?**

Separate environments with isolated data and API keys. Test freely in staging without affecting production.

**Can I use j17 from the browser?**

Yes. j17 supports CORS. Use read-only API keys for client-side code, or proxy writes through your backend.

## Events and aggregates

**Can I delete an event?**

No. Events are immutable. Write a compensating event instead (e.g., `order_was_cancelled`).

**How many events can an aggregate have?**

There is no cap on event count. What is bounded is the work a single request may do: a
request that would have to rebuild an aggregate from its entire event log in one go is
refused with a 422 if that log exceeds 32 MB. Cached reads, writes and
`GET /:type/:id/events` are unaffected at any size, so the aggregate keeps working.

**What's a good aggregate size?**

Most aggregates have 10-100 events. Hundreds is fine. Thousands still reads in constant
time thanks to caching, but usually means the aggregate is accumulating a collection
rather than representing an entity -- see [aggregate sizing](/docs/concepts/aggregates#aggregate-sizing).

**Can I query across aggregates?**

Not directly. Use projections for multi-aggregate views, or export to a data warehouse for analytics.

**How do I list all users?**

Event sourcing isn't table scanning. Maintain a projection with user IDs, or track IDs in your database when you create them.

## Spec and handlers

**Do I need to redeploy when I change the spec?**

No. Upload the new spec via API or dashboard. Changes apply immediately.

**Can I have multiple event types with the same name?**

No. Event types are unique within an aggregate type. `user.was_created` and `order.was_created` are fine.

**What happens to old events when I change a handler?**

Nothing. Old events stay as-is. New events use the new handler. If you need to migrate, consider upcasters (store handler version in event metadata).

**Can I use TypeScript/JavaScript for handlers?**

Not directly. Tick's 17 declarative operations cover most cases without code. For complex logic that Tick can't express, that's a sign to rethink your event modeling.

**How do I validate complex business rules?**

Either:
1. Validate client-side before writing (for UX)
2. Use Tick conditionals for simple rules
3. Accept the event and handle invalid states via implications
4. Use external validation in your backend before posting to j17

## Performance

**How fast is j17?**

- Event write: ~5ms
- Aggregate read: ~2ms (small), ~10ms (medium), ~50ms (large)
- Tick handler: 300k+ ops/sec per core

**When should I use caching?**

For aggregates queried > 100 times/minute. Start without caching, add when needed.

**What are the rate limits?**

Per-instance, tiered by plan: Free 10/s, Hobby 100/s, Pro 200/s, Scale 1000/s (writes). Reads are 5x higher. See [rate limits](/docs/api/rate-limits) for the full table, response headers, and how to handle 429s.

**Can I use j17 for real-time applications?**

Use polling. SSE/WebSocket subscriptions are not yet available.

## Data and storage

**Where is my data stored?**

EU (Germany) by default. US and Asia regions coming soon. Contact support to request a region.

**Is my data encrypted?**

Yes. Encrypted at rest and in transit. TLS 1.3 for all connections.

**Can I export my data?**

Yes. Use the Admin API to export aggregates or events as JSON/NDJSON.

**What happens if I exceed my plan limits?**

At 80% of your plan's event allocation, write responses include an `X-J17-Limit-Warning` header. At 100%, writes are rejected with 429. Reads are never limited by plan. See [plan limits](/docs/api/rate-limits#plan-limits).

**How long do you keep my data?**

Forever, unless you delete it. We don't auto-purge old events.

## Safety limits

j17 enforces safety limits on spec complexity:

- **Handler depth**: max 5 levels of nesting
- **Handler operations**: max 100 total operations per event type

These exist to prevent accidentally creating specs that are expensive to evaluate.

## Security

**Can I rotate API keys?**

Yes. Create new keys, update your services, delete old keys. Old requests fail immediately.

**Are webhooks signed?**

Yes. j17 signs webhooks with HMAC-SHA256. Verify the signature to ensure authenticity.

**Can j17 see my data?**

Technically yes -- we store it. But we:
- Don't access it unless debugging a support issue (with permission)
- Don't sell or share it

**Is j17 SOC 2 compliant?**

In progress. Contact sales@j17.dev for compliance questions.

## Pricing and billing

**How is pricing calculated?**

Per day based on:
- Events written
- Storage used
- API calls made

See pricing page for tiers.

**Do you charge for reads?**

Yes, but minimally. Reads are 1/10th the cost of writes.

**What counts as an "event"?**

Every POST to `/{type}/{id}/{event}` counts as one event, regardless of size.

**Do scheduled/implied events count?**

Yes. Any event written to your instance counts toward your quota.

**Can I set billing alerts?**

Yes. Configure alerts at 50%, 80%, and 100% of your plan limits.

## Troubleshooting

**I get 409 Conflict. What does that mean?**

Optimistic concurrency check failed. Someone else wrote to the aggregate between your GET and POST. Refetch and retry.

**I get 422 Validation failed. How do I fix it?**

Your event data doesn't match the schema. Check the `details` field in the error response -- it tells you exactly what failed.

**Events aren't triggering implications.**

Check:
1. Is the implication defined in your spec?
2. Does the `when` condition evaluate to true?
3. Check `/admin/implications/failed` for errors

**My aggregate loads slowly.**

- Enable caching for frequently-read aggregates
- Create a checkpoint for large aggregates
- Check if you're including events unnecessarily

**I'm rate limited.**

Check the `X-RateLimit-Scope` header — `instance` means you've hit your tier's burst limit (upgrade for more), `ip` means the per-IP safety net fired. Use batch writes and exponential backoff. See [rate limits](/docs/api/rate-limits#what-to-do).

## Comparisons

**How is j17 different from Postgres?**

Postgres stores current state. j17 stores history and derives state. Trade-off: you get audit trails and temporal queries, you lose simple SQL.

**vs Redis?**

Redis is a cache. j17 is persistent storage with cache characteristics. Don't use Redis as your primary event store.

**vs Kafka?**

Kafka is a message bus. j17 is a database. Kafka doesn't compute aggregates or validate schemas.

**vs EventStoreDB/KurrentDB?**

j17 is managed, cheaper, and simpler. EventStoreDB is more powerful (projections, complex queries) but requires expertise and budget.

**vs Supabase/Firebase?**

Those are general-purpose databases. j17 is purpose-built for event sourcing. Use them for CRUD, j17 for audit trails and temporal data.

## Advanced

**Do you support multi-region?**

Not yet. Single region per instance. Multi-region replication is on the roadmap.

**Can I write events in a transaction?**

Yes. Use batch writes to post multiple events to the same aggregate atomically. For cross-aggregate atomicity, use implications.

**How do I migrate from another database?**

1. Identify events in your existing data (every UPDATE is a potential event)
2. Write a migration script that POSTs events to j17
3. Dual-write for a period (write to both old and new)
4. Cut over reads to j17
5. Stop writing to old database

See our migration guide for details.

**Can I use j17 with AI/LLMs?**

Yes. j17 is AI-friendly:
- Spec is JSON (LLMs understand it)
- API is HTTP (universal)
- Entire backend fits in a context window

Load our docs into Claude/Cursor and describe your app. The AI will generate your spec.

## Getting help

**Documentation:** docs.j17.dev
**Email:** support@j17.dev
**Status:** status.j17.dev

We typically respond within 24 hours. Enterprise customers get priority support with 4-hour SLA.
