flash

Plugin wire protocol

This is the complete, language-agnostic contract between Flash and a plugin. Any executable that speaks it over stdio is a valid plugin. The maintained Rust SDK (docs/plugin-rust-sdk.md) is the blessed implementation, not a wall.

Normativity order: Plugins/_flash_plugin_rust/protocol.json (the machine-readable constants: version, deadlines, quotas, error strings, capability registry, transport limits) is the specification, and the Rust SDK’s wire/runtime tests plus its probe crate (Plugins/_flash_plugin_rust/probe) are the conformance authority that proves it. This prose explains the contract; the host asserts its constants against the same file; any divergence is a bug in the derived artifact, never in the contract.

Process model

Plugins are Flash-owned child processes. Official plugins ship inside the app bundle; third-party plugins are listed in [plugins] third_party as github:user/project@<commit-sha> (full 40-character SHA, mandatory — the materializer fetches exactly that commit and refuses a mismatched checkout) or file:<path>. The manifest’s optional install shell string (third-party only) runs sandboxed from the plugin root on an asynchronous install job. The job owns a process group, drains both output pipes while it runs, retains at most 4 MiB stdout / 256 KiB stderr, and escalates timeout or cancellation from SIGTERM to SIGKILL after 500 ms. A stopped/replaced attempt cannot write an install stamp or launch a child. Install attempts for the same canonical root run in submission order across process replacements. Cancellation keeps ownership until the old group exits; cancelled waiting attempts never spawn. exec is an argv array exec’d directly with the scrubbed plugin environment — no shell wrap. Its first element resolves in order: absolute paths pass through, ./-style paths resolve against the plugin root (official Rust plugins), and bare names resolve through mise which then a login-PATH walk. This keeps the protocol open to third-party executables without adding runtime bridges to the official bundle.

Runtime children receive FLASH_PLUGIN_ID, FLASH_PLUGIN_VERSION, FLASH_PLUGIN_DATA_DIR, FLASH_PLUGIN_PARENT_PID, and the plugin’s [plugin.<id>] settings as a JSON object in FLASH_PLUGIN_CONFIG. Flash builds a scrubbed environment containing only basic locale/path/process keys plus those values; ambient tokens, agent sockets, SDK search paths, and login-shell secrets are not inherited — put credentials in the plugin’s config table. Install CLI tools under FLASH_PLUGIN_DATA_DIR, never into global shell paths. A child is spawned only once the host’s login environment has resolved, so its PATH is the user’s; a resolution that fails keeps the seeded one.

Activation. A plugin is resident — spawned at startup and kept running — iff its manifest declares sources, query, hints, status, or listen. A plugin declaring only on-demand surfaces (commands, bangs, navigation) is on-demand: it stays unspawned until its first perform, which launches it (the perform deadline comfortably absorbs the startup budget) and then it stays running normally. Manifest-only plugins (no exec) never spawn at all.

A status-bound plugin — status with no sources, query or hints, its listen subscriptions presumed to feed those segments — is resident only while the enabled status bar, one of its popups or a desktop widget shows one of its segments; otherwise it is on-demand. A config reload that starts showing it spawns it; one that stops showing it stops the process and returns it to on-demand, unless a command started it, since on-demand plugins stay running once a command needs them. :plugins reports the effective activation.

Lifecycle state machine.

initial / idle → installing → launching → running
                   │            │          │
                   └────────────┴──────────┴── failure → backoff → installing
                                                        │
                                               budget exhausted → failed
stop → stopped (all attempt work invalidated)

launching = spawned, awaiting the initialize reply; the host dispatches no other requests and no events until running. Restarts back off linearly (1..30 s); after five retries within 300 s, another failure parks the plugin in failed (recover with :plugins reload or a plugin-file change). Successful initialize does not reset the rolling failure budget; even an initialize-then-crash loop parks, and a status-bound plugin that stops being observed keeps its budget too. Lifecycle transitions belong to attempt generations. Stop/reload invalidates pending retries, installation completions, and transport work. Rejected initialize replies park immediately. The published catalog survives crashes and restarts (see publish) and is dropped on failed or unload. A planned reload keeps the previous status values for up to ten seconds, until each is republished, measured from the newest replacement; any other teardown clears them. In-flight performs settle as errors. Asynchronous host RPC replies stay attached to the child that requested them: a replacement child can reuse an ID without receiving the old child’s reply.

