@clossys/butler 0.1.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 (54) hide show
  1. package/CHANGELOG.md +85 -0
  2. package/LICENSE +21 -0
  3. package/README.md +260 -0
  4. package/dist/audit-shape.check.d.ts +32 -0
  5. package/dist/audit-shape.check.d.ts.map +1 -0
  6. package/dist/audit-shape.check.js +7 -0
  7. package/dist/audit-shape.check.js.map +1 -0
  8. package/dist/cli.d.ts +54 -0
  9. package/dist/cli.d.ts.map +1 -0
  10. package/dist/cli.js +426 -0
  11. package/dist/cli.js.map +1 -0
  12. package/dist/contract.d.ts +256 -0
  13. package/dist/contract.d.ts.map +1 -0
  14. package/dist/contract.js +377 -0
  15. package/dist/contract.js.map +1 -0
  16. package/dist/inbound/index.d.ts +120 -0
  17. package/dist/inbound/index.d.ts.map +1 -0
  18. package/dist/inbound/index.js +125 -0
  19. package/dist/inbound/index.js.map +1 -0
  20. package/dist/index.d.ts +50 -0
  21. package/dist/index.d.ts.map +1 -0
  22. package/dist/index.js +47 -0
  23. package/dist/index.js.map +1 -0
  24. package/dist/schema.d.ts +374 -0
  25. package/dist/schema.d.ts.map +1 -0
  26. package/dist/schema.js +304 -0
  27. package/dist/schema.js.map +1 -0
  28. package/dist/validation.d.ts +74 -0
  29. package/dist/validation.d.ts.map +1 -0
  30. package/dist/validation.js +140 -0
  31. package/dist/validation.js.map +1 -0
  32. package/dist/web/index.d.ts +5 -0
  33. package/dist/web/index.d.ts.map +1 -0
  34. package/dist/web/index.js +25 -0
  35. package/dist/web/index.js.map +1 -0
  36. package/dist/web/internal/peer-version.d.ts +53 -0
  37. package/dist/web/internal/peer-version.d.ts.map +1 -0
  38. package/dist/web/internal/peer-version.js +136 -0
  39. package/dist/web/internal/peer-version.js.map +1 -0
  40. package/dist/web/useStandingWants.d.ts +75 -0
  41. package/dist/web/useStandingWants.d.ts.map +1 -0
  42. package/dist/web/useStandingWants.js +66 -0
  43. package/dist/web/useStandingWants.js.map +1 -0
  44. package/package.json +93 -0
  45. package/src/audit-shape.check.ts +37 -0
  46. package/src/cli.ts +445 -0
  47. package/src/contract.ts +534 -0
  48. package/src/inbound/index.ts +190 -0
  49. package/src/index.ts +113 -0
  50. package/src/schema.ts +622 -0
  51. package/src/validation.ts +172 -0
  52. package/src/web/index.ts +27 -0
  53. package/src/web/internal/peer-version.ts +159 -0
  54. package/src/web/useStandingWants.ts +139 -0
