Plugin Hook Runtime Specification
Status: normative for Motrix 2.x plugin Hook execution. Independent design review passed on 2026-08-31.
This document defines how Motrix discovers, activates, schedules, validates, commits, recovers, and observes plugin Hooks. The same rules apply to the Electron and Server hosts.
1. Problem statement
Motrix exposes four task lifecycle Hooks: beforeCreate, beforeFinalize,
afterComplete, and onError. A reliable implementation has to cross several
boundaries at once: an installed manifest, an inactive or recycled QuickJS
worker, the SDK object seen by guest code, host capabilities, the task and
SQLite lifecycles, and two independently assembled application shells.
Loading a bundle or observing a registration event does not prove that a Hook
works. A conforming runtime must execute the built plugin bundle through the
real PluginHost and QuickJS worker, transfer the complete validated context,
apply its effects at the correct commit point, and recover safely after either
the plugin or the host process stops.
This specification closes the following failure classes:
- permissions matched against raw URL text rather than URL structure;
- Hooks silently missed because their plugin was inactive at dispatch time;
- one plugin VM receiving overlapping task contexts;
- SDK methods existing in types but not in the worker object;
- partial or unvalidated Hook DTOs crossing the worker boundary;
- replacement output being overwritten by the original download;
- file, metadata, and task state diverging across a failed finalize;
- post-Hooks being lost, duplicated without an identity, or blocking peers;
- Electron and Server constructing different runtimes.
2. Non-goals
- This document does not add new manifest Hook names or role bands.
- It does not grant a capability that is absent from the plugin's effective permissions.
- It does not make arbitrary external side effects exactly-once. That is not possible across a guest, a remote service, and SQLite without a shared transaction. Motrix provides at-least-once delivery and a stable delivery ID; plugins must use that ID when an external operation needs deduplication.
- It does not allow a plugin to finalize outside the task save directory or to overwrite an unrelated existing path.
- It does not make community plugins trusted. Signature, install consent, optional grants, and reserved-role policy remain separate gates.
- It does not change the pinned builtin supply chain. Builtins continue to be
consumed as signed, hash-locked
.moextreleases.
3. Terms
Candidate: an enabled installed plugin whose manifest declares the Hook and whose activation and host-permission predicates match the event.
Active plugin: a candidate with a ready QuickJS worker and registered guest entry points.
Invocation: one Hook call identified by a unique invocationId from host
admission until the worker exits the Hook.
Plugin lane: the FIFO serialization boundary for all host-initiated guest entries in one plugin VM.
Working context: the validated DTO plus accepted effects from earlier plugins in a serial Hook chain.
Hook effects: context patches and metadata operations returned by one invocation. Effects are data; they are not committed by the worker.
HookPlan: the immutable result of a successful beforeFinalize chain. It
names the current source artifact, final target, optional replacement artifact,
metadata effects, and attribution.
Finalize journal: a durable row recording a HookPlan and the filesystem phase reached while committing it.
Occurrence: the existing durable terminal task event written with the task transition.
Post delivery: a durable (occurrence, hook, plugin version) delivery of an
afterComplete or onError invocation.
Permission generation: a monotonically increasing registry value for a plugin's enabled state, grants, quarantine state, and executable identity. A guest entry and every capability lease are bound to one generation.
4. Architecture boundaries
The host-neutral runtime lives under src/core/. It owns candidate discovery,
structured permission matching, per-plugin lanes, DTO validation, Hook
orchestration, finalize plans and recovery, and post-delivery scheduling. It
may depend on shared schemas and injected filesystem/database adapters, but not
on Electron, renderer, or Server modules.
src/shared/ owns pure Zod schemas and inferred types for wire-visible Hook
DTOs and worker messages. It contains no I/O, timers, or host imports.
src/main/ and src/server/ construct the same core runtime factory. Each
shell supplies paths, logging, its capability host, task lookup/persistence,
filesystem operations, and shutdown coordination. A shell must not construct a
second orchestrator inside command handlers.
The QuickJS worker evaluates guest code and exposes the SDK surface. It does not decide eligibility, permissions, commit order, or recovery. The capability bridge validates every worker message and binds it to the current invocation.
5. State machines and ordering
5.1 Plugin lifecycle
Inactive -> Activating -> Active -> Quiescing -> Inactive
| |
+-> Faulted +-> Faulted
- Candidate discovery is independent of the active set.
- Admitting a Hook candidate activates it immediately when necessary.
- Concurrent activation requests for one plugin coalesce.
Activeis published only after the worker is ready and every declared Hook used by the runtime is registered.- Idle disposal is allowed only when the plugin lane has no running or queued guest entry and no owned capability operation is still in flight.
- A recycled plugin can be activated again for finalization or post delivery; inactivity never means ineligibility.
- A crash fails only the current plugin invocation, tears down that worker, and permits a clean worker to be created for a later retry.
5.2 Serial pre-Hook chain
validate input -> discover candidates -> sort by role/id
-> for each candidate: activate -> enter plugin lane -> invoke -> validate effects
-> merge into working context -> build result/HookPlan
The order is role band, then lexical plugin ID. A later plugin sees the accepted working context produced by earlier plugins. It never sees another task's context or an uncommitted effect from a failed plugin.
pre-resolve, resolve, and post-process failures abort the chain.
enrich and audit failures are isolated and the chain continues. An audit
plugin cannot produce mutating effects.
Activation failures use the same isolation policy as Hook invocation failures,
and an open circuit breaker skips activation entirely. The built-in filename
template's beforeFinalize Hook is optional: its pre-resolve role controls
ordering, but a worker startup failure, crash, timeout, or Hook error discards
its effects and preserves the previously accepted target name. Finalization
continues with the ordinary path validation and collision handling. Required
resolver and post-processing Hooks retain their abort-on-failure policy.
5.3 Terminal task and post-Hook sequence
snapshot candidates
-> terminal task + occurrence + post-delivery rows commit
-> per-plugin lanes deliver eligible rows
-> success receipt or retry/dead-letter state
Candidate materialization is part of the terminal SQLite transaction; Hook invocation is asynchronous. The occurrence consumer only schedules already materialized rows and is never held open by a failing plugin.
6. Candidate discovery and immediate activation
Candidate discovery reads the current PluginRegistry, not PluginHost's
active map. A plugin is a candidate only when all of these are true:
- it is installed, compatible, enabled, and not quarantined;
contributes.hooksdeclares the requested Hook;- its effective activation set matches the task type or URL protocol, with declared command events handled separately;
- its manifest Hook role is valid for its trust/category policy;
- for an HTTP
beforeCreateresolver, at least one structuredhostPermissionspattern matches the source URL used to select it; - required capabilities remain in the plugin's effective permission set.
beforeFinalize, afterComplete, and onError are selected by Hook
declaration, task-type activation, role, and capability grants. Their task
source may be FTP, magnet, or another non-HTTP identifier and is never filtered
through HTTP host patterns. Any HTTP capability call they make is still checked
per request.
The Hook contribution is an implicit demand-activation source. A candidate
that was disposed after beforeCreate is activated again for
beforeFinalize. Post delivery also activates its recorded candidate before
invocation. Activation failure is classified as that plugin's Hook failure; it
does not make the candidate disappear.
Candidate executable identity is (pluginId, version, executable digest).
Candidate and delivery snapshots separately record createdGeneration and the
complete effective permission set at creation; that generation is audit data,
not a future execution lease. Upgrade, enable/disable, quarantine, uninstall,
or grant change
enters an exclusive policy barrier: new guest and capability admission closes,
the generation advances, owned operations are aborted, the lane drains or the
worker is terminated after budget, and only then may the new state activate.
Every capability call either acquires a live generation lease or presents the
current generation; stale live leases fail closed. A post-delivery attempt runs
with createdEffectivePermissions intersect currentEffectivePermissions, so a
later grant can never expand a historical delivery's authority.
7. Structured host-permission matching
Host permissions are parsed once into structured matchers. Matching never runs a regular expression against the whole raw URL.
A supported manifest pattern has these components:
scheme://host/path-glob
schemeishttp,https, or*;*means HTTP and HTTPS only.hostis*, an exact canonical DNS/IP host, or*.example.test. A subdomain wildcard matches the suffix host and its subdomains only.- Manifest v1 patterns have no port component. A matching scheme/host pattern matches any valid explicit or default port, preserving released-plugin and browser match-pattern semantics. A future port restriction requires a new manifest schema version.
- A bracketed IPv6 literal is an exact host; wildcards are forbidden inside it.
path-globis matched against WHATWG-serializedpathname + searchafter uppercasing percent-escape hex digits. It is not percent-decoded, so encoded separators cannot become path separators.*is the only wildcard and cannot alter scheme or host matching.<all_urls>is equivalent to*://*/*and still means HTTP/HTTPS only.
URLs are parsed with the platform URL parser. The matcher lowercases and
IDNA-normalizes DNS names, removes a single trailing DNS dot, normalizes
default ports, rejects embedded credentials, and ignores fragments. Invalid
patterns or URLs fail closed. Exact host matching uses component equality;
allowed.example.evil and evil.example/path/allowed.example cannot match
allowed.example.
The same matcher and conformance corpus are used for beforeCreate resolver
eligibility, immediate activation, the initial guest HTTP request, and every
redirect hop. Tests cover host-vs-path confusion, suffix confusion,
credentials, arbitrary/default ports, IPv4/bracketed IPv6, IDNA, percent
encoding, query matching, and redirects.
Host permissions authorize network requests made by the plugin. They do not authorize or reject a URL returned to the download engine, and they do not filter non-HTTP lifecycle Hooks. A Hook-produced download URL passes the independent output policy in section 9.4.
Guest HTTP uses only the Host-managed proxy route. A guest-supplied per-request proxy is rejected even if it is syntactically valid; target host permission is not proxy-endpoint permission. Adding guest-selected proxies requires a future explicit capability, structured endpoint allowlist, credential policy, and CONNECT/redirect threat review.
Host permissions constrain declared reach; they are not a DNS pinning system. A broad wildcard is broad authority and must remain visible in consent UI.
8. Per-plugin scheduling and context isolation
Every plugin has one FIFO lane shared by Hook calls, command calls, lifecycle callbacks, and deactivation. Capability responses complete inside the admitted entry but do not open another guest entry.
Each entry propagates an immutable call-chain ID. Re-entry into a plugin already
present in that chain, including self-command calls and A -> B -> A, is
rejected before enqueue with plugin.runtime.reentrant_call; it is never left
to deadlock behind the current lane owner. Aborting an outer entry cancels its
queued descendants and invocation-owned capability operations. Forced worker
termination rejects every remaining entry in that chain.
Each worker message produced during a Hook includes invocationId. The bridge
accepts an invocation-scoped capability call only when that ID is the lane's
current invocation. Missing, stale, or mismatched IDs fail closed. Hook exit
also carries the ID, so a late exit from an aborted worker cannot settle the
next task.
The runtime stores invocation context by ID, not in a reusable unkeyed slot.
Context is cleared in finally after Hook exit, timeout, abort, worker crash,
or validation failure. Idle eviction and manual deactivation wait for the lane
to drain, except forced termination after an abort budget.
Different plugins may run in parallel. Two tasks targeting the same plugin are serialized. This provides isolation without globally serializing downloads.
9. DTO and SDK contract
9.1 Validation boundary
Zod schemas in src/shared/schemas/ are the source of truth for every Hook DTO,
worker enter/exit message, effect list, metadata value, and post-delivery
envelope. The host validates before posting to the worker and validates the
worker result before applying it. Unknown fields in protocol messages are
rejected; explicitly documented additive DTO fields remain allowed by their
schema version.
DTOs contain JSON values only. Strings, arrays, object depth, collection size, and total encoded bytes are bounded. Numbers must be finite. Task snapshots are copied; the guest never receives a live host object.
Schema version 1 uses these inclusive limits, measured after UTF-8 encoding:
| Item | Limit |
|---|---|
| complete Hook enter or exit JSON message | 2 MiB |
| generic string / filesystem path | 64 KiB / 32 KiB |
| URLs per request / bytes per URL | 128 / 16 KiB |
| headers / header-name bytes / header-value bytes / total header bytes | 256 / 256 / 16 KiB / 256 KiB |
| JSON object depth / keys per object / items per array | 16 / 1,024 / 1,024 |
| metadata entries / key bytes / value bytes / snapshot bytes | 1,024 / 128 / 256 KiB / 1 MiB |
| error code / error message bytes | 128 / 16 KiB |
Values at the limit are valid; one byte or item beyond it is rejected. Shared constants back both Zod schemas and conformance tests.
9.2 Common context
Every Hook receives all fields declared for its schema. The common fields are:
schemaVersion,invocationId, andtaskId;sourceUrl,createdBy, andrequestedAtwhere applicable;task, a stable plugin task snapshot;signal, local to the invocation;metadata, scoped to(taskId, pluginId).
The exact PluginTaskSnapshotV1 public shape is:
{
schemaVersion: 1
id, name
type: "http" | "ftp" | "bt" | "magnet" | "metalink"
kind: "direct" | "bt" | "hls" | "mux"
status: "queued" | "fetching_metadata" | "metadata_ready" |
"downloading" | "finalizing" | "seeding" | "paused" |
"completed" | "error" | "removed"
filePath, saveDir, filename
progress, totalBytes, downloadedBytes, uploadedBytes, sizeWhenDone, fileCount
createdAt, updatedAt, finishedAt: number | null
category: string | null
infoHash: string | null
error: ErrorDescriptorV1 | null
}
All identifier/name/path fields use section 9.1 limits. Counts and byte values
are non-negative safe integers, times are non-negative integer Unix
milliseconds, and progress is finite in [0, 100]. ErrorDescriptorV1 is
{ code, message, detailKey: string | null, detailParams: Record<string,string> | null }; it never includes a stack or raw engine payload. The snapshot keeps
the SDK 2.0 compatibility fields id, filePath, and saveDir, and excludes
URIs, headers, proxy credentials, engine IDs, bridge sessions, mutable instance
payloads, and host objects.
9.3 Metadata parity
Pre-Hook ctx.metadata implements the complete six-method synchronous SDK
surface:
get(key),has(key),getAll(), andkeys()read the invocation snapshot;set(key, value)anddelete(key)stage writes in pre-Hooks;- post-Hooks receive the four-method readonly subset:
get,has,getAll, andkeys. They do not exposesetordelete.
Staged writes update the worker snapshot immediately, so read-after-write is
deterministic. The worker returns the operations at Hook exit. The host then
validates keys, JSON values, quotas, role, phase, and permissions before adding
them to the chain. No fire-and-forget metadata or ctx.update request may race
Hook exit.
The asynchronous metadata operations used by the Worker/Host bridge are an
internal transport capability, not a top-level guest export. The only public
Hook metadata API is ctx.metadata, synchronous as required by
@motrix/plugin-api 2.0.
The guest signal is an invocation-local, readonly AbortSignal subset with
aborted, reason, onabort, and addEventListener/removeEventListener for
the abort event. Timeout, task cancellation, policy-generation change,
shutdown, or forced lane termination sets aborted once, assigns a bounded
stable reason, queues exactly one abort event before rejecting outstanding
capability promises, and causes later capability calls/effects to fail. Tests
use an injected scheduler to assert this ordering.
9.4 Hook-specific DTOs
beforeCreate receives the complete HTTP create request: URL list, save
directory, optional filename and connection count, ordered headers, optional
proxy, provenance, and timestamp. Valid effects are limited to those mutable
fields. The final URI list must be non-empty. Returned URLs do not need to match
the plugin's network hostPermissions; instead each passes the normal task
source admission policy used for a user-provided URL: supported scheme,
credential rejection, remote-operator/private-network policy, URL limits, and
role permission to mutate the request. Headers and task proxy effects pass the
same secret, proxy, and engine-option policy as a user request. This distinction
allows the locked Commons resolver to call only commons.wikimedia.org while
returning an independently validated upload.wikimedia.org download URL.
beforeFinalize receives:
sourceUrl: a non-empty source identity. For HTTP-like tasks this is the admitted source URL. For BT/magnet tasks the Host always replaces any raw source with a non-secret canonical identifier:urn:btih:<infoHash>for a valid 40-hex or 32-base32 info hash, otherwiseurn:motrix:bt:<base64url(taskId)>. Torrent file paths, raw magnet query strings, tracker URLs, and credentials never cross this boundary;inputFilePath: the completed artifact currently owned by Motrix;filePath: the proposed final target, retained for SDK 2.0 compatibility;targetFilePath: the same proposed target under its explicit name;- the complete plugin task snapshot and common context.
Changing filePath changes only the target. It never changes the source and
does not itself assert that a replacement artifact exists.
The stable public delivery envelope is exactly:
DeliveryEnvelopeV1 {
schemaVersion: 1
id: string
occurrenceId: string
occurredAt: number
}
id is the stable deliveryId; it and occurrenceId are opaque, non-empty,
at most 128 UTF-8 bytes, and occurredAt is a non-negative integer Unix
millisecond timestamp. Attempt number, lease, retry time, and breaker state are
host diagnostics and never enter this stable envelope.
AfterCompleteContextV1 contains exactly schemaVersion, invocationId,
taskId, task: PluginTaskSnapshotV1, final filePath, delivery: DeliveryEnvelopeV1, readonly metadata, and signal. OnErrorContextV1 has
the same fields plus error: ErrorDescriptorV1. The persisted stable event
payload is (task, filePath, delivery, error?) and is byte-identical across
attempts/restart using canonical JSON with lexically sorted object keys;
invocationId, metadata, and signal are freshly bound
invocation wrappers. Protocol version 1 rejects unknown fields. Additive public
fields require a schema-version revision and a backwards-compatible SDK minor.
The delivery field is new relative to @motrix/plugin-api 2.0. The runtime
pins the published @motrix/plugin-api 2.1.0 contract for typed access to this
field. A compile-time fixture against that exact package and a real QuickJS
invocation both read ctx.delivery.id; a separate npm alias keeps the fixture
against the exact published 2.0 package. SDK 2.0 plugins remain runtime-compatible
because the field is additive and the locked builtins need no source changes.
9.5 Capability phase rules
The capability bridge applies effective permission, phase, invocation, and
path gates in that order. ctx.update and metadata writes are staged in
pre-Hooks and disallowed in post-Hooks. Task filesystem reads are unavailable
before creation and are bound to the invocation's task artifact thereafter.
Direct task rename is disallowed in beforeFinalize; the finalize commit
protocol owns all target mutations. FFmpeg output under the task save directory
is redirected to the invocation's private staging directory. Plugin storage
output stays within that plugin's storage sandbox. Other output paths fail.
10. beforeFinalize HookPlan and file commit protocol
10.1 HookPlan
A successful chain yields:
HookPlan {
taskId
sourcePath
targetPath
sourceIdentity: ArtifactIdentity
replacement?: { pluginId, stagedPath, identity: ArtifactIdentity }
metadataOps[]
contributors[]
}
ArtifactIdentity is a discriminated union:
FileIdentity { kind: "file", size, sha256, platformFileId? }
DirectoryIdentity { kind: "directory", entryCount, totalBytes, treeSha256,
platformFileId? }
A directory walk never follows symlinks or reparse points and rejects sockets,
devices, FIFOs, and other special entries. It records empty directories and
regular files. Entries are sorted by platform-tagged component encoding (POSIX
raw name bytes; Windows exact UTF-16LE code units) and hashed as length-prefixed
records: directory records contain type and relative path; file records
additionally contain unsigned 64-bit size and the file's SHA-256. The root
record, platform tag, and algorithm version are included. A target-filesystem
case/normalization collision fails. Open no-follow handles, before/after file
identity/stat checks, and a final tree rescan detect mutation during hashing or
copy; any change aborts or quarantines rather than accepting a mixed snapshot.
The default maximum is 1,000,000 entries;
exceeding it is plugin.finalize.artifact_too_large, not a partial plan.
sourcePath is captured before Hook execution and never guest-controlled.
targetPath must be a descendant of the task save directory and must not be
the save directory itself. The trusted filesystem adapter resolves path
equivalence using platform case rules and operates relative to held directory
handles with no-follow semantics (or a platform-equivalent anti-symlink-swap
primitive). A platform without an equivalent primitive fails closed. A lexical
or one-time realpath check alone is insufficient.
Before the first source identity read, finalization acquires an exclusive task
artifact-mutation lease, closes plugin filesystem admission for that task, and
successfully quiesces the engine and every Host-owned writer. A failed engine
stop or an unaccounted writer fails closed; logging and continuing is forbidden.
The lease and held no-follow artifact/directory handles remain owned through
db_committed and compensation, and through cleanup for every path cleanup may
delete. The lease coordinates all Motrix/engine writers; external changes are
detected by the identity checks below and are never deleted as known data.
A replacement exists only when one plugin produced a regular artifact through its invocation-private staging adapter and the adapter recorded the logical output-to-staged-path mapping. Directory scanning never guesses ownership. File identities always contain size and SHA-256; directory identities always contain entry count, total bytes, and tree SHA-256. A digest is never optional. Zero mapped artifacts means the original source is selected. More than one selected mapping, a symlink, directory/type mismatch, digest change, or another plugin's artifact aborts the plan.
Immediately before and after every preserve, rename, copy, install, compensation,
or cleanup action, the adapter revalidates type and the complete recorded strong
identity through the held handles. After install it recomputes the complete
target ArtifactIdentity and matches it before writing target_installed, then
matches again immediately before the db_committed transaction. Any source,
target, rollback, or staging mismatch preserves all bytes, quarantines the
journal, and forbids recursive deletion.
Path branches are explicit:
source == target, with no replacement, performs no file mutation;source == target, with a replacement, preserves the source before install;- distinct source and target use no-replace install;
- file-vs-directory conflicts fail, and Windows case-equivalent paths use the
source == targetbranch; - cross-device (
EXDEV) inputs use the target-local copy protocol below, never a best-effort rename.
The commit is no-clobber. The filesystem adapter must provide an atomic
no-replace install, such as a same-filesystem link/create-exclusive protocol or
a native rename-without-replace primitive. Node's overwrite-capable rename
is not sufficient. An unrelated target appearing at any time fails without
being modified.
The orchestrator builds the plan but performs no promotion or source rename.
10.2 Durable phases
The finalize journal uses these durable phases:
prepared -> target_staged -> source_preserved? -> target_installed
-> db_committed -> cleaned
Before filesystem mutation, Motrix durably writes prepared with normalized
paths, case-equivalence decisions, expected strong identities, plan identity,
the exact staging mapping, rollback path, and metadata operations. The database
commit/WAL durability point completes before any file action.
An original source on the target filesystem may be installed directly with a
native no-replace file or directory rename after prepared; this is the normal
large BitTorrent-directory path. Otherwise the selected artifact is
materialized under an invocation-private name inside the target filesystem.
Same-device regular files may hard-link; cross-device files copy. Directories
copy recursively without following links, use exclusive destination creation,
fsync every completed file, and fsync directories in postorder. Motrix then
recomputes the complete ArtifactIdentity, fsyncs the private item's parent,
and durably records target_staged. Partial file or directory copies are
private and can never be selected as a target.
When the target is the source path, or another destructive source move is
needed, the source is moved with no-replace semantics to the recorded
target-filesystem rollback path. Its file and affected directories are made
durable before source_preserved is recorded. The target-local temporary item
is then atomically installed with no-replace semantics; the target file and
parent directory are made durable before target_installed is recorded. For a
replacement, only the replacement temporary item is installed. The original
is never renamed over it. Every phase update occurs after the filesystem state
it names is durable; recovery tolerates a crash between the file action and
phase update by comparing the recorded identities.
For a same-filesystem direct source rename, target_staged is skipped and the
strongly identified source is installed no-replace as the target. Files fsync
themselves and the parent; directories fsync the renamed root and affected
parents before target_installed. On database failure or recovery compensation,
the adapter renames that exact identity back no-replace. A cross-device source
is retained unchanged until db_committed and uses the private-copy path.
When source == target and no replacement exists, Motrix revalidates and makes
that artifact durable, then advances directly from prepared to
target_installed without changing a path.
After target installation, one SQLite transaction commits all of the following:
- final task path, terminal status, timestamps, and instance paths;
- task-file path rebasing;
- staged plugin metadata operations;
- the terminal occurrence;
- journal phase
db_committed.
Only after that transaction succeeds may Motrix delete the rollback artifact,
the now-obsolete original source, target-local temporary files, and unused
staging. Each deletion first revalidates the current type and full identity
against the journal through the held handle; mismatch leaves the path untouched
and records cleanup quarantine. It fsyncs affected directories, records
cleaned only when all known cleanup is complete (or a mismatch is durably
quarantined), releases the mutation lease, and then removes or retains the
journal as the quarantine policy requires. Cleanup is idempotent.
10.3 Compensation and crash recovery
If target staging or installation fails, the source is restored from the rollback path when necessary and the task remains recoverable. If the database commit fails, Motrix removes only a target whose strong identity matches the journal and restores the recorded source. A failed compensation leaves the journal for startup recovery and never marks the task completed.
Startup recovery runs before polling, engine completion subscriptions, or post delivery:
prepared: verify source/replacement identities and retry or cancel safely;target_staged: verify the private temporary item and continue, or discard only that identity and recreate it;source_preserved: restore the source unless the verified install can continue;target_installed: finish the database transaction when target identity matches, otherwise compensate;db_committed: keep the committed target and finish cleanup;- malformed, ambiguous, or identity-mismatched rows are quarantined and the task is marked with a recoverable finalize error without deleting bytes.
Recovery checks both the recorded phase and all possible adjacent-phase file
layouts so a crash before a phase update is unambiguous. Operations are
idempotent. No branch follows a symlink, overwrites an unknown path, or treats
equal size as identity. Fault-injection tests stop before and after every file
action, file/directory fsync, journal write, and SQLite commit on same-device,
cross-device, POSIX-case-sensitive, and Windows-case-folded adapters. The matrix
includes direct files, single-file torrents, multi-file torrent directories,
empty directories, the entry limit, symlink/reparse and special-file rejection,
and crashes during recursive copy and postorder directory fsync. It also injects
tree mutation before/after final rescan, target_staged, target_installed, the
database transaction, compensation, and every cleanup deletion; a mismatch must
retain bytes and quarantine.
11. Reliable afterComplete and onError delivery
Before the terminal transaction, the runtime snapshots candidates under a
registry generation read lease and validates a self-contained delivery DTO. It
revalidates that generation while entering the transaction, so a concurrent
policy change either precedes the snapshot or waits until commit. The same
SQLite transaction writes the task terminal state, stable occurrence, and,
subject to the bounded admission policy below, one post-delivery row per
snapshotted candidate, including plugin/executable
identity, required grants, the complete effective-permission snapshot,
createdGeneration, DTO, stable deliveryId, attempt count, next retry, and
status. A quota-rejected candidate updates the bounded tombstone instead.
Candidate
enumeration never happens later from the current registry. A malformed
candidate becomes an observable permanent row rather than disappearing.
The occurrence consumer only schedules rows that already exist and then marks
its own occurrence-consumer receipt. Replaying it is idempotent by the unique
(occurrenceId, hook, pluginId, version, digest) key. One failing plugin cannot
keep unrelated consumers or plugins undispatched.
An admitted delivery is at least once. Rows move through:
pending -> delivering -> delivered
\-> pending (retryable)
\-> dead_letter (permanent/policy limit)
An expired delivering lease becomes pending on startup. Defaults are: a
two-minute lease, 64-row claim batch, eight global delivery workers, one active
delivery per plugin lane, 12 attempts, and a seven-day maximum active age.
Retry delay is min(1 hour, 1 second * 2^(attempt-1)) multiplied by injected
jitter in [0.75, 1.25]. Attempts/age beyond the limit become dead_letter.
The scheduler rotates plugin IDs round-robin; one plugin cannot consume a whole
batch. Delivered and dead-letter rows are retained for 30 days, with aggregate
audit counters retained after pruning. These defaults are configurable within
schema-enforced safe bounds and tests use an injected clock and jitter source.
Version 1 configuration bounds are exact: lease 30 seconds-10 minutes; claim
batch 1-256; global workers 1-32; attempts 1-32; active age 1 hour-30 days;
base delay 100 ms-1 minute; delay cap 1 minute-24 hours and not below base;
terminal retention 1-90 days; breaker threshold 1-100, window 1 minute-1 hour,
and pause 1 minute-24 hours. Jitter remains the fixed [0.75, 1.25] range and
per-plugin concurrency remains one. Quotas may be lowered only: per-plugin
active rows 1-1,000 and bytes 2-64 MiB; global active rows 1,000-10,000 and
bytes 64-512 MiB; per-plugin terminal rows 1-4,000 and bytes 1-4 MiB; global
terminal rows 4,000-40,000 and bytes 4-40 MiB. Cross-field validation requires
each global bound to cover one per-plugin bound. The 1 GiB post hard budget and
128 MiB minimum core reserve cannot be raised/lowered respectively in v1.
Activation/worker crashes and timeouts are retryable; malformed persisted DTOs, missing or changed executable identities, disabled/uninstalled/quarantined plugins, and revoked required permissions are permanent and observable. A circuit breaker opens after five retryable failures in ten minutes, pauses that plugin for 15 minutes without consuming attempts, then permits one half-open probe; success closes it and failure reopens it. A plugin failure is caught per row and never prevents another plugin from running.
Admission reserves row and byte budget atomically in the terminal transaction. Bytes are the uncompressed UTF-8 payload plus a fixed 512-byte row charge. Defaults are 1,000 active rows and 64 MiB per plugin, and 10,000 active rows and 512 MiB globally. Terminal receipts are compacted to at most 1 KiB immediately after delivery/dead-letter, with limits of 4,000 rows/4 MiB per plugin and 40,000 rows/40 MiB globally. The ledger releases exact active bytes on compaction/prune and is reconciled from table contents at startup.
If either active quota is exhausted, Motrix does not write the full DTO or an
ordinary delivery row. The terminal transaction instead updates a fixed-size
quota tombstone bucket keyed by (pluginId, hook, reason, UTC day) with count,
first/last occurrence ID, and times; one bucket is at most 1 KiB. There are at
most 32 daily buckets per plugin/reason plus one lifetime rollup, 8,192 buckets
globally, and one global overflow rollup. Old buckets merge into the rollup, so
rejection reporting is bounded. This is an explicit observable admission
rejection, not a silently lost or falsely delivered Hook.
Terminal receipts normally retain for 30 days. When their row/byte quota is needed, oldest receipts roll into the same bounded aggregates and are removed; no active row is pruned. Post-delivery tables have a 1 GiB hard logical budget and the database adapter reserves at least 128 MiB of configured capacity for task, finalize-journal, occurrence, and quota-tombstone writes. A plugin payload can never consume that reserve. At the global post hard cap, all new post candidates take the bounded tombstone path while unrelated task terminal state and occurrences still commit. Physical disk failure inside the core reserve is a system-wide retryable storage error; it is never attributed to or hidden as a plugin delivery result.
deliveryId is stable across attempts. Motrix records a receipt only after a
matching Hook exit. A process crash after the guest side effect but before the
receipt can cause redelivery; plugins that call external systems must use the
delivery ID with storage.compareAndSet or the external system's idempotency
facility.
Delivery DTOs do not depend on a live task row. Task deletion uses no cascade to
post deliveries. Upgrade never runs the superseded executable: its barrier
atomically moves that identity's nonterminal rows to permanent superseded,
then deletes the old bundle after the lifecycle transaction/journal commits.
Disable, uninstall, quarantine, or required-grant revocation similarly moves
affected rows to an explicit permanent reason. A grant addition may leave a row
pending, but its attempt receives only the intersection of its complete
creation-time permission snapshot and the current set. A same-executable grant
revocation aborts an active attempt and either retries with the reduced
intersection or becomes permission_revoked when a required grant is absent.
Row transition, bundle deletion, task deletion, and policy changes share the
registry/database transaction or a journaled two-phase coordinator, so their
order is explicit after restart. Tests cover grant add/revoke, upgrade,
disable, uninstall, and quarantine before claim, while delivering, and after a
guest side effect but before receipt.
12. Threat model and security requirements
Guest code, manifests, bundle messages, URLs, metadata, staged files, and persisted recovery rows are untrusted inputs.
The runtime must defend against:
- host/path/port confusion and redirect escape in host permissions;
- guest-selected proxy endpoints bypassing network authority;
- path traversal, symlink swaps, alternate separators, case behavior, and sibling-prefix confusion in save/staging paths;
- stale or forged invocation messages using another task's context;
- a worker retaining references after timeout or idle disposal;
- oversized/deep DTOs, metadata quota evasion, and non-finite numbers;
- replacement/source aliasing and overwrite of existing user data;
- a crash between any filesystem and database phase;
- one plugin exhausting the post-delivery queue or circuit breaker;
- policy revocation racing an already active capability;
- shell-specific assembly accidentally bypassing a gate.
The host revalidates effective permissions for each activation and capability
lease against the live permission generation. Policy mutation uses the
exclusive barrier in section 6. A capability bridge never trusts SDK-side
available flags. Audit logs redact sensitive
headers, proxy credentials, secrets, and private source metadata. Absolute user
paths may be logged locally where required for recovery but never enter public
documentation or remote telemetry.
13. Error classification
Stable categories are used in logs, audit records, task error descriptors, and post-delivery rows:
plugin.hook.input_invalidandplugin.hook.output_invalid;plugin.hook.not_registered,plugin.hook.timeout, andplugin.hook.worker_crashed;plugin.hook.permission_deniedandplugin.http.host_not_permitted;plugin.hook.concurrent_protocol_violationfor a lane/ID violation;plugin.runtime.reentrant_callandplugin.runtime.permission_generation_stale;plugin.finalize.plan_invalid,target_exists,staging_invalid,artifact_too_large,file_commit_failed,db_commit_failed, andrecovery_quarantined;plugin.post.retryable,plugin.post.permanent,plugin.post.admission_rejected, andplugin.post.dead_letter, including explicitqueue_capacity,identity_missing,superseded, andpermission_revokedreasons.
Series Hook errors retain their role-dependent fail-open/fail-closed policy. Post-Hook errors never change a terminal task's status. Error messages exposed to plugins are bounded and do not include another plugin's details.
14. Compatibility
The runtime supports @motrix/plugin-api 2.0 context shapes. Existing plugins
that return the context object continue to work; effects are taken from
ctx.update and metadata operations. New additive DTO fields do not change the
meaning of existing fields.
Manifest v1 keeps its released browser-pattern port behavior: a pattern without a port matches explicit and default ports. Changing that behavior or adding a port grammar requires a new manifest schema version. The public SDK continues to expose metadata only through Hook context: six methods in pre-Hooks and four readonly methods in post-Hooks.
Reliable post delivery is an additive runtime feature for SDK 2.0 plugins, but
typed access to ctx.delivery requires the pinned compatible SDK minor defined
in section 9.4. Runtime protocol schemas, Worker objects, generated declarations,
and the public package version must pass one field-parity test before release.
ctx.filePath in beforeFinalize remains the proposed final target, matching
the published filename-template plugin. inputFilePath and targetFilePath
make the previously implicit distinction explicit.
Plugins that do not declare Hooks keep their activation and command behavior. Missing optional runtime components may remain a no-op only in isolated unit tests. Production Electron and Server assembly must provide the runtime, database, recovery, and delivery dependencies or fail startup.
15. Observability
Every invocation emits structured start/finish records with Hook, task ID, plugin ID/version, invocation ID, role, queue delay, activation time, runtime, duration, outcome, error category, and effect counts. Sensitive payload values are not logged.
Finalize audit records include plan ID, source/target classification, replacement owner, journal phase, promoted/discarded bytes, compensation, and recovery result. Post-delivery metrics include pending/delivering/delivered/ dead-letter counts, attempt latency, retries, and oldest pending age.
Electron and Server use the same event names and field meanings. Tests assert both assembly paths, not merely the core factory.
HTTP URL diagnostics
For each participating HTTP beforeCreate plugin, its log includes host
records for beforeCreate.input, beforeCreate.output, and engine.dispatch,
correlated by taskId. The dispatch record describes the parameters handed to
the engine adapter, not a successful network request. Failed Hooks also record
beforeCreate.error when the chain handles the failure.
Normal application and plugin logs preserve ordinary query parameters such as
id, export, and confirm. A provider-independent credential-name policy masks
values such as token, sig, signature, and X-Amz-Credential with
[redacted]; URL credentials and fragments are removed. Query ordering,
duplicate parameters, empty values, and percent encoding are preserved for
unmasked parameters. These transformations affect log copies only: the Hook
context and engine request retain the original URL bytes.
The plugin log tab's Verbose mode enables raw diagnostic capture for that plugin for one hour. The host enforces expiry on every log write, even with the tab closed; reopening the tab retrieves the existing expiry without renewing it. Disabling the mode affects future entries. Clear empties the displayed buffer; previously captured entries remain in the local log file.
Copy redacted logs sanitizes the selected entries again, including entries captured in verbose mode: all URL query values are masked, URL credentials and fragments are removed, and structured headers and bodies are omitted. This generic policy cannot identify every secret in arbitrary plugin text or URL paths; review the result before publishing it. No per-site rules or plugin SDK API changes are required.
16. Electron and Server assembly
Each shell performs this order:
- open and migrate the database;
- discover plugins and create the capability host and
PluginHost; - create exactly one shared plugin Hook runtime;
- register the runtime's occurrence consumer;
- recover finalize journals;
- restore task/session state without opening producers;
- inject the same runtime into create, finalize, media, recovery, and error paths;
- drain materialized deliveries and occurrences, then open polling and engine completion producers;
- on shutdown, stop admission, drain accepted work within budget, persist leases, then stop plugin workers.
The shells may differ in notification adapters and path-policy inputs, but not in Hook eligibility, DTOs, scheduling, commit, recovery, or delivery policy. Both call one shared startup coordinator; ordering tests assert its exact events.
17. Builtin acceptance matrix
Tests use the build output fetched from scripts/builtins.lock.json, never a
rewritten fixture or source copy.
| Plugin | Locked release | Required real Hook acceptance |
|---|---|---|
motrix.url-resolver |
1.0.0 | beforeCreate keeps its API requests and redirects inside Commons host permission, then rewrites to the returned upload.wikimedia.org URL after independent output-policy validation. |
motrix.filename-template |
1.1.1 | beforeFinalize reads ctx.metadata.getAll(), renders nested metadata, and commits an automatic no-clobber rename. |
The matrix also covers idle disposal followed by finalization, restart
recovery, two tasks completing concurrently through the same plugin, complete
afterComplete/onError DTOs, replacement output not overwritten by the
original, transaction failure/compensation, crash recovery, malicious
host-vs-path URLs, and Electron/Server assembly parity.
Because the locked builtins do not declare post-Hooks, a separately built SDK
2.0 compatibility plugin must execute all four Hooks through real QuickJS
without reading delivery; the SDK >=2.1 fixture executes both post-Hooks and
reads stable ctx.delivery.id. These are runtime bundles, not mocked callbacks.
18. Normative invariants
- H1: a Hook candidate is never derived only from the active plugin set.
- H2: at most one host-initiated guest entry runs in a plugin VM.
- H3: every invocation-scoped worker message carries the current
invocationIdand cannot act on another invocation. - H4: every Hook DTO and result is runtime-validated at both trust boundaries.
- H5: one canonical structured matcher governs HTTP
beforeCreateresolver selection and every guest HTTP request/redirect. - H6: later plugins see only validated effects from successful earlier plugins.
- H7: pre-Hook
ctx.metadataimplements get/has/getAll/keys/set/delete with deterministic read-after-write behavior; post-Hooks expose only the four readonly methods. - H8: before its first identity read the source is quiesced under an exclusive task artifact-mutation lease, held through commit/compensation and identity-checked cleanup; unknown changed bytes are never deleted.
- H9: the orchestrator never promotes staging or renames the source.
- H10: a finalize commit never overwrites an unrelated existing target.
- H11: a replacement is installed instead of, never underneath, the original source rename.
- H12: task terminal state, task-file paths, plugin metadata, occurrence, and journal commit phase change in one SQLite transaction.
- H13: every filesystem-crossing finalize has a durable pre-mutation journal and an idempotent recovery path.
- H14: an admitted post-Hook delivery has stable identity and survives restart until delivered or explicitly dead-lettered; a quota rejection is represented only by the bounded observable tombstone contract.
- H15: one plugin's post-Hook failure cannot block another plugin or an unrelated occurrence consumer.
- H16: Electron and Server construct and inject the same core runtime.
- H17: Hook-produced download URLs use normal task-source output policy, never the plugin HTTP host-permission predicate.
- H18: finalizer and post-Hook eligibility never depends on an HTTP source URL or the active plugin set.
- H19: every destructive file action is preceded by a durable strong identity and followed by file/directory durability before its journal phase.
- H20: terminal task state, occurrence, and every candidate's delivery row or bounded quota-admission decision are committed together.
- H21: permission generation changes close admission and abort stale capability leases before the new policy is published.
- H22: lane call cycles fail before enqueue and cannot deadlock.
- H23: regular files and directory trees have discriminated strong identities and equally journaled same-device/cross-device recovery.
- H24: active payloads, terminal receipts, and quota tombstones have atomic per-plugin/global row and byte bounds; post data cannot consume core reserve.
- H25: a historical delivery can use only creation-time permissions intersected with a live generation lease, and an upgrade never runs old code.
19. Acceptance criteria
Implementation is complete only when:
- all invariants above have focused positive and negative tests;
- the three locked builtin archives first pass locked size, SHA-256, and
signature assertions, then pass the real Hook matrix through
PluginHost, QuickJS, and the runtime; - real compiled SDK 2.0 four-Hook and pinned SDK >=2.1 delivery fixtures pass Worker/Host/schema field-parity and QuickJS invocation tests;
- permission-confusion, idle/reactivation, concurrency, restart, file and BT directory replacement/rename, same/cross-device database failure, crash-phase, historical-grant/upgrade, post-delivery quota, and shell-parity tests pass;
- schema parity, boundary, filename, lint, type-check, complete unit test, Electron build, and Server build gates pass;
- an independent adversarial review finds no unresolved correctness, security, data-integrity, SDK-contract, or shell-drift issue.
20. Initial adversarial design review record
An independent read-only reviewer returned FAIL on 2026-08-31. All eleven findings were accepted; none were rejected. This table records disposition and rationale rather than hiding the failed first pass.
| ID | Severity | Disposition | Resolution and rationale |
|---|---|---|---|
| C1 | Critical | Resolved — accepted | Network hostPermissions now govern guest requests only; Hook output URLs use independent task-source policy, allowing the locked Commons resolver safely. |
| H2 | High | Resolved — accepted | HTTP source matching selects only beforeCreate resolvers; finalizer/post candidates use task activation and per-call capability checks, so FTP/BT are not lost. |
| H3 | High | Resolved — accepted | Strong mandatory identities, recorded staging mappings, no-follow handles, no-replace install, target-local cross-device copies, fsync points, path-equivalence branches, and adjacent-phase recovery are normative. |
| H4 | High | Resolved — accepted | Candidate snapshots and delivery rows now commit with terminal state; exact executable retention and lifecycle/deletion ordering are defined. |
| H5 | High | Resolved — accepted | Startup restores journals and task/session state before draining post deliveries; both shells use one ordered coordinator. |
| H6 | High | Resolved — accepted | Guest-selected HTTP proxies are rejected; only Host-managed proxy routing is allowed. |
| H7 | High | Resolved — accepted | Manifest v1 omits a port and matches any valid port; IPv6, query, percent encoding, and a shared matcher corpus are explicit. |
| H8 | High | Resolved — accepted | A permission-generation barrier closes admission, cancels leases, drains/terminates the lane, and rejects stale messages. |
| M9 | Medium | Resolved — accepted | Call-chain propagation rejects self/cyclic plugin re-entry before queueing and defines cancellation propagation. |
| M10 | Medium | Resolved — accepted | Pre/post metadata surfaces are distinguished, bridge metadata is internal, exact DTO limits and QuickJS AbortSignal ordering are specified. |
| M11 | Medium | Resolved — accepted | Lease, backoff, fairness, attempt/age limits, circuit breaker, queue cap, retention, task independence, and deterministic test controls are specified. |
The review also found the English and Chinese drafts substantively equivalent.
First re-review
The same independent reviewer returned FAIL after the first revision. It kept C1, H2, H5-H7, and M9 closed, but reopened H3/H4/H8/M10/M11 as four concrete High findings. All four were accepted; none were rejected.
| ID | Severity | Disposition | Resolution and rationale |
|---|---|---|---|
| N1 | High | Resolved — accepted | ArtifactIdentity now covers files and deterministic directory trees; same/cross-device no-replace install, recursive durability/recovery, and real multi-file BT cases are explicit. |
| N2 | High | Resolved — accepted | Creation generation is audit-only; historical authority is the creation/current permission intersection, while upgrade atomically supersedes old rows and never runs old code. |
| N3 | High | Resolved — accepted | The stable delivery envelope, post contexts, task/error snapshots, strict schema/version behavior, minimum public SDK minor, compile fixture, and real QuickJS assertion are exact. |
| N4 | High | Resolved — accepted | Atomic per-plugin/global row and byte quotas cover active and terminal data; terminal payloads compact, cap hits use bounded tombstones, and plugin storage cannot consume the core reserve. |
A second re-review is required after these paired revisions; implementation planning remains blocked until it returns PASS.
Second re-review
The reviewer closed every original and N1-N4 finding but returned FAIL for one new High. It was accepted; no finding was rejected.
| ID | Severity | Disposition | Resolution and rationale |
|---|---|---|---|
| N5 | High | Resolved — accepted | Finalization now requires successful engine/Host quiescence and an exclusive artifact-mutation lease before identity capture; complete identities are rechecked around every action and before commit/delete, while mismatches preserve bytes and quarantine. Mutation-injection, exact config-boundary, and SDK 2.0 post compatibility tests were also added. |
A third re-review is required; implementation planning remains blocked until it returns PASS.
Third re-review and gate result
The independent reviewer returned PASS. C1, H2-H8, M9-M11, and N1-N5 are all closed with no new Critical or High finding. The reviewer confirmed paired language equivalence and deterministic acceptance expectations. The residual implementation risk is platform support for held no-follow handles, directory durability, and atomic no-replace primitives; the specification requires an unsupported platform to fail closed. The design-review gate is therefore closed and implementation planning may begin.