@directive-run/claude-plugin 1.17.2 → 1.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@directive-run/claude-plugin",
3
- "version": "1.17.2",
3
+ "version": "1.19.0",
4
4
  "description": "Claude Code plugin for Directive — 12 skills covering modules, constraints, resolvers, derivations, AI orchestration, and adapters. Installable via Claude Code's plugin marketplace or consumable programmatically as an npm package.",
5
5
  "license": "(MIT OR Apache-2.0)",
6
6
  "author": "Jason Comes",
@@ -52,7 +52,7 @@
52
52
  "tsx": "^4.19.2",
53
53
  "typescript": "^5.7.2",
54
54
  "vitest": "^3.0.0",
55
- "@directive-run/knowledge": "1.17.2"
55
+ "@directive-run/knowledge": "1.19.0"
56
56
  },
57
57
  "scripts": {
58
58
  "build": "tsx scripts/build-skills.ts && tsup",
@@ -0,0 +1,433 @@
1
+ # AI × Sources
2
+
3
+ > Wire `@directive-run/core`'s `source` primitive into `@directive-run/ai`
4
+ > agents and orchestrators. Sources are how external event streams
5
+ > (Supabase realtime, WebSockets, Cloudflare DO alarms, MCP server lifecycle)
6
+ > publish into facts that agents reason over.
7
+
8
+ The source primitive's full semantics live at
9
+ [`../core/sources.md`](../core/sources.md). This doc covers the AI-specific
10
+ patterns: which external streams to wire as a source vs. as an
11
+ `AsyncIterable` (anti-pattern), how `runStream` reacts to live fact
12
+ changes, how to keep PII out of agent prompts, and which existing
13
+ adapter (`@directive-run/sources/*`) covers the common vendors.
14
+
15
+ ---
16
+
17
+ ## Decision: source vs AsyncIterable
18
+
19
+ A long-running external stream can show up as a Directive **source** OR
20
+ as a per-call **AsyncIterable**. The boundary is durable.
21
+
22
+ | Use a **source** when... | Use an **AsyncIterable** when... |
23
+ |---|---|
24
+ | The stream's lifetime is the **system's lifetime** (Supabase realtime channel, WebSocket message stream, MCP server connection) | The stream's lifetime is **one agent generation** (LLM token stream from `runStream`, per-call SSE response) |
25
+ | You want every message to update **facts** the agent reasons over | You want the LLM's output tokens delivered to a single caller |
26
+ | The publisher cannot read facts | The consumer needs to act on each chunk synchronously |
27
+
28
+ **Three-tier lifetime ladder:**
29
+
30
+ 1. **System source** (outermost): Supabase channel, MCP server, DO alarm,
31
+ WebSocket message stream. Mount at `system.start()`, tear down at
32
+ `system.stop()`.
33
+ 2. **Conversation subscription** (middle): per-agent message bus
34
+ subscription, PubSub topic subscription, per-agent breakpoint waiter.
35
+ Created in the orchestrator's constructor, cleaned up on
36
+ destroy. Lives across many generations.
37
+ 3. **Generation iterable** (innermost): the actual LLM token AsyncIterable
38
+ returned by `runStream`. Created per `runStream` call, ends when the
39
+ model finishes or `abort()` fires.
40
+
41
+ Sources own the outermost ring; AsyncIterable owns the innermost; the
42
+ middle ring is what `@directive-run/ai/communication` covers today.
43
+
44
+ ---
45
+
46
+ ## Recipes by user intent
47
+
48
+ ### "I want my agent to react to external lifecycle events"
49
+
50
+ Wire the lifecycle as a source on the orchestrator's module. The agent
51
+ sees fact-change reactions through its bound-facts view; resolvers can
52
+ gate execution on `facts.<lifecycle>.healthy` derivations.
53
+
54
+ - **MCP server connect/disconnect** → see "MCP Lifecycle" below.
55
+ - **Health checks** → poll inside the source's `attach`; publish
56
+ `health_changed` events as the upstream service responds.
57
+ - **WebSocket open / close** → `sourceFromWebSocket()` (sources/websocket).
58
+
59
+ ### "I want my agent's facts to update from a live external feed"
60
+
61
+ This is the **live-context** pattern — the differentiator nobody in the
62
+ agent-framework space has. See `runStream({ liveContext })` below for the
63
+ full recipe. tl;dr: sources publish into facts; `liveContext` re-renders
64
+ the agent's system prompt on each change AND can interrupt the stream
65
+ when conditions warrant.
66
+
67
+ ### "I want to bridge a callback-based SDK into Directive"
68
+
69
+ Wrap the SDK's `subscribe()` / `on('event')` API inside `attach(publish)`,
70
+ forward the unsubscribe. Direct generalization of the source primitive —
71
+ no AI-specific machinery required.
72
+
73
+ ### "I want to multiplex agent messages into the system"
74
+
75
+ Use `createMessageBus` from `@directive-run/ai/communication` for
76
+ per-agent messaging. The bus is a conversation-tier subscription (tier 2
77
+ above), not a source. Wire it inside the orchestrator's constructor.
78
+
79
+ ---
80
+
81
+ ## `runStream({ liveContext })` — Reactive Agents
82
+
83
+ > The killer demo: the agent is mid-generation, a source publishes a fact
84
+ > update, the agent's NEXT token reflects the new state — or interrupts
85
+ > the stream and re-prompts with fresh context. No external orchestration
86
+ > required.
87
+
88
+ ### When to use
89
+
90
+ - A market-data agent whose recommendation depends on live prices.
91
+ - A coding agent reviewing a PR while commits keep landing.
92
+ - A customer-support agent whose context updates when Stripe confirms a
93
+ refund mid-conversation.
94
+
95
+ ### Signature
96
+
97
+ ```ts
98
+ const result = orchestrator.runStream(agent, input, {
99
+ signal, // existing AbortSignal — terminates stream
100
+ liveContext: {
101
+ system, // the Directive system whose facts feed the agent
102
+ keys: ["pr.headSha", "pr.state"], // REQUIRED — fact keys to watch
103
+ interruptWhen: (facts, changedKeys) => boolean, // optional; default: () => true
104
+ notifyOn: "interrupt-only", // default; "all-changes" emits chunk per watched change
105
+ onContextUpdate: (changedKeys) => void, // optional logging hook
106
+ },
107
+ });
108
+
109
+ // New methods on the returned result:
110
+ result.interrupt(reason?); // cancel in-flight LLM run, KEEP liveContext alive
111
+ result.abort(); // tear down stream + detach liveContext
112
+ ```
113
+
114
+ Two new chunk variants land on the stream:
115
+
116
+ ```ts
117
+ | { type: "context_updated"; changedKeys: readonly string[] }
118
+ | {
119
+ type: "interrupted";
120
+ reason: string;
121
+ partialOutput: string;
122
+ changedKeys: readonly string[];
123
+ }
124
+ ```
125
+
126
+ ### Anatomy
127
+
128
+ - The orchestrator subscribes to `system.facts.$store.subscribe(keys, ...)`
129
+ for the keys it depends on — the same bridge primitive that powers
130
+ breakpoints and approvals elsewhere in `agent-orchestrator.ts`.
131
+ - The subscription wires up BEFORE the LLM run's IIFE starts, so a fact
132
+ change during the input-guardrail phase is observed correctly.
133
+ - On each batch of fact changes that touches the watched keys:
134
+ - `liveContext.onContextUpdate(changedKeys)` fires (logging hook).
135
+ - `interruptWhen(facts, changedKeys)` runs.
136
+ - If it returns `true`, the orchestrator emits an `interrupted` chunk
137
+ with the partial output captured so far + the changed keys, then
138
+ aborts the in-flight LLM stream. The consumer's `for await` sees
139
+ the `interrupted` chunk before the final `error` chunk lands.
140
+ - If it returns `false` and `notifyOn === "all-changes"`, a
141
+ `context_updated` chunk is emitted. With the default
142
+ `"interrupt-only"`, no chunk is emitted for non-interrupting
143
+ changes.
144
+ - `result.interrupt(reason?)` is the CALLER-driven counterpart: same
145
+ `interrupted` chunk, same abort, but the subscription stays attached
146
+ so the next caller-driven `runStream` continues against the live
147
+ fact stream without re-subscribing.
148
+
149
+ ### Status — shipped
150
+
151
+ `runStream({ liveContext })` is the dedicated subject of
152
+ [RFC 0005](../../docs/rfcs/0005-live-context-agent.md). Today's
153
+ implementation is **231 LOC in `agent-orchestrator.ts`** — under the
154
+ RFC's 300-LOC scope guard. The `mode: "restart"` automatic
155
+ re-invocation is reserved for a follow-up minor; today both `mode`
156
+ values produce identical behavior (the chunk-emission contract +
157
+ abort wiring; consumers re-prompt via a fresh `runStream` call).
158
+
159
+ **Security companion (mandatory when watched facts may carry PII):**
160
+ wire `createFactPIIGuardrail` on the same Directive system. Without
161
+ it, `liveContext` expands the source → fact → prompt PII bypass
162
+ surface into mid-stream context updates. See the
163
+ ["Sources × Security"](#sources--security) section below for the full
164
+ threat model.
165
+
166
+ ---
167
+
168
+ ## MCP Lifecycle as a Source
169
+
170
+ `@directive-run/ai/mcp` exposes `MCPAdapterConfig.events`
171
+ (`onConnect`, `onDisconnect`, `onToolCall`, `onError`, ...) as a single
172
+ bag of callbacks set at adapter-construction time. To bridge the
173
+ adapter's lifecycle into a Directive source the recipe is: declare a
174
+ holder that the source's `attach` populates with the live `publish`,
175
+ then point the adapter's `events.onConnect` / `onDisconnect` at the
176
+ holder. The holder pattern lets you construct the adapter and the
177
+ source in either order — the source's mount/unmount drives which
178
+ publish closure (if any) receives lifecycle events.
179
+
180
+ ```ts
181
+ import { createSystem, createModule, t } from "@directive-run/core";
182
+ import type { SourcePublish } from "@directive-run/core";
183
+ import { createMCPAdapter } from "@directive-run/ai/mcp";
184
+
185
+ // Holder that the source's `attach` populates. The adapter's events
186
+ // forward through this — when the source is detached (`attach`'s
187
+ // unsubscribe runs), the holder goes null and the adapter callbacks
188
+ // no-op. This guarantees post-stop lifecycle events from the MCP
189
+ // transport can't write into a torn-down system.
190
+ let publishRef: SourcePublish | null = null;
191
+
192
+ const adapter = createMCPAdapter({
193
+ servers: [/* ... */],
194
+ events: {
195
+ onConnect: (name) => publishRef?.("MCP_SERVER_CONNECTED", { name }),
196
+ onDisconnect: (name) => publishRef?.("MCP_SERVER_DISCONNECTED", { name }),
197
+ },
198
+ });
199
+
200
+ const orchestrator = createModule("orchestrator", {
201
+ schema: {
202
+ facts: {
203
+ mcp: t.object<{
204
+ servers: Record<string, "connected" | "disconnected">;
205
+ }>(),
206
+ },
207
+ events: {
208
+ MCP_SERVER_CONNECTED: { name: t.string() },
209
+ MCP_SERVER_DISCONNECTED: { name: t.string() },
210
+ },
211
+ derivations: {
212
+ allServersHealthy: t.boolean(),
213
+ },
214
+ },
215
+ init: (f) => {
216
+ f.mcp = { servers: {} };
217
+ },
218
+ events: {
219
+ MCP_SERVER_CONNECTED: (f, p) => {
220
+ f.mcp = {
221
+ ...f.mcp,
222
+ servers: { ...f.mcp.servers, [p.name]: "connected" },
223
+ };
224
+ },
225
+ MCP_SERVER_DISCONNECTED: (f, p) => {
226
+ f.mcp = {
227
+ ...f.mcp,
228
+ servers: { ...f.mcp.servers, [p.name]: "disconnected" },
229
+ };
230
+ },
231
+ },
232
+ derive: {
233
+ allServersHealthy: (facts) =>
234
+ Object.values(facts.mcp.servers).every((s) => s === "connected"),
235
+ },
236
+ sources: {
237
+ mcpLifecycle: {
238
+ attach: (publish) => {
239
+ publishRef = publish;
240
+ // Kick the adapter's connection lifecycle once the source is
241
+ // attached so the first `onConnect` flows into our publish.
242
+ void adapter.connect();
243
+ return async () => {
244
+ publishRef = null;
245
+ await adapter.disconnect();
246
+ };
247
+ },
248
+ },
249
+ },
250
+ });
251
+ ```
252
+
253
+ Constraints can now gate agent execution on
254
+ `facts.allServersHealthy` — no separate health-check loop needed.
255
+
256
+ > The holder + closure pattern is the canonical bridge from any
257
+ > single-callback-bag third-party SDK (MCP, telemetry SDKs that take an
258
+ > `onEvent` config at construction) into a Directive source. The
259
+ > alternative — passing `publish` from inside `attach` into the
260
+ > adapter's events — requires the adapter to be constructed AFTER the
261
+ > source is created, which is awkward when the adapter is used outside
262
+ > Directive too.
263
+
264
+ ---
265
+
266
+ ## Sources × Security
267
+
268
+ > A source can publish PII into a fact BEFORE any input guardrail runs.
269
+ > The pattern is real and exploitable. ALWAYS wire `createFactPIIGuardrail`
270
+ > when sources feed facts the agent will reason over.
271
+
272
+ ### The bypass chain
273
+
274
+ 1. Source wraps Supabase realtime: `subscribe('users', row => publish('USER_UPDATED', { email: row.email, ssn: row.ssn }))`.
275
+ 2. Event handler writes: `f.email = payload.email; f.ssn = payload.ssn;`.
276
+ 3. Agent prompt template embeds: `"Hello ${facts.email}, your record shows ${facts.ssn}..."`.
277
+ 4. `createPIIGuardrail` (input guardrail) only inspects the `input` argument
278
+ passed to `runStream(agent, input, ...)`. The facts injected into the
279
+ prompt are **not in `input`** — they flow through the resolver's
280
+ templating layer.
281
+ 5. The LLM call ships the PII. The audit ledger's `fact.change` entry
282
+ carries it. Devtools / breakpoint snapshots see it. Operators see it.
283
+
284
+ ### The fix — `createFactPIIGuardrail`
285
+
286
+ A Directive plugin that scans every write to a `pii`-tagged fact and
287
+ either redacts (default) or alerts:
288
+
289
+ ```ts
290
+ import { createSystem } from "@directive-run/core";
291
+ import { createFactPIIGuardrail } from "@directive-run/ai/guardrails";
292
+
293
+ const system = createSystem({
294
+ module: customer, // schema tags `email` + `ssn` with `tags: ['pii']`
295
+ plugins: [
296
+ createFactPIIGuardrail({
297
+ mode: "redact", // 'redact' (default) | 'alert'
298
+ types: ["ssn", "credit_card", "email"],
299
+ onBlocked: (key, detected) => {
300
+ Sentry.captureMessage(`pii redacted: ${key}`, { extra: { count: detected.length } });
301
+ },
302
+ }),
303
+ ],
304
+ });
305
+ ```
306
+
307
+ **Modes:**
308
+
309
+ - `"redact"` (default, safest): rewrites the fact value via a follow-up
310
+ store write. The raw value briefly exists for one microtask while the
311
+ redaction lands; downstream subscribers that snapshot at that instant
312
+ see it; the LLM call after the next settle does not.
313
+ - `"alert"`: fires `onBlocked` but does NOT mutate the fact. Use for
314
+ monitoring-only deployments where the source's transport is already
315
+ trusted and you want to page on every match without modifying state.
316
+
317
+ The plugin scans `pii`-tagged facts auto-detected via `meta.byTag("pii")`,
318
+ plus any `includeKeys` you list explicitly. The built-in regex matches
319
+ SSN, credit card, and email; pass a `customDetector` for domain-specific
320
+ patterns (internal account numbers, partner IDs).
321
+
322
+ ### Limitation — hard rejection needs an RFC
323
+
324
+ Plugin hooks (`onFactSet`, `onFactsBatch`) are wrapped by the plugin
325
+ manager's `safeCall`, so a `throw` from the guardrail can't actually
326
+ reject the write. The `"redact"` mode handles this with a follow-up write;
327
+ the `"alert"` mode is observation-only. Hard rejection at the publish
328
+ boundary requires a pre-commit transform hook on the source primitive
329
+ itself — that's tracked as a future RFC. For now: wire `"redact"` for
330
+ safety, `"alert"` for monitoring.
331
+
332
+ See also: [`ai-security.md`](./ai-security.md) — "Sources × PII" section
333
+ for the full threat model and the additional output-side mitigations
334
+ that should accompany this plugin in regulated deployments.
335
+
336
+ ---
337
+
338
+ ## Adapter packages
339
+
340
+ The canonical bridges live in **`@directive-run/sources`** as subpath
341
+ exports. One install, optional peerDependencies per vendor.
342
+
343
+ | Subpath | Source factory | Wraps |
344
+ |---|---|---|
345
+ | `@directive-run/sources/supabase` | `sourceFromSupabaseChannel()` | Supabase realtime channel |
346
+ | `@directive-run/sources/cloudflare` | `sourceFromDOAlarm()` | Cloudflare Durable Object alarm |
347
+ | `@directive-run/sources/websocket` | `sourceFromWebSocket()` | (future RFC) raw WebSocket |
348
+ | `@directive-run/sources/sentry` | `sourceFromSentryHook()` | (future RFC) Sentry production-error stream |
349
+
350
+ Install only the umbrella; the vendor peerDeps are optional and pull in
351
+ only when the corresponding subpath is imported.
352
+
353
+ ---
354
+
355
+ ## Anti-patterns
356
+
357
+ ### Anti-pattern A — Don't use a source for token streaming
358
+
359
+ LLM tokens are a per-generation hot stream. Wrap them in an
360
+ `AsyncIterable` (the existing `runStream` contract), not a source.
361
+
362
+ ```ts
363
+ // WRONG — sources are mount-once; tokens are per-call
364
+ sources: {
365
+ llmTokens: {
366
+ attach: (publish) => {
367
+ const stream = openai.chat.completions.create({ stream: true });
368
+ for await (const chunk of stream) {
369
+ publish("TOKEN", { content: chunk.choices[0].delta.content });
370
+ }
371
+ return () => stream.abort();
372
+ },
373
+ },
374
+ },
375
+
376
+ // RIGHT — tokens flow through the runStream AsyncIterable
377
+ const result = orchestrator.runStream(agent, input);
378
+ for await (const chunk of result.stream) {
379
+ if (chunk.type === "token") render(chunk.content);
380
+ }
381
+ ```
382
+
383
+ The rule: **source per connection; AsyncIterable per generation.**
384
+
385
+ ### Anti-pattern B — Don't poll an external system from a constraint
386
+
387
+ Constraints model declarative when-then rules over facts. A
388
+ `setInterval` inside a constraint smuggles a long-running subscription
389
+ into a non-lifecycle hook.
390
+
391
+ ```ts
392
+ // WRONG — constraint kicks off polling that lives forever
393
+ constraints: {
394
+ pollLatest: {
395
+ when: (facts) => true,
396
+ require: { type: "POLL_INVENTORY" },
397
+ },
398
+ },
399
+ resolvers: {
400
+ pollInventory: {
401
+ requirement: "POLL_INVENTORY",
402
+ resolve: async (req, context) => {
403
+ setInterval(async () => {
404
+ const latest = await fetch("/api/inventory");
405
+ context.facts.inventory = await latest.json();
406
+ }, 5000);
407
+ },
408
+ },
409
+ },
410
+
411
+ // RIGHT — declare a source; the engine owns the lifecycle
412
+ sources: {
413
+ inventoryPoll: {
414
+ attach: (publish) => {
415
+ const id = setInterval(async () => {
416
+ const latest = await fetch("/api/inventory");
417
+ publish("INVENTORY_LATEST", await latest.json());
418
+ }, 5000);
419
+ return () => clearInterval(id);
420
+ },
421
+ },
422
+ },
423
+ ```
424
+
425
+ ---
426
+
427
+ ## Related
428
+
429
+ - [`../core/sources.md`](../core/sources.md) — full source primitive semantics, lifecycle, recipes
430
+ - [`ai-security.md`](./ai-security.md) — PII detection, prompt-injection, output guardrails (see "Sources × PII" section)
431
+ - [`ai-orchestrator.md`](./ai-orchestrator.md) — `createAgentOrchestrator` + `runStream`
432
+ - [`ai-mcp-rag.md`](./ai-mcp-rag.md) — MCP adapter
433
+ - [`ai-communication.md`](./ai-communication.md) — agent message bus + PubSub
@@ -182,6 +182,34 @@ inspection.derivations; // [{ id: "doubled", meta: { ... } }]
182
182
  inspection.modules; // [{ id: "auth", meta: { ... } }]
183
183
  inspection.events; // [{ name: "increment", meta: { ... } }]
184
184
 
185
+ // Sources with per-source telemetry. Operators read these to answer
186
+ // "is this source publishing?" / "did it error?" / "is the engine
187
+ // silently dropping its publishes?" without registering a custom
188
+ // plugin. Counters reset at every system.start() cycle.
189
+ inspection.sources;
190
+ // [{
191
+ // id: "supabaseRealtime",
192
+ // moduleId: "messages",
193
+ // attached: true,
194
+ // attachedAt: 1709000000,
195
+ // detachedAt: null,
196
+ // publishCount: 47,
197
+ // lastPublishAt: 1709000123,
198
+ // // dropCount counts publishes the engine's dispatch guard rejected
199
+ // // (post-stop, BLOCKED_PROPS event name, empty / non-string name).
200
+ // dropCount: 0,
201
+ // lastDropReason: null, // | "post-destroy" | "post-stop"
202
+ // // | "blocked-event-name"
203
+ // // | "invalid-event-name"
204
+ // lastDropAt: null,
205
+ // errorCount: 0,
206
+ // lastError: null, // { phase, message (truncated), at } | null
207
+ // meta: { label: "Supabase channel" },
208
+ // }]
209
+
210
+ // Aggregate — number of sources currently attached (== count of `attached: true` rows).
211
+ inspection.attachedSourceCount; // 3
212
+
185
213
  // Unmet requirements (no matching resolver)
186
214
  inspection.unmet;
187
215
 
@@ -242,8 +270,10 @@ const unsub = system.observe((event: ObservationEvent) => {
242
270
  if (event.type === "fact.change") console.log(event.key, event.prev, "→", event.next);
243
271
  });
244
272
 
245
- // 18 event types: fact.change, constraint.evaluate/error, requirement.created/met/canceled,
246
- // resolver.start/complete/error, effect.run/error, derivation.compute,
273
+ // 23 event types: fact.change, constraint.evaluate/error,
274
+ // requirement.created/met/canceled, resolver.start/complete/error/write.rejected,
275
+ // effect.run/error, derivation.compute,
276
+ // source.attach/publish/detach/error,
247
277
  // reconcile.start/end, system.init/start/stop/destroy
248
278
 
249
279
  unsub(); // Stop observing
@@ -11,6 +11,8 @@ User wants to...
11
11
  ├── Store state → schema.facts + init()
12
12
  ├── Compute derived values → schema.derivations + derive
13
13
  ├── React to state changes → effects
14
+ ├── Subscribe to an external event stream (WebSocket, Supabase realtime,
15
+ │ browser events, MCP server lifecycle) → sources
14
16
  ├── Trigger side effects when conditions are met → constraints + resolvers
15
17
  ├── Handle user actions → schema.events + events handlers
16
18
  └── Coordinate multiple modules → createSystem({ modules: {} })
@@ -233,6 +235,7 @@ const typed = createModule("typed", {
233
235
 
234
236
  - [`constraints.md`](./constraints.md) — the demand side of the constraint-resolver loop
235
237
  - [`resolvers.md`](./resolvers.md) — the supply side that fulfills requirements
238
+ - [`sources.md`](./sources.md) — typed external event sources (the inbound dual of effects)
236
239
  - [`schema-types.md`](./schema-types.md) — the `t.*()` builders that define fact and derivation types
237
240
  - [`multi-module.md`](./multi-module.md) — when a single module isn't enough
238
241
  - [`system-api.md`](./system-api.md) — `createSystem` and the runtime instance once your module is defined
@@ -29,6 +29,7 @@ Website: https://directive.run
29
29
  - [Constraints](https://directive.run/docs/constraints)
30
30
  - [Resolvers](https://directive.run/docs/resolvers)
31
31
  - [Effects](https://directive.run/docs/effects)
32
+ - [Sources](https://directive.run/docs/sources)
32
33
  - [Events](https://directive.run/docs/events)
33
34
  - [Schema & Types](https://directive.run/docs/schema-overview)
34
35
  - [API Reference](https://directive.run/docs/api/core)
@@ -380,6 +380,62 @@ createCompliance({
380
380
  })
381
381
  ```
382
382
 
383
+ ## Sources × PII — closing the fact-injection bypass
384
+
385
+ `createPIIGuardrail` and `createEnhancedPIIGuardrail` only inspect the
386
+ `data.input` argument passed to `runStream(agent, input, ...)`. When a
387
+ source publishes PII into a fact and the agent's prompt template embeds
388
+ that fact (`"Hello ${facts.email}..."`), the PII reaches the LLM call
389
+ without hitting the input guardrail chain.
390
+
391
+ This is the documented R5 audit finding (red team, privacy, AI-integration
392
+ reviewers independently flagged it). The Tier 0 fix:
393
+
394
+ ```ts
395
+ import { createSystem, createModule, t } from "@directive-run/core";
396
+ import { createFactPIIGuardrail } from "@directive-run/ai/guardrails";
397
+
398
+ const customer = createModule("customer", {
399
+ schema: {
400
+ facts: {
401
+ email: t.string().meta({ tags: ["pii"] }), // ← tag pii-bearing facts
402
+ ssn: t.string().meta({ tags: ["pii"] }),
403
+ },
404
+ },
405
+ sources: { /* Supabase realtime, webhook, ... */ },
406
+ });
407
+
408
+ const system = createSystem({
409
+ module: customer,
410
+ plugins: [
411
+ createFactPIIGuardrail({
412
+ mode: "redact", // 'redact' (safe default) | 'alert'
413
+ onBlocked: (key, detected) =>
414
+ Sentry.captureMessage(`pii redacted: ${key}`, {
415
+ extra: { count: detected.length },
416
+ }),
417
+ }),
418
+ ],
419
+ });
420
+ ```
421
+
422
+ The plugin scans every write to a `pii`-tagged fact (auto-discovered via
423
+ `meta.byTag("pii")` at `onInit`) and either redacts the value (default —
424
+ rewrites via a follow-up store write so the next read sees the redacted
425
+ form) or alerts (fires `onBlocked` without mutating). Together with the
426
+ input-guardrail chain on `runStream`, this closes the source → fact →
427
+ prompt PII path.
428
+
429
+ **Limitation:** hard rejection at the write boundary requires a
430
+ pre-commit transform hook on the source primitive itself (Directive
431
+ plugin hooks are wrapped by `safeCall` and a thrown error is
432
+ swallowed). Tracked as a future RFC; today's `"redact"` mode is the
433
+ safe-shipping posture.
434
+
435
+ See [`ai-sources.md`](./ai-sources.md) for the full source primitive
436
+ integration recipes, including the live-context pattern that
437
+ `createFactPIIGuardrail` gates.
438
+
383
439
  ## Quick reference
384
440
 
385
441
  | API | Import path | Returns | Purpose |
@@ -387,6 +443,7 @@ createCompliance({
387
443
  | `createPIIGuardrail` | `@directive-run/ai/guardrails` | `GuardrailFn<InputGuardrailData>` | Basic input PII detection (block or redact) |
388
444
  | `createEnhancedPIIGuardrail` | `@directive-run/ai/guardrails` | guardrail | Context-aware input PII |
389
445
  | `createOutputPIIGuardrail` | `@directive-run/ai/guardrails` | guardrail | Output PII coverage |
446
+ | `createFactPIIGuardrail` | `@directive-run/ai/guardrails` | `Plugin` | **Fact-boundary PII guardrail — closes the source → fact → prompt bypass. Wire at `createSystem({ plugins })`.** |
390
447
  | `createPromptInjectionGuardrail` | `@directive-run/ai/guardrails` | guardrail | Risk-scored injection defense (`strictMode`, `blockThreshold`) |
391
448
  | `createAuditTrail` | `@directive-run/ai` | `AuditInstance` | Hash-chained audit log w/ signing + PII masking + async export |
392
449
  | `createCompliance` | `@directive-run/ai` | `ComplianceInstance` | GDPR exportData / deleteData / consent + retention enforcement |