Explicit reload and file changes reconcile the manifest through PluginManager. Unchanged definitions restart only the affected binaries and retain catalogs. A changed valid definition rebuilds its registrations, authorization, adapter, and process together and drops its previous catalog. Its requests route to the replacement from the moment it is reconciled: they wait while the old process stops, and the replacement starts only once it has. An invalid replacement keeps the last validated definition running and reports its load error; fixing the file allows reconciliation to recover without a resident-app restart. A plugin should also exit when FLASH_PLUGIN_PARENT_PID dies.

Shutdown. There is no shutdown method. The host closes the plugin’s stdin; stdin EOF is the shutdown signal — run cleanup, exit 0. The host waits shutdown_grace (1 s), then SIGTERM, then (+0.5 s) SIGKILL.

Liveness. There is no periodic heartbeat. The process termination handler catches child exit; a hung request is caught by its own deadline. The one residual probe: if the host has received no frame at all from a plugin for idle_before_ping (60 s) and has nothing in flight, it sends ping; one missed reply (10 s) tears down and restarts. Any plugin frame — a publish, a log line, a response — resets the idle clock. A blocking single-threaded plugin is fully conformant: pings never race in-flight requests. The host checks idleness on one shared low-priority sweep at half that threshold (registered only while a plugin runs, held while the displays sleep), so the ping follows 60–90 s of silence; a chatty plugin is never pinged and costs no wake-up.

Framing

NDJSON on stdin/stdout: UTF-8, one JSON object per newline-terminated line, no envelope beyond id/method/params/result (id+method = request, id alone = response, method alone = notification), plus the host’s optional trace on a request: the id (^[0-9a-z]{1,16}$) of the user interaction that caused it. A log notification emitted while serving that request carries it back as params.trace (the Rust SDK does both), so one interaction reads end to end across processes; see observability. Every request also carries deadline_ms, the milliseconds the host still waits for its reply as it writes it. Ids are positive monotonic integers per sender. Booleans and floating-point values are never integer IDs or protocol versions; ok and other Boolean fields accept only JSON booleans. Native process IDs are checked positive 32-bit integers. Host and plugin counters are independent and may overlap — an inbound id+method frame is always a request, never a reply. Exactly one reply per id’d request; responses to unknown ids are dropped. There is no cancellation message: late replies are dropped and the deadline table is the contract. A plugin may stop a handler whose answer would come too late: the Rust SDK drops a search or hints handler still running a tenth of deadline_ms (at most 50 ms) before it, which answers {"ok": false, "error": "deadline exceeded"} and logs a warning under the request’s trace; evaluate and perform run to completion. Lines are capped at 10 MiB in both directions, excluding the terminating newline; an undecodable or oversized line is dropped (never fatal) and the stream self-heals at the next newline. An outbound response that would exceed the cap is replaced by {"ok": false, "error": "response exceeded outbound frame limit"} under the same id. stderr is diagnostics only — lines are logged but never treated as plugin failure; use the log notification for structured logging (recorded with source = "plugin:<id>"); stderr is logged as whole lines, each capped at 4 KiB, at most 20 per 10 s, and the rest are counted. Geometry is always NSScreen coordinates.

The response law. Every result is a JSON object carrying boolean ok. ok: false carries a non-empty, content-free error string — with one deliberate exception: perform’s {"ok": false, "unhandled": true} (see below). Protocol-level failures use the canonical exact strings from protocol.json (unknown method: <m>, protocol version mismatch: host v<H>, plugin v1, initialize may only be called once, …); domain failures are free-form but never contain query text, candidate data, clipboard content, or config values.

Deadlines

One table, in protocol.json, that the host, the Rust SDK, and this doc all share:

