This document specifies st2's current implementation contract. It builds on requirements.md.
Active. This is a concise map to the implementation and its evidence, not a replacement for the README, CLI help, KDL examples, or tests.
st2 validates a declared agent fleet, materializes agent workspaces, launches
host-local work, adopts and supervises independently surviving tasks, and
delivers messages. The agent grammar and harness-facing contract remain
canonical in
compoundingtech/evals/AGENT-SPEC.md.
Decision records are cited by their full filename stem, NNNN-<slug>. Bare
numbers are ambiguous for 0005, 0007, 0014, and 0015; their collisions are
recorded history, and numbers are never reused under
0006-observed-harness-state-is-a-driver-written-catalog-record.
An eval may opt into canonical-agents after its fixture copy and deterministic
run steps have populated the hermetic temporary catalog. The directive is
mutually exclusive with compact team / agent declarations. st2 recursively
discovers the catalog, preserves explicit identity and host authority
independent of organizational placement, and projects only declarations
resolved to the eval host. Each declaration parent remains its native
state/resource anchor. The resulting local Agent Spec vector flows unchanged
through launch admission, kickoff resolution, supervision, logging, and
declaration-driven teardown; remote-host declarations remain inert.
Admission applies validate_for_host strictly, then fails before spawn when
discovery is malformed or warning-bearing, when the selected local projection
is empty, duplicate, non-running, root-overriding, or unrunnable, or when any
resolved local task runtime ID is empty or duplicates another local task.
Materialization warnings and backend launch errors are fatal. The kickoff
target must resolve to exactly one local agent. The eval owns one native
CATALOG / ST_ROOT and its <catalog>/pty registry; declarations cannot
override those roots. Local workspace renders are materialized before any
agent task starts.
Native inbox/archive paths are derived once from the admitted Agent Spec paths
and carried as frozen data; routing never re-discovers the mutable catalog.
The requester alone is an explicit eval-owned flat mailbox. Multi-agent
completion retains the worker-report-before-supervisor-confirmation ordering.
For a singleton, the eval snapshots the requester inbox before kickoff and
completes only for a newly appearing interviewer reply whose timestamp is
at-or-after the exact kickoff receipt. The filename snapshot rejects
future-dated pre-seeded messages while >= accepts a causally new same-ms
reply. Canonical completion is a gating judge, so a timeout cannot pass on
unrelated final-state checks alone.
Without canonical-agents, fixture declarations are not discovered or launched
and compact evals retain their catalog-less flat bus. This explicit opt-in keeps
ordinary fixtures inert while allowing the same canonical declaration to be
exercised in an eval and real work. Parser and admission evidence lives in
eval_spec::tests::canonical_agents_is_bare_once_and_excludes_compact_agents and
the canonical_* unit tests. Named-PTY end-to-end cases prove strict
pre-spawn refusals, poisoned ambient-root isolation, real render
materialization, frozen routing after declaration removal, singleton
completion, custom task-ID supervision/logging/teardown, and the no-opt-in
legacy control in tests/eval_run_e2e.rs.
This section is the accepted target contract. Its mutable-address half is
implemented: the grammar, host-local address uniqueness, st2 agent address,
and the fail-closed bare-or-qualified reference resolution. Its immutable-ID half
is not: no writer emits id, so the positional <host>.<identity> bus identity
remains the durable key every ownership, task-identity, and record surface uses.
0015-immutable-agent-id-and-mutable-address Amendment 1
stages that half behind named triggers and
DELTA-003 records what it
still requires.
An Agent Spec separates four values that the current implementation overloads:
| Concept | Declaration/runtime form | Mutability | Scope | Use |
|---|---|---|---|---|
| agent ID | required target field id "<agent-id>" |
immutable | catalog-global | logical subject, ownership, automation, durable graph edges |
| agent address | optional address "<address>"; positional identity is the legacy fallback |
mutable | unique per logical host | human routing |
| bus address | <host>.<effective-address> |
derived | catalog | qualified human routing |
| agent name | optional name "<name>" |
mutable | non-unique | presentation only |
Before ID-aware routing activates, catalog migration adds an explicit id to
every live and structurally archived declaration. It first assigns each live
legacy subject its existing host-qualified bus identity, preserving current
runtime IDs and declaration-anchored state. An archived subject receives those
same bytes when unused across the combined live-and-archived set; a collision
receives UUIDv7, recorded in both its declaration and tombstone. Migration also
durably records each reassigned legacy bus identity with the subject that kept
it and the archived subject's new ID, so readers of legacy records never retype
colliding bytes into the wrong subject. Supervisor
resolution uses the combined pre-migration live-and-archived subject index. In
the same atomic catalog transition, every reference is rewritten to its
parent's migrated ID. A missing or ambiguous reference refuses before writes
with legacy-supervisor-unresolved; before activation, the operator must
unarchive and repair that declaration through the ordinary legacy authoring
path, then retry migration.
Unarchive preserves the migrated ID, validates its uniqueness against the
prospective live-and-archived set, and refuses an unmigrated archive after
activation. New subjects receive UUIDv7 IDs. A legacy frozen ID's original
host-looking prefix becomes opaque: a later host move changes current placement
and bus address, not the ID.
A subject's ID survives address, name, description, supervisor, graph placement, host placement, desired state, archival, and runtime-incarnation changes. Retirement makes the subject non-routable and releases its address but preserves the ID. Reintroducing the same ID denotes the same subject; a replacement subject receives a newly generated ID.
address is an optional semantic alias. When omitted, positional identity is
the effective legacy address. When present, it is the effective address and the
legacy fallback no longer resolves as an ordinary reference. An explicit
address is at most 255 ASCII characters and is a dotted sequence of
1-to-63-character segments. Each segment contains only lowercase letters,
digits, and hyphens and begins and ends with a letter or digit.
Host qualification forms the bus address:
agent "0199b8f4-8d3a-7c21-9a44-6f85b7320ea1" {
id "0199b8f4-8d3a-7c21-9a44-6f85b7320ea1"
host "dev3"
address "dotfiles.fractal.keymap.verifier"
name "Keymap verifier"
description "Verifies Fractal keyboard behavior and regression evidence."
}agent ID 0199b8f4-8d3a-7c21-9a44-6f85b7320ea1
agent address dotfiles.fractal.keymap.verifier
bus address dev3.dotfiles.fractal.keymap.verifier
agent name Keymap verifier
Every admitted effective address is unique within its resolved logical host among running and suspended subjects. Retired subjects do not resolve and do not occupy the address namespace. The complete prospective catalog enforces both explicit-address collisions and collisions involving an addressless declaration's identity fallback. The same address may exist on two hosts. Address and ID are separate typed namespaces; equal string bytes do not collide.
An explicit ID selector performs only catalog-global ID lookup. An ordinary reference uses this fail-closed candidate set:
- when the caller pins a host, treat the complete input as an address in that host and also try the qualified split whose prefix equals the pinned host;
- otherwise, treat the complete input as a bare address across the selected catalog and also try every dotted split whose prefix is an admitted logical host and whose suffix is an effective address in that host;
- deduplicate candidates by agent ID and succeed only when exactly one subject remains.
This rule makes a dotted semantic address and a host-qualified bus address decidable without guessing which dot is a separator. Absence and multiple distinct subjects fail with address-specific diagnostics.
Ordinary CLI references and message recipients use that address algorithm.
Every agent-selecting command exposes mutually exclusive address and exact-ID
forms. A command that defaults from ST_AGENT consumes it through the typed ID
path; generated hooks and channels use the exact-ID form rather than passing it
to an address parser. Exact subject selection never falls through to address
lookup. Internal ownership, supervisor edges, replies, message provenance,
authoring authority, and lifecycle plans carry agent IDs, not reparsed
free-form references.
Assigning or changing address is one atomic address-book cutover. The old
address stops resolving as soon as the new catalog generation is visible and
may be claimed by another subject. st2 stores no rename history, redirect,
implicit alias, or time-bounded compatibility route. A stale caller fails
loudly and refreshes the roster. Catalog-generation and incomplete-transaction
fences keep each lookup on one coherent before-or-after address book.
Address changes do not alter the subject's declaration-parent state anchor, ID-keyed supervisor edges, task IDs, launch fingerprints, workspace, inbox, archive, context, Resource state, provider-session binding, or runtime ownership. A healthy task keeps its PID, creation identity, and generation. Host, graph, task, and launch changes retain the logical subject ID but still follow their own field-specific runtime rules; logical-subject continuity does not imply process-incarnation continuity.
name remains a non-unique human label and description remains the enduring
responsibility boundary. Omission is the only cleared representation. Name is
limited to 160 Unicode scalars and description to 1,000. Explicit empty,
surrounding-whitespace, Cc-control, U+2028/U+2029, or over-limit values are
invalid; slash and backslash remain ordinary printable characters. Neither
field participates in identity, address resolution, authorization, durable
paths, or lifecycle. The Agent Spec declaration is their sole source of truth.
<agent-dir>/name is hard-retired: st2 neither reads, writes, migrates, nor
interprets it.
st2 agent address, st2 rename, and st2 describe accept an explicit agent
ID and either a value or --clear. Address clearing restores the positional
identity fallback and is admitted only when that effective address remains
unique. They
edit canonical KDL only. Each operation:
- acquires the persistent exclusive
<catalog>/.st2/catalog-authoring.lockbefore discovery; - resolves exactly one declaration by immutable ID and applies the
caller-supplied
ST_AGENTself/descendant guardrail when present; - refuses declarations explicitly marked
meta { managed-by "nix" }, unsupported formats, malformed catalogs, ambiguous targets, and direct ID mutation; - applies one span-bounded edit, reparses and validates the complete candidate catalog, including effective-address uniqueness and ID uniqueness against the structural archive;
- fsyncs a temporary under the reserved
.st2control plane, rechecks the original inode/version and bytes, atomically renames it through retained no-follow directory capabilities, then fsyncs the declaration directory.
ST_AGENT carries the catalog-global agent ID and remains an exact actor
selector supplied by reconciliation, not the mutable bus address. Host
placement comes from the declaration/runtime context rather than being encoded
into ST_AGENT. ST_AGENT is a trusted-fleet convention rather than an
authenticated capability: a same-UID caller can alter or remove it, and its
absence selects the operator path. Nix generators must emit the ownership
marker before activating a binary with authoring commands; st2 cannot infer an
unmarked generator from KDL bytes.
The lock file is a persistent real inode and is never removed or stale-recovered. It serializes cooperating st2 declaration readers and writers in one local POSIX filesystem/kernel lock domain. Direct same-UID writes and independently synchronized hosts do not participate; the source recheck detects observed interference but is not a distributed lock service.
Roster and graph JSON append id, address, and nullable busAddress while
preserving the existing field order and meanings required by their compatibility
invariants. A retired non-routable subject has null busAddress without making
the envelope incomplete. The existing identity field remains the positional
declaration key and legacy address fallback; consumers do not treat it as the
agent ID. name and description remain separate presentation fields. An
exact ID roster query returns one subject plus its current host and nullable
effective address. An ordinary address query fails on absence or ambiguity.
For each healthy managed PTY, reconciliation uses one atomic exact-task-ID
pty metadata patch --id <task-id> request. Compact agent lowering assigns its
canonical task the explicit runtime ID <agent-id>. Every long-form named task
without an explicit task ID defaults to <agent-id>.<task-name>, including a
task named agent; explicitly authored task IDs remain authoritative. This
preserves every legacy task ID and socket path because each frozen ID equals its
former bus identity and the lowering rule itself does not change. Host placement
is not separately concatenated into a default ID; host-looking bytes inside a
legacy ID are opaque. Every PTY receives the exact owned tag snapshot
agent.presentation.schema=2, agent.actor.id=<agent-id>,
agent.actor.address=<bus-address>, and optional
agent.presentation.description=<description>. Clearing a value removes only
its owned tag. Unrelated tags remain unchanged. Only the canonical compact agent
task whose task ID equals the agent ID carries the compatibility tag
role=agent and maps name to native displayName; other PTYs clear that role
tag and retain their task-specific display convention. Name is not duplicated
in tags. Exec tasks receive no PTY metadata.
One effective metadata delta emits one coherent metadata_change event, while
an unchanged patch emits none. Projection failure reports and retries but never
enters launch, teardown, garbage collection, replacement, or flapping
accounting.
A non-agent service that needs bounded judgment work may declare only its bus
endpoint at
principals/<host>/<identity>/principal.kdl:
principal "example-ci" host="host-a"The declaration creates no task, presence, persona, or Agent Spec authority.
Its content must exactly match its canonical path. st2 request send accepts
only such a principal as the caller and only a discovered Agent Spec as the
recipient, so a service neither impersonates an agent nor depends on the flat
orphan-recovery layout.
The caller supplies an idempotency key, a JSON body, and typed string tags.
Before the native message is published, st2 atomically reserves one random
canonical message filename and the exact request envelope under the
principal's resources/request-state/. Replays finish that same publication;
reuse of the key with different caller, recipient, body, or tags fails. An
agent's st2 request reply similarly publishes at most one typed reply to the
principal's canonical inbox. st2 request status --json returns the tagged
union pending | replied, suitable for a durable workflow to observe between
its own durable waits. st2 provides no wait loop or timer and does not turn the
request into agent lifecycle authority.
This transport is design-superseded but still normative for the shipped
implementation: 0004-stream-events-are-a-distinct-record-kind
absorbs typed requests into stream events plus ordinary replies, staged behind
a deprecation window (04-stream DQ-S4). Until
that staging completes, this section and its invariant row remain the truth;
the drift is fenced by
DELTA-002.
An agent may directly declare zero or more generic Resource bindings:
resource "work" uri="github-issue://example/project/123" reason="release work item"The positional name is an agent-local semantic role. reason explains why the
reference belongs to this agent (required; optional inactive-reason retains
inactive bindings). uri is the exact RFC 3986 absolute Resource identity,
preserved byte-for-byte without normalization. Declaration order has no meaning
and binding names are unique within one agent. A Resource URI may be referenced
by any number of agent declarations. The public agent-spec read model
preserves the bindings in name order across canonical KDL and the supported
TOML/JSON forms. st2 agents --json projects the same descriptors for
language-neutral inspection.
The generic binding envelope does not register schemes, resolve targets, infer
authority from URI possession, or attach required/optional, access, readiness,
or lifecycle semantics. Its scheme is, however, the exact lookup key for an
optional catalog-declared Resource Profile. The built-in registry is empty;
unregistered schemes stay opaque. Registered schemes resolve through the
feature-gated wasm-only SDK boundary specified by
07-resource-profile and may supply a contained
local path plus resync notification class. Scheme semantics and profile modules
remain downstream-owned.
A Resource binding and its optional resolution are declaration/observation metadata and are absent from task launch targets. Changing only Resource bindings adopts an already-live task without stop, replacement, or relaunch. Read-oriented renderer integration remains tracked in st2#61, and the portable Agent Spec envelope in evals#41.
st2 agent digest (--spec FILE | --bundle DIR) captures a source through
retained no-follow file descriptors and returns its authoritative digest.
st2 agent publish --catalog ROOT (--spec FILE | --bundle DIR) --input-sha256 HEX (--expect-absent | --expect-sha256 HEX) [--managed-by MARKER] --json binds
publication to that exact capture. It accepts exactly one canonical KDL agent
node with an explicit ID plus path-safe host and identity. st2 no longer
exposes an intent compiler: external renderers own the transformation from
human intent to exact Agent Spec bytes or a create-only publication bundle.
The persistent <catalog>/.st2/catalog-authoring.lock defines one cooperative
read/write transaction domain:
publisher (EX) : snapshot input -> CAS -> full-catalog admission -> atomic publish + fsync
reader (SH) : discover -> materialize/observe -> plan -> execute
bulk apply (EX) : root CAS -> durable stage+marker -> converge -> verify+clear
state plane : message | context | Resource | status (unlocked)
Every publication temporary, including a not-yet-visible identity bundle, is
staged under .st2. Cross-directory rename therefore stays on the catalog
filesystem while a crash can leave debris only in the non-projected control
plane; declaration directories never contain writer-private leaves.
Before a declaration writer mutates the live projection it durably creates
.st2/catalog-generation-incomplete. Shared declaration readers and read fences
fail closed while this intent exists. After the declaration and its parent are
durable, the writer advances and fsyncs catalog-generation, then clears and
fsyncs the intent. The next exclusive writer recovers an orphan intent by
conservatively advancing the generation before clearing it. A crash after the
advance but before intent removal may therefore skip a generation on recovery;
the contract is monotonic change detection, not an exactly-once counter.
The resident supervisor watches the parent of catalog-generation through a
dedicated non-recursive backend instance. Atomic replacement of the generation
file queues one unit wake only after the transaction has made its declaration
durable. The shared catalog lock still fences the following pass from an
in-progress writer. This constant-cost commit channel is independent of the
declaration-space watcher, so declaration subscription volume or failure cannot
delay a successful st2 publication until the full-audit timer.
The supervisor records the control-directory identity for that subscription.
If the directory is replaced, the next pass diagnoses the stale subscription
and installs a watcher on the replacement directory.
The lock file is a persistent real inode: replacing or removing it would split
the lock domain for a process that already has it open. Consequently, the first
coherent declaration reader may initialize exactly .st2 and this lock even
when its requested operation later refuses. Refusal still performs no
declaration, workspace, or state mutation.
The publisher derives the destination from the captured declaration, replaces
only agent.kdl for a hash-authorized update, and preserves all sibling runtime
state. A bundle is create-only and is renamed from a hidden same-filesystem
stage; retry reports unchanged only when every projected bundle file already
matches. --expect-absent is idempotent for identical input.
--input-sha256 rejects a caller/source swap and --expect-sha256 rejects a
stale declaration writer. Full-catalog admission rejects any
structural validation error before publication. The typed result is
published or unchanged.
A hash-authorized update also passes the ownership boundary the lifecycle verb
applies, because it rewrites the same declaration wholesale. --managed-by MARKER asserts the ownership marker the caller believes owns the incumbent,
and the replacement is admitted only when the incumbent's own
meta { managed-by "..." } names exactly that one marker; a mismatched marker,
an unmarked incumbent, an unresolvable multi-marker incumbent, and an empty or
padded assertion each refuse before any write, under the same refusal codes the
authoring verbs use. Without an assertion, only the Nix marker refuses, which
is what lets each publisher of another marker adopt the assertion on its own
schedule. Create-only publication has no incumbent to protect and a
byte-identical republication authors nothing, so neither requires an assertion;
a candidate that merely claims a marker asserts no authority, and the receipt
reports managedBy only for a marker the incumbent confirmed. Incumbent bytes
that are not readable as a declaration carry no ownership claim, so repairing
them stays possible for a writer who could already replace the file directly.
Host-scoped validation rejects a pty task whose session socket path would exceed the
portable sun_path bound. pty binds <PTY_ROOT>/<session-id>.sock and refuses a
bind over the limit, so such a task can never spawn and fails identically on every
reconcile pass, which also makes the pass result useless as a health signal for that
host. The bound is derived from the pty root resolved for the selected host, never a
fixed maximum identity length: the usable identity length is what remains of the limit
after that root. The portable 104-byte bound applies so a declaration admitted on Linux
does not fail on Darwin, and the diagnostic states the resolved path and the byte
overage so the author can shorten the identity rather than discover the failure as a
spawn error later.
A park notice whose cause is structurally unrecoverable says so instead of offering
st2 unpark, which would relaunch into the identical failure. The test is the same
predicate admission uses, not the wording of a spawn error.
Before returning success, publication reads the exact live declaration back
under the catalog lock, verifies its digest and bytes, and re-admits the live
catalog. st2 validate --json emits st2.validate.v2; successful JSON
publication emits st2.agent-publish.v2. Both identify the
st2.core+catalog.v1 policy profile and the same agentSpecRevision. A clean
hermetic build uses the complete 40-hex source revision; dirty or revisionless
local builds use explicit identities that cannot compare equal to a clean
hermetic receipt. The byte-only st2 agent digest --json contract remains
st2.agent-source-digest.v1 because it makes no parser or policy claim.
st2 catalog snapshot --catalog ROOT --output DIR --json holds SH while it
captures the canonical declaration projection: catalog.kdl, exact
agents/<host>/<identity>/agent.kdl files, static files inside those bounded
agent bundles, and every regular file in _templates whether or not a current
render references it. _templates is bounded to depth 8 below its root, 256
files, 1 MiB per file, and 32 MiB total; symlinks, hard links, special nodes,
and reserved control/state names are rejected. Runtime state, .git, .st2,
the native pty registry, and workspace content are excluded. A
catalog-contained Agent workspace or Task cwd is valid only when it names
that agent bundle's canonical real .workspace; the empty directory itself is
an exact declaration fact, while its descendants are never traversed. The
classification uses launch-equivalent variable expansion, resolves relative
values from the Agent Spec bundle, and lexically normalizes before comparing
against the logical catalog. A relative spelling is accepted only when it
normalizes to that bundle's canonical .workspace; unresolved variables and
every other effective relative path fail closed. The
scanner always excludes a canonical .workspace subtree, including an orphan
left after an agent move or removal. External workspaces remain valid and are
not part of the projection. The output is a create-only durable directory; an
identical retry is unchanged. Its domain-separated, path-sorted root SHA-256
covers normalized relative paths, file bytes, executable bits, and empty
workspace directory facts.
st2 catalog snapshot --catalog ROOT --output DIR --raw-preimage --json
externalizes a byte-oriented CAS preimage. Under the same shared lock it
structurally captures catalog.kdl, canonical
agents/<host>/<identity>/agent.kdl leaves, their bounded static bundle files,
the bounded _templates tree, and existing canonical .workspace directory
facts without parsing declaration bytes. The ordinary state/control exclusions
still apply. Every captured input must be a safe real file or directory with no
symlink or hard-link alias. No live validity, catalog-envelope, profile, or
effective PTY-root assertion exists in this mode. Its root uses the distinct
st2.catalog-raw-preimage-root.v1 hash domain and the receipt schema is
st2.catalog-raw-preimage-snapshot.v1; it is not interchangeable with a strict
snapshot root. Create-only retry rechecks both the raw root and output link
counts.
st2 catalog diff --catalog ROOT --prepared DIR --expect-sha256 HEX --json
holds the existing authoring lock in shared mode and performs no initialization
or publication. It projects and fully admits the coherent live catalog, rejects
unless its declaration root equals HEX, captures DIR through retained
no-follow capabilities into private temporary storage, then projects and fully
admits that capture against logical ROOT. Any malformed, ambiguous, stale,
symlinked, hard-linked, special, reserved, or unprojected input fails without a
partial JSON receipt.
The typed result is:
{
"schema": "st2.catalog-diff.v1",
"catalog": "/catalog",
"prepared": "/prepared",
"beforeRootSha256": "<live-declaration-root>",
"afterRootSha256": "<prepared-declaration-root>",
"paths": [
{
"path": "agents/host/worker/agent.kdl",
"kind": "modified",
"before": { "class": "agent-spec", "executable": false },
"after": { "class": "agent-spec", "executable": false }
}
],
"agents": [
{
"host": "host",
"identity": "worker",
"kind": "modified",
"fields": [
{
"address": "/agents/host/worker/tasks/pty/agent/argv/0",
"before": { "state": "present", "type": "string" },
"after": { "state": "present", "type": "string" }
}
]
}
]
}Path changes are ordered lexically and use added, removed, or modified.
They describe projected-fact changes: file content, executable bit,
classification, or workspace-fact presence. A missing before side for an
addition and a missing after side for a removal serialize as null; a
modified path has both sides. Each present side classifies the fact as
catalog, agent-spec, render, template, static, or workspace-fact.
File content remains private and only the existing aggregate declaration roots
are hashed in the receipt.
render means a catalog-owned bundle file consumed by a normalized render
operation, while _templates remains template even when referenced.
Agent fields lower through the shared Agent Spec model and ordered render-plan
parser. This is model-field normalization, not resolved effect normalization:
physical source paths, comments, formatting, map order, and explicit spellings
of effective defaults disappear, while accepted workspace, task cwd, and
render path strings remain exact model values. Task addresses include both kind
and name. Dynamic JSON Pointer segments use RFC 6901 escaping; for example,
environment key A/B~C becomes A~1B~0C. The address necessarily exposes the
host, identity, task/resource names, and environment/tag keys needed to locate
the field. Render operations retain their declaration order, while
json-upsert object keys normalize before comparison. A changed field reports
only absent, default, or present plus its type; inclusion in fields
proves the two normalized payloads differ. Payload values, lengths, and
per-field or per-agent hashes are never emitted.
paths describes projected-fact changes, so a formatting-only source
edit may modify agent.kdl while agents remains empty. An empty agents
array is normalized agent equivalence, not byte identity. The command does not
decide whether a change is safe, select agents for migration, inspect a PTY
registry, or authorize apply.
st2 catalog digest --catalog ROOT --prepared DIR [--json] performs the same
retained no-follow capture and desired projection as apply without taking the
catalog lock or mutating it. Its st2.catalog-digest.v1 receipt carries the
canonical catalog and prepared paths plus rootSha256, the capability passed as
INPUT_HEX. This command exists for invalid-incumbent repair, where semantic diff
cannot parse the live side; ordinary apply callers reuse the diff receipt's
afterRootSha256 rather than making a redundant capture.
st2 catalog apply --catalog ROOT --prepared DIR --input-sha256 INPUT_HEX --expect-sha256 HEX --json
rejects any prepared state/control path, symlink, special node, unprojected
file/directory, malformed declaration, nonempty prepared workspace fact,
catalog-local/default PTY root, or effective PTY-root change. Hash-CAS captures
and validates exact prepared bytes, rejects a captured declaration root unequal
to INPUT_HEX before taking EX or mutating catalog state, takes EX, rechecks the canonical live root,
and either reports unchanged for exact equality or creates a durable
content-addressed stage before publishing the marker. Version 1 requires an
explicit PTY root outside the canonical catalog. Hash-CAS permits declared live
workspace facts and their real ancestry to contain content. It changes
declaration leaves only; a desired workspace fact is runtime-only, so the
prepared plane need not carry its directory — a raw preimage captures none of
them — and the transaction publishes any missing one as an empty directory.
Workspace content and canonical state are never traversed, deleted, or hashed.
When an identity path is absent, its complete bundle uses an exclusive
directory rename. When its declared workspace skeleton already exists, the
durable marker fences declaration readers and marker-time state routing until
every declaration leaf has been published and verified. Applied leaves and
their parents are fsynced, the live root is re-hashed and fully admitted, then
the marker is unlinked and .st2 is fsynced.
The catalog parent is fsynced when .st2 is first created, including the
concurrent create/observe race. Retained source capture rejects a staging
destination contained by its source before enumerating that source.
The lock file is never removed: replacing its inode would split the transaction domain for processes that already hold it open. Reconciliation holds SH from discovery through execution. Validation, doctor, roster, listing, materialization-only, targeted reconciliation, and catalog teardown take a coherent SH snapshot. State-plane commands deliberately do not: their atomic files remain live while a declaration is admitted.
<catalog>/.st2/catalog-apply-incomplete is the durable whole-catalog
transaction fence. Any presence is authoritative, including malformed content.
The reserved canonical record is:
{"schema":"st2.catalog-apply-incomplete.v1","stageName":"catalog-apply-stage-<prepared-root-sha256>","expectedRootSha256":"<previous-root-sha256>","preparedRootSha256":"<prepared-root-sha256>","originalPaths":["<sorted-owned-declaration-leaf>", "..."]}After taking its authoring lock, st2 refuses publication, validation,
materialization, teardown, roster, doctor, and catalog listing while the marker
exists. One-shot and selected reconcile fail explicitly. A resident supervisor
instead remains alive, reports a skipped/incomplete pass, and performs no
runtime observation or lifecycle action, avoiding a service restart storm.
Message, context, Resource, and status operations remain available. While the
marker exists, exact agent-ID operations resolve canonical state from a
validated subject book: the marker's original canonical agent IDs union the
currently published real specs. Ordinary address operations resolve against
the corresponding coherent original-or-current address binding for that
subject and fail when the marker cannot prove one unique binding. The marker
therefore retains or content-addresses enough original declaration evidence to
recover both ID ownership and effective address; it never guesses from a
dotted string. State-only directories are addressable only for original IDs
with recognized real state; an incomplete or arbitrary new ID does not fall
back to a flat bus. Every host, agent ID, and message-box path is opened
component by component without following symlinks, and state mutations remain
relative to those retained capabilities. Only real state directories and a
real regular status file can establish marker-time addressability. Only
catalog apply --resume --catalog ROOT --json may open an existing marker. The
closed marker and internal content-addressed stage are
sufficient recovery authority; the original prepared path, input digest, and incumbent CAS
precondition are neither required nor consulted. The marker's preparedRootSha256
is the input digest already proven against retained capture before publication.
Marker authority proves both original preconditions already passed, so recovery converges the partial live tree from
the durable desired stage and original owned-leaf list without re-enforcing
that stale precondition. Malformed or mismatched records remain fenced.
External lock execution and bypass flags are not part of the contract.
st2 catalog apply --catalog ROOT --prepared DIR --input-sha256 INPUT_HEX --expect-sha256 HEX --raw-preimage --json is the only writer that accepts the raw-preimage root.
It first captures and fully admits DIR through the ordinary strict prepared
projection. Under EX it structurally reprojects the opaque live declaration
plane and checks its raw-domain root against HEX before any declaration,
workspace, state, writer-temporary, marker, or stage mutation. It never parses
or semantically validates the live bytes, including their catalog envelope,
profiles, validity, or effective PTY root. A successful CAS reuses the ordinary
durable stage, generation commit, leaf publication, strict live verification,
and fsync sequence. Its receipt schema is
st2.catalog-raw-preimage-apply.v1; its durable marker schema is
st2.catalog-raw-preimage-apply-incomplete.v1. The marker schema preserves the
projection type, so source-free --resume emits the truthful raw-preimage
receipt after converging from the strictly validated stage. This mode owns no
policy for interpreting or transforming incumbent bytes.
st2 catalog bootstrap --catalog ROOT --prepared DIR --input-sha256 HEX --json
is the create-only declaration transaction for an absent catalog. ROOT must
be one absent final component below an existing canonical real parent. st2
captures DIR through retained no-follow capabilities, verifies its declaration
root against HEX, admits the complete projection against logical ROOT, and
requires one explicit external PTY root. It materializes a 0700 sibling stage,
creates the persistent authoring lock and generation 1 inside it, takes EX on
that lock, fsyncs the complete tree, and publishes it with a capability-relative
no-replace directory rename followed by a parent fsync. Readers therefore see
absence or a complete catalog and cannot cross the already-published lock before
the parent entry is durable.
There is no bootstrap marker or resume mode: interruption before the rename
leaves ROOT absent, while interruption after it leaves the complete target. A
retry re-captures its source and returns unchanged only after taking the
existing lock, rejecting incomplete markers, proving a durable generation,
fully validating the catalog, matching the exact declaration root, proving the
locked directory remains bound to ROOT, and fsyncing the retained parent. A
different, malformed, symlinked, rebound, or uninitialized existing target fails
without mutation. Random sibling stages are non-authoritative and are cleaned
only by the invocation that created them; no broad orphan cleanup is permitted.
Bootstrap performs zero reads or writes below the declared PTY root. The PTY registry has independent producers which catalog EX cannot reserve, so atomic process adoption, continuity, or PTY-root migration requires a separate PTY registry protocol. Bootstrap claims only atomic declaration publication.
hand-authored KDL
│
▼
validate ──► materialize ──► host-local st2 scheduler/reconciler
│
┌────────┴────────┐
▼ ▼
PTY / exec DING sidecar
│ │
└──── state + bus ┘
▲
│
one intelligent root agent
observes · recovers · escalates
-
R01–R03: Fleet validation separates structural errors from selected-host runtime facts. Materialization is inspectable and host reconciliation starts only declarations pinned to the local host. Discovery is recursive: an explicit
identityandhostpair is authoritative independent of the declaration's path, whose parent remains the state/resource anchor. When either field is omitted, the path supplies defaults and mismatches remain diagnostic. Dot-prefixed folders, including.managedand.retired, are ordinary declaration space; only.gitand.st2directories at any depth, the catalog root'sptychild, and a declaration parent'sresources,archive, andinboxchildren are excluded. A resolved workspace-relative render destination has one coherent desired state across the active local fleet: byte-equivalent idempotent claims may share it, while incompatible claims fail every conflicting owner before the first workspace write. Targeted reconciliation checks the selected owner against the full fleet, so selection cannot bypass this ownership boundary. -
R04, R31: Each machine schedules and reconciles only its pinned work. The st2 loop is deterministic; exactly one declared root agent provides intelligent host-local supervision, bounded recovery, and escalation. Filesystem reads never wake reconciliation; only create, modify, rename, or remove events may wake it before the bounded timer. A generated companion is eligible only while its canonical agent task is eligible. Healthy startup launches the agent first and then its missing companions in the same pass. Holding or failing to restart the agent, or terminally parking it, suppresses companion launch and stops an exact generated companion proved live; explicitly authored sibling tasks remain independent. Restart accounting is per task and persists across reconcile passes. Only a successful launch spends its declared budget. Each completed pass supplies the exact task IDs it proved alive; uninterrupted observed liveness may forgive a fail-mode budget according to the restart field contract, while an unobserved task loses accrued recovery uptime. A pass that exits before execution neither supplies a liveness observation nor closes the accounting pass. PR #191 provides cadence, recovery, and unobserved-pass evidence for this accounting.
-
R32: Bounded non-interactive helpers such as
pty list --jsonandpty metadata patchstart in a fresh session whose leader PID is also its process-group ID. Standard output and error use regular temporary files, so a descendant inheriting those descriptors cannot hold a capture pipe open. After spawn, an input setup or write failure or a deadline expiry sendsSIGKILLto the process group and explicitly terminates the direct child. st2 waits for that child until the cleanup deadline; if it cannot finish the wait synchronously, a background waiter takes ownership before the failure returns. The process-group signal reaches a descendant that outlives the direct child; terminating the direct child alone does not. PR #202 provides descendant-lifetime and direct-child-reap evidence for this contract. -
R39: Spawn ownership is a closed two-case lifetime choice:
DetachedTaskis the existing production PTY/exec task generation. Detachment through the PTY daemon orsetsidkeeps it outside the supervisor's ownership group. Supervisor return, unwind, replacement, orSIGKILLtherefore leaves its PID and creation identity unchanged; only an explicit down, retirement, replacement, or other reconciliation action may stop it.OwnedChildGroupis an integration/evaluation harness child that must not outlive its parent. The owner first starts a watchdog as leader of a fresh process group, then joins the child to that exact group. Keeping the watchdog in the group pins the group ID until cleanup has signalled it, so a stale numeric PID cannot redirect teardown to a later process.
The owner and watchdog hold opposite ends of a Unix socket pair. Only the owner retains the write end; it is close-on-exec and the child closes its inherited descriptor before exec. Normal return and Rust unwind run the guard's
Drop, which sendsSIGKILLto the exact group, closes the liveness channel, and reaps the direct child and watchdog. Cleanup polls for at most two seconds; any waits still outstanding are moved to a background reaper before control returns. If the parent dies beforeDrop, socket EOF releases the in-group watchdog, which sendsSIGKILLto its own group. Descendants inherit group membership and are included. After hard parent death the kernel reparents and the host init/subreaper reaps the now-orphaned processes; synchronous wait by the dead owner is impossible.This hard-death realization is supported on the Unix hosts st2 targets; it does not claim a Windows job-object equivalent. The executable regressions are
tests/catalog_apply.rs::paused_catalog_child_is_reaped_when_test_unwindsfor unwind cleanup andtests/eval_up.rs::owned_test_child_group_dies_after_hard_parent_deathfor socket-EOF group cleanup. The control intests/eval_up.rs::st2_up_spec_supervises_and_respawns_a_killed_agent, plus the forced-kill cases intests/nomad_survival.rs, proves that an owned supervisor child is cleaned without absorbing the detached production task. -
R06: st2 passes the complete effective task definition to the underlying launcher so manual and supervised restarts are equivalent. Harness readiness that depends on a dynamically selected account belongs to that declared command. In particular, reconciliation never mutates an ambient Codex config before launch: the command may select an account-specific
CODEX_HOMEonly after st2 starts it.st2 pretrustremains an explicit operator utility for commands that intentionally use the ambient Claude and Codex configs.st2 owns
ST_AGENTfor every PTY and exec task, setting it to the catalog-global immutable agent ID on every reconciliation. Compact agent lowering assigns that ID explicitly to its canonical task. Other named tasks without explicit IDs append.<task-name>, including a task namedagent. The resolved host remains a separate declaration and launcher input; it is not separately concatenated into either derivation, and any host-looking bytes inside a legacy ID are opaque. An omittedST_AGENTis injected, an authored exact match is accepted, and a conflicting authored value refuses the declaration before workspace materialization or runner access. The value is part of the persisted launch environment, so initial launch, supervised replay, and manual PTY restart preserve the same actor ID.The canonical
agenttask treats a reconciler's ambientNO_COLORas a launcher preference rather than agent policy. Unless the Agent Spec declaresNO_COLOR, st2 removes it from the launch environment and records the removal in the PTY launch definition. An explicit Agent Spec assignment takes precedence. Isolation wrappers preserve both assignments and removals, so a manual PTY restart under a different ambient environment reconstructs the same effective color policy. Adoption of an already-live task remains non-mutating: this policy is applied only when st2 creates a generation. -
R42: Launch argv is opaque at every st2-added wrapper boundary. In Linux systemd scope mode the exact outer command order is:
systemd-run --user --scope --collect --quiet --unit=<unit> --expand-environment=no -- <program> <arg>...--expand-environment=nois an outersystemd-runoption immediately before the--separator.<program>and every<arg>are appended as their original OS strings, without shell rendering, dollar escaping, or environment substitution. Detached and degraded-detached modes remain<program> <arg>...pass-throughs with no outer command.src/isolate.rs::tests::wrap_scope_disables_expansion_and_preserves_dollar_bearing_argvfixes the complete wrapper order and proves literal$HOME,${UNSET}, and$$after the separator.src/isolate.rs::tests::wrap_detached_modes_preserve_exact_program_and_argvproves both pass-through modes. The live-system distinction and unaffected scope semantics are recorded in the systemd scope argv experiment and0016-systemd-scope-wrappers-disable-environment-expansion. -
R07: Hook bundles are explicit, content-addressed, installed separately, and verified before materialization references them. Their receipts use the same resolved build identity as the binary's version surfaces for both hermetic package builds and source builds. Installation automatically accepts an ordered upgrade;
--replaceis the explicit exact-state authority for a downgrade, an unorderable build, or an unreadable receipt. Shipped hooks resolve Bash throughPATH; the Nix package executes their integration gate with Bash andjqdeclared. Runtime materialization verifies the invoking binary's own content-addressed set, independent of which installed binary the receipt currently selects, so old and new supervisors can overlap during cutover.hooks verify-ownexposes that read-only capability to package activation tooling. -
R11:
st2 upis a replaceable control plane, not the lifetime owner of its agents. Normal exit, forced termination, binary replacement, and restart leave every running agent PID and creation identity unchanged. The new control plane adopts those processes and starts only missing work; it does not duplicate them. Agent stop or retirement requires a separate explicit lifecycle action.Executable acceptance starts an agent, terminates
st2 upnormally and with a forced kill, verifies the agent remains alive and usable, replaces the st2 binary, starts the control plane again, and proves adoption with the same agent PID/creation identity and no duplicate process. -
Adopt-only migration fence: A compact agent or explicit task may declare
lifecycle "adopt-only". Reconciliation adopts an already-live generation, but classifies a dead or absent generation asheldwithout garbage collection or launch. Returning the declaration to the defaultservicelifecycle is the explicit authority to resume ordinary replacement. A non-running agent desired state remains the separate explicit teardown path. Suspension and retirement never use task lifecycle as an implicit resume or replacement authority. -
R23:
st2 tasks --jsonis a read-only diagnostic boundary. It emits onest2.task-inventory.v2envelope for the selected host. Rows are sorted by immutable agent ID, task name, and runtime ID and cover both PTY and terminal-free exec tasks. Each row includes immutable agent ID and nullable current bus address; a proved non-routable retired subject has a null address without weakening completeness.complete=falseplus a non-zero exit is a closed result: a consumer must not turn a missing row into absence. A running row always carries a PID, creation time, opaque generation ID derived from stable backend evidence, and the requiredruntime.resourceTarget.resourceTargetis internally tagged bytypeand has exactly these wire shapes:typeFields Meaning linuxCgroupV2pathThe slash-prefixed unified-hierarchy path exactly as read from /proc/<pid>/cgroup;/is the cgroup-v2 mount root.darwinProcessTreerootPidThe observed generation's PID, used as a best-effort process-tree root. unavailablereasonNo safe target: one of notRunning,runtimeIndeterminate,processUnavailable,generationChanged,cgroupV2Unavailable, orunsupportedPlatform.Linux accepts exactly one
0::<path>entry. It preserves the path bytes represented by UTF-8 text, including spaces and colons, but rejects a non-absolute path, NUL or carriage return, repeated separators, trailing separators,.or..components, duplicate unified entries, and input with no unified entry. It does not derive the path from a systemd unit, scope name, runtime ID, or naming convention.PTY observation captures each candidate daemon's kernel process-start token after the registry snapshot, then executes
pty stats --jsonexactly once without a session argument. The returned array is socket-backed live evidence for every candidate. A token is admitted only when exactly one stats row has the task name,process.alive=true, the samedaemon.pid, and the samecreatedAt, and a second kernel-token read equals the first. Resource observation then reads the token before and after the target. Exec compares those target-fence reads directly to the token already bound into its generation record. A missing process isprocessUnavailable; a stats or token mismatch, or a token that changes across a read, isgenerationChanged. Neither result exposes the candidate path or root PID. Darwin uses the same fence around its root PID. A boundedunavailabletarget is a truthful successful field and does not by itself make the envelope incomplete. Locators are sampled afresh on each command: st2 stores no scope registry, performs no resource sampling, and gives no target continuity guarantee. Runtime ID remains task identity.Discovery runs before and after runtime observation. A semantic declaration change across those passes makes the result incomplete. The reader also samples
<catalog>/.st2/catalog-generationand the incomplete marker around discovery and runtime observation. Every successful declaration writer advances and fsyncs that monotonic generation after its durable commit; apply does so after live verification and before clearing its marker. Even a completed declaration ABA is therefore incomplete. This remains an observational seqlock and does not serialize catalog writers. A runtime root positively absent at admission is empty and is not passed to its backend. An admitted PTY root that is removed or replaced duringpty listis indeterminate; because the external backend creates an absent registry, concurrent root deletion is not a zero-write boundary. Malformed state, PID reuse, timeouts, duplicate ids, and observer failures are likewise indeterminate. Existing plain-PID exec records are opened read-only without following symlinks and verified by retained file identity, unchanged content and metadata, the final path identity, process start token, and record mtime without rewriting them. If that proof is unavailable on a supported OS, the generation remains indeterminate.Each row retains the task-level
desiredState(runningorabsent) and appends the declaration-levelagentDesiredStateplusagentDesiredStateReason. The legacyretiredboolean remains a projection for compatible readers.st2 agents --jsonlikewise appendsdesiredStateanddesiredStateReason; presence remains an independent observed signal.A believable parked-task projection is a known supervisor fault, not a runtime state: the row retains its unmodified runtime observation and adds the park time, reason, and explicit per-task recovery action while the envelope remains complete. That action is structured
argvcontaining the exact canonical catalog folder and selected host observed by the inventory; executing it does not consult ambient defaults for either ownership axis. An unreadable, malformed, wrong-generation, or otherwise unbelievable park marker is indeterminate and makes the envelope incomplete. The reader selects only the marker channel owned by the exact canonical catalog folder and host it is observing. -
R41/R28: Agent lifecycle intent is one closed declaration state:
running,suspended, orretired. The KDL form is a direct child such asdesired-state "suspended" reason="Waiting for capacity". Omission means running. New suspended and retired states require a bounded rationale; running forbids one. Legacyretired #trueremains readable without a rationale, but old and new lifecycle syntax cannot coexist.Reconciliation treats both non-running states as desired task absence and includes derived companions. Materialization skips them. Suspension is converged when no task is live and only explicitly keep-pinned dead records remain; retirement is complete only after every task record is absent. Resume simply returns to ordinary task planning, preserving
keep,adopt-only, ownership, and replacement fences. Durable messages, context, resources, and the declaration are not task runtime and remain available.st2 agent desired-stateperforms one source-preserving canonical KDL edit under the persistent catalog-authoring lock. The self/descendant trusted-fleet guardrail matches presentation authoring. Running removes lifecycle syntax; suspended and retired emit the canonical node. A success receipt proves authored intent only. Reconciliation and Doctor separately prove observed convergence.Lifecycle is the one authoring verb a declaration's own generator may use. Without an assertion, a Nix-owned declaration refuses exactly as before.
--managed-by <marker>asserts the ownership marker the caller believes owns the declaration, and is admitted only whenmeta { managed-by "..." }names exactly that one marker; a mismatched marker, an unmarked declaration, an unresolvable multi-marker declaration, and an empty or padded assertion all refuse. The authority exists because a projection has exactly one transition its own source cannot express: the source change being projected is the seat's removal, so "edit the generated source instead" names an edit the operator already made. A marker-matched edit therefore stands in for the compare-and-swapagent publisha projection would otherwise perform, and carries that path's admission: the complete prospective catalog must validate before anything is committed, so a retirement leaving an active agent descended from a retired root refuses rather than landing. The receipt records the confirmed marker. Presentation, address, stream, and Resource authoring keep the unconditional refusal: none of them projects a source the generator cannot itself rewrite.Inventory performs no reconciliation, launch, teardown, cleanup, lifecycle edit, state migration, or catalog write. It does not authorize a staged supervisor replacement; any cutover requiring transactional declaration authority needs a separate protocol.
-
Session registry: A catalog owns the
ptyregistry holding its tasks.<catalog>/ptyis the default; a catalog may declare another so that one host can share a single registry across catalogs. Resolution is an exportedPTY_ROOT, then the catalog's declaration, then the default, applied uniformly to spawn, list, kill, and the bus environment st2 hands to native tools, so every reader that can resolve the catalog agrees about where its sessions are. A declaration whose field set does not match failsst2 validaterather than resolving silently back to the default. Runtime observation has a short outer deadline so a wedged client fails the pass closed instead of hanging reconciliation. The deadline is containment, not the mechanism for admitting a larger fleet.
Non-interactive helper shells-outs (src/run.rs, src/ding/mod.rs) share one
shape: the child is setsid so its pid is its process group, stdout/stderr go
to unlinked tempfiles (an escaped descendant that inherited them cannot block
cleanup), and deadline expiry kills the whole group. Wait ownership for a
killed child transfers to one shared reaper thread draining a channel — never
one detached thread per timed-out child, which accumulates without bound under
timeout storms.
Read-back is tail-capped at CAPTURE_CAP_BYTES (256 KiB) per stream: over-cap
streams keep their last 256 KiB and emit one diagnostic line naming the
command, stream, kept/total bytes, and cap. Memory per capture is therefore
bounded by calls × 2 × cap regardless of child behavior. pty list --json
parses structured output that must be whole, so it uses the explicitly named
full-stdout variant; that read is intentionally uncapped and visible at its
call site. Eval run steps and agent log dumps stream child output straight to
their catalog log files without buffering it. Rationale and rejected
alternatives: 0007-child-output-capture-is-bounded-and-tail-preserving.
Managed harness ownership is explicit. A declaration uses its typed driver or
one session-driver "claude|codex|pi|opencode|omp" and pairs it with exactly
one non-secret readiness declaration:
delivery-readiness "credential"
delivery-readiness "credential" account-id="tokengate/shared"
delivery-readiness "anonymous" "model-a" "model-b" harness="omp"Credential omission delegates account choice to the native driver. Anonymous
readiness has at least one model; lowering sorts and deduplicates the model set,
and harness must equal the effective native driver. A legacy deliver value
must match that same explicit driver. No command-basename inference is
admitted. A managed driver, readiness, or native delivery transport cannot
coexist with Ding; Ding remains only for opaque non-harness PTYs.
st2 catalog graph --json schema st2.catalog-graph.v2 publishes
effectiveSessionDriver and deliveryReadiness separately from runtime. It
also publishes admitted topology:
{
"parentId": "host.parent",
"rootId": "host.root",
"depth": 2,
"ancestorIds": ["host.parent", "host.root"]
}A root has null parentId, its own rootId, depth zero, and an empty ancestor
array. Root counting folds retirement before the graph is built: a retired
declaration — legacy retired #true or desired-state "retired" — is outside
the org chart and never holds the root slot, while a suspended root still
counts, so a host suspending its only root stays valid and a host whose every
root is retired reports zero (#402). Duplicate identity, missing or ambiguous
parent, cycle, depth beyond 64, or a host with other than one counted root is
an error. An active agent whose chain terminates at a retired declaration is
an error too (retired-root): the active org chart descends from the counted
root, so one active root plus a retired root still supervising an active
worker does not validate. Every affected topology field
is null and the graph envelope has complete: false; downstream consumers use
these admitted facts rather than walking supervisor edges themselves. The
declarations view applies the same fold to legacy retired #true, publishing
desiredState: "retired"; an absent lifecycle stays null, which lowers to
running.
Retired reconciliation first attempts every live task teardown for the agent.
Only when all of those attempts succeed does it settle the declaration's whole
inbox; one failure leaves every inbox file untouched and the next pass retries
teardown plus settlement. With no live tasks, settlement proceeds immediately.
Each canonical inbox filename is linked into resources/archive and then
removed from the inbox. An existing archive file wins byte-for-byte, so replay
and a sync-restored duplicate converge without overwriting the receipt.
Suspended reconciliation never performs this settlement.
Retirement is where the lifecycle stops being about runtime and starts being
about catalog size, so st2 catalog archive adds one step past it:
running → suspended → retired → archived. Archival moves the whole identity
directory from agents/<host>/<identity> into the catalog control plane at
.st2/archive/<host>/<identity>, under the exclusive authoring lock and inside
one generation commit, as a same-filesystem rename. .st2 is excluded from
catalog space at any depth, so an archived declaration is structurally
undiscoverable and is never projected by a whole-catalog transaction — it is
absent from the declaration plane, not filtered out of it. A tombstone file
beside the moved directory carries {id, host, identity, archivedAt, reason, archiveRoot} and is published as one additive archived row in the
st2.catalog-graph.v2 envelope; an ordinary archived identity therefore leaves
complete: true untouched, while an archived directory with no readable
tombstone is unexplained control-plane state and makes the envelope incomplete.
Eligibility is fail-closed and local-host only, because another host's runtime
records are not observable: canonical path, retired in either spelling, no live
or dead record for any declared task, and no remaining declaration referencing
the subject's immutable ID as supervisor. st2 catalog unarchive is the exact
reverse move; it preserves the archived ID, refuses an unmigrated archive after
ID-aware activation, and validates ID uniqueness against the prospective
live-and-archived subject set. A later transition to a routable desired state
validates prospective effective-address uniqueness.
The supervisor closes the same edge without an operator. Each st2 up reconcile
pass ends by archiving every local seat whose retirement outlived the catalog's
archive-after grace period (default 7d; "0" disables the step), applying
the same fail-closed eligibility as the verb and archiving at most 25 seats per
pass so one pass stays bounded. The step runs after the pass releases its shared
lock and takes the exclusive lock non-blockingly: a contended lock skips the
step, because a pass queued behind st2 catalog apply would stall every live
agent's reconciliation and a due seat is still due next pass. st2 records no
timestamp for a desired-state edit, so the grace period is measured from the
supervisor's first observation of the retirement, kept in
.st2/retired-observed.json (st2.catalog-retired-observed.v1) as host →
identity → epoch millis and never in the spec. That ledger is reconciled to
exactly the currently retired seats on every pass it runs, so a seat that comes
back drops its row and a second retirement serves a fresh grace period; an
absent or unreadable ledger restarts every clock, which errs toward keeping
seats in the live catalog.
Before starting a Codex provider, st2 asks that binary to generate its
app-server JSON schemas and fingerprints only the delivery-critical projection:
every client request and server notification arm st2 uses, the exact
method-to-params reference for each arm, recursively referenced definitions,
and response definitions st2 reads. Only reviewed fingerprints are admitted.
Live turn, resume, and durable-receipt evidence remains a separate behavioral
check and is not inferred from the fingerprint.
sender intent → atomic inbox file → sender row → DING attempt → agent reads → archive receipt
│ └── pending/keyed retry reuses filename ──┘ │
└──────────── explicit partial coverage ───────────────────────┘
- R05: A matching archive filename makes an inbox copy handled; stale
duplicates are removed without another DING. Fresh
dndsuppresses delivery;busydoes not. Failed delivery remains retryable. Sidecar restart emits a bounded recovery notice instead of replaying the inbox. Delivery may wake an agent while it is working, but an active or uncertain human composer must be left untouched. Unsafe delivery retries use a bounded backoff so an active composer cannot create a short-lived PTY probe on every inbox poll. Inbox reads do not wake the sidecar; only mutations bypass its bounded poll cadence. - Sender-owned publication, crash recovery, coverage, and
message sentare specified in03-message/spec.md. Recipient publication precedes a completed sender row; the two directories do not share one atomic commit. Pending unkeyed intent resumes, but exact replay after the intent clears requires a caller-supplied idempotency key.
- R08: Presence and activity status are separate signals. The version 1 presence record below defines presence freshness. The catalog must also expose the agent's current plan and step with explicit freshness so a human or supervising agent can understand progress without PTY inspection. The canonical plan-progress shape is not yet specified.
- R09: Durable work state is external to the model transcript and is restored into replacement sessions through declared workspace files and verified hooks.
- R10: Fleet identities are agents. General-purpose identity kinds are unsupported.
This section answers the presence part of DQ3. The version 1 implementation follows this contract.
The presence record path is <agent-dir>/status.
The status file uses this exact version 1 shape:
available
v1 1785802653486
Line one is one settable state: offline, available, busy, away, or
dnd. unknown remains derived and is never written.
Line two is v1, one ASCII space, and an unsigned base-10 timestamp. The
timestamp counts milliseconds from the Unix epoch.
The record ends with one newline. It has no other non-empty lines. Both lines form one atomic record.
The state remains on line one for old readers. An old reader can ignore line two and continue to parse the state.
st2 status --set writes the requested state with the current timestamp. A
live session owner refreshes valid non-DND records every five minutes.
A missing record becomes available with the current timestamp. The session
owner does not refresh dnd, unknown, or malformed records.
Every new writer emits version 1. It writes a temporary sibling and atomically renames the complete record over the target.
A healthy periodic refresh changes the timestamp bytes. Replication can order that content change without using the source file mtime.
The timestamp uses the writer's UTC wall clock. A monotonic clock cannot cross a process restart or a host boundary.
Participating hosts must keep their UTC clocks within sixty seconds. A larger clock error makes cross-host presence unknown.
The stale interval remains fifteen minutes. A valid record is fresh while its age is less than fifteen minutes.
A record becomes unknown when its age reaches fifteen minutes. This rule
applies to every settable state, including offline and dnd.
A timestamp up to sixty seconds in the reader's future is allowed. The reader uses zero age for this bounded future value.
A timestamp more than sixty seconds in the future produces unknown. The
reader does not use file mtime as a fallback for malformed version 1.
Current readers treat a future legacy status mtime as fresh because they cannot
calculate its age. A sufficiently future dnd mtime can therefore suppress
delivery until the reader's clock catches up. The version 1 skew rules close
this defect. They clamp only bounded future time and map larger future time to
unknown.
An unrecognized state still produces offline. A literal unknown produces
unknown. A valid state with a malformed version, timestamp, or extra line
also produces unknown.
The sixty-second allowance is smaller than the five-minute refresh margin. It can extend a fresh DND hold by no more than sixty seconds.
st2 does not require one catalog transport. Fabric is preferred, and Git over SSH or a plain copy remains supported.
Git does not preserve file modification times. A checkout gives files the checkout time. Therefore, presence freshness lives in record bytes. No supported transport must preserve file metadata.
Replica arrival time measures transport delay, not agent activity. The
embedded writer time protects presence freshness, DND expiry, and the status
contribution to lastActivity.
The same reason applies to the context boot freshness check. A replica arrival must not make old context appear fresh. This proposal does not change the context record.
A fresh dnd record suppresses DING delivery. The session owner leaves its
timestamp unchanged, so an abandoned hold ages out.
A stale or invalid DND record does not suppress delivery. It reads as
unknown, which preserves the existing fresh-DND rule.
Replication delay cannot renew a DND hold. The reader uses the embedded write time, not the replica materialization time.
A legacy record contains one valid state line and no version line. The first version 1 reader release uses legacy file mtime for freshness.
Version 1 writers never emit a legacy record. A live non-DND session owner upgrades its legacy record at its next five-minute refresh.
A version 1 session owner upgrades a legacy DND record once. It uses the legacy mtime as the embedded timestamp, so the migration cannot renew the hold.
After that migration, the session owner does not refresh DND. If the legacy mtime is unavailable, the session owner leaves the record unchanged.
A malformed two-line record is not legacy. Readers must not hide a bad version 1 record behind the legacy mtime fallback.
Fallback removal is a separate reviewed change. Removal requires all three receipts below:
- Every supported deployed status writer emits version 1.
- Two fleet scans, separated by fifteen minutes, find no active legacy record.
- No supported or retained rollback binary can emit a legacy record.
After removal, a one-line record produces unknown. No presence freshness
decision then depends on status file mtime.
For a version 1 status record, lastActivity uses the embedded timestamp. It
does not use the replica materialization mtime.
The reader clamps an allowed future timestamp to its current time. It omits a malformed version 1 timestamp from the activity calculation.
Inbox and archive entries continue to use their local file mtimes. During the legacy window, a one-line status record also contributes its file mtime.
This choice reports when the agent wrote its heartbeat. A delayed replica cannot make an old heartbeat appear to be new agent activity.
fresh durable context --\
+--> compose text --> jq -Rs stdin --> provider JSON stdout
boot ritual ------------/ |
`--> construction failure signal
The Claude SessionStart hook reads fresh context through st2 context read,
wraps non-whitespace content in a source-and-agent envelope, appends the boot
ritual, and streams the complete composed text into jq. jq raw-slurps stdin
and emits continue: true plus
hookSpecificOutput { hookEventName: "SessionStart", additionalContext } on
stdout. Context bytes never occupy one process argument and are not truncated.
Missing or stale context omits the envelope while retaining the ritual. A
missing jq fails open with exit 0 and no output; a missing st2 fails open
with the ritual only. These are supported degraded starts, not evidence that
context was delivered. Any other JSON-construction or delivery failure must be
distinguishable from those cases and propagate durably under R17.
Executable acceptance in tests/claude_hooks.rs covers the model-visible
stdout envelope, empty stderr, missing and stale context, missing dependencies,
and context larger than a platform argument limit without truncation. That
target is gated: it is in checks.st2's cargoTestFlags and the derivation
supplies its bash and jq, so a regression fails CI. Durable failure
propagation under R17 is a separate axis and stays open as DQ5.
The owner updates this spec whenever implementation changes. Changing vision.md or requirements.md requires Nathan's explicit approval.
pi has no MCP, no app-server, and no lifecycle-hook mechanism. Its integration point is an extension loaded into the live interactive process. st2 therefore delivers to pi through a channel that runs inside the session rather than through the terminal.
inbox --> st2 driver pi-channel --NDJSON--> pi-channel.ts --> pi.sendUserMessage()
^ |
`-------- delivered/failed ----'
st2 driver pi-session owns the launch. It resolves pi-channel.ts from this
binary's verified immutable hook set and splices -e <path> in immediately
after the provider program, so a declaration never carries a machine-local
path. It exports ST2_PI_CHANNEL_BIN, ST2_PI_CHANNEL_CATALOG, and
ST2_PI_CHANNEL_IDENTITY; the extension spawns exactly that binary rather than
resolving st2 from PATH, which is what keeps a replaced control plane and
its live agents from disagreeing under R11. The wrapper also owns the
five-minute presence heartbeat for as long as its provider lives, on the same
terms as the Claude wrapper.
The channel is newline-delimited JSON in both directions. It opens with
{"type":"hello","protocol":1,"identity":...}; an extension that does not
understand the protocol number closes the channel rather than guessing. Each
unread inbox entry becomes one {"type":"message","deliverAs":...,"content": ...,"meta":{...}} frame carrying the same subject-and-body envelope the Claude
channel uses. The inbox remains the durable source of truth: the channel holds
only an ephemeral set of filenames delivered in its current lifetime, and a
restart rescans. EOF on stdin is the session boundary.
pi has no session-start hook, so the hello frame also carries
sessionContext: the same restored working state, boot ritual, and
unread-inbox listing that $ST_HOOKS/codex-session-start.sh composes, in the
same order, composed here in Rust. The extension awaits that frame inside
session_start, which orders restoration ahead of the boot turn rather than
after it, and a fresh channel is opened for /new, /resume, and /fork, so
a replaced session is restored on the same terms.
deliverAs is st2's decision, not the extension's, so delivery behaviour is a
Rust change with one place to make it. The shipped value is steer: the
earliest point pi accepts input without discarding the running turn, and the
same choice the Codex native path makes when it routes an active turn to a
steer. Measured against a live provider, the boundary is one tool call, not one
job — each tool call and its result are their own assistant message, so a steer
sent mid-job is injected in the same millisecond as the next tool result.
Latency is bounded by the in-flight call's remaining duration. Whether displaced
work resumes is the model's choice, not a harness guarantee. Holding until the agent settles would defer
delivery inside pi where st2 cannot observe it, which is what the "busy
delivers immediately" rule exists to prevent. Per-message selection between
steer and queue is tracked in #277.
A managed pi seat runs with PI_OFFLINE=1 and PI_SKIP_VERSION_CHECK=1 unless
its declaration already sets them: a supervised agent must not self-update or
make its boot latency depend on the network. The extension unexports
ST2_PI_CHANNEL_* once it has read it, because pi places its environment in
front of every tool child, and eval seats scrub PI_* for the same reason they
scrub CLAUDE_* and CODEX_*.
st2 pins no pi version. pi publishes none to pin — no changelog, stability
policy, compat field, or API version constant — but it does publish its
TypeScript declarations, and those govern this coupling.
checks.pi-extension-types type-checks the shipped extension against a pinned
pi release at build time, which is what makes the asset's otherwise-erased
import type load-bearing. This follows the repo's existing rule that st2 validates
where skew fails silently (pty, the Codex app-server schema) and not where it fails
loudly (claude); the extension's one silent surface was its idle proof, and using
that proof without calling it is now a build error.
A pi agent never enters the DING path. deliver and ding are mutually
exclusive in a declaration, so no PTY write is authorized for a pi seat and the
synchronous composer proof is not consulted. Workspace trust is a launch flag
(pi -a), so nothing in the operator's ambient pi configuration is mutated and
there is no pi analogue of the pre-boot trust batching Claude requires.
The measured basis for this design, including the runs that fixed steer and
the modal case, is recorded in
2026-08-18-pi-harness-integration;
the ruling is
0005-pi-delivers-natively-through-an-injected-extension.
An event is evidence, not permission to mutate runtime state. The classifier maps each accepted path and kind to one consumer. A resident catalog wake starts one serialized full-catalog pass; discovery and runtime observation then compute the desired-versus-actual delta, and an empty plan performs no launch, teardown, materialization, or catalog write.
Watchers are deny-by-default. The classifier/action contract is:
| Event | Minimal action |
|---|---|
.st2/catalog-generation create/modify/replace |
queue one serialized full-catalog reconcile after a cooperative commit |
root catalog.kdl create/modify/remove |
queue one serialized full-catalog reconcile |
declaration-space **/agent.kdl create/modify/remove or declaration-directory topology change |
queue one serialized full-catalog reconcile |
referenced _templates/** mutation |
queue one serialized full-catalog reconcile |
| inbox create/archive/remove | DING consumer only; supervisor no-op |
| plan/resource/status mutation | specialized consumer only; supervisor no-op |
| PTY/exec/log/PID/socket/lock/temp/backup/read/open/unknown | no-op |
The catalog-generation watcher owns a separate backend instance and one
non-recursive control-directory subscription. It revalidates that directory's
identity after every pass and reinstalls a stale subscription. The declaration
watcher owns shallow subscriptions only for declaration-space directories; it
prunes .git, .st2, PTY state, inboxes, archives, and Resource payloads. Both
channels forward unit wakes to the same single-threaded loop. A burst is drained
before one pass, and a pass never overlaps another pass.
Watcher setup or callback failure is diagnosed and queues a conservative audit when a callback exists. The independent channel remains live. Startup, timer, watcher overflow/loss, and ambiguity are bounded full-audit fallbacks. Executable proof covers a post-commit generation replacement, generation-watch reinstallation after control-directory replacement, direct atomic bundle publication at production catalog scale, root configuration and declaration wakes, negative runtime/bus events, disconnected-channel cadence, and an idle supervisor that does not wake on its own reads.
Useful work is quiet by default. Presence, status, and durable plan data are facts. They do not cause a status message or a peer poll.
Coordination starts only after one of these events:
- An unread inbox message. DING the recipient. Process or hand off the request.
- A durable failure or real blocker. Tell the responsible supervisor. Find the cause. Repair the failure or escalate it.
- A completion or decision. Give the result to the agent or principal that needs it.
- A declared schedule with a name. Do that work. Do not replace the schedule with repeated messages or polls.
After an event, continue until you resolve the need or hand it off. Then become quiet.
The inbox uses the source contract in DING requirements. A normal supervisor handles failures and blocked work. It does not continuously manage healthy agents. A custom supervisor persona can ask for more frequent coordination. CoS is only an example. st2 does not require or define that role, and R22 gives it no standard authority.
R22 does not define the schedule grammar in DQ1. A schedule must have a declared name. Transport loss can delay coordination. It does not stop independent host-local work. Local work does not depend on a global service.
st2 up --materialize-only --task <host.agent.task> resolves one exact local
task before writing and renders only its owning agent. st2 up --once --task <host.agent.task> performs the same owner-only materialization, then inspects
PTY/exec state and executes a plan containing only that task. Unknown,
ambiguous, and wrong-host selectors refuse before writes or runner inspection;
unrelated discovery diagnostics remain visible without preventing the selected
owner/task path. A live generated companion may be adopted or retired as that
exact selected task. An active dead or absent generated companion is held: the
bounded pass cannot start its canonical agent without broadening the selector.
Explicitly authored sibling tasks retain ordinary selected-task behavior.
st2 up --materialize-only --agent <id> remains the agent-wide rendering
selector. Targeted task reconciliation is intentionally bounded to --once;
the resident supervisor continues to reconcile the complete local catalog.
-
DQ1 Scheduled work: The vision includes per-machine schedulers that form a distributed workflow engine, but the KDL shape, event inbox, deduplication boundary, and execution receipts are not yet specified. A successful executable eval and Nathan's approval should resolve this before adding scheduler requirements. Three of the four unspecified items now have accepted answers in the stream subsystem: the event inbox is the ordinary agent inbox, deduplication is
(stream, event-id)per recipient, and receipts are thecreated | deduplicatedemit receipt plus archive filenames. ThescheduleKDL shape, its relationship to a timer stream, and the approval above remain open;everyon a stream stays rejected so this question is not resolved by implication. -
DQ2 Safe DING delivery: Bounded observation now replaces the fixed paste-to-Return delay: maintained Codex and Claude composers must be positively empty before paste and show the exact staged notice twice before a separate Return. Human, modal, active, changed, timed-out, and unknown states fail closed, with staged-payload ownership preventing duplicate paste. This measured screen heuristic is still not an evented proof and renderer changes may defer delivery. Resolve the remaining gap with a stronger evented signal or other measured classifier; a small on-device model is an optional experiment, not a required architecture. The 05-harness-state pi producer is the first evented signal for any harness (the injected extension's positive idle observation); it informs observability only — delivery keeps its own evidence, and no DING path reads the observed-state record.
-
DQ3 Remaining catalog agent state: The R08 presence record above defines the presence path, schema, freshness, and atomic update rules. The observed axis is now specified in 05-harness-state (decision 0006): a driver-written record with its own staleness rules, distinct from declared presence and from R08's declared activity status. Declared activity status, current plan, and current plan step remain undefined. The stale-state gate is addressed for the observed axis; the supervisor-following gate is not (DQ-H5) — prove both before adding any of these shapes to
AGENT-SPEC.md. -
DQ4 Relaunch boundary (R29-R30): Preserve R11's nondisruptive adoption while making launch drift visible. For each declared task, derive the desired launch fingerprint from a deterministic, versioned encoding of only: backend kind, lowered shell source or direct argv, resolved working directory, and the st2-managed plus declared effective environment. Tags, descriptive metadata, unrelated inherited environment, file contents, and other boot-time snapshots are not part of this fingerprint.
As part of its own successful task launch, st2 records the observed fingerprint together with that launch's exact runtime identity and creation incarnation. The observed fingerprint is trustworthy only while the current live runtime exactly matches that binding. Inspection then reports:
State Meaning Healthy-task action convergeddesired and bound observed fingerprints match adopt drifteddesired and bound observed fingerprints differ adopt and report drift unknownthe observed binding is missing or does not match the live runtime adopt and report unknown unknownincludes a healthy legacy or externally adopted runtime, as well as a manual PTY restart or external child replacement whose runtime identity or creation incarnation no longer matches st2's launch record. Stale observed metadata is never reused for the new incarnation. Catalog publication, supervisor restart, metadata edits, and launch-field edits do not implicitly disrupt any healthy task.Ordinary reconciliation remains sufficient after every interruption:
Declaration Process Action active absent or dead reap stale state and launch the latest current desired contract active alive adopt and report converged,drifted, orunknownretired alive stop; do not relaunch retired absent or dead do not launch Replacing live drifted work is a separate explicit operation. Its scope is one selected catalog, pinned host, resolved effective PTY root, and selected task set. A future interface may preview drifted tasks and select one, a subset, or all of them; this contract does not reserve a command name. The operation must re-read the selected task and recheck its exact live runtime identity immediately before each stop. A missing, changed, wrong-host, or wrong-root target refuses without disruption.
Replacement does not capture an old launch contract or boot inputs. If st2 stops after the identity check and is then interrupted, ordinary absent/dead reconciliation launches the latest current desired contract. There is no replay of an older generation, durable operation journal, operation ID, phase machine, terminal receipt, or atomic old-to-new runtime transition. A task rename is the explicit sequence retire old, then add new.
This entire lifecycle works from an ordinary copied or synchronized catalog folder. CAS may later add publication, history, or storage optimization, but fingerprinting, inspection, reconciliation, replacement, retirement, recovery, and rename must neither require nor become incomplete without it.
Executable acceptance proves:
- metadata, tags, and Resource-only edits preserve the fingerprint and live
runtime, while kind, launch, resolved-cwd, or effective-environment edits
report
driftedwithout changing runtime identity; - a healthy legacy runtime reports
unknownand remains unchanged, and a manual or external restart with a changed runtime identity or creation incarnation cannot reuse the prior observed fingerprint and reportsunknown; - natural exit or death launches once from the latest current declaration and records that launch's observed fingerprint;
- explicit replacement refuses stale identity or scope, and affects only the selected drifted tasks;
- interruption after stop heals through ordinary reconciliation to the latest current desired contract, without old-state replay;
- retirement stops and prevents relaunch, while rename works as retire-old/add-new; and
- the same proofs pass using only a plain local catalog folder with no CAS service, CAS metadata, database, or network dependency.
The executable acceptance above resolves this open implementation design. See #40, #41, #44, and #60.
- metadata, tags, and Resource-only edits preserve the fingerprint and live
runtime, while kind, launch, resolved-cwd, or effective-environment edits
report
-
DQ5 Session-start failure receipt (R17, R33): The hook has explicit fail-open results for missing enrichment dependencies, but an unexpected
jqconstruction failure can still leave the provider without context or a durable supervisor-visible receipt. Resolve the gap by defining and proving a failure signal that does not block provider startup, is distinguishable from an ordinary cold start, and reaches the responsible supervisor under R17.