Skip to content

Network Policy Engine — model, editor, revisions, dry-run

This document is the authoritative bounded-context reference for the Network Policy Engine that ships under ../../../internal/policy/. It covers the ubiquitous language, the schema reference for the three Postgres tables, the L3/L4 Rule grammar, the append-only Revision history state machine, the dry-run and diff contract, the closed two-event outbox set, the ReBAC ownership matrix, the audit contract, the SelectorPort consumer carve-out, and the depguard rules that keep this context isolated from its siblings.

The Policy Engine is the authoring and storage surface for L3/L4 network policy — and only that. It governs which packets a Node forwards by composing five-tuple rules (source CIDR, destination CIDR, protocol, port range, action) into immutable revisions that operators publish through the dashboard or the HTTP API. The per-Node rule compiler that materialises the published revisions onto each mesh participant lives in a separate story; the Signed Event Bus fan-out that announces "this Node's effective policy has changed" lives in another. The two follow-ups are named below where they become relevant.

This is NOT request authorisation. Identity-and-relation authorisation — the Authorizer, ReBAC Check/Write/Delete, CEL caveats, and the SpiceDB-backed /v1/authz surface — lives in ../../../internal/authz/ and is documented under ../identity/rebac.md. A reader looking for "who is allowed to do what" belongs there, not here.

For the bounded-context siblings and upstream platform references see:

  • ../identity/rebac.md — ReBAC schema, zedtoken flow, and the policy definition this document pins.
  • ../labels/index.md — Label Registry and the SelectorPort seam this context consumes.
  • ../mesh/peers.md — Key & Peer Manager, the source of the per-Domain peer projection the dry-run consumes.
  • ../mesh/reachability.md — the per-Node liveness projection the dry-run cross-references to flag "unreachable" nodes in a preview.
  • ../mesh/sse.md — Signed Event Bus, the future fan-out surface for the per-Node compiled-policy update.
  • ../../architecture/storage-topology.md — the shared Postgres node that backs the three policy tables and the Down-destroys-history rule.
  • ../../contributing/layout.md — the bounded-context map row enumerating the consumed and exposed ports this document pins.

Ubiquitous language

Nine terms travel verbatim across the Go code, the SQL schema, the OpenAPI surface, the outbox event payloads, the audit trail, and operator-facing tooling. Internal code never paraphrases them; documentation and error messages adopt the exact spelling below.

TermDefinitionCode anchor
PolicyThe aggregate root the operator authors. Belongs to exactly one Project, carries an append-only revision history, and exposes exactly one live head revision at any moment. Identified by a UUIDv7 surrogate id plus the natural key (project_id, slug).internal/policy/policy.go
RevisionOne append-only entry in a Policy's version history. Immutable once written. Carries a Selector, an ordered Rule list, and a provenance triple (CreatedBy, CreatedAt, CorrelationID).internal/policy/revision.go
HeadThe live Revision a Policy currently exposes. The pointer advances atomically on every PATCH; the prior head remains in plexsphere.policy_revision as audit evidence. The "exactly one live head per Policy" invariant is pinned by the partial UNIQUE index policy_head_uq.Policy.HeadRevisionID in internal/policy/policy.go
RuleOne five-tuple entry on a Revision's ordered rule list — (SourceCIDR, DestinationCIDR, Protocol, PortRange, Action). Value object with structural equality; carries the L3/L4 metadata the per-Node compiler will project onto each mesh participant.internal/policy/rule.go
ActionThe verdict a Rule pronounces on a matching packet. Closed set {allow, deny, log}. Byte-stable lowercase strings so the value round-trips through the JSONB payload and the OpenAPI schema unchanged.Action in internal/policy/rule.go
ProtocolThe L3/L4 protocol a Rule governs. Closed set {tcp, udp, icmp, any}. The closed shape mirrors the README pin at L3/L4 only — L7-aware rules are explicitly out of scope.Protocol in internal/policy/rule.go
PortRangeThe inclusive [From, To] L4 port range a Rule matches. The zero value (0, 0) is the canonical "no port" signal used by icmp and any rules where the port concept does not apply.PortRange in internal/policy/rule.go
SelectorA pair (Source, Destination) of label-selector text strings. The grammar belongs to the Label Registry and reaches this context exclusively through the SelectorPort outbound port — the policy module never imports internal/labels/selector.internal/policy/selector.go
DryRunA read-only preview that projects a proposed (Selector, []Rule) tuple against the head Revision, returning the matched-node set, the rule diff, the peer-pairs-affected count, and the unreachable subset — without writing a single row or emitting a single outbox event.DryRunService.EvaluateDryRun in internal/policy/services/dryrun_service.go