name value applies to
startup 5 s (config [plugins] startup_timeout) initialize
query 50 ms evaluate
live 1000 ms (config [flashlight] live_query_timeout_ms) search
hints 500 ms (fixed; blocks the AX queue ahead of the prepared model) hints
perform 10 s (per-entry commands[].timeout_ms overrides) perform
ping 10 s ping reply
idle_before_ping 60 s inbound silence before a ping
shutdown_grace 1 s stdin-close → SIGTERM (+0.5 s → SIGKILL)

Notifications have no deadlines.

Host → plugin

Event coalescing

The replacement kinds in protocol.json host_events.replacement are state signals: each one supersedes the earlier ones with the same coalescing key. The key is the event name, except for core:ax.changed, which reports one fact per app and notification and adds the payload pid and notification — a keystroke’s AXValueChanged never replaces a pending AXTitleChanged, and one app’s change never replaces another’s.

The host queues every frame for a child’s stdin in send order under the 256-frame / 20 MiB transport budget. When it queues a replacement event while an unsent one with the same key is still waiting, it drops the older frame and appends the newer, so a child reads a subsequence of the emitted events, in emission order, and a child that briefly stops reading accumulates at most one frame per key rather than overflowing on a burst of signals. Requests, responses and every other event keep FIFO order and their budget: a child that stops reading those is still restarted once the budget fills. A frame already written to the pipe is delivered; the plugin’s own event backlog coalesces what it has read but not yet handled by the same keys (see the SDK’s replacement slots).

Status observation

core:status.observed tells a plugin which of its own status segments a status surface currently shows, so work that only feeds a segment runs only while someone can see it. The processes plugin registers its top-N cadence only while top_cpu or top_mem is observed.

{"method": "event", "params": {"name": "core:status.observed",
  "payload": {"segments": ["focused_app_details", "top_cpu"]}}}

A plugin keeps the segments it publishes correct regardless: observation schedules work, it does not filter what the host renders. A plugin that stops sampling for an unobserved segment should clear it (""), so a surface that shows it again never reads stale figures.

Plugin → host

Notifications:

