Skip to content

fix(policy): dedup co-located cascade policies + extractions#156

Merged
filipeforattini merged 5 commits into
mainfrom
fix/policy-cascade-dedup-engine
Jun 25, 2026
Merged

fix(policy): dedup co-located cascade policies + extractions#156
filipeforattini merged 5 commits into
mainfrom
fix/policy-cascade-dedup-engine

Conversation

@filipeforattini

Copy link
Copy Markdown
Member

What

  • Bug A fix — a co-located cascade _policy.yaml shared by N routes now produces one engine entry (with scope.routes = union) instead of N copies. Per-route nearest-wins is resolved before the engine; unrelated files sharing an id stay separate.
  • Channel enforcer fix — the subscribe-time enforcer no longer re-feeds the per-load accumulator (was dead + polluting state); policies are already loaded at discovery time.
  • Refactors (line-limit cleanup, no behavior change): extracted route-naming.ts + co-located-attach.ts from loader.ts (1700→1424), programmatic-registration.ts + policy-bridges.ts from builder.ts (1723→1472).
  • Docsco-located.md aligned with the new dedup mechanism.

Tests

Full suite 3383/3383, tsc clean, hexagonal boundaries pass. New regression coverage in coverage-gaps.int.test.ts.

🤖 Generated with Claude Code

Bug A from the coverage-gaps investigation: an unscoped cascade
_policy.yaml shared by N routes produced N identical copies in the
engine. The old materialiser keyed each engine id on
(source, index, route) and called engine.addPolicies() once per route,
so the same logical policy was added N times, each with
scope.routes = [singleRoute]. Because addPolicies replaces by id in
place, only the last route's scope survived for any shared id.

Fix: introduce a per-bootstrap CoLocatedAccumulator. Discovery feeds
(policy, routeName) pairs into it during a load; applyDiscoveryResult
flushes once after every route, channel, and GraphQL resource is
registered. The flush materialises exactly one policy per
(source, index) with scope.routes set to the union of routes that
referenced it — so 5 routes under one cascade yield 1 engine entry
whose scope.routes lists all 5.

Per-route nearest-wins (closer _policy.yaml overriding a broader one
with the same id) is resolved in addCoLocatedPoliciesToEngine before
feeding the accumulator, so two unrelated sibling files that happen to
share an id (users.policy.yaml / projects.policy.yaml both 'id: read')
stay separate — they never share a route's cascade chain.

Also:
- GraphQL resources now register before the flush (they accumulate
  their own co-located policies); previously the flush ran first and
  dropped them.
- Protocol-bound surfaces (GraphQL, channels) feed the accumulator
  with applyRouteScope=false so no route filter is added — their
  engine action is independent of any route name.
- Hot reload resets the accumulator before re-applying so a removed
  route's policies don't linger.
- Audit _meta (owner/ticket/description/deprecation) survives
  materialisation and still surfaces via server.policy.list().
- Removed the now-dead materializeCoLocatedPolicies/applyAutomaticScope
  helpers and the unused createHash import.

coverage-gaps Bug A test now asserts exactly 1 engine copy with
scope.routes covering all 5 routes. Full suite: 3383 passing.
…lator leak

Two related cleanups on top of the Bug A accumulator fix:

1. Channel enforcer correctness. After the Bug A change,
   addCoLocatedPoliciesToEngine feeds the per-load accumulator instead
   of calling engine.addPolicies() directly. The WebSocket channel
   enforcer ran it at SUBSCRIBE time (request time), where no flush
   happens — so the call became dead (no engine effect) and worse,
   mutated accumulator state on every subscribe. The channel's
   co-located policies are already loaded into the engine at discovery
   time (registerChannel -> buildAuthzInterceptorsForOperation), so the
   enforcer now only evaluates; it no longer re-registers.

2. Extract createGraphQLPolicyBridge and the channel enforcer into
   src/server/builder/policy-bridges.ts. Keeps the policy-evaluation
   logic next to the rest of the policy code and trims builder.ts from
   1788 to 1723 lines (45 below its main baseline of 1768).

No behaviour change for GraphQL or channel authorization — covered by
the existing co-located + websocket suites (81 tests). Full suite:
3383 passing.
…ader

Split two cohesive, self-contained concerns out of the 1700-line
loader.ts to bring it under the 1500-line cap:

- route-naming.ts: pure path/middleware/auth helpers (parseRoutePath,
  applyHttpVerbConvention, collectMiddlewareChain, findAuthConfig,
  findDirectoryMeta, HTTP_VERB_SEGMENTS, LoadedMiddleware). No I/O —
  operates on already-loaded maps and strings.
- co-located-attach.ts: attachCoLocatedPolicies +
  attachCoLocatedPoliciesToFileItems (sibling/folder policy resolution).

loader.ts: 1700 -> 1424 lines. No behaviour change; fs-routes,
discovery, and co-located suites (118 tests) pass.
…actory

Move mount + addProcedure/addStream/addEvent/addChannel/addRest/
addResource/addTcpHandler/addUdpHandler/registerHandler out of the
1700-line createServer closure into builder/programmatic-registration.ts.
The factory takes the registries, policy factories, and register*
helpers as deps plus a getServer thunk (the methods return server for
chaining), and the result is spread into the server object literal.

Behaviour is identical — verified by the full suite (3383 tests).
builder.ts: 1723 -> 1472 lines, now under the 1500-line cap.
…avior

The Bug A fix changed the internal mechanism: per-route nearest-wins
resolution happens before the engine, and a per-load accumulator commits
one engine entry per source policy with scope.routes = union of routes.
Update the folder-cascade and engine-driver sections that still described
the old 'engine dedupes by id on repeated addPolicies' / 'bridge replays
per route' behaviour. User-facing semantics (nearest-wins, deny
precedence, sibling isolation) are unchanged.
@filipeforattini filipeforattini merged commit 07e2952 into main Jun 25, 2026
2 checks passed
@filipeforattini filipeforattini deleted the fix/policy-cascade-dedup-engine branch June 25, 2026 18:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant