Runtime contract for warpline plugins. Covers the manifest schema additions, the
AbortSignal-threadedHandlerFnsignature, 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.
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.
inputsEach 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.
secretssecrets 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.
scheduleschedule 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.temporalityEach 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.
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.
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:
lastOutput(caller, name) returns that plugin’s most recent Output record, or
null when it has never produced one. See § last_output for why that is a
fact about the plugin and not about its last run.lastRun(caller, name) returns that plugin’s last run status — one of
success, partial, failed, skipped, gated — or null when it has
never run. It is the same enum § plugin_runs records, and it is the whole of
what this member returns: the failure TEXT a run may carry is not part of it,
and no field of the run record other than these two is delivered through this
handle.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.
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.
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.
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.
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:
fetch(url, { signal })Bun.spawn({ signal }) / child_process.spawn({ signal }) equivalentsAbortSignal — thread it
through rather than re-deriving a deadline.child.kill('SIGTERM') on abort.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:
controller.abort() from a host (e.g. a dashboard cancel button).SIGINT to the warpline run CLI entry - propagated as an AbortError
via the same controller.invokePlugin.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.
One plugin, invoked directly, writes two sibling files:
<run_id>.json - structured summary, including every retry attempt.<run_id>.log - captured stdout + stderr, with === Attempt N ===
delimiters between retries.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.
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.
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.
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.
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.
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 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.
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.
These patterns govern warpline’s own suite, which is written against
bun:testand 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:
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.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.)
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.
{
"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.
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.
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.
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.
~/.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.
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_versionRead 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 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_runsA 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.
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_gatesA 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.
warpline approve <plugin> applies the parked gate when one is live. What that
does, in order, all decided before anything is written:
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>.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.
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.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.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.
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.
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.
--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.
denialsWhere 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_outputA 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.