Skip to content

Kerberos v4: Enhance security, performance, and TypeScript support with Cerbos integration - #9

Merged
Alex-Dolid merged 10 commits into
mainfrom
review/wave-0-critical-fixes
Aug 30, 2026
Merged

Kerberos v4: Enhance security, performance, and TypeScript support with Cerbos integration#9
Alex-Dolid merged 10 commits into
mainfrom
review/wave-0-critical-fixes

Conversation

@Alex-Dolid

Copy link
Copy Markdown
Contributor

This pull request introduces significant new features, hardening, and compatibility improvements to the @alexify/kerberos authorization engine, along with updates to documentation and CI. The most notable changes include a comprehensive set of code-review-driven improvements, a new Cerbos conformance test job in CI, stricter linting rules, and expanded documentation. These updates enhance security, TypeScript support, Cerbos compatibility, and developer experience.

Major feature additions and hardening:

  • Extensive hardening and new features: Multiple waves of code-review-driven improvements, including a fail-open fix for inherited keys, Node-ESM named exports restoration, scope-depth caps, cache-reader backoff/timeout/degraded-mode options, per-batch and cross-request policy-resolution memoization, improved audit-stream completeness, a synchronous evaluation driver, reverse-lookup truncation signaling, frozen policy shapes/tokens, and d.ts/export-parity guards.
  • Cerbos compatibility and conformance: Added a Cerbos ORM-adapter compatibility layer (toCerbosQueryPlan) and a conformance test suite that runs against both the Kerberos engine and a real Cerbos PDP in CI, ensuring semantic parity and surfacing divergences. [1] [2]
  • Policy loader and bundle support: Introduced a Node-only file/directory policy loader and versioned hash-stamped bundles under the /loader subpath.
  • Cerbos policy importer: New /cerbos subpath for importing Cerbos YAML/JSON policies, including a CEL-to-$expr translator, with strict error handling for unsupported constructs.

TypeScript and API improvements:

  • Typed authoring and schema enforcement: All core types are now generic over application schemas, supporting compile-time validation of resource kinds, actions, and attributes. Resource policies support attribute schema enforcement (Cerbos schemas parity) via the new schemas engine option.
  • BREAKING semantic and type changes: Resource-policy conflict resolution now matches Cerbos's per-role model (more permissive), and Effect/PlanKind are now const objects rather than enums. Other breaking changes align Kerberos's evaluation model and role-policy semantics with Cerbos.

Developer tooling and CI:

  • New policy-testing CLI and in-browser playground: Added a CLI for policy testing and a browser-based playground for interactive evaluation.
  • CI conformance job: Added a new conformance job to .github/workflows/ci.yml that runs the shared Cerbos test corpus against both Kerberos and a real Cerbos PDP, catching divergences and ensuring correctness.
  • Stricter linting: Enabled additional ESLint rules for stricter code hygiene, including no-undef, no-dupe-else-if, getter-return, no-setter-return, no-unused-private-class-members, and no-constant-binary-expression.

Documentation updates:

  • Expanded CHANGELOG: The CHANGELOG.md now documents all new features, breaking changes, and enhancements in detail, including links to unreleased and previous versions. [1] [2] [3] [4]
  • Project overview update: The project description in CLAUDE.md has been updated to reflect the expanded feature set and increased bundle size.

Alex-Dolid and others added 8 commits August 23, 2026 18:09
Security / correctness:
- Conditions: inherited-key strategy lookups (constructor/toString/valueOf)
  no longer turn a broken conditional rule into an unconditional match —
  own-property guard, fail-closed (+regression tests)
- Scope depth capped at 16 dot-segments (KerberosValidationError, propagates
  even under onError:'deny') and scope strings at 512 chars across all three
  validation backends — closes a cache-read amplification / quadratic-CPU
  request vector

ESM interop:
- src/index.js is now built only from ...require('./file.js') re-export
  spreads (new src/publicExports.js carries the codec names + PlanKind):
  cjs-module-lexer bailed on the local-variable spread and silently dropped
  19 names (codec fns, PlanKind, Effect, schemas, validation helpers) from
  the ESM named surface. Full 64/64 CJS<->ESM parity restored; EXPR_META /
  evalExprAst stay internal (+ESM smoke tests)

Performance:
- Engine log helpers early-return when the logger is disabled (mirrors
  RelationResolver#logDebug) — no timestamp/entry allocation per call:
  simple isAllowed 317k -> 513k ops/sec (+63%), cache-backed 141k -> 249k
  (+77%) on the bench harness

Resilience / observability:
- RelationResolver session memos evict rejected singleflight promises, so a
  transient backend failure never poisons a shared memo (+test, README and
  docs lifetime note)
- winston/consola-shaped loggers (which expose .log alongside info/debug)
  now route to the structured writer instead of silently losing the audit
  trail (+regression test)
- Metadata response schemas accept the engine's real includeMeta output:
  optional matchedPolicy, deny-reason enum, resolution trace union in all
  three backends (+round-trip test against live checkResources meta)

Docs:
- CLAUDE.md: replace the stale pre-v3 logging<->error coupling claim with
  the actual onError contract, correct the runtime-split requirer list, the
  pnpm version reference and the lint scope
- LICENSE year range 2024-2026

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Cache reader (createCacheReader):
- Full-jitter exponential backoff between retry attempts (delayMs base 25ms,
  doubling per attempt, jitter on by default; delayMs: 0 restores immediate
  retries) — back-to-back retries defeated the mechanism and amplified load
  on struggling backends
- Deterministic adapter errors (TypeError/SyntaxError) are no longer retried
- Optional timeoutMs bounds each read attempt via a shared withTimeout helper
  (new src/async.js, platform-neutral) — a hung backend fails as
  KerberosCacheError instead of hanging authorization; the no-timeout hot
  path stays a direct awaited call

Engine options:
- cacheRetry.onExhausted: 'miss' — opt-in degraded mode: exhausted retries
  count as a cache miss so evaluation falls through to static sources; a
  cache outage no longer disables statically-resolvable decisions (visible
  via the cache 'error' metric + guarded log entry)
- cacheKeyPrefix — namespaces ALL cache keys (policies + derived roles) for
  multi-tenant/shared-store deployments; closes the silent cross-tenant
  derivedRoles:<name> collision
- relationsTimeoutMs — bounds relations.check/list calls; a hung resolver
  fails as KerberosRelationsError per onError

Batch resolution memo:
- checkResources shares one singleflight lookups memo across the batch (and
  planResources across its role closure): each distinct policy/derived-roles
  document resolves once per batch instead of once per resource — a
  20-resource batch went from 40 duplicate cache reads to 2 — with trace
  entries replayed into every resource's meta.resolution. Gated on
  cache.enabled; single-shot isAllowed and static-only configs keep an
  allocation-free fast path (bench: simple 525k ops/s, static batch 41.2k,
  cache-backed 260k — all at or above the pre-change numbers)

checkResources error contract:
- Error-shaped batch denials are now marked { reason: 'evaluation-error',
  errorName } under includeMeta, so an outage is distinguishable from a
  policy DENY; documented that onError applies at request level only
  (README + docs wording fixed accordingly)

Reverse lookups (RelationResolver):
- onTruncated: 'throw' option — a maxResults truncation of lookupSubjects /
  lookupResources raises a typed KerberosRelationsError instead of silently
  narrowing the result; truncation always recorded on the span as
  kerberos.result.truncated
- expandRelationOperands accepts an { ids, truncated } envelope and degrades
  a truncated relation branch to the sound `opaque` post-filter operator
  instead of baking an incomplete id list into the plan

Types & docs: KerberosCacheRetry type, new options in index.d.ts /
relations.d.ts, 'evaluation-error' reason (+ errorName) in the d.ts and all
three Metadata schema backends, README/docs option tables and truncation
contract; 14 new regression tests

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Audit stream completeness:
- Fail-closed denials now reach the audit log and the kerberos.decisions
  counter: a resource whose evaluation rejects inside a checkResources batch
  logs its DENY decisions marked { reason: 'evaluation-error', errorName },
  and the onError:'deny' fallback of isAllowed emits an equivalent decision
  entry — previously the most security-relevant denials (cache outages) were
  exactly the ones missing from the decision stream
- Audit entries record principalRoles (the role set the decision was based
  on); roles change over time, so past entries stay explainable
- New engine option audit: { includeMeta: true } — decision tracing runs for
  every request when a logger is attached, so audit entries carry
  meta.resolution and the policy-miss reason regardless of the caller's
  per-request includeMeta response flag (the response stays gated)

Decision trace:
- Derived-roles imports are now traced: { source: 'derivedRoles', name,
  matched, origin? } entries in meta.resolution — previously the one
  cache-backed resolution step invisible to the trace (an evicted/corrupt
  document silently stopped rules from matching with no hint); Metadata
  schemas (all three backends) and index.d.ts extended accordingly

Log levels:
- PlanResources.result audit entries go out at INFO level (new writer.info
  channel; structured -> sink.info, legacy -> logger.info/log): an
  ALWAYS_ALLOWED (fail-open) plan now survives the docs' own
  pino({ level: 'info' }) production recipe instead of vanishing with the
  lifecycle debug events

Swallowed-failure visibility:
- New kerberos.observability.failures counter (sink: logger|telemetry):
  logger/telemetry sink failures stay swallowed (the never-affect-
  authorization contract) but are no longer invisible; the engine also
  console.warns once per instance on the first swallowed logger failure

Relations seam:
- The request span now carries kerberos.relations.count and
  kerberos.relations.duration_ms measured at the engine seam, so relation
  latency is attributable even with a custom resolver that has no
  instrumentation of its own (built-in resolver metrics stay inside the
  resolver to avoid double counting)
- Fixed the stale kerberos.request.duration description (it also records
  PlanResources and Relations* calls)

Docs: telemetry tables (six instruments, planResources span attributes),
decision-metadata resolution entry shapes, configuration/README option
lists and audit-entry field docs; 10 new regression tests

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Synchronous evaluation driver (the big one):
- Fully-synchronous configurations (no cache, no relations — the
  zero-dependency baseline) now evaluate through a sync driver that skips
  interior promise allocation and microtask hops entirely (~10 awaited
  frames per request before). Layering/merge semantics are NOT duplicated:
  both drivers share #applyParentRoleResult / #mergeRoleResultInto /
  #mergeSourceResults, and the new test/SyncAsyncParity.test.js pins
  end-to-end driver equivalence (responses byte-identical incl. meta)
- Bench: simple isAllowed 514k -> ~800k ops/s (+55%), derived-roles 405k ->
  ~650k, static 10-resource batch 39k -> ~63k

Validate once, not three times:
- isAllowed/checkResources/planResources validate ARGUMENTS once with the
  constructor-precompiled validator and assemble the internal request
  without the old buildRequest re-parse — under Zod the same
  principal/resource was deep-parsed up to 3x per call (1+2N per batch) and
  P/principal ended up as different clones (identity now restored). The
  public static Kerberos.parseRequest keeps full validation for external
  callers; new Zod-backend bench scenario records the cost (~470k ops/s)

Cache-path work reduction:
- Cross-request instance memo in #resolveFromCache keyed by the IDENTITY of
  the raw cached value (WeakMap; bounded per-key map for string values):
  an unchanged document skips deserialize+validate+construct entirely while
  TTL/invalidation stays backend-owned (new reference = rebuild). Bench:
  cache-backed isAllowed 243k -> ~330k ops/s (+36%); tests cover reuse,
  replacement-invalidation and string-equality memoization
- Per-role policy lookups resolve as one settled wave on the cache path
  (no short-circuit exists to lose), with per-role trace buffers keeping
  meta.resolution deterministic; the planner's parentRoles closure BFS is
  level-batched (RelationResolver #subjectClosure pattern) — O(depth)
  round-trip waves instead of O(roles) sequential ones

Bounded fan-out:
- New maxConcurrency option on the engine (checkResources batch chains) and
  the RelationResolver (lookupResources candidate verification) — a
  zero-dependency FIFO limiter in src/async.js; settleAll now lives there
  too, shared by engine and resolver (one home for the documented
  parallelism policy)

Minor:
- Policy/derived-roles check() skips the two per-check request spreads when
  the policy declares neither constants nor variables (the common case)
- Bench harness covers the previously-blind paths: role+parentRoles chain,
  scoped requests, Zod validation, includeMeta, cache-backed 50-resource
  batch; README benchmark table refreshed with the new numbers

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Drift guards (the checks the hand-maintained d.ts relied on humans for):
- Six correctness lint rules opted back in per-rule (no-undef above all —
  the only static guard against typo'd identifiers in a codebase of
  guarded catch blocks); the category itself stays off as documented
- test/tests.test-d.ts pulls the /tests subpath declarations into tsd —
  which immediately surfaced two latent errors that had shipped unchecked:
  tests.d.ts imported from the package name (never resolvable) and shadowed
  three classes with parameters named after them (self-referential types)
- test/ExportParity.test.js diffs runtime Object.keys of all three entry
  points against each d.ts's declared value exports, both directions
- The six public static parse* helpers (and the Tests DSL statics) are now
  declared in index.d.ts / tests.d.ts — closing the drift the parity guard
  would have caught

Hardening:
- Policy shapes are deep-frozen after construction and the constructors now
  CLONE the plain spine of their input first (cloneShapeTree in the new
  src/freeze.js, shared with the codec's AST freezing): live engine state
  can no longer be rewritten via policy.shape, the caller's literal is
  neither mutated (pre-existing wart) nor frozen, and the same literal can
  construct many instances; Effect is frozen like PlanKind
- codec: evalNew gets the own-property constructor guard (defense in depth,
  mirrors evalCall); new maxBuiltStringLength limit (default 1M chars) caps
  strings BUILT by repeat/padStart/padEnd — a tiny expression could
  otherwise allocate ~0.5GB per evaluation, exactly the compromised-store
  memory-exhaustion the codec's threat model promises to prevent
- RelationResolver #validKinds is seeded from the schema's type names and
  no longer caches caller-supplied kinds — a fuzzing caller could grow the
  set without bound

Behavior improvements:
- checkResources onError:'deny' fallback returns one all-DENY result per
  requested resource (positional parity like Cerbos) instead of results: []
  which crashed positional consumers during the incident 'deny' exists to
  survive (+end-to-end test via the one real request-level failure path)
- Resolver spans accept opts.callId (kerberos.call_id attribute); the
  engine passes its kerberosCallId through the relations seam automatically
- Scope-chain memo evicts LRU at capacity instead of freezing on the first
  1000 scopes; codec AST cache gets an LRU touch and drops the has/get pair
- Reverse static tuple index builds lazily on first reverse-API use —
  check/list-only deployments no longer pay double index memory
- Subject-set algebra extracted to src/Relations/subjectSet.js with a
  table-driven unit matrix (concrete × wildcard × exclusion across
  union/intersection/subtraction); settleAll shared via src/async.js
- Constructor validator wiring table-driven: one backend dispatch + one
  builder-name list instead of three hand-synced 7-assignment blocks

Docs & meta:
- CHANGELOG: 3.x link refs, mis-filed 3.0.0 additions moved to Added,
  duplicate Changed merged, [Unreleased] section
- README/docs: static-over-cached scope-shadowing warning (hybrid
  deployments), deny-fallback shape, codec limits, callId option;
  CLAUDE.md documents pnpm size

Deliberately skipped: #runtime imports-field alias (would break webpack 4 —
the legacy browser map cannot remap a '#'-specifier, defeating the
finding's own premise)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
pnpm size moved from 25.1/15.1 KB to 28.6/16.3 KB (min+gzip) for the
main entry and /relations subpath as the engine grew across the
review waves (memoization, resilience options, frozen shapes, etc.).
Synced every README/docs/CLAUDE.md mention, including CLAUDE.md's
long-stale "~8 KB" figure left over from before ReBAC/planning.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ance (#7)

* feat(types): typed authoring via an optional application schema (P0.1)

Closes the DX gap against CASL, which was the strongest argument against
adopting Kerberos.js in a TS-first codebase: `attr` was
`Record<string, unknown>`, kinds and actions were bare `string`, and
condition callbacks were untyped.

An application now declares its authorization domain once:

    type AppSchema = {
      principal: { roles: 'admin' | 'user'; attr: { department: string } };
      resources: {
        document: { actions: 'view' | 'edit'; attr: { ownerId: string } };
        invoice:  { actions: 'view' | 'approve'; attr: { amount: number } };
      };
    };
    const kerberos = new Kerberos<AppSchema>(policies, derivedRoles);

and the resource kind then drives everything else — `action`, `attr`,
`roles`, and the `{ P, R, V, C }` envelope handed to conditions,
variables and outputs. Policy documents are discriminated unions over
`resource:`, so a rule naming another kind's action, an undeclared role,
or a condition reading an attribute the kind does not have is a compile
error instead of a silent EFFECT_DENY in production.

Zero runtime change: this is entirely in the hand-maintained `.d.ts`, and
every type parameter defaults to the new `AnySchema`, which reproduces the
previous untyped surface verbatim — the pre-existing `types.test-d.ts`
passes unmodified apart from the PlanKind line noted below.

Also in this commit, all type-level:

- `Effect` and `PlanKind` are declared as frozen const objects rather than
  TypeScript `enum`s. The runtime has always been a frozen plain object
  (verified), so the `enum` declaration mis-described it and, worse, made
  `effect: 'EFFECT_ALLOW'` in a plain JSON policy literal a type error —
  exactly the form stored/serialized policies carry.
- `checkResources` gains overloads: the response's effects are typed
  `Effect`, or `boolean` when `effectAsBoolean` is passed, instead of the
  `Effect | boolean` union in both cases.
- `{ $expr }` descriptors are accepted in `condition.match` and in
  `output`, which stored policies always used but the types rejected.
- `errorName` added to the `checkResources` result meta (present at
  runtime since the wave-1 work, missing from the response type).
- `BaseRule` gains the optional `name` it has always accepted.

New: `test/typed-schema.test-d.ts` — 30+ assertions pinning the narrowing
behaviour and the backward-compatible defaults, including `expectError`
cases for wrong-action/wrong-kind/wrong-role/wrong-attr. Verified the
harness is real by temporarily asserting an error on valid code and
confirming tsd failed. It also pulls `relations.d.ts` into the same
compilation and asserts a `RelationResolver` stays assignable to a
schema-typed engine.

Docs: new "TypeScript" section in README and /guide/typescript, exports
table extended with the type-only surface.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(docs): in-browser playground (P0.3)

Adoption, not engine capability, is the binding constraint — and Kerberos.js
is uniquely cheap to demo because the engine *is* a browser library. The
playground is a static VitePress page with no backend: policies and requests
are edited in the page, and `isAllowed` / `checkResources` / `planResources`
run in the visitor's own tab. That is simultaneously the demo and the proof
of the "runs in your browser" claim.

Three seeded examples: RBAC+ABAC (derived roles, variables, conditions,
outputs), a query plan (showing the Cerbos-compatible conditional filter
with `P.id` partially evaluated into a literal), and scope-chain walking.

Conditions in the examples are `{ "$expr": "..." }` strings rather than
functions, so every example is also a valid *stored* policy and the page
exercises the eval-free codec path a cache-backed deployment uses.

Implementation note: the package is CommonJS and lives outside node_modules
(no workspace self-link), so Vite pre-bundles it neither in dev nor through
the default rollup commonjs `include` — a plain alias produced
"module is not defined" at runtime. Instead of guessing at interop flags, a
small Vite plugin serves the engine as a virtual module built by esbuild with
exactly the options `scripts/size.js` already uses. The page therefore ships
the same artifact `pnpm size` reports, and a broken browser/node runtime swap
fails the docs build loudly rather than silently shipping `node:crypto`.

Verified in a real browser against the dev server: the RBAC example activates
the OWNER derived role, evaluates its `$expr` condition, emits the rule output
and the full resolution trace in ~5 ms; the query-plan example returns
KIND_CONDITIONAL with the expected or/eq tree. Production build checked too —
the engine is a lazily-loaded chunk (30 KB gzipped, only fetched when the
playground is opened), with zero Node builtins and the browser `randomUUID`
shim in place.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(conformance): Cerbos conformance suite (P0.2)

"Cerbos-compatible" was a self-assertion. This makes it checkable.

One corpus — written in Cerbos's OWN formats, so a real PDP serves it
verbatim — is executed by both engines: policy documents in Cerbos policy
YAML, decision expectations in the published TestSuite schema, and
query-plan expectations shaped after Cerbos's internal
QueryPlannerTestSuite golden files. It runs against Kerberos anywhere with
no Docker, and additionally against a live Cerbos PDP when CERBOS_URL is
set, which the new CI job does. That second leg is the point: without it a
wrong expectation could make the two engines look compatible when neither
matches Cerbos.

21 cases green: RBAC wildcards, derived roles, ABAC conditions, deny-
overrides, policy-miss denial, and four query plans including partial
evaluation (a principal-only conjunct folding away at plan time) and
multi-action conjunction.

Deliberate scope decision: there is NO CEL parser here. A CEL→$expr
importer is a much larger piece of work that belongs in the package, and
half-building it inside a test harness would have produced a bad importer
and an untrustworthy suite. Instead the corpus is restricted to expressions
that are simultaneously valid CEL and valid Kerberos `$expr` (verified
against the codec), so one source string feeds both engines unchanged.
`lib/load.js` is therefore a structural mapper whose governing invariant is
that it refuses to guess — anything outside the supported subset throws
ConformanceUnsupportedError rather than being dropped, because a silently
skipped rule turns a real conformance failure into a false pass.

Plan filters are compared after canonicalization (sorting the children of
and/or and the operands of eq/ne) because neither engine promises an
operand order; plans containing Kerberos-only operators (opaque, relation)
fail the scope check instead of being compared against something Cerbos
cannot express.

Also records what the suite cannot yet settle. Kerberos was verified
directly to be deny-overrides unconditionally — an ALLOW for one role and a
DENY for another resolve to DENY for a principal holding both, regardless
of rule order. Cerbos is documented as deny-overrides for resource-policy
rules but is also described as having anti-lockout behaviour where an ALLOW
from one role wins; both cannot be true. suites/ticket_test.yaml encodes
that exact case as an executable probe, and DIVERGENCES.md states plainly
that a failure there on the first live run is a finding to record, not an
expectation to edit green. Every single-role test passes either way, which
is precisely why it needs a dedicated probe.

DIVERGENCES.md also catalogues the deliberate gaps (attribute schemas,
scopePermissions, exported variable sets, auxData/JWT, globals) and tags
each as enforced by the corpus, enforced by the loader, or documented only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: record the P0 strategic work in the changelog

Notes the typed-authoring surface, the conformance suite and the
playground, plus the two type-level breaking changes that land with them
(Effect/PlanKind as const objects, checkResources overloads) so the v4
release notes have them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(conformance): verify cross-role conflict resolution against a live PDP

Ran the suite against a real Cerbos 0.55.0 serving the same corpus. It
behaved exactly as designed: 20/21 passed and the one failure was the probe
built for this question. The divergence is real and now reproduced in-repo.

  Kerberos:      deny-overrides, unconditionally.
  Cerbos >=0.41: deny-overrides WITHIN a role, allow-overrides ACROSS roles.

So an ALLOW on role A plus a DENY scoped only to role B resolves to ALLOW in
Cerbos and DENY here, for a principal holding both. Cerbos's evaluation loop
runs per principal role and returns the first role that independently
allows, overwriting a DENY recorded by an earlier role — deliberate
anti-lockout behaviour.

Bounded the blast radius with two more corpus cases, both confirmed against
the live PDP: a DENY still wins in both engines when it covers the allowing
role, whether as `roles: ['*']` or by enumerating it. Only the
scoped-to-another-role shape diverges.

Rather than delete the failing case or bend it green, the suite now records
divergences explicitly: an expectation may carry `cerbosActions` with the
other engine's verified answer, and the runner asserts BOTH that Cerbos
still returns it AND that the two engines still disagree. A divergence entry
that goes stale — because either engine changed — now fails loudly instead
of quietly misdescribing reality.

Two operational traps found and recorded, both of which would have hidden
this: the behaviour changed in Cerbos 0.41.0 (0.40.0 returns DENY, matching
Kerberos), and `ghcr.io/cerbos/cerbos:latest` is stale and still serves
0.40.0 — so a parity check against `latest` validates the old semantics and
sees nothing. CI pins an explicit version for exactly this reason. Cerbos's
own docs lagged the code here until 0.52.0 and did not flag it as breaking.

Surfaced in the README next to the positioning table, since "if you know
Cerbos, you already know Kerberos.js" now has a documented exception. Noting
the direction of risk: porting Cerbos policies here fails closed, never open.

Whether Kerberos should adopt Cerbos's semantics is a product decision and
is deliberately NOT made here.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix!: resolve resource-policy conflicts per principal role, as Cerbos does

BREAKING CHANGE: an EFFECT_DENY scoped to one role no longer vetoes an
EFFECT_ALLOW carried by a different role the principal also holds. This is
MORE PERMISSIVE than before — audit any policy relying on a role-scoped
deny to revoke access.

Kerberos was deny-overrides unconditionally. Cerbos >=0.41 is
deny-overrides WITHIN a principal role and allow-overrides ACROSS roles:
its loop runs once per role and returns the first role that independently
allows, overwriting a DENY recorded by an earlier one. That is deliberate
anti-lockout behaviour — picking up an extra, less privileged role must not
take away what another role grants. The conformance suite reproduced the
difference against a live PDP; this makes the two agree.

Denies that already cover the allowing role are unaffected: `roles: ['*']`
covers every role by definition, and an enumerated role covers itself.
Single-role principals and same-role conflicts are unchanged, which is
precisely why this stayed invisible for so long.

Implementation notes:

- ResourcePolicy.check collects the rules that actually FIRED and then
  resolves per role bucket. Only fired rules are collected, so the common
  shapes (no denies, or no allows) short-circuit exactly as before —
  `pnpm bench` shows no regression (simple role match 783K ops/sec).
- Derived roles are not a dimension of their own; they collapse into the
  principal roles listed in their `parentRoles`. Verified empirically
  against Cerbos: widening a derived role's parentRoles to include the
  allowing role flips the outcome to DENY. DerivedRoles gained
  `getActivated()` returning name -> parentRoles, and the engine now
  threads that Map (a drop-in for the old Set: `.has`/`.keys` read the
  same) down to the policy.
- `ruleCoversRole` matches parentRoles literally, with no `*` wildcard,
  deliberately mirroring `DerivedRoles.#parentRolesMatch` and the planner's
  `intersects` — a wildcard here would have made the runtime and the query
  planner disagree about which role a derived rule belongs to.
- The planner mirrors the same per-role composition, since runtime/planner
  parity is a project invariant.

The parity suite did NOT catch the planner drift: every principal in its
grid held exactly one role, so the cross-role path was never exercised. It
now carries multi-role principals, a role-scoped deny and a deny reached
through a derived role. Confirmed the fixture is load-bearing by restoring
the old planner against it — 4 failures, 0 with the new one.

Verified: 594 unit tests (new test/ConflictResolution.test.js covers all
four combinations, both evaluation drivers, derived-role attribution, the
no-roles edge case and query plans), and 24/24 conformance cases green
against a real Cerbos 0.55.0 with no remaining divergences.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix!: role policies narrow the resource layer instead of replacing it

BREAKING CHANGE: a RolePolicy no longer grants access on its own, multiple
role policies union instead of intersecting, and a role with no applicable
role policy imposes no restriction. Deployments relying on a RolePolicy to
grant must add the matching ResourcePolicy rules.

Verified against a real Cerbos 0.55.0 PDP on identical policies. Four
distinct divergences, all now closed:

  resourcePolicy doc: ALLOW [view, edit, delete] roles ['*']
  rolePolicy R1: allowActions [view]   rolePolicy R2: allowActions [edit]

  roles              was (Kerberos)        Cerbos / now
  [R1]               view                  view
  [R2]               edit                  edit
  [R1,R2]            (nothing)             view + edit      <- union, not intersection
  [R1,PLAIN]         view                  everything       <- unpolicied role is unconstrained

plus: a role policy allowlisting an action the resource policy withholds
used to grant it (now denies), and a role policy with no resource policy at
all used to grant (now denies). A PrincipalPolicy override is never
narrowed by the role layer — also confirmed against the live PDP.

Kerberos treated rolePolicy as a ranked layer that replaced the resource
layer and whose denies were sticky across roles. Cerbos treats it as a pure
narrowing filter unioned across roles — the same anti-lockout philosophy as
the resource-policy conflict fix: holding an extra role must never take
access away.

Implementation:

- `#evaluateRolePolicies` unions (Allow wins) and abstains entirely unless
  EVERY unique principal role has a role policy that targets this resource
  kind. `RolePolicy.check` already yields no effects for a foreign kind, so
  applicability falls out of the effect count.
- `#evaluatePolicySources` always runs the resource layer for the actions
  the principal policy left open; `#mergeSourceResults` downgrades a
  resource ALLOW to DENY when the filter applies and no role permits it.
  Both the sync and async drivers, kept in step.
- The planner mirrors it: `AND(resourceLayer, roleFilter)` with the filter
  as a union, gated on the same all-roles-covered condition.
- parentRoles are untouched: intersection ALONG the inheritance chain,
  union ACROSS the principal's roles.

Test churn is the semantics landing, not collateral. Several existing
tests encoded the old model in their names ("deny precedence", "role
policies override resource allows"); those are rewritten. Two plan tests
built engines from role policies alone and would now be trivially DENIED —
one of them had been asserting the intersection and was passing for the
wrong reason. They now pair each role policy with a permissive resource
policy so the filter is actually observable.

PlanParity again did not catch the planner drift: its only role-policy
principal held a single role. It now carries a policied+unpolicied pair and
a two-policied-role pair; confirmed load-bearing by restoring the old role
layer against it (7 failures, 0 with the new one).

The corpus also gained a guard the offline run was missing: Cerbos refuses
a policy file with more than one YAML document, which our loader happily
accepted — it now refuses too, so offline cannot pass on a corpus a real
PDP would not load.

609 unit tests and 30/30 conformance cases green, offline and against the
live PDP. Bench unchanged (simple role match 806K ops/sec).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix: a role policy constrains its role for every kind, not just the ones it names

Regression from the previous commit, caught by a differential sweep against
a live Cerbos PDP. I had defined "applicable role policy" as one whose rules
target the requested resource kind, so a principal holding a role whose
policy only mentions OTHER kinds was treated as unconstrained there.

Cerbos scopes the restriction to the ROLE, not to the kind: holding a role
that has a role policy restricts it everywhere, and for a kind the policy
never mentions it allowlists nothing, so the answer is DENY. Verified:

  resourcePolicy pdoc: ALLOW [...] roles ['*']
  rolePolicy GLOBBER:  allowActions [...] on `adoc` only

  roles ['GLOBBER']          on pdoc -> was ALLOW, Cerbos DENY   <- fail-open
  roles ['GLOBBER','PLAIN']  on pdoc -> ALLOW (PLAIN unconstrained), unchanged

The gate is now the count of resolved role policies rather than the count
that matched the kind; a policy that yields no effects simply contributes
nothing to the union, which is exactly "permits nothing here". The planner
follows, and `applicableRolePolicies` disappears since `roleAllowNode`
already returns FALSE for a non-matching policy.

The conformance suite missed this because no corpus role policy targeted a
kind other than the one under test — it now has one, pinned against the
live PDP alongside the unconstrained-partner case that lifts the filter.

609 unit tests and 32/32 conformance green, offline and live.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix!: adopt Cerbos's full rule-table evaluation model (globs, scope walk, deny rows)

BREAKING CHANGE: three semantic families change, all verified against a
live Cerbos 0.55.0 PDP and cross-checked against Cerbos's documentation and
v0.55.0 source. A 2000+-decision differential sweep that previously showed
106 mismatches now shows ZERO.

1. Wildcards (was partially FAIL-OPEN). Name matching now globs exactly as
   Cerbos's internal/util/globs_common.go does: bare `*` matches anything,
   any other `*` stays within a `:`-delimited segment (view:* matches
   view:public, not view or view:a:b), `**` crosses segments. Applies to
   resource-policy actions AND roles (team_*), principal-policy resource and
   action, role-policy resource and allowActions, and derived-role
   parentRoles (['*'] and adm* now work). Previously only a bare `*`
   matched, so `DENY view:*` silently failed to deny.

2. Scoped policies evaluate per (action, role) — OVERRIDE_PARENT. The first
   scope whose policy decides an (action, role) seals it; a failed condition
   decides nothing and falls through to the parent scope; another role can
   still win an allow at the base scope. Previously the first policy found
   in the chain decided ALL actions for its source. Applies to principal
   (principal scope chain), resource, and role policies. Confirmed against
   the docs' own wording: "The first policy to produce a decision for a
   given action is the winner" + "If a rule is matched but its condition is
   not met ... evaluation continues up the hierarchy."

3. Role policies are synthetic DENY ROWS in the same per-role walk, at
   their own scope — replacing the request-level filter I introduced two
   commits ago, whose "all roles constrained" gate had a cross-bucket bug
   the sweep caught: an allow reaching only role RA cannot be revived by
   role RB's allowlist (Cerbos denies; the filter model allowed). Also:
   role policies ride the RESOURCE scope chain and resource policyVersion —
   Cerbos's own docs say principal scope, but its rule table and a live PDP
   say resource; we follow the engine and record the doc discrepancy.

Also fixed by the chain resolver: cache-backed scope resolution is now
per scope (memory first, then cache, at EACH scope), which retires the
documented hybrid-deployment caveat where a static base-scope policy
permanently shadowed a more specific cached policy.

Architecture: the resource layer, the role filter and per-source scope
lookup collapsed into ONE shared decision walk (src/decision.js), used by
ResourcePolicy.check (a single-scope instance of it), both engine drivers,
and — symbolically, via the same fold — the query planner. Glob matchers
(src/matching.js) are precompiled per rule as non-enumerable props,
mirroring the old actionsSet optimization. The docs-verification pass
(evaluation/derived_roles/resource/principal/role/scoped_policies pages +
v0.55.0 source) confirmed all seven previously-implemented semantics with
citations; its findings and the two places where Cerbos's docs contradict
its own engine are recorded in conformance/DIVERGENCES.md.

Verification: 659 unit tests (+50: matching matrix, scoped PlanParity
sweep with a scoped resource policy, scoped role policy and a glob rule);
conformance grew 32 → 57 cases — wildcards_test.yaml and
scope_walk_test.yaml pin every probed behaviour including both
scope-source directions and the cross-bucket case — green offline AND
against the live PDP. Coverage 98%. Bench: simple isAllowed ~650K ops/sec
(was 806K before chain semantics, 318K at baseline) — the cost of walking
chains instead of stopping at the first policy.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test(conformance): pin the condition-error divergence; sharpen the doc-conflict notes

Both places where Cerbos's documentation contradicts its own engine are now
backed by numbers I measured rather than by a reading of the source.

Condition runtime errors: with a DENY rule whose condition raises
(`R.attr.missing.deep`), a live 0.55.0 PDP on its default config returns
EFFECT_ALLOW — the erroring rule is silently skipped, exactly as Cerbos's
engine page warns ("an EFFECT_DENY rule could be silently skipped");
strictEvaluation=true flips it to EFFECT_DENY. Cerbos's conditions page
claims the opposite for v0.55; the engine page and the source are right.
Kerberos has no per-rule skip: the error follows `onError`, and inside a
checkResources batch it isolates to a fail-closed DENY with
reason: 'evaluation-error'. Recorded as a divergence via `cerbosActions`
so the suite tells us if Cerbos ever makes good on its docs.

Role-policy scope: replaced the prose claim with the measured table
(RT@acme allowlists 'other', RT@base allowlists 'ping' → principal-scoped
request ALLOWs, resource-scoped request DENYs), which is what settles that
the field follows the resource scope chain despite the docs calling it a
principal scope.

58 conformance cases, green offline and against the live PDP.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
… and async loader (#8)

* feat: Cerbos policy importer — YAML + CEL→$expr on the /cerbos subpath

New @alexify/kerberos/cerbos subpath (strategic track P1): turns an
existing Cerbos policy repository into Kerberos policies in-process,
with zero dependencies.

- src/cerbos/yaml.js — parser for the YAML subset Cerbos policies are
  written in (block/flow/quoted/block-scalar forms, comments, ---
  streams); anchors, aliases, tags, directives, multi-line plain
  scalars and tab indentation throw. Verified differentially against
  the reference `yaml` package over the whole conformance corpus plus
  edge-case tables (test/CerbosYaml.test.js).
- src/cerbos/cel.js — CEL lexer + recursive-descent parser for the full
  expression grammar (raw/triple-quoted strings, hex/uint/double
  literals, // comments); bytes literals, message construction and
  leading-dot names rejected at parse with offsets.
- src/cerbos/translate.js — celToExpr: CEL AST → JS for the safe $expr
  interpreter (documented jsep setup). Timestamps are epoch-ms numbers
  (Date.parse/Date.now) so comparison/equality/arithmetic stay numeric;
  Go-style duration literals constant-fold to ms; has() →
  typeof … !== "undefined" (explicit null is present, as in CEL);
  in → .includes (fail-loud on maps); replace → split/join (CEL
  replaces every occurrence); UTC date accessors incl. the zero-based
  getDayOfMonth; int-literal division truncates. Macros, matches(),
  Cerbos extension functions, globals/runtime/auxData throw named
  errors. Every translation is tested semantically through the codec.
- src/cerbos/importer.js — importCerbosPolicies: structural document
  mapper for all four policy kinds; skips disabled: true, accepts
  SCOPE_PERMISSIONS_OVERRIDE_PARENT, validates effects, refuses
  unknown keys at every level; the only opt-out is drop: ['schemas'].
- conformance/importer.test.js — the whole PDP-pinned corpus re-run
  through the public importer: all 52 decision and 4 plan expectations
  hold for importer-loaded policies (real YAML parsing + CEL
  translation instead of the shared-subset passthrough).
- Wiring: package.json exports/files, cerbos.d.ts (+ tsd + export
  parity), pnpm size entry (10.7 KB min+gzip, browser-clean), README
  section, docs guide page + nav + exports/installation tables,
  CHANGELOG, CLAUDE.md, conformance README/DIVERGENCES cross-refs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat: attribute schema enforcement — Cerbos `schemas` parity

Resource policies now accept a Cerbos-shaped `schemas:` block
(principalSchema/resourceSchema refs + ignoreWhen.actions globs, all
three validation backends extended), enforced through the new `schemas`
engine option (src/attributeSchemas.js):

- definitions map refs to validators: JSON Schema (compiled with the
  `ajv` option), Zod-like schemas, or plain validator functions — all
  normalized at construction;
- enforcement 'reject' (default when the option is set) denies every
  action of an invalid request — a principal policy cannot rescue it —
  with reason 'invalid-attributes' and Cerbos-shaped validationErrors
  ({ path, message, source }) on the checkResources result, never gated
  on includeMeta; 'warn' reports without changing decisions; 'none' or
  an absent option leaves policy schema refs inert (Cerbos's own
  unconfigured default);
- ignoreWhen skips validation only when EVERY requested action matches;
  with scoped policies the most specific chain entry declaring schemas
  wins; a ref missing from definitions throws KerberosValidationError
  regardless of onError;
- wired into BOTH evaluation drivers before principal evaluation, with
  the resolved resource chain threaded into the decision walk so
  enforcement never adds a second chain lookup; validationErrors also
  reach audit entries;
- the Cerbos importer now translates schemas: blocks verbatim
  (drop: ['schemas'] still discards them);
- types (ResourcePolicyAttributeSchemas, AttributeValidationError,
  KerberosAttributeSchemasOptions, the 'invalid-attributes' reason),
  README/guides/DIVERGENCES/CHANGELOG updated; 21 new unit tests incl.
  sync/async driver parity.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat: /loader subpath — file/directory policy loader + hash-stamped bundles

New Node-only @alexify/kerberos/loader subpath (strategic track P1):

- loadPolicyDirectory / loadPolicyFile read policy-as-code repositories:
  Kerberos serialized JSON and Cerbos YAML/JSON mix freely (`.yaml` and
  apiVersion-carrying JSON route through the /cerbos importer; the
  `cerbos` option forces or disables that routing), deterministic sorted
  order, `_`-prefixed and hidden entries skipped, `_schemas/**.json`
  surfaced keyed both bare and `cerbos:///…` for the engine's
  schemas.definitions option, and an optional codec deserializes
  documents straight into constructor inputs.
- createPolicyBundle / writePolicyBundle / loadPolicyBundle implement
  GitOps bundle artifacts: `version` is the SHA-256 of the canonical
  sorted-key JSON of { policies, derivedRoles } (byte-reproducible with
  createdAt: null), recomputed and verified on load — tampered,
  truncated or foreign-stamped bundles throw; live (deserialized)
  policies are rejected at bundling since functions would silently
  stringify away.
- The one documented exception to the src/ platform-neutral rule:
  src/loader/index.js uses node:fs/path/crypto directly, and browser
  bundlers substitute src/loader/browser.js (identical surface, throwing
  stubs) via the package `browser` map + exports condition.
- Typed KerberosLoaderError (carries `file`); loader.d.ts + tsd +
  export-parity; fixtures-based tests incl. an end-to-end engine build
  with _schemas wiring and bundle tamper detection; README/guide/docs
  nav/CHANGELOG/CLAUDE.md updated.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat: `kerberos` CLI — policy testing + bundle baking from the command line

New published binary (package.json bin, strategic track P1):

- `kerberos test <policiesDir> <testsDir>` runs Cerbos-TestSuite-format
  suites (*_test.yaml/json: named principal/resource fixtures + expected
  effects) against a /loader-loaded policy directory, so a pure policy
  repository tests itself in CI with zero engineering glue. jsep and its
  documented plugins resolve from the CALLER's project (createRequire on
  cwd) with an actionable install hint when { $expr } policies need them;
  --schemas reject|warn wires _schemas/ into attribute-schema
  enforcement (ajv resolved the same way); --json emits a structured
  report; the runner refuses expectation features it does not check
  (e.g. outputs) instead of silently passing. Exit codes: 0/1/2.
- `kerberos bundle <dir> --out <file> [--reproducible]` bakes the
  hash-stamped bundle artifact from the /loader subpath.
- Tested by spawning the real binary: passing/failing suites, --json,
  load-bearing --schemas flag (same suite passes only with enforcement),
  refused expectation keys, bundle verification, byte-stable
  --reproducible output. lint/format now cover bin/.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat: seeded fuzz suite + published cross-library benchmarks

Strategic track P1 "published comparative benchmarks + fuzzing":

- test/Fuzz.test.js — deterministic (mulberry32-seeded) mutation
  fuzzing of the security-sensitive surfaces, running as part of
  `pnpm test` (FUZZ_ITERATIONS cranks it): the $expr codec must throw
  typed errors only, never leak functions, never pollute
  Object.prototype; every expression celToExpr emits must compile under
  the documented jsep setup; the YAML parser and RelationResolver throw
  typed errors only. The suite already paid for itself: a 20k-iteration
  run caught @jsep-plugin/new emitting a malformed callee-less
  NewExpression node for `new R.attr.x`, which escaped the codec as a
  raw TypeError — the validator/evaluators now guard the missing callee
  and reject it as KerberosExprError (regression-pinned).
- bench/compare.js (`pnpm bench:compare`) — Kerberos vs @casl/ability
  vs casbin on one shared RBAC+ownership scenario, with allow/deny
  sanity cross-checks so the three implementations provably encode the
  same rules; scripts/size-compare.js (`pnpm size:compare`) — browser
  min+gzip comparison (casbin does not bundle for the browser at all).
  Honest numbers and their caveats published in docs/guide/benchmarks.md
  and the README (CASL's prebuilt check is faster because it does
  dramatically less; @cerbos/embedded and OPA-WASM excluded because
  their bundles cannot be built from open tooling). Docs benchmark table
  synced with the README's current numbers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat: verified Cerbos ORM-adapter compatibility (toCerbosQueryPlan)

Strategic track P1 "verified ORM-adapter compat":

- New main-entry export `toCerbosQueryPlan` (src/planning/sdk.js):
  converts planResources output from the HTTP-API operand encoding
  Kerberos emits ({ variable } / { expression: { operator, operands } })
  into the flattened @cerbos/core SDK encoding ({ name } /
  { operator, operands }) the official Cerbos ORM adapters consume —
  plan kinds are byte-identical, so this is the one hop needed.
  Kerberos-only operators follow refuse-to-guess at the boundary:
  a `relation` operand throws naming expandRelationOperands (materialize
  first), an `opaque` operand throws with a post-filtering directive.
- test/OrmAdapters.test.js makes the README's "Cerbos ORM adapters
  accept the filter" claim CI-executable against the REAL packages
  (@cerbos/orm-prisma 4.x, @cerbos/orm-drizzle, new devDeps with
  @prisma/client + drizzle-orm peers): conditional and membership plans
  pin exact Prisma where objects and Drizzle SQL (+params), kinds pass
  through, expanded relation plans render as id IN (...), opaque plans
  are refused.
- Recipes in README/query-plans guide ("Using the official Cerbos ORM
  adapters"), exports tables, CerbosSdkQueryPlan type, CHANGELOG,
  CLAUDE.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* docs: refresh bundle-size numbers after the P1 wave

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* feat(loader): async driver — the `promises` namespace with concurrent reads

The /loader subpath (and the CLI on top of it) used only synchronous fs
calls; a large policy repository therefore paid one blocking read at a
time on cold start. The subpath now ships BOTH drivers over one shared
decision core:

- Top-level functions stay exactly as they were (sync, unchanged API).
- New `promises` namespace (Node's fs.promises idiom — same function
  names returning promises): promises.loadPolicyFile /
  loadPolicyDirectory / writePolicyBundle / loadPolicyBundle.
  promises.loadPolicyDirectory walks directories and reads policy +
  _schemas files CONCURRENTLY, bounded by the new `concurrency` option
  (default 64, via the shared createLimiter from src/async.js) — fast
  cold starts over many files without blocking the event loop.
  createPolicyBundle is pure CPU and stays top-level only.
- Shared core, not duplication: routing/JSON+YAML ingestion
  (ingestPolicyText), _schemas parsing (addSchemaDefinition), directory
  assembly, bundle stamping/serialization/verification
  (buildBundle/ensureBundle/resolveBundle), skip/sort rules and option
  normalization are single functions consumed by both drivers — results
  are byte-identical (deterministic sorted ingestion order regardless of
  read-completion order), which the tests pin with deep equality between
  drivers for directories, files and bundle artifacts.
- Browser stub exports the same `promises` surface as rejecting stubs.
- The kerberos CLI now loads policies through the async driver and reads
  test-suite files concurrently too.
- loader.d.ts `promises` typings + tsd; export-parity covers the new
  surface; docs (policy-loader guide "Async loading", README, CHANGELOG,
  CLAUDE.md).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(test): skip the Cerbos ORM-adapter suites where require(esm) is unavailable

`test/OrmAdapters.test.js` failed the whole Node 18 CI leg with
ERR_REQUIRE_ESM. The cause is entirely third-party: `@cerbos/orm-prisma`
and `@cerbos/orm-drizzle` are CommonJS but `require("@cerbos/core")`,
which is ESM-only, so merely LOADING them needs Node's require(esm)
support — 20.19+ / 22.12+, and present-but-flagged on 22.10-22.11. CI's
Node 20 leg resolves to 20.20.2 and its Node 22 leg to a 22.12+ patch,
which is why only the 18 matrix entry broke.

Feature-detect via `process.features.require_module` rather than parsing
versions (a flagged-off runtime then reads correctly too) and gate only
the two adapter suites with `{ skip }`. The `toCerbosQueryPlan` suite
requires nothing third-party, so it now runs on Node 18 as well —
previously the module-load crash took it down with everything else, and
`src/planning/sdk.js` coverage on that leg rises from 46% to 87%.

Documented the constraint in README + docs/guide/query-plans.md: the
adapters are gated to Node 20.19+/22.12+, while `toCerbosQueryPlan` and
the rest of Kerberos keep the package's `engines: >=18` promise.

Verified: Node 18.19.1 `test:coverage` green (973 pass, thresholds met);
Node 22.10.0 skips as intended; Node 24 runs all 10 adapter tests.

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
@vercel

vercel Bot commented Aug 30, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
kerberos Ready Ready Preview Aug 30, 2026 9:21am

The docs site is already deployed to Vercel (vercel.json), so both
products are one theme hook away. Wired in `enhanceApp` rather than as
Vue components: the vanilla `inject()` / `injectSpeedInsights()` entry
points need no layout slot and keep `theme/index.ts` a pure extension of
DefaultTheme.

Two details the naive wiring gets wrong:

- The site is prerendered, and both packages are browser-only, so the
  SSR pass returns early. Verified: zero `_vercel` references in the
  built HTML, both scripts present in the client chunk.
- Analytics tracks VitePress' pushState navigations by itself, but
  Speed Insights does not — an SPA has to announce the route or every
  Core Web Vital is attributed to whichever page loaded first. Hooked
  through `router.onAfterRouteChange` (the non-deprecated name in
  VitePress 1.6).

Both are devDependencies and `files` in package.json omits `docs/`, so
the published package and its zero-dependency runtime are untouched.

Verified in the browser against `docs:dev`: both scripts load, pageviews
fire for `/` -> `/guide/why` -> `/guide/scopes` across SPA navigations,
a vitals payload is emitted, and the injected script's `data-route`
tracks the route (`/guide/scopes` -> `/guide/rebac`). Data starts
flowing once Analytics and Speed Insights are enabled on the Vercel
project.
@Alex-Dolid
Alex-Dolid merged commit 1916a87 into main Aug 30, 2026
8 checks passed
@Alex-Dolid
Alex-Dolid deleted the review/wave-0-critical-fixes branch August 30, 2026 09:23
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