@nullsquare/agent-authority 0.4.1 → 0.4.3

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.
@@ -0,0 +1,146 @@
1
+ import { hashObject } from './index.js';
2
+
3
+ const FORBIDDEN_SELECTOR_SEGMENTS = new Set(['__proto__', 'prototype', 'constructor']);
4
+
5
+ function evidenceError(code, message) {
6
+ const error = new Error(message);
7
+ error.code = code;
8
+ return error;
9
+ }
10
+
11
+ function requireReceipt(receipt) {
12
+ if (!receipt?.receipt_id) throw evidenceError('evidence_receipt_required', 'execution evidence requires a receipt');
13
+ if (!receipt?.receipt_hash) throw evidenceError('evidence_receipt_hash_required', 'execution evidence requires a receipt hash');
14
+ if (receipt.decision !== 'allow') {
15
+ throw evidenceError('evidence_receipt_not_authorized', 'execution evidence can only be created for an ALLOW receipt');
16
+ }
17
+ return receipt;
18
+ }
19
+
20
+ function unsignedEvidence(evidence = {}) {
21
+ const { evidence_hash: _evidenceHash, ...unsigned } = evidence;
22
+ return unsigned;
23
+ }
24
+
25
+ function hashExecutionOutput(output) {
26
+ return hashObject({
27
+ output_type: output === null ? 'null' : typeof output,
28
+ output
29
+ });
30
+ }
31
+
32
+ /**
33
+ * Bind the exact output returned by an authorized effect to its decision receipt.
34
+ *
35
+ * This is integrity evidence, not remote attestation. It prevents downstream code
36
+ * from silently swapping the output object while still claiming the original
37
+ * Agent Authority receipt as provenance.
38
+ */
39
+ export function createExecutionEvidence({ receipt, output } = {}) {
40
+ requireReceipt(receipt);
41
+ const evidence = {
42
+ version: '0.1',
43
+ type: 'execution-output',
44
+ receipt_id: receipt.receipt_id,
45
+ receipt_hash: receipt.receipt_hash,
46
+ mission_id: receipt.mission_id,
47
+ task_lease_id: receipt.task_lease_id || null,
48
+ service: receipt.service,
49
+ action: receipt.action,
50
+ request_hash: receipt.request_hash,
51
+ output_hash: hashExecutionOutput(output)
52
+ };
53
+ return { ...evidence, evidence_hash: hashObject(evidence) };
54
+ }
55
+
56
+ export function verifyExecutionEvidence({ receipt, output, evidence } = {}) {
57
+ requireReceipt(receipt);
58
+ if (!evidence || typeof evidence !== 'object') {
59
+ throw evidenceError('execution_evidence_required', 'derived authority requires execution evidence');
60
+ }
61
+ if (evidence.type !== 'execution-output' || evidence.version !== '0.1') {
62
+ throw evidenceError('execution_evidence_invalid', 'unsupported execution evidence format');
63
+ }
64
+ if (hashObject(unsignedEvidence(evidence)) !== evidence.evidence_hash) {
65
+ throw evidenceError('execution_evidence_tampered', 'execution evidence hash does not match its contents');
66
+ }
67
+ if (evidence.receipt_id !== receipt.receipt_id || evidence.receipt_hash !== receipt.receipt_hash) {
68
+ throw evidenceError('evidence_receipt_mismatch', 'execution evidence belongs to another receipt');
69
+ }
70
+ if (evidence.mission_id !== receipt.mission_id) {
71
+ throw evidenceError('evidence_mission_mismatch', 'execution evidence belongs to another mission');
72
+ }
73
+ if ((evidence.task_lease_id || null) !== (receipt.task_lease_id || null)) {
74
+ throw evidenceError('evidence_lease_mismatch', 'execution evidence belongs to another task lease');
75
+ }
76
+ if (evidence.service !== receipt.service || evidence.action !== receipt.action) {
77
+ throw evidenceError('evidence_operation_mismatch', 'execution evidence operation does not match its receipt');
78
+ }
79
+ if (evidence.request_hash !== receipt.request_hash) {
80
+ throw evidenceError('evidence_request_mismatch', 'execution evidence request does not match its receipt');
81
+ }
82
+ if (hashExecutionOutput(output) !== evidence.output_hash) {
83
+ throw evidenceError('evidence_output_mismatch', 'provider output no longer matches the authorized execution evidence');
84
+ }
85
+ return evidence;
86
+ }
87
+
88
+ export function resolveEvidenceSelector(output, selector) {
89
+ if (typeof selector !== 'string' || selector.trim() === '') {
90
+ throw evidenceError('selector_required', 'trusted extractor must provide a selector');
91
+ }
92
+
93
+ let normalized = selector.trim();
94
+ if (normalized === 'output') return output;
95
+ if (normalized.startsWith('output.')) normalized = normalized.slice('output.'.length);
96
+
97
+ const segments = normalized.split('.');
98
+ if (segments.length === 0 || segments.some((segment) => !segment || FORBIDDEN_SELECTOR_SEGMENTS.has(segment))) {
99
+ throw evidenceError('selector_invalid', 'trusted extractor selector contains an invalid path segment');
100
+ }
101
+
102
+ let current = output;
103
+ for (const segment of segments) {
104
+ if (current === null || current === undefined || typeof current !== 'object') {
105
+ throw evidenceError('selector_unresolved', `trusted extractor selector ${selector} does not resolve against provider output`);
106
+ }
107
+ if (!Object.prototype.hasOwnProperty.call(current, segment)) {
108
+ throw evidenceError('selector_unresolved', `trusted extractor selector ${selector} does not resolve against provider output`);
109
+ }
110
+ current = current[segment];
111
+ }
112
+
113
+ if (current === undefined) {
114
+ throw evidenceError('selector_unresolved', `trusted extractor selector ${selector} resolved to undefined`);
115
+ }
116
+ return current;
117
+ }
118
+
119
+ /**
120
+ * Execute the small trusted-adapter extraction contract.
121
+ *
122
+ * Extractors choose which already-normalized output field is authority-relevant;
123
+ * they do not supply the value. TaskLease resolves the selector itself so the
124
+ * caller cannot substitute a different value while keeping the same evidence.
125
+ */
126
+ export function runAuthorityExtractor({ extractor, receipt, output } = {}) {
127
+ if (typeof extractor !== 'function') {
128
+ throw evidenceError('trusted_extractor_required', 'deriveFromEvidence requires a trusted adapter extractor');
129
+ }
130
+
131
+ const descriptor = extractor({ receipt, output: structuredClone(output) });
132
+ if (!descriptor || typeof descriptor !== 'object') {
133
+ throw evidenceError('trusted_extractor_invalid', 'trusted adapter extractor must return a descriptor');
134
+ }
135
+ if (typeof descriptor.extractor_id !== 'string' || descriptor.extractor_id.trim() === '') {
136
+ throw evidenceError('trusted_extractor_id_required', 'trusted adapter extractor must provide extractor_id');
137
+ }
138
+ if (typeof descriptor.selector !== 'string' || descriptor.selector.trim() === '') {
139
+ throw evidenceError('selector_required', 'trusted adapter extractor must provide selector');
140
+ }
141
+
142
+ return {
143
+ extractor_id: descriptor.extractor_id.trim(),
144
+ selector: descriptor.selector.trim()
145
+ };
146
+ }
package/src/guard.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { createExecutionEvidence } from './authority-evidence.js';
2
+
1
3
  export class AuthorityDeniedError extends Error {
2
4
  constructor({ result, receipt }) {
3
5
  super(result?.reason || 'action denied by Agent Authority');
@@ -25,6 +27,10 @@ export class AuthorityApprovalRequiredError extends Error {
25
27
  * authority boundary returns ALLOW. A guard can use either a static mission or
26
28
  * a TaskLease. Task leases add provenance-bound restrictions without changing
27
29
  * the host application's credential ownership.
30
+ *
31
+ * Successful effects also return execution evidence binding the exact output
32
+ * hash to the ALLOW receipt. TaskLease.deriveFromEvidence() can use that record
33
+ * with a trusted adapter extractor so callers do not provide derived values.
28
34
  */
29
35
  export class AuthorityGuard {
30
36
  constructor({ mission, lease, runtime, onDecision } = {}) {
@@ -66,7 +72,8 @@ export class AuthorityGuard {
66
72
  }
67
73
 
68
74
  const output = await effect();
69
- return { output, result: evaluation.result, receipt: evaluation.receipt };
75
+ const evidence = createExecutionEvidence({ receipt: evaluation.receipt, output });
76
+ return { output, result: evaluation.result, receipt: evaluation.receipt, evidence };
70
77
  }
71
78
  }
72
79
 
@@ -1,12 +1,19 @@
1
1
  import { brokeredProviderAdapter } from '../connections.js';
2
2
 
3
- const MUTATING_ACTIONS = new Set(['issue.create', 'pull_request.create', 'repo.contents.write']);
3
+ const MUTATING_ACTIONS = new Set(['issue.create', 'issue.comment', 'pull_request.create', 'repo.contents.write']);
4
+ const ISSUE_STATES = new Set(['open', 'closed', 'all']);
4
5
 
5
6
  function required(value, name) {
6
7
  if (value === undefined || value === null || value === '') throw new Error(`${name} is required`);
7
8
  return value;
8
9
  }
9
10
 
11
+ function providerError(code, message) {
12
+ const error = new Error(message);
13
+ error.code = code;
14
+ return error;
15
+ }
16
+
10
17
  function repoParts(context = {}) {
11
18
  const repository = required(context.repository, 'context.repository');
12
19
  const [owner, repo, ...extra] = String(repository).split('/');
@@ -22,6 +29,26 @@ function encodedPath(path) {
22
29
  .join('/');
23
30
  }
24
31
 
32
+ function issueNumber(value) {
33
+ const number = Number(value);
34
+ if (!Number.isSafeInteger(number) || number <= 0) {
35
+ throw providerError('invalid_issue_number', 'context.issue_number must be a positive integer');
36
+ }
37
+ return number;
38
+ }
39
+
40
+ function issueListQuery(context = {}) {
41
+ const state = context.state || 'open';
42
+ if (!ISSUE_STATES.has(state)) {
43
+ throw providerError('invalid_issue_state', 'context.state must be open, closed, or all');
44
+ }
45
+ const perPage = context.per_page === undefined ? 100 : Number(context.per_page);
46
+ if (!Number.isSafeInteger(perPage) || perPage < 1 || perPage > 100) {
47
+ throw providerError('invalid_issue_page_size', 'context.per_page must be an integer between 1 and 100');
48
+ }
49
+ return new URLSearchParams({ state, per_page: String(perPage) }).toString();
50
+ }
51
+
25
52
  function buildOperation(request) {
26
53
  const context = request.context || {};
27
54
  const { owner, repo } = repoParts(context);
@@ -37,6 +64,9 @@ function buildOperation(request) {
37
64
  return { method: 'GET', path: `${root}/contents/${encodedPath(path)}${query}` };
38
65
  }
39
66
 
67
+ case 'issue.list':
68
+ return { method: 'GET', path: `${root}/issues?${issueListQuery(context)}` };
69
+
40
70
  case 'issue.create':
41
71
  return {
42
72
  method: 'POST',
@@ -49,6 +79,13 @@ function buildOperation(request) {
49
79
  }
50
80
  };
51
81
 
82
+ case 'issue.comment':
83
+ return {
84
+ method: 'POST',
85
+ path: `${root}/issues/${issueNumber(context.issue_number)}/comments`,
86
+ body: { body: required(context.body, 'context.body') }
87
+ };
88
+
52
89
  case 'pull_request.create':
53
90
  return {
54
91
  method: 'POST',
@@ -77,11 +114,8 @@ function buildOperation(request) {
77
114
  };
78
115
  }
79
116
 
80
- default: {
81
- const error = new Error(`GitHub action ${request.action} has no provider operation mapping`);
82
- error.code = 'unsupported_action';
83
- throw error;
84
- }
117
+ default:
118
+ throw providerError('unsupported_action', `GitHub action ${request.action} has no provider operation mapping`);
85
119
  }
86
120
  }
87
121
 
@@ -94,6 +128,91 @@ function sanitizeBody(body) {
94
128
  return clone;
95
129
  }
96
130
 
131
+ function normalizeIssueList(request, body) {
132
+ if (!Array.isArray(body)) {
133
+ throw providerError('github_issue_list_invalid', 'GitHub issue.list response must be an array');
134
+ }
135
+
136
+ const issues = body.map((issue) => ({
137
+ number: issueNumber(issue?.number),
138
+ title: typeof issue?.title === 'string' ? issue.title : null,
139
+ is_pull_request: Boolean(issue?.pull_request)
140
+ }));
141
+
142
+ const marker = request.context?.fixture_marker;
143
+ if (typeof marker !== 'string' || marker.trim() === '') {
144
+ return { issues, selected_issue_number: null, selected_issue_title: null, selected_issue_match_count: 0, selected_issue_marker: null };
145
+ }
146
+
147
+ const matches = body
148
+ .map((issue, index) => ({ issue, index }))
149
+ .filter(({ issue }) => !issue?.pull_request && typeof issue?.body === 'string' && issue.body.includes(marker));
150
+
151
+ const selected = matches.length === 1 ? issues[matches[0].index] : null;
152
+ return {
153
+ issues,
154
+ selected_issue_number: selected?.number || null,
155
+ selected_issue_title: selected?.title || null,
156
+ selected_issue_match_count: matches.length,
157
+ selected_issue_marker: marker
158
+ };
159
+ }
160
+
161
+ function normalizedOutput(request, response, body) {
162
+ const common = {
163
+ provider: 'github',
164
+ status: response.status,
165
+ ok: response.ok,
166
+ request_id: response.headers?.get?.('x-github-request-id') || null
167
+ };
168
+
169
+ if (request.action === 'issue.list') {
170
+ return { ...common, ...normalizeIssueList(request, body) };
171
+ }
172
+
173
+ if (request.action === 'issue.comment') {
174
+ return {
175
+ ...common,
176
+ comment_id: body?.id || null,
177
+ html_url: body?.html_url || null,
178
+ issue_number: issueNumber(request.context?.issue_number)
179
+ };
180
+ }
181
+
182
+ return { ...common, body: sanitizeBody(body) };
183
+ }
184
+
185
+ /**
186
+ * Reviewed authority extractor for an issue selected by the normalized
187
+ * issue.list mapping using the request's root-bound fixture_marker.
188
+ *
189
+ * The extractor returns a selector only. TaskLease resolves the issue number
190
+ * from the evidence-bound output after verifying the ALLOW receipt.
191
+ */
192
+ export function githubIssueListSelectedNumberAuthorityExtractor({ receipt, output } = {}) {
193
+ if (receipt?.service !== 'github' || receipt?.action !== 'issue.list') {
194
+ throw providerError(
195
+ 'trusted_extractor_operation_mismatch',
196
+ 'GitHub issue-number authority extractor only accepts github:issue.list receipts'
197
+ );
198
+ }
199
+ if (output?.provider !== 'github' || output?.selected_issue_match_count !== 1) {
200
+ throw providerError(
201
+ 'trusted_extractor_output_invalid',
202
+ 'normalized GitHub output must contain exactly one selected issue'
203
+ );
204
+ }
205
+ issueNumber(output.selected_issue_number);
206
+ if (typeof output.selected_issue_marker !== 'string' || output.selected_issue_marker.trim() === '') {
207
+ throw providerError('trusted_extractor_output_invalid', 'normalized GitHub output is missing the selection marker');
208
+ }
209
+
210
+ return {
211
+ extractor_id: 'github.issue.list.selected-number.v1',
212
+ selector: 'output.selected_issue_number'
213
+ };
214
+ }
215
+
97
216
  export function createGitHubProviderAdapter({ broker, fetchImpl = globalThis.fetch, baseUrl = 'https://api.github.com' } = {}) {
98
217
  if (!broker) throw new Error('credential broker is required');
99
218
  if (typeof fetchImpl !== 'function') throw new Error('fetch implementation is required');
@@ -114,7 +233,7 @@ export function createGitHubProviderAdapter({ broker, fetchImpl = globalThis.fet
114
233
  authorization: `Bearer ${token}`,
115
234
  'content-type': 'application/json',
116
235
  'x-github-api-version': '2022-11-28',
117
- 'user-agent': 'nullsquare-agent-authority/0.3'
236
+ 'user-agent': 'nullsquare-agent-authority/0.4'
118
237
  },
119
238
  body: operation.body ? JSON.stringify(operation.body) : undefined
120
239
  });
@@ -125,25 +244,36 @@ export function createGitHubProviderAdapter({ broker, fetchImpl = globalThis.fet
125
244
  try { body = JSON.parse(text); } catch { /* preserve text */ }
126
245
  }
127
246
 
128
- const output = {
129
- provider: 'github',
130
- status: response.status,
131
- ok: response.ok,
132
- body: sanitizeBody(body),
133
- request_id: response.headers?.get?.('x-github-request-id') || null
134
- };
135
-
136
247
  if (!response.ok) {
137
- const error = new Error(`GitHub API ${response.status}`);
138
- error.code = 'provider_error';
248
+ const output = {
249
+ provider: 'github',
250
+ status: response.status,
251
+ ok: false,
252
+ body: sanitizeBody(body),
253
+ request_id: response.headers?.get?.('x-github-request-id') || null
254
+ };
255
+ const error = providerError('provider_error', `GitHub API ${response.status}`);
139
256
  error.provider_output = output;
140
257
  throw error;
141
258
  }
142
259
 
143
- return output;
260
+ return normalizedOutput(request, response, body);
144
261
  }
145
262
  });
146
263
 
264
+ adapter.validateRequest = (request) => buildOperation(request);
147
265
  adapter.isMutation = (request) => MUTATING_ACTIONS.has(request?.action);
266
+ adapter.authorityExtractor = (request, kind = 'opaque') => {
267
+ if (
268
+ request?.service === 'github' &&
269
+ request?.action === 'issue.list' &&
270
+ kind === 'github.issue.number' &&
271
+ typeof request?.context?.fixture_marker === 'string' &&
272
+ request.context.fixture_marker.trim() !== ''
273
+ ) {
274
+ return githubIssueListSelectedNumberAuthorityExtractor;
275
+ }
276
+ return null;
277
+ };
148
278
  return adapter;
149
279
  }