The future stories that consume this vocabulary are named verbatim in internal/policy/doc.go and in the OpenAPI surface — there is no rename in flight. A new term added to this table requires the same triple-update discipline the Label Registry follows: code, contract, and documentation in one commit.

Schema reference

The Policy Engine attaches to three tables in the plexsphere schema. The migration that creates them is internal/platform/db/migrations/0033_policies.sql. Every CHECK constraint, partial-unique index, BEFORE-UPDATE trigger, and ON DELETE behaviour described below is sourced from that file.

TablePurposeNotable constraints
plexsphere.policyOne row per Policy aggregate. Carries the slug, display name, head-revision pointer, and lifecycle timestamps.UNIQUE (project_id, slug) (the natural key, classifier for ErrPolicySlugTaken); FK project_id REFERENCES plexsphere.projects ON DELETE RESTRICT; partial UNIQUE policy_head_uq on (head_revision_id) WHERE head_revision_id IS NOT NULL (the classifier for ErrRevisionConflict); BEFORE-UPDATE trigger policy_set_updated_at_tg stamps updated_at.
plexsphere.policy_revisionAppend-only revision history. One row per PATCH (and one for the initial Create).FK policy_id REFERENCES plexsphere.policy ON DELETE CASCADE; CHECK rule_count BETWEEN 1 AND 1024 pins MaxRulesPerPolicy at the SQL layer; CHECK on the source and destination selector text length BETWEEN 1 AND 4096.
plexsphere.policy_ruleOne row per Rule on a Revision; ordered by position.FK revision_id REFERENCES plexsphere.policy_revision ON DELETE CASCADE; CHECK action IN ('allow', 'deny', 'log'); CHECK protocol IN ('tcp', 'udp', 'icmp', 'any'); CHECK policy_rule_port_range_ok pins from_port <= to_port; CHECK policy_rule_protocol_port_coherence requires icmp/any to have NULL ports and tcp/udp to have NOT NULL ports.

Why head_revision_id is NULLable

A Policy row's head_revision_id column is intentionally NULLable with a partial UNIQUE index — the predicate WHERE head_revision_id IS NOT NULL keeps the "exactly one live head per Policy" invariant on every row that has a head while accommodating the brief intermediate state inside the Create transaction. The constructor inserts the Policy row first (so its id is available for the Revision row's foreign key), then inserts the Revision row, then UPDATEs the Policy row to point its head at the freshly-inserted Revision. A NOT NULL column would force a circular FK resolution that pgx pools handle poorly; the NULLable column plus the partial-unique index keeps the chicken-and-egg out of the schema and only briefly visible inside one transaction. The DECISION block above the migration's CREATE TABLE carries the rejected alternatives.

CIDR storage shape

Source and destination CIDRs are stored as text columns, not as PostgreSQL inet or cidr. The DECISION block in the migration spells out the reasoning: pgx scans inet (and cidr) into netip.Addr by default, dropping the prefix length on read — so a stored 10.0.0.0/8 round-trips as 10.0.0.0 without the /8 mask, losing the load-bearing part of the rule's flow-match semantics. The text column stores the canonical netip.Prefix.String() form that round-trips byte-for-byte, and the Go aggregate's Rule.validate enforces the CIDR-family-match invariant before insertion.

Down-refusal — SQLSTATE 0A000

The migration's Down block deliberately refuses the downgrade and raises SQL exception code 0A000 (feature_not_supported):

sql
RAISE EXCEPTION
    'migrations: 0033_policies downgrade refused: plexsphere.policy_revision carries the append-only audit evidence ...'
    USING ERRCODE = '0A000';

The append-only Revision history is the operator-facing audit evidence that lets a downstream reviewer reconstruct which rule set governed a packet at any past instant. The outbox stream is the change-notification surface; the Revision row is the authoritative canonical projection. Dropping the table would sever the diff-before-apply chain the editor surfaces and break every future replay. The Down-refusal pattern mirrors the seven prior migrations that protect security- and audit-critical material: 0008_node_secret_keys, 0010_node_reachability, 0011_audit_log, 0012_peers, 0029_peer_endpoint, 0030_peer_relay_assignment, and 0032_peer_key_rotation. The migration-test gate TestMigrations_DownRefused_0033 asserts the pgerrcode at internal/platform/db/migrations/migrations_test.go.

