@ixo/oracle-runtime-workers 0.16.0 → 0.17.1

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.
Files changed (40) hide show
  1. package/README.md +3 -0
  2. package/package.json +5 -3
  3. package/src/core/budgeted-llm.ts +115 -6
  4. package/src/core/context-budget.ts +24 -26
  5. package/src/core/context-window.ts +39 -12
  6. package/src/core/domain-context/document.ts +100 -0
  7. package/src/core/domain-context/findings.ts +25 -0
  8. package/src/core/domain-context/index.ts +28 -0
  9. package/src/core/domain-context/integrity.ts +60 -0
  10. package/src/core/domain-context/lru.ts +63 -0
  11. package/src/core/domain-context/resolver.ts +663 -0
  12. package/src/core/domain-context/transport.ts +382 -0
  13. package/src/core/domain-context/turn-context.ts +443 -0
  14. package/src/core/domain-context/types.ts +119 -0
  15. package/src/core/domain-context/validator.ts +41 -0
  16. package/src/core/env.ts +60 -6
  17. package/src/core/index.ts +2 -0
  18. package/src/core/llm.ts +225 -4
  19. package/src/core/main-agent-types.ts +28 -0
  20. package/src/core/main-agent.ts +61 -4
  21. package/src/core/middlewares/tool-execution.ts +19 -2
  22. package/src/core/registries.ts +14 -3
  23. package/src/core/state.ts +2 -1
  24. package/src/core/sub-agent-fallback.ts +14 -2
  25. package/src/core/turn-budget.ts +19 -22
  26. package/src/do/contracts.ts +23 -2
  27. package/src/do/domain-context-store.ts +123 -0
  28. package/src/do/run-request.ts +6 -0
  29. package/src/do/sse-stream.ts +1 -1
  30. package/src/do/transcript.ts +9 -0
  31. package/src/do/turn-body.ts +2 -1
  32. package/src/do/turn-metadata.ts +13 -6
  33. package/src/do/user-oracle-do.ts +111 -42
  34. package/src/index.ts +16 -0
  35. package/src/llm/byo-adapter.ts +6 -0
  36. package/src/llm/byo-catalog.ts +7 -4
  37. package/src/llm/byo-client.ts +42 -7
  38. package/src/llm/provider-error.ts +21 -3
  39. package/src/llm/stream-liveness.ts +498 -0
  40. package/src/plugins/firecrawl/firecrawl-tools.ts +6 -0