Host RPCs, capability-gated default-deny (checked per process against the manifest; the full method↔capability table lives in protocol.json): host.ping, host.fetch (network_fetch), host.normal_mode_target (which also reports the focused window’s WindowServer window_id, so a plugin can name a window without asking the user to pick one) + host.activate (app_control), host.open (open), host.post_media_key (media_keys), host.process_table + host.signal (process_control), host.clipboard_write (clipboard), host.notify (notify), host.wifi_info (wifi_info), host.storage_get/host.storage_set (no capability — plugin-data-dir scoped), host.post_keys + host.post_global_key + host.ax_snapshot / host.ax_perform / host.ax_set / host.ax_select_child (accessibility). A denied call answers `{“ok”: false, “error”: “missing

capability"}`. `host.clipboard_write` replaces the clipboard with `{"text"}` (≤ 1 MiB). `host.wifi_info` requires `{"request_authorization": false}` for passive reads or `true` only for an explicit user action allowed to show the host-owned Location prompt. An undetermined request replies absent immediately after starting the prompt; callers retry after authorization instead of holding an RPC open. The method returns `{"ok": true, "present": true, "ssid": "…"}` for an associated network or `{"ok": true, "present": false}` when Wi-Fi information is unavailable. `host.notify` shows a transient banner from `{"message", "duration_ms"?}` (message ≤ 1 KiB, duration clamped 500–10000 ms, one accepted call per plugin per second). `host.storage_get`/`host.storage_set` are a host-managed KV store persisted to `storage.json` in the plugin's data dir (`{"key"}` / `{"key", "value" | null}`; keys ≤ 128 B, values ≤ 64 KiB, 256 entries; null deletes). `host.open` takes `{"url"}` or `{"bundle_id"}`; a URL's reply also names the app LaunchServices handed it to (`bundle_id`, when resolvable), so a plugin can confirm that app still has focus before acting on it. `host.post_global_key` accepts one `{key_code, modifiers}` chord posted through the host's session event stream for macOS-owned shortcuts and rejects unmodified input. The AX broker exists because `AXUIElement` cannot cross a process boundary: `host.ax_snapshot` BFS-walks a subtree (default cap 3000 nodes) and returns flat nodes with opaque handles; geometry in NSScreen coordinates. An optional `deadline_ms` bounds the walk from the call's arrival: past it the reply carries the nodes reached so far and `"truncated": true`. `capabilities` also includes `network` (composes `network-outbound` into the sandbox profile), `network_fetch` (the host performs HTTPS GETs on the plugin's behalf via `host.fetch`, restricted to the manifest's `fetch_urls` prefixes, 8 s timeout, 1 MiB UTF-8 cap — the plugin keeps a fully network-denied sandbox; the two must be declared together), and `subprocess` (permits helpers that cannot run inside the profile). `open` hands URLs/bundle ids to LaunchServices host-side, `media_keys` posts NX_SYSTEM_DEFINED keys host-side, `process_control` reads the process table and SIGTERMs pids host-side. `wifi_info` exposes only the current SSID after host-owned Location authorization. Omitted capabilities are default-denied. The registry is frozen: additions are allowed, renames never. **SDK `call_host` convention:** `call_host` never throws and never returns nil — it always yields a result object. Capability NAKs, host death (`{"ok": false, "error": "host closed stdin"}`), and the 5 s default timeout (`{"ok": false, "error": "host call timed out"}`, per- call override available) all arrive the same way. ## Payload shapes and quotas Catalog rows (for `publish` and `search`) are `{"source", "title", "url"?, "metadata"?, "effect"?}` — `source` must name a manifest `sources[].name`; routing `source_id` is always host-stamped and never crosses the wire. Catalogs are bounded to 10,000 rows / 4 MiB encoded; titles 4 KiB, URLs 16 KiB, metadata 64 entries (256-byte keys, 64-KiB values), effect text 64 KiB. `evaluate` answers use the deliberately narrower `{"answers": [{"title", "subtitle"?, "effect"}]}` — URLs, metadata, and unknown keys are rejected — bounded to 16 answers / 256 KiB with 16-KiB fields. `effect` runs only after explicit selection: `{"type": "copy_text", "text"}`, `{"type": "insert_text", "text"}`, or — **rows only** — `{"type": "open", "url"}` / `{"type": "open", "bundle_id"}` (answers reject `open` for the same reason they reject URLs: evaluators cannot manufacture navigation). There is deliberately no `run` effect — a host-executed argv would escape the plugin's sandbox; side effects that run commands belong in `perform`, inside the sandbox. Every bound rejects the complete payload atomically; nothing is silently truncated. Array byte quotas count the compact UTF-8 JSON encoding of `rows` or `answers`, including JSON syntax and string escaping, with literal Unicode and unescaped slashes; the surrounding request/response envelope is excluded. Publish and search apply the same catalog decoder and byte boundary. Optional values may be null, but present values must have the declared type. Perform outcomes form an exclusive union: success fields cannot accompany an error, and `unhandled` cannot accompany success or an error string. ## Manifest Required: `id`, `name`, `version`, `description`. `install` is optional and third-party-only: a shell string run once per version from the plugin root, sandboxed (writes confined to the plugin root/data dir/temp, secrets read-denied, network and exec open), with its full output persisted for forensics. `exec` (argv array) is required for any plugin that runs a process; omitting it declares a **manifest-only plugin** — no child process ever runs, and the manifest may only carry surfaces the host serves alone: `mappings`, `help`, `action_bindings`, `terminal_emulators` and `on_demand_hints` (the bundled `defaults` and `terminals` plugins are the exemplars). Anything process-bound is rejected. Loading is strict — unknown top-level or nested keys and malformed known fields are rejected outright, and new manifest surface only ever arrives together with a protocol change, so "unknown key" always means "typo or stale host", never ambiguity. ```json { "id": "example", "name": "Example", "version": "0.1.0", "description": "Example plugin", "exec": ["./flash-plugin-example"], "sandbox": { "exec": ["/usr/bin/osascript"], "appleevents": true }, "capabilities": ["app_control"], "listen": ["core:flash.started", "core:apps.*"], "only_bundle_ids": ["org.mozilla.firefox"], "priority": 25, "sources": [ { "name": "example.items", "kind": "locations", "priority": "normal", "live": false } ], "query": { "prefixes": ["="] }, "commands": [ { "command": "example", "subcommand": "run", "description": "Run example", "timeout_ms": 300000 } ], "bangs": { "command": "example", "items": [ { "token": "ex", "description": "Search example", "source": "example.items" }, { "token": "*" } ] }, "actions": ["resource_archive"], "hints": { "fallback_on_empty": true }, "navigation": ["example"], "status": ["state"], "action_bindings": { "tab_next": { "": "cmd+shift+]" }, "clipboard_copy": { "": ["cmd+a", "cmd+c"] }, "window_new": { "": { "menu": ["File", "New Window"] } }, "app_print": { "": false } }, "action_confirmations": { "resource_archive": "Archive it?" }, "mappings": [ { "key": "ge", "mode": "normal", "command": ["flash", "resource_archive"], "only_bundle_ids": ["org.mozilla.firefox"] } ], "help": { "topics": [] } } ``` Section semantics: - **`sandbox`** — deny-default seatbelt opt-in (even `{}`): everything is denied except loading the binary, broad reads minus secrets (`~/.ssh`, `~/.aws`, keychains, other plugins' data), and writes inside the plugin's data dir. Allowances: `exec` (absolute paths or bare tool names resolved through `mise which`/login-PATH at spawn), `appleevents`, `signal`, `mach`. The `network` capability composes `network-outbound`. `[plugin.] sandbox = false` is the per-plugin fail-open kill switch; `[plugin.] exec_paths` appends machine-specific tool paths. A spec-less plugin runs under a network-deny-only profile, or unsandboxed when it declares the `network` or `subprocess` capability (browsers, for example: Chromium browsers refuse Apple Events from any sandboxed sender). - **`listen`** — event-name patterns; `*` wildcard. Declaring `listen` makes the plugin resident. - **`sources`** — source descriptors for `@` completion and ranking. `kind` is `"default"` (default) or `"locations"`; `priority` is the semantic salience enum `low | normal | high | important | urgent` and sets the source's precedence weight in the flashlight (see [flashlight ranking](/flash/flashlight.html#ranking)): it orders rows against other sources inside one match tier and never lifts a partial hit above an exact one. Bundled: `tmux.windows` is `important`, apps and browser tabs `high`. `live: true` marks a live (pull) source served by `search`. - **`query`** — registers the per-input evaluator. `prefixes` are exact literal markers (never regexes) that route matching input only to this evaluator; without one, evaluators are additive. Evaluator ordering is the manifest root `priority`, ties broken by plugin id. - **`commands`** — `flash ` registrations, the same words as `:command subcommand`. Both words are required. An empty subcommand is rejected, and so is a `verbs` entry (that was a one-word `flash `). Subcommand `"*"` consumes the remainder as args; per-entry `timeout_ms` overrides the `perform` deadline for interactive commands. A command name that matches a Flash verb, a reserved query, or another plugin's command fails the load of every plugin that claims it. Declaring the same pair twice does too. - **`bangs`** — flashlight bangs; `token: "*"` is the catch-all; an entry's `source` scopes live-source completion to that bang. - **`actions`** — source-owned normal-mode action names, dispatched as `perform {kind: "action"}`. - **`hints`** — opts in as a hint provider (always live). Selection is exclusive; set `fallback_on_empty: true` only when applicability is discovered dynamically and empty means "not applicable". - **`navigation`** — durable route schemes restorable from movement history, dispatched as `perform {kind: "navigate"}`. - **`status`** — status-bar segment names fed by the `status` notification. - **`action_bindings`** — what each app does for Flash's actions: action name → bundle id → binding. The bundle id `""` covers every app the root selector matches. A binding is one of: - `"cmd+t"` — a chord; - `["cmd+k", "cmd+w"]` — chords sent in order, `[mode] send_key_interval_ms` apart; - `{ "menu": ["File", "New Tab"] }` — the menu-bar item at that title path (menu, then item, then any submenu item), pressed through Accessibility off the main thread; a missing or disabled item fails the action with no fallback; - `false` — the app has no such action: nothing happens. `tab_select` bindings may name the tab with `{index}` (`"cmd+{index}"`, `{ "menu": ["Window", "Tab {index}"] }`); a chord that doesn't exist for the index (`cmd+10`) sends nothing. Validation is strict: an unknown action, an unparseable or empty chord (`""` is not "none", write `false`), an empty array, `true`, a menu path shorter than two titles, an object other than `{"menu": [...]}`, or `{index}` outside `tab_select` rejects the manifest. **Resolution.** An action first goes to the sources that perform it in the focused app (manifest `actions`, the built-in Accessibility tab strip); a source that claims it and fails stops there. Otherwise one binding wins, by a pure, deterministic ranking: an entry for the app's bundle id beats any plugin-wide `""` entry; then a plugin whose root selector (`only_bundle_ids`, `only_terminals`) matches the app beats an unscoped one (a plugin whose selector doesn't match contributes nothing); then the higher manifest `priority`; then the plugin id, alphabetically first. With no binding nothing happens, except that `resource_next` / `resource_previous` and `scroll_top` / `scroll_bottom` fall back to Flash's own scrolling and `window_close` tries the window's close button before its binding. In a terminal emulator a chord the emulator would type as text is refused, unless a plugin binds that chord for that emulator specifically (its bundle id or a matching selector); a menu press is never refused. The inspector's Mappings page shows the resolution for the focused app. Actions: `tab_select`, `tab_next`, `tab_previous`, `tab_first`, `tab_last`, `tab_new`, `tab_close`, `tab_reopen`, `tab_move_next`, `tab_move_previous`, `pane_next`, `pane_previous`, `pane_split_vertical`, `pane_split_horizontal`, `pane_close`, `app_reload`, `app_reload_force`, `resource_archive`, `resource_next`, `resource_previous`, `scroll_top`, `scroll_bottom`, `history_back`, `history_forward`, `app_undo`, `app_redo`, `app_find`, `app_save`, `app_print`, `document_open`, `window_new`, `window_close`, `clipboard_copy`, `clipboard_cut` and `clipboard_paste`. Every one except `app_reload_force` is also a valid manifest `actions` name. The bundled `defaults` plugin declares the macOS conventions as `""` bindings; disabling it leaves those actions unbound. Manifest-only plugins may declare `action_bindings`. App knowledge lives here, never in the host. - **`action_confirmations`** — the question the host asks before an action this plugin performs destructively: action name → prompt, for names in `actions` only (an empty prompt or an action the plugin does not perform rejects the manifest). In the apps the root selector matches, the host shows the prompt as its confirmation alert (`[y]es / [n]o`, keys in the hint colour) and dispatches the action only on `y`; the plugin then performs it without a prompt of its own, which NORMAL mode's capture would swallow the answer to. Across plugins, a matching selector beats an unscoped one, then the higher `priority`, then the plugin id. The bundled tmux plugin gates `tab_close` and `pane_close` this way. A mapping gates any command the same way with `["flash", "confirm", "--prompt=…", "--command=<verb [--arg=value]>"]`. - **`terminal_emulators`** — bundle ids of apps this plugin declares as terminal emulators. No protocol identifies one, so the host learns them as data: across plugins the union gets the terminal rules (an unbound Command chord is refused rather than typed, pixel wheels are refused, and a primary hint or grid click anywhere in one enters INSERT) and matches `only_terminals`. A terminal link hint stays in NORMAL. The bundled manifest-only `terminals` plugin declares the common emulators. - **`on_demand_hints`** — bundle ids of apps whose accessibility tree costs too much to warm in the background. Across plugins, the union is walked only when hints are requested and observed with the reduced notification set that drives mode, border and focus. The bundled `defaults` plugin declares Notes. - **`mappings`** — key bindings in any mode a config can map: `all | normal | insert | terminal | command | hyper | hold` (default `normal`), under the config's key rules (a `command` key is one modified chord); `command` is an argv array with config-mapping syntax; entries may scope with `only_bundle_ids`, and `repeat: true` repeats the sequence when its final key is pressed again, as in config. Map keys to high-level actions; an app's own chord for one belongs in `action_bindings`, and no bundled mapping uses `send_key`. Terminal mappings are local to a focused popup. Every global mapping registration is suspended while that view owns input; only winning INSERT-active `enter_normal_mode` bindings are inherited as terminal defaults, and explicit terminal bindings override them. Terminal sequences use the shared key syntax without ``, implicit counts, or register prefixes. See [popup input](/flash/normal-mode.html#popup-input). `only_bundle_ids` and `only_terminals` may appear at the root and on mapping entries; root and entry selectors compound. `only_terminals: true` scopes to the declared terminal emulators (the union of every plugin's `terminal_emulators`, see below) so a plugin never carries its own terminal allowlist; the registry instantiates such a plugin only while a known terminal runs, and its `hints` provider is consulted only in those apps, keeping hint activation elsewhere on the zero-hop prepared-model path. The numeric manifest `priority` (default 25) is scheduling/collision arbitration — do not confuse it with the semantic `sources[].priority` salience enum. ## Conformance workflow `Plugins/_flash_plugin_rust/protocol.json` pins the constants; the SDK's `cargo test` suite pins the behaviour. Wire and lifecycle rules — framing, ids, noise, the handshake, protocol mismatch, request decoding, notification shapes, shutdown draining, host-RPC correlation — are `WireHarness` tests in `Plugins/_flash_plugin_rust/src/runtime.rs`, driving the real serve loop over in-memory NDJSON streams. `Plugins/_flash_plugin_rust/fixtures/wire-values.fixture` is the malformed and boundary-value corpus the SDK and the host's XCTest suites both consume, and the probe crate `Plugins/_flash_plugin_rust/probe` drives a generic `plugin!`-generated plugin through every surface (catalog, evaluate, search, hints, the perform trichotomy, notifications, events, host-RPC arms) over the wire. For a protocol defect, land the failing test there before changing the implementation. Behaviour specific to one plugin — scripted host replies, the published catalog, status rows — is a `cargo test` in `Plugins/` written against `flash_plugin::testing::Harness`. Real process spawning, manifest reconciliation and shutdown are the host XCTest suites' business; `PluginSandboxExecTests` compiles every bundled seatbelt profile and boots every built sandboxed plugin under its resolved profile with the scrubbed plugin environment. The complete gate is: ```bash ./Scripts/test-plugins.sh --lane all ``` It formats and lints every Rust crate, runs the plugin-publication test and every crate's unit tests (the SDK workspace includes the probe), and builds all plugins. ### Seatbelt profile invariants Seatbelt denies are not reopened by a later allow rule. When a denied parent contains an allowed child, carve the child out inside the deny filter with `require-all` and `require-not`; `PluginSandbox.secretsReadDeny` is the canonical pattern for allowing a plugin's own data directory while denying its siblings. Seatbelt matches canonical vnode paths. Resolve symlinked executables before putting them in a profile, and canonicalize temporary fixture roots (`/var/folders/...` commonly resolves beneath `/private/var/folders/...`). A path that looks equivalent in the shell may otherwise fail silently in the profile. For diagnostics, `sandbox-exec` exit 65 means the profile did not compile, while exit 71 means the target could not be executed under it. ## Frozen decisions Locked while breaking is still free (the MV3 lesson); changing any of these after external plugins exist is a migration, not an edit: 1. One integer `protocol_version`, echoed exactly at initialize; **never** per-feature wire negotiation — features are manifest declarations. 2. The response law and the canonical error strings in `protocol.json`. 3. Id semantics and no-cancellation (late replies dropped). 4. UTF-8 NDJSON framing, 10 MiB line cap, NSScreen geometry. 5. The quota table and the atomic-rejection doctrine. 6. The capability registry: additions allowed, renames never, granular over coarse. 7. The `performed / unhandled / error` trichotomy as the universal action vocabulary, including "error must not fall back". 8. Strict manifest unknown-key rejection + new surface only with a protocol change. 9. Namespacing: row `source` names, storage keys, status segments, and log sources are plugin-id-scoped host-side. 10. Content-free logging. 11. Normativity order: `protocol.json` + the SDK's conformance tests > this prose > any implementation. Debugging is a first-class feature of this transport: run any plugin binary in a terminal and type NDJSON at it — no host required. The SDK's `WireHarness` does the same in-process from `cargo test`.