Tick - Declarative Handlers

Last updated about 2 months ago

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:

{
  "was_created": {
    "schema": { ... },
    "handler": [
      { "set": { "target": "", "value": "$.data" } },
      { "set": { "target": "status", "value": "active" } }
    ]
  }
}

Each operation reads from the event (via 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:

{
  "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:

"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:
"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.

{ "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:

{ "set": { "target": "profile.name", "value": "$.data.name" } }

Target "" (empty string) means the root — use this to initialize state from event data:

{ "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.

{ "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.

{ "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.

{ "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.

{ "decrement": { "target": "stock", "by": "$.data.quantity" } }

Same behavior as increment but subtracts.

max

Advance a field to a new value, but never backwards.

{ "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 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.

{ "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:

{ "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.

{
  "filter": {
    "target": "sessions",
    "keep": {
      "not": {
        "expired": {
          "timestamp": "$item.created_at",
          "maxAgeSeconds": 1209600,
          "now": "$.metadata.timestamp"
        }
      }
    }
  }
}

The keep field takes any predicate. 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.

{
  "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.

{
  "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.

{ "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.

{
  "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.

{ "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.

{ "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.

{ "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.

{ "max_at": { "target": "clocks", "key": "$.data.clock_name", "value": "$.data.reading" } }

The dynamic-key counterparts to max and 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.

{ "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. 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.

{ "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. 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 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.

{
  "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:

{
  "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.

{
  "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:

{ "set": { "target": "primary_street", "value": "$address.street" } }

Variables work with $merge expressions:

{
  "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.

{ "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.

{ "includes": { "array": "@.approved_ids", "value": "$.data.user_id" } }

min_items

Check that an array has at least N elements.

{ "minItems": { "array": "@.items", "min": 1 } }

max_items

Check that an array has at most N elements.

{ "maxItems": { "array": "@.tokens", "max": 5 } }

expired

Check if a timestamp has exceeded a maximum age.

{
  "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.

{ "every": { "in": "@.stages", "match": { "equals": ["$item.status", "done"] } } }

Within match, $item refers to the current element.

some

Check that at least one element matches.

{ "some": { "in": "@.approvals", "match": { "equals": ["$item.role", "admin"] } } }

subset_of

Check that every item in one array exists in another.

{ "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:

{ "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.

{
  "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:

{
  "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:

{
  "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

When to escape to WASM

Tick covers the vast majority of state-building patterns. Use WASM handlers 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

Can't find what you need? support@j17.app