Skip to content

Design Decisions

PCBZ edited this page Sep 17, 2026 · 1 revision

Design Decisions

Every clause in SPEC.md carries a Rationale. This page is where the longer reasoning lives — the part that doesn't fit next to a clause but that you'll want six months from now when someone proposes to change it.

Format: Context → Decision → Rationale → Consequences → Status. Decisions are append-only; superseding one means adding a new entry that says so.


D1. Host calls plugin, no proxy

Context. The obvious shape for a security layer is a proxy: sit between the RAG system and the model, inspect what flows through.

Decision. The host invokes plugins directly at defined points. RSP is not a proxy and defines no network position.

Rationale. A proxy cannot reach on_chunk. By the time content is visible at a proxy boundary it has been embedded and stored, and embedding inversion makes stored content recoverable. on_chunk is the only hook where "blocked" means "was never there," and it is the reason this protocol exists. A design that structurally cannot implement its own key feature is the wrong design.

Consequences. Every host must be modified — there is no drop-in deployment. This raises adoption cost and makes host-side ergonomics (D7) a first-class concern rather than a nicety.

Status. Accepted, v0.1.


D2. Subprocess transport, JSON over stdin/stdout

Context. Plugins could be in-process Python objects, shared libraries, local HTTP services, or subprocesses.

Decision. Subprocess. JSON on stdin, JSON on stdout.

Rationale. Three reasons, in order of weight:

  1. The tools worth wrapping are binaries in other languages. Gitleaks is Go, Presidio is Python. Wrapping beats reimplementing, and the ~50-line adapter target only holds if the transport is process-shaped.
  2. Plugin authors use any language. An in-process design picks one.
  3. Plugins are untrusted code. A security tool whose plugin system is itself an attack surface would be an embarrassing outcome. A subprocess boundary is a real one — it can be sandboxed, resource-limited, and killed.

Consequences. Per-call overhead is real, which forces D4 (caching) from day one rather than as an optimization. stdout is a protocol channel, so plugins that print debug output to stdout corrupt the stream — the spec must say diagnostics go to stderr, and rsp-chaos must test the flooding case.

Status. Accepted, v0.1.


D3. Fail closed

Context. A plugin crashes, hangs, or emits malformed JSON. The host must decide what the chunk's verdict is.

Decision. Error → BLOCK. A per-plugin on_error: block | allow | skip override exists; the default is block and the spec states the default with its reasoning.

Rationale. This is where security protocols usually get it wrong. Failing open makes the failure mode invisible: a misconfigured plugin looks exactly like a clean corpus, and the operator learns nothing until the secret is already in the vector store. Failing closed makes the failure mode loud and recoverable — an over-blocked chunk can be re-indexed after the plugin is fixed; an embedded credential cannot be unembedded.

The override exists because a FLAG-only plugin (say, a metrics collector) blocking the pipeline when it crashes is not defensible. But the default carries the security property, and defaults are what most deployments run.

Consequences. A flaky plugin can halt an ingest. Timeouts must therefore be tunable per plugin, and the error message must name the plugin, the hook, and the failure class, or operators will disable RSP rather than debug it.

Status. Accepted, v0.1.


D4. Verdicts are cacheable, keyed by content and plugin identity

Context. Reindexing a corpus re-runs every plugin over every chunk. With subprocess transport (D2), that is tens of thousands of process spawns.

Decision. Cache key is hash(content + plugin_version + plugin_config). Plugins declare determinism at handshake; non-deterministic plugins are never cached.

Rationale. Without caching, subprocess transport makes reindexing unusably slow, and the honest response would be to abandon D2 — which would cost the ability to wrap third-party binaries. Caching is what keeps the transport decision affordable.

Including plugin_version and plugin_config in the key means upgrading a plugin or editing its config invalidates exactly the affected entries, with no manual cache-clearing step. Determinism is declared rather than assumed because an LLM-backed classifier plugin is a legitimate thing to write and caching it would be wrong.

LlamaIndex's ingestion cache already hashes node+transformation pairs, which is the same shape — another reason for D7.