Rule grammar

A Rule is a value object describing one L3/L4 forwarding decision. The five-tuple (SourceCIDR, DestinationCIDR, Protocol, Ports, Action) is the full state — equality is structural, and the canon sub-package's sort key is derived from the same tuple.

Invariants

Rule.validate enforces every invariant the aggregate owns:

InvariantSentinelFailure example
Action is in {allow, deny, log}ErrUnknownActionaction: "drop"
Protocol is in {tcp, udp, icmp, any}ErrUnknownProtocolprotocol: "sctp"
SourceCIDR and DestinationCIDR are valid prefixesErrInvalidRulesource_cidr: ""
SourceCIDR.Is4() == DestinationCIDR.Is4() (address-family coherence)ErrCIDRFamilyMismatchsource 10.0.0.0/8, destination 2001:db8::/32
Protocol in {icmp, any} implies PortRange.IsZero()ErrInvalidRuleICMP with from_port: 80
Protocol in {tcp, udp} implies PortRange is non-zeroErrInvalidRuleTCP with no ports
From <= To when ports are non-zeroErrPortRangeInvertedfrom_port: 8080, to_port: 80

The DECISION block on Rule.validate pins the protocol/port-coherence rule as an aggregate invariant rather than a downstream-compiler responsibility: a Policy that round-trips through Create / Patch / Diff carries the guarantee, so the per-Node compiler is free to assume it. The transport layer maps each sentinel to a Problem-envelope code (e.g. unknown_action, cidr_family_mismatch, port_range_inverted) and surfaces the 0-based rule index in the Problem detail so editor tooling can highlight the offending row.

Bounds

A Revision carries between 1 and 1024 rules. Both bounds are enforced in three places that move together:

  1. The constructor NewRevision returns ErrRuleCountExceeded on len(rules) > MaxRulesPerPolicy and ErrInvalidRule on len(rules) == 0. MaxRulesPerPolicy is the named constant in internal/policy/policy.go.
  2. The SQL CHECK rule_count BETWEEN 1 AND 1024 on plexsphere.policy_revision is the defence-in-depth that catches a future caller bypassing the constructor.
  3. The OpenAPI schema's PolicyRule[].maxItems mirrors the same cap so the generated client and the dashboard validate before the request reaches the server.

The cap is large enough to express every realistic operator-authored ruleset and small enough to keep the per-Node compiled ruleset (a follow-up scope item) cheaply bounded.

Closed-set posture

The closed sets on Action and Protocol are deliberate. A new Action value or a new Protocol value requires a coordinated change across:

A schema migration that widens the SQL CHECK lands in a new sequentially-numbered file; the constructor-side widening lands in the same commit so the four points stay in lockstep.

Version history

Every Policy carries an append-only Revision history. The state machine is small:

mermaid
stateDiagram-v2
    [*] --> r1: Create
    r1 --> r2: PATCH (append revision, advance head)
    r2 --> r3: PATCH (append revision, advance head)
    r3 --> [*]: DELETE (cascade revisions)

    state r1 {
        [*] --> head: NewPolicy seeds head_revision_id = r1
    }
    state r2 {
        [*] --> head: head_revision_id atomically advances r1 -> r2
    }
    state r3 {
        [*] --> head: head_revision_id atomically advances r2 -> r3
    }

Append-and-advance semantics

A PATCH on an existing Policy is a single transaction:

  1. Build a fresh Revision value object (with the new selector, new rule list, and the prior head as ParentID).
  2. Call RevisionRepo.AppendAndAdvanceHead(ctx, rev, outbox, audit) — the repo INSERTs the new policy_revision row, INSERTs every policy_rule row, UPDATEs the parent plexsphere.policy row's head_revision_id to point at the new revision, and appends one outbox event and one audit entry — all inside one pgx Tx.
  3. Return the updated Policy value object with the new head.

The atomic UPDATE on the parent row is what makes the partial-unique index load-bearing: a concurrent PATCH whose UPDATE sets head_revision_id to a value already taken by another Policy row trips pgerrcode 23505 on policy_head_uq, which the repo's classifier maps to ErrRevisionConflict. The transport layer maps that sentinel to 409 Conflict with code=revision_conflict, and the dashboard surfaces the conflict to the operator with a "reload and reapply" prompt.

