## Overview

When a permission check returns the wrong answer, duck-iam ships two debugging tools:

* **engine.explain()**: a full evaluation trace - every policy, every rule, every
  condition, with exact match/fail results.
* **validatePolicy() / validateRoles()**: runtime validation for untrusted or dynamic
  configuration data.

## engine.explain()

> **Development mode only.** `explain()` works only when the engine is created with `mode: 'development'` (the default). In production mode it throws. For production debug endpoints, create a separate development-mode engine.

`explain()` runs the same evaluation as `check()` but traces every step without short-circuiting. Even when the first policy denies, the engine evaluates the rest so the trace is complete.

`explain()` is diagnostic. It runs the `beforeEvaluate` hook so the traced request matches real evaluation input. It does **not** trigger `afterEvaluate`, `onDeny`, or `onError`. If `beforeEvaluate` throws, the explain call rejects.

```typescript
const result = await engine.explain('user-1', 'delete', {
  type: 'post',
  id: 'post-1',
  attributes: { ownerId: 'user-2' },
})
```

### The Explain.IResult

```typescript
interface IResult {
  decision: AccessControl.IDecision   // The final allow/deny decision
  request: {
    action: string                    // The action that was checked
    resourceType: string              // The resource type
    resourceId?: string               // The resource ID, if provided
    scope?: string                    // The scope, if provided
  }
  subject: {
    id: string                        // The subject ID
    roles: string[]                   // The subject's base roles
    scopedRolesApplied: string[]      // Extra roles added from scoped assignments
    attributes: Record<string, any>
  }
  policies: Explain.IPolicyTrace[]    // Trace of every policy evaluated
  summary: string                     // Human-readable multi-line summary
}
```

### ExplainSubjectInfo

`explainEvaluation()` takes an `ExplainSubjectInfo` that separates base roles from
scoped roles. Custom explain tooling needs this shape:

```typescript
interface ExplainSubjectInfo {
  /** The subject's unique ID. */
  subjectId: string
  /** The subject's base roles (before scope enrichment). */
  originalRoles: readonly string[]
  /** Additional roles applied from scoped assignments for this request. */
  scopedRolesApplied: readonly string[]
}
```

### The summary string

Print the summary first when debugging:

```typescript
console.log(result.summary)
```

Output:

```text
DENIED: "user-1" -> delete on post
  Roles: [editor, viewer]
  __rbac__ [allow-overrides]: No matching rules -> deny (0/5 rules evaluated)
  owner-policy [deny-overrides]: Denied by rule "deny-non-owner-delete" (1/2 rules matched)
  Result: Denied by rule "deny-non-owner-delete"
```

This tells you:

* The final result was **DENIED**.
* The subject has roles **editor** and **viewer**.
* The RBAC policy had 5 rules but none matched the `delete` action.
* The `owner-policy` matched, and rule `deny-non-owner-delete` produced the deny.

### Reading Explain.IPolicyTrace

Each entry in `result.policies` is an `Explain.IPolicyTrace`:

```typescript
interface IPolicyTrace {
  policyId: string                            // Policy identifier
  policyName: string                          // Human-readable policy name
  algorithm: AccessControl.CombiningAlgorithm // deny-overrides, allow-overrides, etc.
  targetMatch: boolean                        // Did the policy's target filter match?
  rules: Explain.IRuleTrace[]                 // Trace of every rule in this policy
  result: 'allow' | 'deny'                    // The policy's final per-policy result
  reason: string                              // Human-readable explanation
  decidingRuleId?: string                     // Which rule determined the result
}
```

When `targetMatch` is `false`, the policy's target filter (actions, resources, or roles) did not match the request and its rules were not evaluated, so `rules` will be empty. In the current trace format, `result` still mirrors the engine's default effect, so use `targetMatch` as the signal that the policy was bypassed.

```typescript
for (const pt of result.policies) {
  if (!pt.targetMatch) {
    console.log(`${pt.policyId}: skipped (targets don't match)`)
    continue
  }

  const matched = pt.rules.filter((r) => r.matched)
  console.log(`${pt.policyId} [${pt.algorithm}]: ${matched.length}/${pt.rules.length} rules matched`)
  console.log(`  Result: ${pt.result} -- ${pt.reason}`)
}
```

### Reading Explain.IRuleTrace

Each rule inside a policy produces an `Explain.IRuleTrace`:

```typescript
interface IRuleTrace {
  ruleId: string
  description?: string
  effect: AccessControl.Effect    // 'allow' | 'deny'
  priority: number
  actionMatch: boolean            // Did the rule's actions match the request action?
  resourceMatch: boolean          // Did the rule's resources match the request resource?
  conditionsMet: boolean          // Did all conditions evaluate to true?
  conditions: Explain.IGroupTrace // Full condition tree trace
  matched: boolean                // actionMatch && resourceMatch && conditionsMet
}
```

A rule only `matched` if all three criteria are true: action match, resource match, and conditions met.

```typescript
for (const rule of policyTrace.rules) {
  if (rule.matched) {
    console.log(`  [MATCH] ${rule.ruleId} (${rule.effect}, priority ${rule.priority})`)
  } else {
    const reasons = []
    if (!rule.actionMatch) reasons.push('action mismatch')
    if (!rule.resourceMatch) reasons.push('resource mismatch')
    if (!rule.conditionsMet) reasons.push('conditions failed')
    console.log(`  [SKIP]  ${rule.ruleId} -- ${reasons.join(', ')}`)
  }
}
```

### Reading the condition trace

Conditions form a tree of logical groups (`all`, `any`, `none`) with leaf conditions at the bottom. The trace preserves this structure.

```typescript
// Group node
interface IGroupTrace {
  type: 'group'
  logic: 'all' | 'any' | 'none'
  result: boolean
  children: Array<Explain.ILeafTrace | Explain.IGroupTrace>
}

// Leaf node
interface ILeafTrace {
  type: 'condition'
  field: string           // e.g. "resource.attributes.ownerId"
  operator: AccessControl.Operator    // e.g. "eq"
  expected: any           // The value from the condition definition
  actual: any             // The value resolved from the request at runtime
  result: boolean         // Did this condition pass?
}
```

Walk the tree to find which condition failed:

```typescript
function printConditions(trace, indent = '') {
  if (trace.type === 'condition') {
    const mark = trace.result ? 'PASS' : 'FAIL'
    console.log(`${indent}[${mark}] ${trace.field} ${trace.operator} ${JSON.stringify(trace.expected)} (actual: ${JSON.stringify(trace.actual)})`)
  } else {
    console.log(`${indent}${trace.logic} (${trace.result ? 'PASS' : 'FAIL'}):`)
    for (const child of trace.children) {
      printConditions(child, indent + '  ')
    }
  }
}

// Print condition tree for a specific rule
const rule = result.policies[1].rules[0]
printConditions(rule.conditions)
```

Output:

```text
all (FAIL):
  [PASS] subject.roles contains "editor"
  [FAIL] resource.attributes.ownerId eq "user-1" (actual: "user-2")
```

The subject has the `editor` role but the `ownerId` condition failed because the post belongs to `user-2`, not `user-1`.

## Validation

### validatePolicy()

Use `validatePolicy()` to validate untrusted policy objects before saving them. This is necessary when policies come from a database, API, or admin dashboard where the data could be malformed.

```typescript
import { validatePolicy } from '@gentleduck/iam'

// Policy from an external source (database, API, user input)
const policyJson = await db.query('SELECT data FROM policies WHERE id = $1', [id])

const result = validatePolicy(policyJson)

if (!result.valid) {
  const messages = result.issues.map((i) => i.message).join(', ')
  throw new Error(`Invalid policy: ${messages}`)
}

