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 <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 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/, 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.

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
side_effects (sends_email | creates_issue | writes_db | external_api | modifies_file)[] no []
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 may not be a member of Object.prototype__proto__, constructor, toString, valueOf and the rest are refused. The set is derived from the prototype, not listed, so it cannot go stale.

The key in the plain-object plugin_runs and denials records is the plugin DIRECTORY name, not manifest.name, and it carries the same refusal at the loader — a directory named after a prototype member is a load failure with the plugin absent from manifests. That is where the constraint has to bite: loadPluginManifests keys its map by the directory entry, every downstream key comes out of that map, and it casts the imported module rather than parsing it through PluginManifestSchema, so the schema refinement above never runs on a load. 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 two refusals are independent on purpose: the strings are not the same string, and manifest.name is not today a record key anywhere. 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 when their mtime is older than 30 days.

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.

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. Five sets are closed — the side-effect type, the autonomy level, the schedule, the minimum tier and inputs[].type — 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 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 delegated false false
handler returns skipped + a needs_llm field delegated 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.

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.

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.

§ 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 two sibling files:

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:

=== 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 log captures whatever the handler emitted before abort.

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
}

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.

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, 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 and its result was parked pending a human answer
denied A human answered no, and the answer still applies to what is being proposed

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.

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

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

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 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 constant rather than restated — a daily file whose mtime is older than that window is unlinked at the start of the next advance. Three formats pruned on three literals would be three retention rules that agree until somebody tunes one.

6. Retention

Last 20 artifacts per plugin. On every invocation that completes with persistArtifact: true, trimPluginHistory(pluginName, 20) runs after the terminal write. It reads every <run_id>.json in the runs directory, filters by plugin, sorts by started_at DESC, and deletes both the JSON and its .log sibling for anything past the 20 newest. Deletion is atomic per-run (the JSON and log are unlinked together) so no orphaned .log files accumulate.

Applies to NEW runs only. The 51 pre-existing artifacts from pre-121 engine runs are left alone; a one-shot cleanup is tracked as deferred work.

The other deletion path

The 20-newest trim is not the only thing that deletes out of <home>/runs/, and reading this section as though it were will mislead you about what survives.

trimPluginHistory runs only under 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 the 20-newest trim never sees an advance’s output at all.

What deletes an advance’s run log is pruneRunLogs, and its rule is different: any <run_id>.json in the runs directory whose mtime is older than 30 days, regardless of plugin or count. That is the retention bound anything holding a run_id is subject to — a versioned Output’s history, and a last_output pointer both.

The two also differ in what they leave behind. The 20-newest trim unlinks the JSON and its .log sibling together. pruneRunLogs unlinks the JSON only, so a pruned run can strand its own transcript.

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. 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 an operator has approved it for this session. The approval is a single JSON file; there is no daemon, no keyring and no server.

Path: <warplineHome>/.session-approval, where <warplineHome> is 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": ["issue-render", "digest-sender"]
}
Field Type Meaning
granted_at ISO 8601 string When the most recent grant was written.
first_granted_at ISO 8601 string 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 When the grant stops being honoured.
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.

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.

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.

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 checkApproval is never called 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.

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

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.
expires_at Preserved from the live grant. An explicit --ttl may extend it, never shorten it.
Ceiling expires_at is capped at first_granted_at + 23h. A capped grant reports the cap on stdout.
--long Permits an expiry past the ceiling, and prints that it did.
Prior --long grant The ceiling never shortens time already held. mergeGrant caps at max(first_granted_at + 23h, live expires_at), so a window opened by an earlier --long survives every later plain approve unchanged, and capped is false. Revoke to close it early.
--replace Overwrites the scope list and resets expires_at; first_granted_at is preserved.
Unparseable first_granted_at The grant 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 starts a fresh window instead, which costs the operator scopes they re-grant in one command rather than handing out a window nobody authorised.
Expired grant Not merged onto. The window has closed; the next grant restarts it, with a new first_granted_at.
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.
Concurrent approve The file is not locked, and the outcome is last-write-wins: each invocation reads the live grant, merges in memory and writes the whole result, so of two overlapping invocations the later write wins outright and the earlier one’s scopes are lost.
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.

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.

warpline revoke deletes the file and exits 0, including when no grant exists. After a revoke, every side-effecting plugin reads as unapproved.

Nothing reachable from a run writes this file. checkApproval — the only function the engine calls — opens it read-only, and the write path (grantApproval / mergeGrant / revokeApproval) has no caller inside runAdvance. That is a property of the call graph, verifiable by inspection, and a test pins it: a full advance over side-effecting plugins leaves the file byte- and mtime-identical.


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

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.

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.

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, and the only field that check consults is last_run_at.

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

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.

skipped records a run whose handler returned skipped — in practice every dispatched [needs-llm] handoff, since that is the only path producing one today. 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

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

One hop only. In a chain A → B → C, a B gated by this reason writes no run record, so B’s own recorded status stays whatever it last was — very likely success. C is therefore not gated, and once C’s own freshness window expires it runs against B’s stale data, which is exactly the failure the gate closes one level up. Every one-hop edge is covered; the second hop is not.

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.

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. The reverse can no longer happen: the set only ever removes a dependency_failed verdict, never adds one. 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.

pending_gates

A supervised plugin’s result parked pending a human answer. One entry per plugin gated by the most recent advance.

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 Skill result The REAL result the handler returned, Outputs and all
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.

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

warpline approve <plugin> applies the parked gate when one is live. What that does, in order, all decided before anything is written:

  1. Already denieddenials[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. 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.

    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. 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.
  4. 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.
  5. Otherwise applied. The gated plugin_runs entry is overwritten in place: last_run_at stays at run_completed_at, the status becomes the result’s real terminal status, and last_output carries the Output the run already produced. applied_at is stamped on the gate.

On either refusal 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 a denial is live.

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

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.

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 durable is lost. The run artifact holds the full SkillResult in RunLog.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.

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.

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 discarded on apply, 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.

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 Output record shape from § 5, reused rather than restated — a second shape would be a second thing that could disagree with the first.

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.

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.

The pointer may dangle. Its run_id names a run log, and run logs are pruned at 30 days by mtime (§ 6), so a pointer can outlive the run it names. 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.