Immutability

Once written, a Revision row is never UPDATEd. The repo adapter exposes only AppendAndAdvanceHead, Get, and List — there is no Update method, by construction. The "edit" shape is always a new Revision; the prior revision remains as audit evidence and is retrievable through GET /v1/projects/{projectId}/policies/{policyId}/revisions/{revisionId}.

Why ParentID is part of the value object

Revision.ParentID denormalises the prior head's id so a downstream auditor reconstructing the chain can walk backwards without joining back to the parent Policy row. The constructor accepts a zero ParentID for the initial revision on a fresh Policy; every subsequent revision must carry the prior head's id.

Dry-run and diff

The Policy editor's diff-before-apply contract is the load-bearing preview shape that lets an operator inspect a proposed change before committing it. The contract has two halves: the diff sub-package computes the structural delta between two canonicalised rule lists, and the dry-run application service projects a proposed (Selector, []Rule) against the head Revision and returns the matched-node set, the rule diff, the peer-pairs-affected count, and the unreachable subset.

Canonicalisation and equality

The canon sub-package (internal/policy/canon/canon.go) provides:

go
func Canonicalise(rules []policy.Rule) ([]policy.Rule, []byte)

The returned slice is the input sorted by the stable key (SourceCIDR, DestinationCIDR, Protocol, Ports.From, Ports.To, Action). The returned byte slice is a SHA-256 fingerprint over the canonical JSON encoding of the sorted slice. Two byte-identical fingerprints are byte-identical policies; the application service can short-circuit a no-op PATCH by comparing fingerprints before appending a revision.

Diff structure

The diff sub-package (internal/policy/diff/diff.go) exposes:

go
func Diff(prev, next []policy.Rule) diff.Diff

type Diff struct {
    Added    []policy.Rule
    Removed  []policy.Rule
    Modified []ModifiedPair
}

A ModifiedPair shares a stable rule-key — the four-tuple (SourceCIDR, DestinationCIDR, Protocol, PortRange) — and differs only on Action. Reorder-only edits canonicalise to an empty diff; the diff sub-package's property test pins symmetry over 50 random rule pairs.

Dry-run request and response

POST /v1/projects/{projectId}/policies/{policyId}/dry-run accepts a proposed selector + rule list and returns the four read-only projections the editor renders:

json
{
  "selector": {
    "source": "acme:prod/tier=frontend",
    "destination": "acme:prod/tier=backend"
  },
  "rules": [
    {
      "action": "allow",
      "protocol": "tcp",
      "from_port": 8080,
      "to_port": 8080,
      "source_cidr": "10.0.0.0/8",
      "destination_cidr": "10.0.0.0/8"
    }
  ]
}

The response shape:

json
{
  "matched_node_ids": ["018f3a40-...-001", "018f3a40-...-002"],
  "rule_diff": {
    "added": [ { "action": "allow", "protocol": "tcp", "...": "..." } ],
    "removed": [],
    "modified": [
      {
        "prev": { "action": "log",   "protocol": "tcp", "...": "..." },
        "next": { "action": "allow", "protocol": "tcp", "...": "..." }
      }
    ]
  },
  "peer_pairs_affected": [
    { "a": "018f3a40-...-001", "b": "018f3a40-...-002" }
  ],
  "unreachable_node_ids": ["018f3a40-...-001"]
}

Zero-write contract

The dry-run is strictly read-only:

  • No policy_revision row is INSERTed.
  • No policy_rule row is INSERTed.
  • No head_revision_id UPDATE on the parent Policy.
  • No outbox event is appended.
  • No audit entry is written.

The integration test tests/integration/policy_dryrun_http_test.go takes a row-count snapshot of every policy table before and after a randomised dry-run request and asserts the counts are byte-identical. The differential test in the same file replays ≥200 randomised selectors against the in-memory selector evaluator and asserts the HTTP surface returns the same matched-node set.

Project-scope isolation

The dry-run pins the request's ProjectID onto the SelectorScope passed to SelectorPort.List. A silent drop would let a dry-run accidentally cross-Project; the DECISION block in internal/policy/services/dryrun_service.go pins the scope on the call as a security-relevant parameter, and the integration test asserts a dry-run scoped to Project A never returns nodes from Project B.

