warpline

Plugin Runtime Spec

Runtime contract for warpline plugins. Covers the manifest schema additions, the AbortSignal-threaded HandlerFn signature, the retry / timeout semantics, run artifact shape + retention, and test patterns.

Board-level invocation semantics (when the engine picks which plugins to run on a board pass) stay in BOARD-SPEC.md. This spec covers what happens once a plugin has been selected and the runtime starts invoking its handler.


1. Manifest Fields

Every plugin under <plugin root>/<name>/manifest.ts exports a value validated against PluginManifestSchema, imported from warpline/schemas/plugin-manifest.

The plugin root is resolved by exactly one rule: AdvanceOptions.pluginsDir when the host supplies it, otherwise the WARPLINE_PLUGINS_DIR env var when it is set and non-empty, otherwise <warplineHome()>/plugins. It is a single root, not a search-path list — nothing falls back to a second location when a plugin is not found under the first.

The env var is how a host that supplies its own root reaches the CLI verbs that validate plugin names against it — approve, deny, resolve, configure, plan — and advance, init and scaffold, all of which run in a process the host’s AdvanceOptions never touch. Set it wherever WARPLINE_HOME is set. approve prints the grant file it wrote (Grant file: <path>), so a grant written into a home no scheduled run reads is visible at the moment it is made.

The plugin root and the home are independent. A supplied root may sit outside the home, inside it, or be exactly <home>/plugins; nothing requires the two to be disjoint. What does not move is everything the home derives: state/, runs/, handoffs/, events.jsonl, the session-approval grant, and config/<plugin>.json all stay under warplineHome() whatever plugin root an advance is given. A root that is absent, is not a directory, cannot be read, or is the empty string is refused before the advance writes anything.

A library host runs another home with AdvanceOptions.home. For that advance, handler calls included, everything the home derives moves with it: state/, runs/, handoffs/, logs/, events.jsonl, the run lock, preferences.json, the session-approval grant, standing-grants.json, principals.json, the audit store and config/<plugin>.json. The plugin root follows the rule above, with <home>/plugins as its last step. The scope is the advance’s own async work, so two advances on two homes may run at once in one process. It does not reach a child process a handler spawns: that inherits the environment and resolves WARPLINE_HOME as usual. Nor does it reach code a plugin runs at module scope. A plugin’s modules are evaluated once per process, outside the advance’s scope, so const dir = join(warplineHome(), 'state') at a handler’s top level holds the process home, for that advance and for every advance after it in the process. A handler resolves warplineHome(), and any path built from it, inside its handler function. home is refused before any write when it is the empty string, or when it is passed with stateDir, runsDir, logsDir, eventsPath, preferencesPath, approvalPath or lockPath, each of which is a test seam that moves one file.

The table below is generated from that schema — bun run docs:generate in a clone refreshes it, and CI regenerates and fails on a stale diff, so it cannot drift from the code. Edit the schema, not the table.

Field Type Required Default
name string yes —
version string yes —
description string yes —
inputs object no {}
outputs object no {}
capabilities string[] no []
schedule on_run | daily | weekly | manual no "on_run"
autonomy_level autonomous | supervised | manual yes —
approval_class session | content no "session"
side_effects (sends_email | creates_issue | writes_db | external_api | modifies_file)[] no []
llm_handoff boolean no false
judgment_fed_effects boolean no false
secrets string[] no []
ttl_hours number yes —
dependencies string[] no []
timeout_ms integer no 60000
max_retries integer no 1
retry_delay_ms integer no 2000
actions object no —
max_parallelism integer no 1
min_tier normal | degraded | extended | suspended no "normal"

Every field with a default is optional in a manifest file, so adding one never invalidates an existing plugin. name and each dependencies entry follow the plugin-name rule, ^[a-z][a-z0-9-]{0,63}$, published as PLUGIN_NAME and isPluginName from warpline/schemas/plugin-name. name may also not be a member of Object.prototype — __proto__, constructor, toString, valueOf and the rest are refused. constructor passes the plugin-name rule, so this refusal is not redundant. The set is derived from the prototype, not listed, so it cannot go stale.

llm_handoff declares that the handler MAY return a [needs-llm] handoff. It is permission, not a promise: a declaring plugin that returns success records success. A handoff from a plugin that does not declare it is refused, and the run is recorded failed with an error naming the field (see § 3). The free-text capabilities array never counts as this declaration, whatever it holds.

judgment_fed_effects declares that the plugin’s side effects fire on a Judgment, input a model wrote, for example the work a dependency handed off. It is declared only, never derived from the dependency graph, and the engine never reads it, so an advance fires or skips such a plugin exactly as it would without it. A manifest that sets it with no declared side effect is refused at load, with an error naming the field. warpline plan flags such a plugin under each session or standing grant that would let it fire (§ 9).

The key in the plain-object plugin_runs and denials records is the plugin DIRECTORY name, and the loader holds the directory to the same rules before it imports anything: a directory named after a prototype member, or one outside the plugin-name rule, is a load failure with the plugin absent from manifests, and the message names the directory and says to rename it. A manifest whose name is not its directory’s name is a load failure too, so the two strings are one string for every loaded plugin. That is where the constraint has to bite: loadPluginManifests keys its map by the directory entry and every downstream key comes out of that map. A __proto__ key would invoke the prototype setter and drop the record on write — no plugin_runs entry after a gated run, which is the re-firing defect that record exists to close — and the others answer a lookup with an inherited member rather than the absence that is the truth. The plugin-name rule closes the rest: * would merge into a session grant as the wildcard, a control byte would reach the audit store, and two names differing only in case would share one config/<name>.json on a case-insensitive filesystem. invokePlugin, which every run of a plugin goes through, joins the name it is given under the plugin root, so it applies both rules itself, for every caller, a warpline/unstable-runtime host included. A name outside the plugin-name rule fails to load before anything is read, so ../x imports nothing. A manifest the loader would refuse, one that is not a valid manifest or whose name is not its directory’s, fails to load too, and its handler never runs. The loader and invokePlugin share one function for the second rule, so warpline run admits exactly the manifests advance and plan do. warpline run also refuses a positional outside the plugin-name rule itself, with exit 1, before it reads or records anything. ttl_hours must be positive — zero or negative would disable caching rather than mean “always fresh”. max_retries is capped at 10 and retry_delay_ms at 60s; the backoff that uses them is described in §2. actions is an optional registry that only surfaces in a host UI when non-empty.

inputs

Each entry in inputs declares one parameter the plugin expects to receive as an argument at invoke time, and it is a declaration the runtime enforces rather than documentation nobody reads.

inputs[].type is a closed set — string, number, boolean, array or object. A value outside it is a hard .parse() failure, not a fall back: manifests are parsed at import time, so a misspelled type name stops the plugin rather than letting it run unvalidated.

A declared name is checked against the value received, not merely accepted. The array and object checks are predicates rather than typeof comparisons, because typeof answers object for an array and for null alike.

inputs[].default is optional and holds the value the input takes when nobody supplies one. It is the LOWEST of three precedence tiers, resolved inside invokePlugin:

Tier Source Beats
1 (lowest) inputs[].default in the manifest —
2 <home>/config/<plugin>.json the declared default
3 (highest) per-invocation arguments both

A name that also appears in secrets takes no tier in this table at all. It is resolved from the process environment before the handler is called, so the declared default is not applied to it and the required check does not run against it. The entry stays legal and stays useful — it names the parameter for whoever reads the manifest — but it describes a value this resolution order never supplies.

A value for such a name supplied through tier 2 or tier 3 is refused rather than used: the run fails once with a parse_error naming the key and the environment variable to set instead, and never the value it received. That refusal sits above the retry loop with the rest of the config resolution, so it happens once.

A missing config file is an empty config, not an error. A config file that exists but is unparseable or the wrong shape is a parse_error that fails once and never enters the retry loop; its message names the file and the offending input key and the shape expected of it, and never the value it read.

A default declared here is data a resolver can act on. A default stated only in a description is one the handler has to re-implement, and the two drift.

Both fields are nested inside the inputs record value, so neither appears in the generated table above — that table lists top-level manifest fields only. This prose is the documentation for them.

secrets

secrets is a list of environment variable keys the plugin requires. It is a list of names, and the names are the whole of it.

Warpline resolves the names and never stores the values. There is no vault, no .env file the runtime reads and no secrets file under the warpline home, so a copied home carries no declared credential — the strongest form of protection at rest available, which is having nothing at rest.

Every declared name is looked up in the process environment before the handler is called, by exact key equality: no case folding, no trimming and no prefix convention. A name that does not resolve fails the run with a single auth_failure error naming the key and this field. That failure sits above the retry loop, so it happens once and is never retried — a credential absent on the first attempt is absent on the third.

A key that is present but set to the empty string counts as absent and fails by name. An empty token is a broken credential, and admitting it would only move the same failure into the handler, which is the position this check exists to get in front of.

secrets: [], and a manifest that declares nothing, both run the check and pass it. Adding the field invalidates no manifest that already validated.

This is not capabilities, which is a free-text array of informational tags. Those two fields do not read each other.

inputs is the field that does. Declaring one name in both records is legal, and the runtime consults secrets while it resolves inputs: such a name is excluded from the input resolution entirely and comes from the environment alone. The inputs entry for it is documentation, and its default is not applied — a placeholder written there cannot stand in for a credential.

schedule

schedule says when an advance should consider the plugin at all. It is a closed set of four values — on_run, daily, weekly and manual — declared by the plugin itself rather than written by the operator somewhere else.

An advance may be requested with a run profile, and a profile admits a tier of schedules rather than one. daily admits on_run and daily; weekly admits on_run, daily and weekly; manual admits manual and nothing else. A plugin whose schedule falls outside the requested tier is skipped, and the skip names the profile and the schedule.

manual is the one schedule no scheduled tier reaches. The manual profile is the only profile that admits it, so a plugin declaring it runs when that profile is asked for, or when an operator invokes it by hand.

An advance requested with no profile applies no tier — and still excludes manual. That is the plain reading of the word: a manual schedule runs when something asks for it, and an advance that asked for nothing has not asked. The other three schedules all run in that case, so an unprofiled advance is the widest one available and is still not a route to a manual plugin. The skip is reported like any other, naming the schedule and the profile that would admit it.

This exclusion is a change, not a rule that always held. Earlier releases ran a manual schedule on an unprofiled advance. They no longer do, and the change is quiet where it lands: the advance still reports complete, the plugin is recorded skipped with a profile_schedule reason, and nothing about the run reads as wrong. A host whose only invocation path is an unprofiled runAdvance should read that list once and ask for the manual profile where it meant to.

This is a different question from autonomy_level, which is a separate gate. schedule decides whether the plugin is considered; autonomy_level decides whether it may proceed once it has been. A plugin may declare schedule: 'manual' alongside autonomy_level: 'autonomous' and mean exactly that — nothing starts it unasked, and nothing supervises it once a human has.

outputs.temporality

Each entry in outputs also declares temporality, which says what a re-run does to that output:

Value Meaning
versioned Each run yields a new Output instance. The latest is shown by default; older ones stay reachable.
replace A run overwrites the previous Output.

replace is the default, so an entry that declares no temporality is not versioned and the Board says so. Reports and briefs are the versioning case; snapshots and current-state summaries are the replacing one.

A value outside those two is a hard validation failure, not a silent fall back to the default. Manifests are parsed at import time, so a plugin that misspells its temporality stops rather than running under a policy nobody declared.

Versioned history is bounded by run retention, and the bound is not generous: an older Output version is reachable exactly while its producing run log survives, and run logs are pruned under the operator’s configured retention policy (§ 6).

append is a known deferred third value — a run adding to the previous Output rather than replacing or superseding it. It is not implemented. It is recorded here because the enum can grow additively, and a reader who needs it should know it was considered rather than overlooked.

This field is nested inside the outputs record value, so it does not appear in the generated table above — that table lists top-level manifest fields only. This prose is the documentation for it.

Contract stability

The manifest contract is best-effort and explicitly pre-1.0 — it may change in any 0.x release. That is the whole promise, and it is deliberately not a stronger one.