Consequences. Plugins must report a meaningful version. A plugin that changes behavior without bumping its version serves stale verdicts, so the spec states the requirement and the conformance kit checks that a version is present and stable within a run.

Status. Accepted, v0.1.


D5. Large content passes by reference

Context. Inlining a 40 MB document into a JSON message is possible and unwise.

Decision. Under a threshold, content is inlined. Over it, the message carries a file path plus offsets. The threshold is negotiated at handshake.

Rationale. Inlining everything makes the common small-chunk case simple and the rare large case pathological. Referencing everything makes the common case require filesystem I/O for a 400-byte chunk. Negotiating the threshold lets a plugin that streams from disk raise it and a plugin that can only take stdin lower it to zero.

Consequences. Plugins must handle both forms, which the conformance kit tests. The referenced file is host-owned and read-only for the plugin's lifetime of the call; the spec states that lifetime explicitly, because a plugin that stashes the path and reads it later is reading something the host may have deleted.

Status. Accepted, v0.1.


D6. Spans are UTF-8 byte offsets

Context. REDACT returns ranges. A range needs a unit.

Decision. Byte offsets into the UTF-8 encoding of the decoded content string, half-open [start, end).

Rationale. Character offsets have no cross-language meaning. Python counts code points, JavaScript counts UTF-16 code units, Go ranges over runes but indexes bytes. A span produced by a Go plugin and applied by a Python host has to agree on one unit, and bytes-of-UTF-8 is the only unit every language can produce without a conversion table. Half-open ranges because end - start is the length, which removes an off-by-one from every implementation.

Consequences. A plugin can return a span that splits a multi-byte character. The host must validate: in range, start <= end, and on a character boundary — otherwise a malicious plugin DoSes the host through a decoding error, which is exactly the rsp-chaos out-of-range case.

Status. Accepted, v0.1.


D7. LlamaIndex is the reference host

Context. The demo needs a RAG system. Hand-rolling one is less work than integrating someone else's.

Decision. LlamaIndex. TransformComponent → on_chunk, BaseNodePostprocessor → on_retrieve.

Rationale. Strategic, not convenient. A protocol with one hand-written host looks like an API with extra steps — the host was built to fit the protocol, so of course it fits. A protocol that embeds into somebody else's framework in ~60 lines is evidence the abstraction matches how RAG systems are actually built. Combined with gitleaks on the plugin side, both ends of the contract have third-party evidence, and that is the whole answer to the one-implementation risk.

Consequences. The demo depends on LlamaIndex, and a breaking change upstream breaks the demo. Acceptable — it's a demo, and the protocol does not depend on it.

Status. Accepted, v0.1.


D8. Unknown fields are ignored, never an error

Context. Every future protocol change is breaking unless forward compatibility is designed in before the first release.

Decision. The handshake exchanges rsp_version. Receivers MUST ignore fields they do not recognize, on both sides, in every message.

Rationale. This costs one sentence now and is impossible to retrofit. Without it, adding an optional field to a request breaks every strict plugin, and the only safe change becomes a version bump — which in practice means changes don't happen.

Consequences. Typos in config and message fields fail silently. Mitigation is in tooling, not the protocol: rsp validate warns on unrecognized keys in the host's own config file, where there is no forward-compatibility requirement.

Status. Accepted, v0.1.


D9. The strictest verdict wins, and BLOCK short-circuits

Context. Several plugins return verdicts for the same content.

Decision. Plugins run in configured order. Severity ordering is ALLOW < FLAG < REDACT < BLOCK. REDACT spans accumulate across plugins. The first BLOCK short-circuits the remaining plugins.

Rationale. Any rule other than "strictest wins" means a permissive plugin can override a restrictive one, which turns plugin ordering into a security-relevant configuration mistake. Short-circuiting on BLOCK is both faster and honest: once content is rejected, later verdicts about it are unused, and running them would invite a plugin to expect a verdict it never receives.

Consequences. A plugin cannot count on seeing every chunk, so plugins that also collect metrics see a biased sample. Stated in the spec so nobody discovers it through a wrong dashboard.

Status. Accepted, v0.1. Composition of overlapping spans is not yet settled — see Open Questions → Q1.