@@ -0,0 +1,663 @@
1
+ /**
2
+ * Resolves and verifies one domain: IID anchor → bytes → CID check → strict
3
+ * UTF-8 → `domain.md` lint → identity and profile checks → optional capsule
4
+ * manifest inspection. One instance lives per user Durable Object and is
5
+ * shared by every session of that user, so its caches are what make a repeat
6
+ * turn cheap:
7
+ *
8
+ * - anchors per DID (TTL, at most 32), with in-flight deduplication so
9
+ * concurrent sessions share one Blocksync lookup;
10
+ * - verified public bytes per CID (LRU, 8 MiB);
11
+ * - decoded public text per CID (LRU, 4 Mi characters);
12
+ * - parsed indexes per anchor CID (lint report, brief JSON, capsule
13
+ * inspections; LRU, 16 entries and 4 Mi characters of index text), so a
14
+ * repeat turn re-parses nothing.
15
+ *
16
+ * Private bytes are not kept in the content caches: every private read goes
17
+ * back through the transport, which re-authorizes it. The parsed index of a
18
+ * private document (its text included) does stay in the parsed-index cache,
19
+ * keyed by CID, but it is only handed out again after a fresh authorized read
20
+ * of bytes that verify against that CID.
21
+ *
22
+ * Every finding list is bounded where it enters a snapshot (see
23
+ * `findings.ts`).
24
+ */
25
+ import type { DomainDocument } from '@ixo/domain.md/workers';
26
+ import { z } from 'zod';
27
+ import { estimateTokensApprox } from '../manifest';
28
+ import { briefBody, record } from './document';
29
+ import { boundFindings } from './findings';
30
+ import { decode, INDEX_MAX_BYTES, verifyBytes } from './integrity';
31
+ import { LruMap } from './lru';
32
+ import type { Logger } from '../../plugin-api/types';
33
+ import { NOOP_LOGGER } from '../utils';
34
+ import type {
35
+ DocumentRequest,
36
+ DomainAnchor,
37
+ DomainCapsuleInspection,
38
+ DomainSnapshot,
39
+ DomainTransport,
40
+ } from './types';
41
+ import { domainValidator } from './validator';
42
+
43
+ export const DEFAULT_ANCHOR_TTL_MS = 300_000;
44
+ /** A shared anchor lookup owns this timeout; one cancelled subscriber never cancels it. */
45
+ export const ANCHOR_LOOKUP_TIMEOUT_MS = 10_000;
46
+ /** `source` of an anchor taken from a durable run's pins. */
47
+ export const PINNED_ANCHOR_SOURCE = 'durable-run-pin';
48
+
49
+ const MAX_ANCHORS = 32;
50
+ const CONTENT_CACHE_BYTES = 8 * 1024 * 1024;
51
+ /** No single cached document may take more than a quarter of the content cache. */
52
+ const MAX_CACHED_DOCUMENT_BYTES = 2 * 1024 * 1024;
53
+ const MAX_CONTENT_ENTRIES = 256;
54
+ const TEXT_CACHE_CHARS = 4 * 1024 * 1024;
55
+ const MAX_PARSED_INDEXES = 16;
56
+ /** Index text the parsed-index cache holds in all (a parsed index keeps its raw text). */
57
+ export const PARSED_CACHE_CHARS = 4 * 1024 * 1024;
58
+ /** No single parsed index may take more than a quarter of that. */
59
+ const MAX_CACHED_PARSE_CHARS = PARSED_CACHE_CHARS / 4;
60
+ const MAX_CAPSULES_PER_INDEX = 4;
61
+ /** Most items any frontmatter list (at any depth) may hold before the index is refused unlinted. */
62
+ export const MAX_FRONTMATTER_ARRAY_ITEMS = 64;
63
+ /** Deepest nesting of objects and lists the frontmatter may have (the frontmatter mapping is depth 1). */
64
+ export const MAX_FRONTMATTER_DEPTH = 16;
65
+ /** Largest YAML frontmatter (UTF-8 bytes) linted. */
66
+ export const MAX_FRONTMATTER_BYTES = 64 * 1024;
67
+
68
+ /**
69
+ * True when `value` has a list of more than {@link MAX_FRONTMATTER_ARRAY_ITEMS}
70
+ * items, or nests deeper than {@link MAX_FRONTMATTER_DEPTH}, anywhere. The
71
+ * walk stops at the first violation and never descends past the depth bound.
72
+ */
73
+ export function exceedsFrontmatterShape(value: unknown, depth = 1): boolean {
74
+ if (typeof value !== 'object' || value === null) return false;
75
+ if (depth > MAX_FRONTMATTER_DEPTH) return true;
76
+ const children: unknown[] = Array.isArray(value)
77
+ ? value
78
+ : Object.values(value);
79
+ if (Array.isArray(value) && value.length > MAX_FRONTMATTER_ARRAY_ITEMS)
80
+ return true;
81
+ return children.some((child) => exceedsFrontmatterShape(child, depth + 1));
82
+ }
83
+ /** The frontmatter fence, as `@ixo/domain.md` matches it. */
84
+ const FRONTMATTER = /^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/;
85
+ /**
86
+ * `externalChecksRequired` codes the resolver performs itself, so they are
87
+ * not reported as unresolved: the index bytes are verified against the
88
+ * anchored CID before they are parsed, and every linked document is verified
89
+ * against its declared CID before it is used.
90
+ */
91
+ const CHECKS_PERFORMED = new Set(['cid-verification']);
92
+
93
+ const CAPSULE_CONTRACT = 'ixo.earth/oracle-capsule/v0alpha1';
94
+ const DID_PATTERN = /^did:ixo:[^\s#/?]+(?::[^\s#/?]+)*$/;
95
+ const FINDING_PATTERN = /^[a-z][a-z0-9-]{1,70}$/;
96
+ const INVALID_PATTERN = /mismatch|invalid|unsupported|unanchored|utf8/;
97
+
98
+ /** A capsule result plus the findings it adds to its snapshot. */
99
+ interface CapsuleResult {
100
+ capsule: DomainCapsuleInspection;
101
+ findings: string[];
102
+ }
103
+
104
+ /** Everything derived from one index's bytes, keyed by its CID. */
105
+ interface ParsedIndex {
106
+ /** Set when the bytes cannot be decoded at all. */
107
+ error?: string;
108
+ ok: boolean;
109
+ findings: string[];
110
+ unresolvedChecks: string[];
111
+ document?: DomainDocument;
112
+ brief?: { json: string; tokens: number };
113
+ /** Keyed by `<did>\n<manifest cid>`; only deterministic outcomes are kept. */
114
+ capsules: LruMap<string, CapsuleResult>;
115
+ }
116
+
117
+ const capsuleBinding = z.object({
118
+ uri: z.string(),
119
+ cid: z.string(),
120
+ sha256: z.string(),
121
+ version: z.string(),
122
+ schema: z.string(),
123
+ media_type: z.literal('application/vnd.ixo.oracle-capsule+json'),
124
+ });
125
+
126
+ /**
127
+ * What a parsed index weighs in the parsed-index cache: the characters of the
128
+ * index text it keeps. An invalid parse keeps no document, so it weighs
129
+ * nothing beyond its (bounded) finding codes.
130
+ */
131
+ function parsedWeight(entry: ParsedIndex): number {
132
+ return entry.document?.raw.length ?? 0;
133
+ }
134
+
135
+ /** The stable code of an error, or `fallback` when its message is not one. */
136
+ export function errorCode(error: unknown, fallback: string): string {
137
+ return error instanceof Error && FINDING_PATTERN.test(error.message)
138
+ ? error.message
139
+ : fallback;
140
+ }
141
+
142
+ function waitFor<T>(pending: Promise<T>, signal?: AbortSignal): Promise<T> {
143
+ if (!signal) return pending;
144
+ signal.throwIfAborted();
145
+ return new Promise<T>((resolve, reject) => {
146
+ const abort = () => {
147
+ reject(
148
+ signal.reason instanceof Error
149
+ ? signal.reason
150
+ : new Error('domain-context-cancelled'),
151
+ );
152
+ };
153
+ signal.addEventListener('abort', abort, { once: true });
154
+ pending.then(
155
+ (value) => {
156
+ signal.removeEventListener('abort', abort);
157
+ resolve(value);
158
+ },
159
+ (error: unknown) => {
160
+ signal.removeEventListener('abort', abort);
161
+ reject(
162
+ error instanceof Error
163
+ ? error
164
+ : new Error('domain-context-resolution-failed'),
165
+ );
166
+ },
167
+ );
168
+ });
169
+ }
170
+
171
+ export class DomainContextResolver {
172
+ private readonly now: () => number;
173
+ private readonly anchors = new LruMap<
174
+ string,
175
+ { anchor: DomainAnchor | null; checked: number }
176
+ >({ maxEntries: MAX_ANCHORS });
177
+ private readonly pending = new Map<string, Promise<DomainAnchor | null>>();
178
+ private readonly content = new LruMap<string, Uint8Array>({
179
+ maxEntries: MAX_CONTENT_ENTRIES,
180
+ maxWeight: CONTENT_CACHE_BYTES,
181
+ weigh: (bytes) => bytes.length,
182
+ });
183
+ private readonly text = new LruMap<string, string>({
184
+ maxEntries: MAX_CONTENT_ENTRIES,
185
+ maxWeight: TEXT_CACHE_CHARS,
186
+ weigh: (text) => text.length,
187
+ });
188
+ private readonly parsed = new LruMap<string, ParsedIndex>({
189
+ maxEntries: MAX_PARSED_INDEXES,
190
+ maxWeight: PARSED_CACHE_CHARS,
191
+ weigh: parsedWeight,
192
+ });
193
+
194
+ private readonly logger: Logger;
195
+
196
+ constructor(opts: { now?: () => number; logger?: Logger } = {}) {
197
+ this.now = opts.now ?? Date.now;
198
+ this.logger = opts.logger ?? NOOP_LOGGER;
199
+ }
200
+
201
+ /**
202
+ * Forget the cached anchor of `did` so the next load asks Blocksync again.
203
+ * A lookup already in flight still answers its current subscribers but can
204
+ * no longer repopulate the cache; the last verified anchor stays available
205
+ * as a labelled stale fallback.
206
+ */
207
+ invalidate(did: string): void {
208
+ const old = this.anchors.get(did);
209
+ if (old) this.anchors.set(did, { ...old, checked: -Infinity });
210
+ this.pending.delete(did);
211
+ }
212
+
213
+ async load(
214
+ did: string,
215
+ transport: DomainTransport,
216
+ opts: {
217
+ ttlMs?: number;
218
+ signal?: AbortSignal;
219
+ pinned?: { cid: string; uri: string; private: boolean };
220
+ } = {},
221
+ ): Promise<DomainSnapshot> {
222
+ const snapshot = await this.loadSnapshot(did, transport, opts);
223
+ snapshot.findings = boundFindings(snapshot.findings);
224
+ return snapshot;
225
+ }
226
+
227
+ private async loadSnapshot(
228
+ did: string,
229
+ transport: DomainTransport,
230
+ opts: {
231
+ ttlMs?: number;
232
+ signal?: AbortSignal;
233
+ pinned?: { cid: string; uri: string; private: boolean };
234
+ },
235
+ ): Promise<DomainSnapshot> {
236
+ const { signal, pinned } = opts;
237
+ const prefix: string[] = [];
238
+ if (pinned) {
239
+ const anchor: DomainAnchor = {
240
+ did,
241
+ ...pinned,
242
+ resolvedAt: this.now(),
243
+ source: PINNED_ANCHOR_SOURCE,
244
+ };
245
+ try {
246
+ signal?.throwIfAborted();
247
+ if (!DID_PATTERN.test(did)) throw new Error('invalid-anchor-did');
248
+ const bytes = await this.bytes(
249
+ { ...anchor, maxBytes: INDEX_MAX_BYTES },
250
+ transport,
251
+ signal,
252
+ );
253
+ return await this.fromBytes(
254
+ { did, status: 'unavailable', stale: false, anchor, findings: [] },
255
+ bytes,
256
+ transport,
257
+ signal,
258
+ );
259
+ } catch (error) {
260
+ signal?.throwIfAborted();
261
+ if (errorCode(error, '') === 'invalid-anchor-did')
262
+ return {
263
+ did,
264
+ status: 'invalid',
265
+ stale: false,
266
+ findings: ['invalid-anchor-did'],
267
+ };
268
+ prefix.push('pinned-revision-unavailable');
269
+ }
270
+ }
271
+ const snapshot = await this.resolveAndLoad(
272
+ did,
273
+ transport,
274
+ opts.ttlMs ?? DEFAULT_ANCHOR_TTL_MS,
275
+ signal,
276
+ );
277
+ snapshot.findings.unshift(...prefix);
278
+ return snapshot;
279
+ }
280
+
281
+ /**
282
+ * Bytes for `request`, verified against its CID. Public bytes come from the
283
+ * content cache when present; private ones (or a transport that reports the
284
+ * request as authenticated) are read every time.
285
+ */
286
+ async bytes(
287
+ request: DocumentRequest,
288
+ transport: DomainTransport,
289
+ signal?: AbortSignal,
290
+ ): Promise<Uint8Array> {
291
+ signal?.throwIfAborted();
292
+ const cacheable = this.cacheable(request, transport);
293
+ const cached = cacheable ? this.content.get(request.cid) : undefined;
294
+ if (cached) {
295
+ if (cached.length > request.maxBytes)
296
+ throw new Error('document-too-large');
297
+ return cached;
298
+ }
299
+ const bytes = await transport.read(request, signal);
300
+ if (bytes.length > request.maxBytes) throw new Error('document-too-large');
301
+ await verifyBytes(bytes, request.cid);
302
+ if (cacheable && bytes.length <= MAX_CACHED_DOCUMENT_BYTES)
303
+ this.content.set(request.cid, bytes);
304
+ return bytes;
305
+ }
306
+
307
+ /** {@link bytes} decoded as strict UTF-8; public text is cached by CID. */
308
+ async readText(
309
+ request: DocumentRequest,
310
+ transport: DomainTransport,
311
+ signal?: AbortSignal,
312
+ ): Promise<string> {
313
+ const cacheable = this.cacheable(request, transport);
314
+ const cached = cacheable ? this.text.get(request.cid) : undefined;
315
+ if (cached !== undefined) return cached;
316
+ const text = decode(await this.bytes(request, transport, signal));
317
+ if (cacheable) this.text.set(request.cid, text);
318
+ return text;
319
+ }
320
+
321
+ /**
322
+ * The serialized brief of a verified snapshot and its token estimate,
323
+ * computed once per index CID.
324
+ */
325
+ brief(
326
+ snapshot: DomainSnapshot,
327
+ ): { json: string; tokens: number } | undefined {
328
+ if (!snapshot.document) return undefined;
329
+ const entry = snapshot.anchor
330
+ ? this.parsed.get(snapshot.anchor.cid)
331
+ : undefined;
332
+ if (entry?.brief && entry.document === snapshot.document)
333
+ return entry.brief;
334
+ const json = JSON.stringify(briefBody(snapshot.document));
335
+ const brief = { json, tokens: estimateTokensApprox(json) };
336
+ if (entry && entry.document === snapshot.document) entry.brief = brief;
337
+ return brief;
338
+ }
339
+
340
+ private cacheable(
341
+ request: DocumentRequest,
342
+ transport: DomainTransport,
343
+ ): boolean {
344
+ return !request.private && (transport.isPublic?.(request) ?? true);
345
+ }
346
+
347
+ private async resolveAndLoad(
348
+ did: string,
349
+ transport: DomainTransport,
350
+ ttl: number,
351
+ signal?: AbortSignal,
352
+ ): Promise<DomainSnapshot> {
353
+ const snapshot: DomainSnapshot = {
354
+ did,
355
+ status: 'unavailable',
356
+ stale: false,
357
+ findings: [],
358
+ };
359
+ try {
360
+ signal?.throwIfAborted();
361
+ if (!DID_PATTERN.test(did)) throw new Error('invalid-anchor-did');
362
+ const resolved = await waitFor(this.anchor(did, transport, ttl), signal);
363
+ signal?.throwIfAborted();
364
+ if (!resolved.value)
365
+ return { ...snapshot, status: 'missing', findings: ['anchor-missing'] };
366
+ snapshot.anchor = resolved.value;
367
+ snapshot.stale = resolved.stale;
368
+ const bytes = await this.bytes(
369
+ { ...resolved.value, maxBytes: INDEX_MAX_BYTES },
370
+ transport,
371
+ signal,
372
+ );
373
+ return await this.fromBytes(snapshot, bytes, transport, signal);
374
+ } catch (error) {
375
+ signal?.throwIfAborted();
376
+ return this.failed(snapshot, error);
377
+ }
378
+ }
379
+
380
+ private failed(snapshot: DomainSnapshot, error: unknown): DomainSnapshot {
381
+ const code = errorCode(error, 'resolution-failed');
382
+ // A failure outside the finding vocabulary is a runtime fault (a thrown
383
+ // TypeError, a provider's message), not a property of the domain: the
384
+ // provenance only carries the fallback code, so the cause goes to the log.
385
+ if (errorCode(error, '') === '')
386
+ this.logger.warn(
387
+ `[domain-context] resolution of ${snapshot.did} failed: ${error instanceof Error ? error.message : String(error)}`,
388
+ );
389
+ const failed: DomainSnapshot = {
390
+ ...snapshot,
391
+ status: INVALID_PATTERN.test(code) ? 'invalid' : 'unavailable',
392
+ findings: [...snapshot.findings, code],
393
+ };
394
+ delete failed.document;
395
+ return failed;
396
+ }
397
+
398
+ /** Verified `snapshot.anchor` bytes → a verified (or invalid) snapshot. */
399
+ private async fromBytes(
400
+ snapshot: DomainSnapshot,
401
+ bytes: Uint8Array,
402
+ transport: DomainTransport,
403
+ signal?: AbortSignal,
404
+ ): Promise<DomainSnapshot> {
405
+ const did = snapshot.did;
406
+ const anchor = snapshot.anchor;
407
+ if (!anchor) throw new Error('anchor-missing');
408
+ try {
409
+ const parsed = await this.parse(anchor.cid, bytes);
410
+ if (parsed.error) throw new Error(parsed.error);
411
+ snapshot.findings = [...parsed.findings];
412
+ if (!parsed.ok || !parsed.document) {
413
+ snapshot.status = 'invalid';
414
+ return snapshot;
415
+ }
416
+ const front = parsed.document.frontmatter;
417
+ if (
418
+ record(front.domain).id !== did ||
419
+ record(front.domain).iid !== did ||
420
+ record(front.source_of_truth).iid_document !== did ||
421
+ record(front.constitution).subject !== did
422
+ )
423
+ throw new Error('domain-identity-mismatch');
424
+ if (
425
+ !['anchored', 'runtime'].includes(
426
+ String(record(front.conformance).profile),
427
+ )
428
+ )
429
+ throw new Error('unanchored-profile');
430
+ snapshot.status = 'verified';
431
+ snapshot.document = parsed.document;
432
+ snapshot.findings.push(...parsed.unresolvedChecks);
433
+ if (snapshot.stale) snapshot.findings.push('anchor-stale');
434
+ const binding = record(front['x-oracle-capsule']);
435
+ if (Object.keys(binding).length) {
436
+ const result = await this.inspectCapsule(
437
+ parsed,
438
+ snapshot,
439
+ binding,
440
+ transport,
441
+ signal,
442
+ );
443
+ snapshot.capsule = result.capsule;
444
+ snapshot.findings.push(...result.findings);
445
+ }
446
+ return snapshot;
447
+ } catch (error) {
448
+ signal?.throwIfAborted();
449
+ return this.failed(snapshot, error);
450
+ }
451
+ }
452
+
453
+ /**
454
+ * Decode + lint, once per CID while the entry stays cached. The bytes have
455
+ * already been verified against `cid`. Finding codes are deduplicated and
456
+ * bounded here, so a cached entry never holds the validator's full report.
457
+ */
458
+ private async parse(cid: string, bytes: Uint8Array): Promise<ParsedIndex> {
459
+ const cached = this.parsed.get(cid);
460
+ if (cached) return cached;
461
+ const entry: ParsedIndex = {
462
+ ok: false,
463
+ findings: [],
464
+ unresolvedChecks: [],
465
+ capsules: new LruMap({ maxEntries: MAX_CAPSULES_PER_INDEX }),
466
+ };
467
+ let text: string | undefined;
468
+ try {
469
+ text = decode(bytes);
470
+ } catch (error) {
471
+ entry.error = errorCode(error, 'invalid-utf8');
472
+ }
473
+ if (text !== undefined && (await this.tooLarge(text))) {
474
+ entry.findings = ['index-too-large'];
475
+ } else if (text !== undefined) {
476
+ const report = await domainValidator.lint(text, {
477
+ sourceName: 'domain.md',
478
+ maxBytes: INDEX_MAX_BYTES,
479
+ });
480
+ entry.ok = report.ok;
481
+ entry.findings = boundFindings(
482
+ report.findings.map((finding) => finding.code),
483
+ );
484
+ entry.unresolvedChecks = boundFindings(
485
+ report.externalChecksRequired
486
+ .filter((check) => !CHECKS_PERFORMED.has(check.code))
487
+ .map((check) => `unresolved:${check.code}`),
488
+ );
489
+ // Only a valid parse keeps the document (and with it the raw text).
490
+ if (report.ok && report.document) entry.document = report.document;
491
+ }
492
+ if (parsedWeight(entry) <= MAX_CACHED_PARSE_CHARS)
493
+ this.parsed.set(cid, entry);
494
+ return entry;
495
+ }
496
+
497
+ /**
498
+ * True when the index is too large to lint. The validator's lint time grows
499
+ * about quadratically with the number of findings (seconds for a few
500
+ * hundred malformed list items, in any list), so the frontmatter's size is
501
+ * checked first by the fence's regular expression, then its shape (list
502
+ * lengths, nesting depth) on the library's parse, whose cost is linear.
503
+ */
504
+ private async tooLarge(text: string): Promise<boolean> {
505
+ const frontmatter = FRONTMATTER.exec(text)?.[1];
506
+ if (
507
+ frontmatter !== undefined &&
508
+ new TextEncoder().encode(frontmatter).length > MAX_FRONTMATTER_BYTES
509
+ )
510
+ return true;
511
+ const parsed = await domainValidator.parseDomain(text, {
512
+ sourceName: 'domain.md',
513
+ maxBytes: INDEX_MAX_BYTES,
514
+ });
515
+ return exceedsFrontmatterShape(parsed.document?.frontmatter);
516
+ }
517
+
518
+ private async anchor(
519
+ did: string,
520
+ transport: DomainTransport,
521
+ ttl: number,
522
+ ): Promise<{ value: DomainAnchor | null; stale: boolean }> {
523
+ const old = this.anchors.get(did);
524
+ if (old && this.now() - old.checked < ttl)
525
+ return { value: old.anchor, stale: false };
526
+ let pending = this.pending.get(did);
527
+ if (!pending) {
528
+ const lookup: Promise<DomainAnchor | null> = transport
529
+ .resolve(did, AbortSignal.timeout(ANCHOR_LOOKUP_TIMEOUT_MS))
530
+ .then((anchor) => {
531
+ if (this.pending.get(did) === lookup)
532
+ this.anchors.set(did, { anchor, checked: this.now() });
533
+ return anchor;
534
+ })
535
+ .catch((error: unknown) => {
536
+ // A malformed anchor is a confirmed state, not an outage: drop the
537
+ // fallback so no later turn reuses a revision the IID disowned.
538
+ if (
539
+ this.pending.get(did) === lookup &&
540
+ errorCode(error, '').startsWith('invalid-anchor')
541
+ )
542
+ this.anchors.delete(did);
543
+ throw error;
544
+ })
545
+ .finally(() => {
546
+ if (this.pending.get(did) === lookup) this.pending.delete(did);
547
+ });
548
+ this.pending.set(did, lookup);
549
+ pending = lookup;
550
+ }
551
+ try {
552
+ return { value: await pending, stale: false };
553
+ } catch (error) {
554
+ if (errorCode(error, '').startsWith('invalid-anchor')) throw error;
555
+ if (old?.anchor) return { value: old.anchor, stale: true };
556
+ throw error;
557
+ }
558
+ }
559
+
560
+ private async inspectCapsule(
561
+ parsed: ParsedIndex,
562
+ snapshot: DomainSnapshot,
563
+ binding: Record<string, unknown>,
564
+ transport: DomainTransport,
565
+ signal?: AbortSignal,
566
+ ): Promise<CapsuleResult> {
567
+ const invalid: CapsuleResult = {
568
+ capsule: { status: 'invalid' },
569
+ findings: ['capsule-inspection-failed'],
570
+ };
571
+ const manifest = capsuleBinding.safeParse(binding.manifest);
572
+ if (!manifest.success || binding.contract !== CAPSULE_CONTRACT)
573
+ return invalid;
574
+ const key = `${snapshot.did}\n${manifest.data.cid}`;
575
+ let bytes: Uint8Array;
576
+ try {
577
+ // Read even on a cache hit: a private manifest must be re-authorized.
578
+ bytes = await this.bytes(
579
+ {
580
+ did: snapshot.did,
581
+ uri: manifest.data.uri,
582
+ cid: manifest.data.cid,
583
+ private: snapshot.anchor?.private ?? true,
584
+ maxBytes: INDEX_MAX_BYTES,
585
+ },
586
+ transport,
587
+ signal,
588
+ );
589
+ } catch {
590
+ signal?.throwIfAborted();
591
+ return {
592
+ capsule: { status: 'unavailable', cid: manifest.data.cid },
593
+ findings: ['capsule-unavailable'],
594
+ };
595
+ }
596
+ const cached = parsed.capsules.get(key);
597
+ if (cached) return cached;
598
+ const result = await this.validateCapsule(snapshot, manifest.data, bytes);
599
+ parsed.capsules.set(key, result);
600
+ return result;
601
+ }
602
+
603
+ private async validateCapsule(
604
+ snapshot: DomainSnapshot,
605
+ manifest: z.infer<typeof capsuleBinding>,
606
+ bytes: Uint8Array,
607
+ ): Promise<CapsuleResult> {
608
+ const invalid: CapsuleResult = {
609
+ capsule: { status: 'invalid', cid: manifest.cid },
610
+ findings: ['capsule-inspection-failed'],
611
+ };
612
+ const result = await domainValidator.validateOracleCapsule(bytes, {
613
+ expectedIdentity: { cid: manifest.cid, sha256: manifest.sha256 },
614
+ });
615
+ const parsed = record(result.manifest);
616
+ const oracle = record(record(parsed.domains).oracle);
617
+ if (
618
+ !result.ok ||
619
+ record(parsed.metadata).release !== manifest.version ||
620
+ parsed.schema !== manifest.schema ||
621
+ oracle.id !== snapshot.did
622
+ )
623
+ return invalid;
624
+ const compatibility = record(parsed.compatibility);
625
+ const components = Array.isArray(parsed.components)
626
+ ? parsed.components.map(record)
627
+ : [];
628
+ const master = components.find(
629
+ (component) => component.kind === 'master_skill',
630
+ );
631
+ const findings: string[] = [];
632
+ if (oracle.cid !== snapshot.anchor?.cid)
633
+ findings.push('capsule-oracle-revision-differs');
634
+ return {
635
+ capsule: {
636
+ status: 'inspected-not-activated',
637
+ cid: manifest.cid,
638
+ release: manifest.version,
639
+ minimumKernel: String(compatibility.minimum_kernel ?? '').slice(0, 128),
640
+ requiredFeatures: Array.isArray(compatibility.required_features)
641
+ ? compatibility.required_features
642
+ .filter((value): value is string => typeof value === 'string')
643
+ .slice(0, 16)
644
+ .map((value) => value.slice(0, 256))
645
+ : [],
646
+ master: master
647
+ ? {
648
+ id: String(master.id).slice(0, 256),
649
+ cid: String(record(master.artifact).cid).slice(0, 128),
650
+ entrypoint: String(master.entrypoint).slice(0, 256),
651
+ }
652
+ : undefined,
653
+ requestedToolCount: Array.isArray(parsed.tools)
654
+ ? parsed.tools.length
655
+ : 0,
656
+ externalChecksRequired: boundFindings(
657
+ result.externalChecksRequired.map((check) => check.code),
658
+ ),
659
+ },
660
+ findings,
661
+ };
662
+ }
663
+ }