It is also the whole subject. The promise is made about the manifest contract and by its own words about nothing else: the run-log schema, the board schema, the engine-state schema and the capability schemas are outside it. Those shapes are reachable through warpline/schemas/* because that specifier is how this package publishes shapes, not because publishing them promised anything; a release may change any of them without a deprecation window. Where a persisted document does carry an undertaking, it is written beside the document and not here — § 9’s first_granted_at rule is the one such case, and it is stated there because nothing above it covers the file. The negative half is stated here rather than left to inference because a promise whose edges are unwritten is read at its widest by whoever is relying on it.

Adding a field is already safe by construction, for the reason stated immediately above — every field with a default is optional in a manifest file, so a new one cannot invalidate a manifest that already validates. An older build reading a manifest written for a newer one ignores what it does not know.

llm_handoff is the one addition that is not safe for every existing plugin. A manifest that validated still validates, but a plugin that hands off without declaring the field is now refused, so its runs record failed where they used to record delegated. That is a breaking change under the pre-1.0 promise, taken deliberately, and the fix is one manifest line: llm_handoff: true.

Removing or narrowing something is the case that can break you, and what limits it is a convention that already exists rather than a promise invented here: closed enums stay closed. Six sets are closed — the side-effect type, the autonomy level, the schedule, the minimum tier, inputs[].type and the approval class — and an addition to any of them fans out into exhaustive switches and into this document, which is why they are not extended casually.

The side-effect type is closed at five — sends_email, creates_issue, writes_db, external_api and modifies_file — and one of those five is not like the others. creates_issue names an outcome where the other four name a mechanism: writing a row, calling an API, sending mail, touching a file. Asked once, answered, and recorded here so it is not asked again. The asymmetry is known and it is not being corrected, because every value is a literal that installed manifests declare and that the runtime hashes into a denial fingerprint. Renaming one breaks every installed plugin’s manifest and invalidates the denials already recorded against it — a manifest-contract break, which is the one thing the promise above actually covers. A cosmetic gain is not worth spending that.

inputs[].type is the one of the five that was narrowed rather than born closed. It accepted any string before 0.2, so a manifest outside this repo declaring a name that is not in the set now fails at import time. That is a breaking change, permitted by the pre-1.0 promise above and taken deliberately: a type field nothing validates is a field that means nothing.

The same set gained array and object in 0.3.2. That direction is additive — a manifest that validated before still validates — and it corrects a claim this document made rather than granting a new liberty: the two were left out on the stated premise that nothing declared them, and consumer manifests declare both. The set is still closed at five, and a sixth name is still a hard failure.

Pin the version you tested against, and read the release notes for the version you move to. The release notes are the record of what changed between two versions; nothing else here claims to be.

Published specifiers, and what each promises

Seven specifiers are published: warpline, warpline/schemas/*, warpline/lib/paths, warpline/unstable-runtime, warpline/unstable-fs, warpline/unstable-result — the three result builders skillOk, skillFailure and skillHandoff, and nothing beside them — and warpline/unstable-capabilities. Nothing else in the package is reachable — the exports map is an allowlist, and an import of any other subpath fails at resolution rather than resolving to something internal.

The root barrel warpline and the two narrow subpaths beneath it are public contract from 0.1.0 onward. They are governed by the stability promise stated above and are deliberately small for that reason.

warpline/unstable-runtime is not. Any name behind a warpline/unstable-* specifier may change, narrow or disappear in any 0.x release. What you get is a line in that release’s notes, and no deprecation window — the specifier carries the warning so that nobody has to have read this paragraph to be warned. If you depend on one of those names, pin the exact version you tested against.

That statement is scoped to the specifier and not to any list of symbols, which is deliberate: what is behind an unstable-* specifier is expected to move, and a later release that adds a specifier of that shape inherits this promise rather than inventing its own.

warpline/unstable-capabilities is type-only. Every name behind it is erased at build time, so the module it resolves to exports no runtime value at all, and importing it for a value gets you nothing. It carries the shape of the capability context a handler is handed, the shape of each member on it — SecretsHandle and DependenciesHandle — the shape of the grant witness a caller of the runtime must supply, and the four-parameter handler type that ties them together. The mint and the capability registry are deliberately not behind it: the registry is a table designed to grow, and publishing it would owe a stability promise on every row anybody adds.

DependenciesHandle carries two member functions, both taking the caller and a name the reading manifest declares:

Both members answer from the dependency state the HOST supplied, and a host may supply none: invokePlugin’s dependencyRuns is optional, and a caller that omits it hands the handler a member reading null for every declared name whatever engine-state.json holds. warpline run is such a caller — it invokes one plugin standalone and reads no runtime state. So null distinguishes “never run” from “produced nothing” only on an engine advance, and a handler that must run correctly under both should not publish “has not run yet” on the strength of a null.

An undeclared name throws from either member, through one shared refusal, and the message names the reading plugin, the requested name and the manifest field to add it to.

InvokePluginOptions.dependencyRuns is what a host fills to supply both facts. It was briefly named dependencyOutputs and carried only the record; no published release ever shipped that name. Renaming it would have been allowed regardless, because the field rides warpline/unstable-runtime, whose promise about any name behind it is stated above and is exactly nothing.

2. Retry Policy

Retries fire only on a first failure whose SkillResult.retryable === true. Validation errors, non-retryable handler errors, timeouts, and cancellations never retry. Total attempts equals 1 + max_retries, capped by the manifest’s max_retries or by ?retries=N / --retries=N at the call site.

Delay between attempts uses exponential backoff plus jitter, capped at 30s:

const expBase = Math.min(baseDelay * Math.pow(2, attempt - 1), 30_000)
const jitterMult = 1 + (Math.random() * 0.5 - 0.25) // +/-25%
const delay = Math.round(expBase * jitterMult)

Each attempt emits a run-attempt-started SSE event and, on failure, a run-attempt-failed event (with data = error message). Successful attempts end the retry loop immediately.

3. Timeout Contract

timeout_ms applies per-attempt; every retry gets a fresh budget. A timeout is always fatal. The runtime never retries after a timeout trip (timed_out: true on the result). Enforcement is an AbortController.signal.addEventListener( 'abort', ...) plus a setTimeout-armed abort that races the handler promise.

timeout_ms bounds awake time: the time the plugin could actually run. A system sleep does not count against it. A setTimeout counts a sleep and fires on wake, while performance.now() stops for the duration (measured under Bun on macOS: 124 s asleep read as 131.2 s of wall time and 9.9 s of performance.now()). So when the timer fires with awake budget left, it re-arms for the remainder rather than failing the attempt. elapsed_ms stays wall time, how long the attempt took end to end, and can therefore exceed timeout_ms on an attempt that did not time out.

A timeout whose awake deadline passed by more than max(timeout_ms, 1s) before the timer could run is still a timeout, but its summary and error say why: <plugin>: timeout (timer fired <n>s late against timeout_ms=<ms>: the event loop was blocked). That tells a handler that held the event loop apart from one that was merely slow.

Timeout vs. retry interaction:

Outcome status retried timed_out
handler resolves with success success attempt_count > 1 false
handler returns retryable: true loop (final attempt determines) false
handler returns retryable: false failed false false
handler throws failed false false
per-attempt timeout trips failed false true
external controller.abort() cancelled false false
handler returns skipped + [needs-llm] summary prefix, from a plugin declaring llm_handoff: true delegated false false
handler returns skipped + a needs_llm field, from a plugin declaring llm_handoff: true delegated false false
handler returns either handoff, from a plugin that does not declare llm_handoff failed false false
handler returns a declared handoff and its Handoff record cannot be written (warpline run) failed false false

delegated (2026-08-19): a [needs-llm] handoff is a successful dispatch to a companion LLM skill, not a failure. deriveRunStatus() in invoke-plugin.ts is the single mapping shared by the persisted run artifact, any live run bus, and the board event (severity info). A plain skipped without the prefix still maps to failed — widen deliberately if a persisted-run path ever produces one.

The two handoff rows are one predicate, not two. isHandoff() reads the structured needs_llm field or the [needs-llm] summary prefix, and both rows still require skipped. A result carrying both arms is classified once. See needs-llm-contract.md for the field’s shape and for why the prefix arm is emitted alongside it rather than replaced by it.

A handoff from a plugin whose manifest does not declare llm_handoff: true is refused. The refusal happens at one site, in the retry loop, where that predicate meets the manifest bit, and it runs before either classifier. The manifest there is the module as exported, so any value other than the boolean true counts as undeclared. The handler’s result is replaced by a fresh one: status: 'failed', phases_failed naming the plugin, summary <name>: undeclared handoff, and an empty artifacts_produced, so the plugin’s prior last_output carries forward and nothing the handler wrote is published. errors[0] is a parse_error with retryable: false and the message Plugin '<name>' returned a [needs-llm] handoff but its manifest does not declare llm_handoff: true. The refusal is never retried, so the run has one attempt and emits no attempt_failed notice. Timeout and cancellation still take precedence at the status level: an attempt aborted after the handler returned reports timeout or cancelled, while its result body carries the refusal. Under the default preferences (review_gate: true), or for a plugin with autonomy_level: 'supervised', the refused result is not parked, because a failed result never is (§ 9). The plugin entry and plugin_runs read failed there too, with the refusal in the summary. It is never delegated.

A declared handoff from an advance whose Handoff record cannot be written is replaced by a fresh failed result: summary <name>: handoff not recorded, errors[0] a dependency_unavailable with retryable: false, and an empty artifacts_produced. The plugin entry and plugin_runs read failed, and it is never parked. See needs-llm-contract.md § The Handoff record.

warpline run writes the record before the run artifact. A record it cannot write replaces the result the same way, rewrites the last attempt to failed with that error, and sets final_error. The artifact persists as failed, and warpline run reports ok: false. A caller of invokePlugin that passes its own runsDir gets the record in a handoffs/ directory beside that directory.

Attempt status

Each entry in attempts[] carries its own terminal status, from a five-value set: success | failed | cancelled | timeout | delegated.

delegated joined it on 2026-08-28. Until then the attempt classifier had four values and collapsed everything that was not success into failed, so a handoff produced a run artifact that contradicted itself — status: "delegated" at the run level, attempts[0].status: "failed" one field below. Nothing behaved wrongly, because deriveRunStatus and the CLI both read the result rather than the attempt, but anyone reading attempts[] directly was told the dispatch failed.

Both levels now classify a handoff through one shared predicate, so the run and its attempts cannot disagree. A delegated attempt also carries error: null and contributes no final_error, even when the handoff result populates errors[]: the dispatch succeeded, so there is no failure to attribute. A handoff from a plugin that does not declare llm_handoff is rewritten before either level classifies it, so both read failed, and the attempt’s error carries the refusal message. A declared handoff whose Handoff record cannot be written is rewritten after the loop, so its last attempt reads failed with the record’s error, and the run and its attempts still agree.

Consumers should treat the set as open and not assume four members. The persisted artifact types this field as a plain string for that reason.

4. AbortSignal Contract

HandlerFn gained a third parameter in :

export type HandlerFn = (
  manifest: PluginManifest,
  args: Record<string, unknown>,
  signal: AbortSignal,
) => Promise<SkillResultInput>

The return type is SkillResultInput, not SkillResult. A handler writes a result; it never reads one back. SkillResult is the schema’s OUTPUT type — what a caller holds after .parse() — so a handler typed against it can only write the already-normalized shape, and the bare-string artifacts_produced arm that § Output records below documents as valid until 1.0 was unreachable through the only path a plugin has. SkillResultInput is the same schema’s input side: defaulted fields optional, the string arm allowed. Both types come from warpline/schemas/skill-result.

Everything assignable to SkillResult is assignable to SkillResultInput, so a handler already annotated with the output type keeps typechecking unchanged.

Handlers with real I/O should forward signal to their I/O primitives:

Handlers without real I/O (pure compute, LLM stubs) may ignore the signal. The runtime wraps each handler call in a Promise.race against a signal-aborted fallback so ignorant handlers still honour the timeout / cancel clock. This is documented as residual DoS and accepted.

External abort sources:

  1. An external controller.abort() from a host (e.g. a dashboard cancel button).
  2. SIGINT to the warpline run CLI entry - propagated as an AbortError via the same controller.
  3. Per-attempt timeout - internal, handled inside invokePlugin.

5. Run Artifact and Run Log Shapes

Warpline keeps three records of a run, in three formats, in two directories. They answer different questions, and reading one as another is the mistake this section used to make.

Written by Document Files Answers
warpline run <plugin> — one plugin, invoked directly RunArtifact <home>/runs/<run_id>.json and a <run_id>.log transcript what happened on each retry attempt of one invocation
an engine advance — every due plugin in one pass RunLog <home>/runs/<run_id>.json what the whole pass did, plugin by plugin, as one document
an engine advance, additionally JSONL run log <home>/logs/runs/YYYY-MM-DD.jsonl what happened across many runs on one day, as a stream to tail or grep

The first two share a directory and a filename pattern and are not the same shape. The third shares neither, on purpose — see below.

No transcript is written today. Nothing in this runtime produces a <run_id>.log. A plugin’s output is redirected to stderr for the length of its invocation (§ 11) rather than captured, and nothing else writes one — so the first row above, the layout below it, and every sentence about a transcript in § 6 describe a file no home currently contains. They are kept rather than deleted because the prune already enumerates and deletes the pair correctly, and because giving that file a writer is planned rather than abandoned. Read all of them as conditional on that landing, and do not build a reader that expects the file to be there.

§ 6 turns on that distinction: the 20-newest trim only ever sees a RunArtifact, only pruneRunLogs deletes an advance’s RunLog, and the JSONL stream prunes itself on the same window from the same constant.

The run artifact

One plugin, invoked directly, writes one file today and is specified for two:

JSON schema:

{
  "run_id": "<uuid>",
  "plugin": "<plugin-name>",
  "started_at": "<iso>",
  "completed_at": "<iso>",
  "status": "success | failed | cancelled | timeout | running | delegated",
  "summary": "<final result summary>",
  "user_initiated": true,
  "attempts": [
    {
      "attempt": 1,
      "started_at": "<iso>",
      "elapsed_ms": 1234,
      "status": "failed",
      "error": "rate limited"
    },
    {
      "attempt": 2,
      "started_at": "<iso>",
      "elapsed_ms": 987,
      "status": "success",
      "error": null
    }
  ],
  "final_error": null,
  "cancelled": false,
  "timed_out": false,
  "retried": true
}

Log file, as specified — and again, nothing writes one today:

=== Attempt 1 ===
<captured stdout + stderr for attempt 1>
=== Attempt 2 ===
<captured stdout + stderr for attempt 2>

Cancelled runs persist with status: 'cancelled' and partial attempts. The transcript is where whatever the handler emitted before the abort would go, once there is one.

A run whose plugin returned a [needs-llm] handoff without declaring llm_handoff: true persists with status: 'failed', one attempt, and a final_error naming the field. It never persists as delegated, so nothing that selects run artifacts by the delegated status picks it up. § 3 describes the refusal.

A warpline run whose declared handoff could not be recorded persists with status: 'failed' and a final_error naming the Handoff record. It never persists as delegated.

A RunArtifact also carries an optional plugin_entries, kept only for backward compatibility with an older combined engine-run shape. Nothing writes it. The plugin_entries an advance fills belongs to the run log below, and the two are unrelated.

The run log

An engine advance writes one RunLog for the whole pass, and no .log sibling — the transcript file belongs to the direct-invocation path.

{
  "run_id": "<run-id>",
  "started_at": "<iso>",
  "completed_at": "<iso>",
  "status": "complete | partial | failed | interrupted",
  "resumed_from": null,
  "summary": "Engine run <run-id>: 3 plugins processed",
  "plugin_entries": [],
  "manifests_loaded": 3,
  "trigger": "scheduled"
}

completed_at is null for a run that was interrupted. resumed_from names the run this one continued, and is null for a fresh advance.

status is failed when the advance loaded no plugin manifests at all: a readable plugin root holding nothing importable, or one whose every manifest threw on import. That is a distinct outcome from partial, which means some plugins ran and others did not — a root that loaded manifests and executed them never reports failed, however many of them failed individually. A root that cannot be read at all is a third thing again, and is refused before the run starts, so it writes no run log to carry a status.

The quiet-hours skip reports the same status a normal advance would for that root. A skipped cycle over a root that loaded no manifests is still a cycle over a root that loaded no manifests, so it reports failed and calls onRunFailure from that path — it writes no run log, because it did no work. The same holds one step up: a root that loaded some of its manifests and failed on others reports partial on this path too, and calls onRunFailure naming the plugins that did not load. No plugin runs during a skip, so a load failure is the only way to be partial here — but a quiet hour is not a reason to stop reporting one.

Only the plugin root’s own directories are candidates. A stray file in the root is not a plugin and is not a failure, because there is no plugin there to fail; a symlink resolving to a directory is a plugin like any other. A directory that holds no loadable manifest IS reported as a failure — that is a misconfiguration an operator can act on, not a stray file. Quiet hours suppresses the work, not the verdict on the root.

plugin_entries is the only accumulated field, and it is deliberately the only one. A host that wants run telemetry derives it from the per-plugin entries. The runtime does not compute aggregates, does not store them, and does not define what an aggregate should mean for a host whose plugins it has never seen — the same “derive, don’t store” rule the rest of this runtime follows.

manifests_loaded sits beside it without contradicting that rule, because it is not derived from anything. It is how many plugin manifests the loader found for this run — an input to the advance, not a value computed from its outcome. It is also not derivable from plugin_entries: a run that stops at a gate never reaches the later levels, so it holds entries for fewer plugins than it loaded, and reading the entry count as the manifest count would under-report on the ordinary gated path. The field is optional, never defaulted, so a run log written before it existed reads back as absent rather than as a run that loaded nothing — and zero, which is the signature of the empty root, keeps meaning exactly that.

trigger is what started the advance, scheduled or manual, as the host asserts it. warpline cannot tell a scheduler tick from a person at a terminal, so it records only what it is told: AdvanceOptions.trigger from a library host, or the WARPLINE_TRIGGER env var from warpline advance, which refuses any other value with 1 before running (§ 11). The env var is read by the CLI alone: runAdvance() never reads it, so a library host that sets it and not the option records no trigger. The field is optional and never defaulted, like manifests_loaded: absent means nobody said, which is also what every run log written before the field existed reads as. A host counting consecutive scheduled advances counts only "trigger": "scheduled", so a hand run cannot stand in for a tick that failed or never fired. It is not RunArtifact.user_initiated, which sits on the single-plugin run verb’s artifact and says nothing about an advance.

Before 0.2 this document also declared six fields nothing here ever wrote and no document ever described: an optional aggregate metrics object, a per-mode array with its own two sibling schemas, and four task-board counters that were written as literal constants and read by nothing. They came across with the extraction and were public API through warpline/schemas/* from 0.1.0. All six were removed in 0.2, along with the two stranded schemas and their inferred types.

A run log written by 0.1.x still parses against the current schema: unknown keys are stripped rather than rejected, so no migration exists and none is needed. The break is compile-time only, for a consumer that named one of the removed types.

Plugin entry status

Each entry in plugin_entries records how one plugin ended in that run. The set is closed — an unlisted value fails validation rather than being dropped.

Status Meaning
completed The handler ran and returned a result the engine accepted
failed The handler threw, returned a failed result, returned a [needs-llm] handoff its manifest does not declare (llm_handoff), returned a declared handoff whose Handoff record could not be written, or the plugin’s manifest never loaded
skipped The plugin was not due — fresh, filtered, locked, without a session Grant, or holding a declared dependency whose last run failed
gated Supervised: the handler ran, its result did not fail, and the result was parked pending a human answer. A declared handoff from a plugin declaring no side effect is never parked (§ 9)
denied A human answered no, and the answer still applies to what is being proposed
refused A human answered yes, and the conditions that yes was bound to no longer hold

plugin_entries therefore no longer means “the plugins the engine attempted”. A plugin whose manifest.ts failed to import gets a failed entry too, carrying the loader’s error text as its result_summary and a zero elapsed_ms — nothing ran. Without those entries a root whose every manifest was broken and a root that was simply empty would write the same log, and telling those two apart is the whole diagnosis.

gated and denied are the two outcomes of supervision, which is why they sit together and apart from skipped. A denial recorded as skipped would land in the same bucket as “no Grant” and “still fresh”, and the log could no longer tell an unanswered question from an answered one.

refused sits beside them on the same argument one step over. It is the outcome of a content approval that existed and stopped applying, and recording it as skipped would put a lapsed authority in the same bucket as “no Grant” and “still fresh” — so the log could no longer tell an authority that lapsed from one that was never asked for. A refused entry carries a reason, the closed-set cause below; every other status leaves it absent. The field is optional and never defaulted, so a run log written before it existed reads back as what it says rather than as a refusal with a placeholder cause.

Adding a member fans out into this table and into every run log written afterwards, so the set is not extended casually.

Refusal reasons

A content approval that exists and does not authorise a fire refuses for exactly one of five reasons. Two points can refuse. The gate can produce three of them, and the spend mark, reached later, can produce four. They overlap, so the “Decided by” column below is not a partition. The set is closed and validated on parse, because this is the value an unattended scheduler switches on: an open-ended reason string would let something the runtime did not author reach that switch, and a consumer parsing prose breaks the first time the wording changes.

Reason Decided by Meaning
indeterminate the gate, or the spend mark A fire was marked and never confirmed, so the runtime cannot tell whether the bytes already shipped
outside_window the gate The approval window has closed, its zone no longer resolves on this host, or its approval instant cannot be parsed
content_moved the gate, or the spend mark The approved bytes are no longer what would ship, or the producer’s Output that held them has been erased, or the producer’s latest run produced no Output (§ 10, last_output)
mark_unavailable the spend mark The mark could not be attempted at all — the state document could not be locked or could not be read, or the fire intent could not be recorded (§ 14) — so nothing was written and nothing was sent
mark_uncertain the spend mark The mark’s own write failed, so whether it landed is unknown; nothing was sent either way

At the gate the first three are decided in that order, and the order is not arbitrary. An indeterminate mark outranks both of the others because neither “the window closed” nor “the bytes moved” can be answered honestly while the runtime does not know whether the fire already happened.

That precedence covers the gate and nothing else. The spend mark is a second decision, taken after the gate has already said fire, and a refusal from one is never a candidate for the other’s decision. The mark re-reads the record under the state document’s lock as its own precondition, so it can answer content_moved when the record has gone, its fingerprint moved, the producer’s Output was erased since the advance read the document, or a run of the producer that produced no Output was written since the gate read it, and indeterminate when something else has marked it since. An erased record the advance read, ran the producer over and produced again is not refused: the gate read the bytes that run produced, and the end-of-run write puts them over the erased record. It checks content_moved first. A consumer holding either of those two reasons cannot tell from the reason alone which point refused; the entry’s result_summary says so in prose. outside_window is the one reason the mark never produces. mark_unavailable and mark_uncertain come only from the mark, and they are two members rather than one because they leave the operator in different places: after mark_unavailable the record on disk is untouched and the next advance retries cleanly, while after mark_uncertain the record may be marked and the next advance may refuse with indeterminate. Neither maps onto indeterminate itself, which means the runtime cannot tell whether the BYTES SHIPPED — on both mark reasons they definitely did not, because the handler is never invoked.

The underlying I/O error is deliberately not carried into the refusal string. Its message embeds the state document’s path and the parser’s quotation of the document’s own bytes, and these strings reach the run log, the JSONL rows and the board. The diagnostic is deferred rather than lost: the next advance’s top-of-run read raises the same error to stderr with a non-zero exit.

already_spent is deliberately not among them. A spent approval is no live authority and no operator error — the runtime already fired those bytes, which is the instruction having been carried out. It is a state report, and there is nothing in it for a consumer to act on.

An approval that has not opened yet is likewise no refusal: it is the operator’s own instruction arriving on time, and the plugin is ordinary not-due.

A refusal also reaches the board, as a notice carrying metadata_json.event = "plugin_refused" and the same closed reason. It is not filed as a skip: the run log’s refused exists to tell an authority that lapsed from one that was never asked for, and a skip event would lose that distinction one log over. The event’s summary is built from the plugin name and the reason value alone — never from the approved bytes, and never from the gate’s own detail string, so each persisted string keeps exactly one author.

Output records

SkillResult.artifacts_produced is an array of Output records — a thing the plugin produced that an operator will read and take away. SkillResult.schema_version defaults to 2 to mark the change.

A handler may also write a bare path string here. It normalizes at the parse boundary to { type: 'artifact', format: 'markdown', path: <the string> }, so nothing downstream branches on which arm an entry arrived through. That arm is the pre-0.2 shape, it stays valid until 1.0, and it is reachable only because HandlerFn returns the schema’s input type — see § 4.

Field Type Required Notes
type string yes Semantic kind, chosen by the plugin — report, brief, artifact
format markdown | json | html | text no, defaults markdown Rendering key
run_id string stamped The run that produced it
produced_at ISO 8601 stamped When the producing run accepted it
body string exactly one of Inline content, capped at 16384 UTF-8 bytes
path string exactly one of Filesystem path to the content
erased_at ISO 8601, UTC (Z) stored only Set by the runtime when it erased body (§ 10, last_output). Never accepted from a handler
body_sha256 64 lowercase hex characters stored only The sha256 of the erased body, so a fingerprint does not move across erasure

A handler’s Output declares exactly one of body and path. Declaring both fails validation and declaring neither fails validation, so a reader never has to decide which one wins.

The record the state document stores, StoredOutputRecordSchema (exported from warpline/schemas/skill-result beside OutputRecordSchema), adds one state: erased. An erased record declares neither body nor path, carries erased_at, and must carry body_sha256. A record that is not erased carries no body_sha256, and the stored read refuses one that does. The two stored-only keys are the runtime’s, never a handler’s. A handler that returns either one has it stripped when its Output has a body or a path, and is refused as an invalid result when its Output has neither. So no plugin can hand the runtime a bodiless Output. An applied gate stores its result’s Outputs in the same shape. StoredSkillResultSchema, exported beside SkillResultSchema, is the skill result with stored Outputs, used only at pending_gates[].plugin_result. Handlers are still parsed against SkillResultSchema.

The inline cap is 16384 UTF-8 bytes, and the unit is the point. It is enforced with Buffer.byteLength, not with a string length: a string length counts UTF-16 code units, so '😀'.repeat(5) measures 10 against a limit of 10 while costing 20 bytes on disk. The constraint being bounded is not the number of characters an operator typed, it is the size of engine-state.json, which is reparsed and rewritten whole on every advance and every warpline plan — an inline body sits inside a parked gate in that document.

The cap is measured after credential redaction. invokePlugin replaces every value it resolved from secrets with [redacted] before handing the result to this schema, so the bytes counted here are the bytes that get written. [redacted] is ten bytes, so a declared credential shorter than that makes a result larger than the handler returned it — an inline body within ten bytes of the cap carrying a short credential is refused at the parse boundary rather than persisted. Refusing it there is the point: engine-state.json embeds this same schema, it is reparsed whole on every read, and an over-cap body written into it makes every later read of the document fail.

run_id and produced_at are stamped by the runtime at the point it accepts a result, never by the plugin. A plugin that could stamp its own provenance could claim a run it did not come from, so whatever a handler puts in those two fields is overwritten rather than preferred. Both are optional in the schema for exactly that reason: a handler must be able to return an Output without them.

format is a closed enum. An unrecognised value fails validation rather than being dropped; an undeclared one reads markdown. A format the renderer does not understand is shown as preformatted text, never hidden.

An Output record is persisted only for an attempt that actually produced one. Nothing synthesizes an empty Output for a run that produced none.

The runtime never deletes a path Output’s target, but nothing stops the operator or the producing plugin from doing so. A path that no longer resolves is a defined missing state that renders as such — not an error.

The pre-0.2 bare-string form still validates. A string normalizes at the parse boundary to {type: 'artifact', format: 'markdown', path: <the string>}, so nothing downstream branches on which form an entry arrived through. The string form stays valid until 1.0 and is removed then with an announcement.

The JSONL run log

An advance also appends a line per event to a daily file at <home>/logs/runs/YYYY-MM-DD.jsonl. One file per day, one JSON object per line. The audience is a headless scheduled cycle nobody watched: the run artifact and the run log each describe one run, and this is the format you tail or grep when the question spans several.

The path is <home>/logs/runs/, not <home>/runs/, and the two are easier to confuse than they look. The logger joins its configured logs directory with its own runs/ segment, so pointing it at the warpline home root would land the daily files in <home>/runs/ — the directory pruneRunLogs and trimPluginHistory scan for the two <run_id>.json shapes above. The literal path is what this spec fixes.

A line carries:

Field Type Notes
ts ISO 8601 when the line was appended
run_id string the advance that emitted it; shared by every line of one run
level info | warn | error error for a failed plugin, warn for a run that did not complete cleanly
event string run_start, plugin_result, run_end
plugin string, optional present on plugin_result
status string, optional the plugin’s outcome, or the run’s
elapsed_ms number, optional the plugin’s duration
detail string, optional the plugin’s result summary, or the run’s

The first line is written after the plugin root has been loaded and accepted, so an advance refused for an unreadable root leaves the home byte-identical, as it did before this format existed.

Retention is the same window as pruneRunLogs, read from the same preferences key (retention.days, § 6) rather than restated — a daily file whose mtime is older than that window is unlinked at the start of the next advance that runs. An advance skipped for quiet hours returns above this prune and reclaims nothing (§ 13), so a long quiet window is a stretch of hours during which nothing here is reclaimed at all. Three formats pruned on three literals would be three retention rules that agree until somebody tunes one.

6. Retention

Three bounds, all of them the operator’s, set under retention in <home>/preferences.json:

Key Default What it bounds
retention.days 30 how long a record survives, by mtime
retention.keep_per_plugin 20 how many records survive, per plugin
retention.max_bytes 104857600 the total size of <home>/runs/

preferences.json is read strictly. An unknown key at the top level or inside quiet_hours or retention is refused, and so are a wrong type, an out-of-range value, a bad HH:MM and malformed JSON. An advance refuses before any write, exiting 75 with the home byte-identical (§ 11). warpline run refuses before the plugin runs, exiting 1 with ok: false. The message names the file, the key path and the expected shape, and never a value read out of the file. A missing file is the built-in defaults. The strictness has one accepted cost: a key added by a later release is refused by an earlier one, so remove it before downgrading.

The notify block is the one exception. The file-level read accepts it without judging it, and it is parsed on its own. An unusable block, an empty argv for example, does not refuse the file: the advance raises and sends no Ask, and nothing else changes (§ 16). An unusable block is reported as unusable, never read as unset. The Answer verbs read the block too, and with it unset or unusable they treat no Ask as open (§ 16). An earlier build refuses a file carrying notify as an unknown key, so remove it before downgrading.

Every production read of preferences.json is checked against the audit store (§ 14): an advance, once it holds the run lock and before it uses any value, and warpline run, before the plugin runs. The Answer verbs record the bytes they read: approve <plugin>, approve <plugin> --content, deny <plugin>, deny --standing and renew read the file in their state-lock hold to tell whether an Ask can be open, and record nothing at that read. On a command they carry out, they record those same bytes just before their first record or write, never by a second read, and a command they refuse leaves the audit store as it was. They go on with a file that does not parse, counting notify set, so they record it too. A file they cannot read at all has no bytes to record. For an advance and warpline run, only a file that parsed is checked. The sha256 of the exact bytes parsed is compared with the last digest the store holds for the file. A difference appends preferences.observed naming the two digests, and neither what changed nor who changed it. A hand edit is a difference, so is a file the store has not seen and a file that was deleted. Bytes are compared, not values, so re-spacing the file is recorded too. A failed append refuses the read: advance exits 75 with nothing fired, and run exits 1 with ok: false and the plugin not run. An advance and warpline run refuse an invalid file before the comparison, as above, and never digest it. A missing file the store has never seen records nothing.

warpline prefs set <dotted.key> <json-value> is the audited writer of preferences.json. It reads the file as above, records a pending hand edit first, then appends preference.set naming the key path, the sha256 of the old file bytes (null when there was no file) and the sha256 of the bytes it is about to write, and only then writes those bytes through the same atomic write as every state file. The record never carries the value. Because the new digest is over the exact bytes written, the next read finds the file matching the store and records nothing. A value already in effect writes nothing and records nothing. An unknown key, a value that is not JSON or a value the schema refuses exits 1 with nothing written and nothing recorded, and so does a failed append. No message repeats the typed value. A hand edit is still allowed, and is still recorded on the next read. The read, the record and the write happen under the state lock (§ 10), so two sets at once never read the same file and lose one of their writes.

One policy object, read by every path that deletes. Three record formats pruned on three literals would be three retention rules that agree until somebody tunes one.

The two deletion paths

Two things delete out of <home>/runs/, and reading this section as though either were the only one will mislead you about what survives.

trimPluginHistory runs after the terminal write of any invocation that completes with persistArtifact: true. The manual path, warpline run, passes it. An engine advance does not, deliberately — an advance writes a RunLog rather than a per-plugin RunArtifact, so this trim never sees an advance’s output at all. It reads every <run_id>.json in the runs directory, filters by plugin, sorts by started_at DESC, and keeps the retention.keep_per_plugin newest.

pruneRunLogs runs at the top of every advance that runs, over the whole runs directory. An advance skipped for quiet hours returns above it and prunes nothing — § 13 says the same thing about the pruned count that advance reports, which is always 0. So a nightly quiet window does not reclaim disk; it is the stretch of hours during which none is reclaimed. It deletes runs, not files: a run id is enumerated from the union of the .json and .log stems of one directory listing, so a transcript whose document is already gone is reachable rather than immortal, and is reclaimed on the first advance after this rule shipped.

Two kinds of record are removed from its candidate set before any bound applies, and a third is never a candidate at all:

An approval’s protection ENDS when its window closes. From the first advance after not_after, the referenced run’s log is an ordinary candidate again, unless a pending gate or another open approval names the same run, and the three bounds below reclaim it with no new mechanism. That log holds a summary, not the content. The content a frozen batch carries is recipient data. The end-of-run write of the same advance erases it, with body deleted and erased_at and body_sha256 stamped, and sweeps the binding that named the run, by the rules § 10, “Expiry and deletion”, states. That section names the cases this paragraph does not reach. Three readers share one predicate: prune protection, the content erasure and the binding sweep. They share the window test, not one set: a run log covers every plugin in its advance, so any open approval naming the run protects it, while content belongs to one producer’s Output, so only an open approval for that producer holds it. So the three never disagree about whether a window has closed. Protection does not depend on the mark: an approval already spent still protects its run while its window is open, and a marked-unconfirmed one stops protecting when the window closes exactly as an unmarked one does.

The three bounds then apply, in this order, to what is left: days, then count, then bytes. The count bound is applied within one plugin, not across the directory — applied across it, it would evict artifacts trimPluginHistory had just decided to keep. An advance’s run log names no plugin, and neither does an orphan transcript, so all of them share one bucket: on a frequently scheduled advance retention.keep_per_plugin is the bound that binds, well before retention.days does. The byte bound is a per-home total with oldest-first eviction, and the size counted for a run is its document plus its transcript. Where a transcript exists it is normally the larger of the two, which is why a budget counting only the document would not bound this directory; no transcript exists today (§ 5), so today that arithmetic reduces to the document alone. Runs at equal ages are ordered by run id, never by the order the directory happened to list them.

Exempt and unparseable records count toward the total even though nothing can evict them. That has a consequence worth stating rather than discovering: a home whose exempt records alone exceed retention.max_bytes evicts every ordinary run and is still over budget. A single large legitimate record can likewise evict several small ones. The prune’s own test suite in this repository is what holds all of the above — its byte-bound case carries a delegated run and a gate-referenced run as the two oldest and largest records in its fixture, which is the case an eviction loop written over the raw directory listing gets wrong while passing.

Both paths unlink the pair. A run’s <run_id>.json and its <run_id>.log are deleted together on either path, so neither can strand a transcript. That rule is in place ahead of the file it protects: with no transcript written today (§ 5), it has nothing to strand yet.

Confirming a retention setting took effect is still an observation, not a read. A misspelled key no longer passes: it refuses the advance, as above. What a correctly spelled key did is another question, and the file cannot answer it.

What an advance reports is how many runs it removed, not why. Removed means gone from the directory and not merely selected: a run whose files survive the unlink — a permission error, a file something else holds open — is not in the count. warpline advance --json carries a pruned count for the advance that just ran, and the dead-man file (§ 13) carries the same number for the last advance that returned. Read it for what it is: one integer standing for all three bounds, which cannot say which of them bound. So a non-zero count does not attribute the eviction to the setting you just made, and a 0 says only that nothing was eligible. The count also covers one of the two deletion paths — trimPluginHistory runs after warpline run and never during an advance, so nothing it removes is in there.

To confirm a bound: set it, run an advance, and list <home>/runs/. The count is what tells you whether looking is worth it.

7. HTTP / SSE surface (not in this repo)

The source system exposes the runtime over HTTP + SSE from a local web dashboard (run trigger, live attempt events, cancel via DELETE). The dashboard was not extracted — it is a candidate for a later release. The runtime’s contract is API-first regardless: invokePlugin() accepts an external AbortController and emits attempt events, so any host (CLI, dashboard, another process) gets identical semantics.

8. Test Patterns (repository-only)

These patterns govern warpline’s own suite, which is written against bun:test and does not ship in the package. They are recorded here because they are runtime behaviour, not test trivia — but if you installed warpline rather than cloned it, nothing in this section applies to you.

Fixtures and mocks for plugin runtime tests follow two rules:

  1. NEVER use mock.module for plugin registry / engine / invokePlugin overrides. It is process-global and leaks across test files. A mock established in file A will silently apply to unrelated files B, C, D in the same bun test run.
  2. Use spyOn(obj, 'method') with describe-level beforeEach / afterEach to set up / tear down mocks. Per-test spyOn + mockRestore() has leaked between tests in the same describe block.

Pattern:

describe('my route', () => {
  let spy: ReturnType<typeof spyOn>
  beforeEach(() => {
    spy = spyOn(engine, 'loadPluginManifests').mockResolvedValue({
      manifests: fixtureMap(),
      failures: [],
    })
  })
  afterEach(() => {
    spy.mockRestore()
  })
  test('...', async () => { /* ... */ })
})

loadPluginManifests(pluginsDir) resolves to { manifests: Map<string, PluginManifest>, failures: LoadFailure[], root_error?: { path, code } }. A plugin directory whose manifest.ts cannot be imported is absent from manifests and present in failures as { plugin, error }, where plugin is the directory name (a broken manifest has no trustworthy name field) and error is the thrown Error.message — no stack trace. A directory whose name is a member of Object.prototype fails the same way, without being imported at all.

A manifest.ts that imports cleanly but has no manifest export is a load failure of the same shape, reported as manifest.ts has no 'manifest' export. That text is fixed, so it carries no value the manifest author wrote.

A broken manifest is reported on every call within one process, with the error it threw the first time, because both supported runtimes re-throw a module’s cached evaluation error on a later import of the same file. Bun does so from 1.4.2, which is why engines.bun names it. The loader keeps no memo of its own. A manifest.ts written after a call that found none loads on the next call. A manifest.ts fixed in place after it failed, whether it threw or had no manifest export, keeps reporting that first failure for the rest of the process, because both runtimes serve the file’s URL from their module cache. A new process loads the fixed file.

A manifest that imports cleanly is then validated against PluginManifestSchema, and one that does not satisfy it is a load failure of the same shape — absent from manifests, present in failures. The loader validates rather than asserts: it previously cast the imported value, which made every invariant the schema states decorative at runtime. That is load-bearing for the content approval class, whose approval_class and dependencies fields together decide whether the bytes a human reviewed are the bytes that fire — a content-class manifest declaring two dependencies would bind its approval to the first while shipping the second’s unreviewed Output to the handler. Refusing the manifest at load is the fail-closed outcome: the plugin never enters the map, so nothing runs it, and warpline plan exits 1 naming the directory.

The failure text for an invalid manifest names the field path and the issue code. It deliberately does not carry Zod’s own issue.message, for the reason lib/plugin-config.ts gives: that message is upstream prose which may begin quoting the received value in any minor release, and a manifest is hand-written, so the received value is author input — while this string is rendered by warpline plan, which operators read and share.

failures is sorted by plugin inside the loader, so alphabetical ordering is a property of the data rather than of whichever surface renders it, and it stays an array in every case.

A plugin root that is missing, is not a directory, or cannot be read is a different thing from a per-plugin failure: there is no plugin to attribute it to. It is reported on the return as root_error, carrying the resolved path and the errno code, and manifests and failures are both empty. The loader still never throws. runAdvance turns root_error into a rejection, before any write; warpline plan renders the result without failing, because a preview of a home that has no plugins directory is a legitimate question with a legitimate answer. A mock that returns a bare Map no longer satisfies the signature.

Fixture plugins live under test-utils/fixture-plugins/ in a clone:

Fixture Purpose
success-plugin Always succeeds on attempt 1.
retryable-fail-plugin Always retryable failure - exhaust-retries tests.
retry-then-succeed-plugin Fails once, succeeds on attempt 2.
nonretryable-fail-plugin retryable: false - never loops.
timeout-plugin Sleeps past timeout_ms - verifies fatality.
abort-aware-plugin Polls signal.aborted and exits early.
abort-unaware-plugin Ignores signal - verifies Promise.race fallback.

(The source system’s HTTP-layer test patterns — Hono app.request() instead of a real server, per-test registry resets — travel with the dashboard if it is ever extracted.)

9. Session Approval File