// Safe to use
await engine.admin.savePolicy(policyJson)
```

What it checks:

* Required fields: `id`, `name`, `algorithm`, `rules`
* Valid combining algorithm (`deny-overrides`, `allow-overrides`, `first-match`, `highest-priority`)
* Each rule has: `id`, `effect`, `priority`, `actions`, `resources`
* `priority` is a finite number - `Number.isFinite(priority)` is required because `NaN` / `Infinity` break `highest-priority` ranking
* Valid effect values (`allow` or `deny`)
* Valid operators in conditions
* Correct condition group structure (`all`/`any`/`none` with arrays)
* Duplicate rule IDs (warning)
* Valid `targets` structure if present
* Unresolvable field references in conditions - code `UNRESOLVABLE_FIELD`
* Unresolvable `$variable` values in conditions - code `UNRESOLVABLE_VALUE`
* Inheritance chains exceeding `MAX_INHERITANCE_DEPTH` - code `INHERITANCE_TOO_DEEP`
* Suspiciously broad allow rules (wildcard action + wildcard resource with no conditions) - code `BROAD_ALLOW`
* Policy/rule counts, field lengths, and condition nesting exceeding configured caps - code `LIMIT_EXCEEDED`

### Validate-time exports

The validate module also exports a handful of constants and a JSON Schema (Draft 2020-12) that admin tooling can consume:

```typescript
import {
  IAM_POLICY_JSON_SCHEMA,     // JSON Schema (Draft 2020-12) for AccessControl.IPolicy
  IAM_POLICY_LIMITS,          // { rulesPerPolicy, actionsPerRule, resourcesPerRule, cartesianPerRule }
  MAX_INHERITANCE_DEPTH,  // 32 - cap used by INHERITANCE_TOO_DEEP
  MAX_CONDITION_DEPTH,    // 10 - cap on nested all/any/none groups
  IAM_MAX_FIELD_LENGTH,       // 256 - cap on dot-path field length
} from '@gentleduck/iam'
```

Limits are constants in the package; the runtime applies them whether or not you call `validatePolicy` yourself. The JSON Schema is suitable for non-TypeScript consumers, editor schema-driven completion, and admin dashboard form validation.

### IamValidate.IResult

```typescript
interface IResult {
  valid: boolean                  // true if no errors (warnings are ok)
  issues: IamValidate.IIssue[]
}

interface IIssue {
  type: 'error' | 'warning'       // errors cause valid=false, warnings don't
  code: string                    // machine-readable: UNRESOLVABLE_FIELD, UNRESOLVABLE_VALUE,
                                  //                  INHERITANCE_TOO_DEEP, BROAD_ALLOW, LIMIT_EXCEEDED,
                                  //                  INVALID_TYPE, MISSING_FIELD, ...
  message: string                 // human-readable description
  roleId?: string                 // which role, if applicable
  path?: string                   // JSON path like 'rules[0].effect'
}
```

### validateRoles()

Use `validateRoles()` to validate your role configuration. This catches structural mistakes that cause silent failures at runtime.

```typescript
import { validateRoles } from '@gentleduck/iam'

const viewer = defineRole('viewer').grant('read', 'post').build()
const editor = defineRole('editor').inherits('viewer').grant('update', 'post').build()
const admin = defineRole('admin').inherits('editor').grant('delete', 'post').build()

const result = validateRoles([viewer, editor, admin])

if (!result.valid) {
  throw new Error('Role configuration error: ' + result.issues.map((i) => i.message).join(', '))
}
```

What it checks:

| Check | Severity | Example |
| --- | --- | --- |
| Duplicate role IDs | error | Two roles both named `'editor'` |
| Dangling inherits | error | Role inherits from `'superadmin'` which does not exist |
| Circular inheritance | warning | `admin` inherits `editor`, `editor` inherits `admin` |
| Empty roles | warning | Role has no permissions and no inheritance |

Circular inheritance is a warning (not an error) because the engine handles it gracefully at runtime by tracking visited roles during resolution.

### Example: catching a dangling inherit

```typescript
const editor = defineRole('editor')
  .inherits('viewer')  // "viewer" role is missing!
  .grant('update', 'post')
  .build()

const result = validateRoles([editor])

console.log(result.valid)   // false
console.log(result.issues)
// [{ type: 'error', code: 'DANGLING_INHERIT',
//    message: 'Role "editor" inherits from "viewer" which does not exist',
//    roleId: 'editor' }]
```

### Example: catching circular inheritance

```typescript
const a = { id: 'a', name: 'A', permissions: [], inherits: ['b'] }
const b = { id: 'b', name: 'B', permissions: [], inherits: ['a'] }

const result = validateRoles([a, b])

console.log(result.valid)   // true (circular is a warning, not an error)
console.log(result.issues)
// [{ type: 'warning', code: 'CIRCULAR_INHERIT',
//    message: 'Circular inheritance detected involving role "a" (cycle includes "a")' }]
```

## Common Debugging Scenarios

### "Why was my request denied?"

Use `engine.explain()` and check the summary:

```typescript
const result = await engine.explain('user-1', 'update', {
  type: 'post',
  id: 'post-5',
  attributes: { ownerId: 'user-3' },
})

