Rendered from innerwall v0.4.1ADR-0018

Render everything on change, version by diff, keep rendered state in Postgres

Status
Accepted

Context

ADR-0015 fixed the wire contract for policy: agents receive a fully rendered per-workload WorkloadPolicy as a snapshot on every new stream and as PolicyDeltas within a live stream, versions are per workload and monotonic, and a failed apply is answered with a snapshot. ADR-0002 fixed that policy moves as desired-state deltas over one persistent stream. ADR-0017 fixed that nothing durable lives in a control-plane replica and that the compiler runs singly under a Postgres advisory lock. What none of them fixed is how rendered policy is produced, when a version advances, and how a change made in one process reaches the stream held in another. Implementing the first sync stream made those three decisions concrete, and each is one a later change could plausibly undo: a version bump on every save, a delta hand-built per change type, a rendered policy cached in a replica.

Decision

  1. Render on change; version 1 re-renders every workload. Any authored mutation (a ruleset, service, or address group created, updated, or deleted), an enrollment, a label change, an enforcement-mode change, and an inventory report that changes a workload's addresses triggers one render. A render is synchronous in the process that made the change and recomputes the rendered policy of every registered workload from the complete authored model and the current registry, inside a single transaction. There is no affected-set computation: correctness comes from re-deriving everything from the same inputs, and the cost is acceptable while an estate is small enough that one process authors its policy. The scaling path is an inverted index from label to the selectors that mention it, which narrows the set of workloads a change can affect; it is an optimization of this decision, not a replacement for it, and it must preserve decision 2 exactly.

    The advisory lock ADR-0017 requires is taken by every render transaction, so renders in any two processes are serialized. ADR-0017 is amended to carry this render model: the affected set is every workload, and the property a narrower set would protect, that a change to one workload's policy never disturbs another's version, is recovered through decision 2.

  2. Deltas are the diff of rendered outputs, and a version advances only when the diff is not empty. For each workload the render diffs the previously persisted WorkloadPolicy against the newly rendered one and produces the minimal DeltaChange set of ADR-0015's rendered model: rule upserts and removals, peer additions and removals, and a mode change. An empty diff means the version stays and nothing is pushed. A non-empty diff means the version advances by one, the new policy is persisted at that version, and the change is announced. Consequently there is one code path for every kind of change, coalescing of several authored edits into one version is automatic, and an authored change that touches nothing a workload can see leaves that workload alone. The contract this rests on is executable and lives in the repository for as long as the decision holds: for every pair of consecutive versions, applying the delta between them to the earlier snapshot yields the later snapshot exactly, under a canonical serialization, across every DeltaChange variant.

    A resolved rule carries exactly one protocol while an authored rule may permit several, so an authored rule renders to one resolved rule per protocol. Deltas key rule removals and peer changes by resolved-rule id, which must therefore be distinct per protocol: the id is the authored rule id followed by / and the protocol name, and provenance is recovered by splitting it.

    The diff and apply operations are one implementation shared by the control plane and the agent. It depends only on the generated wire contract, so the agent binary links it without linking any control-plane package; it is part of the contract's semantics, not of the control plane.

  3. Rendered state is durable and versions survive restarts. Each workload's current rendered policy, serialized deterministically, and its version are persisted in Postgres in the same transaction that renders them (ADR-0017). A new stream is served the persisted artifact; connecting is not a render trigger. The first render of a newly registered workload always persists a version 1, even when the policy is empty, so a snapshot exists for every workload from enrollment on, and the control plane renders once at startup so workloads registered before this record have one too. The single exception is repair: a workload with no persisted policy at all, which only a render that failed at enrollment can leave behind, is rendered once when its stream opens rather than refused until an operator intervenes, and the repair is logged as such. The applied version and convergence state each stream reports are persisted beside it; a control-plane restart preserves every version and every workload's status, and a render after the restart changes nothing.

  4. A render announces changed workloads through the database, and the replica holding the stream pushes. Authoring runs in the command line today and in the API façade later, in processes that hold no streams. Rather than give those processes a path to the gateway, the render transaction publishes each changed workload and version on a database notification channel, delivered on commit. Every control-plane replica listens and pushes to the streams it holds, computing the delta from what that stream has already received to the persisted policy. Nothing polls (ADR-0002); a process that made a change has no knowledge of where the stream is; and the same path serves the control plane's own triggers (enrollment, inventory) and the command line's. A replica that loses its listener reconciles every live stream against persisted state when it reconnects, so a lost announcement costs one reconciliation, never a missed version.

    Directed directives ride the same channel, keyed by workload. A process that wants one workload's agent to reconnect publishes the request naming that workload; every replica hears it, the replica holding that workload's stream sends the Reconnect directive, and the others drop it. This is detail of the bridge, not a second mechanism. Delivery is best-effort by design: there is no presence table, so neither the requesting process nor any replica can know authoritatively that a stream exists, and a directive published while a listener is down is not reconciled, because it is a request rather than state. A requester may decline to ask when the workload's recorded sync state says its agent is offline; that refusal mirrors the stored sync state, the same judgment the console displays as offline, and is neither a liveness probe nor a presence check. The observable outcome is the snapshot instant: the sync path records when it last sent each workload a snapshot, whatever caused it (a stream opening, the repair decision 3 allows, a recovery snapshot after a failed apply), beside the stream state decision 3 persists, and nothing else writes it. A directed reconnect has worked when that instant advances. (Amended 2026-09-24; see Amendments.)

