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:
{
"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. Only aggregate_types and agent_types are required inside spec. Everything else is optional.
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.
{
"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 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 for supported keywords.
Schema evolution
Events are immutable, but your understanding changes. Use optional fields and sensible defaults:
{
"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
{
"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.
{
"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 for the full rule (the same one applied to aggregate keys and target ids).
{
"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 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:
{
"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.
{
"target_types": ["user", "order", "product"]
}
Include in event metadata:
{
"metadata": {
"actor": { "type": "user", "id": "..." },
"target": { "type": "order", "id": "..." }
}
}
The target.id follows the same rule as aggregate keys and actor ids — see Identifiers. Useful for cross-aggregate queries: "show me all events where the target was this order."
singletons
Custom singleton identifiers beyond the implicit global.
{
"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):
{
"agent_types": ["user", "webhook"],
"singletons": ["twilio_webhook"]
}
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.
Concurrency
Singletons name a single aggregate, which means every write contends on the same Redis 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 or a projection 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 for the canonical reference.
modules
Optional configuration for platform features.
{
"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) |
cache |
Array of strings | Deprecated, removal before 1.0. Explicit opt-in cache list. |
sagas |
Object | Long-running process definitions |
All aggregate types are cached by default. Use never_cache to exclude specific types. See the caching guide for details.
See Implications guide for saga definitions.
geo_types
Define geographic shapes for geospatial queries.
{
"geo_types": {
"delivery_zone": {
"type": "polygon",
"properties": ["name", "delivery_fee"]
}
}
}
Attach to aggregates for location-based lookups:
{
"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:
curl "https://myapp.j17.dev/store/nearby?lat=40.7&lng=-74.0&radius=5000"
projections
Multi-aggregate views that combine data from multiple sources.
{
"projections": {
"user_dashboard": {
"sources": [
{ "aggregate": "user", "id": "$.user_id" },
{ "aggregate": "order", "query": "user_id = $.user_id" }
]
}
}
}
Projections are read-only and recomputed on demand. Use them when you need data from multiple aggregates in one call.
See Projections guide for details.
Validation at spec deploy
When you deploy a spec, j17 validates it in full before accepting it:
- Structure -- required fields present, correct types, no unknown keys
- agent_types -- non-empty array, no
system_*prefix - JSON Schema -- only supported keywords, valid Draft 2020-12 syntax
- Handlers -- valid tick operations and JSONPath expressions
- 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.
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:
- Aggregate type -- must exist in spec's
aggregate_types - Event type -- must exist for that aggregate type
- Actor --
typemust be inagent_types(or besystem_agent),idmust be a valid UUID - Target type -- if present, must be in
target_types - Event data -- must validate against the event's JSON Schema
- 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:
{
"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:
j17 spec validate spec.json
See also
- Tick reference -- handler operations
- JSON Schema reference -- validation keywords
- Projections guide -- multi-aggregate views
- Implications guide -- reactive event chains