@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.
- package/CHANGELOG.md +85 -0
- package/LICENSE +21 -0
- package/README.md +260 -0
- package/dist/audit-shape.check.d.ts +32 -0
- package/dist/audit-shape.check.d.ts.map +1 -0
- package/dist/audit-shape.check.js +7 -0
- package/dist/audit-shape.check.js.map +1 -0
- package/dist/cli.d.ts +54 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +426 -0
- package/dist/cli.js.map +1 -0
- package/dist/contract.d.ts +256 -0
- package/dist/contract.d.ts.map +1 -0
- package/dist/contract.js +377 -0
- package/dist/contract.js.map +1 -0
- package/dist/inbound/index.d.ts +120 -0
- package/dist/inbound/index.d.ts.map +1 -0
- package/dist/inbound/index.js +125 -0
- package/dist/inbound/index.js.map +1 -0
- package/dist/index.d.ts +50 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +47 -0
- package/dist/index.js.map +1 -0
- package/dist/schema.d.ts +374 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +304 -0
- package/dist/schema.js.map +1 -0
- package/dist/validation.d.ts +74 -0
- package/dist/validation.d.ts.map +1 -0
- package/dist/validation.js +140 -0
- package/dist/validation.js.map +1 -0
- package/dist/web/index.d.ts +5 -0
- package/dist/web/index.d.ts.map +1 -0
- package/dist/web/index.js +25 -0
- package/dist/web/index.js.map +1 -0
- package/dist/web/internal/peer-version.d.ts +53 -0
- package/dist/web/internal/peer-version.d.ts.map +1 -0
- package/dist/web/internal/peer-version.js +136 -0
- package/dist/web/internal/peer-version.js.map +1 -0
- package/dist/web/useStandingWants.d.ts +75 -0
- package/dist/web/useStandingWants.d.ts.map +1 -0
- package/dist/web/useStandingWants.js +66 -0
- package/dist/web/useStandingWants.js.map +1 -0
- package/package.json +93 -0
- package/src/audit-shape.check.ts +37 -0
- package/src/cli.ts +445 -0
- package/src/contract.ts +534 -0
- package/src/inbound/index.ts +190 -0
- package/src/index.ts +113 -0
- package/src/schema.ts +622 -0
- package/src/validation.ts +172 -0
- package/src/web/index.ts +27 -0
- package/src/web/internal/peer-version.ts +159 -0
- 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
|
+
}
|