package/src/schema.ts ADDED
@@ -0,0 +1,622 @@
1
+ /**
2
+ * The want contract: what a person asked for right now, and what they told
3
+ * us to keep doing.
4
+ *
5
+ * Two record families live here and they are deliberately not merged. An
6
+ * INTENT is a single request, interpreted, carrying a confidence, and true
7
+ * only until it has been acted on. A STANDING INSTRUCTION is a durable
8
+ * answer that keeps speaking for the person until it expires or the policy
9
+ * it answered is superseded. Both are consumer-authored data; nothing in
10
+ * this package ships a value, a topic vocabulary, a jurisdiction rule, or
11
+ * an obligation of its own.
12
+ *
13
+ * THE THREE-STATE MODEL, PRESERVED
14
+ * ---------------------------------
15
+ * `StandingState` is three states, not two. `"absent"` (never asked) is a
16
+ * distinct value from `"denied"` (asked, refused), and neither is a
17
+ * boolean. Collapsing them would make "never asked" indistinguishable from
18
+ * a passing signal — the same absence-of-signal-looks-like-a-passing-signal
19
+ * failure this repository already writes down for gate exit codes (see
20
+ * this repository's own contribution guide, "Gate CLIs exit `0` clean,
21
+ * `1` findings, `2` could not run"). `"granted"` and `"denied"` each carry
22
+ * the policy version they answered and when; a bare boolean can carry
23
+ * neither, which is exactly what turns a stored answer into an unauditable
24
+ * guess instead of a record.
25
+ *
26
+ * A fourth value, `"stale"`, exists only as an EVALUATION status
27
+ * (`StandingEvaluation`), never as a stored state — staleness is computed
28
+ * by comparing a stored record against the policy in force and the clock,
29
+ * so writing it down would be storing a derivation that starts rotting the
30
+ * moment it is written.
31
+ *
32
+ * IDENTIFIERS
33
+ * ------------
34
+ * `subjectId` is the person the want belongs to; `actorId` is whoever or
35
+ * whatever is acting. They are separate fields in every signature in this
36
+ * package and are never unified, because "the system acted on its own
37
+ * reading" and "the person asked for this" are the two things this whole
38
+ * package exists to keep apart. Both are opaque host-owned references —
39
+ * never an email address, a name, a phone number, or an IP. See
40
+ * `audit-shape.check.ts` for the compile-time proof that the audit event
41
+ * carries nothing else.
42
+ *
43
+ * Validation here is hand-rolled over `unknown`, with no schema library,
44
+ * matching every other package in this workspace (see the root
45
+ * `AGENTS.md`). These validators exist for the boundary where records
46
+ * arrive as untyped JSON — a preference-centre route body, a file the CLI
47
+ * reads, a value read back out of a host's own store — before anything
48
+ * downstream is allowed to trust them.
49
+ */
50
+
51
+ import {
52
+ isOneOf,
53
+ isPlainObject,
54
+ pushIssue,
55
+ optionalTimestamp,
56
+ requireArrayOf,
57
+ requireBoolean,
58
+ requireNumber,
59
+ requireString,
60
+ requireTimestamp,
61
+ type ValidationIssue,
62
+ type ValidationResult,
63
+ } from "./validation.js";
64
+
65
+ // ------------------------------------------------------------ shared vocabulary
66
+
67
+ /**
68
+ * Identifies which version of a policy document a record answers. A
69
+ * standing instruction that cannot name what it answered cannot be shown
70
+ * to have gone stale.
71
+ */
72
+ export interface PolicyVersion {
73
+ policyId: string;
74
+ version: string;
75
+ }
76
+
77
+ /**
78
+ * Consumer-defined; this package does not enumerate topics. A topic is
79
+ * whatever a consumer's own policy defines ("marketing-email",
80
+ * "contact-window", "language", ...) — nothing here needs it to be more
81
+ * than a stable string used to key one subject's standing instructions.
82
+ */
83
+ export type StandingTopic = string;
84
+
85
+ /**
86
+ * Where a standing instruction came from. `"stated"` is the person's own
87
+ * words. `"inferred"` is a reading of their behaviour, and an inferred
88
+ * instruction is NEVER binding until they confirm it — see
89
+ * `evaluateStandingInstruction` (`contract.ts`), which reports an
90
+ * unconfirmed inference as `absent`, because an unconfirmed guess is not a
91
+ * want we have.
92
+ */
93
+ export type StandingProvenance = "stated" | "inferred";
94
+
95
+ /** Every provenance value, for a caller validating untyped input. */
96
+ export const STANDING_PROVENANCES: readonly StandingProvenance[] = ["stated", "inferred"];
97
+
98
+ /**
99
+ * How long a stored answer keeps speaking for the person. Consumer-declared
100
+ * per instruction, with NO default anywhere in this package: a window this
101
+ * package invented would be this package authoring one of the consumer's
102
+ * values, and a missing window silently read as "forever" is precisely the
103
+ * open loop the currency gate exists to close.
104
+ */
105
+ export interface CurrencyWindow {
106
+ /** Whole days after `decidedAt` during which the answer is still current. */
107
+ days: number;
108
+ }
109
+
110
+ /**
111
+ * Three states, not two. See this file's header for why `"absent"` is a
112
+ * value rather than the absence of one, and why neither `"granted"` nor
113
+ * `"denied"` is a boolean.
114
+ */
115
+ export type StandingState =
116
+ | { kind: "absent" }
117
+ | { kind: "denied"; policyVersion: PolicyVersion; decidedAt: string }
118
+ | { kind: "granted"; policyVersion: PolicyVersion; decidedAt: string };
119
+
120
+ /** One durable answer, for one subject, on one topic. */
121
+ export interface StandingInstruction {
122
+ /** Stable, host-owned id for this instruction. The currency gate joins usages to instructions on it. */
123
+ instructionId: string;
124
+ /** Host-owned identity reference — an opaque id, never raw personal data. */
125
+ subjectId: string;
126
+ topic: StandingTopic;
127
+ state: StandingState;
128
+ provenance: StandingProvenance;
129
+ currency: CurrencyWindow;
130
+ /** When the subject confirmed an INFERRED instruction. Absent on an unconfirmed inference; meaningless (and ignored) on a `"stated"` one. */
131
+ confirmedAt?: string;
132
+ }
133
+
134
+ /**
135
+ * The result of comparing a stored instruction against the policy in force
136
+ * and the clock. `"stale"` means a stored answer no longer speaks for the
137
+ * subject and they should be asked again; it carries WHY, because "the
138
+ * policy moved" and "the window ran out" are different facts about the
139
+ * same record and a consumer's re-ask copy will differ between them.
140
+ *
141
+ * `"absent"` likewise carries a reason. An unconfirmed inference and a
142
+ * subject who was genuinely never asked are both "we do not have a want
143
+ * here" — the same status, correctly — but they are not the same event,
144
+ * and flattening them would hide the more interesting of the two.
145
+ * `previousPolicyVersion` on a `"stale"` result is the version the stale
146
+ * answer actually answered, not the current one; `"granted"`/`"denied"`
147
+ * likewise report the version actually answered rather than echoing the
148
+ * caller's current version back.
149
+ */
150
+ export type StandingEvaluation =
151
+ | { status: "absent"; reason: "no-record" | "unconfirmed-inference" }
152
+ | { status: "stale"; reason: "policy-superseded" | "window-elapsed"; previousPolicyVersion: PolicyVersion; decidedAt: string }
153
+ | { status: "granted"; policyVersion: PolicyVersion }
154
+ | { status: "denied"; policyVersion: PolicyVersion };
155
+
156
+ /**
157
+ * Governs whether a policy-version bump also invalidates a stored `denied`
158
+ * record. Left as a caller-supplied value with **no default** in either
159
+ * direction: whether a policy bump invalidates a prior refusal is a
160
+ * jurisdiction judgment, and this package answers no jurisdiction
161
+ * questions. A `granted` record always goes stale on a version bump
162
+ * regardless of this flag; only the `denied` case is caller-decided.
163
+ */
164
+ export interface StandingEvaluationPolicy {
165
+ invalidateDenialOnPolicyBump: boolean;
166
+ }
167
+
168
+ /**
169
+ * The three things a subject can do to their own standing instruction.
170
+ * Every variant carries the `policyVersion` in force at the moment of the
171
+ * action — including `withdraw`, so an audit event never has to guess one
172
+ * or leave the field empty. There is no fourth, harder variant for
173
+ * withdrawing, and no variant an actor can use to decide FOR a subject.
174
+ */
175
+ export type StandingAction =
176
+ | { kind: "grant"; topic: StandingTopic; policyVersion: PolicyVersion; currency: CurrencyWindow }
177
+ | { kind: "deny"; topic: StandingTopic; policyVersion: PolicyVersion; currency: CurrencyWindow }
178
+ | { kind: "withdraw"; topic: StandingTopic; policyVersion: PolicyVersion; currency: CurrencyWindow };
179
+
180
+ /**
181
+ * `"reopened"` records that a subject reopened their preference surface —
182
+ * audit-worthy on its own, independent of whether they changed anything.
183
+ * `"policy-superseded"` and `"window-elapsed"` record that a stored answer
184
+ * was found stale, by a policy bump and by the clock respectively.
185
+ * `"confirmed"` and `"misread"` record the outcome of an intent read-back.
186
+ * None of the last four is emitted by `decideStandingChange`, which only
187
+ * ever emits `"granted"` / `"denied"` / `"withdrawn"`.
188
+ */
189
+ export type StandingAuditEventType =
190
+ | "granted"
191
+ | "denied"
192
+ | "withdrawn"
193
+ | "reopened"
194
+ | "policy-superseded"
195
+ | "window-elapsed"
196
+ | "confirmed"
197
+ | "misread";
198
+
199
+ /** Every audit event type, for a caller validating untyped input. */
200
+ export const STANDING_AUDIT_EVENT_TYPES: readonly StandingAuditEventType[] = [
201
+ "granted",
202
+ "denied",
203
+ "withdrawn",
204
+ "reopened",
205
+ "policy-superseded",
206
+ "window-elapsed",
207
+ "confirmed",
208
+ "misread",
209
+ ];
210
+
211
+ /**
212
+ * An audit trail entry. Deliberately carries no raw personal-data field —
213
+ * no email, no name, no phone, no address, no IP — only the opaque
214
+ * `subjectId` and the separately-opaque `actorId`. `topic` is a
215
+ * consumer-defined label, never itself personal data.
216
+ * `src/audit-shape.check.ts` is a compile-time contract test that fails the
217
+ * build if a personal-data-shaped key is ever added to this type.
218
+ *
219
+ * `previousPolicyVersion` is present only on a `"policy-superseded"` event;
220
+ * every other type's `policyVersion` fully describes which version the
221
+ * event pertains to on its own.
222
+ */
223
+ export interface StandingAuditEvent {
224
+ /** The subject the want belongs to. */
225
+ subjectId: string;
226
+ /** Whoever or whatever performed the action. Separate from `subjectId`, always. */
227
+ actorId: string;
228
+ topic: StandingTopic;
229
+ type: StandingAuditEventType;
230
+ policyVersion: PolicyVersion;
231
+ occurredAt: string;
232
+ previousPolicyVersion?: PolicyVersion;
233
+ }
234
+
235
+ /**
236
+ * Host-implemented audit ledger. This package decides what an audit event
237
+ * contains; the host decides where it is durably recorded. No
238
+ * implementation of this interface ships here, and no person-attributable
239
+ * record is ever written into this repository.
240
+ */
241
+ export interface StandingAuditLedger {
242
+ record(event: StandingAuditEvent): Promise<void>;
243
+ }
244
+
245
+ /**
246
+ * Host-implemented storage port. This package does not choose a database,
247
+ * a cookie, a session, or a file — that choice, and its durability
248
+ * guarantees, belongs entirely to the host, and no concrete implementation
249
+ * of this interface ships here.
250
+ */
251
+ export interface StandingInstructionStore {
252
+ read(subjectId: string, topic: StandingTopic): Promise<StandingInstruction | undefined>;
253
+ write(instruction: StandingInstruction): Promise<void>;
254
+ readAll(subjectId: string): Promise<readonly StandingInstruction[]>;
255
+ }
256
+
257
+ // ------------------------------------------------------------------- intents
258
+
259
+ /**
260
+ * What happened to an interpreted intent. `"acted"` means something was
261
+ * done in the world on the strength of this reading. `"handed-off"` means
262
+ * it was explicitly given to a person instead — the only legitimate exit
263
+ * for a reading the machine was not confident enough to act on.
264
+ * `"awaiting-confirmation"` means it is still waiting on the subject's own
265
+ * read-back and nothing has acted yet.
266
+ */
267
+ export type IntentDisposition = "acted" | "handed-off" | "awaiting-confirmation";
268
+
269
+ /** Every disposition, for a caller validating untyped input. */
270
+ export const INTENT_DISPOSITIONS: readonly IntentDisposition[] = ["acted", "handed-off", "awaiting-confirmation"];
271
+
272
+ /**
273
+ * One request, admitted on some channel and interpreted into a structured
274
+ * reading.
275
+ *
276
+ * `confidence` is a first-class value on the record, not an
277
+ * implementation detail left inside whichever model produced the reading:
278
+ * a number in `[0, 1]` that a gate can read months later and compare
279
+ * against the floor that was in force. `interpretation` is a
280
+ * consumer-defined label for what the reading concluded — this package
281
+ * never enumerates interpretations and never inspects the request text,
282
+ * which is why no field here holds it.
283
+ */
284
+ export interface IntentRecord {
285
+ intentId: string;
286
+ /** The person whose want this is. */
287
+ subjectId: string;
288
+ /** Whoever or whatever produced and dispositioned this reading. Separate from `subjectId`, always. */
289
+ actorId: string;
290
+ /** Consumer-defined label for what the reading concluded. */
291
+ interpretation: string;
292
+ /** `0`–`1` inclusive. Read against a declared floor; never against a floor this package invented. */
293
+ confidence: number;
294
+ observedAt: string;
295
+ disposition: IntentDisposition;
296
+ }
297
+
298
+ /**
299
+ * The subject's own answer to a read-back. Three outcomes, not two:
300
+ * `"unclear"` is the person saying they cannot tell, which is a real,
301
+ * distinct answer and must never be rounded up into `"confirmed"`.
302
+ */
303
+ export type ConfirmationVerdict = "confirmed" | "misread" | "unclear";
304
+
305
+ /** Every verdict, for a caller validating untyped input. */
306
+ export const CONFIRMATION_VERDICTS: readonly ConfirmationVerdict[] = ["confirmed", "misread", "unclear"];
307
+
308
+ /** One read-back answered by the subject, naming the intent it answers. */
309
+ export interface ConfirmationRecord {
310
+ intentId: string;
311
+ subjectId: string;
312
+ verdict: ConfirmationVerdict;
313
+ confirmedAt: string;
314
+ }
315
+
316
+ /**
317
+ * The declared confidence floor. Consumer-authored, with no default: this
318
+ * package will not invent the number below which a reading is too weak to
319
+ * act on, and a run that cannot find one refuses to run at all rather than
320
+ * assume one.
321
+ */
322
+ export interface ConfidenceFloor {
323
+ /** `0`–`1` inclusive. A reading strictly below this may not be acted on silently. */
324
+ minimumConfidence: number;
325
+ }
326
+
327
+ /**
328
+ * One occasion on which a standing instruction was actually relied on.
329
+ * This is the observation half of the loop: an instruction that is written
330
+ * and never re-checked is an open loop, and a usage record is what closes
331
+ * it — the currency gate reads these, not the instructions alone.
332
+ *
333
+ * `currentPolicyVersion` is the version in force AT THE MOMENT OF USE, not
334
+ * today's; that is what makes this record replayable rather than a
335
+ * derivation that changes every time it is read.
336
+ */
337
+ export interface InstructionUsage {
338
+ instructionId: string;
339
+ actorId: string;
340
+ usedAt: string;
341
+ currentPolicyVersion: PolicyVersion;
342
+ }
343
+
344
+ // --------------------------------------------------------------- parity paths
345
+
346
+ /**
347
+ * What one route through a consumer's own interface costs the person.
348
+ * Deliberately three coarse, countable facts rather than a score: a
349
+ * comparison that produces a number nobody can trace back to a step is not
350
+ * evidence of anything.
351
+ */
352
+ export interface PathCost {
353
+ /** How many discrete actions the person takes, counted the same way on both sides of the comparison. */
354
+ steps: number;
355
+ /** Whether the route requires contacting a human (writing in, calling) rather than completing it themselves. */
356
+ requiresContact: boolean;
357
+ /** Whether the route requires an authenticated account. */
358
+ requiresAccount: boolean;
359
+ }
360
+
361
+ /**
362
+ * The grant route and the withdraw route for one topic on one surface,
363
+ * measured the same way, so "withdrawing is no harder than granting" stops
364
+ * being an assurance and becomes a comparison. `withdraw` is optional
365
+ * precisely so its ABSENCE is representable and reportable — a surface
366
+ * that offers no way out at all is the worst version of this defect, and a
367
+ * required field would have made it unsayable.
368
+ */
369
+ export interface PreferencePath {
370
+ surfaceId: string;
371
+ topic: StandingTopic;
372
+ grant: PathCost;
373
+ withdraw?: PathCost;
374
+ }
375
+
376
+ // ------------------------------------------------------------------ validators
377
+
378
+ function result<T>(value: T | undefined, issues: ValidationIssue[]): ValidationResult<T> {
379
+ if (value === undefined || issues.length > 0) return { ok: false, issues };
380
+ return { ok: true, value };
381
+ }
382
+
383
+ function readPolicyVersion(value: unknown, path: string, issues: ValidationIssue[]): PolicyVersion | undefined {
384
+ if (!isPlainObject(value)) {
385
+ pushIssue(issues, path, "must be an object with policyId and version");
386
+ return undefined;
387
+ }
388
+ const before = issues.length;
389
+ const policyId = requireString(value.policyId, `${path}.policyId`, issues, { minLength: 1 });
390
+ const version = requireString(value.version, `${path}.version`, issues, { minLength: 1 });
391
+ if (issues.length > before || policyId === undefined || version === undefined) return undefined;
392
+ return { policyId, version };
393
+ }
394
+
395
+ function readCurrencyWindow(value: unknown, path: string, issues: ValidationIssue[]): CurrencyWindow | undefined {
396
+ if (!isPlainObject(value)) {
397
+ pushIssue(issues, path, "must be an object with a whole-number days field");
398
+ return undefined;
399
+ }
400
+ const days = requireNumber(value.days, `${path}.days`, issues, { min: 0, integer: true });
401
+ if (days === undefined) return undefined;
402
+ return { days };
403
+ }
404
+
405
+ function readStandingState(value: unknown, path: string, issues: ValidationIssue[]): StandingState | undefined {
406
+ if (!isPlainObject(value)) {
407
+ pushIssue(issues, path, "must be an object with a kind of absent, denied, or granted");
408
+ return undefined;
409
+ }
410
+ const kind = value.kind;
411
+ if (kind === "absent") return { kind: "absent" };
412
+ if (kind !== "denied" && kind !== "granted") {
413
+ pushIssue(issues, `${path}.kind`, 'must be "absent", "denied", or "granted"');
414
+ return undefined;
415
+ }
416
+ const before = issues.length;
417
+ const policyVersion = readPolicyVersion(value.policyVersion, `${path}.policyVersion`, issues);
418
+ const decidedAt = requireTimestamp(value.decidedAt, `${path}.decidedAt`, issues);
419
+ if (issues.length > before || policyVersion === undefined || decidedAt === undefined) return undefined;
420
+ return { kind, policyVersion, decidedAt };
421
+ }
422
+
423
+ function readStandingInstruction(value: unknown, path: string, issues: ValidationIssue[]): StandingInstruction | undefined {
424
+ if (!isPlainObject(value)) {
425
+ pushIssue(issues, path, "must be an object");
426
+ return undefined;
427
+ }
428
+ const before = issues.length;
429
+ const instructionId = requireString(value.instructionId, `${path}.instructionId`, issues, { minLength: 1 });
430
+ const subjectId = requireString(value.subjectId, `${path}.subjectId`, issues, { minLength: 1 });
431
+ const topic = requireString(value.topic, `${path}.topic`, issues, { minLength: 1 });
432
+ const state = readStandingState(value.state, `${path}.state`, issues);
433
+ const currency = readCurrencyWindow(value.currency, `${path}.currency`, issues);
434
+ const confirmedAt = optionalTimestamp(value.confirmedAt, `${path}.confirmedAt`, issues);
435
+ if (!isOneOf(value.provenance, STANDING_PROVENANCES)) {
436
+ pushIssue(issues, `${path}.provenance`, 'must be "stated" or "inferred"');
437
+ }
438
+ if (issues.length > before) return undefined;
439
+ if (instructionId === undefined || subjectId === undefined || topic === undefined || state === undefined || currency === undefined) return undefined;
440
+ return {
441
+ instructionId,
442
+ subjectId,
443
+ topic,
444
+ state,
445
+ provenance: value.provenance as StandingProvenance,
446
+ currency,
447
+ ...(confirmedAt !== undefined ? { confirmedAt } : {}),
448
+ };
449
+ }
450
+
451
+ function readIntentRecord(value: unknown, path: string, issues: ValidationIssue[]): IntentRecord | undefined {
452
+ if (!isPlainObject(value)) {
453
+ pushIssue(issues, path, "must be an object");
454
+ return undefined;
455
+ }
456
+ const before = issues.length;
457
+ const intentId = requireString(value.intentId, `${path}.intentId`, issues, { minLength: 1 });
458
+ const subjectId = requireString(value.subjectId, `${path}.subjectId`, issues, { minLength: 1 });
459
+ const actorId = requireString(value.actorId, `${path}.actorId`, issues, { minLength: 1 });
460
+ const interpretation = requireString(value.interpretation, `${path}.interpretation`, issues, { minLength: 1 });
461
+ const confidence = requireNumber(value.confidence, `${path}.confidence`, issues, { min: 0, max: 1 });
462
+ const observedAt = requireTimestamp(value.observedAt, `${path}.observedAt`, issues);
463
+ if (!isOneOf(value.disposition, INTENT_DISPOSITIONS)) {
464
+ pushIssue(issues, `${path}.disposition`, 'must be "acted", "handed-off", or "awaiting-confirmation"');
465
+ }
466
+ if (issues.length > before) return undefined;
467
+ if (
468
+ intentId === undefined ||
469
+ subjectId === undefined ||
470
+ actorId === undefined ||
471
+ interpretation === undefined ||
472
+ confidence === undefined ||
473
+ observedAt === undefined
474
+ ) {
475
+ return undefined;
476
+ }
477
+ return { intentId, subjectId, actorId, interpretation, confidence, observedAt, disposition: value.disposition as IntentDisposition };
478
+ }
479
+
480
+ function readConfirmationRecord(value: unknown, path: string, issues: ValidationIssue[]): ConfirmationRecord | undefined {
481
+ if (!isPlainObject(value)) {
482
+ pushIssue(issues, path, "must be an object");
483
+ return undefined;
484
+ }
485
+ const before = issues.length;
486
+ const intentId = requireString(value.intentId, `${path}.intentId`, issues, { minLength: 1 });
487
+ const subjectId = requireString(value.subjectId, `${path}.subjectId`, issues, { minLength: 1 });
488
+ const confirmedAt = requireTimestamp(value.confirmedAt, `${path}.confirmedAt`, issues);
489
+ if (!isOneOf(value.verdict, CONFIRMATION_VERDICTS)) {
490
+ pushIssue(issues, `${path}.verdict`, 'must be "confirmed", "misread", or "unclear"');
491
+ }
492
+ if (issues.length > before) return undefined;
493
+ if (intentId === undefined || subjectId === undefined || confirmedAt === undefined) return undefined;
494
+ return { intentId, subjectId, verdict: value.verdict as ConfirmationVerdict, confirmedAt };
495
+ }
496
+
497
+ function readInstructionUsage(value: unknown, path: string, issues: ValidationIssue[]): InstructionUsage | undefined {
498
+ if (!isPlainObject(value)) {
499
+ pushIssue(issues, path, "must be an object");
500
+ return undefined;
501
+ }
502
+ const before = issues.length;
503
+ const instructionId = requireString(value.instructionId, `${path}.instructionId`, issues, { minLength: 1 });
504
+ const actorId = requireString(value.actorId, `${path}.actorId`, issues, { minLength: 1 });
505
+ const usedAt = requireTimestamp(value.usedAt, `${path}.usedAt`, issues);
506
+ const currentPolicyVersion = readPolicyVersion(value.currentPolicyVersion, `${path}.currentPolicyVersion`, issues);
507
+ if (issues.length > before) return undefined;
508
+ if (instructionId === undefined || actorId === undefined || usedAt === undefined || currentPolicyVersion === undefined) return undefined;
509
+ return { instructionId, actorId, usedAt, currentPolicyVersion };
510
+ }
511
+
512
+ function readPathCost(value: unknown, path: string, issues: ValidationIssue[]): PathCost | undefined {
513
+ if (!isPlainObject(value)) {
514
+ pushIssue(issues, path, "must be an object with steps, requiresContact and requiresAccount");
515
+ return undefined;
516
+ }
517
+ const before = issues.length;
518
+ const steps = requireNumber(value.steps, `${path}.steps`, issues, { min: 0, integer: true });
519
+ const requiresContact = requireBoolean(value.requiresContact, `${path}.requiresContact`, issues);
520
+ const requiresAccount = requireBoolean(value.requiresAccount, `${path}.requiresAccount`, issues);
521
+ if (issues.length > before || steps === undefined || requiresContact === undefined || requiresAccount === undefined) return undefined;
522
+ return { steps, requiresContact, requiresAccount };
523
+ }
524
+
525
+ function readPreferencePath(value: unknown, path: string, issues: ValidationIssue[]): PreferencePath | undefined {
526
+ if (!isPlainObject(value)) {
527
+ pushIssue(issues, path, "must be an object");
528
+ return undefined;
529
+ }
530
+ const before = issues.length;
531
+ const surfaceId = requireString(value.surfaceId, `${path}.surfaceId`, issues, { minLength: 1 });
532
+ const topic = requireString(value.topic, `${path}.topic`, issues, { minLength: 1 });
533
+ const grant = readPathCost(value.grant, `${path}.grant`, issues);
534
+ const withdraw = value.withdraw === undefined ? undefined : readPathCost(value.withdraw, `${path}.withdraw`, issues);
535
+ if (issues.length > before || surfaceId === undefined || topic === undefined || grant === undefined) return undefined;
536
+ return { surfaceId, topic, grant, ...(withdraw !== undefined ? { withdraw } : {}) };
537
+ }
538
+
539
+ /** Validates one untyped `StandingInstruction`. Never throws. */
540
+ export function validateStandingInstruction(value: unknown): ValidationResult<StandingInstruction> {
541
+ const issues: ValidationIssue[] = [];
542
+ return result(readStandingInstruction(value, "(root)", issues), issues);
543
+ }
544
+
545
+ /** Validates an untyped array of `StandingInstruction`s. Never throws. */
546
+ export function validateStandingInstructions(value: unknown): ValidationResult<StandingInstruction[]> {
547
+ const issues: ValidationIssue[] = [];
548
+ return result(requireArrayOf(value, "(root)", issues, readStandingInstruction), issues);
549
+ }
550
+
551
+ /** Validates one untyped `IntentRecord`. Never throws. */
552
+ export function validateIntentRecord(value: unknown): ValidationResult<IntentRecord> {
553
+ const issues: ValidationIssue[] = [];
554
+ return result(readIntentRecord(value, "(root)", issues), issues);
555
+ }
556
+
557
+ /** Validates an untyped array of `IntentRecord`s. Never throws. */
558
+ export function validateIntentRecords(value: unknown): ValidationResult<IntentRecord[]> {
559
+ const issues: ValidationIssue[] = [];
560
+ return result(requireArrayOf(value, "(root)", issues, readIntentRecord), issues);
561
+ }
562
+
563
+ /** Validates one untyped `ConfirmationRecord`. Never throws. */
564
+ export function validateConfirmationRecord(value: unknown): ValidationResult<ConfirmationRecord> {
565
+ const issues: ValidationIssue[] = [];
566
+ return result(readConfirmationRecord(value, "(root)", issues), issues);
567
+ }
568
+
569
+ /** Validates an untyped array of `ConfirmationRecord`s. Never throws. */
570
+ export function validateConfirmationRecords(value: unknown): ValidationResult<ConfirmationRecord[]> {
571
+ const issues: ValidationIssue[] = [];
572
+ return result(requireArrayOf(value, "(root)", issues, readConfirmationRecord), issues);
573
+ }
574
+
575
+ /** Validates an untyped array of `InstructionUsage`s. Never throws. */
576
+ export function validateInstructionUsages(value: unknown): ValidationResult<InstructionUsage[]> {
577
+ const issues: ValidationIssue[] = [];
578
+ return result(requireArrayOf(value, "(root)", issues, readInstructionUsage), issues);
579
+ }
580
+
581
+ /** Validates an untyped array of `PreferencePath`s. Never throws. */
582
+ export function validatePreferencePaths(value: unknown): ValidationResult<PreferencePath[]> {
583
+ const issues: ValidationIssue[] = [];
584
+ return result(requireArrayOf(value, "(root)", issues, readPreferencePath), issues);
585
+ }
586
+
587
+ /**
588
+ * Validates an untyped `ConfidenceFloor`. Separate from the record
589
+ * validators because the floor is not a record: it is the one declared
590
+ * value the confirmation gate reads, and a run that cannot validate it
591
+ * must decline rather than substitute one.
592
+ */
593
+ export function validateConfidenceFloor(value: unknown): ValidationResult<ConfidenceFloor> {
594
+ const issues: ValidationIssue[] = [];
595
+ if (!isPlainObject(value)) {
596
+ pushIssue(issues, "(root)", "must be an object with a minimumConfidence field");
597
+ return { ok: false, issues };
598
+ }
599
+ const minimumConfidence = requireNumber(value.minimumConfidence, "(root).minimumConfidence", issues, { min: 0, max: 1 });
600
+ return result(minimumConfidence === undefined ? undefined : { minimumConfidence }, issues);
601
+ }
602
+
603
+ /** Validates an untyped `PolicyVersion`. Never throws. */
604
+ export function validatePolicyVersion(value: unknown): ValidationResult<PolicyVersion> {
605
+ const issues: ValidationIssue[] = [];
606
+ return result(readPolicyVersion(value, "(root)", issues), issues);
607
+ }
608
+
609
+ /** Convenience guard over `validateStandingInstruction`, for callers that only need the boolean answer at a type boundary. */
610
+ export function isStandingInstruction(value: unknown): value is StandingInstruction {
611
+ return validateStandingInstruction(value).ok;
612
+ }
613
+
614
+ /** Convenience guard over `validateIntentRecord`. */
615
+ export function isIntentRecord(value: unknown): value is IntentRecord {
616
+ return validateIntentRecord(value).ok;
617
+ }
618
+
619
+ /** Convenience guard over `validateConfirmationRecord`. */
620
+ export function isConfirmationRecord(value: unknown): value is ConfirmationRecord {
621
+ return validateConfirmationRecord(value).ok;
622
+ }