@directive-run/knowledge 1.17.2 → 1.18.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/ai/ai-security.md +57 -0
- package/ai/ai-sources.md +406 -0
- package/core/anti-patterns.md +94 -1
- package/core/core-patterns.md +3 -0
- package/core/naming.md +6 -1
- package/core/sources.md +424 -0
- package/core/system-api.md +32 -2
- package/package.json +3 -3
- package/sitemap.md +1 -0
package/ai/ai-security.md
CHANGED
|
@@ -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 |
|
package/ai/ai-sources.md
ADDED
|
@@ -0,0 +1,406 @@
|
|
|
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
|
+
mode: "inject-system-message", // shipped today (default behavior).
|
|
105
|
+
// "restart" reserved for follow-up minor;
|
|
106
|
+
// behaves identically to inject-system-message today.
|
|
107
|
+
notifyOn: "interrupt-only", // default; "all-changes" emits chunk per watched change
|
|
108
|
+
onContextUpdate: (changedKeys) => void, // optional logging hook
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// New methods on the returned result:
|
|
113
|
+
result.interrupt(reason?); // cancel in-flight LLM run, KEEP liveContext alive
|
|
114
|
+
result.abort(); // tear down stream + detach liveContext
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Two new chunk variants land on the stream:
|
|
118
|
+
|
|
119
|
+
```ts
|
|
120
|
+
| { type: "context_updated"; changedKeys: readonly string[] }
|
|
121
|
+
| {
|
|
122
|
+
type: "interrupted";
|
|
123
|
+
reason: string;
|
|
124
|
+
partialOutput: string;
|
|
125
|
+
changedKeys: readonly string[];
|
|
126
|
+
}
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
### Anatomy
|
|
130
|
+
|
|
131
|
+
- The orchestrator subscribes to `system.facts.$store.subscribe(keys, ...)`
|
|
132
|
+
for the keys it depends on — the same bridge primitive that powers
|
|
133
|
+
breakpoints and approvals elsewhere in `agent-orchestrator.ts`.
|
|
134
|
+
- The subscription wires up BEFORE the LLM run's IIFE starts, so a fact
|
|
135
|
+
change during the input-guardrail phase is observed correctly.
|
|
136
|
+
- On each batch of fact changes that touches the watched keys:
|
|
137
|
+
- `liveContext.onContextUpdate(changedKeys)` fires (logging hook).
|
|
138
|
+
- `interruptWhen(facts, changedKeys)` runs.
|
|
139
|
+
- If it returns `true`, the orchestrator emits an `interrupted` chunk
|
|
140
|
+
with the partial output captured so far + the changed keys, then
|
|
141
|
+
aborts the in-flight LLM stream. The consumer's `for await` sees
|
|
142
|
+
the `interrupted` chunk before the final `error` chunk lands.
|
|
143
|
+
- If it returns `false` and `notifyOn === "all-changes"`, a
|
|
144
|
+
`context_updated` chunk is emitted. With the default
|
|
145
|
+
`"interrupt-only"`, no chunk is emitted for non-interrupting
|
|
146
|
+
changes.
|
|
147
|
+
- `result.interrupt(reason?)` is the CALLER-driven counterpart: same
|
|
148
|
+
`interrupted` chunk, same abort, but the subscription stays attached
|
|
149
|
+
so the next caller-driven `runStream` continues against the live
|
|
150
|
+
fact stream without re-subscribing.
|
|
151
|
+
|
|
152
|
+
### Status — shipped
|
|
153
|
+
|
|
154
|
+
`runStream({ liveContext })` is the dedicated subject of
|
|
155
|
+
[RFC 0005](../../docs/rfcs/0005-live-context-agent.md). Today's
|
|
156
|
+
implementation is **231 LOC in `agent-orchestrator.ts`** — under the
|
|
157
|
+
RFC's 300-LOC scope guard. The `mode: "restart"` automatic
|
|
158
|
+
re-invocation is reserved for a follow-up minor; today both `mode`
|
|
159
|
+
values produce identical behavior (the chunk-emission contract +
|
|
160
|
+
abort wiring; consumers re-prompt via a fresh `runStream` call).
|
|
161
|
+
|
|
162
|
+
**Security companion (mandatory when watched facts may carry PII):**
|
|
163
|
+
wire `createFactPIIGuardrail` on the same Directive system. Without
|
|
164
|
+
it, `liveContext` expands the source → fact → prompt PII bypass
|
|
165
|
+
surface into mid-stream context updates. See the
|
|
166
|
+
["Sources × Security"](#sources--security) section below for the full
|
|
167
|
+
threat model.
|
|
168
|
+
|
|
169
|
+
---
|
|
170
|
+
|
|
171
|
+
## MCP Lifecycle as a Source
|
|
172
|
+
|
|
173
|
+
`@directive-run/ai/mcp` already exposes `MCPAdapterConfig.events`
|
|
174
|
+
(`onConnect`, `onDisconnect`, `onToolCall`, ...) as callback hooks. The
|
|
175
|
+
canonical adapter wraps those callbacks into a source on the
|
|
176
|
+
orchestrator's module:
|
|
177
|
+
|
|
178
|
+
```ts
|
|
179
|
+
import { createSystem, createModule, t } from "@directive-run/core";
|
|
180
|
+
import { createMCPAdapter } from "@directive-run/ai/mcp";
|
|
181
|
+
|
|
182
|
+
const adapter = createMCPAdapter({ /* ... */ });
|
|
183
|
+
|
|
184
|
+
const orchestrator = createModule("orchestrator", {
|
|
185
|
+
schema: {
|
|
186
|
+
facts: {
|
|
187
|
+
mcp: t.object<{ servers: Record<string, "connected" | "disconnected"> }>(),
|
|
188
|
+
},
|
|
189
|
+
events: {
|
|
190
|
+
MCP_SERVER_CONNECTED: { name: t.string() },
|
|
191
|
+
MCP_SERVER_DISCONNECTED: { name: t.string() },
|
|
192
|
+
},
|
|
193
|
+
},
|
|
194
|
+
init: (f) => {
|
|
195
|
+
f.mcp = { servers: {} };
|
|
196
|
+
},
|
|
197
|
+
events: {
|
|
198
|
+
MCP_SERVER_CONNECTED: (f, p) => {
|
|
199
|
+
f.mcp = {
|
|
200
|
+
...f.mcp,
|
|
201
|
+
servers: { ...f.mcp.servers, [p.name]: "connected" },
|
|
202
|
+
};
|
|
203
|
+
},
|
|
204
|
+
MCP_SERVER_DISCONNECTED: (f, p) => {
|
|
205
|
+
f.mcp = {
|
|
206
|
+
...f.mcp,
|
|
207
|
+
servers: { ...f.mcp.servers, [p.name]: "disconnected" },
|
|
208
|
+
};
|
|
209
|
+
},
|
|
210
|
+
},
|
|
211
|
+
derive: {
|
|
212
|
+
allServersHealthy: (f) =>
|
|
213
|
+
Object.values(f.mcp.servers).every((s) => s === "connected"),
|
|
214
|
+
},
|
|
215
|
+
sources: {
|
|
216
|
+
mcpLifecycle: {
|
|
217
|
+
attach: (publish) => {
|
|
218
|
+
const off1 = adapter.onConnect((name) =>
|
|
219
|
+
publish("MCP_SERVER_CONNECTED", { name }),
|
|
220
|
+
);
|
|
221
|
+
const off2 = adapter.onDisconnect((name) =>
|
|
222
|
+
publish("MCP_SERVER_DISCONNECTED", { name }),
|
|
223
|
+
);
|
|
224
|
+
return () => {
|
|
225
|
+
off1();
|
|
226
|
+
off2();
|
|
227
|
+
};
|
|
228
|
+
},
|
|
229
|
+
},
|
|
230
|
+
},
|
|
231
|
+
});
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
Constraints can now gate agent execution on
|
|
235
|
+
`facts.allServersHealthy` — no separate health-check loop needed.
|
|
236
|
+
|
|
237
|
+
---
|
|
238
|
+
|
|
239
|
+
## Sources × Security
|
|
240
|
+
|
|
241
|
+
> A source can publish PII into a fact BEFORE any input guardrail runs.
|
|
242
|
+
> The pattern is real and exploitable. ALWAYS wire `createFactPIIGuardrail`
|
|
243
|
+
> when sources feed facts the agent will reason over.
|
|
244
|
+
|
|
245
|
+
### The bypass chain
|
|
246
|
+
|
|
247
|
+
1. Source wraps Supabase realtime: `subscribe('users', row => publish('USER_UPDATED', { email: row.email, ssn: row.ssn }))`.
|
|
248
|
+
2. Event handler writes: `f.email = payload.email; f.ssn = payload.ssn;`.
|
|
249
|
+
3. Agent prompt template embeds: `"Hello ${facts.email}, your record shows ${facts.ssn}..."`.
|
|
250
|
+
4. `createPIIGuardrail` (input guardrail) only inspects the `input` argument
|
|
251
|
+
passed to `runStream(agent, input, ...)`. The facts injected into the
|
|
252
|
+
prompt are **not in `input`** — they flow through the resolver's
|
|
253
|
+
templating layer.
|
|
254
|
+
5. The LLM call ships the PII. The audit ledger's `fact.change` entry
|
|
255
|
+
carries it. Devtools / breakpoint snapshots see it. Operators see it.
|
|
256
|
+
|
|
257
|
+
### The fix — `createFactPIIGuardrail`
|
|
258
|
+
|
|
259
|
+
A Directive plugin that scans every write to a `pii`-tagged fact and
|
|
260
|
+
either redacts (default) or alerts:
|
|
261
|
+
|
|
262
|
+
```ts
|
|
263
|
+
import { createSystem } from "@directive-run/core";
|
|
264
|
+
import { createFactPIIGuardrail } from "@directive-run/ai/guardrails";
|
|
265
|
+
|
|
266
|
+
const system = createSystem({
|
|
267
|
+
module: customer, // schema tags `email` + `ssn` with `tags: ['pii']`
|
|
268
|
+
plugins: [
|
|
269
|
+
createFactPIIGuardrail({
|
|
270
|
+
mode: "redact", // 'redact' (default) | 'alert'
|
|
271
|
+
types: ["ssn", "credit_card", "email"],
|
|
272
|
+
onBlocked: (key, detected) => {
|
|
273
|
+
Sentry.captureMessage(`pii redacted: ${key}`, { extra: { count: detected.length } });
|
|
274
|
+
},
|
|
275
|
+
}),
|
|
276
|
+
],
|
|
277
|
+
});
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
**Modes:**
|
|
281
|
+
|
|
282
|
+
- `"redact"` (default, safest): rewrites the fact value via a follow-up
|
|
283
|
+
store write. The raw value briefly exists for one microtask while the
|
|
284
|
+
redaction lands; downstream subscribers that snapshot at that instant
|
|
285
|
+
see it; the LLM call after the next settle does not.
|
|
286
|
+
- `"alert"`: fires `onBlocked` but does NOT mutate the fact. Use for
|
|
287
|
+
monitoring-only deployments where the source's transport is already
|
|
288
|
+
trusted and you want to page on every match without modifying state.
|
|
289
|
+
|
|
290
|
+
The plugin scans `pii`-tagged facts auto-detected via `meta.byTag("pii")`,
|
|
291
|
+
plus any `includeKeys` you list explicitly. The built-in regex matches
|
|
292
|
+
SSN, credit card, and email; pass a `customDetector` for domain-specific
|
|
293
|
+
patterns (internal account numbers, partner IDs).
|
|
294
|
+
|
|
295
|
+
### Limitation — hard rejection needs an RFC
|
|
296
|
+
|
|
297
|
+
Plugin hooks (`onFactSet`, `onFactsBatch`) are wrapped by the plugin
|
|
298
|
+
manager's `safeCall`, so a `throw` from the guardrail can't actually
|
|
299
|
+
reject the write. The `"redact"` mode handles this with a follow-up write;
|
|
300
|
+
the `"alert"` mode is observation-only. Hard rejection at the publish
|
|
301
|
+
boundary requires a pre-commit transform hook on the source primitive
|
|
302
|
+
itself — that's tracked as a future RFC. For now: wire `"redact"` for
|
|
303
|
+
safety, `"alert"` for monitoring.
|
|
304
|
+
|
|
305
|
+
See also: [`ai-security.md`](./ai-security.md) — "Sources × PII" section
|
|
306
|
+
for the full threat model and the additional output-side mitigations
|
|
307
|
+
that should accompany this plugin in regulated deployments.
|
|
308
|
+
|
|
309
|
+
---
|
|
310
|
+
|
|
311
|
+
## Adapter packages
|
|
312
|
+
|
|
313
|
+
The canonical bridges live in **`@directive-run/sources`** as subpath
|
|
314
|
+
exports. One install, optional peerDependencies per vendor.
|
|
315
|
+
|
|
316
|
+
| Subpath | Source factory | Wraps |
|
|
317
|
+
|---|---|---|
|
|
318
|
+
| `@directive-run/sources/supabase` | `sourceFromSupabaseChannel()` | Supabase realtime channel |
|
|
319
|
+
| `@directive-run/sources/cloudflare` | `sourceFromDOAlarm()` | Cloudflare Durable Object alarm |
|
|
320
|
+
| `@directive-run/sources/websocket` | `sourceFromWebSocket()` | (future RFC) raw WebSocket |
|
|
321
|
+
| `@directive-run/sources/sentry` | `sourceFromSentryHook()` | (future RFC) Sentry production-error stream |
|
|
322
|
+
|
|
323
|
+
Install only the umbrella; the vendor peerDeps are optional and pull in
|
|
324
|
+
only when the corresponding subpath is imported.
|
|
325
|
+
|
|
326
|
+
---
|
|
327
|
+
|
|
328
|
+
## Anti-patterns
|
|
329
|
+
|
|
330
|
+
### Anti-pattern A — Don't use a source for token streaming
|
|
331
|
+
|
|
332
|
+
LLM tokens are a per-generation hot stream. Wrap them in an
|
|
333
|
+
`AsyncIterable` (the existing `runStream` contract), not a source.
|
|
334
|
+
|
|
335
|
+
```ts
|
|
336
|
+
// WRONG — sources are mount-once; tokens are per-call
|
|
337
|
+
sources: {
|
|
338
|
+
llmTokens: {
|
|
339
|
+
attach: (publish) => {
|
|
340
|
+
const stream = openai.chat.completions.create({ stream: true });
|
|
341
|
+
for await (const chunk of stream) {
|
|
342
|
+
publish("TOKEN", { content: chunk.choices[0].delta.content });
|
|
343
|
+
}
|
|
344
|
+
return () => stream.abort();
|
|
345
|
+
},
|
|
346
|
+
},
|
|
347
|
+
},
|
|
348
|
+
|
|
349
|
+
// RIGHT — tokens flow through the runStream AsyncIterable
|
|
350
|
+
const result = orchestrator.runStream(agent, input);
|
|
351
|
+
for await (const chunk of result.stream) {
|
|
352
|
+
if (chunk.type === "token") render(chunk.content);
|
|
353
|
+
}
|
|
354
|
+
```
|
|
355
|
+
|
|
356
|
+
The rule: **source per connection; AsyncIterable per generation.**
|
|
357
|
+
|
|
358
|
+
### Anti-pattern B — Don't poll an external system from a constraint
|
|
359
|
+
|
|
360
|
+
Constraints model declarative when-then rules over facts. A
|
|
361
|
+
`setInterval` inside a constraint smuggles a long-running subscription
|
|
362
|
+
into a non-lifecycle hook.
|
|
363
|
+
|
|
364
|
+
```ts
|
|
365
|
+
// WRONG — constraint kicks off polling that lives forever
|
|
366
|
+
constraints: {
|
|
367
|
+
pollLatest: {
|
|
368
|
+
when: (facts) => true,
|
|
369
|
+
require: { type: "POLL_INVENTORY" },
|
|
370
|
+
},
|
|
371
|
+
},
|
|
372
|
+
resolvers: {
|
|
373
|
+
pollInventory: {
|
|
374
|
+
requirement: "POLL_INVENTORY",
|
|
375
|
+
resolve: async (req, context) => {
|
|
376
|
+
setInterval(async () => {
|
|
377
|
+
const latest = await fetch("/api/inventory");
|
|
378
|
+
context.facts.inventory = await latest.json();
|
|
379
|
+
}, 5000);
|
|
380
|
+
},
|
|
381
|
+
},
|
|
382
|
+
},
|
|
383
|
+
|
|
384
|
+
// RIGHT — declare a source; the engine owns the lifecycle
|
|
385
|
+
sources: {
|
|
386
|
+
inventoryPoll: {
|
|
387
|
+
attach: (publish) => {
|
|
388
|
+
const id = setInterval(async () => {
|
|
389
|
+
const latest = await fetch("/api/inventory");
|
|
390
|
+
publish("INVENTORY_LATEST", await latest.json());
|
|
391
|
+
}, 5000);
|
|
392
|
+
return () => clearInterval(id);
|
|
393
|
+
},
|
|
394
|
+
},
|
|
395
|
+
},
|
|
396
|
+
```
|
|
397
|
+
|
|
398
|
+
---
|
|
399
|
+
|
|
400
|
+
## Related
|
|
401
|
+
|
|
402
|
+
- [`../core/sources.md`](../core/sources.md) — full source primitive semantics, lifecycle, recipes
|
|
403
|
+
- [`ai-security.md`](./ai-security.md) — PII detection, prompt-injection, output guardrails (see "Sources × PII" section)
|
|
404
|
+
- [`ai-orchestrator.md`](./ai-orchestrator.md) — `createAgentOrchestrator` + `runStream`
|
|
405
|
+
- [`ai-mcp-rag.md`](./ai-mcp-rag.md) — MCP adapter
|
|
406
|
+
- [`ai-communication.md`](./ai-communication.md) — agent message bus + PubSub
|
package/core/anti-patterns.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
> Covers `@directive-run/core` and `@directive-run/react` — hallucination-prone API patterns to avoid.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
21 most common mistakes when generating Directive code, ranked by AI hallucination frequency. Every code generation MUST be checked against this list.
|
|
6
6
|
|
|
7
7
|
## 1. Unnecessary Type Casting on Facts/Derivations
|
|
8
8
|
|
|
@@ -357,6 +357,97 @@ const system = createSystem({
|
|
|
357
357
|
});
|
|
358
358
|
```
|
|
359
359
|
|
|
360
|
+
## 20. Hand-Rolled External Subscriptions Inside React/`useEffect`
|
|
361
|
+
|
|
362
|
+
When wrapping an external event stream (Supabase realtime, WebSocket, polling timer, browser listener) into a Directive system, do NOT write a React `useEffect` that owns the subscription and dispatches `sys.events.X()` from the callback — declare a `source` on the module instead. The runtime owns the mount / unmount lifecycle and observability. The component collapses to fact reads.
|
|
363
|
+
|
|
364
|
+
```typescript
|
|
365
|
+
// WRONG – subscription lives in component code; module never knows about it.
|
|
366
|
+
// On every remount you get duplicate channels; on unmount you can leak.
|
|
367
|
+
function Game({ system, gameId }: Props) {
|
|
368
|
+
useEffect(() => {
|
|
369
|
+
const ch = supabase
|
|
370
|
+
.channel(`game:${gameId}`)
|
|
371
|
+
.on('postgres_changes', { event: 'UPDATE', table: 'games' }, (payload) => {
|
|
372
|
+
system.events.realtimeUpdate({ snapshot: mapRow(payload.new) });
|
|
373
|
+
})
|
|
374
|
+
.subscribe();
|
|
375
|
+
return () => { supabase.removeChannel(ch); };
|
|
376
|
+
}, [gameId]);
|
|
377
|
+
return <Board snapshot={useFact(system, 'snapshot')} />;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// CORRECT – declare a `source` on the module. The runtime owns the
|
|
381
|
+
// mount / unmount lifecycle and observability. The component collapses
|
|
382
|
+
// to fact reads.
|
|
383
|
+
const gameModule = createModule('game', {
|
|
384
|
+
schema: {
|
|
385
|
+
facts: { snapshot: t.object<GameSnapshot>().nullable() },
|
|
386
|
+
events: { realtimeUpdate: { snapshot: t.object<GameSnapshot>() } },
|
|
387
|
+
},
|
|
388
|
+
sources: {
|
|
389
|
+
realtime: {
|
|
390
|
+
attach: (publish) => {
|
|
391
|
+
const ch = supabase
|
|
392
|
+
.channel(`game:${gameId}`)
|
|
393
|
+
.on('postgres_changes', { event: 'UPDATE', table: 'games' },
|
|
394
|
+
(p) => publish('realtimeUpdate', { snapshot: mapRow(p.new) }))
|
|
395
|
+
.subscribe();
|
|
396
|
+
return () => { supabase.removeChannel(ch); };
|
|
397
|
+
},
|
|
398
|
+
},
|
|
399
|
+
},
|
|
400
|
+
on: { realtimeUpdate: (f, p) => { f.snapshot = p.snapshot; } },
|
|
401
|
+
});
|
|
402
|
+
```
|
|
403
|
+
|
|
404
|
+
**Why:** the hook-as-bridge pattern duplicates lifecycle code at every call site. Cleanup correctness drifts. `system.observe()` can't see the subscription. With a `source`, the engine attaches at `start()`, detaches at `stop()`, isolates failures, and emits `source.attach` / `.publish` / `.detach` / `.error` observation events for plugins.
|
|
405
|
+
|
|
406
|
+
**Don't subscribe in both an effect AND a source on the same channel** — the effect re-runs on fact changes, the source mounts once, you'll get 2× messages with silent duplicates. Pick one.
|
|
407
|
+
|
|
408
|
+
See [`sources.md`](./sources.md) for the full decision tree, recipes (Supabase / WebSocket / browser events / polling), and the typed-publish factory pattern.
|
|
409
|
+
|
|
410
|
+
## 21. Abbreviating Type Names (`*Def` instead of `*Definition`)
|
|
411
|
+
|
|
412
|
+
```typescript
|
|
413
|
+
// WRONG – `Def` is short for `Definition`. Same anti-pattern as `ctx`
|
|
414
|
+
// → `context` (entry #5). Source code reads better with spelled-out
|
|
415
|
+
// names; the minifier handles the bytes either way.
|
|
416
|
+
import type { ModuleDef, SourceDef, ResolverDef } from "@directive-run/core";
|
|
417
|
+
|
|
418
|
+
// CORRECT – use the spelled-out aliases. `*Def` stays canonical in 1.x
|
|
419
|
+
// for back-compat; consumers can migrate today.
|
|
420
|
+
import type {
|
|
421
|
+
ModuleDefinition,
|
|
422
|
+
SourceDefinition,
|
|
423
|
+
ResolverDefinition,
|
|
424
|
+
} from "@directive-run/core";
|
|
425
|
+
```
|
|
426
|
+
|
|
427
|
+
The `*Definition` aliases ship in 1.x via `export type { X as XDefinition }`
|
|
428
|
+
in `packages/core/src/core/types/index.ts`, so generic forwarding +
|
|
429
|
+
TS inference rules (mapped types, conditional distribution, tagged-
|
|
430
|
+
union discrimination, barrel re-exports) match the canonical `*Def`
|
|
431
|
+
form bit-for-bit. **2.0 swaps:** `*Definition` becomes canonical and
|
|
432
|
+
`*Def` becomes the deprecated alias. Start writing `*Definition` in
|
|
433
|
+
new code today so the 2.0 migration is a no-op.
|
|
434
|
+
|
|
435
|
+
Per RFC 0006, this applies to:
|
|
436
|
+
|
|
437
|
+
- `ModuleDef` → `ModuleDefinition`
|
|
438
|
+
- `ConstraintDef` / `ConstraintsDef` → `ConstraintDefinition` / `ConstraintsDefinition`
|
|
439
|
+
- `ResolverDef` / `ResolversDef` → `ResolverDefinition` / `ResolversDefinition`
|
|
440
|
+
- `DerivationDef` / `DerivationsDef` → `DerivationDefinition` / `DerivationsDefinition`
|
|
441
|
+
- `EffectDef` / `EffectsDef` → `EffectDefinition` / `EffectsDefinition`
|
|
442
|
+
- `EventsDef` → `EventsDefinition`
|
|
443
|
+
- `SourceDef` / `SourcesDef` → `SourceDefinition` / `SourcesDefinition`
|
|
444
|
+
- `SourcePublish` → `SourcePublishFn`, `SourceUnsubscribe` → `SourceUnsubscribeFn`
|
|
445
|
+
- Plus all `Typed*Def`, `CrossModule*Def`, `Dynamic*Def` variants.
|
|
446
|
+
|
|
447
|
+
`EffectCleanup`, `MetaAccessor`, `EventsAccessor`, `DeriveAccessor`,
|
|
448
|
+
`Snapshot` are explicitly kept as-is (each has reasoning recorded in
|
|
449
|
+
RFC 0006).
|
|
450
|
+
|
|
360
451
|
## Quick Reference Checklist
|
|
361
452
|
|
|
362
453
|
Before generating any Directive code, verify:
|
|
@@ -371,10 +462,12 @@ Before generating any Directive code, verify:
|
|
|
371
462
|
8. Multi-module uses `facts.self.*` for own facts
|
|
372
463
|
9. Imports from `@directive-run/core`, not deep paths
|
|
373
464
|
10. `await system.settle()` after `system.start()`
|
|
465
|
+
11. External event subscriptions live in `sources:`, not in `useEffect` or `onMount`
|
|
374
466
|
|
|
375
467
|
## See also
|
|
376
468
|
|
|
377
469
|
- [`naming.md`](./naming.md) — the strict canonical-term rules AND the alias map for cross-paradigm searches
|
|
470
|
+
- [`sources.md`](./sources.md) — the source primitive (the right answer for #20)
|
|
378
471
|
- [`constraints.md`](./constraints.md) — the constraint shape these anti-patterns reference (`facts` not in scope inside static `require:`, etc.)
|
|
379
472
|
- [`resolvers.md`](./resolvers.md) — the resolver shape these anti-patterns reference (return `void`, mutate `context.facts`, `(req, context)` not `(req, ctx)`)
|
|
380
473
|
- [`schema-types.md`](./schema-types.md) — the `t.*()` builders that exist and the hallucinated ones (`t.map`, `t.set`, `t.promise`, `t.date`) that don't
|
package/core/core-patterns.md
CHANGED
|
@@ -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
|
package/core/naming.md
CHANGED
|
@@ -16,7 +16,8 @@ This file is both the rules and the bridge. Search for the term you already know
|
|
|
16
16
|
| **thunk**, **saga**, **effect**, **middleware**, **listener** | Redux Toolkit, redux-saga, redux-observable, NgRx | **resolvers** | Async logic that mutates state in response to a requirement. Same role as a thunk; declared once, run by the runtime. |
|
|
17
17
|
| **rule**, **condition**, **guard**, **trigger**, **policy** | XState guards, Cerbos, OPA, business-rule engines | **constraints** | Declarative "when X is true, Y must happen." Constraints emit requirements; resolvers fulfill them. |
|
|
18
18
|
| **request**, **query**, **side-effect spec** | React Query / TanStack Query queries, Apollo queries | **requirements** | The object a constraint emits. The runtime dedupes them, dispatches to resolvers, tracks status. |
|
|
19
|
-
| **subscription**, **listener**, **reaction**, **watcher** | Zustand subscribe, MobX reactions, Redux subscriptions | **effects** |
|
|
19
|
+
| **subscription**, **listener**, **reaction**, **watcher** | Zustand subscribe, MobX reactions, Redux subscriptions | **effects** (for fact-change reactions) or **sources** (for external event streams) | Reacting to facts → **effects**; subscribing to an external stream that publishes INTO facts → **sources**. Both replace ad-hoc `subscribe()`/`useEffect` patterns. |
|
|
20
|
+
| **observable**, **subject**, **event source**, **WebSocket/SSE subscription** | RxJS, DOM `addEventListener`, EventSource, Supabase Realtime | **sources** | The inbound dual of effects. Mount-once external event stream the runtime owns. Declared on a module; auto-detached on `system.stop()`. |
|
|
20
21
|
| **slice**, **feature**, **domain**, **bounded context** | Redux Toolkit slices, NgRx feature modules | **modules** | Encapsulates facts + derivations + constraints + resolvers + events for one bounded slice of the system. |
|
|
21
22
|
| **store**, **container**, **context**, **app state** | Redux Store, Pinia, React Context, NgRx Store | **system** | The runtime that wires modules together, evaluates constraints, dispatches resolvers, and exposes observation. |
|
|
22
23
|
| **state machine**, **statechart**, **xstate machine** | XState | (closest: **module** + **constraints**) | Directive isn't a state machine — but if your XState `states` model business rules (not UI flow), constraints + facts express the same logic declaratively with no transition functions. |
|
|
@@ -54,6 +55,10 @@ Tagged union `{ type: "FETCH_USER", … }`. Constraints emit them; the runtime d
|
|
|
54
55
|
|
|
55
56
|
Declared via `effects: { name: { run: (facts, prev) => … } }`. Run on relevant fact changes. The auto-tracking dependency model means no `deps` array unless you specifically need a partial dep set.
|
|
56
57
|
|
|
58
|
+
### `sources` — typed external event subscriptions
|
|
59
|
+
|
|
60
|
+
The inbound dual of effects. Declared via `sources: { name: { attach: (publish) => cleanup } }`. The runtime mounts each source once at `system.start()` and unmounts at `system.stop()`. Inside `attach`, the source subscribes to an external event stream (WebSocket, Supabase realtime, browser events, MCP server lifecycle) and calls `publish(eventName, payload)` to dispatch into the system's event queue. Sources are the canonical replacement for `useEffect(() => subscribe(); return unsubscribe)` patterns. See [`sources.md`](./sources.md).
|
|
61
|
+
|
|
57
62
|
### `events` — typed mutation entry points
|
|
58
63
|
|
|
59
64
|
Declared via `events: { name: (facts, payload) => … }`. Called as `system.events.name(payload)`. The typed events accessor is the canonical way to dispatch — `system.dispatch({type, ...payload})` works but the typed accessor carries autocomplete.
|
package/core/sources.md
ADDED
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
# Sources
|
|
2
|
+
|
|
3
|
+
> Covers `@directive-run/core` — `source` primitive: typed external event sources, the inbound dual of effects. Use for Supabase realtime channels, WebSocket message streams, polling timers, browser event listeners — any inbound external event that maps into the module's own event dispatch surface.
|
|
4
|
+
|
|
5
|
+
Sources sit on the same lifecycle plane as effects but mount once per system instance instead of on every fact change. `attach(publish)` runs at `system.start()`; the returned unsubscribe runs at `system.stop()`.
|
|
6
|
+
|
|
7
|
+
## When to use `source` vs `effect`
|
|
8
|
+
|
|
9
|
+
| Use a **source** when... | Use an **effect** when... |
|
|
10
|
+
|---|---|
|
|
11
|
+
| You're subscribing to an external event stream (WebSocket, Supabase realtime, browser `addEventListener`, polling timer) | You're updating an external system based on fact changes (logging, DOM mutation, analytics ping) |
|
|
12
|
+
| The subscription is **mount-once** for the system's lifetime | You need to re-run when a fact changes (`deps` or `on` predicate) |
|
|
13
|
+
| You want to publish events INTO the system | You only need to react to fact changes |
|
|
14
|
+
| You don't read facts inside the handler | You read facts (`facts.someValue`) inside `run` |
|
|
15
|
+
|
|
16
|
+
If you're hand-rolling `useEffect(() => { const ch = supabase.channel(...).subscribe(); return () => ch.unsubscribe(); }, [])` in React and dispatching `sys.events.X(payload)` from the callback — that's the signature pattern sources replace. Move the subscription into the module declaration; the React hook collapses to `useFact` reads.
|
|
17
|
+
|
|
18
|
+
## Decision Tree: "How should this subscription work?"
|
|
19
|
+
|
|
20
|
+
```
|
|
21
|
+
Do I need to subscribe to an external event stream?
|
|
22
|
+
├── Yes
|
|
23
|
+
│ │
|
|
24
|
+
│ ├─ Does the subscription depend on a fact (e.g. "subscribe when status = active")?
|
|
25
|
+
│ │ ├── Yes → Use `effect` with `deps` or `on` predicate. Effects re-run when facts change.
|
|
26
|
+
│ │ └── No → Use `source`. Mount once at system.start(); tear down at system.stop().
|
|
27
|
+
│ │
|
|
28
|
+
│ ├─ Does the subscription need to read facts inside the callback?
|
|
29
|
+
│ │ ├── Yes → Consider hoisting the fact read up + using `effect`. Sources never read facts.
|
|
30
|
+
│ │ └── No → Use `source`. Pass any static config via closure.
|
|
31
|
+
│ │
|
|
32
|
+
│ └─ Do I need to publish events INTO the system from external input?
|
|
33
|
+
│ └── Yes → Use `source`. The `publish` callback dispatches into the same queue as `system.events.X()`.
|
|
34
|
+
│
|
|
35
|
+
└── No → You probably want an effect, resolver, or event handler instead.
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Basic Source
|
|
39
|
+
|
|
40
|
+
```typescript
|
|
41
|
+
import { createModule, t } from '@directive-run/core';
|
|
42
|
+
|
|
43
|
+
const counter = createModule('counter', {
|
|
44
|
+
schema: {
|
|
45
|
+
facts: { count: t.number() },
|
|
46
|
+
events: { TICK: { delta: t.number() } },
|
|
47
|
+
},
|
|
48
|
+
init: (f) => { f.count = 0; },
|
|
49
|
+
events: {
|
|
50
|
+
TICK: (f, payload) => { f.count = f.count + payload.delta; },
|
|
51
|
+
},
|
|
52
|
+
sources: {
|
|
53
|
+
heartbeat: {
|
|
54
|
+
attach: (publish) => {
|
|
55
|
+
// Subscribe to the external source. The `publish` callback dispatches
|
|
56
|
+
// into the system's event queue (same path as system.events.X()).
|
|
57
|
+
const id = setInterval(() => publish('TICK', { delta: 1 }), 1000);
|
|
58
|
+
// ALWAYS return a cleanup function. The engine calls it at system.stop().
|
|
59
|
+
return () => clearInterval(id);
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Supabase realtime channel
|
|
67
|
+
|
|
68
|
+
```typescript
|
|
69
|
+
import { createModule, t } from '@directive-run/core';
|
|
70
|
+
import { createClient } from '@supabase/supabase-js';
|
|
71
|
+
|
|
72
|
+
interface GameUpdateModuleDeps {
|
|
73
|
+
readonly gameId: string;
|
|
74
|
+
readonly subscribeToGameUpdates: (
|
|
75
|
+
onUpdate: (snapshot: GameSnapshot) => void,
|
|
76
|
+
) => () => void;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function createGameUpdateModule(deps: GameUpdateModuleDeps) {
|
|
80
|
+
return createModule('gameUpdate', {
|
|
81
|
+
schema: {
|
|
82
|
+
facts: { snapshot: t.object<GameSnapshot>().nullable() },
|
|
83
|
+
events: { REALTIME_UPDATE: { snapshot: t.object<GameSnapshot>() } },
|
|
84
|
+
},
|
|
85
|
+
init: (f) => { f.snapshot = null; },
|
|
86
|
+
events: {
|
|
87
|
+
REALTIME_UPDATE: (f, payload) => { f.snapshot = payload.snapshot; },
|
|
88
|
+
},
|
|
89
|
+
sources: {
|
|
90
|
+
// apps/web supplies the Supabase channel via deps. The module
|
|
91
|
+
// declaration stays pure — the source just calls deps and threads
|
|
92
|
+
// the publish callback through.
|
|
93
|
+
gameUpdates: {
|
|
94
|
+
attach: (publish) =>
|
|
95
|
+
deps.subscribeToGameUpdates((snapshot) =>
|
|
96
|
+
publish('REALTIME_UPDATE', { snapshot }),
|
|
97
|
+
),
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// In your bootstrap (e.g. apps/web):
|
|
104
|
+
const supabase = createClient(/* ... */);
|
|
105
|
+
const module = createGameUpdateModule({
|
|
106
|
+
gameId: 'g-123',
|
|
107
|
+
subscribeToGameUpdates: (onUpdate) => {
|
|
108
|
+
const channel = supabase
|
|
109
|
+
.channel(`game:g-123`)
|
|
110
|
+
.on('postgres_changes', { event: 'UPDATE', table: 'games', filter: 'id=eq.g-123' },
|
|
111
|
+
(payload) => onUpdate(mapRow(payload.new)))
|
|
112
|
+
.subscribe();
|
|
113
|
+
return () => { supabase.removeChannel(channel); };
|
|
114
|
+
},
|
|
115
|
+
});
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
## Browser event listener
|
|
119
|
+
|
|
120
|
+
```typescript
|
|
121
|
+
sources: {
|
|
122
|
+
online: {
|
|
123
|
+
attach: (publish) => {
|
|
124
|
+
const onOnline = () => publish('CONNECTIVITY_CHANGED', { online: true });
|
|
125
|
+
const onOffline = () => publish('CONNECTIVITY_CHANGED', { online: false });
|
|
126
|
+
window.addEventListener('online', onOnline);
|
|
127
|
+
window.addEventListener('offline', onOffline);
|
|
128
|
+
// Cleanup must remove BOTH listeners.
|
|
129
|
+
return () => {
|
|
130
|
+
window.removeEventListener('online', onOnline);
|
|
131
|
+
window.removeEventListener('offline', onOffline);
|
|
132
|
+
};
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
}
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
## Lifecycle
|
|
139
|
+
|
|
140
|
+
| Phase | What happens |
|
|
141
|
+
|---|---|
|
|
142
|
+
| `createSystem({ module })` | Sources are recorded in the manager but NOT attached yet |
|
|
143
|
+
| `system.start()` | Each source's `attach(publish)` runs in registration order. Errors are isolated — one failed source does not block others |
|
|
144
|
+
| `system.registerModule(newModule)` (system already running) | New module's sources attach IMMEDIATELY (no need to restart) |
|
|
145
|
+
| `system.stop()` | Each source's returned unsubscribe runs in **reverse** registration order |
|
|
146
|
+
| `system.destroy()` | Calls `stop()` first, so sources detach. After destroy, any stale publish callback the source closed over no-ops (the engine guards against post-destroy dispatch). |
|
|
147
|
+
| `system.start()` again (after stop) | Sources re-attach cleanly. The manager handles the start → stop → start lifecycle without leaking subscriptions. |
|
|
148
|
+
|
|
149
|
+
## Typed publish
|
|
150
|
+
|
|
151
|
+
The raw `publish: (event: string, payload?: unknown) => void` deliberately uses unchecked string event names — sources don't see the module schema and shouldn't be type-coupled to it. If you want compile-time event-name + payload safety, wrap `publish` once per source:
|
|
152
|
+
|
|
153
|
+
```typescript
|
|
154
|
+
import type { SourcePublish } from '@directive-run/core';
|
|
155
|
+
|
|
156
|
+
// Build a typed publisher up-front. Match the module's `events:` keys
|
|
157
|
+
// + payload shapes exactly. The TS compiler now catches typos.
|
|
158
|
+
function createPublisher(publish: SourcePublish) {
|
|
159
|
+
return {
|
|
160
|
+
TICK: (delta: number) => publish('TICK', { delta }),
|
|
161
|
+
HEARTBEAT: () => publish('HEARTBEAT'),
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
sources: {
|
|
166
|
+
ticker: {
|
|
167
|
+
attach: (publish) => {
|
|
168
|
+
const p = createPublisher(publish);
|
|
169
|
+
const id = setInterval(() => p.TICK(1), 1000); // ← typed
|
|
170
|
+
return () => clearInterval(id);
|
|
171
|
+
},
|
|
172
|
+
},
|
|
173
|
+
}
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
The wrapper is purely a DX convention; the runtime path is unchanged. Recommended for any non-trivial source.
|
|
177
|
+
|
|
178
|
+
## Observation
|
|
179
|
+
|
|
180
|
+
Source lifecycle is fully observable. Subscribe via `system.observe()`:
|
|
181
|
+
|
|
182
|
+
```typescript
|
|
183
|
+
const unsub = system.observe((event) => {
|
|
184
|
+
switch (event.type) {
|
|
185
|
+
case 'source.attach': console.log(`${event.moduleId}::${event.id} attached`); break;
|
|
186
|
+
case 'source.publish': console.log(`${event.id} → ${event.eventName}`); break;
|
|
187
|
+
case 'source.detach': console.log(`${event.moduleId}::${event.id} detached`); break;
|
|
188
|
+
case 'source.error':
|
|
189
|
+
console.error(`${event.moduleId}::${event.id} ${event.phase} threw:`, event.error);
|
|
190
|
+
break;
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
`system.inspect().sources` lists the declared sources + `system.inspect().attachedSourceCount` reports how many are currently bound.
|
|
196
|
+
|
|
197
|
+
## Error handling — runtime errors via `reportError`
|
|
198
|
+
|
|
199
|
+
Per RFC 0008, `attach` receives an optional second argument: a
|
|
200
|
+
`reportError` callback. Authors route mid-flight errors from the
|
|
201
|
+
underlying stream (WebSocket disconnect, Supabase channel goes stale,
|
|
202
|
+
polling fetch throws) through this callback instead of inventing
|
|
203
|
+
magic event names like `STREAM_ERROR`. The manager fires
|
|
204
|
+
`onSourceError` with `phase: "runtime"` — distinct from `"attach"`
|
|
205
|
+
(setup failed) and `"cleanup"` (unsubscribe threw) — so the audit
|
|
206
|
+
ledger, logging plugin, and `inspect().sources[i].lastError`
|
|
207
|
+
attribute mid-flight failures correctly.
|
|
208
|
+
|
|
209
|
+
```typescript
|
|
210
|
+
sources: {
|
|
211
|
+
socket: {
|
|
212
|
+
attach: (publish, reportError) => {
|
|
213
|
+
const sock = new WebSocket(url);
|
|
214
|
+
sock.addEventListener("message", (e) =>
|
|
215
|
+
publish("MSG", JSON.parse(e.data)),
|
|
216
|
+
);
|
|
217
|
+
sock.addEventListener("error", () =>
|
|
218
|
+
reportError?.(new Error("WebSocket lost connection")),
|
|
219
|
+
);
|
|
220
|
+
return () => sock.close();
|
|
221
|
+
},
|
|
222
|
+
},
|
|
223
|
+
}
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
`reportError` is optional — short-lived transports that don't surface
|
|
227
|
+
mid-flight errors can ignore it. The callback type is exported as
|
|
228
|
+
`SourceReportError` from `@directive-run/core`. Error messages are
|
|
229
|
+
truncated at 256 chars at the manager boundary so authors who embed
|
|
230
|
+
payloads in error messages get a bounded leak surface.
|
|
231
|
+
|
|
232
|
+
## Backpressure — `coalesce: "lastWriteWins"`
|
|
233
|
+
|
|
234
|
+
Per RFC 0007, high-frequency sources (cursor moves, sensor telemetry,
|
|
235
|
+
price ticks, Supabase channel storms) declare `coalesce:
|
|
236
|
+
"lastWriteWins"` so the manager debounces same-event-name publishes
|
|
237
|
+
within one microtask. Per-event-name keying means a `priceTick` storm
|
|
238
|
+
coalesces while a one-shot `connected` event in the same tick still
|
|
239
|
+
dispatches.
|
|
240
|
+
|
|
241
|
+
```typescript
|
|
242
|
+
sources: {
|
|
243
|
+
ticker: {
|
|
244
|
+
attach: (publish) => {
|
|
245
|
+
const id = setInterval(() => publish("TICK", { v: liveValue() }), 4);
|
|
246
|
+
return () => clearInterval(id);
|
|
247
|
+
},
|
|
248
|
+
coalesce: "lastWriteWins", // 250 publishes/sec → 1 dispatch/microtask
|
|
249
|
+
},
|
|
250
|
+
}
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
Coalesce-dropped publishes bump `dropCount` + record
|
|
254
|
+
`lastDropReason: "coalesced"` on `inspect().sources` so operators
|
|
255
|
+
verify the debouncing is firing on the right sources. The strategy
|
|
256
|
+
is per-source (uniform across event names from that source) —
|
|
257
|
+
splitting strategies per event name requires two source declarations.
|
|
258
|
+
|
|
259
|
+
## Async-aware teardown — `system.stopAsync()` + DO `onEvict`
|
|
260
|
+
|
|
261
|
+
Per RFC 0009, sources with async unsubscribes (Supabase realtime's
|
|
262
|
+
`channel.unsubscribe()` returns a Promise; Cloudflare DO storage
|
|
263
|
+
flushes return Promises) work with both sync and async teardown:
|
|
264
|
+
|
|
265
|
+
- `system.stop()` (sync) — fires unsubscribes; awaits each one's
|
|
266
|
+
return synchronously (Promise returns are fire-and-forget).
|
|
267
|
+
- `system.stopAsync()` (RFC 0009) — awaits each Promise-returning
|
|
268
|
+
unsubscribe. Use when the caller needs teardown to actually
|
|
269
|
+
complete (e.g. the external broker must drop the subscription
|
|
270
|
+
before the next call).
|
|
271
|
+
- `system.evict(deadline?)` — Cloudflare DO eviction: fires each
|
|
272
|
+
source's optional `onEvict()` in registration order, then
|
|
273
|
+
`destroyAsync()`. Optional deadline races teardown against a
|
|
274
|
+
wall-clock cutoff so the runtime can evict the isolate even if
|
|
275
|
+
some sources hang.
|
|
276
|
+
|
|
277
|
+
```typescript
|
|
278
|
+
sources: {
|
|
279
|
+
channel: {
|
|
280
|
+
attach: (publish) => {
|
|
281
|
+
const ch = supabase.channel("game").subscribe();
|
|
282
|
+
ch.on("postgres_changes", { event: "UPDATE" }, (p) =>
|
|
283
|
+
publish("ROW_UPDATED", p),
|
|
284
|
+
);
|
|
285
|
+
return () => ch.unsubscribe(); // returns a Promise
|
|
286
|
+
},
|
|
287
|
+
onEvict: async () => {
|
|
288
|
+
// Cloudflare DO hibernate signal: close the channel actively
|
|
289
|
+
// so the broker drops the subscription before the isolate dies.
|
|
290
|
+
await supabase.removeChannel(ch);
|
|
291
|
+
},
|
|
292
|
+
},
|
|
293
|
+
}
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
Inside a DO `alarm()` or `webSocketClose()` handler:
|
|
297
|
+
`await system.evict(/* deadline */ Date.now() + 5000)`.
|
|
298
|
+
|
|
299
|
+
## Common Patterns
|
|
300
|
+
|
|
301
|
+
### Pattern: Source supplies inbound; resolver supplies outbound
|
|
302
|
+
|
|
303
|
+
A Supabase realtime channel both INGESTS messages AND can SEND them back. The split is:
|
|
304
|
+
|
|
305
|
+
```typescript
|
|
306
|
+
// INGESTING — use a source. Mount once at system.start().
|
|
307
|
+
sources: {
|
|
308
|
+
inbound: {
|
|
309
|
+
attach: (publish) => channel.on('postgres_changes', ..., (p) => publish('UPDATE', p)).subscribe(),
|
|
310
|
+
},
|
|
311
|
+
},
|
|
312
|
+
// SENDING — use a resolver. Fires on a constraint trigger.
|
|
313
|
+
resolvers: {
|
|
314
|
+
sendMessage: {
|
|
315
|
+
requirement: 'SEND_MESSAGE',
|
|
316
|
+
resolve: async (req) => { await supabase.channel('chat').send(req); },
|
|
317
|
+
},
|
|
318
|
+
}
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
### Pattern: Roster + reconnect
|
|
322
|
+
|
|
323
|
+
A source for the channel + an effect keyed on `isReconnecting` for refresh logic:
|
|
324
|
+
|
|
325
|
+
```typescript
|
|
326
|
+
sources: {
|
|
327
|
+
channel: { attach: (publish) => connect(publish) },
|
|
328
|
+
},
|
|
329
|
+
effects: {
|
|
330
|
+
refreshOnReconnect: {
|
|
331
|
+
deps: ['isReconnecting'],
|
|
332
|
+
run: (facts) => { if (facts.isReconnecting) refetch(); },
|
|
333
|
+
},
|
|
334
|
+
}
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
### Anti-pattern: subscribe twice
|
|
338
|
+
|
|
339
|
+
Don't subscribe to the same channel inside BOTH an effect and a source. The effect re-runs on fact changes; the source mounts once. You'll get 2× messages with silent duplicates.
|
|
340
|
+
|
|
341
|
+
```typescript
|
|
342
|
+
// ❌ WRONG
|
|
343
|
+
sources: { channel: { attach: (p) => connect(p) } },
|
|
344
|
+
effects: {
|
|
345
|
+
reconnect: {
|
|
346
|
+
deps: ['userId'],
|
|
347
|
+
run: (f) => { connect(p); }, // ← second subscription!
|
|
348
|
+
},
|
|
349
|
+
},
|
|
350
|
+
|
|
351
|
+
// ✓ RIGHT — single source, key the resubscribe by re-registering the module
|
|
352
|
+
sources: { channel: { attach: (p) => connect(p) } },
|
|
353
|
+
// to change the channel, use system.registerModule() with a fresh module
|
|
354
|
+
// whose source attaches with new parameters.
|
|
355
|
+
```
|
|
356
|
+
|
|
357
|
+
### Anti-pattern: async `attach`
|
|
358
|
+
|
|
359
|
+
```typescript
|
|
360
|
+
// ❌ WRONG — attach is sync. A returned Promise is discarded; the engine
|
|
361
|
+
// has no cleanup function and any await result is unreachable.
|
|
362
|
+
sources: {
|
|
363
|
+
bad: {
|
|
364
|
+
attach: async (publish) => {
|
|
365
|
+
const token = await getAuthToken();
|
|
366
|
+
const ch = subscribe(token, publish);
|
|
367
|
+
return () => ch.unsubscribe(); // ← never registered as cleanup
|
|
368
|
+
},
|
|
369
|
+
},
|
|
370
|
+
},
|
|
371
|
+
|
|
372
|
+
// ✓ RIGHT — do async work inside the subscription's own internals.
|
|
373
|
+
sources: {
|
|
374
|
+
good: {
|
|
375
|
+
attach: (publish) => {
|
|
376
|
+
const ch = subscribe(publish); // sync subscribe stub
|
|
377
|
+
// The subscribe impl can await internally and start publishing once ready.
|
|
378
|
+
// The cleanup function is registered immediately.
|
|
379
|
+
return () => ch.unsubscribe();
|
|
380
|
+
},
|
|
381
|
+
},
|
|
382
|
+
},
|
|
383
|
+
```
|
|
384
|
+
|
|
385
|
+
### Anti-pattern: forget the cleanup function
|
|
386
|
+
|
|
387
|
+
```typescript
|
|
388
|
+
// ❌ WRONG — returns undefined. The engine logs an error + skips the source.
|
|
389
|
+
sources: {
|
|
390
|
+
bad: {
|
|
391
|
+
attach: (publish) => {
|
|
392
|
+
setInterval(() => publish('TICK'), 1000);
|
|
393
|
+
// ← missing return!
|
|
394
|
+
},
|
|
395
|
+
},
|
|
396
|
+
},
|
|
397
|
+
|
|
398
|
+
// ✓ RIGHT — always return a function, even if there's nothing to clean up.
|
|
399
|
+
sources: {
|
|
400
|
+
good: {
|
|
401
|
+
attach: (publish) => {
|
|
402
|
+
const id = setInterval(() => publish('TICK'), 1000);
|
|
403
|
+
return () => clearInterval(id);
|
|
404
|
+
},
|
|
405
|
+
},
|
|
406
|
+
// Or if the source genuinely has no teardown:
|
|
407
|
+
fireAndForget: {
|
|
408
|
+
attach: (publish) => {
|
|
409
|
+
publish('READY');
|
|
410
|
+
return () => undefined;
|
|
411
|
+
},
|
|
412
|
+
},
|
|
413
|
+
}
|
|
414
|
+
```
|
|
415
|
+
|
|
416
|
+
## Related
|
|
417
|
+
|
|
418
|
+
- `core-patterns.md` — where sources sit in the constraint-driven module model (decision tree).
|
|
419
|
+
- `multi-module.md` — declaring sources on multiple modules; collision rules.
|
|
420
|
+
- `system-api.md` — `system.start()` / `system.stop()` / `system.inspect()` / `system.observe()`.
|
|
421
|
+
- `anti-patterns.md` (#20) — "subscribe inside an effect / useEffect" anti-pattern; prefer sources.
|
|
422
|
+
- `naming.md` — canonical-term entry for `sources` + cross-paradigm aliases (RxJS Observable, DOM EventTarget, XState callback actor, Supabase realtime).
|
|
423
|
+
- [`../ai/ai-sources.md`](../ai/ai-sources.md) — AI integration: `runStream({ liveContext })`, MCP lifecycle as a source, the `@directive-run/sources/*` adapter subpaths.
|
|
424
|
+
- [`../ai/ai-security.md`](../ai/ai-security.md#sources-pii--closing-the-fact-injection-bypass) — `createFactPIIGuardrail`: closes the source → fact → agent-prompt PII bypass. Wire whenever sources publish into facts the agent reads.
|
package/core/system-api.md
CHANGED
|
@@ -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
|
-
//
|
|
246
|
-
// resolver.start/complete/error
|
|
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
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@directive-run/knowledge",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.18.0",
|
|
4
4
|
"description": "Knowledge files, examples, and validation for Directive — the constraint-driven TypeScript runtime.",
|
|
5
5
|
"license": "(MIT OR Apache-2.0)",
|
|
6
6
|
"author": "Jason Comes",
|
|
@@ -53,8 +53,8 @@
|
|
|
53
53
|
"tsx": "^4.19.2",
|
|
54
54
|
"typescript": "^5.7.2",
|
|
55
55
|
"vitest": "^3.0.0",
|
|
56
|
-
"@directive-run/core": "1.
|
|
57
|
-
"@directive-run/ai": "1.
|
|
56
|
+
"@directive-run/core": "1.18.0",
|
|
57
|
+
"@directive-run/ai": "1.18.0"
|
|
58
58
|
},
|
|
59
59
|
"scripts": {
|
|
60
60
|
"build": "tsx scripts/generate-api-skeleton.ts && tsx scripts/generate-sitemap.ts && tsx scripts/extract-examples.ts && tsup",
|
package/sitemap.md
CHANGED
|
@@ -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)
|