Closed outbox event set

The Network Policy Engine emits exactly two event types from the shared outbox_events table. The closed shape is fixed at:

Event type literalPayload structTrigger
policy_revision_createdPolicyRevisionCreatedEmitted on every Create (initial revision) AND every PATCH (subsequent revisions). Carries the policy id, revision id, project id, and the canonicalised rule fingerprint.
policy_deletedPolicyDeletedEmitted on aggregate removal. Denormalises ProjectID and Slug so downstream consumers can purge per-Policy caches without joining back to a row that is already gone.

Both struct definitions live in internal/identity/tenancy/events/events.go, adjacent to the existing tenancy event family — they reuse the shared tenancy.ID types and the event-header construction helper, keeping outbox writers and consumers on a single envelope shape.

Wire-contract gate

The closed two-event set is enforced by the workspace gate tests/workspace/policy_event_type_set_test.go. The gate parses every .go source file under internal/policy/ (non-test files only), finds every composite literal of type OutboxEvent, extracts the EventType field, and fails the build if any literal escapes the closed set. The gate accepts EITHER form at the call site — the bare string literal ("policy_revision_created") or the events.TypePolicy* selector — so a reader of the service code does not have to remember which form the service uses today.

Adding a new event type is a wire-contract change that must land in three places in the same commit: the constant + payload struct in events.go, the allow-list in policy_event_type_set_test.go, and the consumer arm in internal/authz/sync/mapping.go.

Stories that own the downstream

Two distinct downstream consumers attach to the policy_revision_created event:

  • The per-Node rule compiler that materialises the published revision onto each mesh participant. The compiler consumes the outbox row to project the canonical five-tuple list into the per-Node forwarding-table format plexd applies; the contract is documented in full under ./compiler.md.
  • The Signed Event Bus publisher that fans the change out to affected Nodes as the wire-side policy_updated event. The publisher emits one wire envelope per matched Node from the compile-service arm that runs alongside the compiler upsert; the remaining relay-loop side (stream provisioning + the per-Node reader) is owned by the Signed Event Bus epic. The wire literal policy_updated does NOT appear in this context's closed outbox event set because the publisher's translation seam produces it from the two outbox literals above rather than from a third outbox row. The DECISION block on TypePolicyRevisionCreated in events.go carries the rationale, and ./events.md documents the publisher-side dispatch table in full.

ReBAC ownership matrix

