Current release candidate: 0.1.0rc5, paired with Herdres 0.7.0rc4 on
Python 3.13. Cross-repository and live-provider proofs remain explicit
release-owner operations.
Tendwire is a local API and durable control plane for Herdr. It lets apps, automations, and connectors observe coding agents, read lossless turns and pending questions, and submit commands without depending on terminal internals. It stores neutral snapshots and command receipts and exposes a public-safe API for local clients. Concrete-provider delivery bookkeeping, raw terminal controls, socket paths, and private Herdr identifiers are not part of public JSON.
Tendwire is listed as a community Herdr plugin. Install the source checkout and its read-only actions with:
herdr plugin install plotarmordev/tendwire
herdr plugin action invoke plotarmordev.tendwire.doctorThe plugin actions expose public-safe diagnostics, snapshots, schema-v2 turns, and pending interactions. They run from Herdr's managed checkout and require Python 3.13 or newer. Plugin installation does not silently create or start the background daemon; use INSTALL.md for persistent service setup.
Herdr is the only concrete runtime backend documented here. Tendwire can observe Herdr through the conservative CLI one-shot path or, when explicitly enabled, through the Herdr socket/event backend. Both paths normalize Herdr state into neutral Tendwire spaces, workers, attention, turns, pending interactions, command results, connector jobs, and backend health.
Herdres can use Tendwire as its source/control plane while Herdres remains the Telegram connector. Tendwire owns Herdr observation, private bindings, turns/pending interactions, attention, command routing, receipts, backend health, event/projection state, and the neutral connector outbox. Herdres owns Telegram formatting, topic/message state, replies, rate limits, retries, and delivery bookkeeping. Hermes, MCP, iOS, AR, UI surfaces, local LLMs, and concrete connector implementations remain outside this README's active scope. The connector outbox is only a neutral Tendwire boundary for a separate process to poll, acknowledge, fail, or defer jobs; it is not a Telegram, UI, or delivery bridge.
Public Tendwire JSON must not expose raw pane_id, terminal_id,
backend_target, Telegram/chat/topic/message IDs, socket paths, raw target
values, private fingerprints, argv/env/stdout/stderr, tokens, or secrets. Herdr
pane and terminal identifiers may exist only inside private
WorkerBinding/store internals used by Tendwire itself.
When Tendwire can validate an authoritative Herdr workspace/public-pane
identity, a public worker may carry optional continuity metadata in meta.
meta.stable_key has the exact format wsk1_ followed by 64 lowercase
hexadecimal characters (^wsk1_[0-9a-f]{64}$), and
meta.stable_key_version is the exact integer 1 (not a string or boolean).
Consumers must treat an absent or invalid pair as no continuity claim.
Within one Tendwire installation, the authoritative version-1 pair—not
worker.id—is the continuity authority. Restoring the same Herdr public
workspace/pane identity therefore reproduces the exact meta.stable_key, even
when Tendwire worker IDs or runtime terminal, agent, and session identifiers
change.
The handle is a Tendwire-owned, opaque public value. Before projection,
Tendwire recursively removes source-supplied fields in the normalized
stable-key family, so Herdr metadata cannot select or spoof this identity.
Tendwire then derives the handle locally from a validated workspace/public-pane
identity and a private 32-byte installation key. Neither the handle nor any
other public field exposes that key or the raw identity used to derive it.
Herdres accepts only the exact public format and version. An absent pair, a
partial pair, a malformed value, or any version other than the integer 1
fails closed: Herdres quarantines the local binding instead of falling back to
worker ID. It neither possesses the installation key or raw identity nor calls
Herdr to reconstruct them. Herdres also requires the turns source wrapper's
schema_version to be the exact integer 1; a missing, malformed, or
unsupported value stops source state and delivery mutation. After that gate,
legacy private state can adopt an already exact key only through a
deterministic, unique match; conflicting claims remain quarantined.
The installation identity has three artifacts in data_dir: the private
32-byte installation.key, its nonsecret installation.key.sha256 digest
marker, and installation.key.initialized. The sentinel is the exact
nonsecret one-byte value 1; Tendwire creates it only after validating and
publishing the key and digest. Ordinary loads validate and reuse an initialized
identity and never rotate it. The default data_dir is
~/.local/share/tendwire and can be changed with TENDWIRE_DATA_DIR. All three
artifacts belong to Tendwire, not Herdr or Herdres, and must be backed up and
restored together from one stopped-service checkpoint.
Continuity is deliberately narrow. Moves that keep the worker in the same workspace and retain its authoritative public-pane identity, including tab moves, preserve the handle. A cross-workspace move changes the handle. Terminal and agent-session identifiers are not continuity inputs: they may be recreated during restore without changing the handle when Herdr restores the same logical pane. Destroying and recreating a logical pane does not inherit the old handle. The same raw worker identity under a different Tendwire installation key produces an unrelated handle, preventing cross-installation correlation.
With installation.key.initialized present, a missing key, digest, or both
fails closed and never bootstraps a replacement. Replaced, mismatched, malformed,
or unsafe identity state also fails closed. An absent sentinel is only initial
bootstrap or legacy-migration state, never a rotation request: Tendwire
validates and publishes the key and digest before publishing the sentinel.
Tendwire does not trust source continuity metadata or publish a locally
unauthenticated handle.
Intentional rotation is an explicit coordinated offline operation. Stop
Tendwire and every identity consumer, then invoke
tendwire.worker_identity.reset_installation_key(Path(data_dir), acknowledge_continuity_break=True) from a controlled operator Python
environment; do not delete identity artifacts by hand. The next eligible load
bootstraps a new three-artifact identity. Every wsk1_ handle changes, so
Herdres state, bindings, and topics require explicit migration and review;
stale bindings are quarantined and old topics are not silently rebound or
automatically reused.
Tendwire installs a console script named tendwire. The primary public entry
points are JSON-only:
tendwire snapshot --json
echo '{"schema_version":1,"action":"noop"}' | tendwire command --json
tendwire daemon --db-path /path/to/tendwire.dbYou can also run the CLI module directly:
python -m tendwire.cli snapshot --jsonsnapshot --json prints one neutral JSON snapshot to stdout and exits
successfully, even when no Herdr data is available. command --json reads
exactly one schema-v1 JSON command request from stdin. When Tendwire can prove
the result, it prints exactly one schema-v2 command envelope to stdout and exits
0 for ok=true or 1 for ok=false. If a mutating daemon request may have
started but no exact authoritative envelope or durable replay can be proven, it
prints no stdout envelope, writes a fixed diagnostic to stderr, and exits 2;
it never forges a status-only result for that process-level ambiguity. Stdout is
JSON-only for these public machine-readable commands when stdout is present.
Snapshot-adjacent public turn and pending-interaction views are also available:
tendwire turns --json
tendwire pending --jsonThese commands derive conservative public data from the current Tendwire snapshot and also exit successfully with empty collections when no Herdr data is available.
To inspect why Herdr data is absent without changing the snapshot contract, use the read-only diagnostic command:
tendwire doctor --jsondoctor --json prints JSON-only diagnostics for herdr workspace list,
herdr agent list, and herdr pane list, with --json compatibility variants
run only when a no-flag command is not healthy. It distinguishes missing Herdr
binary, launch error, command timeout, nonzero exit, malformed JSON, empty
healthy output, non-empty healthy output, skipped compatibility probes, and
checks skipped after a timeout or exhausted aggregate deadline. Diagnostics do
not expose raw backend argv. The Herdr binary path, data directory, and database
path expand ~; each Herdr probe uses TENDWIRE_HERDR_TIMEOUT_SECONDS or
--herdr-timeout when set, defaulting to 5.0 seconds.
When Herdr 0.7.0 is present, the adapter first tries the no-flag JSON envelopes
(herdr workspace list, herdr agent list) that wrap records under
result.workspaces and result.agents, then keeps --json list variants as a
compatibility fallback. If no agents are returned, it uses herdr pane list as
a worker fallback, keeping only panes that describe an agent. Each attempt is
independent and safe: a missing binary, timeout, or malformed response simply
produces empty spaces or workers rather than failing the snapshot. Timeout
handling is conservative: once a probe times out, Tendwire stops that
compatibility/fallback chain instead of trying every remaining variant.
Snapshot, command-observation, and doctor probe chains also use an aggregate
deadline derived from the per-probe timeout and planned probes; remaining
subprocess timeouts are capped by the time left in that budget.
The Herdr smoke harness is an opt-in Tendwire-only check for the boundary
between Tendwire's public contracts and Herdr's daemon/socket/command surfaces.
Normal tendwire commands and ordinary pytest do not contact or mutate live
Herdr. Live contact requires either --live or
TENDWIRE_HERDR_LIVE_SMOKE=1, and the harness does not add Herdres, source
polling, connector delivery, outbox processing, UI integration, or raw terminal
control.
Live mode proves Tendwire's own Herdr assumptions end to end: discovery, temporary worker attachment when Herdr exposes a safe operation for it, observation, high-level send addressing, target validation, event subscriptions, public binding updates for status/move/close events, degraded backend behavior, and public-safe evidence generation.
Safe live command:
python3 scripts/herdr_smoke.py --liveThe optional scripts/live_herdr_smoke.sh wrapper remains only a thin shell
entrypoint for the same opt-in live mode.
The command above gives child Herdr commands an isolated default
HERDR_SESSION=tendwire-smoke when the caller has not already chosen a session,
and the live subprocess calls address that selected scope explicitly with
herdr --session <selected> .... The environment variable alone is not treated
as enough isolation. That default is intentional: the smoke suite must never
silently target a daily Herdr session.
Two override paths are available and both are deliberate risk:
python3 scripts/herdr_smoke.py --live --session VALUEsets the childHERDR_SESSIONtoVALUE.- Running with an existing caller
HERDR_SESSIONpreserves that value as an explicit caller override.
The smoke output never prints the actual session value. If you prefer an
environment opt-in instead of the flag, this is equivalent to --live:
TENDWIRE_HERDR_LIVE_SMOKE=1 python3 scripts/herdr_smoke.pyWithout --live, without TENDWIRE_HERDR_LIVE_SMOKE=1, and without fixture
replay, the harness prints a valid JSON skip summary and makes no Herdr
subprocess or socket calls. Fixture replay is deterministic and offline:
python3 scripts/herdr_smoke.py --fixture-dir tests/fixtures/herdr/live_smoke/okFixture mode validates the same public summary shape as live mode but reads only fixture files, so normal offline pytest can import and exercise the harness without contacting Herdr. Any future live pytest coverage must be explicitly selected by maintainers rather than running by default.
Live mode first checks that the selected Herdr scope is available. If the default
smoke scope is stopped, unsupported, or unavailable, the harness fails closed
with ok: false and does not continue into workspace/agent observation or send
checks. A high-level herdr agent send probe only counts as successful when the
command exits zero and the harness records at least one accepted send.
Some Herdr installations do not expose safe live create, move, close, or
degraded-backend operations. When those operations are unsafe or unavailable,
the live harness reports the affected record as live_skipped_unreliable
instead of mutating a real workspace. Deterministic fixture or fake-backed
Tendwire validation still proves the public contract for the create/move/close
and degradation scenarios.
Live prerequisites are intentionally narrow:
- The
herdrbinary is onPATH, or--herdr-bin /path/to/herdrpoints at it. - Herdr supports the high-level workspace/agent surfaces and the socket/event surfaces used by Tendwire's daemon and command boundary.
- You are willing to create temporary smoke workers in the selected Herdr session only when Herdr exposes safe operations for doing so.
- The selected session is a disposable/sandbox session unless you intentionally override the isolated default.
The smoke evidence records are:
create_attachobservesend_addressingtarget_validationevent_subscriptionstatus_agent_status_changedpane_moved_binding_updateclose_exiteddegraded_backend_preserves_workerspublic_safety
Stdout is a public-safe aggregate JSON evidence artifact. Its top-level shape is
limited to neutral fields such as schema_version, ok, mode, status,
summary, default_isolated_session, explicit_session, checks, and
failures. Individual records use aggregate fields such as name, status,
required, ok, exit_code, json_status, item_count, variants, and
detail.
The public smoke summary is recursively sanitized and must not expose raw
pane_id, terminal_id, backend targets, socket paths, target values,
Telegram or Herdres IDs, tokens, private bindings, private fingerprints,
stdout, stderr, env, argv, secrets, or raw Herdr payloads.
Non-goals:
- No Herdres import, mutation, connector bridge, Telegram delivery integration, or connector outbox draining.
- No UI, local LLM, MCP, Hermes, iOS, or AR integration.
- No raw Herdr pane, socket-path, terminal, PTY, shell, or low-level command control exposure.
- No default contact with live Herdr from ordinary CLI usage or normal pytest.
Tendwire exposes a stdlib-only local daemon:
tendwire daemon --db-path /path/to/tendwire.db
tendwire daemon --db-path /path/to/tendwire.db --socket-path /run/tendwire/tendwire.sock --socket-group tendwire-clientsOn POSIX systems the daemon serves a local Unix domain socket JSON
request/response API. Startup loads the normal Tendwire config,
initializes/migrates the SQLite store, performs one authoritative initial
reconcile, persists the resulting snapshot and projections, starts the
background turn-ingestion scheduler, and requests its initial refresh before
publishing the socket. The public methods are ping, health.get,
snapshot.get, attention.list, turn.list, turn.content.get,
pending.list, command.submit, connector.poll, connector.ack,
connector.fail, connector.defer, and connector.reclaim. Read handlers
including health.get, snapshot.get, attention.list, turn.list, and
pending.list serve persisted/cached projections only; they never wait for a
private turn adapter.
The socket server has eight request workers and admits at most 32 in-flight
connections. Excess admissions receive public error server_busy with
details.retryable=true; capacity becomes available again as requests finish.
Request and response frames remain limited to 1 MiB (1,048,576 bytes). An
oversized request is rejected, and an oversized response is replaced by
response_too_large without making the server unavailable.
POSIX local state is private-only by default. Tendwire enforces mode 0700 on
its state directory and mode 0600 on its database family and regular private
files. The default Unix socket is mode 0600. If an entry owned by the service
account is broader, Tendwire removes excess bits using the intersection of its
current and required modes; it never widens a stricter mode. Symlinks, wrong
owners, and wrong entry types are refused, and private files are created
securely before publication.
Socket group access is an explicit daemon-only option:
--socket-group GROUP (or TENDWIRE_SOCKET_GROUP=GROUP). At runtime Tendwire
resolves the existing group and verifies that the service account is already a
member before changing group ownership or permissions. Only the socket changes
to mode 0660; database and other state files stay private. Every validated
group member can invoke the full daemon API, including mutating commands and
connector operations. Put a shared socket in a dedicated parent owned by the
service account, assigned to that group, group-traversable, and inaccessible to
other users (for example, mode 0710). Never use a shared /tmp socket.
Existing CLI commands remain one-shot by default. When --socket-path or
TENDWIRE_SOCKET_PATH selects a daemon, any received daemon result or domain
error is authoritative. tendwire turns performs its single direct-source
fallback only when the daemon is definitely unavailable before request bytes
were sent: for an initial page it runs one refresh with the configured turn
worker bound, per-adapter TENDWIRE_HERDR_TIMEOUT_SECONDS, and total bound of
that timeout plus one second, then reads one store page. --cursor and
--since continuations read the store without refreshing. A timeout after
transmission may mean the daemon started the request, so the CLI returns
daemon_timeout and does not run a second refresh.
Other invalid daemon exchanges return daemon_protocol_error.
Mutating command --json requests (send_instruction or answer_pending with
dry_run: false) do not fall back in explicit daemon/socket mode. A
missing/refused daemon proven unavailable before request start returns an exact
schema-v2 backend_unavailable/no_receipt envelope and exits 1, before any
one-shot Herdr send is attempted. After request start, a daemon timeout or
malformed response is not itself command authority: the CLI first attempts an
exact durable receipt replay. It returns that schema-v2 envelope when proven;
otherwise it prints no stdout envelope and exits 2 because the process result
is ambiguous. The daemon API never returns raw Herdr pane IDs, terminal IDs,
backend targets, socket paths, target values, private fingerprints,
argv/env/stdout/stderr, tokens, or secrets.
The Herdr socket/event backend is opt-in:
TENDWIRE_HERDR_BACKEND=socket tendwire daemon --db-path /path/to/tendwire.dbWith TENDWIRE_HERDR_BACKEND=socket, the daemon connects to Herdr's socket,
performs the initial reconcile, writes the authoritative public snapshot, and
then maintains projections from Herdr events. The official Herdr subscription
method is exactly events.subscribe; the params object is exactly a
subscriptions array of objects with a string type:
{
"subscriptions": [
{"type": "workspace.created"},
{"type": "workspace.updated"},
{"type": "workspace.renamed"},
{"type": "workspace.closed"},
{"type": "workspace.focused"},
{"type": "pane.created"},
{"type": "pane.closed"},
{"type": "pane.focused"},
{"type": "pane.moved"},
{"type": "pane.exited"},
{"type": "pane.agent_detected"},
{"type": "pane.output_matched"},
{"type": "pane.agent_status_changed"},
{"type": "worktree.created"},
{"type": "worktree.opened"},
{"type": "worktree.removed"}
]
}Subscription builders reject unknown names, non-string names, and empty names.
Tendwire may tolerate legacy inbound aliases only after receiving an event, so
older Herdr payloads can still be harmlessly normalized. Tendwire must never
subscribe to legacy names such as pane.observed, workspace.observed,
agent.status_changed, or worktree.updated.
Unknown and malformed events are safe no-ops. Initial reconcile remains authoritative; event updates are incremental hints applied on top of that snapshot. Idle event-read timeouts are normal and do not mark Herdr failed. Disconnects and protocol failures degrade backend health but do not prune private worker bindings or pretend Herdr is authoritatively empty. Reconnects resubscribe to the same official event set.
The daemon owns ongoing turn ingestion. It scans eligible durable Codex, OMP,
and pane bindings immediately at startup and every two seconds by default.
Persisted
pane.created, pane.focused, pane.moved, pane.closed, pane.exited,
pane.agent_detected, pane.agent_status_changed, and
pane.output_matched batches, plus a completed full reconcile, request an
earlier scan. Bursts coalesce into one pending scan; duplicate queued work
coalesces, and a signal for a running target requests at most one follow-up
refresh.
Refreshes are serialized by private binding fingerprint, so one target is
never read concurrently with itself while distinct targets can use the fixed
four-worker pool. The scheduler queue is bounded at 64; a full queue records
aggregate queue_full evidence and rotates later scans rather than growing
without bound. Every adapter read uses TENDWIRE_HERDR_TIMEOUT_SECONDS;
filesystem adapters receive bounded timeout/cancellation cleanup, and each
result is revalidated against the current binding before commit.
Adapter failures and timeouts make current ingestion health degraded until a
later successful refresh clears the consecutive-failure state; the aggregate
failed and timed_out counters remain cumulative. Binding churn reported as
stale_binding increments failed for evidence but does not poison current
health. Absence of a recent successful refresh becomes stale. Cached daemon
reads remain available while an adapter is blocked or ingestion is degraded.
OMP caching and IPC use a coordinate-only checkpoint: EOF/parse offset,
observed device/inode/size/mtime/ctime, replay offset, open-turn flag, and
validated project root. The checkpoint never contains or transports prompt
IDs, user text, assistant final/stream text, or other turn bodies. An unchanged
stable stat returns unchanged without spawning a child, reading the session,
or transporting a frame. When the file changes, an open turn is reconstructed
from its replay coordinate; a completed final compacts to an idle EOF
checkpoint, and only a later eligible user message opens another turn. Its
private checkpoint LRU is bounded by both 64 entries and 64 KiB of serialized
cache-key-plus-checkpoint weight.
Codex bindings accept only exact canonical lowercase, non-nil UUIDs. A rollout
must have the exact path
sessions/YYYY/MM/DD/rollout-YYYY-MM-DDTHH-MM-SS-<uuid>.jsonl, with a valid
date/time matching its hierarchy; the identifier is never interpolated into a
glob. Resolution succeeds only for one canonical regular file beneath the
canonical sessions root after symlink and device/inode checks. Missing, unsafe,
over-limit, or multiple exact matches are unavailable.
Every cache hit validates the current sessions-root device/inode; a found result
also validates the rollout inode. A root identity change immediately clears
that root's cached path results and complete index.
The complete Codex index is bounded to 100,000 visited filesystem entries, 32,768 session identities, and 8 MiB retained; its path-result LRU is bounded to 256 entries and 256 KiB. Negative results live for 2 seconds. A lookup rebuilds the complete index once its snapshot is 60 seconds old. This deliberately avoids a 20,000-file walk on each poll, so a duplicate created after a successful lookup may remain undiscovered for up to one 60-second snapshot interval; the refreshed index makes that identity ambiguous and therefore unavailable.
The private Codex parser LRU is bounded to 64 entries and 16 MiB. State commits only through valid newline-terminated records, retaining a partial final record until its newline arrives. A record is limited to 8 MiB, cold recovery starts with 64 KiB and never scans more than 16 MiB or 65,536 records, and one poll reads at most 64 MiB. Warm append-only polls read only newly appended bytes and an unchanged poll reads zero source bytes. Truncation, rotation, replacement, or lost state triggers the same bounded recovery rather than a whole-file fallback; malformed/oversized input or failure to find a boundary fails the read without advancing the prior checkpoint. If one delayed append read contains both a newly completed turn and the start of a later turn, ingestion publishes and checkpoints the completed turn first. The next refresh resumes at the following record. A delayed poll therefore cannot silently replace an unpersisted final with the newer open turn. No Codex session ID, rollout path, file identity/coordinate, parser/cache state, or raw record enters public JSON.
OMP reader requests remain capped at 16 KiB. A canonical OMP response has no total-size ceiling: its exact ordered payload is streamed in frames of at most 1 MiB under the same deadline, with manifest, nonce, end-marker, and EOF validation, so canonical finals are not truncated by IPC. A Codex parser-state request is capped at 12 MiB, and each Codex response remains one frame capped at 64 MiB. The parent performs nonblocking framed socket send and receive, parsing, and child join under one adapter deadline without a helper IPC thread. Timeout cleanup spends at most 250 ms on terminate/kill/join attempts and reaps the child under normal POSIX scheduling; it does not wait beyond that grace for an OS-uninterruptible child. Binding scans prune disappeared cache entries and advance a generation when the same resolved target's private-fingerprint set changes.
A valid file-reader response yields only a candidate checkpoint. A content-bearing candidate is published only after the parent revalidates the exact binding and durably applies the turn; a no-content candidate still requires exact binding validation. Cancellation, failed apply, stale binding, disappeared path, or a changed same-path fingerprint generation cannot advance the cache.
Shutdown first stops socket admission, then flushes/detaches event signaling, cancels and boundedly drains turn work, and finally stops the event backend. Repeated stop requests are safe. A stopped daemon or scheduler is not restarted in place: service restart constructs a new instance, whose immediate scan reuses durable bindings and preserves already-final turns and connector state.
PR16 adds conservative daemon/runtime tuning knobs for 24/7 and Raspberry Pi
use. They are available as Config constructor arguments and environment
variables:
| Config field | Environment variable | Default | Validation |
|---|---|---|---|
event_debounce_seconds |
TENDWIRE_EVENT_DEBOUNCE_SECONDS |
0.05 |
non-negative float |
reconcile_interval_seconds |
TENDWIRE_RECONCILE_INTERVAL_SECONDS |
300.0 |
non-negative float; 0 disables periodic reconcile |
event_retention_days |
TENDWIRE_EVENT_RETENTION_DAYS |
7 |
integer >= 1 |
output_excerpt_chars |
TENDWIRE_OUTPUT_EXCERPT_CHARS |
200 |
integer >= 1 |
max_workers |
TENDWIRE_MAX_WORKERS |
512 |
integer >= 1 |
max_outbox_attempts |
TENDWIRE_MAX_OUTBOX_ATTEMPTS |
10 |
integer >= 1 |
connector_claim_ttl_seconds |
TENDWIRE_CONNECTOR_CLAIM_TTL_SECONDS |
60 |
integer >= 1 |
command_retry_horizon_seconds |
TENDWIRE_COMMAND_RETRY_HORIZON_SECONDS |
604800 |
positive integer no greater than 604800 |
command_receipt_retention_seconds |
TENDWIRE_COMMAND_RECEIPT_RETENTION_SECONDS |
2592000 |
integer at least 691200 and strictly greater than the retry horizon |
command_receipt_retention_count |
TENDWIRE_COMMAND_RECEIPT_RETENTION_COUNT |
4096 |
positive integer; newest bounded inactive receipts per host |
acknowledged_final_retention_days |
TENDWIRE_ACKNOWLEDGED_FINAL_RETENTION_DAYS |
30 |
positive integer |
acknowledged_final_retention_count |
TENDWIRE_ACKNOWLEDGED_FINAL_RETENTION_COUNT |
4096 |
positive integer; newest proven acknowledged finals per host |
snapshot_retention_days |
TENDWIRE_SNAPSHOT_RETENTION_DAYS |
14 |
positive integer |
snapshot_retention_count |
TENDWIRE_SNAPSHOT_RETENTION_COUNT |
4096 |
positive integer; includes each host's latest row |
snapshot_maintenance_batch_size |
TENDWIRE_SNAPSHOT_MAINTENANCE_BATCH_SIZE |
100 |
integer from 1 through 1000 |
store_maintenance_cadence_seconds |
TENDWIRE_STORE_MAINTENANCE_CADENCE_SECONDS |
3600 |
positive integer |
turn_refresh_interval_seconds |
TENDWIRE_TURN_REFRESH_INTERVAL_SECONDS |
2.0 |
finite positive float |
turn_refresh_workers |
TENDWIRE_TURN_REFRESH_WORKERS |
4 |
integer from 1 through 32 and no greater than max_workers |
The socket/event backend uses event_debounce_seconds for event batching and
reconcile_interval_seconds for bounded periodic full reconciles. Set
TENDWIRE_RECONCILE_INTERVAL_SECONDS=0 on very small hosts if periodic
reconcile is not wanted. max_workers is an operational cap only; it does not
create any public worker ID surface. If a healthy reconcile observes more
workers than the cap, Tendwire reports a degraded
worker_cap_exceeded backend health state and preserves the previous public
snapshot/projections instead of publishing a truncated authoritative snapshot.
Incremental events that would add workers over the cap are ignored with the
same public-safe degraded evidence.
Snapshot history defaults are sized for a five-minute observation rhythm:
An active agent.list row must resolve to one authoritative pane.list owner
before a healthy source snapshot can replace authenticated worker continuity.
If both probes succeed but that match is missing, Tendwire reports
continuity_unavailable and retains the previous authenticated snapshot and
bindings. This treats cross-probe lifecycle skew as non-authoritative without
turning it into a permanent connector quarantine.
health.get remains schema-version 1 and includes public-safe operational
fields: daemon status and started_at; store status/counts and outbox counts;
snapshot and last event/snapshot/reconcile timestamps when available; backend
runtime readiness when the socket backend is active; backend health; and numeric
limits for debounce, reconcile, event retention, output excerpt, worker cap,
outbox attempt/claim TTL, snapshot retention age/count/batch/cadence, exact
acknowledged-final age/count, and exact command retry-horizon and receipt
retention age/count values. store.command_requests is aggregate-only:
total, the five state counters, stale_active, eligible,
retry_horizon_seconds, retention_seconds, retention_count, and
storage_pressure. It never exposes a request ID, action, canonical request or
fingerprint, resolved worker, instruction, pending choice, or private binding.
store.final_retention is also aggregate-only:
acknowledged, unresolved, queued, leased, deferred, retry,
dead_letter, awaiting_ack, eligible,
acknowledged_final_retention_days,
acknowledged_final_retention_count, and storage_pressure. Storage pressure
degrades the public store and overall health status without exposing final
content, turn/revision/final identities, private connector state, or source
paths. The aggregate turn_ingestion object contains status, queue,
active, refreshed, failed, timed_out, coalesced, queue_full,
last_success, last_duration_ms, stale_age, and
bounds.{refresh_interval_seconds,max_workers,queue_capacity,adapter_timeout_seconds}.
It never identifies which worker or binding failed. Health output does not
expose daemon socket paths, database paths, Herdr binary paths, backend targets,
raw Herdr payloads, private bindings or fingerprints, connector private state,
tokens, argv/env/stdout/stderr, or low-level terminal identifiers.
Optional local persistence uses the stdlib SQLite store and does not change stdout:
tendwire snapshot --json --store
tendwire snapshot --json --store --db-path /path/to/tendwire.db
tendwire attention --json --db-path /path/to/tendwire.dbWithout --store, the CLI does not write the database. --db-path overrides
the configured database path for persistence and store-backed public views.
The snapshot --json output uses this device-neutral contract:
{
"schema_version": 2,
"host_id": "myhostname",
"updated_at": "2026-06-27T16:18:34+00:00",
"content_fingerprint": "14afa7e139f55770113291c1",
"spaces": [],
"workers": [],
"attention": [],
"backend_health": [
{
"name": "herdr",
"status": "healthy",
"outcome": "empty_healthy",
"observed_at": "2026-06-27T16:18:34+00:00",
"message": "Herdr observation is healthy but empty",
"counts": {
"spaces": 0,
"workers": 0
}
}
]
}Top-level keys:
schema_version— integer contract version; milestone 2 snapshots use2.host_id— string identifying the host.updated_at— ISO-8601 UTC timestamp string for this snapshot.content_fingerprint— deterministic hash prefix for the snapshot content, excludingupdated_atandcontent_fingerprintthemselves.spaces— neutral space observations with stableid,name, canonicalstatus, optional timestamps/status text, per-itemfingerprint, andmeta.workers— neutral worker observations with stableid,name, canonicalstatus, optionallast_seen_at/summary, per-itemfingerprint, andmeta.attention— deterministic human-actionable attention signals derived from the snapshot.backend_health— public-safe backend observation health. Tendwire includes a Herdr entry with fixed fieldsname,status,outcome,observed_at,message, and optional aggregatecountssuch as spaces and workers.
Canonical statuses are unknown, active, idle, waiting, blocked,
warning, done, failed, and closed. done, complete, completed, and
success canonicalize to done, which remains eligible for follow-up
instructions. Raw adapter status strings may appear only as sanitized
meta.raw_status; connector-specific fields and private backend fields are
stripped before public JSON is emitted.
Snapshot hashing uses the Python standard library only:
json.dumps(sort_keys=True, separators=(",", ":"), ensure_ascii=False), then
SHA-256 with a fixed prefix. Spaces, workers, and attention entries are sorted by
stable ID/fingerprint before the hash is computed. Volatile timestamps,
including backend health observed_at, are excluded from the content
fingerprint.
Backend health status is one of healthy, degraded, unavailable, or
unknown. A healthy non-empty Herdr observation reports
outcome: "healthy_non_empty"; a healthy empty Herdr observation reports
outcome: "empty_healthy". Missing Herdr reports missing_binary, launch
errors report launch_error, timeouts report timeout or
deadline_exhausted, nonzero exits report nonzero, malformed JSON reports
malformed_json, worker cap safety stops report worker_cap_exceeded, and
unclassified results report unknown. Health messages are short sanitized
public strings and do not expose private bindings, raw stdout/stderr, argv,
environment values, or secrets.
Attention signals expose deterministic id and fingerprint values plus
kind, severity, status, reason, source, updated_at,
suggested_actions, and meta. The attention fingerprint input includes
host_id, source, kind, severity, reason, and normalized status, so
the same logical condition keeps the same attention ID across snapshots.
Suggested actions are neutral data with action_id, label,
tendwire_action, and params; they do not include delivery state or raw shell
command fields.
Attention is for human-actionable items, not a general worker status feed. Empty snapshots and workers that are idle, active/running, done/completed, or closed do not create attention items by default. Failed/error workers create critical attention; blocked/warning workers create warning attention. Waiting/pending workers create attention only when structured metadata or very explicit summary text says human input, review, or approval is required. Generic waiting, responding, or pending wording alone is not enough.
Worker-derived attention updated_at uses a source worker timestamp when one is
available, such as last_seen_at or a backend updated_at normalized into
last_seen_at. It does not fall back to the snapshot updated_at; when no
worker source time exists, the attention item serializes updated_at as null.
The turns --json and pending --json outputs are public snapshot-adjacent
views, not routing or private binding surfaces. They must not expose Telegram
IDs, chat IDs, topic IDs, message IDs, raw pane IDs, terminal IDs, backend
targets, socket paths, raw target values, private bindings, session IDs, private
fingerprints, raw command payloads, argv/env/stdout/stderr, raw terminal
controls, tokens, or secrets.
turn.list accepts only schema_version, limit, cursor, and since.
Schema version defaults to 1 and may be 1 or 2. The limit defaults to 100 and
must be an integer from 1 through 250. cursor and since are opaque,
nonempty tokens and are mutually exclusive.
A successful page contains exactly the wrapper fields schema_version,
host_id, updated_at, turns, backend_health, next_cursor, has_more,
as_of, since, and content_fingerprint. as_of and since carry the same
watermark token. Pages are ordered by public worker ID, then immutable
per-host insertion sequence newest-first, then turn ID. A continuation is
bound to the original host, schema, limit, optional since position, watermark,
store generation, and last row. Inserts after that watermark cannot enter the
traversal, so a first-page result set remains stable while it is paged; use the
first page's since token in a later new request to discover newly inserted
turns.
Continuation cursors have a fixed 900-second TTL measured from the first page;
following a cursor does not extend it. Malformed, tampered, or cross-bound
cursors return invalid_cursor; elapsed cursors, removed anchors, retention
that advances the floor beyond the traversal's original floor, or a changed
store generation return cursor_expired. Since
tokens have no wall-clock TTL, but a changed store generation or impossible
watermark returns since_expired. These are domain results inside a valid
daemon success envelope. Unsupported schema and malformed parameters are API
errors (unsupported_schema or invalid_params); an unavailable store returns
store_unavailable. Every list page remains below the unchanged 1 MiB response
frame bound, even when fewer than the requested number of turns fit.
turns --json --schema-version 2 and daemon turn.list with
{"schema_version":2} return the schema-v2 turn-list wrapper. Each turn keeps
the neutral identity/status metadata above and adds a schema-v1 content
descriptor:
{
"schema_version": 1,
"content_revision": "twrev1.<opaque>",
"known_incomplete": false,
"fields": {
"user_text": {
"availability": "complete",
"inline": true,
"char_length": 12,
"byte_length": 12,
"page_count": 1,
"first_cursor": null
},
"assistant_final_text": {
"availability": "complete",
"inline": false,
"char_length": 20000,
"byte_length": 20000,
"page_count": 1,
"first_cursor": "twcur1.<opaque>"
}
}
}The two descriptor fields are exactly user_text and
assistant_final_text. Their availability is exactly absent, complete,
or known_incomplete; lengths are exact character and UTF-8 byte counts.
Complete fields of at most 12,000 characters remain inline. A longer complete
field is not copied into the list: the turn contains only a user_preview or
assistant_final_preview of at most 1,000 characters and the descriptor makes
it pageable. known_incomplete means Tendwire inherited content that was
already irrecoverably truncated; it is preview-only, with inline=false,
page_count=0, and first_cursor=null. Consumers must isolate that field or
turn and continue processing eligible turns rather than presenting its preview
as a complete final.
Schema v1 remains a compatibility view only while every present canonical field
is complete and inline. If any present field is long or known incomplete, a v1
request fails clearly with schema_version=1, ok=false,
status=upgrade_required, and required_turn_schema_version=2; the CLI exits
nonzero instead of returning a lossy v1 turn. Consumers that support v2 can
remain lazy: only a field with availability=complete, inline=false, and a
non-null first_cursor is eligible for page retrieval.
The daemon method turn.content.get and CLI command
tendwire turn content get expose one content page at a time. The exact daemon
parameters are schema_version: 1, turn_id, content_revision, field
(user_text or assistant_final_text), and optional cursor. Omit cursor
or pass null for page zero; thereafter pass only the previous response's opaque
next_cursor. A cursor is integrity-bound to the revision, field, segment
index, and exact character/byte start, so malformed coordinates, a modified
cursor, or a cross-field or cross-revision cursor fails with invalid_cursor.
Each successful page contains only schema_version=1, turn_id,
content_revision, field, availability=complete, opaque segment_id,
zero-based index, count, exact text, segment_char_length,
segment_byte_length, total_char_length, total_byte_length, and nullable
next_cursor. Page text
is at most 49,152 UTF-8 bytes and never splits a code point. Concatenating pages
in first_cursor/next_cursor order reproduces the exact sanitized canonical
field. Paging is deliberately linear: Tendwire reads each page directly from
the one canonical SQLite value and does not store independently editable page
copies.
pending --json prints a schema-v1 wrapper with host_id, updated_at,
content_fingerprint, public-safe backend_health, and
pending_interactions. Each pending interaction has deterministic id,
host_id, worker_id, optional worker_fingerprint, optional space_id,
bounded kind, question, finite public-safe choices, neutral status,
optional timestamps, optional fingerprint, and sanitized meta. Each choice
has exactly a deterministic opaque choice_id and a user-facing label.
Backend option, tool, or decision identifiers and any values sent to the
backend stay private.
Daemon pending.list and the CLI's store fallback use the same durable
projection. One read transaction captures the latest stored public snapshot
and the corresponding backend_pending rows, so pending derivation never
mixes snapshots from different moments. Turn ingestion updates each worker's
backend-provided pending state durably with its refresh. A missing, malformed,
wrong-host, or otherwise invalid stored snapshot returns store_unavailable
rather than projecting partial state.
Successful reads with an open prompt upsert that prompt; successful reads with
no prompt retain a fresh non-answerable tombstone so snapshot fallback clears
for that worker. Authoritative binding removal deletes the worker's row.
Transient reads retain the last open prompt as stale for one fixed,
non-sliding TENDWIRE_PENDING_STALE_GRACE_SECONDS window (30 seconds by
default); repeated failures do not extend the deadline. At expiry the prompt
leaves the public overlay, but freshness health remains degraded until a
successful read or authoritative removal. A successfully read malformed prompt
leaves the backend overlay immediately, preserving any valid snapshot-derived
fallback while reporting degraded freshness.
command.submit action answer_pending accepts exactly the public
pending_id, pending_fingerprint, and choice_id in params; a non-dry-run
request also requires request_id. The store atomically validates the observed
revision, binding fingerprint, and exact private pane target before returning
only the private picker ordinal to the send path. A changed, stale,
disappeared, or already claimed decision fails before pane mutation.
Once sending starts, an indeterminate failure is reported as request-state
uncertainty and is never automatically retried. After confirmed submission,
the exact answered revision and claim atomically become a fresh non-answerable
tombstone before the accepted result is returned, so an older snapshot fallback
cannot reappear. A concurrently observed newer revision is never overwritten.
Any daemon response is authoritative. Only definite unavailability before
request transmission permits the CLI fallback, which reads that durable store
view without observing Herdr. A timeout after transmission returns the fixed
daemon_timeout error; other ambiguous/invalid exchanges return
daemon_protocol_error. Neither case starts a second request or source read.
Canonical turn IDs are owner-aware sanitized public identities. Their input
includes host, worker, space, kind, source, command/source-turn lineage, and
either the exact version-1 stable-key pair or an explicit unavailable marker.
The stable pair therefore participates in identity when authenticated, while
worker_fingerprint, status, and content affect only the turn fingerprint and
never final routing. Volatile observation timestamps affect neither value.
Backend pending and choice handles separately bind to a one-way digest of the
exact private source binding and pane target, so byte-identical prompts observed
on a replacement source mint different public handles without exposing either
private value. Pending interactions derive only from explicit human-actionable
public attention signals or suggested actions; generic waiting or pending
worker status alone does not create one.
The current store schema is version 14 (PRAGMA user_version=14). Migration is
idempotent and transactional. Schema v6 introduced immutable canonical turn
content revisions and backfilled legacy rows, marking a legacy [truncated]
value known_incomplete rather than claiming recovery. Schema v7 added
presentation-plan generations, failed-plan lineage, and an immutable recovery
audit. Schema v8 added persisted maintenance state and bounded
snapshot-retention indexes. Schema v9 added an immutable positive
list_sequence for each host/turn, a per-host uniqueness constraint and paging
index, and a store-generation row used to bind list cursors and since tokens.
Schema v10 adds explicit pending observation/freshness state, private
revision-bound choice routing, and durable two-phase answer claims. The
v9-to-v10 migration preserves legacy public pending rows but leaves them
unrouted until a fresh binding-scoped observation supplies authoritative
revision and route data.
Schema v11 adds typed durable final-delivery roots, acknowledged-history
retention, and the private per-host store_maintenance_cursors table used for
fair automatic service. A routable root copies the exact public stable_key
and integer stable_key_version=1 from persisted turn metadata into the root of
its schema_version=2 payload. Every materialized range job preserves that
exact source route under turn. Neither worker_id nor worker_fingerprint
is final-delivery routing authority.
Schema v12 rebuilds command receipts around one host-wide
(host_id, request_id) authority, canonical resolved public-worker identity,
and the explicit reserved, send_started, accepted, rejected, and
uncertain lifecycle. The transactional v11-to-v12 migration converts
ambiguous legacy action-scoped collisions into terminal uncertainty rather
than selecting one mutation as authoritative.
Schema v13 adds a private selector proof to new command receipts so exact alias retries can replay durable outcomes after worker churn without trusting a mutable snapshot. Migrated v12 receipts receive no invented proof and therefore fail closed when their original selector cannot be established. Schema v14 repairs legacy nonpositive turn-list coordinates transactionally and preserves the durable per-host sequence high-water mark, preventing cursor recurrence after deletion or migration.
A missing, partial, malformed, boolean-valued, or unsupported owner pair creates
a nonroutable schema_version=1 final_migration_hold/dead_letter.
Internally classified automation and known-incomplete finals are also
nonpollable holds, even when a schema-v2 pair exists. These safety holds are
permanently nonretryable for that canonical turn identity; a later
owner-authenticated observation has a distinct owner-aware turn identity rather
than converting or reposting the held one.
The conservative v10-to-v11 migration marks a current final
final_ready/delivered only when a completed lineage has exact canonical
range coverage and exact host-bound all-part ACK proof. A linkable unresolved
plan must additionally carry the authoritative schema-v2
turn/revision/final-identity/stable-key route on every job before it can remain
nonpollable final_ready/awaiting_ack; every unknown or mismatched case
becomes a migration hold. A valid-owner hold whose only defect was unproven
legacy delivery may be eligible for an exact current-identity retry, but safety
holds are not. The migration never infers delivery from partial evidence or
mass-reposts history.
Migration creates the typed columns, root indexes, and maintenance-cursor table
inside the v10-to-v11 transaction. A partial legacy final-table set, invalid
recovery edge, descriptor failure, or any later error rolls back all table,
column, root, and PRAGMA user_version changes. Migrations do not reconstruct
absent bytes or rewrite canonical turn history.
The optional SQLite store keeps canonical snapshot JSON blobs in the snapshots
table and maintains Tendwire-local operational tables for attention lifecycle,
command receipts, connector outbox/deliveries, backend health, and private
worker bindings. Schema initialization and migration are idempotent and preserve
the existing latest_snapshot and list_hosts behavior. The store is an
implementation detail behind public JSON, not a broad public schema expansion.
Snapshot and private-binding projection is monotonic and atomic per host. The
strict winner is the greatest (updated_at, content_fingerprint) pair; an exact
replay is accepted without advancing state. An older or losing equal-time
snapshot, even if different or empty, cannot replace history, regress/prune
worker or turn projections, mutate/expire bindings, or duplicate/release a
final root. A winning snapshot refreshes its public projection and upserts or
expires only its same-scope private bindings in the same transaction.
Private Herdr worker bindings are stored separately in the local
worker_bindings table. These rows associate a stable public Tendwire
worker_id with private backend target material such as Herdr agent, terminal,
or pane identifiers. Bindings are local SQLite records only; they are not public
snapshot fields, command request fields, command response fields, connector
payload fields, or stored snapshot payload fields. Expired bindings are retained
for local history and debugging but ignored by command routing.
Every store connection applies a 30-second SQLite busy_timeout; file-backed
databases use WAL journaling, foreign keys are enabled, and synchronous mode is
NORMAL. The current safety stance is one local Tendwire service process: its
bounded turn-ingestion workers can persist distinct targets concurrently
through the store's transaction APIs, while external Tendwire writers are not a
supported multi-service event bus. One-shot CLI persistence writes only when
explicitly requested. The store does not persist raw Herdr event payloads,
socket paths, terminal streams, argv/env/stdout/stderr, or connector-specific
state.
A file-backed store is one validated family: the main database plus the
optional -wal, -shm, and -journal members. An absent optional sidecar is
normal, and a transient optional that disappears while the family is being
captured is also accepted as absent. Once the main database has been selected,
however, it is mandatory: disappearance or identity replacement fails closed.
Every present member must remain an owned regular file at the selected identity.
A symlink, wrong type, wrong owner, insecure mode on a validation-only path, or
replacement race is rejected without following, changing, or deleting the
hostile entry.
Creation, repair, and inspection are separate authorities. Explicit store
startup/creation uses the SQLite-family prepare operation, which may create only
the missing main database and may narrow permissions on validated present
members. Explicit repair narrows validated existing members only. Neither
operation creates an absent optional sidecar or widens a stricter mode.
Private-mode preparation and repair cannot disturb active Tendwire SQLite
transactions, and a no-op private prepare preserves them. Any main creation or
permission narrowing first requires bounded, nonblocking exclusive authority over
the store parent directory. Current-schema filesystem reads stay cheap and
nonmutating after their schema-version read: they take no exclusive parent
authority and perform no persistent WAL negotiation or schema DDL. An
uninitialized or migrating filesystem store takes that exclusive authority before
persistent WAL negotiation or schema DDL, performs that work under private
creation mode, then revalidates and narrows the resulting main database, -wal,
and -shm members before restoring retained shared authority. A live Tendwire
connection retains shared parent-directory authority, so a conflicting repair
fails with a typed, path-free error before mutation; that shared authority also
rejects the schema branch before WAL, DDL, or sidecar mutation. A connection
obtains shared authority before preparation, promotes the same authority only for
a necessary mutation, and restores shared authority for the remainder of its lifetime.
Ordinary reads, store status, health collection, and doctor are
validation-only and non-creating: they do not initialize a missing database or
repair a family. Their finite capture, preflight, and final validation passes do
not recursively retry sidecar churn; stable transient absence succeeds and an
unstable identity fails closed.
Private filesystem failures are typed, path-free LocalStateError values.
Public diagnostics and store surfaces map them to fixed aggregate records such
as the database_permissions unsafe outcome or store_unavailable; they do
not expose a path, suffix, owner, inode, raw exception, or private content.
Automatic retention stays batch-bounded and has no compaction authority.
Compaction remains the separately acknowledged offline operation described
below, with the selected source identity revalidated before publication.
The synthetic Goal 08B race/recovery evidence is recorded in
docs/evidence/goal08b-sqlite-sidecar-race-recovery.md; its driver and focused
audit are scripts/sqlite_sidecar_race_benchmark.py and
tests/test_sqlite_sidecar_race_benchmark.py. The evidence uses only generated
private state and an isolated candidate from this source checkout.
Bounded operational store hooks are JSON-only:
tendwire store status --db-path /path/to/tendwire.db
tendwire store events-tail --limit 20 --db-path /path/to/tendwire.db
tendwire store cleanup --dry-run --db-path /path/to/tendwire.db
tendwire store cleanup --retention-days 14 --max-outbox-attempts 5 \
--acknowledged-final-retention-days 30 \
--acknowledged-final-retention-count 4096 \
--snapshot-retention-days 14 --snapshot-retention-count 4096 \
--snapshot-batch-size 100 --db-path /path/to/tendwire.db
tendwire store compact --dry-run --db-path /path/to/tendwire.db
tendwire store compact --execute --acknowledge-offline \
--backup-path /path/to/tendwire.pre-compact.db \
--db-path /path/to/tendwire.dbstore status returns host-scoped table counts, last event/snapshot timestamps,
and aggregate outbox pending, leased, terminal, and by_status counts.
Its maintenance.snapshot_count, snapshot backlog, and final_retention
pressure are also computed for the requested host; only the persisted automatic
maintenance cadence marker is database-wide. store events-tail returns only
bounded event metadata such as row ID, event type, aggregate type, timestamp,
and content fingerprint; it never returns payload_json or raw event payloads.
The daemon accepts the retention aggregate only when its host_id, configured
policy, nonnegative counts, component totals, eligibility, and pressure
relationships validate. A wrong-host or malformed aggregate degrades health,
as does valid storage_pressure. The public surface exposes counts and
pressure only, never final content or turn, revision, final, provider, or
private-state identity.
store cleanup performs one bounded online batch and reports aggregate
retention, database-wide snapshots, outbox, final_retention,
command_requests, and turn_content objects. Event, outbox, final,
command-receipt, and turn-content policy defaults come from the normal
configuration. Retention age/count/batch/cadence values must be
positive integers. Day policies are capped at 365000, counts at
9223372036854775807, maintenance batches at 1000, and cadence at 31536000000
seconds; an affected cleanup class rejects an invalid override rather than
applying that class. The flags shown above override event age, retry count,
acknowledged-final age/count, snapshot age/count, and snapshot batch size for
that invocation. Snapshot retention keeps the intersection of the configured
age window and newest-per-host count window: a changed-history row is eligible
when it falls outside either window. The newest row for every host
is always exempt, even when it is old or the count is one. Final retention can
reduce only a whole current turn graph whose completed/superseded plan lineage
contains exactly every declared part, whose matching host/name/turn/revision
part rows are delivered, and whose delivered attempts have durable ACK
timestamps. Every root and plan for that turn must also be resolved; an
unlinked part or any queued, leased, deferred, retry, awaiting_ack, or
dead_letter work blocks cleanup and contributes to pressure. Only then is an
acknowledged graph eligible when older than the 30-day age window or ranked
beyond the newest 4096 per-host count window by default. Cleanup also does not
remove current projections, private worker bindings, live
outbox work or leases, current referenced turn content, active reserved
command-owner leases, or any send_started row directly. An expired reserved
row remains a reclaimable pre-send reservation for the same canonical mutation;
the retry horizon does not convert it to uncertainty. A send_started row older
than the retry horizon (604800 seconds by default) becomes uncertain instead
of being deleted or retried. The bounded deletion pool contains only expired
reserved rows and terminal accepted, rejected, or uncertain rows. A row
in that pool is eligible only when it is both older than the configured age
floor (2592000 seconds by default) and ranked beyond the newest-per-host count
floor (4096 by default).
With --dry-run, every maintenance transaction is rolled back; the aggregate
candidate/action counts are predictions and no store row or maintenance marker
is changed.
Final-graph cleanup preserves one immutable delivered-attempt tombstone for the opaque final key before deleting its root, plans, jobs, canonical revisions, and turn. A repeated authoritative snapshot consults that tombstone and cannot recreate or repost the final. Tombstones are deduplicated per key and bounded per host by the acknowledged-final retention count (4096 by default).
Automatic maintenance is deliberately coarse and bounded. After a stored
snapshot, the daemon consults one persisted database-wide cadence marker (3600
seconds by default). When due, it removes at most 100 snapshot rows, uses a
shared budget of 100 eligible acknowledged-final graphs across hosts, and runs
one separate command-receipt batch of at most 100 transitions/deletions.
Command-receipt maintenance converts stale send_started receipts to
uncertain before considering bounded inactive deletion. Private per-host
final service cursors
order never-serviced hosts first and then the least-recently serviced,
preventing a lexicographically early busy host from starving others. Later
batches resume backlog; no batch loops to empty, compacts pages, or invokes
VACUUM.
store compact is an explicit CLI-only database operation, not a daemon API.
Dry-run accepts optional --snapshot-retention-days,
--snapshot-retention-count, and --batch-size overrides, but rejects
--acknowledge-offline and --backup-path; it opens the current v12 store
read-only, runs PRAGMA quick_check, estimates eligible snapshots and disk
headroom, and is strictly non-mutating. Execute requires all writers stopped,
the literal --acknowledge-offline flag, and a new nonexistent private backup
path. Its result status is one of completed, invalid_request,
store_unavailable, schema_not_current, permissions_failed,
offline_required, integrity_failed, insufficient_space, backup_failed,
maintenance_failed, checkpoint_failed, replacement_failed,
rollback_completed, or rollback_failed; dry-run success is dry_run.
These statuses describe verified local outcomes, not exactly-once execution.
See INSTALL.md for the required maintenance and rollback order.
Tendwire exposes a Tendwire-only connector delivery boundary above the SQLite
store. The public daemon methods are connector.prepare, connector.poll,
connector.ack, connector.fail, connector.defer, connector.inspect,
connector.retry, and the operational helper connector.reclaim. Matching
JSON-only CLI hooks include:
tendwire connector poll --name attention --limit 10 --lease-seconds 60 --db-path /path/to/tendwire.db
tendwire connector ack --name attention --ref '<opaque-ref>' --response-json '{"delivered":true}' --db-path /path/to/tendwire.db
tendwire connector fail --name attention --ref '<opaque-ref>' --reason temporary --delay-seconds 60 --db-path /path/to/tendwire.db
tendwire connector defer --name attention --ref '<opaque-ref>' --reason scheduled --available-at 2026-01-01T00:10:00+00:00 --db-path /path/to/tendwire.db
tendwire connector inspect --name turn-final --status dead_letter --limit 100 --db-path /path/to/tendwire.db
tendwire connector retry --name turn-final --final-identity 'twfinal1.<opaque>' --db-path /path/to/tendwire.dbThe boundary is neutral and separate from concrete connector integrations.
Public requests use name, ref, limit, lease_seconds, available_at,
delay_seconds, reason, optional sanitized response, and the strictly
scoped inspect/retry selectors shown above. Dead-letter inspection accepts only
name=turn-final, status=dead_letter, and a limit from 1 through 100. It
reserves room for a failed plan even when migration holds fill the limit.
An exhausted-root item contains bounded status, created_at, updated_at,
attempt_count, sanitized final, and an opaque key only when that key
validates. A failed-plan item contains kind=failed_plan, status, opaque
plan_token, final_identity, and key, plus public turn/revision, generation,
failed_job_count, and cumulative attempt_count. It remains discoverable
when its source link is absent. Retry accepts exactly one inspected
twfinal1. identity (or exact key through the API) and returns either a
bounded requeued result with prior_attempt_count or the existing bounded
recovered result. It is never bulk.
Missing/malformed-owner, internal-automation, and known-incomplete safety holds are inspection-only and exact retry fails closed. Eligible exhausted roots and unique failed plans can retry only after authoritative current-revision and route validation.
name must otherwise be a neutral queue name: 1-64 ASCII letters, digits, .,
_, or -, and not a concrete provider, delivery, backend, or terminal-routing
token. Public responses never expose private_state_json, backend routing,
pane/session/terminal identifiers, socket paths, target values,
Telegram/chat/topic/message IDs, tokens, or connector-specific delivery
internals.
connector.prepare is the schema-v1 planning surface for the neutral
turn-final queue. It stores canonical coordinate ranges, never copied content
text or provider presentation data. Requests are exact objects with no
additional fields:
begin:schema_version,action="begin",name="turn-final",turn_id,content_revision,presentation_version, andpart_countfrom 1 through 10,000.part:schema_version,action="part",name="turn-final",plan_token, zero-basedordinal, and one through 64spans. Every span has exactlyfield,start_char, andend_char;fieldisuser_textorassistant_final_text, and the range is nonempty and half-open.commit:schema_version,action="commit",name="turn-final", andplan_token.recover:schema_version,action="recover",name="turn-final",failed_plan_token, andrequest_id.
begin, part, and commit are idempotent for identical input. Commit accepts
only every declared ordinal with exact, contiguous, ordered coverage of the
selected complete canonical fields, then atomically materializes ordered
neutral upsert jobs followed by any required reverse-order retire jobs.
Consumers fetch text lazily through turn.content.get from each job's ranges;
the plan itself contains no stored page or provider-specific message copy.
A plan without a live source ref is not route-free. Begin and commit reconstruct and validate an immutable authoritative schema-v2 turn/revision/final-identity/stable-key route from a delivered root or the current complete revision, reject internal automation and conflicting roots, and copy that route into every materialized job. Failed-plan recovery retains the same lineage; a source-less row can be inspected and recovered without inventing a worker or fingerprint route.
recover is an explicit, one-shot operator action, not an automatic retry
loop. It applies only to the latest failed generation for the still-current
immutable content revision, after at least one recorded delivery attempt. The
failed source plan, its outbox rows, and its delivery receipts remain unchanged.
Tendwire retains the contiguous explicitly ACKed prefix, creates a fresh plan
token and next generation for only the unfinished suffix, and preserves ordered
continuity through the predecessor job key. A delivered item after a gap, a
leased suffix, a superseded revision, a nonfailed plan, or a competing
generation fails closed.
The recovery request_id is a provider-neutral idempotency key of 1 through
128 ASCII letters, digits, ., _, or -. Repeating the same request ID with
the same failed plan returns the same recovery with
idempotent_replay=true; reusing it for another failed plan returns
request_conflict, and a second request for an already recovered generation
returns plan_conflict. A successful recovery response is deliberately bounded
to exactly schema_version, ok, status (recovered),
failed_plan_token, plan_token, generation, content_revision, state
(active), acknowledged_prefix_count, executable_job_count,
retained_failed_job_count, prior_attempt_count, and
idempotent_replay. Schema v7 records the request, source/recovered plan,
generation, retained prefix, fresh suffix, failed-job count, prior attempts,
and outcome in one immutable recovery audit row.
Only an explicit connector.ack proves a delivered prefix. A provider may have
accepted an operation even when its success receipt was lost; that state is
delivery-uncertain and no connector can promise perfect exactly-once external
effects. Recovery therefore reports and preserves Tendwire's durable evidence
without claiming that an unacknowledged provider operation definitely had no
effect.
connector.poll atomically leases due connector_outbox rows for one name
and returns opaque per-attempt refs. A live lease prevents duplicate polling.
Expired leases are reclaimed before polling and before ref-mutating operations;
connector.reclaim can also be called directly. connector.ack validates the
host, name, attempt, lease, and ref before marking the delivery and outbox item
delivered. connector.fail records sanitized failure data and schedules retry
availability. connector.defer records sanitized defer data and schedules future
availability without treating the item as delivered. Stale, expired,
wrong-host, wrong-name, and superseded refs fail closed with neutral errors.
When callers omit lease_seconds, the daemon and CLI use
connector_claim_ttl_seconds from config, defaulting to 60 seconds; an explicit
public lease_seconds value still wins. max_outbox_attempts prevents
unbounded retry loops. Once a failed job reaches the configured cap, Tendwire
moves the outbox item to a neutral terminal dead_letter state and returns the
public status attempts_exhausted without exposing private outbox or delivery
state.
Persisting an authoritative, owner-authenticated complete final atomically
creates one neutral durable final_ready root before Herdres is available. Its
schema_version=2 payload binds the exact public opaque stable_key and integer
stable_key_version=1 at the root, copied from persisted turn metadata. A
range plan preserves that route under every job's turn; no worker fingerprint
is a fallback.
Missing/malformed ownership creates a nonpollable schema_version=1
final_migration_hold/dead_letter. Known-incomplete or internally classified
automation finals are also nonpollable safety holds. All are permanently
nonretryable for that canonical identity. Polling leases only a routable root so
Herdres can materialize its ordered plan, and the root becomes delivered only
after every part is durably ACKed. Omission, lease expiry, defer, failure,
restart, and exhaustion never make unresolved work retention-eligible.
dead_letter means unresolved, not discardable. Bounded inspect keeps roots and
failed plans visible without content. Exact retry revalidates the current
complete owner and route. A unique failed plan, including one with no source
link, uses deterministic recovery that retains the contiguous ACKed prefix and
materializes a fresh suffix. An eligible exhausted root preserves cumulative
attempts and receives a fresh budget. Safety holds, stale/incomplete/resolved
identities, and ambiguous plans fail closed. After acknowledged cleanup, a
bounded immutable delivered tombstone prevents the same final key from being
recreated or reposted. Operators use inspect/retry, never manual SQLite edits.
These source-only contracts and their hermetic Tendwire/Herdres checks do not claim a live connector run, service restart, migration of live state, or production deployment.
Connector payloads are generic Tendwire jobs such as attention_created and
attention_escalated; the existing attention lifecycle writer continues to
enqueue them in connector_outbox. This boundary does not add Telegram
delivery, Herdres integration, bot tokens, chat/topic/message IDs, backend
targets, pane/session/terminal routing, shell control, argv handling, UI
delivery, or private connector routing fields to public models or public JSON.
Tendwire now exposes a minimal, safety-first command interface:
echo '{"schema_version": 1, "action": "noop"}' | tendwire command --jsonThe connector-facing structured Claude decision payload, semantic
answer_decision action, validation, and retry behavior are documented in
docs/answer_decision.md.
The command --json subcommand reads exactly one schema-v1 JSON request from
stdin. A proven result is exactly one schema-v2 command envelope on JSON-only
stdout with exit 0/1; unresolved process ambiguity is no stdout envelope
and exit 2. Human argparse errors may use stderr. command.submit is the
daemon method for the same contract; mutating Herdr sends require the socket
backend and a healthy authoritative snapshot.
command.submit (CLI: tendwire command --json) is the only public send
path. Callers such as Herdres select a worker by neutral identity
(worker_id/worker_fingerprint/space_id/name) and pass instruction text;
they never see or supply pane_id, terminal_id, send_keys, or any
backend_target. Those identifiers stay private to Tendwire and never appear in
public snapshot/turns/pending JSON.
Internally, Tendwire resolves the neutral target to a private worker binding and, for delivery reliability on Herdr today, drives a private Herdr pane transport (pane keystroke submission) behind the public contract. This is an implementation detail: it is not exposed, not part of the neutral command shape, and can change without affecting callers.
Planned follow-up (not in this RC): switch the internal transport to Herdr's
semantic agent.send API once it is stable, replacing private pane keystroke
submission. The public command.submit contract stays the same, so no caller
change is required.
{
"schema_version": 1,
"action": "noop",
"request_id": "optional-id",
"dry_run": true,
"target": {
"worker_id": "...",
"worker_fingerprint": "...",
"space_id": "...",
"name": "..."
},
"instruction": {
"text": "..."
},
"params": {}
}schema_version— required; must be the JSON integer1exactly. Missing values, strings, floats, booleans, null, arrays, objects, and other integers are rejected before store, projection, Herdr observation, or backend send work.action— one ofnoop,read_snapshot,resolve_target,send_instruction, oranswer_pending.request_id— optional for non-mutating/dry-run work; required for every non-dry-runsend_instructionandanswer_pending. A required mutating ID must match[A-Za-z0-9._-]{1,128}exactly. It is opaque ASCII and is never trimmed, normalized, or case-folded; the exact supplied bytes round-trip in every command envelope that can be authoritatively returned.dry_run— defaults totrue; only literal JSON booleans are accepted, and a request must explicitly setdry_run: falseto ask for mutation.target— optional neutral target descriptor using onlyworker_id,worker_fingerprint,space_id, andname.send_instructionrequires at least one non-empty explicit selector.instruction— optional; forsend_instructioncontainstextonly.params— optional opaque parameters.
Requests containing connector or low-level terminal fields are rejected before
any backend call. Rejected fields include telegram, chat_id, topic_id,
message_id, thread_id, route, delivery, token, bot_token,
pane_id, terminal_id, backend_target, tty, pty, pid, tmux,
screen_session, window_id, tab_id, argv, command, shell,
target_kind, target_value, turn_target_kind, turn_target_value, and
private_fingerprint.
The command request remains schema v1. Its command result is a distinct, exact schema-v2 envelope:
{
"schema_version": 2,
"action": "noop",
"request_id": "optional-id",
"ok": true,
"dry_run": true,
"status": "noop",
"disposition": "no_receipt",
"result": {},
"error": null,
"warnings": []
}The envelope contains exactly those fields. The local daemon's outer RPC frame
remains schema v1; for command.submit, its result is this exact schema-v2
command envelope. The CLI unwraps that result and prints the schema-v2 command
envelope itself.
disposition, not status alone, is the receipt-authority and finality
contract:
no_receiptasserts no terminal receipt authority in this response. It is used for non-mutating work, dry runs, validation/pre-authority failures, and other results that were not finalized against a canonical durable receipt.in_progressprojects durablereservedorsend_started; it is not terminal, and replay of the same request ID does not start a second send.terminal_acceptedprojects durableacceptedand requiresok=true,status=accepted.terminal_rejectedprojects durablerejected; it is terminal even when its explanatory status is also seen in ano_receiptenvelope.terminal_uncertainprojects durableuncertainand requiresok=false,status=request_state_uncertain; the effect may have occurred and is never silently retried.
Status values include noop, snapshot, resolved, dry_run, accepted,
rejected, not_found, ambiguous_target, stale_target,
backend_unavailable, ambiguous_backend_target, backend_unsupported,
backend_failed, duplicate_request, request_state_uncertain,
invalid_request, and pending. pending is the status paired with
in_progress; status text never proves terminality by itself.
In particular, backend_unavailable has two authority cases. With
disposition=no_receipt, Tendwire could not establish a receipt-authoritative
terminal result (including a daemon failure proven to occur before request
start), so the status alone is not final. With
disposition=terminal_rejected, Tendwire established the canonical request,
reserved its durable receipt, and persisted the pre-send rejection; that
combination is terminal and replayable. A disconnect, timeout, protocol
failure, or OS error after send start instead becomes
request_state_uncertain/terminal_uncertain.
A valid schema-v2 CLI envelope exits 0 when ok=true and 1 when
ok=false, including in_progress, terminal rejection, and terminal
uncertainty. Exit 2 is outside the envelope protocol: it means the CLI cannot
prove whether the mutating daemon process accepted the request, emits no stdout
JSON, and must not be interpreted by status or replaced with a synthetic
envelope.
Errors use a neutral shape: code, message, and sanitized details. Public
results must never include connector delivery state, Herdr routing objects, bot
tokens, chat/topic/message IDs, raw pane IDs, terminal IDs, PIDs, TTY paths,
backend argv, or route/delivery fields.
noop— returns immediately with statusnoop.read_snapshot— returns the current neutral snapshot underresult.snapshot.resolve_target— resolves a target against live workers and returns a single target (resolved) or a list of sanitized candidates (not_found,ambiguous_target,stale_target).send_instruction— validates the request shape and instruction. Dry runs return statusdry_runwithout consulting or requiring a backend or store, creating a receipt, resolving mutable authority, or calling Herdr. Non-dry runs resolve a neutral public worker selector against the authoritative Tendwire snapshot, load Tendwire-owned private bindings from the local store, and send through Herdr's socketagent.sendmethod. The private target value is chosen by Tendwire fromWorkerBindinginternals and is never accepted from or returned to public clients.answer_pending— validates the exact public pending ID, fingerprint, and choice ID. Dry runs return the same puredry_runpreview described above. Non-dry runs resolve the authoritative public worker and private revision-bound route, then submit the private picker ordinal without exposing it.
- Dry-run by default. A request must explicitly set
dry_run: falseto request mutation. A validated mutation dry-run is pure and backend/store independent; it never creates a receipt or consults mutable target authority. - Every non-dry-run
send_instructionoranswer_pendingrequires arequest_id, an available command receipt store, and exact single public worker resolution.send_instructionalso requires at least one explicit target selector. - Targets with status
closed,failed, orunknownare rejected forsend_instruction. - The send adapter never treats public selectors as raw Herdr send targets.
Clients may use
worker_idonly as a neutral Tendwire selector, never as a backend target value. Clients must never provideterminal_id,pane_id,backend_target,target_value, argv, shell, or backend parameters as raw Herdr send targets; low-level backend fields are rejected before mutation. - Backend-owned Herdr targets are private. Tendwire computes the final socket
agent.sendtarget parameters from privateWorkerBindingrows for every observed worker; if two public workers would send to the same private target, all affected targets are non-sendable and mutation fails closed withambiguous_backend_target. Missing, empty, unsupported, or otherwise non-sendable private targets fail closed withbackend_unsupported. - For non-dry-run
send_instruction,TENDWIRE_HERDR_BACKENDmust besocketand the current Herdr backend health must behealthy. A disabled socket backend, missing Herdr socket, launch/connect failure, unhealthy backend health, or unavailable receipt store returnsbackend_unavailableand does not execute send. Only a healthy empty socket observation may produce a finalnot_found. - Once Tendwire has reserved a receipt and started Herdr socket
agent.send, disconnects, timeouts, protocol failures, and OS errors returnrequest_state_uncertain. Tendwire records that uncertainty and will not silently retry the samerequest_id. - Private Herdr binding expiration is guarded by authoritative healthy observation. Healthy empty observations may expire stale bindings; degraded or unavailable empty observations do not prune bindings as if Herdr were authoritatively empty.
- Instruction text is validated: it must be non-empty, no longer than 4096 characters, and may include LF newlines and tab characters. It must not contain NUL, ESC/ANSI/CSI/OSC sequences, bracketed-paste sequences, carriage returns, DEL, C1 controls, or other raw control characters.
- Unknown actions are rejected before any backend or store call.
Every non-dry-run send_instruction and answer_pending reserves a durable
SQLite receipt before backend mutation. The sole authority key is the
host-wide (host_id, request_id) pair; action is not part of that unique key.
The receipt table enforces one durable row per key with a unique index.
After authoritative target resolution, Tendwire builds canonical mutation v1
from the action, resolved public worker_id, and exact instruction text or
pending-ID/fingerprint/choice triple. The request ID, unresolved selector
spelling, worker observation fingerprint, connector origin, and private binding
are not canonical identity inputs. Different selector forms that resolve to
the same public worker therefore name the same mutation; a selector that names
a different public worker does not.
The durable lifecycle is reserved, send_started, accepted, rejected,
or uncertain:
reservedmeans one owner has claimed the request before send start. A concurrent replay reports it in progress; only an expired pre-send owner lease may be reacquired for the same canonical mutation.send_startedis recorded before the external mutation. It is never automatically retried; replay returnsrequest_state_uncertain.acceptedandrejectedare terminal and replay their stored sanitized envelope for the same canonical mutation.uncertainis terminal evidence that the mutation may have occurred. Replay returnsrequest_state_uncertainand never starts another send.- Reusing the same host/request ID for a different action, resolved public
worker, instruction, or pending choice rejects with
duplicate_requestbefore mutation. - A different
request_idis an independent mutation and, when otherwise valid, sends even when its instruction text matches earlier work. Tendwire performs no content-based command suppression or time-window heuristic.
The retry horizon defaults to 604800 seconds and may not exceed 604800. Bounded
maintenance converts only a send_started receipt older than that horizon to
uncertain; it does not retry the effect. A reserved receipt is pre-send
ownership: its active lease remains protected, and an expired lease may be
reacquired for the same canonical mutation rather than converted to
uncertain. The bounded deletion pool contains only expired reserved rows
and terminal accepted, rejected, or uncertain rows. Within that pool, a
row is deletable only when it is both older than the configured retention age
(2592000 seconds by default) and ranked beyond the newest 4096 rows for its host
by default. Retention seconds have a hard minimum of 691200 and must be strictly
greater than the retry horizon. The unchanged 2592000-second default therefore
remains greater than Herdres's entire configurable connector retry horizon (at
most 604800 seconds), so the connector cannot legitimately outlive Tendwire's
default receipt evidence. Active reserved owner leases are outside the
deletion pool, and send_started rows are transitioned rather than deleted
directly. Dry-runs never create receipts.
The only active Herdr mutation surface is the Tendwire-owned socket
agent.send call for literal instruction text. Tendwire does not use
pane send-text, send-keys, pane run, shell/PTY control, signals, paste
buffers, raw argv, client-provided worker IDs as backend targets,
client-provided backend params, fallback terminal-control paths, or connector
delivery as a command transport.
Tendwire intentionally does not include:
- Herdres, Telegram, Hermes, MCP, iOS, AR, UI, or local LLM integrations.
- Modifications to Herdres or local Herdres files.
- Connector-specific routing or delivery state inside core models or snapshots; the neutral connector outbox boundary remains separate.
- Telegram delivery, topic mapping, reply rendering, rate limiting, retry policy, or connector-specific presentation logic.
- Raw Herdr pane, terminal, socket-path, target-value, shell, PTY, argv, env, stdout, or stderr exposure in public JSON.
- Network sync or cross-device transport.
- Runtime dependencies beyond the Python standard library.