501 rather than returning an empty document.
The subject
A subject is four fields:target_kindandtarget_nameare the conversation-target scope — the only tenancy the platform has. A subject never crosses this pair.kindnames the subject family the state serves. Kindpersonis the one platform-validated family: itskeyis a person id resolved against the conversations identity store, and the resolved person’s target must equal the subject’s target. Every other kind is declared by the state and its key is opaque to the platform.keyaddresses one subject within its kind. It is trimmed and must be 1 to 512 characters.
422) when its rule fires: an undeclared
kind, an empty key, an unknown person, or a person whose target does
not match the subject’s. A subject kind is a lowercase identifier of at most
63 characters (^[a-z][a-z0-9_-]{0,62}$).
Declarations and kinds
A state is declared before it is written. A declaration names the state, its base JSON Schema, the subject kinds it serves, and the default kind a door’s ambient subject resolves to:subject_kindsis a non-empty, unique list; each entry matches the subject-kind charset above.default_subject_kindmust be one ofsubject_kinds. It is the kind a door’s ambient subject resolves to when no explicit kind is named (see Doors).retention_daysis optional: a positive integer, or unset to keep records forever.- A state
namematches^[a-z0-9][a-z0-9_-]*$— no colon, because the write op-id namespace splits on:.
effective_schema (the base schema composed with every attached template’s fragment)
and regimes (the absolute write-regime rules composed over the attachments). A client
that supplies a non-null value for either on a write is refused — neither can be
forged across the wire.
Re-declaring
Once records exist, a re-declare accepts only additive schema changes: a re-declare that would remove or change an existing field while records exist is refused (409), and removing a subject kind still present in records is
refused the same way. retention_days is metadata, not schema, so changing it
alone is never gated.
Templates and attachments
A state template is a reusable schema fragment with fillable parameters, per-path writer regimes, attach-time declarations, a set of template jq programs, an optional reconcile program, and a trace switch. It is a document the platform owns:name is a slug —
^[a-z](?:[a-z0-9]|-(?=[a-z0-9])){0,62}$, so no leading, trailing, or consecutive hyphens —
because a template name is a URL path segment (/api/state-templates/{name}). A document that
carries keys outside its own schema is refused, so an operator who pastes a whole consumer
document is told exactly where those belong.
Every section is typed:


A state template in the studio, each section shown typed.
Rendered templates
The platform also serves a template rendered: every program body (inline or a storedid)
as jq text, a stored fragment as its schema, and the input programs in dependency order
(input_order). The states facet serves it three ways — get_rendered_template(name),
rendered_attachments(state) (every attachment on a state with its rendered template), and
render_template(document) for a candidate not yet stored. Each carries an opaque version
that changes whenever anything the rendering read changes; compare it for equality only.
Template jq
A template’stemplate_jq is a map of named jq programs a door or another consumer runs
against a subject’s record, each declaring a purpose:
input—{description, purpose: "input", params, jq}.jqruns over the record subtree at the attach path (.is the attached data) with the effective$parameters, the$declarations, and its declaredparamsdelivered as the single object$params, and returns a value. Read-only.update—{description, purpose: "update", params, reads, writes, jq}.jqruns over the record’s attached subtree (.is the current data at the attach path) with the caller’s argument bound as$inputand$parameters/$declarationsbound, and returns an ordered, template-relative op batch (the keyed-op shape the store applies).paramsare the key names the$inputobject must carry — uniform with aninputprogram’sparams— so apply refuses an$inputmissing a declared key or carrying an undeclared one (422); a named update that declaresparamsneeds anadapteron the binding’s update to fill them: a jq over the run’s output, with the run input bound as$input, that builds the program’s$inputobject.reads/writesare template-relative paths declared for readability; at put eachreads/writespath is validated only for structure — that it resolves against the fragment — while the write regime (that awritespath lies under a declared-writable regime) is enforced at apply by the store guard.
<template>.<name>names that template’s program. The template must be attached on the state and declare the program, else404.- An unqualified
<name>resolves to the one template attached on the state that declares it. None declaring it is404; two or more is422, naming them and asking to qualify it as<template>.<name>. - The program’s purpose must be the caller’s: an evaluation or an input injection needs an
inputprogram, an apply or an update anupdateprogram; a mismatch is422(template_jq 'x' on template 't' has purpose 'update', needs 'input').
[<template>], so the reference it accepts is the
one its runs resolve. A plugin’s per-node binding resolves through the same rule
(Babelfish).
The states facet serves the rule as resolve_template_jq(state, name, *, purpose, declared=()),
returning a ResolvedTemplateJq (template, program, purpose, params, path); declared
names templates a caller is about to attach. resolve_template_jq_batch(refs) resolves a list of
TemplateJqReferences (state, name, purpose, declared) in one call: one statement reads
every named state’s declaration version, and each reference resolves against its state’s catalog at
that version. A write that lands on a state during the call can make any reference on that state —
the first one too — read the catalog’s current rows and resolve against the newer version. Its answer holds one item per reference, in order:
the ResolvedTemplateJq, or the StateNotFoundError / ValueValidationError that
resolve_template_jq raises for that reference, with the same message, so one refused reference
never hides the others’ verdicts.
Sibling input programs are ordered dependency-first, so one may call another by name. A
binding on any door (or a plugin’s per-node binding, such as
a Babelfish node) injects an input
program’s value into a run’s input before the run and applies an update program’s batch
after it; the record-level HTTP and CLI surfaces evaluate an input program and apply an
update program directly (see Template jq).
A binding applies its whole update set in one transaction — every engaged state’s
updates land together, and if any update fails the run’s writes roll back as a
unit. The atomicity is automatic; nothing configures it.
Attaching
Attaching a template places its fragment at a path in a state’s document. The template is named in the URL segment (PUT …/attachments/{template}), never in the body; the body carries
only path/parameters/declarations/options (the last a per-operation, never-stored bag):
409) naming
the colliding path.
A fragment’s local $refs keep the fragment’s meaning once it is placed: a pointer
reference (# or #/…) is rebased to the attach path, so #/$defs/t in a fragment
attached at ["box"] reads #/properties/box/$defs/t in the effective schema, while an
anchor reference (#name) is left as it is. What a fragment may carry:
- With its parameters filled in, the fragment must be a valid draft 2020-12 schema.
- At a path, a pointer reference whose target sits inside a
constorenumvalue is refused: that value is data a record must equal, and rebasing the reference would change it. - At the root (
path: []), the fragment’s roottypemust beobject, and its root may carry onlyproperties,requiredand$defs, which merge into the state’s root (a key the root already holds is a conflict, as a property is). Annotation keywords there (title,description,$comment,examples,default,deprecated,readOnly,writeOnly, and a$schemanaming draft 2020-12) are dropped; any other root keyword is refused. A pointer reference must name a target under#/properties/<key>or#/$defs/<key>;#or any other root pointer would name the state’s root once merged, so it is refused. Attach such a fragment at a path instead.
- a value a validator cannot use is refused: a
patternthat does not compile,patternPropertieskeys that cannot be combined, a bound that is not a finite number; - every
$refresolves to a valid schema:#, a#/…pointer, a#anchoror an absolute reference under the root$id, or into a JSON Schema meta-schema by its URI (no schema is ever fetched); a#/…pointer indexes an array only with0or a whole number without a leading zero; $dynamicRef, a$schemanaming a dialect other than draft 2020-12, and a$idbelow the root are refused, and so is a root$idthat is a meta-schema’s URI;- an anchor name is unique across every
$anchorand$dynamicAnchorin the schema, and a$dynamicAnchorbelow the root that a meta-schema a reference leads to would jump back to is refused, as for a form; - a cycle of references that never descends into the document is refused.
schema and its declarations.schema are held to the same rules:
each must be a valid draft 2020-12 schema whose $refs resolve by the rules above. A template
breaking this is refused when it is saved, previewed or seeded.
A consumer may register an attach validator on the states facet with
register_attach_validator: given the state name, the template document, the attachment’s
declaration values, and the state’s effective schema, it raises loudly to refuse an attach or a
declarations write — it is consulted before every attach, every declarations write, and every
template replace (once per live attachment).
Attach reconciliation
Replacing a live attachment’sdeclarations can retire part of a template while the state
still holds OPEN records shaped by the old declarations. The platform runs a built-in
reconciler that reads the template’s reconcile program, and a consumer may register its
own with register_attach_reconciler(reconciler) — a pre-write hook run inside every attach
and update_attachment_declarations write, after the validators and before the write. It
receives an AttachReconcileContext: the state name, the template document, the operation
(attach | update_declarations), the previous_declarations (None on a first attach) and
new_declarations, the attach options, and a record door bound to the state. The reconciler
either raises to refuse the attach (naming the records the new declarations would orphan)
or writes resolutions through the context’s record door and returns, letting the attach
commit with those writes.
The options bag on the PUT/PATCH attach body (AttachBody.options, --options on the
CLI) is a free-form, per-operation directive bag the reconciler reads to decide how to
settle open records against the new declarations — for example, whether to close records the
new declarations no longer cover. It is passed to the reconcilers for THIS attach only,
never stored or served back; any key outside
path/parameters/declarations/options is refused.
Detaching
Detaching a template is guarded so a live reference is never stranded. The detach door consults every registered detach referee before it removes the attachment and refuses (409, listing the human-readable references each referee returns) while any binding still
calls the template’s template jq. The platform’s own binding holders — preset
versions, conversation-route configs, hooks, and schedules — are checked, and any consumer that
registers a referee is too: the Babelfish flows engine, for example, registers
one for its per-node bindings, just as it does
against a state delete. Nothing is auto-detached — the author removes or rewrites the referencing bindings
first, then detaches. A consumer registers a referee with register_detach_referee, beside
register_delete_referee on the tools facet. A detach referee is a coroutine called with the
state’s name and the template’s name; it answers a list of ReferenceHolder entries, each
carrying the feature tag its holder lives under (see Plugin
referees).
The detach preview door (GET /api/states/{name}/attachments/{template}/referees,
tai states attachment-referees on the CLI) runs the same gather and changes nothing. It
answers {"action": "detach", "holders": [...]}, where each holder entry carries its kind,
its tag, its name, a locator and a one-line summary, so a UI can link each one to the
place that fixes it. The preview and the refusal show the caller’s view: the holders under a
tag the caller’s role cannot read come back as one count per kind and tag (see Holders a
role cannot read).
Beside the referees, a consumer that keeps rows keyed on a tool name registers a delete
cascade with register_delete_cascade. A cascade is called once for each tool a preset
delete removes — the preset and each extension branch tool bound off it — after every veto
has passed and before the record is removed.
Regimes
A regime is a per-path writer rule composed over a state’s attachments. Each rule is a{path, regime} where the path may use the "*" wildcard (one list index or key)
and the regime is one of:
free— no restriction. An undeclared path isfree.composing— the path is built up by many writers, so a whole-pathset/removeover it is refused (422); it admits only a keyed op or an appendset. The refusal names the path and template, for example:composing path ['items'] of template 'utilities' admits only keyed ops or an append set.single— a single-writer path. This is enforced as an authoring check when a consumer binds a state, not at the store: a hook, schedule, or builtin-tool write on asinglepath is recorded in the audit trail with its door and actor, never refused at write time.
_trace
object on every write under the attachment, and the platform stamps it (see
The audit trail).
Doors
Every door that can write a subject’s document deposits one ambientStateContext — the entering door, the resolvable subject candidates, and the
accountable actor — so a downstream write resolves its subject and provenance
without a per-door argument. The context is one generic object, so a run parked at
an async ask and resumed later rejoins the same attribution.
A tool run with no ambient context — a sync run-tool call, a background run, the
MCP edge — is the
api door and a state tool it calls must carry an explicit
subject; called with none it raises no subject in scope.
Subject references
A binding’ssubject_expr result and a builtin tool’s subject argument are both a
subject reference, resolved by one platform rule — the states facet’s
resolve_subject(state, ref):
A reference that cannot be resolved is refused (
422, SubjectRefusedError) naming the rule
that fired: a mapping giving only one of target_kind/target_name, no ambient context where
the form needs one, a blank key, no ambient candidate for the default kind, a mapping that does
not validate (naming each failing field), or any other shape. An undeclared state is 404: a
key string or an omitted subject reads the declaration’s default_subject_kind and is refused
there, while an object form resolves without reading the declaration and the record read or write
it addresses refuses it. A builtin tool’s subject argument takes the two object forms or is
omitted.
A binding’s subject and scope
A binding — on any door definition (a preset, hook, schedule, or channel route) and on a plugin’s per-node binding (a Babelfish node, for example) — resolves its subject with a jqsubject_expr and gates itself with an optional jq scope_expr. Both mean the same
on every door and every node:
subject_expris required and non-empty, and yields a subject reference: a full subject object{target_kind, target_name, kind, key}, a{kind, key}object, or a bare key string — for a bare key thekindis the state’s declared subject kind; for a key or a{kind, key}object the target is the run’s ambient target. Anullresult, like a missing or emptysubject_expr, is refused (422), and so is a reference that does not resolve.scope_expris an optional boolean predicate over the run’s inputs, evaluated first:falseskips the state for that run — no input injections and no updates; a non-boolean is refused loudly; an absent expression means engaged.
scope_expr
wins when present, else the preset’s. A preset’s own binding fires only when that preset
is the outermost dispatch; called as a sub-tool of another dispatch, its binding is a
no-op.
A binding fires on every door it reaches through, the MCP edge included: an MCP tools/call
that omits arguments still receives the binding’s input injections — absent arguments are
treated as an empty argument object, never a skipped injection.
Saving a binding
A binding is checked when the definition carrying it is written — a hook registered, a schedule created, a preset created, saved as a version, or rolled back, a per-target conversation config set. Each of these has one write service that the HTTP door and the backup restore both run: it compiles the binding’s jq slots, resolves each state’s named template jqs in oneresolve_template_jq_batch
call, and attaches every named template (attach-on-use, idempotent) before the definition is
stored. A refusal stores nothing.
At a door, the refusal maps by its exact error type:
The preset validate dry run runs the same checks without attaching,
and reports a refused binding as an invalid verdict; a store failure there is a
500 too,
never an invalid verdict.
In a backup restore the states section replays after storage (with access_control and
sub_mcp between them) and before every section that restores a binding, so the states and templates a restored binding names exist
by the time its definition is written.
The turn subject block
Every tool turn’s payload carries a genericturn block so a binding’s
subject_expr can read the subject the platform resolved:
person is null off a non-multichannel target (a person is resolved only for
multichannel targets); thread is always present.
Hook and schedule subjects
A hook may declare an optional subject: the target scope, a subjectkind, and a
key_expr — a jq expression evaluated over the event payload at fire, which must
yield a non-empty string, else the fire fails loudly like any hook error. A
schedule carries its subject in the job’s tool kwargs; a malformed subject is
refused when the job is created (invalid schedule subject: …), so a job that
could never resolve its subject is never persisted. Both surface a Subject
group on their Studio forms; see Hooks and the scheduling
reference.
The builtin tools
Four LLM-facing tools read and write the calling subject’s document. Each resolves its subject from the ambient context, or from an explicitsubject argument
({target_kind, target_name, kind, key}, or {kind, key} with the target taken
from the context), by the same rule as a binding (see
Subject references); with neither resolvable it refuses, naming the
state, kind, and door.
The tools supply only the consumer half of a write’s provenance (their tool name,
run, and — for
state_apply — the idempotency op_id); the door, actor, and turn
id are stamped by the platform, never derived by the tool.
The audit trail
Every write is recorded. A state’swrites trail is a keyset page
{items, next_cursor} — the same paging idiom as subjects and record search — with
each items row carrying the write’s seq and timestamp at, the completed origin
that produced it, and the absolute paths it touched. next_cursor feeds the next
call (?cursor=) and is null on the last page. The origin carries what the consumer
supplied — consumer, meta (an opaque object the writing consumer supplies — its own identifiers, for instance — stored and shown as is), run_id, op_id — and,
stamped by the platform write chokepoint from the ambient context, door, actor,
turn_id, and inbound_id. Because door, actor, and turn are stamped by the platform,
the trail cannot be forged by a consumer.
Under a traced attachment the platform stamps a _trace object on every write:
at is always present; run, turn, and inbound are the run, turn, and inbound
ids, or null when the writer has none (a hook, a schedule, the API, or a builtin
state_* tool has none); and meta is an opaque object the writing consumer supplies
— its own identifiers, for instance — stored and shown as is. A plugin consumer’s binding
write and a hook’s keyed write get the same stamp.
Unit of work
A consumer that stages several writes and lands them together opens a unit of work over the states facet, bound to the scope it owns. Every write set staged against the unit runs through the same applier the persisted write path runs — the guards, the path ops, the composing-shape rule — and is projected, not stored. The whole-document schema is checked once per touched subject per staged batch, on the document the batch leaves: a batch is accepted or refused exactly as one multi-op apply would be (an intermediate document between two of its writes is never checked on its own). A validation, composing-shape, or schema error raises at stage time and leaves nothing of the refused batch staged. While the unit is open, a read of a subject the unit has staged is served from the projection — the committed document overlaid with the scope’s staged writes in order, so a later step reads its own earlier writes — while every other scope still sees only the committed document.commit writes the unit’s validated intent — every staged batch, plus any calls deferred
with defer_call — as one pending save in one small transaction. On the serving loop commit returns before
the records are applied, so the run’s reply never waits on the record writes; the save is applied
after the reply (see Pending saves): every staged subject lands in one
transaction, all-or-nothing, and the save is removed in that same transaction. A subject whose
committed record and declaration are unchanged since it was staged gets its already validated
projected document written as it stands; a subject whose record, declaration, or op_id ledger
moved in the meantime has its writes replayed onto the latest committed document instead, under
the declaration as it stands at apply: a subject kind it no longer declares is refused, and the
schema is checked once per staged batch. The guards and op_id idempotency are authoritative at
apply; a replay whose answer differs from the staged projection is logged and counted. commit
returns the staged answers, the pending save’s id, and the number of deferred calls. A unit that
staged nothing and deferred nothing writes no save.
discard drops the staging. A unit left open when its scope ends is discarded at
teardown and the discard is logged.
A committed or discarded unit refuses any further stage, savepoint, commit, discard, or
deferred call with StateUnitClosedError (a RuntimeError).
defer_call(tool, arguments, *, run_id=None) queues a tool call to run after the save’s
records apply. The arguments must be plain JSON and the tool must exist, or the call is refused
at once with DeferredCallRefusedError. The call runs later under the identity, run attribution,
state context, and trace the caller had when it deferred it, as an outermost tool dispatch (so
authorization, retry policy, run recording, and tracing all apply), with the idempotency key
<save id>:<index> in its extras under tai42/idempotency_key. A discarded unit, or a
savepoint rolled back, drops the calls it deferred.
A savepoint nests inside a unit: writes staged in the child scope are kept on a
clean exit and dropped on an exception, rolling back only the child’s writes and
never the parent’s. Savepoints nest to any depth, so a nested run rolls back its own
writes within the enclosing unit.
The unit of work is a programmatic facet API for in-process consumers; it has no HTTP
route.
Caches and consistency
Each process keeps derived artifacts of the catalog — a declaration’s effective schema and its document validator, its composed regime paths, a template’s validated form, and its rendered programs. Every declaration and template row carries aversion drawn from one database sequence
on each write to it (an attachment write and an attached template’s replace included), so a
version never repeats for a name, even when the row is deleted and re-created. Each artifact is
served only for the version read inside the statement that serves it, so a change made through
any process is seen by every other on its next read. A rendered program also keys on the resource
manager that rendered it, so a stored-resource upload, a cache eviction, or a reload re-renders;
a rendered template’s version also changes whenever a re-render (after the template cache’s
TTL, or on every read while that cache is off) produces different text.
Pending saves
A pending save is the durable intent a unit commit writes before the reply: the staged writes of every subject the run touched, and the calls it deferred. It is applied after the reply, and every reader of its subjects waits for it, so a participant’s next message reads its own previous turn. Who writes one. A unit of work’s commit, a door binding’s updates on a tool call, and a parked run’s deferred binding updates each write one pending save. Applying. On the serving loop the save is applied in its own task right after the commit returns. Off it — a backend whose jobs run on their own loop, such as Celery — the records are applied inline and the deferred calls are left to the next sweep pass; a backend whose jobs run on the process’s serving loop, such as ARQ, runs them in the worker right after the job. A sweep runs in every serve process and backend worker everySTATES_OUTBOX_SWEEP_SECONDS,
and applies any save left behind (a crash, a lost task, a retry that is due).
Per-subject order. Saves that share a subject apply their records in the order they were
written. A save applies its records and removes itself in one transaction; when it carries
deferred calls it moves on to them after the records land, and its calls start only once every
older save on its subjects is gone, so deferred calls on a subject run in order too. The records part is applied exactly once. A deferred call runs at least once: a
call interrupted by a process exit is re-run only when its tool declares tai42/crash_resume,
and otherwise the save fails.
Readers wait. A read, write, fold, erase, template-jq call, or write-history read of a
subject first applies the subject’s pending save (or waits for the one being applied), bounded by
STATES_OUTBOX_DRAIN_TIMEOUT_SECONDS. When nothing is pending this costs no extra round trip:
the check rides on the subject lookup the read already makes. A subject’s write history includes
its pending save. A tool call, a hook or schedule fire, a parked run’s continuation, and a turn
also apply their subjects’ pending saves when they start, and wait for those saves’ deferred
calls to finish, so a run never starts behind its subject’s earlier save.
Failures. A transient failure (a lost connection, a timeout, an unavailable dependency) is
retried with an exponential backoff from STATES_OUTBOX_RETRY_BASE_SECONDS, capped at
STATES_OUTBOX_RETRY_CAP_SECONDS, up to STATES_OUTBOX_MAX_ATTEMPTS attempts. Any other
failure, or the last attempt, marks the save failed, and the failure is loud: an ERROR log,
an entry in the operator notifications inbox, the states_outbox_save_failed platform event (see
Hooks), and the tai42_state_outbox_* metrics (see
Observe).
Held subjects
A failed save holds its subjects until an operator retries or discards it. A newer save that shares a subject with it is held behind it. A read or write of a held subject raisesStatePendingSaveFailedError (409, naming the save) rather than serve a record that silently
lacks the save; a wait that runs out raises StatePendingSaveTimeoutError (503). The run-tool
door answers the same 409 / 503, and a turn that meets a held subject fails through the
route’s ordinary error reply. On the agents stream the stream.error frame names the held subject.
The scans over a whole state treat a held save in one of two ways:
A widening re-declare followed by a retry therefore repairs a save that failed on the
declaration. A tool rename is refused while any save is outstanding under the tool — pending,
still running its deferred calls, or failed — and the refusal names each one. The sweep applies
a pending save; a failed one needs a retry or a discard. The rename preview lists the same saves
and applies nothing.
Retry and discard
Only a failed save can be retried or discarded (
409 otherwise). A listed save carries its
status (pending, calls, running, or failed), run, states, subjects, the calls it runs
(kind and target), attempts, last error, failed phase, and times — never its record data or
call arguments. A discard is logged at WARNING with the principal who made it and writes an
operator notification. The same three operations are MCP tools, and tai states pending-saves list|retry|discard on the command line (discard requires --yes). The
Studio’s States screen shows them in its Pending saves card.
Checking records
A record is validated against its state’s effective schema — the base schema composed with every attachment — on every write. Two read-only doors check that same conformance without writing, so a schema can be checked before a document is stored and after a declaration or attachment changes what the schema admits.-
A candidate, before it is stored.
POST /api/states/{name}/records/validatetakes a candidate document as its whole body and checks it against the state’s effective schema exactly as a write would, but stores nothing. The answer is aRecordValidation—{fits, reasons}. A document that fits is{fits: true, reasons: []}; one that does not is a200withfits: falseand everyreasona{path, message}ordered by JSON path, so a caller sees all of a document’s misfits at once rather than only the first. Only a non-object body or an effective schema with a broken$refis a422, and an undeclared state a404. -
The stored records, against the schema as it stands now.
GET /api/states/{name}/records/checkpages the state’s stored records and reports the ones that no longer fit the current effective schema — the records a re-declare or an attachment change left behind. The answer is aRecordCheckPage—{checked, items, next_cursor, held}.checkedcounts the records the page read, anditemsare the misfits only, each aRecordCheckItem(subject,updated_at, and the same path-orderedreasons). A page that read records but found none wanting therefore carries an emptyitemsbeside a non-zerochecked. It pages like the subjects listing and record search — a keyset?cursor=, an optional?kind=, and a?limit=— andheldnames the subjects a failed pending save made the scan skip. -
Without a store. The skeleton’s
RecordValidatorchecks a document against a state’s effective schema the same way the candidate door does, with no server: build it from theeffective_schemaa state’s read serves, or compose one withcompose_effective_schemafrom the base schema and the attachments, then callvalidate. The answer is the sameRecordValidation, with the same path-orderedreasons. Compiling the schema is the costly step, so build the validator once and reuse it for every document.Building the validator checks the schema by the declaration rules (above) and raisesSchemaValidationErrorfor a schema they refuse.validateraisesValueValidationErrorfor a document that is not a JSON object, and for a$refit cannot resolve while validating.
tai states validate <name> (--data / --file)
and tai states check <name> (--kind) — and the Studio’s
States screen runs the stored-records check from a card on a
state’s Records tab.
The HTTP surface
All routes are authed. Templates are a top-level sibling collection and prune is its own route, so no literal segment sits on the/api/states/{name} position — a
state literally named templates stays reachable at GET /api/states/templates while
GET /api/state-templates lists templates.
States
Records
The subject is four path segments:/api/states/{name}/records/{target_kind}/{target_name}/{kind}/{key}.
Template jq
The record-level jq programs a state’s attached templates declare share one path, the method selecting the purpose:{program} is a program name, or {template}.{name} when a name is shared across the
state’s attachments. A GET on an update program, or a POST on an input program,
refuses loudly.
Templates and retention
Errors map to status:
501 when the store’s database is unbound (a stable code
states-not-configured), 404 not found, 409 exists / in use / conflict / a subject held by
a failed pending save, 422 validation (schema, subject, regime, path, value), and 503 when a
subject’s pending save is still not applied after STATES_OUTBOX_DRAIN_TIMEOUT_SECONDS.
The command line
Two command groups mirror the HTTP surface.tai states manages declarations,
attachments, subjects, records, and the audit trail — list, get, put, delete,
stats, attachments, attach, get-attachment, update-attachment (its --options
carrying the per-operation reconcile directives),
detach, subjects, search, validate, check, read, replace, merge, apply, template-jq
(eval an input program, apply an update program), erase,
fold, writes, consumers, prune, and pending-saves (list, retry,
discard). tai state-templates manages template
documents — list, get, put, delete. Record commands take
--target-kind, --target-name, --kind, and --key; apply, replace, and
merge read their JSON from --data or a --file. See the
CLI reference.
Enabling the store
The store is thestates DB registry component. Like the skeleton component it
defaults to the default database when its binding is unset, so an ordinary
deployment serves the store out of the box. A deployment that wants records in a
separate database points the binding elsewhere with TAI_DB_BINDING_STATES, then
applies the component’s migration chain with tai db migrate. Until the bound
database is configured, every read and write returns 501 states-not-configured.
Record retention is opt-in: nothing is deleted until a retention is configured,
either per state (retention_days on the declaration) or as a deployment default
(STATES_DEFAULT_RETENTION_DAYS). tai states prune runs the sweep.