A plugin whose manifest declares a non-empty side_effects array may not run until a live grant covers it: a session grant (this file) or a standing grant (### Standing grants below). The session approval is a single JSON file; there is no daemon, no keyring and no server.

Path: <warplineHome>/.session-approval, where <warplineHome> is the advance’s AdvanceOptions.home when one is given, else WARPLINE_HOME if set, else the nearest ancestor directory containing a .warpline/, else <cwd>/.warpline.

Shape

{
  "granted_at": "2026-08-20T12:00:00.000Z",
  "first_granted_at": "2026-08-20T09:30:00.000Z",
  "expires_at": "2026-08-20T13:30:00.000Z",
  "scopes": ["digest-sender", "issue-render"],
  "scope_windows": {
    "digest-sender": {
      "first_granted_at": "2026-08-20T12:00:00.000Z",
      "expires_at": "2026-08-20T16:00:00.000Z"
    },
    "issue-render": {
      "first_granted_at": "2026-08-20T09:30:00.000Z",
      "expires_at": "2026-08-20T13:30:00.000Z"
    }
  }
}
Field Type Meaning
granted_at ISO 8601 string When the most recent grant was written.
first_granted_at ISO 8601 string The earliest first_granted_at across scope_windows. On a file without scope_windows, when the FIRST grant in this window was written — the anchor for the 23-hour ceiling below. Optional on read, always written.
expires_at ISO 8601 string The earliest expiry across scope_windows. On a file without scope_windows, when the grant stops being honoured for every scope. Always written, and a file whose expires_at will not parse is corrupt.
scopes "*" or string[] "*" approves every plugin. An array approves exactly the plugin directory names it lists — the same key the engine passes to the gate, not manifest.name. Always written sorted, so both the file and its diff are stable.
min_reader_version integer, optional The oldest reader that may interpret the file. A reader whose own version (GRANT_READER_VERSION, 1 in this build) is lower refuses the whole file, and so does any value that is not a finite number. Nothing writes it yet.
scope_windows object, optional One window per scope key (a plugin name, or "*"), each with its own first_granted_at and expires_at. Each scope expires on its own clock and is capped at its own ceiling. Written by warpline approve; grantApproval does not write it. A window may also carry issuer: the registered id, human or machine, that --principal named on the last approve that named the scope. It is absent when none was named, and read as none when it is not a valid id. A session window’s issuer may be either type; a standing grant’s issuer is always a human (### Standing grants below).

A fire’s attribution (§ 14) lists each live window that covers it as its own entry, so a live "*" window and the plugin’s own window are two entries.

The file is written with JSON.stringify(payload, null, 2). It is a plain TypeScript interface, not a Zod schema, and carries no schema_version: the only compatibility rule it needs is the one below.

Compatibility. first_granted_at was added in 0.1.0. Every read is first_granted_at ?? granted_at, so a file written without the field still loads and its single grant time serves as its own anchor. An older build reading a newer file ignores the field. Removing the field later would silently reset every ceiling anchor to the latest grant, which is the failure the field exists to prevent — treat it as permanent.

scope_windows was added in 0.5.0. Before it, one expires_at covered every scope, so approve b --ttl 1h after approve a --long --ttl 30d gave b thirty days. A file without it is read as one window over every listed scope, which is what it meant when it was written. The top-level expires_at and first_granted_at are written as the EARLIEST window’s, so an older build, which ignores scope_windows, expires every scope at the soonest window’s expiry. A rollback narrows authority and never widens it. An older build’s approve rewrites the file without scope_windows, and the file then reads the old way again.

A window’s issuer is newer than scope_windows. An older build ignores it, and an older build’s approve rewrites the file without it, so attribution narrows and authority does not change.

Approving a parked result never writes this file. warpline approve answers whichever gate is waiting, and when a parked result is waiting it records that result and touches the session approval file not at all — not its scopes, not its expiry, not its mtime. The gate-apply path reaches no symbol in the module that owns this file, so there is no code path from an outcome review to a grant write.

The two clocks stay separate for that reason. The 23-hour ceiling below bounds how long side-effect AUTHORITY lives, anchored at first_granted_at. The gate ceiling in § 10 bounds how long an OBSERVED OUTCOME stays acceptable, anchored at the gated run’s completion. They read the same number and answer different questions; neither is derived from the other.

Read semantics

Reads are fail-closed and never throw. A missing, expired, corrupt, truncated or unreadable file is treated as unapproved; an exception here would surface as an error a caller could catch and mistake for a recoverable condition, which is the one failure mode a gate must not have.

A grant whose expires_at equals the current instant is still valid — the comparison is now > expires_at, not >=.

A grant whose expires_at is not a parseable date is corrupt, and so unapproved. It is called out because the comparison alone does not reach that answer: new Date('nonsense').getTime() is NaN, and now > NaN is false, so an unguarded read treats a garbage expiry as an expiry infinitely far away. The same holds for an absent expires_at. Both are refused before the scope list is consulted.

A file with scope_windows is decided per scope: a plugin is approved when the file lists it and its own window is live, or when a "*" window is live. One scope’s window never approves another, and a window for a scope the file does not list approves nothing. A window whose expires_at will not parse is not live.

A file carrying a min_reader_version above this build’s is refused outright, before anything else in it is read: every scope reads unapproved. This is the X.509 critical-extension rule applied to one file. A reader that cannot see a field must not guess at what it grants. No build writes the field yet. It exists so the next format change that an older reader could misread as more authority can set it, and every reader from this build on already refuses. warpline approve over such a file starts a fresh grant, which narrows.

An unapproved side-effecting plugin is recorded skipped and the run continues. The gate withholds execution from one plugin; it does not abort the run.

A plugin whose side_effects array is empty is never gated. The engine tests for a non-empty array before it consults the gate at all, so the gate is never consulted for such a plugin and it runs whether or not a grant exists — always, including with no grant file on disk at all. This is worth stating because everything above reads like a universal rule: it is not. The gate covers the effects a plugin declares. A plugin that performs an effect it did not declare is a plugin bug, and no approval state changes that.

warpline plan’s approved: column is rendered from whichever mechanism authorises that plugin’s class, not from this file for every plugin. A content-class plugin’s column reads its content approval’s standing (§ 10, approvals); every other plugin’s reads the live grants covering it, this file’s windows and the standing grants (### Standing grants below). The registry is read the way a run reads it and never recorded, so the column agrees with the run. Rendered from the grant for all of them the column was wrong in both directions at once — a plugin with a live approval and no grant previewed as blocked while it was authorised, and one under a live scopes: '*' grant and no approval previewed as ready while it was refused. The preview exists to tell an operator whether a frozen batch will go out, so a column answering with the wrong file is a worse failure than the disagreement-by-one-comparison the preview is otherwise built to avoid. Reading the record here decides nothing: previewing is not firing, and the fire decision is still read at exactly one call site inside the engine.

warpline plan flags a Judgment-fed effect under a blanket grant. Under a due plugin whose manifest declares judgment_fed_effects: true, after its side-effect lines, plan prints one line per live grant that would authorise the fire, in the gate’s order: session entries first (*, then the plugin’s own window), then standing grants by id. The two shapes:

    judgment_fed_effects: ⚠ fires on a Judgment under session grant (digest-sender)
    judgment_fed_effects: ⚠ fires on a Judgment under standing grant 3f9a0c5e1b2d (holder ci-bot)

A session scope prints with the same escape as the standing section (### Standing grants below), so a control byte prints as \xNN. There is no line for a content-class plugin, whose content approval is the authority; none for a plugin no live grant covers, which is not due, and whose side-effect line already says it would be skipped; and none for a plugin that does not declare the field. The lines come from the same grant read as the approved: marker, so the two cannot disagree, and the advance never reads the field.

Who reads the grant, and who mints a capability

Two invariants, and they read like a contradiction until you notice they are about different files.

The declaration mints; the engine checks. A capability member reaches a handler only when the plugin’s manifest declared the effect that member performs — the declaration is what mints. Whether the run carries approval for that effect is a separate question, and it is answered ONCE, by the engine, before invocation. One of each, in different files. So “no capability re-reads the grant” and “authority flows only from a checked grant” are both true at the same time: the capability layer imports nothing that reads the grant file and calls no read function, and the answer it works from is handed in by the caller that did read it.

The mint is called from exactly one place. invokePlugin is the only function that mints a context, so there is no path from warpline approve — the verb that WRITES the grant — to a capability. The verb that grants authority and the code that hands authority out do not meet. That is not a convention. A test in this repository asserts set equality over every non-test source file that names the mint, so a third one reddens on the day it lands, and a lost one reddens too.

A content-class plugin does not consult this grant at all. A manifest declaring approval_class: 'content' (§ 1) is answered by its own approval record in § 10 and by nothing else — the runtime branches on the declared class ABOVE the grant read, so the grant is not even consulted. That is what keeps a live scopes: '*' grant from composing additively with an approved batch and rendering the approval decorative. Disjointness is a stronger property than ordering, and it is the one being claimed here.

A content-approved plugin also declares autonomy_level: 'autonomous', and that is doctrine rather than a convenience: a content approval IS the review, performed before the bytes shipped rather than after. A plugin asking for a human at fire time as well would ask the same human the same question twice. The manifest schema enforces it at parse time, so the two cannot come apart.

A caller that reads no grant at all — warpline run, which starts a plugin by hand — says so explicitly rather than by omission. It passes the witness arm naming a manual run, and receives only the members that need no approval. The obligation is fixed at the signature: the witness is a required parameter of invokePlugin, so a caller cannot be added without answering. Within one package that witness can of course be constructed by hand; this runtime does not sandbox its handlers, and nothing here claims otherwise. What is bought is that the question cannot be skipped, not that the answer cannot be written.

The review gate, and why the content class is exempt

review_gate is an operator preference, true by default. On that default the engine treats every autonomous plugin as supervised AFTER invocation: it runs, its result is parked in pending_gates, it is recorded gated, and the advance stops after its level. This is independent of the side-effect gate above — it applies whether or not the plugin declares any side effect, with the one exception for a handoff below.

A failed result is never parked. This holds for a promoted plugin and for one declaring autonomy_level: 'supervised' alike, and in a dry run too. The result is recorded failed exactly as an autonomous failure is: in the run log, in plugin_runs, in the board events, the exit code (§ 11) and the dead-man file. The advance does not stop after its level, and a plugin that declares it as a dependency is skipped with dependency_failed. There is nothing to review. The handler’s side effects fired before any gate, and a park would have published the result’s Output to plugin_runs all the same.

A declared handoff from a plugin that declares no side effect is never parked. This holds under review_gate: true and for a plugin declaring autonomy_level: 'supervised' alike, and in a dry run, which reports no pause for it. The result takes the unsupervised path: its run log entry reads completed, plugin_runs reads skipped, nothing goes into pending_gates, and the advance goes on past its level, so a dependent runs as it would with review_gate: false. Its Handoff record is written either way. A handoff from a plugin that declares a side effect is parked exactly as before, and its record is written too. A content-class plugin never reaches the exception, because it declares at least one effect.

Its Output reaches its dependents unreviewed. An Output the handoff’s result carries is published to plugin_runs, and a dependent reads it in the same advance, before anyone has reviewed it. A dependent that declares a side effect still needs its own approval before it runs, and the review gate treats that dependent’s result as it treats any other plugin’s.

A plugin declaring approval_class: 'content' is not promoted. One conjunct on the promotion condition, and nothing else about it changes: every other plugin behaves exactly as before, review_gate: false included.

The argument is that a content approval IS the review. The operator read the exact bytes and said yes before they shipped, rather than being shown the result after. Promoting a content-class plugin would ask the same person the same question twice — and because a parked gate stops the level loop, a plugin doing what it was approved to do would fire, park, and halt the rest of the fleet behind itself on every single advance.

What makes the exemption safe rather than a hole is the manifest’s own cross-field rule (§ 1): a content-class manifest is validated autonomy_level: 'autonomous' at .parse() time, and an invalid manifest is a hard stop at import. So the exemption cannot be reached by a supervised plugin — a manifest declaring both does not load at all.

Merge semantics (warpline approve)

Grants are additive by default. An operator typing approve b after approve a means “and b”, not “instead of a” — losing an earlier grant to a later one is the failure this behaviour exists to prevent.

Rule Behaviour
Scopes Unioned with the live grant and written sorted. A "*" on either side absorbs the other in scopes; the named windows stay in scope_windows.
Windows One per scope. A requested scope that holds a live window keeps its expires_at; a scope without one opens its own window from now, with its own first_granted_at. Every scope the command did not name keeps its window unchanged, so a later approve b --ttl 1h gives b one hour and neither shortens nor lends a standing --long window on a.
expires_at Preserved per scope. An explicit --ttl may extend a scope’s window, never shorten it.
Ceiling Each window’s expiry is capped at its own first_granted_at + 23h. A capped scope reports the cap on its stdout line.
--long Permits an expiry past the ceiling for the scopes named, and prints that it did.
Prior --long window The ceiling never shortens time a scope already holds. mergeGrant caps at max(first_granted_at + 23h, live expires_at) for that scope, so a window opened by an earlier --long survives every later plain approve of it unchanged, and capped is false. Revoke to close it early.
--replace Drops every other scope and resets the requested scopes’ expiries. It never restarts a live scope’s ceiling: a requested scope that holds a live window keeps its own first_granted_at, or repeating --replace would walk the window forward. A requested scope with no live window starts fresh, as on any approve. It never borrows another scope’s anchor, which could hand it a ceiling already in the past.
Unparseable first_granted_at That window is not merged onto. The anchor is what the ceiling is measured from, so an anchor that will not parse is a ceiling that cannot be computed. mergeGrant drops the window and starts that scope fresh, which costs the operator a scope they re-grant in one command rather than handing out a window nobody authorised.
Expired window Not merged onto. That scope’s window has closed; the next grant of it restarts it, with a new first_granted_at. Other scopes are unaffected.
Output One line per live scope, each with its own expiry, then Grant file: <path>.
Default TTL 4 hours.
--all The only path to "*". No positional name is ever treated as a wildcard. It prints the number of side-effecting plugins and the total number of declared side effects it covers before granting.
Issuer The last grant that named a scope sets its issuer, none included. A scope the command did not name keeps its issuer.
Concurrent approve Serialised by the state lock: each invocation checks the principal, records the grant and writes the file inside one hold, so two overlapping invocations both land. Bare warpline revoke holds the same lock, so a revoke and an approve never interleave.
Zero duration Rejected before anything is written. --ttl takes a positive integer followed by m, h or d; a bare 0 fails the grammar and 0h fails the positive-value check. The command exits 1 and the file is untouched.
Empty scope list Reachable only from the library path, which writes an empty scopes array. It approves nothing — an empty list is not a synonym for "*". The command cannot produce one: approve with no plugin name and no --all prints usage and exits 1.

warpline approve appends a grant.issued record (§ 14) after every refusal check and before it merges, carrying the scopes asked for (["*"] for --all), the requested TTL in milliseconds or null, and the --replace and --long flags. When that record cannot be written, it says so on stderr, grants nothing and exits 1.

--principal <id> names who is approving. It must name an active registered principal of either type. It is checked under the state lock, recorded as principal on grant.issued, and never taken from the environment or the account running the command. With a parked result waiting the flag is refused, because a bare apply of a parked result is not recorded with a principal. An apply that answers an Ask (§ 16, ### Answers) takes it, and ask.answered records it.

The 23-hour ceiling belongs to the merge path, not to the file. mergeGrant, behind warpline approve, is the only code that computes it; grantApproval, the programmatic pre-grant, writes the lifetime it was handed with no ceiling logic in it at all. An embedder calling the library directly can therefore hold a grant well past 23 hours, and a grant file’s expiry is not evidence that any ceiling was ever applied. Read “capped at 23h” as a property of the command, never of the format.

An unknown plugin name aborts the whole command, writes nothing, and exits 1 — partial application is not a state the file is ever left in.

Bare warpline revoke deletes the file and exits 0, including when no grant exists. After a revoke, every side-effecting plugin reads as unapproved. It holds the state lock, and never reads or writes the standing grants file. When a grant file exists, revoke first appends a grant.revoked record (§ 14) with kind: session, naming its live scopes. A revoke only narrows authority, so it removes the file even when that append fails, then exits 70 with a stderr line saying no audit record of the revoke was written. With no grant file it writes no record. The standing forms, --holder and --standing, are under Standing grants below.

Nothing reachable from a run writes this file or the standing grants file. The engine reaches both only through the gate’s readers, grantsCovering and listStandingGrants, which open them read-only. The write paths (grantApproval / mergeGrant / revokeApproval for this file, writeStandingStore for the standing grants file) have no caller inside runAdvance. That is a property of the call graph, and tests pin it: a file-graph guard walks every module warpline advance and warpline run reach, dynamic imports and every quote style included, and finds no mention of a standing writer or of the accessor that names the standing grants file, so an aliased import or a reference held for later is caught too; a full advance over side-effecting plugins leaves this file byte- and mtime-identical; and a full advance that fires a plugin under a standing grant leaves the standing grants file byte- and mtime-identical. A name built from pieces at run time is beyond the file-graph guard, and only that last check covers it.

Standing grants

A standing grant is authority an active human issues to an active machine principal over named plugins. It is held in the standing grants file, apart from the session file above, and every read derives whether it is live or lapsed.

Path: <warplineHome>/standing-grants.json, owner-only 0600. One writer writes the whole file atomically and never deletes it: a file holding no grant is written as "grants": [].

{
  "min_reader_version": 1,
  "grants": [
    {
      "id": "3f9a0c5e1b2d",
      "holder": "ci-bot",
      "issuer": "ops",
      "scopes": ["digest-sender", "issue-render"],
      "issued_at": "2026-08-20T09:30:00.000Z",
      "period_start": "2026-08-22T09:00:00.000Z",
      "period_ms": 86400000,
      "hard_max_ms": 2592000000
    }
  ]
}
Field Type Meaning
min_reader_version integer The oldest reader that may interpret the file. Always written, as STANDING_READER_VERSION (1 in this build).
grants array Every standing grant, sorted by id.
id string 12 random lowercase hex characters. Issue refuses an id already in the file.
holder principal id The principal the grant authorises. It must be an active machine in principals.json (§ 15) for the grant to be live.
issuer principal id Always the active human who issued it. Never the holder.
scopes string[] The plugin directory names it covers, sorted, without duplicates. Never "*": a standing grant names every plugin it covers. Each is a plugin name as the audit store’s records take one: 1 to 255 characters, none of them a C0 control character, DEL, / or \. That carriage rule is looser than the admission rule a plugin’s own name follows (§ 1), on purpose: a grant or record written under a name the loader no longer admits stays readable.
issued_at ISO 8601 string When it was issued. The hard maximum is measured from here.
period_start ISO 8601 string Set at issue, and moved only by a renewal. The renewal deadline is measured from here. Never before issued_at, and never after the hard maximum.
period_ms integer The renewal period in milliseconds. At most 7 days, and 24 hours when the issuer names none.
hard_max_ms integer How long after issued_at the grant can stay live, however often it is renewed. At most 90 days, and at least period_ms.

Deadlines are derived from these fields, never stored.

The live rule. A standing grant is live at now when all four hold:

Both instants are inclusive, like a session window’s expiry. Live or lapsed is derived on every read from the fields, the clock and the registry. Nothing ever writes a lapse into the file.

A grant that is not live is lapsed, for one of seven reasons. When several apply, the first in this order is the one reported:

  1. hard max: past the hard maximum.
  2. not renewed: past the renewal deadline.
  3. future dated: period_start is later than now. A clock that ran ahead when the grant was issued or renewed, then was set back, leaves one. It turns live on its own, with no renewal and no human act, once the clock reaches period_start.
  4. registry unreadable: no usable registry was read (below). A missing principals.json is one: it says nothing about any holder.
  5. holder not registered: the registry was read and does not name the holder.
  6. holder not machine: the holder is a human.
  7. holder disabled: the holder is a disabled machine.

hard max, not renewed and holder not registered are final: no verb clears the lapse, and only a new grant fixes it. A holder missing from principals.json does not come back through § 15’s verbs: they never delete an id, and principal add refuses every known id, which takes in every id a standing grant names, and refuses every add while it cannot tell. A hand edit that writes the holder back into principals.json does clear the lapse. The store records that edit as principal_registry.observed (§ 15) before an advance or a verb acts on the file. registry unreadable, holder not machine and holder disabled are not final. Such a lapse clears, with no renewal, if principals.json names the holder an active machine before the grant’s next expiry, the earlier of its renewal deadline and its hard maximum. After that expiry the lapse is final. future dated is not final either. It clears by itself when the clock reaches period_start, which the read keeps at or before the hard maximum.

The caps hold for every stored grant. The caps are constants in code, never preferences, and no file can raise them. The read refuses a grant whose period_ms or hard_max_ms is over its cap, or whose period_start is before issued_at or after the hard maximum, and a grant whose period has not started is lapsed. So a grant is live only inside its current period and before its hard maximum, whatever its stored timestamps say: at most 7 days from any read to its renewal deadline, and at most 90 days from issued_at. The caps bound how long a grant is live, never when. Both are measured from the stored timestamps, which are the readings of the clock that issued or renewed the grant. A grant issued by a clock running ahead by some lead covers nothing until the clock reaches its period_start, then turns live on its own, and its renewal deadline and hard maximum fall that lead later than a right clock would have set them. The read cannot tell a clock’s lead from a date meant, so warpline plan names the moment such a grant turns live (below).

A lapsed grant stays in the standing grants file until revoked. Nothing collects it.

The whole file or nothing. The standing grants file is read whole. Any malformed entry, a cap exceeded, a scope that is not a plugin name, a period_start outside issued_at and the hard maximum, a duplicate id, or a min_reader_version this build does not read makes the whole file unreadable, so one bad entry takes every grant in it down, and then no standing grant is live. A missing file holds none. No read throws.

Reader version. min_reader_version is read before any other field, and a file asking for a reader newer than STANDING_READER_VERSION, 1 in this build, is refused whole even when its grants would not parse here. The version is the standing grants file’s own, apart from the session file’s GRANT_READER_VERSION. Because the standing grants file sits apart from the session file, a build that predates it reads its session grant and no standing grant: a rollback drops standing authority and keeps the session.

The registry, once an advance. The gate never reads principals.json; the engine hands it a snapshot. An advance reads and records principals.json once (§ 15), after taking the run lock and only when the standing grants file holds a grant, so a home without one gains no audit line. A record the audit store cannot take ends the advance with 75 (§ 11) before anything fires. The snapshot holds for the whole advance, so a principal disabled during an advance takes effect at the next one. When the standing grants file held no grant at that check, a grant issued after it lapses as registry unreadable for that advance. A missing or unusable principals.json lapses every standing grant as registry unreadable. warpline plan reads the registry without recording it, so its approved: column agrees with the run. An advance given AdvanceOptions.home reads that home’s standing grants file and principals.json, and records the registry in that home’s audit store (§ 1).

What warpline plan shows. Under the session grant line, plan prints a Standing grants (<n>): heading and one line per standing grant:

  3f9a0c5e1b2d — holder ci-bot, issuer ops, scopes digest-sender, issue-render — next expiry 2026-08-23T09:00:00.000Z — live

The next expiry is the earlier of the renewal deadline and the hard maximum. A lapsed grant’s line ends lapsed (<reason>), with one of the seven reasons above, except a future dated one. That lapse ends with no human act, so its line ends lapsed (future dated, live from <period_start>). The lines come in the gate’s order, by next expiry and then by id, and no line shows a principal’s key. The read refuses a scope that is not a plugin name, so no scope holds a C0 control byte or DEL. A plugin name as the store carries one can hold a C1 byte (the loader admits none, but the store reads history written before that rule), so plan escapes every control byte, C1 included, as \xNN, and a backslash doubles. No scope reaches the terminal as bytes it acts on. With no standing grant the section is absent. A standing grants file that cannot be read, for any cause, prints the one line Standing grants: none live — the standing grants file cannot be read, never an absent section, and shows no cause.

The header names the session grant: Session grant: <scopes> — <n>m remaining, — expired, or with none live Session grant: none — plugins with side effects would be SKIPPED this run. That last sentence adds unless a live standing grant covers them exactly when the standing grants file was read and lists at least one grant, live or lapsed. A file that cannot be read gets the plain sentence, since its own line already says none is live.

Issuing. warpline approve <plugin>... --standing --holder <machine-id> --principal <human-id> --hard-max <dur> [--period <dur>] issues one standing grant. On approve, --standing names the kind of grant. On revoke, --standing <grant-id> names one grant.

Every refusal exits 1 and writes nothing. The command appends grant.issued with kind: standing (§ 14) before it writes the standing grants file, so a failed append issues nothing: it says so on stderr and exits 1. It never applies a parked result. On success it prints the grant’s id, its renewal deadline and hard maximum, and the standing grants file’s path.

Renewal. warpline renew <grant-id> --principal <human-id> restarts one standing grant’s renewal period from now.

Every refusal exits 1 and writes nothing. The command appends grant.renewed (§ 14) before it writes the standing grants file, so a failed append renews nothing: it says so on stderr and exits 1. On success it prints the new renewal deadline, the hard maximum and the next expiry, the earlier of the two. A renewal close to the hard maximum sets a renewal deadline past it, and the grant still lapses at the hard maximum. Renewal never touches the session file.

Revoking. warpline revoke has three forms. Bare, it clears the session grant and never reads or writes the standing grants file (above). warpline revoke --holder <principal-id> revokes every standing grant that one principal holds, and no other grant. warpline revoke --standing <grant-id> revokes one standing grant by its id. Here --standing takes a grant id; on approve it names the kind of grant. --holder and --standing together are refused.

The standing grants file is read whole, so one malformed grant makes every standing grant in it unreadable, and then both standing forms refuse with nothing written. The refusal names the cause: the file is not JSON or holds a malformed grant, a newer warpline wrote it, or the file system refused the read. The one way out is to move the file aside, which drops every standing grant in it at once. A file a newer warpline wrote must not be edited: lowering its min_reader_version would let this build act on grants it does not understand. Revoke with the newer warpline instead.

A grant written by hand. A standing grant written into the standing grants file by hand carries no grant.issued record. The file is not compared with the audit store the way principals.json and preferences.json are.


10. Engine state

~/.warpline/state/engine-state.json is the single JSON document the engine persists between runs. It is operator-owned and hand-editable, which is the whole reason the read policy below is written down rather than inferred.

Read policy

There are two reads and they behave differently on purpose.

Read Used by Missing file Unusable file
Write-capable Anything that may go on to write state — an advance, the task board Defaults Refuses: names the path and the reason, exits non-zero, changes nothing on disk
Read-only Commands contracted never to write, warpline plan above all Defaults Defaults

One refusal is shared by both, and it is the only one: a format version this build does not understand. See Format versions below.

The write-capable read fails closed because the alternative is worse than a failure. Returning defaults from an unreadable document means the next write persists those defaults, and the operator’s task history, deferrals and completed tasks are gone with nothing to recover them from. A document we cannot read is a document we must not overwrite.

Nothing on either read path writes. There is no {path}.corrupt copy any more — that backup existed only to preserve evidence before defaults destroyed it, and refusing preserves the original in place instead. A read-only command that hits an unusable document degrades its output; it does not leave a file behind in the operator’s home.

A missing file is not an unusable one. A fresh install has no state document and both reads return defaults, so failing closed does not break first run.

Format versions

There are two, and they answer two different questions.

Version Where Question it answers
schema_version a field inside engine-state.json what SHAPE is this document
the home layout version ~/.warpline/version what LAYOUT is this home

Both are currently 2. The layout version is stored at the home root rather than under state/, because a layout-level fact filed inside state/ would be covered by the per-file versioning it exists to describe.

schema_version

Read tolerantly: any non-negative integer parses, so a build reading a file one version behind still loads it.

One version is refused. A schema_version above the newest this build knows is reported as your build is older than this file — a distinct message from the corrupt-document one, because the operator’s fix is different. Upgrade warpline rather than letting an older build rewrite a newer document down to the fields it happens to understand.

A schema_version that is not a non-negative integer — a fraction, a negative number — is not a version at all and is refused as an unreadable document, never treated as an older one to load tolerantly.

~/.warpline/version

A bare integer and nothing else. Exactly one trailing newline is trimmed before the format is checked, so 2 and 2\n are the same file; ` 2, 2.0, 2a and two` are not versions and are refused as unreadable.

Unreadable is a different outcome from older, deliberately. An unreadable version file is not treated as version 1 and migrated over — migrating over it would destroy whatever the file was trying to say, and a file the runtime cannot interpret is the last thing it should overwrite.

A missing file means version 1. Every home written before this file existed is a version 1 home, and there is nothing to migrate to make that true.

Refuse-newer, on both read policies

A format version above the newest this build knows — either one — aborts with a non-zero exit and a message naming the version found and the highest version understood. It never degrades to defaults, and it never skips.

This is the one place the read-only policy does not return defaults, and the asymmetry is the point. Defaults are an honest answer about a document this build cannot READ: the preview says so and shows nothing. They are a dishonest answer about a document a NEWER build wrote, because that home is not empty — it holds state this binary is too old to see. warpline plan rendering such a home as empty and healthy would tell an operator that an approval they granted does not exist.

warpline plan aborts here too, and it is the only thing it aborts on. The preview degrades around every other failure — a plugin directory that will not load, a state document it cannot parse — because a partial answer about a home it can read beats no answer. It exits 1 on a format version it does not understand, with the same message on stderr and nothing on stdout, so a script reading the exit code is not handed a plan-shaped document describing a home this build cannot see. That 1 is the usage-error row of the exit-code table in § 11: the command ran nothing and wrote no plan.

The refusal carries its own error type, separate from the unusable-document one. warpline approve and warpline deny report an unusable document as “Cannot read engine state”; reusing that type would have them report a perfectly well-formed newer home as corrupt, sending the operator to the wrong remedy.

Migrate-on-write, and the backward-compatibility promise

An advance stamps the current version on both files — the document’s schema_version and ~/.warpline/version — in the same locked region as the end-of-run state write. That is the only migration site; the board’s state writes never stamp a version, because they never read the layout one.

The promise runs one way. A build reads any format version at or below its own and refuses anything above it. So an older home upgrades silently, and a newer home stops an older build instead of being quietly rewritten down to the fields that build happens to know.

The consequence is a one-way door, stated plainly: once an upgraded build has completed a single advance against a home, every earlier build refuses that home from then on. There is no supported rollback, and that is the intended behaviour rather than a gap. Corrections are forward only — a deprecation plus a patch version, never an unpublish.

~/.warpline/version’s format is a permanent on-disk contract. It cannot be changed later without a third version mechanism, which is why it is the narrowest thing that could work.

Unknown top-level keys

Unknown top-level keys round-trip. A field a newer build wrote survives being read and rewritten by an older one, so a rollback does not silently delete it.

The accepted cost: a typo’d top-level key round-trips silently instead of failing validation loudly. The named fields stay strict, so a typo surfaces as a missing value rather than as a rejected file.

plugin_runs

A record keyed by plugin name, holding the last run of each. It is what the TTL staleness check reads. That check consults last_run_at, and status for one rule: an entry recording failed is not fresh, so the plugin is due at the next advance whatever its ttl_hours. ttl_hours is how long a success stays fresh, and a timeout is recorded failed like any other failure. partial, skipped and gated entries age by last_run_at as before. A plugin that keeps failing is therefore retried on every advance; the scheduler’s interval is the backoff.

The dueness evaluator is a second reader of the record, and the first reader for which status decides whether a plugin runs at all. A plugin holding a declared dependency whose entry here records failed is not due, for the reason dependency_failed, and is recorded skipped with a summary naming every such dependency in manifest-declared order. A plugin whose declared dependency an earlier level of the same advance held back for this reason is held back too. That dependency is named '<name>' held back by a failed dependency, after any that recorded failed, separated by ; : dependency failed — 'a' last recorded status 'failed'; 'b' held back by a failed dependency. A plugin whose dependencies only recorded failed reads exactly as before. Until that gate existed, the dependent ran and read whatever the failed producer had left behind on an earlier cycle — a diff-against-history consumer then reported “no change” for a cycle in which nothing was observed, and no field distinguished the two.

There is no data migration. The field is read, never written or reshaped, and no schema changed. What does change on upgrade: an existing home already carrying a failed status for a scheduled dependency begins gating that dependency’s dependents on the first advance afterwards. That is the correct behaviour and it arrives without a migration step, so it arrives unannounced.

Field Type Meaning
last_run_at ISO 8601 string When the run ended
status success | partial | failed | skipped | gated How it ended
duration_ms integer, optional Wall time for the run
last_output Output record, optional The most recent Output this plugin produced. Its content may have been erased; see § last_output
run_id string, optional The run that wrote the entry; for an applied gate, the run that parked it. A last_output whose run_id differs was carried forward from an earlier run, because the latest one produced no Output (§ last_output). An entry without it, written before the field existed, reads as produced

gated records a supervised plugin that ran and was parked pending approval. It is written when the plugin is parked, anchored at the gate’s completion time — a later approval is a separate event and does not move when the work happened.

It is recorded as a run because it is one. The handler is invoked, and its declared side effects fire, before the supervision gate sees the result at all; the gate decides what happens to the result, not whether the work happened. A parked run that recorded nothing left the plugin due on the next advance, so its side effects fired again — every advance, for the whole grant window, on one approval.

A supervised run whose result is failed is not parked, and records failed (§ 9).

skipped records a run whose handler returned skipped — in practice every dispatched handoff from a plugin declaring llm_handoff: true, since that is the only path producing one today. A handoff from a plugin that does not declare it is refused and records failed. The plugin’s own terminal status is written through unnarrowed, so a handoff is not folded into success: a consumer reading lastRun beside a carried-forward last_output would otherwise be told “produced, and its latest run is healthy” about a plugin that handed its work to an LLM and produced nothing. This is not the delegated of deriveRunStatus, which answers a different question for the run artifact and the board events; a plain skipped and a handoff lead a consumer to the same action, so this field does not distinguish them.

A run whose invocation threw is recorded here too, as failed. Only invokePlugin throwing out of itself reaches that path — a handler that throws is caught inside and returns a failed result through the ordinary write — and the reachable cause is a config file that exists but cannot be read. Recording it is what keeps status a fact about the last run: without the write, the previous run’s entry stayed and lastRun named a run two advances back.

The status set is closed. Adding a member fans out into this document, and into every operator state file written afterwards, which is why it is not extended casually.

What the dependency gate does not cover

Transitive within an advance. In a chain A → B → C, a B held back writes no run record, so the hold is not stored. B’s own recorded status stays whatever it last was, very likely success. The hold is derived again on every advance, level by level, from A’s failed record: B is held because A recorded failed, and C is held because this advance held B. This holds for every consumer class. A chain member that is still fresh is not due, holds nothing, and its dependents read its fresh Output, which is what its TTL promises. On upgrade, more plugins read dependency_failed than before, in every fleet: a scheduler or dashboard keyed on skip counts sees the change. No field, status or board event changed.

Four limitations, written down here rather than left for a reader to discover.

The latch, and how it clears. The gate reads the LAST run’s status, so a dependency whose last run failed gates its dependents until it runs again without failing. In the ordinary case it self-clears on the very next advance: a failed run is never fresh (§ plugin_runs), so the dependency is due, it runs, its entry is overwritten, and its dependents are due again. It cannot be cleared by hand — warpline run invokes one plugin standalone and writes no run record, so a manual run of the failed dependency leaves the latch exactly where it was. It is genuinely sticky only for a dependency that has stopped being scheduled at all: a manual dependency nobody invokes under an advance, one filtered out by the active profile or tier, and — the worst case — one deleted from the plugin directory outright, whose stale failed record outlives its manifest and can never be overwritten. A dependent declaring a dropped dependency is then gated permanently. Editing engine-state.json is the only way out.

A manifest that never loaded is a blind spot. A plugin whose manifest.ts fails to import is recorded as a failed run-log entry and a failed engine state, but it writes no plugin_runs record at all — nothing ran. Its dependents are therefore not gated. Fixing it here would mean writing a run record for a plugin that never ran, moving a last_run_at for a run that did not happen, so the gap is named rather than closed.

A declared dependency that is not installed does not gate. A name in dependencies with no plugin behind it has no run record, and an absent record is not a failed one. The engine warns about the unresolved name at load time and topoSort ignores it for ordering; the gate deliberately adds no second roster check of its own, because that would be a second dependency signal answering the same question.

warpline plan and an advance can disagree, in one direction only. The gate reads a run outcome, and a preview does not run anything. plan walks levels against the state document as it sits on disk; an advance evaluates against a plugin_runs its own level loop is overwriting as it goes. To keep the preview from publishing a skip for every dependent on the self-clearing path above, the evaluator takes an optional dueAtEarlierLevel set — the plugins an earlier level of the same preview already found due — and does not gate on a dependency in it. Only plan supplies one; an advance leaves it undefined, because its state is already the answer. The preview derives the second hop from its own dependency_failed verdicts, so it reports one only where it already reported the first, and the direction below is unchanged.

That assumes a due producer clears its latch, which plan cannot know. A producer that is due and fails again leaves plan reporting a dependent due where the advance skips it, and reports that dependent’s own dependents due where the advance holds them too. The reverse can no longer happen: the due set only ever removes a dependency_failed verdict, and the held set adds one only behind a verdict the advance also reaches. The direction is the point. This runtime asks a human to approve side effects on the strength of what the preview showed, so a preview that under-states an advance is the input to a wrong answer, and one that over-states it is only a plugin that did not run. Pinned by plan.test.ts Test 2b.

The content gate over-states the same way. A content-class consumer whose approval reads content_moved, for carried, erased or moved bytes, while its declared producer is due at an earlier level of the preview, is listed due with the line may fire if '<producer>' re-produces the approved bytes this advance. The producer’s run decides the standing, and a preview cannot know what that run will produce. The evaluator’s verdict stays not-due and carries a hint that only plan reads, so an advance never fires on it without a content authority. A producer that produces other bytes, or none, leaves plan reporting the consumer due where the advance refuses it content_moved. Pinned by plan.test.ts Tests 2d to 2f.

pending_gates

A supervised plugin’s result parked pending a human answer. There is at most one entry per plugin, and a fresh park replaces it. An advance that parks nothing for the plugin leaves its entry in place, for example when the plugin was not due, its result failed, or its invocation threw. So the entry can come from an earlier advance than the most recent one, and such an older gate is refused as superseded when applied (§ 10, “Applying a gate”, step 2). An entry another writer applied or discarded while an advance ran stays as that writer left it (§ 12).

Field Type Meaning
plugin string The gated plugin
run_id string The advance that parked it
created_at ISO 8601 string When the gate was written
payload_summary string The result’s summary, for a one-line render
plugin_result Stored skill result The REAL result the handler returned, Outputs and all. An applied gate’s copy of released content is erased, not removed (§ 10)
run_started_at ISO 8601 string or null When the gated run started
run_completed_at ISO 8601 string or null When the gated run ended
applied_at ISO 8601 string or null When the gate was applied; null while live

plugin_result is the result the plugin actually returned. Earlier builds stored a fabrication here — status: 'partial', an empty artifacts_produced — and dropped the real thing. Approval is acceptance of an observed outcome, so a gate that does not carry the outcome cannot be approved in any meaningful sense. Its Outputs are stored records (§ 5). When erasure releases content an applied gate recorded, the gate’s copy is erased in the same write and kept as a record marked erased_at, so the gate still says what the run produced. A gate still pending keeps its copy.

run_completed_at is written from the same string as the plugin_runs entry the engine writes on the same branch, not from a second clock read. The two must not disagree by a millisecond: the approve verb anchors plugin_runs.last_run_at at the gate’s copy.

Both clocks null means the gate is unusable. A gate written by a build older than this one carries neither, and no real result behind them. Such a gate is discarded when the state document is read — on the write-capable read, with a notice naming the plugin appended to events.jsonl; on the read-only read, silently, because a command contracted to write nothing may not append to a log. It is never applied. This is the one deliberate data drop in the format: there is nothing to migrate, because the real result was never recorded.

Applying a gate

One predicate says whether a gate can still be applied. A gate can be applied while it is unapplied, carries both run_started_at and run_completed_at, is not superseded (step 2), has no dependency that moved (step 3), and has not expired (step 4). Age is measured from run_completed_at against the earlier of the plugin’s ttl_hours and 23 hours, and a gate exactly at that ceiling can still be applied. Steps 1 to 4 below are its refusals, in order. Between steps 1 and 2, a gate missing either clock (unclocked) is refused as expired, and nothing is written. The advance’s count of waiting gates (§ 13), its parked-result Asks (§ 16) and approve’s choice of which decision an Answer answers read the same predicate. The count reads it at the wall clock when the advance’s hold writes, the Asks at the later of that clock and the advance’s own (§ 16), and approve at the wall clock when it runs. Time only takes a gate from applicable to not, so no Ask is raised for a gate the count does not treat as waiting.

A bare warpline approve <plugin> takes the apply path whenever the plugin has an unapplied gate and no Ask its form answers is open (§ 16), so steps 2 to 4 refuse and discard one that can no longer be applied, and no Grant is written. An Answer takes it only for a gate that can still be applied (§ 16, Answers). What that does, in order, all decided before anything is written:

  1. Already denied — denials[plugin] exists and its fingerprint still matches the live proposal. Refused, and nothing is written. deny and approve answer the same proposal, so applying a result the operator explicitly refused is the one gesture the denial record exists to make impossible; without this check it succeeded silently and left a live denial and an applied outcome for the same proposal in the same document. A superseded denial does not block — it is already stale everywhere else. The refusal names the denial and says to take it back with warpline deny --remove <plugin>.
  2. Already applied (applied_at is set) — there is no live gate, so the verb does not enter this list at all. It prints a note naming the run whose result was already applied and then answers the Grant gate, granting the plugin permission to run again. A result is still recorded exactly once: applyPendingGate checks applied_at itself and refuses a second apply, and the verb simply never reaches it. The refusal narrows to a second apply; the verb’s answer to a spent marker is a Grant that says so.

    The branch is chosen on “is there a live gate”, not “is there a gate”. Branching on mere existence locked the Grant verb out for as long as the marker lived — up to 23 hours — so an operator whose Grant expired after an apply saw the plugin skipped as unapproved on every advance and could not renew by name, with only the far wider --all still working.

    The gate is marked rather than deleted so the note has a run to name and so deny can tell an accepted result from a pending one. Neither needs the bytes. When erasure releases the content the gate recorded, the same write erases the marker’s copy of it (§ 10, “Expiry and deletion”), and the marker stays. A gate survives the next advance, applied or not, and is dropped only when it passes the 23-hour gate ceiling or when the plugin gates again and the new parked gate supersedes it. One rule covers the whole array; the clock differs because the question does. A marker ages from applied_at, the moment the result was accepted. An unapplied gate ages from run_completed_at, the moment the run produced it — the same clock the expiry check uses. A gate carrying no run_completed_at never survives, since it is refused at apply time anyway. The advance applies this rule to the gates the document holds when it writes, not to the ones it read at its start. A gate applied while it ran survives as a marker, and a gate discarded while it ran stays discarded.

    Both halves were once overwritten wholesale, and the split that replaced it kept markers while still discarding parked results. Nothing chose that: a daily engine destroyed the previous day’s proposal before anyone could review it, and the 23-hour ceiling was unreachable in live operation — a limit only a seeded clock could observe.

  3. Superseded — the plugin’s plugin_runs entry names a run other than the one that parked this gate, so the plugin ran again after the park. A failed run, or one whose invocation threw, parks nothing, so it does not replace the older gate, but its entry is the plugin’s latest record. Refused, the gate is discarded with a notice (gate_invalidated, reason superseded), and the entry is kept as the later run wrote it. Checked before the next two because both delete the entry. Only identity is compared, never order (§ last_output). An entry with no run_id, written before the field existed, and no entry at all are applied as before.
  4. A dependency moved — some dependency’s plugin_runs.last_run_at is newer than run_started_at. The parked result was computed against inputs that have since changed, so it is refused, the gate is discarded, and a notice naming the plugin is written.
  5. Expired — the gate is older than the earlier of the plugin’s ttl_hours and 23 hours, measured from run_completed_at. Refused and discarded, with a notice. This is a state transition the approve verb makes, not something a renderer infers, which is what stops an approval and an expiry racing into a double apply.
  6. Otherwise applied. The plugin’s plugin_runs entry is overwritten in place: last_run_at stays at run_completed_at, the status becomes the result’s real terminal status, run_id is the run that parked it, and last_output carries the Output the run already produced. applied_at is stamped on the gate. If that Output’s content was erased while the gate was pending, the erased record stays, because erasure is one-way and the binding that released it is gone. The same write erases the gate’s copy of those bytes.

    The entry overwritten is always the one the parking run wrote, because step 2 refused any other.

On a moved dependency or an expiry (steps 3 and 4) the plugin’s plugin_runs entry is deleted, which leaves it due on the next advance. The parked result was never accepted, so there is no accepted run to hold the work back; the gated entry existed to stop the effects re-firing during the hold, and the hold is over.

The delete takes last_output with it, and that loss is permanent. The pointer lives inside the entry, and the carry-forward described in § last_output works by reading the entry it is about to overwrite — with no entry there is nothing to carry, so the key returns only from a fresh Output on a later advance, never as the record that was deleted. The plugin being due again is a re-run opportunity and not a repair: a re-run that also produces no Output leaves the plugin reading as having run and never produced. What IS bounded is the trigger. This path fires only on the two refusals above — a dependency moved, or the gate expired — and the delete is skipped entirely while either a denial or a content approval is live against the plugin.

The plugin_runs entry is kept while a denial is live, and the denial is left exactly as it was. Deleting the entry is what makes a plugin due again after its inputs moved, and proposalFingerprint reads that entry’s Output — so the delete moves the fingerprint the denial is bound to, the answer stops matching, and the plugin runs again on the next advance, re-firing the side effects the operator refused. Silently, under a live Grant: the superseded-denial note only rides the unapproved arm, which a denied plugin never reaches.

The delete is also pointless in that case. A denied plugin does not run, so making it due achieves nothing; breaking the binding is the only thing it does.

Keeping the entry means the denial stays bound to the real proposal. It lapses on its own if the plugin ever genuinely re-runs with a different Output, which is what a proposal-bound answer is for. An earlier design re-bound the denial to hash(plugin, side_effects, []) instead — a value nothing can move, since a suppressed plugin can never produce a new Output — which made the denial permanent by name and required rewriting its reason to say so. Neither the permanence nor the rewrite is needed once the entry survives.

The entry is kept while a live content approval references the plugin, on the identical argument. An approval is a standing yes to specific bytes, and those bytes are a last_output. Delete the entry that holds them and the fingerprint the operator’s yes was bound to can never be recomputed: the record survives, matches nothing, and the answer becomes unhonourable — permanently, because the destroyed Output never returns. A gate belonging to one question would have silently voided the answer to another.

The reference runs both ways, so this is a scan and not a lookup. An approval is keyed by the CONSUMER and the bytes belong to the PRODUCER, and the plugin whose gate is being discarded is protected when it is named as either. The producer case is the one the delete actually destroys.

Only a live approval protects. One whose window has closed, whose bytes have moved, or which is already spent leaves the delete to go ahead exactly as before — a stale answer protects nothing, here for the same reason a superseded denial does not. A binding that still holds over erased content does protect the entry, although the gate refuses to fire it (content_moved): deleting the entry would make the producer read as never having produced (§ 10, last_output). The carve-out reads the binding through the function the gate’s own read is built on, so nothing re-derives the predicate, and nothing here decides whether anything fires.

The protection lives in applyPendingGate rather than in its caller, deliberately. approve refuses on a live denial before reaching that call, so no CLI gesture arrives here with one standing — which is precisely why a guard placed in the caller would protect nothing today while being the thing a second caller tomorrow silently depends on.

applyPendingGate takes the plugin manifest map as a required argument for this reason: the approval names a producer, and that producer’s manifest is what the fingerprint is computed over. It is required rather than defaulted because an absent map answers “no approval” for every record, which is the destructive direction.

The apply runs inside the state document’s lock. warpline approve’s named path takes that lock around its whole read-modify-write — the state read, the gate dispatch, and the apply loop — using the same derived lock path the --content branch uses. It wraps the loop rather than each call because applyPendingGate writes state per call, so several gated plugins are several read-modify-writes over one in-memory document and a lock released between them reopens the window. applyPendingGate itself takes no lock; the acquire is non-reentrant, so one there would nest and deadlock. Without this, a warpline approve --content landing from a second attachment between the read and the apply was overwritten.

A superseded denial does not hold the entry. It is already stale, so the plugin becoming due again is the correct outcome and the delete goes ahead.

An Answer drops a dead gate and keeps the entry. An Answer to the plugin’s missing_grant Ask, given while an unapplied gate that can no longer be applied is on file, removes that gate from pending_gates in the same hold, after the Answer is recorded. It writes a notice naming its reason (gate_expired for an expiry, gate_invalidated otherwise), leaves plugin_runs[plugin] as it is, last_output included, and then merges the Grant. It is the drop the gate ceiling would make later, made when the operator answers.

Denying a parked result discards it

warpline deny <plugin> discards the plugin’s live parked gate as it records the denial. Answering a parked result is what dequeues it, exactly as applying one does. The denial’s reason says the operator declined that run, and leaving the run in pending_gates made that sentence describe something that had not happened.

It could not legitimately be applied afterwards either. While the denial holds, both approve arms refuse it. Once the denial is superseded the proposal has moved — which means plugin_runs.last_output changed, which means the plugin ran again and a newer gate exists — so the old one answers a question nobody is asking. The only path that ever reached it was deny --remove, which would hand back a stale result the operator had already declined, in answer to a question they believed they were re-opening.

Nothing a reader depends on is lost. Whatever the park wrote to plugin_runs[plugin] stays, last_output included, and the run log keeps a summary of the run, never its result. pending_gates is the review queue, not the record.

An applied marker is left alone. It is the trace of a result the operator accepted, and it is the only thing stopping a second approve from re-recording that result — so a later denial, which answers the standing proposal rather than that outcome, must not erase it.

A parked result that was a handoff has a Handoff record, and deny withdraws it with the gate. That run’s record goes from open to withdrawn, after the denial.recorded audit record and before the state write (needs-llm-contract.md). deny reads each such record before that audit record, and one that cannot be read stops the command there: deny exits 1, records no denial, and names the file. A record that cannot be rewritten stops the command after the audit records, with exit 1, and a state write can fail after the close. Either way no denial is recorded, and deny names each record it already withdrew, which stays withdrawn. The gate stays parked and is answered as any parked result is: approve reads the gate alone, so approving it applies the run and routes no judgment for it, and denying it again records the denial. An applied marker’s record stays open, for the reason the marker itself is kept.

deny says so on stdout, because the consequence is not visible in the state file: taking the denial back re-opens the question rather than re-offering the run.

The handler is never re-invoked. Its declared side effects fired at invocation, long before the supervision gate saw the result, so re-running would double effects that already happened. Downstream dependents run on the next advance under the normal guard chain, not from inside the CLI command.

Granting a plugin that is denied

Both arms refuse on a live denial. Step 0 above refuses an apply; the Grant path refuses too. approve <plugin> writes nothing, exits 1, and names the denial’s timestamp, its reason, and warpline deny --remove <plugin>.

The denial check in evaluatePlugin sits before the approval gate, so a denied plugin is skipped as denied on the next advance no matter what is granted. A grant written here buys the operator nothing and widens side-effect authority to get it, and reporting exit 0 and Approved 1 scope for a plugin that will not run is the gate claiming a success it did not achieve.

The two arms answering the same standing differently was itself the defect: one denial produced exit 1 on a parked result and exit 0 without one.

This is a refusal with a way out, not a lockout — warpline deny --remove retires the answer standing in the way, and the refusal names it. Every name is checked before anything is written, so a refusal leaves nothing on disk. When several names are denied, all of them are reported and the command refuses once.

A superseded denial does not refuse — it is already stale everywhere else, and refusing on it would strand the operator behind a question that no longer exists.

--all narrates rather than refuses. It is a breadth gesture — the operator did not name the denied plugin — so refusing the whole command over one denial would answer a question they did not ask. The blanket grant is written, the coverage line counts only plugins that can actually run, and a note names each plugin that stays denied along with warpline deny --remove.

That check takes a READ-ONLY, tolerant read of the engine state, unlike the write-capable read on the named path. --all cannot park a result, so an unreadable state document is not the wrong-gesture hazard it is there. The note is advisory: a read that fails costs a sentence, not the command, and --all grants exactly as it did before.

Grant-clock flags on an apply

--ttl, --replace and --long set a Grant clock. Applying a parked result records an outcome and writes no grant, so there is no clock for them to set. They are reported as ignored, named individually, on stderr. Nothing about the apply changes — the note describes what happened rather than altering it.

denials

Where a human’s “no” lands, so the next advance reads it instead of asking again. A record keyed by plugin name, sibling to plugin_runs.

Field Type Meaning
plugin string The denied plugin, stored as a field as well as being the key
reason string Why the engine is not asking, rendered on the next plan
denied_at ISO 8601 string When the answer was given
note string or null The operator’s own words, if they gave any
fingerprint hex sha256 string The proposal this answered, whole and untruncated

A record, not an array. That gives one live denial per plugin by construction: denying the same plugin again lands on the same key, so there is nothing to accumulate and no de-dupe scan to get wrong. It also makes a fleet-wide denial inexpressible — there is no key that means every plugin. deferrals is an array because a task can carry several; a denial cannot.

A denial is bound to a proposal, not to a plugin. The fingerprint is hex sha256 over the plugin’s name, its declared side effects, and the Outputs it produced. Each Output enters as its semantic type plus either its path or a hash of its inline body — type included, because turning the file at report.md from a draft into a report is a change of proposal, and without it a denial recorded against the draft went on silently suppressing the report. It is recomputed on every advance and compared: while it matches, the plugin is not due and the question is not asked; when it moves, the plugin is due again and the answer that comes back says a denial existed and that the proposal changed. A denial that outlived what it was answering would suppress a question nobody has answered.

Both hashed sets are sorted before hashing, so reordering the side_effects array in a manifest — an editing accident, not a change of proposal — does not re-raise an answered Ask. The plugin name is inside the hashed object as well as being the record key, so two plugins with byte-identical payloads produce different values and no denial can answer for another plugin’s proposal. An inline Output enters by a hash of its body rather than by the body itself, which bounds the fingerprint whatever the inline cap allows and keeps Output content out of the record. An erased Output enters by the body_sha256 stored when its body was erased. That is the same digest, so erasure never moves a fingerprint and never re-raises an answered Ask.

The Outputs hashed are the ones in plugin_runs[plugin].last_output, not the ones in a parked gate. A gate now outlives the advance that parked it, so the original reason — that one would vanish a day later — no longer holds as stated; the choice does. A gate is still the shorter-lived record of the two: it is marked spent on apply, and discarded on denial, when superseded, and at the ceiling, while plugin_runs outlives all four. Binding an answer to the longer-lived record is what keeps a denial from expiring for a reason the operator never sees. The narrowing that buys: last_output is the last Output of the run, so a change confined to an earlier Output of a multi-Output result does not re-raise.

A plugin with no declared side effects and no recorded Output hashes the empty sets. That is a stable value scoped by its name — it is denied by name — not an error.

A run that produces no Output no longer moves the fingerprint. It leaves last_output as it was (§ last_output), so a denial recorded against a real proposal stays bound to it across a producer’s failed run, rather than being superseded by the empty-set hash the same plugin would otherwise fall back to.

Both directions are on the audit record (§ 14) before this table changes. warpline deny appends one denial.recorded per plugin with its fingerprint, and warpline deny --remove appends one denial.lifted per plugin, in the order named, with the stored fingerprint (null when it is not a hex sha256). Either refuses with the state document unchanged when an append fails. Either takes an optional --principal <id>, which must name an active registered principal. It is checked under the state lock, recorded as principal on the record, and never inferred. warpline deny --list refuses it, because listing records nothing.

approvals

Live content approvals, keyed by plugin name. Each one is a standing yes to SPECIFIC BYTES a producer has already written: the operator read them, approved them, and a later unattended advance may ship exactly those and nothing else.

A record and not an array, for the reason denials gives one section up. One live approval per plugin by construction, so re-approving lands on the same key rather than accumulating, and there is no de-dupe scan to get wrong. It also makes a fleet-wide approval inexpressible — no key means every plugin, and blanket authority is precisely what a content approval is not. A document written before approvals existed loads and reads as none.

An approval applies only to a plugin whose manifest declares approval_class: 'content'. For every other plugin the record is not consulted at all, and the session grant of § 9 decides. The two are disjoint, not additive.

warpline approve <consumer> --content appends a content_approval.issued record (§ 14) inside the state lock, before the state write, with the consumer, the producer, the fingerprint, the producer’s run id and the window as the two ISO instants it resolved to. The typed wall clocks and the zone stay out. A re-approve that replaces a record carries the replaced fingerprint in replaced_fingerprint and writes no separate withdrawal. --content --remove appends a content_approval.withdrawn record with the stored fingerprint before the record is deleted. Either refuses with the state document unchanged when its append fails.

The fields:

not_before and not_after are naked wall clocks — YYYY-MM-DDTHH:mm[:ss], no trailing Z and no numeric offset — resolved against zone at fire time, not at approval time. The host tz database is read live and no snapshot is pinned, so a tzdb update between approval and fire changes the resolved instant; pinning would make the runtime wrong about the world. A zone the host cannot resolve is refused when the approval is written, and if it becomes unresolvable afterwards the window reads as closed rather than open — the conservative direction, never a fire and never a throw.

A lapsed window does not auto-approve, does not extend and does not degrade to an ordinary send. It stops applying, and the operator is the only one who can write another.

Expiry and deletion

A record whose window has closed is removed from this subtree at the end-of-run state write, unless its fire is marked and unanswered or the last row of the table below keeps it. Nothing an operator does is required, and the sweep runs inside every advance.

This is the last of three steps in one deletion policy. All three read the same window predicate over the same instant:

  1. The run log stops being protected. An approval stops protecting the run it names the moment its window closes (§ 6), so the log is an ordinary prune candidate again unless a pending gate or another open approval names the same run. It holds a summary of the run, not the content.
  2. The content is erased. The end-of-run write erases the content of every Output that a closed approval for that producer binds, once no open approval for that producer still binds it: body is deleted, and erased_at and body_sha256 are stamped. The record stays (§ last_output). An approval for that producer binds its Output when it names the Output’s run_id, or when its fingerprint equals the one the Output’s bytes produce, because the gate decides authority by fingerprint: a producer that re-produced byte-identical content under a later run leaves an approval naming the earlier run live, and erasing under it would void that yes. Binding is not the same question as being live. A confirmed approval can never fire again, but it binds by fingerprint until its window closes: bytes it shipped under a later run than it names, and byte-identical bytes the producer makes again after the fire, are held while its window is open and erased when it closes. The fire does not rewrite its run_id, which stays the run the operator read. Two limits are stated, not closed. A marked approval whose fire is neither confirmed nor answered binds by run_id only: the sweep keeps it until the operator answers it, and binding by fingerprint would erase every later identical Output at the write that produced it, so bytes it would match only by fingerprint are neither held nor released by it. And the fingerprint is computed over the producer’s manifest as it is now: when the producer is uninstalled, its manifest failed to load, or its side_effects changed before the window closes, content bound only by fingerprint is not erased, and stays until the producer’s next Output replaces it, for as long as that condition lasts. Should the manifest load again, or its side_effects change back, while a closed binding still matches the body by fingerprint, that binding releases it then. This step erases the body at that advance’s end-of-run write, unless the producer’s own Output in that advance has already replaced it with different bytes. A run_id is the advance’s id, shared by every plugin that ran in that advance, so an approval for another producer neither holds nor releases this content: it never reads these bytes. Withdrawing an approval with approve --content --remove, or replacing it by re-approving the same consumer, is a closure too: that command erases the content the old record bound in its own locked write, by this same rule, when no other open approval for that producer still binds it (§ “Writing and withdrawing one”). Whichever write erases the content also erases the copy an applied gate of that producer holds of those bytes, with the same erased_at and body_sha256, and the gate stays as a marker (§ pending_gates). A gate still pending keeps its copy, because the operator has not answered it yet.
  3. The binding is swept. This removes what is left of the approval — a fingerprint, a producer name, a run id and some timestamps — once there is nothing for it to bind to.

Sharing one predicate and one instant is what keeps the three steps from disagreeing about whether a window has closed, and what stops content being erased while an open window still binds it. The reverse, content kept after every window that bound it has closed, is stopped as far as the binds rule reaches, and the two limits in step 2 are where it does not. Nor is it stopped for an approval in a zone the host can no longer resolve: the shared predicate never reads that window as closed, so the record is kept, and so is the content it binds (the last paragraph of this section). A binding the table below keeps after its window closes is retained after it stops protecting its run.

The one exception:

state at expiry what happens
marked_at null dropped
marked_at set, confirmed_at null, no answer kept — this is the did-it-ship evidence
marked_at set, confirmed_at null, not_shipped_at set dropped — the operator answered it not shipped
marked_at set, confirmed_at null, shipped_at set dropped — the operator answered it shipped
confirmed_at set dropped
still binds its producer’s last_output, which keeps its body kept — its erasure was deferred

The last row is the one that is not an exception to deletion, and it takes precedence over the rows above it, so a confirmed record in that role is kept too. When an open approval for the same producer still binds the content a closed one binds, the erasure leaves the body and the sweep keeps the closed binding, so the content still has a binding to release it once the holder closes. The sweep asks this by the erasure’s own binds rule, by run or by fingerprint, because a holder that binds by fingerprint stops binding if its fire is left unconfirmed, and the closed binding must still be there then. It is dropped on the first sweep after its content is erased or replaced by the producer’s next Output. Until then the gate reports an unmarked one outside_window, which is true, and that has a cost: each advance that evaluates its consumer records one refused_plugins entry and one plugin_refused event for it. A confirmed one reads spent and is not counted. A confirmed approval binds by fingerprint until its window closes, so a closed, confirmed record is kept this way whenever an open approval for the same producer still holds the bytes it shipped.

A marked-unconfirmed record is never replaced by absence. It is the runtime’s account of a fire it began and cannot prove it finished, and deleting it would destroy that account for a send that may well have landed. Keeping it is safe because its bound content is erased by the same rule, and it binds by run_id only, so identical bytes the producer makes later are not erased on its account (step 2). The evidence it keeps is the fingerprint and the effect id, never the bytes. The operator settles it at the sink using the effect id, and answers it with warpline resolve once they know whether anything arrived there. An answered record is swept when its window closes, and binds its content by fingerprint as a confirmed one does.

A confirmed record past its window is dropped, unless the last row of the table above keeps it. While that row keeps it, it reads spent, and the ordinary not-due report naming the spent approval and the instant it fired is still rendered. Once the record is dropped, that report stops being rendered, and the plugin reads as having no approval, which it no longer has.

Ceilings this does not reach, besides the two limits in step 2 and the zone case in the last paragraph of this section:

Erasure reaches inline content in the state document and nothing else. What it does not erase:

A zone the host tz database can no longer resolve retains the record rather than sweeping it, and does not fail the advance. The content it binds is kept with it, and the run it names stays protected, because the content erasure and prune protection read the same predicate. Deleting recipient-bound data because the host forgot a timezone is not a deletion policy. Note that this is the opposite direction from the fire decision, which reads an unresolvable zone as a closed window and refuses: both are the conservative answer to their own question — never fire on a window you cannot read, never delete on one either.

The spend mark

effect_id, marked_at and confirmed_at are written by the ADVANCE and by nothing else. No operator command sets any of the three. The operator’s answer is a field of its own, shipped_at or not_shipped_at, written by one operator command, warpline resolve (§ “Answering an indeterminate fire”), and never by the advance. A shipped answer is still not a confirmation.

marked_at and effect_id are written together, before the handler is invoked — the only mark-before-effect in the runtime besides the run lock. marked_at IS the instant the fire was decided on, which is what makes the effect id recomputable: a reader holding (plugin, fingerprint, marked_at) can check an id they were handed, without the runtime having to promise that a retry regenerates one. confirmed_at is written at the single end-of-run state write and nowhere else.

Two fields rather than one, because one timestamp cannot express both post-fire states. Written before the handler, a single field makes every successful send read as unfinished forever and turns a content-approved plugin into a one-shot; written after, it loses the crash case entirely. The five states and their predicates:

state predicate what the next advance does
not yet fired marked_at null fires, if the window and the fingerprint still agree
indeterminate marked_at set, confirmed_at null, no answer refuses with indeterminate — never fires
answered not shipped marked_at set, confirmed_at null, not_shipped_at set ordinary not-due, like spent, naming the answer; approve again to fire
answered shipped marked_at set, confirmed_at null, shipped_at set ordinary not-due, like spent, naming the answer; approve again to fire
spent confirmed_at set ordinary not-due, naming the instant it fired

A handler that returns failed leaves the record indeterminate, not un-marked. The mark is not cleared on that path: a failed return does not prove the sink never received the bytes, and clearing it would re-arm a send that may already have gone out.

Every other handler status confirms the spend: success, partial and skipped. partial confirms because some bytes went out and cannot be unsent. skipped confirms too, and that includes a [needs-llm] handoff that shipped nothing, so a handoff consumes the content approval and the operator re-approves to fire again. This is deliberate. The mark is taken before the handler runs, so leaving skipped unconfirmed would not leave the approval live. It would leave the record marked and unconfirmed, which is indeterminate, the same as failed: a refusal on every later advance, a trip to the sink to look for bytes the runtime knows never left, and an answer with warpline resolve before a re-approval is even accepted. Handing the approval back would mean clearing the mark on the handler’s word that nothing shipped, which is the trust refused for failed above.

A handoff from a content-class plugin that does not declare llm_handoff reaches this rule already rewritten to failed, so it leaves the record indeterminate like any other failed return. Declaring the field is the fix.

The mark is taken under the state document’s own lock, with the document re-read inside it, so two content-class plugins in one execution level and a concurrent warpline approve --content are serialised by one mechanism. The write persists only the approvals subtree merged onto that fresh read — never the advance’s in-flight plugin_runs, which are not durable until the run returns. If the record has gone, its fingerprint has moved, or it is already marked by the time the lock is held, the fire is refused rather than taken: no mark, no invocation.

The fire intent is recorded inside the same lock. After that re-check and before the mark is written, the spend mark appends a fire.intent (§ 14) for the fire: the plugin, the run id, the class content, the effect id and the approval’s fingerprint. So when the mark lands, its intent is already on disk, and the record and the mark share one window. An intent the audit store cannot take stops the mark before anything is written, and the fire is refused mark_unavailable. A mark that fails after its intent landed is refused mark_uncertain, and that refusal’s fire.refused carries the intent’s seq, which closes it. A fire whose mark lands is closed by its fire.outcome once the handler returns, as a session fire is.

The durability ceiling, stated rather than claimed away. The guarantee is against process crash, not power loss: the state document is written with rename atomicity and no fsync. The outcome is also not durable until the end-of-run write, so a crash after a successful send but before that write reads indeterminate on the next advance too. That is the conservative and correct reading — the runtime genuinely does not know — and the effect id is the remedy: the operator checks the sink with it and, when nothing arrived, answers the fire with warpline resolve, rather than the runtime guessing here.

When the mark’s own I/O fails. The mark sits between a gate that has already said fire and a handler that has not been invoked, so its own failure is a refusal with a reason of its own rather than an error that ends the advance. On either arm there is no invocation: the level loop continues, and the advance still writes its run log, its JSONL rows and its dead-man file, with the refused plugin carried on all of them. The two arms are split by where the failure happened, not by which error class arrived. A lock that could not be acquired, a document that could not be read, or a fire intent the audit store could not take is mark_unavailable, because all three sit above the write and nothing can have been written. A write that threw is mark_uncertain, because the rename may have landed.

On the mark_uncertain arm the in-memory record is restored to the value it held before the mark, and the plugin is not among those the advance marked, so the disk wins in the merge (§ 12, “approvals is merged per key”). The disk then decides: a write that landed reads indeterminate on the next advance, and one that did not is retried and fires. Left marked instead, the end-of-run merge would promote a mark this process never observed land, which is the runtime asserting a fact it does not have.

Writing and withdrawing one (warpline approve --content)

warpline approve <plugin> --content --not-after <wall> [--not-before <wall>] [--zone <iana>] writes the record above for one plugin, and warpline approve <plugin> --content --remove takes it back. Both validate everything inside a single state-lock critical section before mutating anything, so a refused command leaves the document byte-unchanged. A --not-after that has already passed is refused before anything is written, on a first approval and on a re-approve alike: the record would be outside_window from the moment it was written, and a re-approve would erase in the same write the bytes it had just printed.

The command prints the bytes it is asking about. The whole guarantee rests on the operator having read them, so the producer’s inline body is written to stdout between two delimiters, preceded by the whole untruncated fingerprint and by the window both as it was typed and as it resolves to instants — a zone mistake is invisible in a wall clock and obvious in the instant it lands on. Every C0 control character except LF and TAB, DEL, and every C1 control character render as a visible \xNN escape. They are escaped and never stripped: a stripping renderer removes the evidence that an ANSI repaint was attempted, and repainting is how a screen is made to disagree with the record. Nothing is ever truncated — the Output schema’s 16 KiB inline cap is the only bound, and cutting would hide the tail, which is where a long batch’s surprises sit.

An Output declaring path rather than body is refused, not read. The runtime does not resolve, normalise or stat the path, and does not repeat it in the refusal. Approving by content means the operator saw the exact bytes; a path is a promise about a file that may say something else by the time it is read, and refusing outright is how the verb sidesteps path traversal entirely rather than defending against it.

An Output whose content was erased is refused by name. When the producer’s last_output carries erased_at, there are no bytes for the operator to read, so the command says the content was erased when the approval that bound it closed or was withdrawn, tells the operator to run the producer again, and exits 1. This is checked before the file check above. The refusal for a producer that has never produced is unchanged, and in both cases nothing is written.

A carried Output is refused by name. When the producer’s latest run produced no Output, the last_output on file was carried forward from an earlier run (§ last_output), and it is not what the producer proposes now. The gate would refuse to ship it, so the command refuses to bind it: it says the producer’s latest run produced no Output, tells the operator to run the producer again, and exits 1 with nothing written. Checked after the erased refusal and before the file check. Only the two plugin names are interpolated.

Withdrawal is validated against the record, not against what is installed. A plugin uninstalled after it was approved is still reachable by name from --remove; were it checked against the loaded manifests, its record would be stranded in the state document with no gesture that reaches it. --remove is not revoke, which retires a session grant, and not deny, which answers a proposal with a no — it is the yes to specific bytes taken back, and it leaves the plugin reported as ordinary unapproved rather than as refused.

Withdrawal releases the content. Removed, the record no longer names its run, so no later advance could tell which content it bound. The same locked write therefore erases the producer’s last_output content as a window closing would (§ “Expiry and deletion”), unless another open approval for the same producer still binds it. Content left to such a holder is erased when that holder closes or is withdrawn, as long as it still binds the content then. A holder that has fired and confirmed still binds by fingerprint until its window closes. The two limits in § “Expiry and deletion” step 2 apply here too: a holder whose fire is marked, unconfirmed and unanswered binds by run_id only, and content bound only by fingerprint is not erased once the producer is uninstalled, its manifest failed to load, or its side_effects changed. Re-approving a consumer over an existing record withdraws the old record the same way, in the same write. When the new record names the same producer, it binds that producer’s current Output and holds it. A re-approve onto a different producer says so. Right after the Answering the content gate: line it prints Withdrew the earlier content approval for <plugin>, which named a different producer. and, only when that withdrawal erased the earlier producer’s bytes, The bytes it held were erased, since no open approval still holds them. Both lines come before the bytes. It never names the earlier producer, which may no longer be the consumer’s dependency. A re-approve onto the same producer is a renewal and prints nothing new.

Two refusals protect a marked-unconfirmed record. For a record with marked_at set, confirmed_at still null and no answer, both a fresh approval and a --remove are refused, naming the plugin, the effect_id and the marked_at instant, and pointing at warpline resolve, the one gesture that answers it. A fresh approval would erase the open question rather than answer it, and re-arm a send that may already have gone out; a removal would destroy the only evidence that a send may have landed. For a record with no effect_id, marked by a build that recorded none, both refusals say instead that nothing can answer it and only a hand edit of the state document clears it. A record whose confirmed_at is set, or that was answered, is a state report rather than an open question, and re-approves and removes normally.

Answering an indeterminate fire (warpline resolve). warpline resolve <plugin> --shipped <effect-id> or warpline resolve <plugin> --not-shipped <effect-id> answers a fire that was marked and never confirmed, after the operator checked the sink with its effect id: shipped when the bytes reached it, not shipped when nothing did. It is its own verb and not a mode of approve, because it answers a claim about a past fire and grants nothing. This form and the --intent form below take an optional --principal <id>, which must name an active registered principal. It is checked under the state lock, recorded as principal on fire.resolved, and never inferred.

The answer is the operator’s word about a sink the runtime cannot see, and the runtime cannot check it. That is why it is recorded as its own field rather than as a confirmation, and why it does not re-arm anything: an answer that turns out wrong has cost a retry the operator chose, never a fire the runtime chose for them. It erases no content. An answered record binds its producer’s content by fingerprint until its window closes, as a confirmed one does, and the release rule (§ “Expiry and deletion”) lets it go then.

Answering any open fire intent (warpline resolve --intent). warpline resolve --intent <seq> --shipped or --not-shipped answers any fire.intent the audit store lists as open (§ 14), session-class or content-class, after the operator checked whether its effect happened. An intent is left open by a process that died between it and its outcome, or by an outcome that could not be written, and this is how it is closed.

last_output

A pointer to the most recent Output a plugin produced, so a reader can name it without scanning the runs directory. It is the § 5 Output record plus one stored-only state, erased. That makes it a second shape on purpose, and its only difference from the handler’s shape is erased_at and body_sha256 (StoredOutputRecordSchema, § 5).

Every write of a plugin_runs entry decides this key — the autonomous completion, the supervised park, the approve verb applying a gate, and the invocation that threw. A gated run produced its Outputs before the gate ever saw them, so it carries a pointer like any other run. A run that threw has no result to read one from, which is the strongest form of “produced nothing” and takes the same carry-forward as the rest.

What each write records is the run’s own most recent Output when the run produced one, and otherwise the pointer the entry already held. The field is a fact about the PLUGIN — the most recent Output it produced — not about its last run, so a run that produced nothing has said nothing about it and does not clear it. For the advance, the entry already held is the one the document holds when the advance writes, not the one it read at its start (§ 12). So a run that produced nothing carries an Output another advance wrote while it ran, or an erased record, which stays erased. A dependent later in the same advance reads the advance’s own copy, so it can have read an older pointer than the one written. One write keeps an erased record instead: applying a gate whose run’s Output was erased while it was pending (§ “Applying a gate”, step 5). Erasure is one-way.

Carried, and never shipped. A carried Output still serves lastOutput readers, for the reason above. The entry says it is carried: its run_id is the run that wrote the entry, and it differs from last_output.run_id exactly when that run produced no Output. A content-class consumer never ships a carried Output, and approve --content never binds one (§ approvals), because the producer’s latest run proposed nothing and an approval is a yes to its current proposal. The gate, the spend mark and approve --content read one predicate for it. The spend mark reads it on the entry it re-reads under its lock, and refuses only an entry written since the advance read the document: the entry the advance started from can read carried while the advance has already run the producer again and produced, and the end-of-run write replaces it. It treats an erased entry the same way, by the erasure the advance saw when it read the document, since erasure keeps the entry’s run and the run alone cannot tell the two apart. Every writer of the entry decides run_id and last_output in one place, so neither can be written without the other.

Only a run’s own write moves run_id. An entry’s run_id changes only when a run writes its own result, so no write moves it back to an earlier run’s. An advance stamps the run it is. The gate apply stamps the run that parked the gate, which finishes that run’s own write, so it lands only on the entry that run left. An entry naming any other run means a later run wrote it, and the apply is refused as superseded (§ “Applying a gate”). An earlier run’s result over a later run’s would make a carried Output current again and re-arm a content approval over bytes the producer no longer proposes. Order is never compared, only identity, since § 12 already rejected ordering runs by clocks that can come from different hosts. The one place a later write carries the older run is § 12’s overlap, where each advance still stamps its own run. plugin-run-writers.test.ts holds every writer to the run its row says it stamps.

Status-blind. What survives is keyed on the run producing no Output, never on how the run ended. A run that threw, a run that returned failed, and a run that succeeded carrying an empty artifacts_produced are one case here. The consequence for a reader: this field cannot be read as a health signal for the plugin that produced it, and a consumer that needs to know how its dependency’s latest run went asks capabilities.dependencies.lastRun for it instead. The two answers come from one projection of this same entry, so they cannot disagree about which run they describe.

Absent, not null. A plugin that has never produced an Output has no last_output key at all — not null, not {}. Reading a missing key is unambiguous; reading an empty object means guessing whether the plugin produced nothing or the writer failed.

Erased, not absent. Once the last approval for its producer that binds it has closed, been withdrawn or been replaced by a re-approve, the content is erased and the record kept: body is gone, and erased_at and body_sha256 are set (§ approvals, “Expiry and deletion”, which names the cases this does not reach). So capabilities.dependencies.lastOutput still returns the record, and null still means never produced. The producer’s next Output replaces it.

The pointer may dangle. Its run_id names a run log, and run logs are pruned under the operator’s configured retention policy (§ 6), so a pointer can outlive the run it names. A pointer is not protective — § 6 says why. That resolves to “run no longer retained” rather than an error, and nothing deletes the pointer to avoid the case — the pointer is the only remaining record that the Output existed.

11. Exit codes

warpline advance is the unattended entry point, and its exit code is the machine interface a scheduler keys on: this runtime ships no HTTP surface and no alerting hook. Two things carry detail a code has no room for, and neither replaces it — warpline advance --json writes one JSON document to stdout describing the advance that just ran, and the dead-man file (§ 13) records the last one that returned. Both have to be parsed before they can say whether anything ran; the code says it without being read. The document also carries audit, the audit store as this advance’s Checkpoint left it, with every fire whose outcome never arrived (§ 14). The codes below are contract surface. A scheduler unit, a monitoring check or a wrapper script may key on them.

Code Meaning
0 The advance ran and nothing failed. Every plugin completed, nothing was due, a plugin is holding at an approval gate, or a content approval declined to authorise a fire.
1 At least one plugin failed, the plugin root loaded no manifests at all, or the command line was not valid. Or, under --strict, an approval gate is still waiting or a content approval refused a fire. Or a write to stdout or stderr failed for a reason other than a reader that has gone away. Output a plugin prints after the drain has begun is dropped, and does not count as a failed write.
70 The audit store failed during this run: a fire intent, a fire outcome, a fire refusal or the advance’s Checkpoint could not be recorded, or the advance’s Checkpoint was written but the store could not be read back after it (§ 14).
75 Could not finish. Often nothing ran and nothing was written, but not always — see below before treating it as a free retry.
130 Interrupted by a SIGINT or SIGTERM that warpline caught. The process stopped; the work may not have.

A 1 before the advance starts is a usage error. An unregistered flag and a positional argument are both refused by the argument parser: the command writes the parser’s message plus its usage text to stderr, writes nothing to stdout, and exits 1 without running anything. warpline advance strict — the typo for --strict — is refused there rather than quietly running non-strict. A WARPLINE_TRIGGER that is set to anything but scheduled or manual is refused the same way, with one line naming the value. That is a cause of 1 in its own right and this table is a closed enumeration, so it is named here rather than left for a monitor to discover as “a plugin failed”. What tells them apart from outside: under --json a plugin failure, an empty plugin root and a --strict promotion each write a document to stdout, the last with pending_gates or refused above zero, and a usage error writes none.

70 means a fire or a refusal has no audit record. A plugin whose fire intent could not be recorded did not fire. It is recorded failed in the run log, and it gets no run record, because no run happened. A plugin whose outcome could not be recorded did fire. It keeps its run record like any other, and its intent stays open in the store for the next advance to list. A refusal whose record could not be written still stands: the plugin did not fire. stderr names each plugin, and for an outcome the seq of the intent left open. Nothing retries an append. The run finishes and writes its document to stdout with exit_code: 70. 70 outranks 1 and --strict, so a run that also had a plugin failure or a held gate still reports 70. An advance whose Checkpoint could not be written exits 70 as well: its document carries audit: null, stderr says the Checkpoint was not recorded, and the dead-man file is still written, because the advance itself finished. An advance whose Checkpoint was written but whose store could not be read back after it exits 70 too: its document carries the Checkpoint’s seq with indeterminate: null, never an empty list, and stderr points at warpline audit verify for the reason. warpline revoke exits 70 too, when the grant was removed but its record, or a change to principals.json it read, could not be written (§ 9).

130 is the conventional code for a process ended by SIGINT, and this command reports it deliberately rather than by default: it installs a handler for the length of the run, and 130 is what a signal warpline catches reports. A signal before the run starts or after it returns kills the process outright, and a shell shows that as 143 or 130. A death by signal is not an exit code, so the table above stays a closed enumeration. After the run returns, the advance has finished and released its lock, so a signal there costs only the tail of the output.

The handler ends stdout and stderr, and exits 130 once both have drained. A plugin still printing after the signal loses only what it prints from then on. What it printed before still drains, and the code is still 130. What it drains is plugin output: while a handler runs, its prints go to stderr (§ What reaches stdout), and an interrupted plugin can leave them queued. The document is never queued while the handler can run. It is written and the handler comes off in one synchronous stretch, and a signal can’t land between the two.

On the ordinary path the promise is general: every verb drains stdout and stderr before the process exits, so a piped --json document reaches a slow reader whole. There is no ceiling there. A reader that stops reading blocks the writer, as it would any Unix writer. A reader that has gone away (| head) ends the command quietly, with its own exit code. A plugin still printing when the drain begins keeps the command’s own code too. Only what it prints after that point is lost, and everything queued before it still drains. Any other write error on either stream is not quiet. The process ends with a stack trace and exit 1, whatever the command would have returned, a caught signal included.

SIGTERM takes the same handler and reports the same code. That is the signal a scheduler sends — systemctl stop, a launchd bootout and a container stop are all SIGTERM, and none of them is a SIGINT. Left to the default disposition they killed the process with no flush and no exit code at all, which is the failure the SIGINT handler existed to prevent, reached by the route an operator is far likelier to take. Everything the rest of this section says about an interrupt now reads for both signals. What your scheduler makes of a 130 afterwards is its own question and scheduler-recipe.md is where it is asked.

The handler also puts a ceiling on itself. It exits once stdout and stderr have drained, and after two seconds it exits anyway. Without that, a pipe whose reader has stopped consuming would leave the process unkillable by further signals — the default disposition is gone while the handler is installed, so each further signal only asks for the same drain.

Read 130 as “the process stopped”, never as “the work stopped”. The advance is not interruptible — no abort is threaded into a plugin invocation — so the plugin that was in flight may run to completion in a process the operator believes is dead. Two consequences follow, and both are bounded rather than open:

What reaches stdout

The document is the only thing on that stream, and the runtime holds that against the plugins as well as against itself. While a plugin is being loaded and while its handler runs, anything it prints through process.stdout.write, console.log, console.info or console.debug is redirected to stderr. warpline run --json gets the same protection from the same place: both verbs invoke a handler through one function, and the redirect lives there rather than in either verb.

Redirected, not discarded. A plugin author’s debug line still reaches them, on the stream every other diagnostic in this runtime already uses. It carries no label naming the plugin that wrote it, and a level runs its plugins concurrently, so two chatty plugins interleave.

Three writes are outside that reach and can still put bytes on stdout:

The first two are a plugin doing something unusual, and the third only happens on a run that already timed out or was cancelled. None of them is ruled out, so a consumer that cannot tolerate a corrupt document at all should key on the exit code, which reaches it without being parsed.

What the Asks could not do this advance (§ 16, Delivery) goes to stderr, one line per fact, and never moves the exit code.

The code comes from the run’s own state, never from its status

The code is computed from exactly four fields of the advance result: plugin_states, gated_plugins, refused_plugins and pending_gates, the count of gates still waiting (§ 13). It never reads AdvanceResult.status, and that distinction is the point. A run that stops at an approval gate reports partial, because it did not get through the fleet — but a held gate is the runtime doing exactly what it is for. Reporting it as a failure would train an operator to ignore the code, which is the one outcome that makes every other code in this table worthless.

Two consequences worth stating plainly:

--strict

warpline advance --strict promotes an advance to 1 while any approval gate is still waiting on a human, or when a content approval refused a fire. A waiting gate is one the state document holds that can still be applied (§ 10, Applying a gate), for a plugin the advance loaded (pending_gates, § 13), whichever advance parked it: the advance that parks it counts it, and so does every later advance until it is applied or dropped. Use it where a gate waiting on a human is itself the thing you want paged about — a fleet that is supposed to be running fully autonomously, for instance.

A content refusal is 0 on its own, for all five reasons in § 5. Three of them say the approval stopped applying: indeterminate (a marked fire was never confirmed), outside_window (the window closed) and content_moved (the approved bytes moved). That is the same gate holding for a different reason, and a held gate is the runtime doing its job. The other two, mark_unavailable and mark_uncertain, are not the gate holding. They are the spend mark’s own state-document I/O failing, and they exit 0 too.

That second half is a decision, and it is safe only because of what surrounds the mark. A full disk, a read-only home or a lock that cannot be broken fails the mark and then fails the advance’s end-of-run state write the same way, so the advance exits 75. A state document too corrupt to read fails the next advance’s first read with 75. A lock that was only busy leaves nothing marked and nothing sent, and the next tick retries and fires, which is the right outcome. One case stays quiet and leaves stuck state: a mark write that fails after its rename landed. That advance exits 0, and the next one refuses with indeterminate, which the operator answers with warpline resolve once the sink shows nothing shipped (§ 10). If the end-of-run write ever stops failing the advance on a storage fault, this decision has to be made again.

Without --strict the code says 0, and the count is what tells you a refusal happened: warpline advance --json carries refused and the structured reason for each refusal, and the dead-man file (§ 13) carries the count. That pair is deliberate. A fleet can refuse every send on every advance for a week, and nothing about the exit code alone would distinguish that from a fleet with nothing to do.

--strict changes none of the 1 cases. A plugin failure is 1 with it or without it, and a plugin root that loaded no manifests is 1 with it or without it. The flag moves the waiting-gate and refused cases and nothing else — and it moves them together, as one 1: an advance with both is not two failures.

75

75 means the advance could not finish. Read it as “could not look”, never as “looked and it was fine” — but do not read it as “nothing happened” either. Two of the three causes below leave the home untouched. The third does not, and it is the one a monitor is most likely to meet.

There are three causes, and all three have code behind them.

The first is a throw out of the advance itself — which includes an engine-state.json that fails validation, since that read happens inside the advance. Treat this one as “retry later” rather than as a fault to page on: under a fifteen-minute timer the retry costs nothing.

This cause is not free of side effects, and the distinction matters to anything that retries automatically. Two arms of it are: a plugin root that cannot be read, and a preferences.json that fails validation (§ 6), are both refused above every writer in the advance, including the run lock, so those two do leave the home byte-identical. The rest do not.

The preferences arm does not clear on its own. Every tick refuses until the file is fixed or removed, and the dead-man file (§ 13) goes stale meanwhile. “Retry later” is the wrong reading for it: read the stderr line, which names the file, the key path and the expected shape. warpline advance maps any throw out of the advance to 75, deliberately and with no chain of special cases, so a state write that hits a full disk, a run-log write that fails, or an append to events.jsonl that fails all arrive here. Those writes happen after the fleet has run, and a plugin that has already sent has already sent. Even a throw from the retention prune, which runs before the first plugin, arrives after run logs have been deleted. A wrapper that reads 75 as “safe, nothing happened, retry” will retry a fleet that already acted.

What discriminates, if you need to know: a run_started event in events.jsonl with no matching run_completed for the same run_id means the advance reached the fleet and did not get out of it. Its absence is weaker than it looks — the retention prune runs above that event, so a throw there leaves no run_started and has still deleted files. The dead-man file (§ 13) does not help here at all: it is written only by an advance that returns, so a 75 leaves it reading whatever the last completed advance left, and its age cannot tell the two apart.

The second is a refusal raised before the advance starts. With no warpline home at the resolved path and no terminal on standard input, warpline advance will not create one. Under a scheduler an unset WARPLINE_HOME resolves against the working directory the scheduler happened to give the job, so a missing home means a second empty home is invented, the fleet runs nothing, and the command exits 0 reporting healthy. This refusal repeats until the operator sets the variable or creates the directory, and the message names both. Nothing is written on this arm: it is decided before the advance is called at all.

The refusal is narrow on purpose: it refuses to create a home, never to run unattended. An existing home with no terminal on standard input is the ordinary scheduled case and it proceeds, which is the entire point of running this command from a timer.

The third is contention on the run lock: another advance is already running against this home. The message names the holder and says that nothing ran, which holds: the lock is taken above every writer in the advance, so the loser never reaches one. This one is also “retry later” — the next tick is the retry, and no flag exists to break a lock somebody else is holding. § 12 is the whole of it.

Unknown codes

New codes may be added over time. A consumer MUST treat any unknown non-zero code as failure. Do not read the table above as a closed set: a wrapper that special-cases 0, 1 and 75 and falls through to success on everything else will report a green fleet on the first code this document adds.

12. The run lock

Two advances against one warpline home must not both write it. warpline advance takes a lock for the length of the run, and refuses rather than waits.

The file is .lock, beside engine-state.json in the home’s state directory. It is JSON, and it records when it was taken, the run id that took it, the mode (advance), the holding process id — which is null when the holder is an orchestrator session rather than a long-lived process — the host identifier of the machine that took it, which is null when that machine could not identify itself, and heartbeat_at, when the holder last said it is still running. host is nullable and optional (§ The host identifier, below). heartbeat_at is optional (§ The heartbeat, below).

Acquisition is an exclusive create, not a check followed by a write. There is no window in which two processes both see an absent lock and both proceed.

A stale lock heals; a live one does not. A lock is stale when its holder has not refreshed it for more than two hours, or when it names a process id that is no longer running on the machine that took it. The process-id test is reached only when the lock’s host and the reading machine’s host are both known and equal; in every other combination it is skipped and only the two-hour window expires the lock. On contention the acquire reads the holder back, and if it is stale it breaks it and retries exactly once. A holder that is neither — and a lock file that cannot be read back as a lock at all — is refused rather than broken. lock.test.ts in this repository holds every arm of that, including the two that refuse to unlink a file they could not parse.

The heartbeat

The two-hour window is measured from the holder’s last heartbeat. The advance writes heartbeat_at when it takes the lock, equal to acquired_at, and refreshes it every minute while it runs. So a holder that is alive is never healed, however long its advance runs: a plugin’s timeout_ms has no upper bound, and healing a long advance let a second one run beside it and fire session-class side effects again. A holder that stops refreshing, wedged, asleep or gone, is healed two hours after its last refresh. A lock with no heartbeat_at is measured from acquired_at. So is one whose heartbeat_at is not a date, or lies more than two hours ahead of the reading machine’s clock: that is a wrong value, not a clock that runs fast, and trusting it would keep the lock from ever healing. A holder whose clock runs ahead by less than that keeps its lease, and is healed that much later. Only an advance refreshes the heartbeat. A lock an orchestrator session takes through a short-lived process is never refreshed, so it expires two hours after acquired_at, however long the session runs.

The refresh never writes a lock this run does not hold. It reads the lock back, compares the run id, and renames a new copy into place, so a reader never sees a torn lock. The first time the lock is not this run’s, the heartbeat stops for good and the advance carries on. Not this run’s means the file is gone, or it reads back as a lock with another run id, and nothing else. A read that fails, or a file that does not read back as a lock, writes nothing, and the next refresh tries again: stopping there would let one failed read on a flaky mount heal a live holder two hours later. The read and the rename are not atomic, like the release below: a second process can heal and acquire in between, and the rename then writes over its lock. That is reachable only when this holder’s heartbeat is already two hours old.

The release waits for the heartbeat. The advance stops the heartbeat, and waits for a refresh already in flight, before it releases the lock. A refresh landing after the release would put the lock back, held by a run that has ended.

The field is optional, and an older build ignores it. A lock written by an older build has no heartbeat_at. An older build reading a lock this one wrote drops the field, so it measures the window from acquired_at, as it always did. A home attached from both builds can therefore still heal a healthy advance two hours after it started, until every attached build writes the field.

The host identifier

A home can be attached from more than one machine. A process id is only meaningful against the kernel that issued it, and the liveness test asks the local kernel whatever the lock says — so without a host identifier, machine B reads machine A’s live lock as a dead process, heals it, and two advances run against one home. That is the single way to get two writers, which is what the lock exists to prevent.

Known and known and equal, or no process-id test at all. Either side null, either side absent, or the two known and different: the lock expires only by the two-hour window.

null never compares equal to null. A machine that could not identify itself stores null, and two such machines are not the same machine. No literal placeholder string is ever written in place of an unknown host — that is the shape Git’s gc.pid has, and it makes two unidentified machines compare equal.

The field is nullable and optional, and the optionality is load-bearing. A required field would make every in-flight lock written by an older build unparseable, and an unparseable lock is refused rather than broken — so the upgrade would strand every home that had an advance running when it happened. A record carrying no host key behaves exactly as the null case.

The stored value is not the machine’s identifier. It is an HMAC over the machine id, keyed by the id and taking a fixed warpline application UUID as the message — the remedy machine-id(5) prescribes for its own “must not be used directly” and “must not be exposed … on the network”. A warpline home may sit on a network mount, so the raw identifier must not be written there. What that buys is precisely that the stored value cannot be used as a machine id anywhere else. It does not buy unguessability. The identifier is derived from /etc/machine-id, then /var/lib/dbus/machine-id, then the macOS IOPlatformUUID, then null. The machine’s operator-facing name is never consulted: it is operator-chosen, routinely duplicated across a fleet, and changes without the machine changing.

The honest ceiling. The host field makes the lock honest across machines; it does not make it correct. Where a container image bakes in a machine id, or two containers bind-mount one, the identifier lies in the dangerous direction — two machines look like one — and warpline cannot detect it. A PID-namespace-based identity is the named upgrade path and is not implemented.

A release removes only the lock it took. Both unlinks in the lock module read the file back and compare the run id before deleting anything. Without that, an advance whose own lock was healed out from under it — its heartbeat stopped for two hours while the run was still going — deletes the next advance’s live lock on its way out, and the tick after that acquires cleanly while two advances are still running. The narrowing is real and it is not a guarantee: a read followed by an unlink is not atomic, and there is no portable compare-and-delete to make it one.

Contention exits 75 and names the holder (§ 11). The message says a process id, or says an orchestrator session, and it says that nothing ran. It never says the word for an absent value in place of a process id. advance.test.ts holds both arms, and asserts in each that the holder’s lock is still on disk and that no run appeared under the home.

The lock is taken below the plugin-root and preferences refusals and above the state read, and released in a single finally below the return. That placement is what keeps two promises at once: a plugin root that cannot be read and a preferences.json that fails validation are both refused before any lock exists, and every write the advance makes happens while it holds one. The release covers every way out, including the quiet-hours early return and a throw from inside the span. engine-lock.test.ts holds all three of those arms, and the one that matters most is the quiet-hours one: a lock leaked there is unhealable for two hours, which under a fifteen-minute timer is eight consecutive advances reporting “retry later” to a monitor that reads them as merely busy.

What the lock is and is not for

The lock is not what stops a scheduler starting a second copy of the job. Two of the three schedulers this runtime is deployed under already do that themselves, and their own documentation says so.

So the lock is load-bearing for cron, where it is the only protection an operator has, and for a manual advance racing a scheduled one on all three platforms — nothing in any scheduler knows about a human at a terminal. Read it that way rather than as a guarantee about schedulers in general, which is a guarantee this runtime cannot cash.

Two locks, and neither moves

There are two lock files in the state directory and they are not the same thing.

.lock is the one this section is about: one advance at a time, per home. .state.lock serialises read-modify-writes of the engine state document and of the Ask store beside it (§ 16), and its holders are every writer of either: the board, deny, approve when it writes state, and the advance’s three regions, which are its spend mark, its end-of-run write and the try record after each notify try (§ 16). The three are sequential and never nested, and notify runs in none of them. They have different lifetimes and different holders.

Neither collapses into the other. The state lock is non-reentrant, and the board takes it around a call that writes the state document, so the run lock cannot be moved down into that writer without deadlocking against a lock the same process already holds. An advance holds its own lock across its state write, which fixes the acquisition order as run-lock-first. That order is a fact about the code as it stands, not a lock hierarchy the runtime enforces.

The accepted cost

No flag breaks a lock another process is holding. There is no --force and there will not be one: it is the single way to get two writers onto one home, which is the failure the lock exists to prevent. The cost, stated rather than discovered: a genuinely wedged live process blocks every advance against that home until two hours after its last heartbeat. That is the choice, and the two-hour window and the dead-process check are what bound it — and for a lock taken on a different machine, only the two-hour window bounds it. An operator who knows the holder is gone can delete .lock by hand; an operator who is not sure should wait for the window.

A process whose event loop is alive keeps its lock, however stuck its advance. The heartbeat runs on the process’s event loop, not in the advance’s steps. A loop blocked by a synchronous plugin, or a mount that never answers, stops the refresh, and the lock heals. A process whose loop is alive while the advance waits forever outside a plugin’s timeout_ms keeps refreshing, and nothing heals it. Every plugin call is bounded by its timeout_ms, so that takes a fault in the runtime itself. An operator who finds one deletes .lock by hand.

An interrupted advance can leave a plugin running. An advance interrupted by SIGINT or SIGTERM exits 130 (§ 11) once its stdout and stderr have drained, or after two seconds if they have not, which ends the process and not the work: the plugin that was in flight may run to completion in a process the operator believes is dead. The lock that interruption leaves behind is reclaimed by the heal described above: on the next advance if the holder’s process is gone and the lock’s host and this machine’s are both known and equal, and in every other case at the two-hour window, measured from the last heartbeat or from when it was taken if it carries none it can use: none at all, one that is not a date, or one more than two hours ahead (§ The heartbeat). That covers a lock that was orchestrator-held, names no process, came from another machine, or carries no host or a null one, and any lock read on a machine that cannot identify itself. The two facts belong beside each other because the second is what bounds the first.

What the end-of-run write merges: issue #25

The run lock serialises advance against advance, and nothing more. An advance reads the engine state document at the top and writes it at the end, a window that spans plugin execution, and it holds no state lock across that window — holding one there would block the board for the length of a run. Every other writer of the document takes the state lock, so it can land inside that window. Issue #25 was that the advance’s write erased what those writers wrote.

So the end-of-run write merges. It takes the state lock, re-reads the document inside it, and writes the advance’s own changes onto that fresh read, field by field. Every field the advance did not change is written as the fresh read holds it: denials, completed_tasks, extensions, and any other top-level key. What the advance did change goes on top, each by its own rule.

Field The advance’s change What is written
schema_version, last_run_id, last_run_at, last_interaction_at set by the advance the advance’s
plugin_runs the entry of each plugin it ran the advance’s entry for each plugin it ran, and the fresh read’s for every other plugin, absence included. last_output is decided again against the fresh entry, and run_id is the advance’s (§ last_output)
pending_gates the gates it parked the fresh read’s gates that survive (§ 10, “Applying a gate”, step 1), then the gates it parked
approvals the marks and confirmations of its content fires merged per key (below)
task_aging, deferrals the tasks its tier archived or auto-deferred changed by task id, and only while the fresh read still holds the task open, not archived. An auto-deferral also needs the task to have no deferral of its own

The release rule (§ approvals, “Expiry and deletion”) then runs over the merged document, and the expiry sweep after it.

So a write that lands while the advance runs is kept. A denial, and the gate it discarded. A deny --remove. An apply, with the entry it wrote and its spent marker. A refused apply, with the gate it discarded and the entry it deleted. A withdrawal or a re-approve, with the content it erased. A board write. The advance tells the plugins it ran from the rest by where it wrote their entries, never by a timestamp: another advance’s entries post-date its start too, and an apply’s entry is dated at its gated run.

approvals is merged per key. Both of the advance’s writes to it — the spend mark taken before a content-approved handler runs, and the single end-of-run write — take the state lock, re-read the document inside it, and merge that subtree per key. The two regions are sequential and never nested. approvals gets a per-key rule rather than the fresh read because the advance writes it too, and so does another attachment: one home can be attached from several machines, so a warpline approve --content lands at an instant the advance cannot predict.

The rule is the one plugin_runs follows: the advance’s copy wins only for what the advance itself wrote. For approvals that is the records whose spend mark this advance took and saw land.

Key Marked by this advance What is written
on disk only — the disk record (another attachment approved mid-advance)
in the advance’s copy yes the advance’s record, over the disk and over absence
in the advance’s copy no the disk record, absence included

The advance’s copy of a record it did not mark is a read taken at its start, so it can only be older than the disk. That holds for a record an earlier advance marked, too. So an operator’s re-approval (warpline approve <plugin> --content) or removal (warpline approve <plugin> --content --remove) that lands mid-advance on a record this advance did not mark is kept. An answer does not land mid-advance, because warpline resolve refuses while a live advance holds the run lock (§ 10). The record it would answer and the advance could still overwrite is the one that advance marked, whose fire may still be in flight. Only the overlap below lets an answer land while an advance runs, and then the merge rule keeps it for any record that advance did not mark. A record this advance marked is never replaced by absence: it is the runtime’s account of a fire it began, and with confirmed_at it is the evidence that the fire finished.

The accepted cost: two advances after a TTL heal. If an advance’s run lock is healed by the two-hour window while the advance is still running, a second advance can run beside it. The heartbeat makes that reachable only when the holder stopped refreshing for two hours, or when an older build that ignores the heartbeat is attached to the same home. Each writes under the state lock, so neither erases what the other wrote for a plugin it did not run, or a gate it did not supersede. While the older advance runs on without its lock, resolve cannot see it, which is the same residual. For a plugin both ran, the advance that writes last wins, even when its run is the older one, and a gate either one parks supersedes the other’s gate for the same plugin. Only a parked gate supersedes. A run one advance recorded does not, so a gate the other parked for that plugin stays pending beside it, and an approve of that gate later is refused as superseded, because the entry names the other advance’s run. Keeping the newer run was rejected: the two runs’ clocks can come from different hosts.

Which file each writer writes, since this has been recorded wrongly before. The deny verb and the board write the engine state document, under the state lock. So does approve --content, which records the approval, and approve --content --remove, which withdraws it — both under the same lock. So does approve <plugin> when it answers a parked gate, applying it or discarding it, under the same lock. So does warpline resolve <plugin> --shipped|--not-shipped, which answers an indeterminate fire, under the same lock. Its --intent form takes the same lock and writes no state document. So does the advance itself, twice, under the same lock: its spend mark and its end-of-run write. approve’s Grant path writes the session-approval file instead, .session-approval at the root of the home, and writes no state document at all. The state lock serialises all of them. The run lock only keeps a second advance out, and makes resolve wait (§ 10).

13. The dead-man file

An advance leaves a record of itself at last-successful-advance, beside engine-state.json in the home’s state directory. It is JSON, it is rewritten in full on every advance that returns, and nothing in this runtime ever reads it back.

It exists because this runtime has no other way to tell you it is alive. There is no HTTP surface in this repository — § 7 — and no alerting hook of any kind, and a warpline that has stopped cannot alert you that it has stopped. The exit code of § 11 reaches you only when the command runs; if the timer never fires, no code is ever produced. So the interface is a file, and the signal an outside detector reads is the file’s own age.

The fields

Field Type Meaning
run_id string The advance’s own run id. Names the run log this advance wrote — but only when it ran: an advance skipped for quiet hours still gets a run id and writes no log.
completed_at string ISO 8601 UTC, the moment this file was written. Use the file’s mtime for age checks; this field is for a human reading the file.
status "complete" | "partial" | "failed" The advance’s own status. Not the exit code.
skipped_reason string | null null when the advance ran. "quiet_hours" when it returned early because a quiet window was active.
gated integer How many plugins this advance parked at an approval gate. 0 on every later advance while the same gate still waits: pending_gates counts that.
pending_gates integer How many approval gates are waiting on a human: entries in the state document’s pending_gates (§ 10) that can still be applied (§ 10, Applying a gate), whichever advance parked them. A gate applied and kept as a spent marker is not counted. Nor is a gate for a plugin this advance did not load, one uninstalled or renamed since it parked: approve and deny both refuse a name with no loaded manifest, so no command can clear it, and it would page --strict on every tick until the ceiling drops it. Counted from the document this advance wrote, or on a skipped advance from the one it read, since that advance writes none. Either way a gate that can no longer be applied is not counted: one past its plugin’s ceiling, one a later run superseded, or one whose dependency re-ran.
failed integer How many plugins ended failed, manifests that would not load included.
refused integer How many plugins holding a content approval were not fired: the approval stopped applying (indeterminate, outside_window, content_moved), or the spend mark’s own state I/O failed and nothing was sent (mark_unavailable, mark_uncertain) (§ 5). The count only: the reasons are plugin-derived and reach a reader through warpline advance --json, never through this file. An unmarked closed binding the sweep keeps because its content is still held (§ 10, “Expiry and deletion”) counts here on every advance until that content is erased. A confirmed one reads spent and is not counted.
pruned integer How many run records this advance’s retention prune removed. Always 0 on a skipped advance, which returns above the prune.

Those nine keys are the whole document, and dead-man.test.ts enumerates them so that adding a tenth has to be a deliberate act. Nothing else belongs here: no plugin summary, no plugin output, no value read out of your configuration and no path. A field carrying free text would make this file a channel for content it was never meant to carry.

Three values, not four. The shared status union this field is typed from has a fourth member, "interrupted", and no advance can put it here. The engine assigns only complete, partial and failed, and an interrupted advance exits from its signal handler without reaching this writer at all — which is what the paragraph two down says about a throw, and holds for the same reason. A detector that branches on "interrupted" here gets dead code.

status is the advance’s status and not the exit code, and the difference matters most on the case you will hit most. An advance that stops at an approval gate reports partial here and exits 0 there, because a held gate is the runtime doing its job. A detector that treats status as a pass/fail verdict will page you every time a plugin waits for a human. Read pending_gates, refused and failed for the verdict, and read status for what the run did.

When it is written, and when it is not

It is written on every advance that returns, whatever that advance found. A complete run writes it, a run holding at a gate writes it, a run with a failed plugin writes it, a plugin root that loaded nothing writes it, and an advance that returned early because quiet hours were configured writes it. Each of those arms has a case in dead-man.test.ts. The gated arm is the one the file exists for: a fleet whose only news is a waiting approval is a healthy fleet, and a switch that fired on it would be a switch you learn to ignore.

The quiet-hours arm is written for a reason worth stating. A configured quiet window is hours wide. If a skipped advance wrote nothing, every threshold you set would have to be wider than that window, and the resolution a fifteen-minute timer buys you would be gone. Writing it with skipped_reason: "quiet_hours" is what lets a detector tell an asleep fleet from a stopped one. Quiet hours are opt-in — the window is off until you configure one — so on a home without one this field is always null.

It is not written when the advance throws. This is deliberate and it is the requirement most easily implemented backwards. An advance that cannot take the run lock, cannot find the home, or cannot read the state document is exactly the situation in which you need the last good file left standing, going stale, saying what the last successful run was and how long ago it finished. A writer that ran on the failure path would overwrite that signal with a fresh timestamp and report a wedged runtime as a healthy one. dead-man.test.ts proves this by writing a file from a real advance, forcing the next advance to throw, and asserting that the file’s contents and its modification time are both unchanged.

The write is atomic — a temp file and a rename — so a detector reading the file while an advance writes it sees either the old document or the new one, never half of either. It happens after the run log is written and before the run lock is released, which gives you two guarantees: when the advance ran, the run log named by run_id is already on disk by the time you can see this file, and two advances against one home cannot interleave writes to it. The first guarantee is about ordering, not existence — a skipped advance writes this file and no run log, and skipped_reason is how you know which you are looking at.

Reading it

Check the file’s age first, then its contents. Five outcomes, in the order you should test them:

  1. Stopped. The file is older than a few times your advance interval, or it does not exist at all on a home that has run before. Nothing is writing it. This is the case no code inside this runtime can report to you, and it is the reason the file exists. A fifteen-minute timer with a one-hour threshold gives three missed ticks of tolerance before it pages.
  2. Asleep. skipped_reason is "quiet_hours". The advance returned early on purpose, so it is recent and it is not stopped. No plugin ran, so gated is always 0 here — but failed is not: a manifest that would not load is counted on this arm too, which is what lets a broken plugin root show through a quiet night instead of waiting for morning. pending_gates is not always 0 either: a gate still waiting overnight is counted from the state document the skipped advance read. Check this step before the next one so that a skipped advance with a load failure reads as broken and asleep rather than as broken and awake.
  3. Broken. failed is greater than zero. At least one plugin failed or one manifest would not load. Read the run log named by run_id — unless skipped_reason is set, in which case there is no log and the failure is a manifest that would not import.
  4. Waiting. pending_gates or refused is greater than zero and failed is zero. Plugins are holding — at a session approval gate, or on a content approval the runtime declined to fire. Whether that pages you is your call — it is the same distinction warpline advance --strict makes at the exit code, and it covers both fields for the same reason. gated is not the field to key on here: it counts only what this advance parked, so it reads 0 on every later tick while the same gate waits.

    Read refused on its own terms. A non-zero pending_gates means a human has not answered yet; a non-zero refused means a human already did, and the send still did not happen. Either the answer stopped applying (a marked fire never confirmed, indeterminate; the window closed, outside_window; the approved bytes moved or were erased, content_moved), or the runtime could not record the send in its own state and so did not make it (mark_unavailable, mark_uncertain). A fleet that refuses every send on every advance for a week is a fleet doing nothing, and failed and pending_gates both stay 0 throughout. This field is the only thing in the document that shows it. warpline advance --json carries the reason for each refusal; this file carries the count.

  5. Healthy. Recent, failed, pending_gates and refused are zero, and skipped_reason is null. gated is then zero too.

A healthy file, an all-gated file and an all-refused file are told apart by pending_gates and refused on every tick. gated and status tell an all-gated file apart only on the tick that parked its gates. On every later tick while they wait, both read as a healthy file’s do.

Two operator notes. Set your staleness threshold from your own timer interval, not from a number in this document — the runtime does not know how often you run it. And do not write to this file yourself: nothing here reads it back, so a file you author misleads only your own detector, but it will do that silently.

14. The audit store

Every change to who may act, and every side effect that fires, is appended to an audit record under <home>/audit/ before it takes effect. The store is append-only and has one writer, the runtime’s audit module. Nothing in this runtime prunes it, rotates it away or rewrites a line in it. Retention (§ 6) never reaches it. The operator archives it by export.

The record is tamper-evident relative to the last head you exported and kept off the box. A local writer that can write the home can still edit, insert, reorder or truncate lines and re-link the chain behind them. What it cannot do is make the result agree with a head it never saw. Nothing stronger than that is claimed.

Record format

Each line the writer writes is one CloudEvents 1.0 structured-mode JSON object followed by a newline. CloudEvents defines a single object and a JSON array batch but no line-delimited format, so one object per line is warpline’s own convention. Such a line is called an audit record.

The keys are written in this order, and a reader may rely on it:

Key Value
specversion "1.0"
id The record’s warplineseq, as a decimal string.
source The home id: a urn:uuid: minted when the store’s first line is written. Never the home path, which carries the operator’s username.
type warpline.audit.<kind>, one of the kinds below.
time RFC 3339 UTC, when the record was appended.
datacontenttype "application/json"
warplineseq An integer, 1 for the first line, and the previous record’s plus one after that. Contiguous across the whole store.
warplineprev The lowercase hex sha256 of the previous record, or 64 0s on the first line.
data The kind’s own fields. See below.

The byte rule. The hash input is the exact bytes of a line as stored, with its trailing newline excluded. warplineprev is the hash of the previous record under that rule. Nothing is re-serialized to compute or check it, so a verifier needs no canonical JSON form and compares bytes. A record’s own hash is never stored in that record. It is stored in the next record’s warplineprev, or nowhere if there is none.

warplineseq is a CloudEvents Integer, so it is valid up to 2,147,483,647 records. source plus id is unique per record, as CloudEvents requires, because the seq is contiguous within one home.

data holds identifiers, closed enums, lowercase hex sha256 digests and ISO times. It never holds approved content, a secret value or a configuration value. Each kind’s fields are a strict schema: an unknown key, a malformed digest or a line longer than 16384 bytes is refused, nothing is written, and the refusal’s message names the kind and a fixed reason, never a value from the data.

Record kinds

The set is closed. A kind outside it cannot be written.

Kind What it records
grant.issued warpline approve is about to write a grant. A session grant (kind: session) carries the scopes, duration and flags asked for. A standing grant (kind: standing) carries its id, holder, scopes, renewal period and hard maximum. Both carry the acting principal.
grant.renewed warpline renew is about to restart a standing grant’s period: the grant id, its holder, the renewing principal and the new renewal deadline. That deadline can fall after the grant’s hard maximum, and the grant lapses at the earlier of the two (§ 9).
grant.revoked warpline revoke is about to remove a grant. A session revoke (kind: session) carries the live scopes it clears. A standing revoke (kind: standing) carries the grant ids, sorted, their holder and the union of their scopes. Both carry principal, the registered id --principal named and the registry confirmed, or null, and principal_unchecked, the id --principal named when the registry could not confirm it, or null.
content_approval.issued A content approval is about to be bound to an Output’s fingerprint.
content_approval.withdrawn A content approval is about to be removed.
denial.recorded warpline deny is about to record a denial: the plugin, the proposal’s fingerprint, and the run id of any parked gate it discards.
denial.lifted warpline deny --remove is about to take a denial back.
fire.intent A plugin holding side-effect authority is about to be invoked. grants names every live grant that covered it: one entry per covering session window (kind, scope, and issuer or null), so a live * window and the plugin’s own window are two entries, and one per live standing grant (kind, id, holder, issuer). Session windows come first, then standing grants by id. A content-class fire’s list is empty: its authority is its content approval.
fire.outcome The invocation that intent announced returned or threw.
fire.refused A plugin holding a content approval was not fired, and why (§ 5).
fire.resolved warpline resolve recorded the operator’s answer to an open fire intent: shipped or not_shipped.
principal.added A principal is about to be added to the registry.
principal.disabled A principal is about to be disabled.
principal_registry.observed The principal registry changed without a warpline command.
preference.set A preference key is about to be set by a warpline command.
preferences.observed The preferences file changed without a warpline command.
ask.raised An Ask is about to be stored (§ 16): its id, kind, scope, plugin (null for a standing grant’s renewal), digest, raised_at and expires_at. Never its nonce or its summary.
ask.answered An Answer is about to close an Ask (§ 16): the Ask’s id, kind, scope, digest and raised_at; the answer; principal, the registered id that answered, never null; via, the id it came through, or null; os_user, the account that ran the command, or unknown when it cannot be read or is not an account-name token, recorded beside the principal and never used as one; evidence, a bounded reference token with no whitespace, ?, &, = or @, or null; assurance, always asserted in this release; and decide_ms, the ms from the Ask’s raised_at to the Answer, never from a reminder.
handoff.tried A handoff tried.
segment.opened The first line of a segment file. One opened by warpline audit pass-over names each line it passed over, by its position and sha256, as passed_over.
segment.sealed The last line of a segment file that is full or old.
checkpoint.recorded The head as it stood, so it can be exported and checked later.

Nothing writes handoff.tried yet. Its schema admits nothing, so an append under it is refused until the work that writes it lands and defines its fields. The last three kinds in the table are written by the store itself, never by a caller.

Each grants entry on a fire.intent names its issuer. A session window’s issuer is whichever active principal, human or machine, last named that scope with --principal, or null when the last grant of it named none. A standing grant’s issuer is always the active human who issued it. The seven verb records, grant.issued, grant.revoked, content_approval.issued, content_approval.withdrawn, denial.recorded, denial.lifted and fire.resolved, carry principal: the registered id --principal named, or null when none was named. A principal is never taken from the environment or from the account running the command. os_user on ask.answered is the one account read on record, and it is never a principal. grant.issued and grant.revoked carry kind, the kind of grant the record is about. Records written before these fields existed are read without them.

The two observed kinds come from comparing an authority file’s bytes with the last digest the store holds for it, never from a warpline command. preferences.observed carries old and new, the whole file’s sha256 before and after (null for a file the store had not seen, or one now missing), and changed and editor, which are always unknown. No value, key path or per-key digest is recorded. principal_registry.observed carries old, new and editor the same way, changed_ids: every id added, removed or whose entry digest differs from the map the store carries, sorted, and changed_entries, which maps each of those ids to its new entry digest, or to null when the id left the file. It does not carry the whole map. Records written before this change carried the whole map as entries in place of changed_entries, and principal.added and principal.disabled did too (§ 15). Those are still read. The comparison and the append happen in one hold of the audit lock, against the active segment and the authority it carried forward (§ 14 Segments).

Three kinds close a fire.intent: a fire.outcome, a fire.refused or a fire.resolved whose intent_seq is that intent’s seq. One whose intent_seq is null closes nothing. An intent none of them has closed is open. A fire.resolved carries the operator’s answer, shipped or not_shipped, and its effect_id is null for a session-class fire.

Genesis and the head

The store’s first line is a segment.opened record at seq 1 with 64 0s in warplineprev. Its data names the home id, which is also every record’s source, and the warpline version that wrote it. There is no separate genesis kind. Segment files are named by the seq of their first line, zero-padded to 16 digits, so the first is 0000000000000001.jsonl and name order is seq order.

warpline audit head prints the head as <seq> <hex> on one line: the last record’s seq and the sha256 of its bytes under the byte rule. Before the first record it prints 0 and 64 0s, exits 0, and creates nothing. It takes no lock and writes nothing, so it can be run at any time. Keeping its output off the box is what makes a later check of the store mean something.

warpline audit head --c2sp prints the same head as an unsigned note body shaped like a C2SP checkpoint, three lines: the home id as the origin, the head seq as the size, and the standard base64 of the 32-byte head hash as the root. It is not a valid C2SP checkpoint. It carries no signature line, and the root is the head of a hash chain, not a Merkle root. On an empty store it refuses with exit 1 and prints nothing, because there is no home id yet to name.

When the active segment’s last complete line is not a record, there is no head to print, and audit head and --c2sp exit 1, print nothing on stdout, and print the walk’s refusal on stderr (§ When the store refuses).

Writing

One writer appends at a time. Within a process, appends queue behind each other. Across processes, writers take the audit lock, audit/.lock, a symbolic link made in one step whose text names the holder: a random token, the holder’s process id, the machine identifier § 12 derives, and the time it was taken, which is for people and judges nothing. Making the link either creates it with that text or fails because it exists, so a lock is never empty and never half-written. So the home’s filesystem must be able to hold a symbolic link. On one that cannot, every append refuses, and nothing is written. On Linux the identifier also carries the holder’s pid namespace and the kernel’s boot id. So containers that share a machine id, and clones of one machine image, are not taken for one machine, and a process id from before a reboot is never tested after it. When either cannot be read, the holder names no machine. A writer waits for the lock, polling every 50 ms, for up to 10 seconds.

A lock is broken only when its holder is provably gone on this machine: it names a process id and a machine, that machine is this one, and no such process runs. A process that belongs to another user counts as running. No lock is broken for its age. A lock whose holder still runs, however long it has held the lock, or that names another machine, one that cannot be told, or no process, or that cannot be read as a holder at all, stays until its holder removes it or someone removes it by hand.

One writer at a time breaks a lock. It makes audit/.lock.break the same way, reads the lock again, judges it again, and removes it only while it still names the token it was judged by and its holder is still gone. Then it removes its own break file. The break runs without a wait, and a gone holder cannot release, so the lock cannot change between that second judgment and the removal. A lock that changed in between, gone, replaced or no longer read as gone, is not removed, and the writer judges it again at once. A break file is never removed by another writer, whatever it names and however old it is. While one is there no lock is broken.

A writer removes its own lock only while it still names that writer’s token. It does so at the end of its hold, and from an exit hook when the process ends through process.exit while holding it, as advance does on SIGINT and SIGTERM. A holder killed outright, by SIGKILL or by a signal to a command with no handler, runs no exit hook. On a machine that identifies itself, the next writer breaks its lock at once. A lock that cannot be taken in time is an append failure, whatever is at the lock’s path. The refusal is worded from the lock as it stands when the wait ends, never from an earlier look. It says to remove a lock by hand with no condition only when its holder is provably gone and removing it failed (§ When the store refuses).

Four cases stay open.

A holder that hangs while it runs blocks every append until it is killed, as § 12 chooses for the run lock.

A holder that died on another machine that shares the home, on one that cannot identify itself, or, on Linux, before a reboot leaves its lock for removal by hand. So does a breaker killed outright inside its break, for its break file. Elsewhere the machine identifier outlives a reboot, so a process id from before it is tested after it: a free one reads gone and its lock is broken at once, and a reused one reads as running, as the next case says.

A dead holder whose process id was reused reads as running, and its lock stays. If it is not a warpline process, the lock can be removed by hand. A process start time in the identifier is the upgrade path.

There is no fence on the store itself. A holder judged gone that is not, through an identifier two machines share that the boot id does not tell apart, or a write still in flight when the exit hook removed its lock (a window of microseconds), can interleave with the next holder’s write, and audit verify reports it.

A break file removed by hand while its breaker is still inside the break, a window of a few milliseconds, lets a second breaker in, and the first breaker then removes the second one’s break file without checking whose it is. So two breaks can overlap, and one can remove a lock a new holder has just taken. Warpline never removes a lock or break file it cannot read as a holder, such as a directory or a plain file.

The state lock (§ 12) is always taken first. A command that holds the state lock may append, and the store never takes the state lock, so the two are never taken in the other order.

Each record is one write of the whole line on a file opened for append, checked for length, followed by datasync. Creating a segment file also syncs the directory. The append resolves only after both.

A limit on macOS under Bun. Bun’s datasync on macOS does not issue F_FULLFSYNC. A record written there survives a process or OS crash, but not a power loss while the drive’s write cache still holds it. Under Node, and on Linux under either runtime, the call flushes. The upgrade path is fcntl(F_FULLFSYNC) through bun:ffi. Until then this is a stated limit, not a guarantee.

Segments

The store is a run of segment files. Each is named by the seq of its first line, zero-padded to 16 digits, so name order is seq order and the last name is the active segment. No line spans two files, and the chain runs straight across them.

Before each append the writer checks the active segment. It rotates when the segment is 64 MiB or larger, or when 30 days have passed since the time on its segment.opened line. Both are constants in the code. Neither is a preference or an environment setting.

A rotation writes, in order:

  1. segment.sealed as the old file’s last line. Its data holds the reason, size or age, and the file’s length in bytes before the sealed line.
  2. A new file whose first line is segment.opened, with warplineprev the hash of the sealed line.
  3. checkpoint.recorded, covering every line up to that segment.opened. Its data holds the home id as origin, the opened line’s seq as size, and the opened line’s hash as root.
  4. The record being appended.

Each is its own write and datasync, and the directory is synced once the new file exists.

Every segment.opened after the first carries forward what a reader would otherwise have to look for in an earlier file:

So a reader that needs the current authority digests or the open intents reads the active segment and nothing else, and it walks only what it carries. That is each line’s type, and for fire.intent, fire.outcome, fire.refused, fire.resolved, preference.set, preferences.observed, principal.added, principal.disabled and principal_registry.observed the fields it carries: the plugin, run id and effect id of an intent, the intent seq a closing record names, the authority digests and the principal entries. Each is checked by its own field rule, the one the writer uses. A principal record is folded into the carried map in either shape: one that carries the whole map in entries replaces it, and one that names a change sets that id’s digest, or for changed_entries removes an id whose digest is null. A principal record that holds both shapes at once stops the walk, since no writer writes one. The segment.opened that opens the active segment is held to the same rules for the authority and open intents it carries. Every other kind, and any key a field rule does not name, is passed over, so a record a later build writes, or a field it adds, does not stop an older reader. A line that is not a record, a first line that is not a segment.opened it can carry, or a carried field no writer would write stops the walk, so no such value reaches a reader’s output.

A walk that stops. Every command that needs the walk refuses: an advance, prefs set, principal, resolve, and any append that opens a segment or finds no record at the end of the active segment. Each gives the walk’s refusal (§ When the store refuses). Keep an export of the store, then run warpline audit pass-over <seq>..., naming every line the walk stops on. If another line still stops the walk, it refuses and names that one.

Pass-over edits no line. It opens a new segment after the active one as it stands. The new segment’s segment.opened carries forward the state reached by a walk that skips exactly the named lines, and names each one in passed_over, by its position and the sha256 of its bytes under the byte rule. Whatever a passed-over line said is lost to the walk. Its bytes stay where they are and --after 0 exports them (§ Export), but the new segment.opened keeps only its position and sha256, so an intent it opened is not carried and one it closed stays open. Verify checks each entry against the segment before it (§ Verify), so every anchor taken before or after the pass-over still holds. A partial last line is acknowledged in fragment beside it, as any rotation does. When a size or age rotation is due, it seals first and writes that rotation’s Checkpoint, as any append does. Otherwise it writes no seal and no Checkpoint. Apart from a due rotation’s seal and Checkpoint, it writes nothing but the new segment.opened, as the heal of a lost successor writes only that.

The position of a line is the seq its segment is named for, plus the lines before it in that file. The warplineseq a line carries is what the line claims about its position, and verify reports a record whose claim is not its position tampered. A segment.opened names lines of the segment before it by position. The walk, resolve and verify take a line as passed over only at that position, and only while its bytes still hash as the entry records. A pass-over opens the new segment at the position after the active segment’s last record, whatever seq that record claims, so a pass-over carries no claimed seq forward. When lines that are not records end the active segment, the first of them therefore shares its position with the new segment’s opening line.

It refuses with exit 1, and writes nothing, for a seq outside the active segment (a sealed segment’s line, or past the head), a line the walk carries, a store where another line it was not given still stops the walk, and a home with no store, where it creates nothing. A build that predates passed_over still reads the segment, because the walk strips keys it does not carry.

The active segment’s first line has its own refusal, seq N opens the active segment and cannot be passed over. That line holds the state the walk starts from, and a pass-over has nothing to carry forward without it. A walk that stops there refuses with seq N opens the active segment and is not a segment.opened the walk can carry, whether the line is a segment.opened this build cannot carry or not a record at all. The first case is a line only a later build writes, so run the build that wrote it, or a later one. A first line that is not a record has no recovery in this build.

A line that is not a record is passed over the same way. When it ends the active segment, as a write that went wrong leaves it, the new segment opens after the last record before it: its segment.opened takes the position after that record, links to that record’s hash, and names the line in passed_over by its position and the sha256 of its bytes. No size or age seal is written past such a line, as none is written past a partial one. One with records after it in its segment can only be put there by hand, and it breaks the chain where it sits. The pass-over still lets the walk go on past it.

A torn tail. A write that stops part way leaves a partial last line. The writer never writes past it and never removes it. The next append seals nothing, because a partial line can’t be sealed past. It opens a new segment after the last complete line, whose segment.opened records the fragment’s length in bytes and its sha256 as fragment, and then writes the record. Every byte of the torn file stays as it was. A newest segment that holds no complete line holds no record. When it is empty, the writer derives what the writer that created the file would have, and writes into it only when that opens a segment of the same name, or else refuses. When it holds only a partial line, every append and pass-over refuses (§ When the store refuses).

A lost successor. A crash between the sealed line and the new file leaves an active segment that ends in segment.sealed. The next append heals it. It opens the successor with segment.opened and writes the record, and it writes no Checkpoint.

Nothing in this runtime moves, prunes or deletes a segment. The store only grows, and the operator archives by export.

Before the effect

A record is written before its effect, and an effect whose record cannot be written does not happen. The record says what was about to happen, not what did.

warpline deny appends one denial.recorded per plugin, in the order named, inside the state lock and before it writes the state document. The --note text is the operator’s own words and never enters a record. If any append fails, deny writes nothing, says on stderr that nothing was denied, and exits 1. A record already appended for an earlier plugin in the same command stands when a later one cannot be written: it records what was about to happen, and the store never takes a line back.

The same rule covers every other authority change a verb makes. warpline approve appends grant.issued before it merges a grant, and warpline renew appends grant.renewed before it writes the standing grants file (§ 9). approve appends content_approval.issued or content_approval.withdrawn inside the state lock before the state write (§ 10). warpline deny --remove appends one denial.lifted per plugin before the state write. Each of these refuses with exit 1 and nothing changed when its append fails.

When --principal names who acts, approve, renew, deny and resolve check it inside the state lock, before the append. So a principal disabled while the verb waited for the lock is refused, and nothing is written.

warpline revoke is the one exception. It appends grant.revoked before it removes the session grant file or writes the standing grants file, but a revoke only narrows authority, and a failing store must never leave authority wider than the operator chose. So when that append fails, or the store cannot record a change to principals.json the revoke read, the grant is removed anyway, and the command exits 70 with a stderr line saying what was not recorded. That covers all three forms, --holder and --standing as well as bare (§ 9).

A fire is written ahead the same way. When warpline advance fires a session-class plugin that declares side effects, it appends fire.intent immediately before the handler runs, and fire.outcome naming that intent’s seq once the handler returns, with the result’s status, or threw when the invocation threw. An intent that cannot be written stops the fire. The intent must fit one record, so enough live standing grants on one scope push it past 16384 bytes: the append refuses and the effect does not fire, which fails closed. Revoking grants on that scope brings it back under. A content-class fire’s intent is written by its spend mark, inside the state lock after the re-check and before the mark (§ 10), and its outcome closes it the same way. A refused fire gets fire.refused with its closed-set reason and no intent, because nothing fired, except when the spend mark’s write failed after its intent landed: that refusal carries the intent’s seq and closes it. warpline resolve has its fire.resolved on the record before it writes anything: appended then, or found there from a run whose state write did not land (§ 10). Its --intent form writes nothing else (§ 10). A plugin with no declared side effects gets no record, and neither does warpline run. An outcome or refusal that cannot be written does not undo what already happened; the advance exits 70 (§ 11).

Checkpoints

A checkpoint.recorded record anchors the chain. Its data is { origin, size, root }: origin is the home id, size the seq of the line before it, and root that line’s hash under the byte rule, in lowercase hex. It covers every line up to and including that one, and nothing after.

Every warpline advance that returns writes exactly one, quiet hours included, immediately before the dead-man file (§ 13). Three advances add three. A rotation writes one after the new segment’s segment.opened (§ Segments), so an advance that rotates the store writes two. An advance refused before the run lock, by an invalid preferences.json or an unreadable plugin root, writes none and adds no byte to the store. Neither does an advance that throws after the lock. warpline audit export, audit head and every other reader write none.

It is shaped like a C2SP tlog checkpoint and is not one. There is no Merkle tree, so size counts records in a linear chain and root is the hash of the last one, not a tree root. There is no signature either. A witness tool or a C2SP verifier cannot check it. What it gives you is a head to export and keep off the box, inside the record itself, at the cadence of your scheduler.

The audit object in advance --json

warpline advance --json carries audit on every advance that returns, gated, failing and quiet-hours ones included. It is the Checkpoint append’s own return value, computed in the same hold of the audit lock that wrote the Checkpoint, and passed through unchanged:

Field Meaning
seq The head seq, which is the Checkpoint’s own seq.
bytes The summed size of every segment file, in bytes. Null when the Checkpoint landed but the store could not be read back after it.
segments How many segment files the store holds. Null when the Checkpoint landed but the store could not be read back after it.
checkpoint_seq The Checkpoint’s seq.
indeterminate Every fire.intent no fire.outcome, fire.refused or fire.resolved has closed, in seq order, each { seq, plugin, run_id, effect_id }. effect_id is null for a session-class fire. Null, never [], when the Checkpoint landed but the store could not be read back after it.

An intent is indeterminate when the process died between it and its outcome, or when its outcome could not be written (§ 11). The fire may or may not have happened, and the store cannot say which. It is surfaced and never held: the plugin fires again when it is next due, and the advance’s exit code does not change on its account. Once you have checked whether the effect happened, answer it with warpline resolve --intent <seq> --shipped or --not-shipped, which closes it, and the next advance no longer lists it. A content fire still marked and unconfirmed is answered with `warpline resolve --shipped

` or `--not-shipped `, which closes its intent too, and the by-seq form refuses it (§ 10). `audit` is `null` only when the Checkpoint could not be written. Its three read-back fields, `bytes`, `segments` and `indeterminate`, are `null` when the Checkpoint was written but the store could not be read back after it, and `seq` and `checkpoint_seq` still name the Checkpoint. Either way the advance exits `70` (§ 11). The object holds integers and identifiers only. The dead-man file does not carry it. ### Export `warpline audit export --after ` starts at the last segment whose name is at or below that seq plus one. From there it prints, segment by segment in name order, every complete line whose position is after that seq, with its bytes unchanged, a record as one CloudEvents structured-mode object per line. One object per line is warpline's own convention (§ Record format). Checkpoints are records, so they travel with the stream. `--after` takes `0` or a positive integer up to 9007199254740991, nothing else: `-1`, `01`, `1e3`, a larger integer and a missing `--after` are usage errors with exit `1`. `--after 0` prints every complete line in the store, and on a store with no partial line its output equals the segment files concatenated in name order, byte for byte. At the head it prints nothing and exits `0`. Past the head it prints nothing, names the head on stderr as `beyond head`, and exits `1`. What this section says export prints, and what `audit head` prints (§ Genesis and the head), is promised only on a store `audit verify` reports `clean`, `torn` or `unreadable`. On a tampered store verify's verdict is the true account, and export still prints only complete lines, segment by segment in name order. Export prints a line that is not a record, passed over at the end of a segment, exactly when `--after` is below the position of the last record before it, as `--after 0` is. When the active segment's last complete line is not a record, there is no head to hold `--after` against. Export prints by the rule above, that line included, and exits `0`, so an export can be kept before a pass-over. A partial line is never exported. When a `segment.opened` follows it, that line records its length and digest (§ Segments), and stands in for it. Export reads only. It takes no lock, writes no Checkpoint and adds no byte to the store. It reads each segment a chunk at a time and waits for the reader before it writes more, so a 64 MiB segment is never held in memory. A reader that goes away (`| head`) ends it quietly with exit `0`. The store is archived this way, by export, and never moved. ### Verify `warpline audit verify --checkpoint <file|->` checks the store against a head you kept off the box. The store is tamper-evident relative to the last exported Checkpoint, and this is the check that makes it so. The anchor is read from the file, or from stdin with `-`, up to 64 KiB, in either form: - ` ` on one line, as `audit head` prints it, seq up to 9007199254740991. - The note body `audit head --c2sp` prints: origin, size up to 9007199254740991 and base64 root, read up to the first empty line. There is no unanchored check. Without `--checkpoint`, or with an anchor that is neither form, verify is a usage error with exit `1`. A check with nothing to compare against would read as fine when it could not look. | Verdict | Exit | Meaning | |---------|------|---------| | `clean` | `0` | Every record links to the previous record's hash (64 `0`s for the first record). The record at the anchored seq hashes to the anchor, or the anchor is `0` with 64 `0`s. | | `torn` | `3` | As clean, except the store ends in a partial line or in a segment that holds no complete line, keeps a partial line that a later `segment.opened` acknowledges, or its last segment ends in `segment.sealed` with no successor. A crash leaves these. | | `tampered` | `4` | A complete line does not parse, other than the last complete lines of a segment that the next `segment.opened` names in `passed_over` by position (a next segment that holds no record names nothing), a record's `warplineseq` is not its position, its `warplineprev` is not the previous record's hash (64 `0`s for the first record), a segment is not named by its first seq, a partial line anywhere but the very end goes unacknowledged, a `segment.opened` has a `passed_over` that names a position not in the segment before it or a line that does not hash as recorded, the anchored seq is beyond the head, or the record at the anchored seq does not hash to the anchor. | | `wrong log` | `5` | The anchor's origin is another home's id. | | `unreadable` | `6` | The chain checks clean or torn, but the active segment holds a line the walk cannot carry (§ 14 Segments), so the open intents cannot be listed. The reason is the walk's refusal (§ When the store refuses). | When more than one applies, wrong log wins, then tampered, then unreadable, then torn. None of the codes is `70`, `75` or `130`. A writer that can write the home can edit, reorder, insert or drop lines and re-link every `warplineprev` behind them, and every link then checks. Only the anchor catches that: the line at its seq no longer hashes to it, or the seq is gone. Verify prints `verdict`, the anchor, the head when there is one, how stale the anchor is, a `reason` naming a seq or a file and the rule it broke when the verdict is not clean, and one `open intent` line per fire intent nothing has closed, each with its seq, plugin and run id. When the open intents cannot be read, it prints one `open intents unreadable` line naming why in their place, under any verdict, and never an empty list. Staleness is the seq of the last record minus the anchored seq in records, and the time since the anchored record's `time` in seconds, the second only once that record's hash has matched. An anchor at the head is `0 records` stale. Verify never prints a record's data or the anchor file's text. Verify reads only. It takes no lock, writes no Checkpoint and adds no byte to the store. ### When the store refuses A crash, a killed command or a lock left behind can leave the store where an append, or a command that needs the walk, will not go on. The refusal names what is wrong, and the next step when there is one. It is fixed words, a seq, a segment file name, a process id or an errno code, and never a line's data. A command that does not hold the audit lock never names a step by hand on a segment file, and reads a newest segment that holds no complete line as not yet written. `audit verify` prints the walk's refusal on its `open intents unreadable` line under any verdict. Take the step, then run the command again. The words of each refusal live in the code, and this section does not repeat them. ## 15. The principal registry `/principals.json` names who may act. It is owner-only: mode `0600` after every write, a file that was looser beforehand included. A missing file is an empty registry, except to a standing grant: there it reads as `registry unreadable` (§ 9). ```json { "principals": [ { "id": "ops", "type": "human", "status": "active" }, { "id": "ci-bot", "type": "machine", "status": "disabled", "key": "ssh-ed25519 AAAA..." } ] } ``` | Field | Value | |-------|-------| | `id` | 1 to 64 characters of `a-z`, `0-9`, `.`, `_` and `-`, starting with a letter or digit. Unique in the file. | | `type` | `human` or `machine`. | | `status` | `active` or `disabled`. | | `key` | Optional. One key, 1 to 8192 characters with no control character. Stored here and nowhere else. | The file has one writer, `warpline principal`: - `warpline principal add --type human|machine [--key ]` adds an active entry at the end of the file. - `warpline principal disable ` sets an active entry's status to `disabled`. - `warpline principal list` prints one tab-separated line per entry, in file order: the id, the type, the status, and `key` or `no key`. It never prints the key. With no file it prints nothing and exits `0`. Each add appends `principal.added`, and each disable `principal.disabled`, to the audit store (§ 14) before the file changes. The record carries the id, the sha256 of the exact bytes about to be written, and `entry_sha256`, the sha256 of that one entry as it will be written (`id`, `type`, `status`, then `key` when set, as JSON in that order). `principal.added` also carries the type and `key_sha256`, the key's sha256 or null. The key itself is never on the record. No record names the other entries, so no record grows with the registry, and there is no limit on how many principals it holds. The whole map of id to entry digest rides each `segment.opened` instead (§ 14 Segments). When the append fails, nothing is written and the verb exits `1`. An add or a disable reads the file, checks it, records the change and writes it under the state lock (§ 10), so two adds of one id at once register it once, and two adds of different ids keep both. Ids are never deleted or reused. Disable is the only way out of the registry. Adding a known id is refused with exit `1` and no record. An id is known when any of three sources names it: - `principals.json`, active or disabled; - the audit store, on any line it holds: a principal record, the ids a hand edit changed, or the registry a `segment.opened` carries, so an id only an older segment named counts, even after that segment was moved aside; - the standing grants file, as a grant's holder or its issuer. So an id stays known after a hand edit, a lost file or a home restored without `audit/` took it out of `principals.json`: a standing grant it held would otherwise read live again for whoever got the id. A source that names the id settles it. Otherwise, when any source cannot be read, `principal add` cannot tell, and refuses every add, naming the source. `revoke --holder` asks the same question (§ 9). Disabling an entry that is already disabled, or an id that is not there, is refused too. No principal is ever inferred from the account running the command. An id comes from the operator's argument and from nowhere else, and `add` with no id is a usage error. The file may still be edited by hand. Every read by `warpline principal` compares its bytes with the last digest the store holds, before the verb acts on what it read, and a difference is recorded as `principal_registry.observed` naming the ids whose entry digests changed (§ 14). When that record cannot be written the verb refuses with exit `1`. That record must fit in one 16384-byte line, and it names every changed id twice, so one edit can change at most 78 entries with 64-character ids, more with shorter ones. An edit that changes more at once is refused by every `warpline principal` verb with a sentence saying it is more than one audit record can name, and nothing is written. To record it, put part of the change back, run `warpline principal list`, then make the rest. A file that will not parse, or that fails the schema, is refused with a message naming key paths and schema facts, never a value from the file. `warpline approve`, `warpline deny`, `warpline resolve` and `warpline revoke` read this file when `--principal` is given, and record a hand edit first, as `warpline principal` does. The forms that read `asks.json` are the exception: an Answer through `approve `, and every form of `approve --content`, `deny `, `deny --standing` and `renew`. They judge `--principal` and `--via` on the file as read, and record a hand edit of those same bytes just before their first audit record, never by a second read. So a command they refuse leaves the audit store as it was (§ 16), and one they carry out records the edit before anything it does. Without the flag the verbs do not read the file, with two exceptions. `warpline approve --standing` always reads it, since it requires `--principal` and checks `--holder` against it too. `warpline revoke --holder` reads it to check the holder, with or without `--principal` (§ 9). `warpline renew` always reads it, since it requires `--principal`. The forms outside that exception record a hand edit first. A revoke goes through even when that record cannot be written (§ 9). An advance reads this file only when the standing grants file holds a grant (§ 9), and records a hand edit first, as `warpline principal` does. A record it cannot write ends the advance with `75` before anything fires. `warpline plan` reads this file without recording it. ## 16. Asks and Answers A run that waits on a human sends an Ask through the operator's `notify` command, and the Answer comes back through the CLI. ### The `notify` setting `notify` is a block in `/preferences.json`. Absent or `null`, it is unset. | Key | Default | Bounds | |---|---|---| | `notify.argv` | none, required | at least one entry, each a non-empty string | | `notify.timeout_ms` | `10000` | an integer from `1` to `300000` | | `notify.include_content` | `false` | a boolean | | `notify.reminders_ms` | `[3600000, 14400000]` | positive integers, strictly ascending | Set it with `warpline prefs set notify ''`, or one key at a time with `warpline prefs set notify.timeout_ms 5000` once a block holding `argv` exists. The set validates the block strictly and goes on the audit record like every other set (§ 6): an empty `argv`, an empty entry, an unknown key, a timeout out of range or reminders out of order exits `1` with nothing written and nothing recorded, naming the key path and never the typed value. `prefs set notify null` unsets it. A block holding only `argv` takes the defaults above. Retry count and backoff are not settings. The command is spawned from `argv` with no shell, so `$(…)` or a quote in an entry arrives as text. One JSON message goes to its stdin. Its stdout and stderr are discarded, and only its exit code, or `timeout`, is kept: exit `0` is delivered, any other exit is failed, and a command that cannot start is failed with no code. At the timeout the command's whole process group is killed, a grandchild included. So is a group still running when warpline exits. The command's environment is built from an allowlist and nothing else: `PATH`, `HOME`, `LANG`, `LC_ALL`, `TZ`, `TMPDIR`, `http_proxy`, `https_proxy`, `HTTP_PROXY`, `HTTPS_PROXY`, `all_proxy`, `ALL_PROXY`, `no_proxy`, `NO_PROXY`, `SSL_CERT_FILE`, `SSL_CERT_DIR` and `NODE_EXTRA_CA_CERTS`, each only when set. A plugin secret in warpline's environment never reaches it. A hand-edited block that is unusable degrades instead of refusing the file (§ 6). Keep secrets out of `argv`, which `ps` shows to every user on the machine. Put them in the script's own configuration instead. ### The Ask store Each Ask lives in `asks.json`, beside `engine-state.json` in the state directory. The file is written owner-only (`0600`), atomically, and synced after each write, because it holds live nonces: a lost write could hand back a nonce already used. The file is `{ "min_reader_version": 1, "asks": [...] }`. A missing file holds no Asks. A file that is not JSON, not an object, whose `asks` is not an array, or holding any Ask that does not parse is `corrupt`. A `min_reader_version` above this build's, read before any other field, is `newer reader`. Any other read error is `io`. An unreadable store is never read as "no Asks". A key this build does not know is ignored. | Field | Meaning | |---|---| | `id` | 12 lowercase hex, unique in the file. It names the Ask wherever the Ask is listed. | | `nonce` | 32 lowercase hex, drawn apart from the id. It is the single-use token an Answer presents, and no command prints it. | | `kind` | What the run waits on: `parked_result`, `missing_grant`, `content` or `renewal`. | | `scope` | The plugin the Ask is about, or for a renewal the standing grant's id. | | `plugin` | The plugin, or `null` for a renewal. | | `digest` | 64 lowercase hex naming exactly what the Ask is about. | | `summary` | A fixed sentence for the kind (below). | | `raised_at` | When the Ask was raised. | | `expires_at` | The earlier of the moment the thing asked about stops being answerable and 23 hours after `raised_at`. | | `state` | `open`, `answered`, `superseded` or `expired`. | | `closed_at` | When the Ask left `open`, or `null`. | | `answer` | `approve`, `approve_content`, `renew` or `deny` once answered, else `null`. | | `events` | Each lifecycle event, `{ seq, event, at }`, with `event` one of `raised`, `reminder`, `expired` or `superseded`. `seq` counts from 1 within the Ask. | | `tries` | Each attempt to deliver an event, `{ seq, at, outcome, exit_code }`, with `outcome` one of `delivered`, `failed` or `timeout`. | The summary is built from the kind and the plugin name only. Nothing a plugin or a model wrote can reach it: | Kind | Summary | |---|---| | `missing_grant` | ` declares side effects and holds no Grant, so it did not run. Approve or deny it.` | | `parked_result` | ` ran and its result is parked for review. Approve or deny it.` | | `content` | ` is waiting for its content to be approved. Approve or deny it.` | | `renewal` | `A standing grant is due for renewal. Renew it or deny it.` | An Ask goes on the audit record as `ask.raised` (§ 14) before it is stored. When the audit store refuses that record, the Ask is not stored and nothing is sent for it. While an Ask is open with the same kind and digest, nothing new is raised for its scope. An event is due until a try with outcome `delivered` is recorded for its `seq`. Every due event goes to `notify` in one message, `{ "entries": [...] }`. Each entry holds `id`, `seq`, `event`, `kind`, `scope`, `plugin`, `digest`, `nonce`, `summary`, `raised_at` and `expires_at`, and `content` only when `notify.include_content` is `true`. A retry resends an event under its own `seq`, so a receiver drops a duplicate by (`id`, `seq`). ### Raising and sending `warpline advance` raises Asks, through `runAdvance`, so a library host that calls `runAdvance` raises them too. `warpline run` never does: it reads no grant (§ 9), so it records none of the decisions an Ask is about. With `notify` unset, nothing is raised or sent and no `asks.json` is written. A **missing Grant** raises an Ask of kind `missing_grant`. That is a plugin declaring side effects, of the session class, that the advance skipped for want of a Grant. Its `scope` and `plugin` are the plugin's name. Its `digest` is the plugin's proposal fingerprint, the value `warpline deny` records for it (§ 10, `denials`). It expires 23 hours after it was raised, because the Grant it waits on has no clock of its own. The raise happens inside the end-of-run state-lock hold, after the state document and the home's version stamp are written and before the Checkpoint (§ 14). So the Checkpoint covers each `ask.raised`. A decision whose open Ask has the same kind and digest raises nothing new, however many advances see it. A fault here leaves the decision waiting. It never skips the state write and never changes the exit code. The send happens after the dead-man file (§ 13) is written, with the run lock held and no state lock held. Every due entry goes in one message per advance. The try is recorded afterwards, in its own hold of the state lock, from a fresh read of the store, by Ask id and `seq`. The copy of the store read before the send is never written back, so an Answer that lands while `notify` runs is kept. A fault while sending or recording leaves the entry due, and the next advance sends it again. A dry run and an advance skipped for quiet hours raise and send nothing. So does an advance whose `notify` block is unusable, and everything else it does is unchanged. ### The four kinds Every kind is raised in the end-of-run hold above, from the state document that hold just wrote. | Kind | Raised when | `scope` | `plugin` | `digest` | `expires_at` | Severity | |---|---|---|---|---|---|---| | `parked_result` | A supervised plugin's result waits in `pending_gates` and can still be applied (§ 10, Applying a gate), for a plugin this advance loaded. | the plugin | the plugin | the plugin's proposal fingerprint | the earlier of the gate ceiling (`run_completed_at` plus the earlier of `ttl_hours` and 23 hours, the rule `approve` refuses an expired gate on) and `raised_at` plus 23 hours | `warning` | | `missing_grant` | A session-class plugin declaring side effects is skipped for want of a Grant. | the plugin | the plugin | the plugin's proposal fingerprint | `raised_at` plus 23 hours | `info` | | `content` | A content-class plugin is refused at the content gate on a standing an Answer can settle, and its producer's current Output carries its bytes inline. | the consumer | the consumer | the producer's proposal fingerprint, the value `warpline approve --content` records | `raised_at` plus 23 hours | `info` | | `renewal` | A live standing grant's renewal deadline is before its hard maximum and at most 23 hours away, read at the advance's instant with the registry the advance read. | the grant's id | `null` | sha256 over the grant's stored fields, in the order the standing grants file writes them, scopes sorted. `period_start` is one of them, so a renew moves it. | the earlier of the grant's next expiry and `raised_at` plus 23 hours | `warning` | Severity is derived from the kind and never stored. The entries of one message are ordered `warning` before `info`, then oldest `raised_at` first, then by id. A content Ask is raised for four standings: no approval on file, `spent`, `outside_window` and `content_moved`. Two standings raise nothing. `before_window` is already approved, and its window has not opened yet. `indeterminate` is a fire marked and never confirmed, and `warpline resolve` settles it, which is not an Answer. The standing is the one the content gate read for the fire decision, carried on its verdict. Nothing reads the approval a second time to decide whether to ask. A content Ask also needs bytes `approve --content` could bind: the producer is loaded, and its last Output is current, not erased, and carries `body` rather than `path`. Otherwise nothing is raised. Parked results are read from the state document the hold just wrote, so a gate an earlier advance parked is asked about as well as one parked now. They are read by the one predicate (§ 10, Applying a gate) at the later of two clocks: the advance's clock (`now`, for a library host), which also drives each Ask's `raised_at` and every expiry comparison, and the wall clock when the hold writes, which the count of waiting gates reads (§ 13). A gate dead at the advance's clock could get no Ask and would hold its scope against the plugin's other decision, and one dead at the wall clock would be asked about after `approve` already refuses it. So every gate asked about is counted, and when the advance's clock is not ahead of the wall clock, as on every CLI advance, the gates asked about are the gates counted. There is at most one open Ask per scope. Where one plugin carries more than one decision, the first of these is asked about: its parked result, then its content, then its missing Grant. That is the order `warpline approve` answers them in. A renewal Ask's scope is a grant id, so it never shares one with a plugin. With `notify.include_content` set, the `parked_result` entry carries the plugin's own inline Output body, and the `content` entry carries the producer's, each only while that Output is current, not erased and inline. Every other entry carries `content: null`. With it unset, no entry carries `content` at all. No other text a plugin or a model wrote ever reaches a message or a summary, a result's `summary` and its error messages included. ### One open Ask per scope, reminders and expiry Each advance takes the store through one pass, in this order: prune, expire, the decisions it saw, reminders, then the due set. Every step runs on the advance's clock (`now`, for a library host). None of them writes an Answer, a Grant, a content approval or a renewal, and none of them reads one. **One open Ask per scope.** A decision whose open Ask has the same kind and digest raises nothing. One whose kind or digest differs supersedes it: the new Ask is raised with a new id and a new nonce, the old one closes `superseded` with its `closed_at`, and a `superseded` entry for the old one goes out in the same message as the new one's `raised` entry. A change of kind with an equal digest supersedes too. The old Ask closes only once the new one's `ask.raised` is on the audit record. When the audit store refuses it, nothing is superseded and the old Ask stays open. **Reminders.** `notify.reminders_ms` lists the reminder points, in milliseconds after `raised_at`, by default 1 hour and 4 hours. An open Ask gets a `reminder` entry from the first advance at or after a point. One advance sends one reminder however many points have passed since the last, and an advance with no new point passed sends none. Reminders are counted from `raised_at` and the last reminder event, never from the number of advances. **Expiry.** `expires_at` is the earlier of the clock of the thing asked about and 23 hours after `raised_at`. The first advance at or after it closes the Ask `expired` and sends one `expired` entry. An expiry is never an Answer. If the decision is still waiting, the next advance raises a new Ask for it, with a new id and a new nonce. An Ask that expires before its next reminder point sends only the `expired` entry. **No Ask is raised for a decision whose own clock has run out.** No Ask is ever raised with an `expires_at` at or before its `raised_at`. **A parked gate that can no longer be applied is not asked about.** A gate past its plugin's `ttl_hours`, one a later run superseded, and one computed before a dependency re-ran all fail the one predicate (§ 10, Applying a gate). Each stays on file until the gate ceiling drops it, no `parked_result` Ask is raised for it, and the plugin's other decision, a missing Grant for example, is asked about instead. That missing-Grant Ask is answered through `approve ` with its nonce, and the Answer drops the dead gate before it merges the Grant (Answers below). **A decision that vanished lapses silently.** Its plugin was evaluated this advance and no longer waits on a human, or for a renewal Ask its grant is gone from a readable standing grants file. Examples: `approve --all` covered it, its gate was discarded, the grant was revoked. Its Ask stays `open` with no reminders, and at `expires_at` it closes `expired` with no message. It is never closed `superseded`, because nothing replaced it. It holds no bare form back (see Answers below). **An Ask whose plugin was not evaluated keeps its state.** When the level loop stops above a plugin, its Ask is not closed, not superseded and keeps its reminders. The same holds for a renewal Ask while the standing grants file cannot be read. **A renewal answered no is not asked again.** A renewal scope whose most recently closed Ask was a renewal answered `deny`, with the same digest as the decision now, raises nothing, because `deny --standing` writes no other record of the no. For the other kinds the denial record stops the asking: a denied plugin is skipped as `denied` (§ 10), so no decision for it reaches the pass. A `deny` whose denial was not written is still waiting, and the next advance raises a fresh Ask for it, as for a decision answered `approve`, `approve_content` or `renew` whose effect did not take. **Closed Asks leave the file 23 hours after they close.** The first advance at or after `closed_at` plus 23 hours drops a closed Ask from `asks.json`. The audit store keeps its `ask.raised` record. **What is due.** Every event of an open Ask with no delivered try. For a closed Ask, only its closing `superseded` or `expired` event, until it is delivered. An answered Ask, or one that lapsed silently, has nothing due. An advance with nothing due sends no message. ### Delivery Delivery is at least once. An advance runs `notify` up to three times for its one message, 500 ms and then 2 s apart, and stops at the first try that delivers. These are constants, not settings. The long tail is the next advance. Each try goes on every Ask in the message, under each entry's own `seq`, with its time, its outcome and its exit code: `delivered` with `0`, `failed` with the code (`null` for a command that could not start or was killed by a signal), or `timeout` with `null`. Its time is the advance's clock moved on by the time the tries took. The next advance sends every entry still undelivered again, with the same Ask id and the same `seq`. So does an advance after one that stopped between recording an Ask and sending it. A receiver drops a duplicate by (`id`, `seq`). Two advances can both send one entry: each records its try under that entry's one `seq`, and the Ask's event seqs stay unique. `notify` runs with the run lock held and no state lock, so a `notify` that hangs to its timeout never blocks an `approve`, a `deny` or any other writer of the state. Its whole process group is killed at the timeout, and when warpline itself exits, an interrupted advance included. Nothing about delivery changes what a run decides or its exit code. Each way the Asks fall short is one line on stderr, built from Ask ids, a key path, a cause, a file path and fixed phrases, and nothing about Asks reaches stdout: | Fact | Line | |---|---| | A message no try delivered | `warpline advance: notify did not deliver Ask after 3 tries. The next advance sends it again.` | | `ask.raised` refused by the audit store | `warpline advance: the audit store could not record Ask(s), so none of them was raised or sent. The decision still waits.` | | An unusable `notify` block | `warpline advance: notify is set but unusable at , so no Ask was raised or sent.` | | An unreadable Ask store | `warpline advance: cannot be read (), so no Ask was raised or sent.` The file is left as it was. | | A fault in the Ask pass | `warpline advance: the Asks could not be updated, so none was raised or sent.` | | A try that could not be recorded | `warpline advance: a notify try could not be recorded on its Ask, so the next advance sends it again.` | A library host reads the same facts on `AdvanceResult.asks`: `undelivered` (the Ask ids), `unrecorded` (a count), `skipped` (`null`, or the reason with its key path, or its cause and file path) and `tries_unrecorded`. A dry run and an advance skipped for quiet hours report nothing there. ### `warpline approvals` An approver, or a channel adapter, reads what is waiting with two forms: - `warpline approvals` lists every Ask in `asks.json` that is `open` and has not reached its `expires_at`. They are ordered `warning` before `info`, then oldest `raised_at` first, then by id, the order a message uses. Each line holds the id, the severity, the kind, the scope, `raised_at`, `expires_at` and the first 12 hex of the digest. With none it prints exactly `Nothing waiting.` and exits `0`, whether `asks.json` is absent or holds no open Ask. - `warpline approvals show ` prints one Ask, in any state: its id, kind, scope, plugin (`none` for a renewal), severity, state, `raised_at`, `expires_at`, the full digest and the summary. Then the content it is about: the plugin's current Output for a parked result, the producer's current Output for content, the manifest's declared side effects for a missing Grant, and the grant's stored fields for a renewal, or that the grant is gone. An id that is not 12 lowercase hex exits `1` saying it is not an Ask id. An id no Ask in the store has exits `1` with `no Ask has id .` Both forms take no lock and write nothing. An unreadable `asks.json` makes either form exit `1`, naming the file and its cause (`corrupt`, `newer reader` or `io`), with nothing on stdout. It is never read as "nothing waiting". Neither form prints a nonce. It reaches an approver in the `notify` message. An operator with no `notify` channel reads it from `/state/asks.json`. Every byte a plugin wrote is printed through one escape, the one `approve --content` uses: a C0 control character other than LF and TAB, DEL and a C1 control character each print as `\xNN`, and a backslash prints as two. Nothing is stripped. An inline body prints between begin and end lines. A `path` Output is never read. `show` prints its escaped path and says the Ask binds the path, not the file's bytes: the fingerprint covers `path:`, so the file can say something else by the time it is read, a relative path would resolve against the reader's directory, and a FIFO or device would never end. This is the reason `approve --content` refuses a `path` Output (§ 10), and the diff says there are no inline bytes to compare. An erased Output prints that its content was erased, with its `body_sha256`. **The diff.** `show` prints a line diff from the last approved content to the content asked about, with ` `, `-` and `+` lines, when that approved content's bytes are still on disk. For content, the baseline is an inline Output, from the producer's applied gate markers or its current Output, whose fingerprint over the producer's name, its declared side effects and that one Output (the § 10 proposal fingerprint) equals the one the consumer's content approval recorded. For a parked result, it is the inline Output of the plugin's latest applied gate marker. No copy of approved bytes is ever kept to diff against, so the content erasure policy (§ 10) is unchanged: once those bytes are erased or gone, there is no baseline. Without one, `show` prints `no earlier approved content`. ### Answers An Answer is a decision on one Ask, typed through the CLI. Answering an Ask is not issuing a Grant. `approve --all`, `approve --standing`, `deny --list`, `deny --remove` and `resolve` are not Answers: they never read `asks.json`, and an open Ask stays open across them. `warpline approve` answers three kinds, on the form gate-first already picks for the plugin named: | Form | Answers | Effect | |---|---|---| | `approve ` with a parked result it can still apply (§ 10) | `parked_result` | the parked result is applied | | `approve ` with none it can still apply | `missing_grant` | an unapplied gate it cannot apply is dropped, with its notice, and a session Grant is merged | | `approve --content --not-after ` | `content` | the content approval is written | `warpline deny` answers the same three kinds no, and a renewal: | Form | Answers | Effect | |---|---|---| | `deny ` | `parked_result`, `missing_grant` or `content` | the denial is recorded (§ 9), and a parked result waiting is discarded | | `deny --standing ` | `renewal` | nothing beyond the Answer record | `deny ` answers whichever of the three kinds the open Ask on the plugin is. For a `content` Ask the decision as it stands now is the producer's proposal fingerprint, the digest the Ask was raised with; for the other two it is the plugin's own. `deny --standing ` is the no to a standing grant's renewal Ask. `--standing` takes the grant's id as its value, and `deny`'s positional stays a plugin name, so it goes with no plugin name, `--list`, `--remove` or `--note`. It answers an Ask and does nothing else, so without `--nonce` it refuses by name. It appends `ask.answered` with `answer: 'deny'`, marks the Ask answered, and writes neither the standing grants file nor the state document. The grant lapses at its renewal deadline unless someone renews it. The next advance does not raise a renewal Ask for that grant while its digest is unchanged, because the most recently closed Ask on that scope was answered `deny` with the same kind and digest (see One open Ask per scope, reminders and expiry above). `warpline renew ` answers a renewal Ask yes, with the same flags. Its `--principal` must still be an active human, and `--via` an active machine judged against the same read of `principals.json`, so a machine cannot renew its own grant by relaying the Answer. The decision as it stands now is the renewal digest of the stored grant, so a grant renewed since the Ask was raised has moved its `period_start` and the Answer refuses as `digest mismatch`. `ask.answered` with `answer: 'renew'` precedes `grant.renewed` and the write of the standing grants file. **A no costs what a yes costs.** Every Answer form takes the same flags and refuses the same missing ones: `--nonce`, `--digest` and `--principal` are required on `approve`, `deny`, `renew` and `deny --standing` alike, and `--via` and `--evidence` are optional on each. `deny` needs no flag, confirmation or step that `approve` does not. An Answer carries four flags beside the form's own: | Flag | Meaning | |---|---| | `--nonce ` | The Ask's nonce, from the `notify` message. Compared lowercased. | | `--digest ` | A prefix of the digest the Ask names: 12 to 64 hex characters, compared case-insensitively. | | `--via ` | Optional. The registered machine that relayed the Answer. | | `--evidence ` | Optional. A reference token: a letter or digit, then letters, digits and `. _ : / # -`, at most 200 characters. | These are checked before anything is read, and each refusal exits `1` naming the flag, never a typed value, with nothing written: `--nonce` without `--digest` or the reverse; `--via` or `--evidence` without `--nonce`; `--nonce` with no plugin or with more than one; `--nonce` with `--all`; any Answer flag with `--standing` or with `--content --remove`; a `--digest` that is not hex or is longer than 64; an `--evidence` off the token rule; and an Answer without `--principal`. `--principal` is required on an Answer and must name an active principal of either type. `--via` must name an active machine. Both are judged under the state lock, against one read of `principals.json`, so a `--via` naming a human, an unregistered id or a disabled principal refuses with nothing written. Neither is ever taken from the environment or the account running the command. On the parked form, the refusal of `--principal` that a bare apply gets (§ 9) is lifted for an Answer, because `ask.answered` records it. The Answer names its Ask by the plugin and the nonce. A nonce that names no Ask on that plugin is refused as `unknown` (see Refusals below), never run as the bare form, so a nonce carried from another plugin's Ask is unknown here. **A bare form refuses only on an Ask it could answer.** `approve `, on either gate, `approve --content`, `deny ` and `renew ` exit `1` with nothing written while the plugin or grant has an Ask the form could answer now: `open`, before its `expires_at`, of a kind the form answers, naming the digest of the decision as it stands now, recomputed by the command, while `notify` is set and usable. The line names the Ask's id and `warpline approvals show `, never its nonce. An Answer is checked against the same conditions, so a bare form never refuses unless the Ask's nonce would answer it. An Ask whose decision vanished or moved, or a `parked_result` Ask whose gate is gone, holds no bare form back, though it stays `open` until its `expires_at`. For `approve `, beside a gate it can no longer apply, the form answers the missing Grant: a bare one refuses while that Ask is open, and with none open it reaches the gate and refuses it as § 10 says, writing no Grant. With `notify` unset or unusable no Ask is open: every form behaves as it did before Asks existed, and an Answer is refused as `unknown`. A `preferences.json` that cannot be read, or does not parse, counts as `notify` set. **An unreadable store refuses every form.** Every named and `--content` form of `approve`, every form of `deny` but `--list` and `--remove`, and every `renew`, bare or answering, reads `asks.json` under the state lock. When it cannot be read, the command prints `: cannot be read (). Nothing was written.` (`Nothing was renewed.` from `renew`), with the cause `corrupt`, `newer reader` or `io`, and exits `1`. It is never read as "no Asks", because a bare form must never slip past an open Ask the command could not see. **Order inside the state lock.** After every check of the form itself, an Answer: 1. appends `ask.answered` (§ 14); 2. reads `asks.json` fresh and marks the Ask `answered`, with `closed_at` and `answer`, written and synced, so its nonce is spent; 3. writes the effect, with its own record where it has one (`grant.issued`, `content_approval.issued`, `denial.recorded`, `grant.renewed`). `deny --standing` has no effect to write. When the audit store refuses `ask.answered`, the command prints `The audit store could not record this Answer. Nothing was written.` and exits `1` with every file as it was. When the store cannot be read or written at step 2, it exits `1` saying the audit record names an Answer whose effect was not written. When the effect fails after step 2, the decision is still waiting for every Answer but a renewal's `deny`. The Ask is answered, so the next advance raises a fresh Ask for it with a new nonce. A parked result refused at apply prints its usual line, and a line saying the Answer is on the record and the parked result was not applied, and exits `1`. A `deny` whose denial could not be recorded prints that nothing was denied, that the Answer is on the record, and that the next advance asks again, and exits `1`. **The Answer record.** `ask.answered` carries the Ask's id, kind, scope, digest and `raised_at`; `answer`, `approve`, `approve_content`, `deny` or `renew`; `principal`, the `--principal` given; `via`, the `--via` given or `null` when answered directly; `os_user`, the account that ran the command, or `unknown`, recorded beside the principal and never as one; `evidence`, or `null` when absent; `assurance`, `asserted`, the only level in this release; and `decide_ms`, the answer instant minus the Ask's `raised_at`, never a reminder's. The answer instant is the same one the Ask's `closed_at` records. ### Refusals An Answer that does not match its Ask is refused by name. Each refusal exits `1`, prints `: refused (): Nothing was written.` (`Nothing was renewed.` from `renew`) (the short prefix prints `prefix shorter than 12:` and its sentence), never a typed value, and writes nothing: the state document, the session grant file, the standing grants file, `asks.json` and the audit store are as they were. This holds after a hand edit of preferences.json or principals.json too, and for a refused `--principal`, `--via` or bare form: the next command that acts records the edit (§ 6, § 15). | Reason | When | |---|---| | `prefix shorter than 12` | `--digest` has fewer than 12 hex characters. Checked before anything is read. | | `unknown` | No Ask on the plugin named carries the nonce, in any state; or notify is unset or unusable, so no Ask is open. | | `superseded` | The Ask was closed `superseded`: its decision changed and a newer Ask, with a new nonce, replaced it. | | `expired` | The Ask is marked `expired`, or the command's clock has reached its `expires_at`, checked even when no advance has marked it. | | `nonce reused` | The Ask is `answered`: its nonce was spent by an earlier Answer. | | `digest mismatch` | The Ask's digest does not start with the `--digest` prefix; or the digest of the decision as it stands now, recomputed by the command, is not the Ask's digest (the decision moved before any advance superseded the Ask); or the form typed does not answer the Ask's kind, for example a `content` Ask answered without `--content`. | The checks after the short prefix run in a fixed order, `unknown`, `superseded`, `expired`, `nonce reused`, `digest mismatch`, and the first that fails is the one named. An Answer to an expired Ask that also carries a wrong prefix is refused as `expired`.