Consequences

  • Every authored change renders the whole estate. This is deliberate and bounded: the render is one transaction, its cost is proportional to workloads times rules, and the inverted-index path narrows it when a real estate demands it, without changing what an agent sees.
  • Versions mean something: two versions of one workload always differ in rendered content, and the delta between consecutive versions is minimal and reproducible. Status displays, drift detection, and audit can rely on that.
  • The snapshot-plus-delta property test is the specification of the delta model in executable form. A change to the rendered model or to the diff that breaks it is a broken build, not a subtle disagreement between control plane and agent.
  • Rendered rule ids embed the protocol. Anything that maps a rendered rule back to its authored rule splits the id; nothing parses authored ids out of rendered ones by any other rule.
  • The notification channel is the propagation mechanism between processes and between replicas: each replica pushes to its own streams, so no replica needs to know which replica holds a stream. ADR-0017 is amended to record this in the control-plane shape, in place of the presence table it anticipated. A presence table becomes necessary only for a directed operation that must know a stream exists; the one directed operation there is, a reconnect, rides this channel as a best-effort request and is observed through the snapshot instant instead (decision 4). (Amended 2026-09-24; see Amendments.)
  • Rendered policy is not signed in this version, by decision (PR #5): the agent's only policy source is the mutually authenticated sync stream to the control plane, a signature on the rendered bundle would re-prove what that connection already proves, and a second key with its own custody story is not free. ADR-0012's signing requirement stands down until policy transits anything other than that stream. Any future distribution design involving relays, caches, or regional intermediaries (the reserved topology) must include an authenticity envelope for rendered policy; this note is the tripwire for that requirement.
  • The enforce.PolicyStore interface takes each complete next policy and installs it atomically, which is the shape the owned-table replacement of ADR-0003 requires. The Linux backend behind it persists each policy durably to disk before it installs it into the owned nftables table as one transaction, acknowledges it applied only when both succeeded, and re-applies the persisted policy on start before the control plane is dialed, so fail-static restarts (ADR-0011) hold and no acknowledged version lacks its persisted copy; ADR-0020 carries that design. (Amended 2026-09-13 and 2026-10-05; see Amendments.)

Amendments

Amendment2026-09-13

(ADR-0020). The core decision stands. The consequence that recorded the agent's installed policy as in-memory, with disk persistence deferred to the enforcement backend, is corrected in place now that the backend exists: the nftables store persists the last applied policy on every successful apply and re-applies it on start. ADR-0020 carries the enforcement design in full.

Amendment2026-09-24

(PR #13). The core decision stands. Decision 4 gains additive detail on the notification bridge: directed directives ride it keyed by workload, and the replica holding that workload's stream sends the Reconnect directive. Delivery is best-effort by design, because there is no presence table (ADR-0002 and ADR-0017 as amended; that decision stands); the directive is a request, and its observable outcome is the workload's last snapshot instant advancing, which the sync path records whenever it sends a snapshot. A refusal because the agent is offline mirrors the stored sync state, the same judgment the console displays, and is not a liveness probe. The consequence that said a presence table becomes necessary for directed operations on a specific stream is corrected in place: the reconnect needs no knowledge that a stream exists, and a presence table would still be needed only by an operation that does.

Amendment2026-10-05

(PR #23, conformance-audit absorption). The core decision stands. The durability note in the consequences, which said the backend persists the last applied policy after every successful apply, is corrected in place to the order ADR-0020 as amended fixes: persist durably first, then apply, and acknowledge APPLIED only when both succeeded. The earlier note overstated durability, because a persistence failure after the kernel transaction was logged and the apply still acknowledged.