@@ -42,6 +42,37 @@ export function gmailThreadSenderEmail(thread) {
42
42
  throw providerError('gmail_sender_missing', 'Gmail thread does not contain a usable From header');
43
43
  }
44
44
 
45
+ /**
46
+ * Trusted authority-extraction contract for the normalized Gmail thread output.
47
+ *
48
+ * The extractor chooses the authority-relevant selector only. It never returns
49
+ * the value itself; TaskLease resolves output.sender_email after verifying the
50
+ * guard's execution evidence. This prevents host code from substituting another
51
+ * email while retaining the original Gmail receipt/evidence chain.
52
+ */
53
+ export function gmailThreadSenderAuthorityExtractor({ receipt, output } = {}) {
54
+ if (receipt?.service !== 'gmail' || receipt?.action !== 'thread.read') {
55
+ throw providerError(
56
+ 'trusted_extractor_operation_mismatch',
57
+ 'Gmail sender authority extractor only accepts gmail:thread.read receipts'
58
+ );
59
+ }
60
+
61
+ const raw = typeof output?.sender_email === 'string' ? output.sender_email.trim() : '';
62
+ const normalized = extractEmailAddress(raw);
63
+ if (!normalized || normalized !== raw) {
64
+ throw providerError(
65
+ 'trusted_extractor_output_invalid',
66
+ 'normalized Gmail output does not contain a canonical sender_email'
67
+ );
68
+ }
69
+
70
+ return {
71
+ extractor_id: 'google.gmail.thread.sender-email.v1',
72
+ selector: 'output.sender_email'
73
+ };
74
+ }
75
+
45
76
  function validateSendUpdates(value) {
46
77
  const normalized = value || 'none';
47
78
  if (!SEND_UPDATES.has(normalized)) {
@@ -197,5 +228,11 @@ export function createGoogleProviderAdapter({
197
228
 
198
229
  adapter.validateRequest = (request) => operationFor(request);
199
230
  adapter.isMutation = (request) => MUTATING_ACTIONS.has(request?.action);
231
+ adapter.authorityExtractor = (request, kind = 'email.address') => {
232
+ if (request?.service === 'gmail' && request?.action === 'thread.read' && kind === 'email.address') {
233
+ return gmailThreadSenderAuthorityExtractor;
234
+ }
235
+ return null;
236
+ };
200
237
  return adapter;
201
238
  }
package/src/task-lease.js CHANGED
@@ -1,5 +1,10 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { assertMission, createReceipt, hashObject, matchPattern } from './index.js';
3
+ import {
4
+ resolveEvidenceSelector,
5
+ runAuthorityExtractor,
6
+ verifyExecutionEvidence
7
+ } from './authority-evidence.js';
3
8
 
4
9
  function authorityError(code, message) {
5
10
  const error = new Error(message);
@@ -95,6 +100,12 @@ export class TaskLease {
95
100
  return structuredClone(fact);
96
101
  }
97
102
 
103
+ /**
104
+ * Legacy host-trusted derivation path.
105
+ *
106
+ * The caller supplies both value and selector. Keep this API for compatibility,
107
+ * but prefer deriveFromEvidence() for authority-relevant provider outputs.
108
+ */
98
109
  derive({ fact_id, kind = 'opaque', value, from = [], receipt, selector } = {}) {
99
110
  if (!fact_id) throw new Error('derived fact_id is required');
100
111
  if (value === undefined) throw new Error('derived value is required');
@@ -125,6 +136,7 @@ export class TaskLease {
125
136
  value,
126
137
  provenance: {
127
138
  type: 'derived',
139
+ derivation_mode: 'host-trusted',
128
140
  from: parents,
129
141
  task_lease_id: this.lease_id,
130
142
  receipt_id: receipt.receipt_id,
@@ -140,6 +152,71 @@ export class TaskLease {
140
152
  return structuredClone(fact);
141
153
  }
142
154
 
155
+ /**
156
+ * Strict derivation path for provider data.
157
+ *
158
+ * The caller cannot supply the authority value. A trusted adapter extractor
159
+ * identifies one normalized output selector, TaskLease resolves that selector
160
+ * itself, and execution evidence proves the output still matches the exact
161
+ * ALLOW receipt returned by guard.run().
162
+ */
163
+ deriveFromEvidence({
164
+ fact_id,
165
+ kind = 'opaque',
166
+ from = [],
167
+ receipt,
168
+ evidence,
169
+ output,
170
+ extractor
171
+ } = {}) {
172
+ if (!fact_id) throw new Error('derived fact_id is required');
173
+ if (this.facts.has(fact_id)) throw authorityError('fact_exists', `authority fact ${fact_id} already exists`);
174
+ if (!receipt) throw authorityError('receipt_required', 'derived authority requires an authorized source receipt');
175
+ if (receipt.decision !== 'allow') throw authorityError('receipt_not_authorized', 'derived authority requires an ALLOW receipt');
176
+ if (receipt.mission_id !== this.mission.mission_id) {
177
+ throw authorityError('receipt_mission_mismatch', 'source receipt belongs to another mission');
178
+ }
179
+ if (receipt.task_lease_id !== this.lease_id) {
180
+ throw authorityError('receipt_lease_mismatch', 'source receipt belongs to another task lease');
181
+ }
182
+
183
+ const parents = [...new Set(from)];
184
+ if (parents.length === 0) {
185
+ throw authorityError('parent_fact_required', 'derived authority must descend from at least one existing task authority fact');
186
+ }
187
+ for (const parentId of parents) {
188
+ if (!this.facts.has(parentId)) throw authorityError('parent_fact_missing', `authority fact ${parentId} does not exist`);
189
+ }
190
+
191
+ verifyExecutionEvidence({ receipt, output, evidence });
192
+ const extraction = runAuthorityExtractor({ extractor, receipt, output });
193
+ const value = resolveEvidenceSelector(output, extraction.selector);
194
+
195
+ const fact = {
196
+ fact_id,
197
+ kind,
198
+ value,
199
+ provenance: {
200
+ type: 'derived',
201
+ derivation_mode: 'execution-evidence-v1',
202
+ from: parents,
203
+ task_lease_id: this.lease_id,
204
+ receipt_id: receipt.receipt_id,
205
+ receipt_hash: receipt.receipt_hash,
206
+ source_service: receipt.service,
207
+ source_action: receipt.action,
208
+ source_request_hash: receipt.request_hash,
209
+ selector: extraction.selector,
210
+ extractor_id: extraction.extractor_id,
211
+ source_output_hash: evidence.output_hash,
212
+ execution_evidence_hash: evidence.evidence_hash
213
+ },
214
+ created_at: new Date().toISOString()
215
+ };
216
+ this.facts.set(fact_id, fact);
217
+ return structuredClone(fact);
218
+ }
219
+
143
220
  bind(binding) {
144
221
  const normalized = validateBinding(binding);
145
222
  this.bindings.push(normalized);