Every mutating policy operation is gated by a dual ReBAC check: the policy-side permission (policy#manage, policy#edit, or policy#read) AND, for mutations, the project-side project#operator permission. Owning the policy does not confer the right to act inside an arbitrary Project; owning the Project does not confer the right to bypass per-Policy editor and viewer grants. The dual-check pattern mirrors the labels context's Definition + object check.

The policy definition lives in schema/authz.zed:

text
definition policy {
  relation parent: project

  relation owner:  user | group#member
  relation editor: user | serviceaccount | group#member
  relation viewer: user | serviceaccount | group#member

  permission manage = owner + parent->manage
  permission edit   = owner + editor + parent->manage
  permission read   = owner + editor + viewer + parent->read
}

The derivation chain follows the Project/Resource pattern: manage and edit walk up to parent->manage so a Project admin (or a Domain admin, transitively) inherits Policy mutation rights without a per-Policy grant, and read walks up to parent->read so any Project observer can list the Policy. The owner is always folded into every permission so the creator never loses access when the parent grants are revoked. The DECISION block above the definition policy clause spells out the rejected alternative of routing edit through a new project#edit permission.

Permission matrix

OperationRequired permission on PolicyRequired permission on Project
POST /v1/projects/{projectId}/policies (Create)— (policy does not exist yet)project#operator
GET /v1/projects/{projectId}/policies (List)per-row policy#readproject#read
GET …/{policyId} (Get)policy#read
PATCH …/{policyId} (Patch)policy#manageproject#operator
DELETE …/{policyId} (Delete)policy#manageproject#operator
GET …/{policyId}/revisions (List revisions)policy#read
GET …/{policyId}/revisions/{revisionId} (Get revision)policy#read
POST …/{policyId}/dry-run (Dry-run)policy#edit
GET …/{policyId}/diff?from_revision&to_revision (Diff)policy#read

Schema invariants

Two structural invariants from the labels context apply here verbatim:

  • Labels never grant Policy permissions. No relation declared on definition policy accepts labeldefinition (or any labeldefinition#… userset) as a subject type. Policy authorisation is owner/editor/viewer plus the project parent edge — never a label selector. Labels remain selectors, not authorisation primitives.
  • Groups stay Domain-scoped. Every group#member userset on the Policy relations resolves transitively through the group's single parent Domain edge; cross-Domain nesting is forbidden by construction.

The test internal/authz/schema_invariants_test.go pins both invariants at compile time so a drift between the schema and the invariants surfaces as a red test instead of a silent authorisation defect.

Authz sync arm

When the Policy aggregate emits policy_revision_created, the outbox consumer at internal/authz/sync/mapping.go writes two SpiceDB tuples:

  • policy:<policyId>#owner@user:<creator> — names the principal who authored the Policy as its owner.
  • policy:<policyId>#parent@project:<projectId> — wires the parent edge so the parent->manage and parent->read derivations resolve.

On policy_deleted, the consumer issues a SpiceDB DELETE for the policy:<policyId> object so every relation tuple is reaped in one transaction. The closed event set means the consumer's switch statement has exactly two arms — a future event type that escapes the gate would surface as a missing branch at compile time.

Audit contract

Every Policy mutation emits exactly one entry to the platform audit log via the shared audit.Sink. The shape mirrors the labels context's contract:

FieldValue
actionpolicy.create / policy.patch / policy.delete
actorThe authenticated principal (user or service account) resolved by the transport middleware
objectpolicy:<policyId>
reasoninsufficient_relation on a ReBAC denial, revision_conflict on a partial-unique-index race, invalid_rule / cidr_family_mismatch / port_range_inverted on a Rule validation failure, or empty on success
correlation_idThe request correlation id from the transport middleware so the audit row joins to the outbox event with the same id

One mutation → one audit entry is an invariant, not a best-effort target. The repo's Create, AppendAndAdvanceHead, and Delete methods take the audit row as a value-object parameter and write it inside the same transaction as the aggregate mutation and the outbox event row.

SelectorPort consumer carve-out

The Network Policy Engine is itself a consumer of the labels SelectorPort — never a peer. The dry-run service composes SelectorPort.List with NodeLister.ListNodesByProject and the reachability projection to compute the matched-node set, the peer-pairs-affected count, and the unreachable subset.

The carve-out has two halves:

Minimal in-policy re-declaration

The SelectorPort interface is re-declared in internal/policy/ports.go with the minimal surface the dry-run consumes:

go
type SelectorPort interface {
    List(ctx context.Context, scope SelectorScope, selector string,
         cursor ListCursor, limit int) ([]ObjectRef, ListCursor, error)
}

The re-declaration is deliberate: the policy module never imports internal/labels/*. The composition root at cmd/plexsphere/policies_factory_prod.go binds the in-policy SelectorPort to the real labels.SelectorPort through a one-line type-conversion adapter — ObjectRef and SelectorScope are deliberately shaped to make the adapter trivial.

depguard enforcement

The no-cross-context-imports-policy rule in .golangci.yml denies imports of internal/labels/**, internal/mesh/**, and internal/identity/** from any file under internal/policy/ — with one negative-glob carve-out for internal/policy/repo/** where the pgx-backed adapter needs the persistence primitives. The lint rule is the mechanical guard; a contributor who needs label-aware behaviour inside the policy domain must reach it through the port, never through a direct import.

The peers and reachability projections follow the same pattern: NodeLister and ReachabilityPort in internal/policy/ports.go are the in-policy outbound seams the composition root binds to internal/mesh/peers.Provider and internal/mesh/reachability.State respectively.

Depguard rules

The bounded-context isolation is enforced by three depguard rules that move together. The full rule definitions live in .golangci.yml; the bounded-context map row for internal/policy in ../../contributing/layout.md enumerates them in operator-facing form.

RuleWhat it deniesCarve-outs
no-cross-context-imports-policyImports of internal/labels/**, internal/mesh/**, and internal/identity/** (except internal/identity/tenancy/events and internal/identity/tenancy) from internal/policy/**.internal/policy/repo/** is excluded from the deny-list so the persistence adapter can import its narrow pgx-shaped helpers.
no-cross-context-imports (the global rule)Imports of internal/policy/** from any other bounded context.The transport layer (internal/transport/http/**) and the composition root (cmd/plexsphere/**) are the two allowed callers; both compose application services across contexts by design.
no-direct-persistence-from-contexts (the global rule)Imports of github.com/jackc/pgx/** from any internal/<context>/ directory.internal/policy/repo/** is on the allow-list — the persistence adapter is the single seam that may speak pgx.

A new bounded context that needs to consume Policy data must declare a port in internal/policy/ports.go and reach the application service through it — direct imports are denied. A new consumer of the closed outbox event set is a tuple-change in tests/workspace/policy_event_type_set_test.go and a new arm in internal/authz/sync/mapping.go; the two move together.

Known limitations

The implementation that ships with this iteration intentionally defers two cross-context wirings. Operators reading this page who expect end-to-end behaviour today should know about the gaps; each one is named with the follow-up that closes it.

The production dry-run wiring that was previously deferred has now landed: the composition root in ../../../cmd/plexsphere/policies_factory_prod.go binds the SelectorPort, NodeLister, and ReachabilityPort ports to the real labels selector evaluator, the internal/mesh/peers.Provider projection, and the internal/mesh/reachability state respectively via the anti-corruption adapters at ../../../cmd/plexsphere/labels_selector_adapter.go, ../../../cmd/plexsphere/peers_nodelister_adapter.go, and ../../../cmd/plexsphere/reachability_adapter.go. A real operator hitting POST /v1/projects/{id}/policies/{id}/dry-run now receives the matched-node set, peer_pairs_affected, and the unreachable subset that the Selector evaluates against the live label snapshot and the live reachability projection. The wiring landed alongside the per- Node rule compiler — see ./compiler.md for the full per-Node projection contract, the outbox-consumer arm, and the PolicySource seam onto the reconciliation-pull snapshot.

  • Audit-row emission is best-effort. The PolicyService accepts an AuditSink port and emits one entry per successful Create / Patch / Delete through the atomic-counter-slog-pair-as-the-loud-but-non-fatal-audit-failure-idiom pattern — a sink failure increments the services.AuditRecordFailuresTotal counter and emits a structured slog.Error, but the mutation itself stays committed. The production composition root currently passes a nil audit sink (which the constructor replaces with a no-op), so no plexsphere.audit_log row lands directly from the policy service today. The outbox event (policy_revision_created / policy_deleted) still publishes through plexsphere.outbox_events and reaches the relay; the audit pipeline's outbox-driven Source backfills the row once the policy event types are added to its allow-list. The wiring lands alongside the audit-chain Source extension follow-up.
  • Optimistic concurrency runs at the SQL boundary; audit-row emission does not yet share that transaction. The UpdatePolicyHead query carries an IS NOT DISTINCT FROM $expected_prior_head predicate so a same-Policy concurrent PATCH race surfaces as ErrRevisionConflict deterministically — pinned by the 50-iteration race test in ../../../tests/integration/policy_aggregate_repo_test.go. When the audit-sink wiring lands (above), the emission moves inside the same transactional boundary so a successful PATCH followed by an audit-chain failure cannot leave the head advanced with no audit row.

The deferred wirings are pinned by the EXPLICIT REVIEW ACK block at the top of ../../../cmd/plexsphere/policies_factory_prod.go; when a follow-up story closes one of them, the entry above moves into the body of this document under the matching section.

What this context is not

To keep the boundary sharp, the Network Policy Engine is deliberately NOT:

  • An authorisation engine. Request authorisation (who is allowed to call which API operation) lives in ../../../internal/authz/; this context governs packet forwarding, not request admission.
  • A per-Node rule compiler. The compiler that projects a canonicalised revision into the per-Node forwarding-table format is owned by a follow-up story. The Policy Engine produces immutable revisions; the compiler consumes them off the outbox.
  • A wire-side fan-out. The Signed Event Bus relay that emits policy_updated to affected Nodes is owned by a separate follow-up story; the Policy Engine emits the internal policy_revision_created handover shape from the outbox, not the wire-side event.
  • An L7-aware policy surface. The closed Protocol set {tcp, udp, icmp, any} reflects the README pin at L3/L4 only; application-layer awareness is explicitly out of scope.
  • A free-form annotation store. Every Rule carries the five-tuple at the SQL CHECK layer; arbitrary metadata belongs in the Label Registry, not on a Policy row.

Cross-references