Skip to main content

LLM Integration

3 min read

createAuditLedger – court-admissible audit, built-in

An append-only, queryable, cryptographically-chained log of every state change a Directive system makes. The auditor's "why did this user get that decision?" question is now a one-line answer.


Setup

import { createSystem } from "@directive-run/core";
import { createAuditLedger } from "@directive-run/core/plugins";

const ledger = createAuditLedger();

const system = createSystem({
  module: checkoutModule,
  plugins: [ledger.plugin], // that's it
});

system.start();

The ledger subscribes to every observation event the system emits – constraint evaluations, fact changes, resolver writes – and records them, in order, with the rule that was in effect at the time and the per-clause whenExplain payload.


Query

// What changed cart-total between Jan and June?
ledger.query({
  factPath: "cartTotal",
  changedBetween: ["2026-01-01", "2026-06-01"],
});

// Why did the checkout constraint fire?
ledger.forConstraint("canCheckout");

// All entries touching the user's email
ledger.forFact("user.email");

// Most recent 50 entries
ledger.recent(50);

Filter shape:

FieldTypeNote
factPathstringExact match (no LIKE wildcards).
constraintIdstringFilter by constraint.evaluate entries.
kindAuditEntryKind | AuditEntryKind[]Discriminator.
changedBetween[start, end]ISO-8601 strings, epoch numbers, or Date.
limitnumberDefault 1000.

Captured events

KindPayload includes
constraint.evaluateconstraintId, active, whenSpec, whenExplain
fact.changekey, prior, next
resolver.write.rejectedresolverId, fact, expected, actual (rejection) or dropped (summary)
resolver.completeresolverId, requirementId, duration
resolver.errorresolverId, requirementId, error
system.init/start/stop/destroy(lifecycle markers)

Hash chain – tamper detection

Every entry stores prevHash, the djb2 hash of the previous entry's canonical JSON. To detect tampering:

const result = ledger.verify();
if (!result.valid) {
  console.error(
    `Tampered entry at index ${result.brokenAt}`,
    `expected prevHash: ${result.expectedHash}`,
    `actual prevHash:   ${result.actualHash}`,
  );
}

Sync by default. Pass { strong: true } for an async SHA-256 walk – collision-resistant against an adversary; v1 sync walk uses a 32-bit djb2 hash (fast, fits in StackBlitz / browser, catches accidental + light-adversarial tamper).

Threat model: detects tampering; does NOT prevent it. Pair with file ACLs / append-only filesystem / WORM storage for full guarantee.


PII redaction (default ON)

Fact values flow into the ledger via whenExplain.actual and fact.change.{prior,next}. By default, the ledger reads system.meta.byTag("pii") at start and redacts values for those facts to "[redacted]":

const checkoutModule = createModule("checkout", {
  schema: {
    facts: {
      email: t.string().meta({ tags: ["pii"] }),
      cartTotal: t.number(),
    },
    // ...
  },
});

// In the ledger:
// fact.change { key: "email", prior: "[redacted]", next: "[redacted]" }
// fact.change { key: "cartTotal", prior: 30, next: 75 }

Opt out with capturePII: true. Custom sanitization: pass redact?: (entry) => entry.


React hook

import { useAuditLedger } from "@directive-run/react";

function AuditLog({ ledger }) {
  const entries = useAuditLedger(ledger, {
    kind: "constraint.evaluate",
    limit: 20,
  });
  return (
    <ul>
      {entries.map((e) => (
        <li key={e.seq}>
          {e.kind} @ {new Date(e.ts).toISOString()}
        </li>
      ))}
    </ul>
  );
}

Re-renders on each new matching entry. Default poll 250 ms; override with pollMs.


Sinks

import { memorySink } from "@directive-run/core/plugins";

const sink = memorySink({ capacity: 100_000 });
const ledger = createAuditLedger({ sink });

v1 ships the in-memory ring buffer (memorySink). It drops oldest past capacity (default 10k). For durable / queryable-after-restart sinks (SQLite, Parquet, Loki), implement AuditLedgerSink – the interface is open.


What this does NOT do

  • Not a durable store on its ownmemorySink is bounded; spill to your own sink for production.
  • Not a real-time alerting pipeline – pair with system.observe() for that. The ledger is the record, not the alarm.
  • Doesn't capture every observation eventderivation.compute, requirement.created/met/canceled, effect.run, reconcile.start/end are skipped in v1. They're available via system.observe() directly if needed.

Reference

Previous
predicateFromIntent

Stay in the loop. Sign up for our newsletter.

We care about your data. We'll never share your email.

Powered by Directive. This signup uses a Directive module with facts, derivations, constraints, and resolvers – zero useState, zero useEffect. Read how it works

Directive - Constraint-Driven Runtime for TypeScript | AI Guardrails & State Management