console.log(result.summary)
```

If the summary shows "No matching rules," the subject does not have a role with the required permission. Check `result.subject.roles`.

If the summary shows a specific deny rule, look at that rule's conditions in the trace to see which condition failed.

### "Why was my request allowed when it should be denied?"

Check if any policy has `allow-overrides` that is too permissive:

```typescript
const result = await engine.explain('user-1', 'delete', {
  type: 'post',
  attributes: {},
})

for (const pt of result.policies) {
  if (pt.result === 'allow') {
    console.log(`Policy "${pt.policyId}" allowed this:`)
    for (const rule of pt.rules.filter((r) => r.matched && r.effect === 'allow')) {
      console.log(`  Rule "${rule.ruleId}" matched`)
    }
  }
}
```

### "My scoped roles are not being applied"

Check that the scope is being passed and that the scoped roles are resolving:

```typescript
const result = await engine.explain(
  'user-1',
  'manage',
  { type: 'dashboard', attributes: {} },
  undefined,  // environment
  'org-1',    // scope
)

console.log('Base roles:', result.subject.roles)
console.log('Scoped roles added:', result.subject.scopedRolesApplied)
```

If `scopedRolesApplied` is empty, the adapter either has no scoped role assignments for this subject/scope combination or does not implement `getSubjectScopedRoles()`.

### "My conditions reference the wrong field path"

Use the condition trace to compare `expected` vs `actual` values:

```typescript
const result = await engine.explain('user-1', 'update', {
  type: 'post',
  id: 'post-1',
  attributes: { author: 'user-1' },
})

// Find the failing condition
for (const pt of result.policies) {
  for (const rule of pt.rules) {
    if (!rule.conditionsMet) {
      printConditions(rule.conditions)
      // [FAIL] resource.attributes.ownerId eq "user-1" (actual: null)
      // The field is "author" not "ownerId"!
    }
  }
}
```

### "My policy from the database is being rejected"

Validate it before saving:

```typescript
const policyFromDB = await fetchPolicyFromDB(policyId)

const result = validatePolicy(policyFromDB)
if (!result.valid) {
  for (const issue of result.issues) {
    console.error(`[${issue.type}] ${issue.path}: ${issue.message}`)
  }
  // [error] rules[2].effect: Invalid effect "Allow". Must be "allow" or "deny"
  // [error] rules[3].conditions.all[0].operator: Invalid operator "equal"
}
```

Common mistakes in dynamic policies:

* Capitalized effect values (`"Allow"` instead of `"allow"`)
* Wrong operator names (`"equal"` instead of `"eq"`)
* Missing required fields (`id`, `priority`)
* Conditions using a bare object instead of an `all`/`any`/`none` group

***

## Explain FAQ

Why did engine.explain() not trigger my audit hooks?

Explain mode is intentionally read-only from the side-effect hooks' perspective. It applies
<code className="rounded bg-muted px-2 py-1">beforeEvaluate</code>
because that hook can change the request, but it does not run
<code className="rounded bg-muted px-2 py-1">afterEvaluate</code>, <code className="rounded bg-muted px-2 py-1">onDeny</code>,
or <code className="rounded bg-muted px-2 py-1">onError</code>. If <code className="rounded bg-muted px-2 py-1">beforeEvaluate</code>
throws, the explain call still rejects.

Why does ExplainResult.decision.rule stay empty even when a rule clearly won?

The explain trace records the deciding rule on each <code className="rounded bg-muted px-2 py-1">PolicyTrace</code>
via <code className="rounded bg-muted px-2 py-1">decidingRuleId</code>, but the top-level
<code className="rounded bg-muted px-2 py-1">decision.rule</code> is not currently populated on the explain path.
Use the policy traces and summary string when you need the exact rule-level breadcrumb.

Why are duplicate rule IDs warnings instead of hard validation errors?

Rule IDs are important for traceability, explain output, and admin tooling, but evaluation does not require them
to be unique to function. Validation warns because duplicates make debugging worse even though the engine can still run.

Can I use explain() in production troubleshooting?

Yes, but treat it as a debugging tool. It evaluates the real request path and returns rich traces, which is useful for
audits and support cases, but control who can access those traces because they expose policy and condition details.