ADR-017: Official pfSense/Netgate documentation guidance layer¶
- Status: Accepted — architecture and inert scaffolding only; not wired into any production READ tool output or any Tier 1 PREPARE path. Live retrieval and semantic search are explicitly deferred, not part of this acceptance.
- Date: 2026-08-08
Context¶
This project's READ tools currently return only OBSERVED STATE — typed
data read directly from the live appliance. They carry no context about
what official pfSense/Netgate documentation says about the thing being
read, and the inert Tier 1 WRITE-planning framework (docs/tier1/) has no
concept of consulting documentation before a future Recovery Contract is
prepared. The owner asked for research and a design for an "Official
Guidance Layer" that could eventually supply that context in both places,
under one hard constraint stated up front and treated as non-negotiable
throughout this ADR: official documentation must never become
authorization. A capability, endpoint, method, or mutation must remain
selectable only through this project's existing, explicit, reviewed
registries (Capability, WriteEndpoints, profiles) — never through
anything a document says, however official its source.
This ADR is architecture and design only. Per AGENTS.md's Approval
boundaries and this session's own operating instructions, adding a new
public MCP tool, changing a READ tool's output schema, or wiring anything
into the Tier 1 PREPARE path all require separate explicit approval this
session does not have. What follows is accepted as the target
architecture and its inert building blocks (typed models, a deterministic
registry, isolation tests) — not as a production integration.
The three-layer separation¶
This design keeps three concerns structurally distinct, matching the framing the owner gave this task:
| Layer | What it is | Who/what already governs it |
|---|---|---|
| Observed state | What this MCP server reads from the live appliance right now | PfSenseClient, the existing 41 READ tools — unchanged by this ADR |
| Official guidance | Provenance-preserved excerpts of official pfSense/Netgate documentation, capability-keyed | New: this ADR's guidance layer |
| Safety authority | Capability gates, endpoint allow-lists, Recovery Contracts, confirmation, sealed executor, reconciliation, lab evidence, owner activation controls | Already exists (capabilities.py, write_endpoints.py, pfsense_mcp.tier1) — this ADR adds nothing to it and grants it no new input channel |
The guidance layer is only ever a read-only annex to the first layer and a read-only, advisory input to material a human reviews before using the third layer's existing confirmation mechanism. It has no write access to any of the three layers, including its own registry at runtime — the registry is Git-tracked, PR-reviewed data, not something the running server can extend.
Options considered¶
| Option | Strengths | Costs |
|---|---|---|
| A — No guidance layer | Zero new attack surface, zero new trust boundary, zero maintenance | READ recommendations and future WRITE planning stay context-free forever; does not answer what the owner actually asked for |
| B — General web search / unrestricted retrieval, LLM-summarized | Maximum coverage, least curation effort | Unbounded source trust, no deterministic capability→document mapping, summarization is itself a hallucination surface, no fixed corpus to security-review; explicitly the failure mode the task's own framing warns against |
| C — Full RAG: crawl docs.netgate.com, embed into a vector database, semantic search over the whole corpus | Best recall for free-text questions | A large new dependency stack and a poisonable index for a corpus that, capability-by-capability, is actually small (a handful of pages per capability at most); the task's own instructions explicitly warn against introducing this "merely because fashionable" |
D (recommended) — Deterministic Capability → DocumentSource registry, bundled/offline snapshot corpus, advisory-only typed references; live retrieval and semantic search both deferred as narrower, separately-activated future extensions |
Reuses the existing Capability enum as the join key (no new taxonomy to keep in sync); fully offline-testable; reviewable at commit time like code; smallest attack surface that still answers the actual question; matches this project's existing "protocol designed, backend/activation deferred" pattern (ADR-011, ADR-015) |
Narrower recall than B/C — a capability with no curated snapshot yields no guidance, by design, not as a bug |
Recommendation: D. It is the option that satisfies "prefer the simplest architecture that preserves deterministic trust boundaries" and the explicit steer toward "capability → deterministic approved documentation family → optional semantic retrieval within that approved corpus" rather than unrestricted search. B and C are rejected for the reasons the task itself gives; A is rejected because it does not address the stated objective.
Why bundled/offline first, not live retrieval¶
A live fetch from docs.netgate.com (even domain-allowlisted, even
TLS-strict) is a runtime network call whose response was never reviewed by
anyone at commit time. Everything this project's existing threat model
already says about A2 (untrusted upstream appliance/API response) applies
identically to a live documentation fetch — except a documentation
response is text meant to be read and acted on, which is a strictly
worse position than a typed JSON appliance response that goes through
Pydantic validation before anything downstream sees it. Starting with a
small, curated, PR-reviewed, content-hash-pinned offline snapshot gets the
actual value (context for common capabilities) with a fraction of the
trust-boundary cost, and is fully deterministic for testing — no
MockTransport-equivalent has to be invented for a v1 that makes zero
network calls of its own. Live retrieval is not rejected outright; it is
named as a distinct, future, separately-activated mode (see "Future
migration path" below), exactly as ADR-011 named a future anti-rollback
backend without selecting or building one.
Why capability-keyed, not free-text/semantic, in v1¶
Per-capability, the realistic document count is small — a handful of pages at most (e.g., the alias documentation family, the firewall-rule documentation family). Semantic search over a handful of items has worse expected value than simply returning the curated set for that capability directly: it adds a new dependency, a new failure mode (false match), and a new thing to test, to solve a recall problem that does not exist yet at this corpus size. If a capability's curated corpus ever grows large enough that returning it whole becomes unhelpful, semantic retrieval scoped only inside that capability's already-deterministically-selected document set — never a general search — is the sanctioned future extension, matching the task's own preferred shape.
Self-challenge: is "advisory-only" actually enforceable, or just a policy statement?¶
A policy statement is what the task explicitly warns is not enough — this
project's own convention (capability_adapter_contract.md's G1/G2: "the
type system, not just documentation, refuses it") is to make the unsafe
shape structurally awkward to write, not merely against the rules. This
design applies that same discipline:
GuidanceReference(the only object type the layer ever returns) has no field of type capability, endpoint, method, HTTP verb, or confirmation token — Pydanticextra="forbid", a closed field set, and a review checklist item requiring any addition to be independently justified.- The guidance package has zero import of
pfsense_mcp.tier1, zero import ofWriteEndpoints/capabilities.py's WRITE members, zero import ofRestApiClient/WriteApiClient/Transport— enforced by a new AST isolation test in the same style astests/tier1/test_isolation.py, not just a docstring promise. - The registry itself is a Git-tracked, PR-reviewed static data file, not a database or runtime-writable structure — there is no code path, today or in the design's future extensions, by which the running server mutates which documents map to which capability while it runs.
- Confirmation evidence (
confirmation_authority.md) binds to the contract's own canonical digest, computed from the contract's PREPARE fields — guidance content is never part of that digest, so a compromised or adversarial document cannot alter what an owner's confirmation signature actually authorizes even if it tried.
This makes "guidance becomes authorization" not just discouraged but
absent a code path to happen through, matching the standard this project
already holds itself to elsewhere. See the companion spec
(docs/OFFICIAL_GUIDANCE_LAYER.md) for the full invariant list.
Self-challenge: does returning documentation text to the model create a prompt-injection channel?¶
Yes, and this ADR does not claim otherwise. A malicious or compromised document could contain injected instructions. Three things bound the actual risk, none of which is "trust the source because it's official":
- No privilege exists for injected text to escalate into. Even once
a first WRITE capability is eventually authorized (Phase 5+, entirely
separate from this ADR), moving a Recovery Contract from
PREPAREDtoEXECUTINGrequires exact-bound, externally-authenticated confirmation evidence (confirmation_authority.md) that the guidance layer has no path to produce, forge, or influence. An LLM "convinced" by injected document text still cannot invoke a tool that does not exist, still cannot select a capability/endpoint outside the existing allow-lists, and still cannot manufacture a valid Ed25519-verified confirmation signature. This collapses the threat to the same class already named in the threat model as A1 (a caller with full tool access but no ability to reach anything beyond what the profile's tools expose) — "a bad influence on the conversation," not privilege escalation. - Bundled snapshots are human-reviewed before they ship. Unlike a live fetch, an offline snapshot only enters the corpus through an ordinary Git commit and PR review — the same review discipline that already catches other classes of bad content in this repository.
- Excerpts are bounded, structured, and separated from any instruction
channel.
GuidanceReference.content_excerptis a length-bounded plain string field inside a typed object returned as tool-call data, not concatenated into system/instruction content — reviewers can see exactly what content a capability can surface, and a bound on length both limits how much an injected payload can carry and keeps manual review of the bundled corpus tractable.
Live retrieval (deferred) reintroduces A2-class risk at the level of "did this specific fetch return what we expect," which is exactly why it is named as a separate, narrower, future activation rather than folded into this ADR's v1 scope.
Self-challenge: pfSense CE vs. Plus, and version drift¶
The registry's DocumentSource records explicit edition applicability
(CE / Plus / both) and a version-applicability expression per entry —
this is inspectable, deterministic data, not inference. The currently
existing SystemVersion READ model (src/pfsense_mcp/models/system_version.py)
exposes base/patch/version/buildtime but no explicit CE-vs-Plus
discriminator field — this is a real, open gap this ADR does not resolve:
matching an observed appliance to the right edition-scoped guidance may
require either a new field derived from existing upstream data (an
investigation, not a design decision, and out of scope for this session)
or an explicit "edition unknown, showing edition-neutral guidance only"
fallback. The spec adopts the fallback as the default behavior: when
edition cannot be determined, only both-applicable entries are eligible,
never a guess. Version-string matching against an entry's
version_applicability expression follows the same fail-closed rule —
on any parse ambiguity, exclude rather than guess. A guidance/state
version mismatch never overrides or edits the guidance text; it either
excludes the reference or (spec detail) attaches a version_mismatch flag
for it to be displayed as clearly non-authoritative for this appliance,
never silently presented as current.
Self-challenge: licensing and redistribution¶
Netgate documentation content ownership/licensing terms were not
independently verified this session — that determination is a legal
question this session is not positioned to resolve, and asserting a
specific license here would be a claim this ADR cannot back. The design
sidesteps needing that determination to proceed safely: the bundled
snapshot corpus is scoped to short, attributed excerpts plus a canonical
source URL for the reader to follow — never a full-page mirror — which
keeps the redistribution footprint small regardless of the exact license
terms, and every DocumentSource entry carries a license_note field the
registry maintainer must fill in per-source rather than assume. If a
future session or the owner determines a specific source's terms do not
even permit short-excerpt reuse, that source is removed from the registry;
nothing else in the design depends on any one source being present.
Self-challenge: why is this not simply folded into an existing Tier 1 spec?¶
Because its blast radius is not Tier 1-shaped. It is meant to annotate
today's active READ tools (once separately approved to do so) as much as
it is meant to feed a future WRITE PREPARE step — Tier 1's own package
boundary (pfsense_mcp.tier1, deliberately unimported by production) is
the wrong home for something that must also be reachable, eventually, from
the READ path that already ships. It is documented as its own top-level
spec (docs/OFFICIAL_GUIDANCE_LAYER.md, paired with TIER1_ARCHITECTURE.md's
existing sibling pattern for ADR-006) precisely so it is not mistaken for
Tier 1-gated work, and so its own activation gate (a schema-change
approval for the READ side; the existing Tier 1 gates for the WRITE side)
is stated once, correctly, in one place.
Consequences¶
Positive¶
- Answers the owner's stated objective (official documentation as provenance-preserved decision evidence) without weakening any existing trust boundary — verified structurally (isolation test), not just documented.
- Fully inert and fully offline-testable in this ADR's accepted scope: no network code, no new runtime dependency, no schema change to any shipped tool.
- Reuses the existing
Capabilityenum as the join key, so there is no second capability taxonomy to keep in sync withcapabilities.pyas it grows. - Establishes a clear, narrow activation gate for each future extension (READ-output wiring, live retrieval, semantic search, WRITE-PREPARE wiring) rather than one large "guidance layer, activated" decision — matching this project's existing phased-activation discipline.
Negative¶
- v1's bundled-snapshot corpus is necessarily small and will go stale
relative to live documentation; staleness is handled by explicit
version_applicability/retrieved_atmetadata and fail-closed exclusion on ambiguity, not by silently serving old content as current. - A capability with no curated snapshot yields no guidance at all, which may read as a coverage gap rather than a deliberate boundary if not clearly labeled at the presentation layer (a requirement carried into the spec, not resolved by this ADR alone).
- Every future extension named here (READ-tool wiring, live retrieval, semantic search, WRITE-PREPARE wiring) is a separate activation decision with its own review — this is deliberate (see Positive), but it does mean this ADR alone does not make guidance visible to any actual user yet.
Future migration path¶
None of the following is authorized by this ADR; each requires its own
future ADR/activation decision, exactly as ADR-011's anti-rollback
backend and ADR-016's lab authorization were separated from the
protocol-level ADRs that preceded them:
- READ-tool output wiring. Attaching an optional
guidancefield to specific READ tool outputs is a public schema change and requires the same explicit approval any other schema change requires (AGENTS.md). This ADR's scaffolding (typed models, registry, isolation test) is what such a future change would build on, not what performs it. - Live retrieval mode. A capped, domain-allowlisted, hash-comparing
HTTPS fetch, using its own isolated HTTP client entirely separate from
RestApiClient/WriteApiClient, with the same fail-closed-on-anomaly posture already applied to the pfSense transport. Needs its own spec section (present as an explicitly deferred section in the companion spec) and its own decision to actually build/activate it. - Semantic retrieval within an approved per-capability corpus. Only if a capability's curated corpus grows large enough that whole-corpus return stops being useful — not before, and never as retrieval outside the deterministically-selected set for that capability.
- WRITE-PREPARE consumption. Attaching
GuidanceReferenceevidence to a future Recovery Contract's evidence bundle for human review during confirmation — additive only, never a substitute for confirmation, never an input to capability/endpoint selection. This is a Phase 5+ concern and is gated by everything Phase 5 already requires (IMPLEMENTATION_ROADMAP.md), plus this ADR's own non-authorization invariants.
References¶
- OFFICIAL_GUIDANCE_LAYER.md — the companion spec: security goals, invariants, trust boundaries, registry schema, failure modes, required tests, activation requirements.
- THREAT_MODEL.md — TB9 and the guidance-layer adversarial-paths table added alongside this ADR.
reports-ai/reviews/ADR_017_RED_TEAM.md— adversarial review of this design and the revisions it produced (external, not Git-tracked; seereports-ai/README.md).- capability_adapter_contract.md — precedent for "the type system, not just documentation, refuses it."
- ADR-011, ADR-016 — precedent for separating protocol/architecture acceptance from backend selection/live-execution activation.