@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
@@ -0,0 +1,534 @@
1
+ /**
2
+ * The runtime contract: evaluate one stored want, decide one change to it,
3
+ * and the three gates that read a whole set of them.
4
+ *
5
+ * THE LOOP THIS CLOSES
6
+ * ---------------------
7
+ * A stated want is the setpoint. Acting on it is the act. Reading the
8
+ * interpretation back to the person, and asking whether the standing
9
+ * answer is still current, is the observation. Unconfirmed intents and
10
+ * expired instructions are the comparison. Re-asking is the correction. A
11
+ * preference written to a row and never re-checked has no observation and
12
+ * no comparison — it is an open loop, and closing it is the entire reason
13
+ * this package exists.
14
+ *
15
+ * Everything here is pure. No I/O, no clock read, no ambient state:
16
+ * `evaluateStandingInstruction` and `decideStandingChange` take `now` as a
17
+ * parameter rather than calling `Date.now()` themselves, so the same
18
+ * inputs always produce the same output and a test can put the clock
19
+ * anywhere it needs it. Storage and audit are host-supplied ports
20
+ * (`StandingInstructionStore`, `StandingAuditLedger` in `schema.ts`); this
21
+ * file never touches either.
22
+ *
23
+ * THE THREE GATES
24
+ * ----------------
25
+ * Each returns the same shape of three-state result: `ok: true` when it
26
+ * genuinely checked something and found nothing; `ok: false` with a
27
+ * violation reason and findings when it checked and found something; and
28
+ * `ok: false` with an INDETERMINATE reason when there was nothing to check
29
+ * at all. `cli.ts` maps those onto `0` / `1` / `2` and never collapses the
30
+ * third into either of the first two. A gate that reports "clean" after
31
+ * checking nothing is worse than no gate.
32
+ */
33
+
34
+ import type {
35
+ ConfidenceFloor,
36
+ ConfirmationRecord,
37
+ CurrencyWindow,
38
+ InstructionUsage,
39
+ IntentRecord,
40
+ PolicyVersion,
41
+ PreferencePath,
42
+ StandingAction,
43
+ StandingAuditEvent,
44
+ StandingEvaluation,
45
+ StandingEvaluationPolicy,
46
+ StandingInstruction,
47
+ StandingTopic,
48
+ } from "./schema.js";
49
+
50
+ const MILLISECONDS_PER_DAY = 86_400_000;
51
+
52
+ function samePolicyVersion(a: PolicyVersion, b: PolicyVersion): boolean {
53
+ return a.policyId === b.policyId && a.version === b.version;
54
+ }
55
+
56
+ function windowElapsed(decidedAt: string, currency: CurrencyWindow, at: string): boolean {
57
+ const decided = Date.parse(decidedAt);
58
+ const asked = Date.parse(at);
59
+ if (Number.isNaN(decided) || Number.isNaN(asked)) return false;
60
+ return asked - decided > currency.days * MILLISECONDS_PER_DAY;
61
+ }
62
+
63
+ /**
64
+ * Compares one stored instruction against the policy in force AND the
65
+ * clock. This is the check a weaker tool skips: asking whether a row
66
+ * exists, or reading a boolean off it, cannot see age, so a row three
67
+ * policy versions old and a year past its own declared window both read as
68
+ * a pass. Presence is not currency.
69
+ *
70
+ * - `undefined` input, and a record whose own `state.kind` is `"absent"`,
71
+ * both return `{ status: "absent", reason: "no-record" }`. There is no
72
+ * third "never asked" representation, and this function deliberately does
73
+ * not runtime-validate its typed input or fall back to `"absent"` for a
74
+ * malformed one — a silent fallback would make a broken record
75
+ * indistinguishable from a subject who was genuinely never asked. Untyped
76
+ * input is validated at the boundary, by `schema.ts`'s validators.
77
+ * - An INFERRED instruction with no `confirmedAt` returns
78
+ * `{ status: "absent", reason: "unconfirmed-inference" }`. An inferred
79
+ * want is not a want we have until the person says it is; treating one as
80
+ * binding is the same defect as reading absence as permission, one step
81
+ * further upstream.
82
+ * - A `"granted"` record whose `policyVersion` differs from
83
+ * `currentPolicyVersion` is ALWAYS stale, unconditionally. A policy
84
+ * change invalidates prior permission by definition.
85
+ * - A `"denied"` record whose `policyVersion` differs is stale only when
86
+ * `policy.invalidateDenialOnPolicyBump` is `true`. Whether a policy bump
87
+ * also invalidates a prior refusal is a jurisdiction judgment; `policy`
88
+ * has no default, so nobody gets either answer by accident.
89
+ * - Independently of the policy version, a record whose `decidedAt` is more
90
+ * than `currency.days` before `at` is stale with reason
91
+ * `"window-elapsed"` — for grants AND denials alike. The window is the
92
+ * consumer's own declared statement of how long their own answer keeps
93
+ * speaking, and a refusal that has expired is as much a reason to ask
94
+ * again as a permission that has.
95
+ *
96
+ * When both a policy bump and an elapsed window apply, `"policy-superseded"`
97
+ * is reported: it is the more specific fact, it names something that
98
+ * happened on this side of the boundary rather than merely time passing,
99
+ * and either way the answer is the same — ask again.
100
+ */
101
+ export function evaluateStandingInstruction(
102
+ instruction: StandingInstruction | undefined,
103
+ currentPolicyVersion: PolicyVersion,
104
+ policy: StandingEvaluationPolicy,
105
+ at: string,
106
+ ): StandingEvaluation {
107
+ if (instruction === undefined || instruction.state.kind === "absent") {
108
+ return { status: "absent", reason: "no-record" };
109
+ }
110
+ if (instruction.provenance === "inferred" && instruction.confirmedAt === undefined) {
111
+ return { status: "absent", reason: "unconfirmed-inference" };
112
+ }
113
+
114
+ const { state } = instruction;
115
+ const current = samePolicyVersion(state.policyVersion, currentPolicyVersion);
116
+ const expired = windowElapsed(state.decidedAt, instruction.currency, at);
117
+
118
+ if (state.kind === "granted") {
119
+ if (!current) return { status: "stale", reason: "policy-superseded", previousPolicyVersion: state.policyVersion, decidedAt: state.decidedAt };
120
+ if (expired) return { status: "stale", reason: "window-elapsed", previousPolicyVersion: state.policyVersion, decidedAt: state.decidedAt };
121
+ return { status: "granted", policyVersion: state.policyVersion };
122
+ }
123
+
124
+ // state.kind === "denied"
125
+ if (!current && policy.invalidateDenialOnPolicyBump) {
126
+ return { status: "stale", reason: "policy-superseded", previousPolicyVersion: state.policyVersion, decidedAt: state.decidedAt };
127
+ }
128
+ if (expired) return { status: "stale", reason: "window-elapsed", previousPolicyVersion: state.policyVersion, decidedAt: state.decidedAt };
129
+ return { status: "denied", policyVersion: state.policyVersion };
130
+ }
131
+
132
+ /**
133
+ * The pure decision core for one change to one standing instruction:
134
+ * produces the new record and its audit event. The caller's
135
+ * `StandingInstructionStore` and `StandingAuditLedger` perform the writes.
136
+ *
137
+ * `actorId` and `subjectId` are separate parameters, in that order, and
138
+ * are never derived from one another — an actor recording a subject's own
139
+ * decision and an actor deciding on a subject's behalf must be
140
+ * distinguishable in the audit trail, and a single conflated id makes them
141
+ * identical forever.
142
+ *
143
+ * Withdrawing produces `state: { kind: "absent" }`: the same value a
144
+ * subject who was never asked has. That is deliberate and it is the point
145
+ * of the three-state model — a withdrawal returns the person to "we do not
146
+ * have a want here", which is not permission, rather than to a `false`
147
+ * that some later boolean read could round back up.
148
+ */
149
+ export function decideStandingChange(
150
+ actorId: string,
151
+ subjectId: string,
152
+ instructionId: string,
153
+ action: StandingAction,
154
+ now: string,
155
+ ): { instruction: StandingInstruction; auditEvent: StandingAuditEvent } {
156
+ const base = { instructionId, subjectId, topic: action.topic, provenance: "stated" as const, currency: action.currency };
157
+ const instruction: StandingInstruction =
158
+ action.kind === "grant"
159
+ ? { ...base, state: { kind: "granted", policyVersion: action.policyVersion, decidedAt: now } }
160
+ : action.kind === "deny"
161
+ ? { ...base, state: { kind: "denied", policyVersion: action.policyVersion, decidedAt: now } }
162
+ : { ...base, state: { kind: "absent" } };
163
+
164
+ const auditEvent: StandingAuditEvent = {
165
+ subjectId,
166
+ actorId,
167
+ topic: action.topic,
168
+ type: action.kind === "grant" ? "granted" : action.kind === "deny" ? "denied" : "withdrawn",
169
+ policyVersion: action.policyVersion,
170
+ occurredAt: now,
171
+ };
172
+ return { instruction, auditEvent };
173
+ }
174
+
175
+ /**
176
+ * A pure audit-event builder for a subject reopening their preference
177
+ * surface, independent of whether they change anything once it is open.
178
+ * Reopening at all is audit-worthy on its own: it is the observation half
179
+ * of the loop, and a surface nobody ever reopens is a surface whose
180
+ * instructions are never re-checked.
181
+ */
182
+ export function recordReopened(
183
+ actorId: string,
184
+ subjectId: string,
185
+ topic: StandingTopic,
186
+ policyVersion: PolicyVersion,
187
+ now: string,
188
+ ): StandingAuditEvent {
189
+ return { subjectId, actorId, topic, type: "reopened", policyVersion, occurredAt: now };
190
+ }
191
+
192
+ /**
193
+ * A pure audit-event builder for the moment `evaluateStandingInstruction`
194
+ * reports a stored answer stale. `reason` picks the event type, so an
195
+ * auditor can tell a policy bump from an expired window without re-deriving
196
+ * it. `previousPolicyVersion` is the version the invalidated record
197
+ * actually answered; `currentPolicyVersion` is the one in force now.
198
+ * Calling this is optional — the evaluation itself performs no I/O and
199
+ * emits nothing — but a host that wants staleness to appear in its trail
200
+ * rather than only ever being silently recomputed on read calls it once it
201
+ * observes a `"stale"` evaluation.
202
+ */
203
+ export function recordStaleness(
204
+ actorId: string,
205
+ subjectId: string,
206
+ topic: StandingTopic,
207
+ reason: "policy-superseded" | "window-elapsed",
208
+ previousPolicyVersion: PolicyVersion,
209
+ currentPolicyVersion: PolicyVersion,
210
+ now: string,
211
+ ): StandingAuditEvent {
212
+ return {
213
+ subjectId,
214
+ actorId,
215
+ topic,
216
+ type: reason,
217
+ policyVersion: currentPolicyVersion,
218
+ previousPolicyVersion,
219
+ occurredAt: now,
220
+ };
221
+ }
222
+
223
+ // ------------------------------------------------- gate 1: confirmation completeness
224
+
225
+ export type ConfirmationFindingKind =
226
+ /** Something was done on this reading and the subject never answered a read-back at all. */
227
+ | "acted-without-confirmation"
228
+ /** Something was done on this reading after the subject said it was misread. */
229
+ | "acted-against-misread"
230
+ /** Something was done on this reading after the subject said they could not tell. `"unclear"` is not a quiet yes. */
231
+ | "acted-against-unclear"
232
+ /** A reading below the declared floor was acted on with no hand-off and no confirmation. */
233
+ | "below-floor-acted-silently"
234
+ /** A read-back answers an intent that is not in the set being checked. */
235
+ | "confirmation-without-intent";
236
+
237
+ export interface ConfirmationFinding {
238
+ kind: ConfirmationFindingKind;
239
+ intentId: string;
240
+ /** The actor that dispositioned the intent, or the subject that answered a dangling read-back. Never both, never merged. */
241
+ actorId?: string;
242
+ message: string;
243
+ }
244
+
245
+ export type ConfirmationFailureReason = "unconfirmed-intents" | "no-intents-provided";
246
+
247
+ export interface ConfirmationCompletenessResult {
248
+ ok: boolean;
249
+ reason?: ConfirmationFailureReason;
250
+ intentsChecked: number;
251
+ confirmationsChecked: number;
252
+ /** The floor actually applied, echoed back so a report names the number it judged against rather than implying a universal one. */
253
+ floorApplied: number;
254
+ findings: ConfirmationFinding[];
255
+ }
256
+
257
+ /**
258
+ * GATE 1 — every acted-on intent has the subject's own confirmation, or an
259
+ * explicit below-floor hand-off.
260
+ *
261
+ * Pure, no I/O. `floor` is supplied by the caller and has no default
262
+ * anywhere in this package: the number below which a reading is too weak
263
+ * to act on is one of the consumer's own values, and a floor this package
264
+ * invented would be this package authoring it.
265
+ *
266
+ * The below-floor rule is the AI-native half. A reading whose confidence
267
+ * is strictly below the floor may still be acted on — but only after the
268
+ * subject confirms it. What it may never be is acted on SILENTLY: a
269
+ * below-floor reading has to become a confirmation request or an explicit
270
+ * hand-off, and `"handed-off"` is a first-class disposition precisely so
271
+ * declining to act is representable as a decision rather than as an
272
+ * absence of one.
273
+ *
274
+ * `ok: false` with `"no-intents-provided"` is not a violation — it is this
275
+ * gate saying it never formed an opinion, and `cli.ts` maps it to `2`.
276
+ */
277
+ export function checkConfirmationCompleteness(
278
+ intents: readonly IntentRecord[],
279
+ confirmations: readonly ConfirmationRecord[],
280
+ floor: ConfidenceFloor,
281
+ ): ConfirmationCompletenessResult {
282
+ const base = { intentsChecked: intents.length, confirmationsChecked: confirmations.length, floorApplied: floor.minimumConfidence };
283
+ if (intents.length === 0) {
284
+ return { ok: false, reason: "no-intents-provided", ...base, findings: [] };
285
+ }
286
+
287
+ const byIntentId = new Map<string, ConfirmationRecord>();
288
+ for (const confirmation of confirmations) byIntentId.set(confirmation.intentId, confirmation);
289
+ const knownIntentIds = new Set(intents.map((intent) => intent.intentId));
290
+
291
+ const findings: ConfirmationFinding[] = [];
292
+ for (const intent of intents) {
293
+ const confirmation = byIntentId.get(intent.intentId);
294
+ const belowFloor = intent.confidence < floor.minimumConfidence;
295
+
296
+ if (intent.disposition !== "acted") {
297
+ // "handed-off" and "awaiting-confirmation" are both explicit,
298
+ // recorded decisions not to act yet. Neither needs a confirmation to
299
+ // be complete, because neither has done anything.
300
+ continue;
301
+ }
302
+
303
+ if (confirmation === undefined) {
304
+ findings.push({
305
+ kind: belowFloor ? "below-floor-acted-silently" : "acted-without-confirmation",
306
+ intentId: intent.intentId,
307
+ actorId: intent.actorId,
308
+ message: belowFloor
309
+ ? `acted on a reading of confidence ${intent.confidence} below the declared floor ${floor.minimumConfidence}, with no confirmation and no hand-off`
310
+ : "acted with no confirmation record from the subject",
311
+ });
312
+ continue;
313
+ }
314
+ if (confirmation.verdict === "misread") {
315
+ findings.push({
316
+ kind: "acted-against-misread",
317
+ intentId: intent.intentId,
318
+ actorId: intent.actorId,
319
+ message: "acted after the subject answered the read-back with \"misread\"",
320
+ });
321
+ continue;
322
+ }
323
+ if (confirmation.verdict === "unclear") {
324
+ findings.push({
325
+ kind: "acted-against-unclear",
326
+ intentId: intent.intentId,
327
+ actorId: intent.actorId,
328
+ message: "acted after the subject answered the read-back with \"unclear\"",
329
+ });
330
+ }
331
+ }
332
+
333
+ for (const confirmation of confirmations) {
334
+ if (knownIntentIds.has(confirmation.intentId)) continue;
335
+ findings.push({
336
+ kind: "confirmation-without-intent",
337
+ intentId: confirmation.intentId,
338
+ message: "a read-back answers an intent that is not in the set being checked",
339
+ });
340
+ }
341
+
342
+ if (findings.length > 0) return { ok: false, reason: "unconfirmed-intents", ...base, findings };
343
+ return { ok: true, ...base, findings: [] };
344
+ }
345
+
346
+ // -------------------------------------------------------------- gate 2: currency
347
+
348
+ export type CurrencyFindingKind =
349
+ /** An instruction was relied on after its own declared window ran out. */
350
+ | "used-past-window"
351
+ /** An instruction was relied on after the policy version it answered was superseded. */
352
+ | "used-after-policy-superseded"
353
+ /** An instruction was relied on while there was no answer on record — including an inference the subject never confirmed. Absence is not permission. */
354
+ | "used-while-absent"
355
+ /** A usage names an instruction that is not in the set being checked. */
356
+ | "usage-without-instruction";
357
+
358
+ export interface CurrencyFinding {
359
+ kind: CurrencyFindingKind;
360
+ instructionId: string;
361
+ /** Whoever relied on the instruction. Separate from the instruction's own `subjectId`, always. */
362
+ actorId: string;
363
+ usedAt: string;
364
+ message: string;
365
+ }
366
+
367
+ export type CurrencyFailureReason = "stale-instructions-used" | "no-instructions-provided" | "no-usages-provided";
368
+
369
+ export interface CurrencyResult {
370
+ ok: boolean;
371
+ reason?: CurrencyFailureReason;
372
+ instructionsChecked: number;
373
+ usagesChecked: number;
374
+ findings: CurrencyFinding[];
375
+ }
376
+
377
+ /**
378
+ * GATE 2 — no standing instruction is used past its declared window.
379
+ *
380
+ * Pure, no I/O. Reads USAGES, not instructions alone, and that is the
381
+ * whole design: a set of instructions with nobody relying on them proves
382
+ * nothing, while a set of usages is a record of the loop actually being
383
+ * closed or not. Each usage carries the policy version in force at the
384
+ * moment it happened, so this gate replays a real decision rather than
385
+ * re-deriving one against today's policy.
386
+ *
387
+ * `policy.invalidateDenialOnPolicyBump` has no default — see
388
+ * `evaluateStandingInstruction`. A caller that cannot supply it does not
389
+ * get a guess.
390
+ *
391
+ * `used-while-absent` is here because it is the same defect the three-state
392
+ * model exists to prevent, observed one layer up: relying on nothing at all
393
+ * — a subject never asked, or an inference they never confirmed — is not a
394
+ * currency problem in the narrow sense, but it is exactly what a boolean
395
+ * read of a missing row silently permits, and a gate that only measured
396
+ * age would pass it.
397
+ *
398
+ * `ok: false` with either "nothing provided" reason is indeterminate, and
399
+ * `cli.ts` maps it to `2`.
400
+ */
401
+ export function checkCurrency(
402
+ instructions: readonly StandingInstruction[],
403
+ usages: readonly InstructionUsage[],
404
+ policy: StandingEvaluationPolicy,
405
+ ): CurrencyResult {
406
+ const base = { instructionsChecked: instructions.length, usagesChecked: usages.length };
407
+ if (instructions.length === 0) return { ok: false, reason: "no-instructions-provided", ...base, findings: [] };
408
+ if (usages.length === 0) return { ok: false, reason: "no-usages-provided", ...base, findings: [] };
409
+
410
+ const byId = new Map<string, StandingInstruction>();
411
+ for (const instruction of instructions) byId.set(instruction.instructionId, instruction);
412
+
413
+ const findings: CurrencyFinding[] = [];
414
+ for (const usage of usages) {
415
+ const instruction = byId.get(usage.instructionId);
416
+ if (instruction === undefined) {
417
+ findings.push({
418
+ kind: "usage-without-instruction",
419
+ instructionId: usage.instructionId,
420
+ actorId: usage.actorId,
421
+ usedAt: usage.usedAt,
422
+ message: "relied on an instruction that is not in the set being checked",
423
+ });
424
+ continue;
425
+ }
426
+
427
+ const evaluation = evaluateStandingInstruction(instruction, usage.currentPolicyVersion, policy, usage.usedAt);
428
+ if (evaluation.status === "absent") {
429
+ findings.push({
430
+ kind: "used-while-absent",
431
+ instructionId: usage.instructionId,
432
+ actorId: usage.actorId,
433
+ usedAt: usage.usedAt,
434
+ message:
435
+ evaluation.reason === "unconfirmed-inference"
436
+ ? "relied on an inferred instruction the subject never confirmed"
437
+ : "relied on an instruction with no answer on record",
438
+ });
439
+ continue;
440
+ }
441
+ if (evaluation.status === "stale") {
442
+ findings.push({
443
+ kind: evaluation.reason === "window-elapsed" ? "used-past-window" : "used-after-policy-superseded",
444
+ instructionId: usage.instructionId,
445
+ actorId: usage.actorId,
446
+ usedAt: usage.usedAt,
447
+ message:
448
+ evaluation.reason === "window-elapsed"
449
+ ? `relied on an answer decided at ${evaluation.decidedAt}, past its declared window of ${instruction.currency.days} day(s)`
450
+ : `relied on an answer to policy ${evaluation.previousPolicyVersion.policyId}@${evaluation.previousPolicyVersion.version}, superseded by ${usage.currentPolicyVersion.policyId}@${usage.currentPolicyVersion.version}`,
451
+ });
452
+ }
453
+ }
454
+
455
+ if (findings.length > 0) return { ok: false, reason: "stale-instructions-used", ...base, findings };
456
+ return { ok: true, ...base, findings: [] };
457
+ }
458
+
459
+ // ------------------------------------------------------ gate 3: withdrawal parity
460
+
461
+ export type WithdrawalParityFindingKind =
462
+ /** The surface declares a way in and no way out. */
463
+ | "withdrawal-unavailable"
464
+ /** Withdrawing takes more discrete actions than granting did. */
465
+ | "withdrawal-costs-more-steps"
466
+ /** Withdrawing requires contacting a human where granting did not. */
467
+ | "withdrawal-requires-contact"
468
+ /** Withdrawing requires an account where granting did not. */
469
+ | "withdrawal-requires-account";
470
+
471
+ export interface WithdrawalParityFinding {
472
+ kind: WithdrawalParityFindingKind;
473
+ surfaceId: string;
474
+ topic: StandingTopic;
475
+ message: string;
476
+ }
477
+
478
+ export type WithdrawalParityFailureReason = "withdrawal-harder-than-granting" | "no-paths-provided";
479
+
480
+ export interface WithdrawalParityResult {
481
+ ok: boolean;
482
+ reason?: WithdrawalParityFailureReason;
483
+ pathsChecked: number;
484
+ findings: WithdrawalParityFinding[];
485
+ }
486
+
487
+ /**
488
+ * GATE 3 — withdrawing is no harder than granting.
489
+ *
490
+ * Pure, no I/O. Compares the two routes a consumer measured itself, using
491
+ * three coarse countable facts rather than a score: a parity claim that
492
+ * produces a number nobody can trace back to a step is not evidence.
493
+ *
494
+ * A missing `withdraw` path is the loudest finding here, not a skipped
495
+ * comparison. A surface that offers a way in and no way out is the extreme
496
+ * of the same defect, and a gate that quietly had nothing to compare would
497
+ * report it as a pass.
498
+ *
499
+ * Reopening is not a degraded path, and this gate is only the measurement
500
+ * half of that. The API half is structural and lives in `./web`, where
501
+ * `withdraw` shares `grant`'s and `deny`'s exact call shape, so there is no
502
+ * separate, harder-to-reach function for revoking than for giving.
503
+ *
504
+ * `ok: false` with `"no-paths-provided"` is indeterminate, and `cli.ts`
505
+ * maps it to `2`.
506
+ */
507
+ export function checkWithdrawalParity(paths: readonly PreferencePath[]): WithdrawalParityResult {
508
+ if (paths.length === 0) return { ok: false, reason: "no-paths-provided", pathsChecked: 0, findings: [] };
509
+
510
+ const findings: WithdrawalParityFinding[] = [];
511
+ for (const path of paths) {
512
+ const where = { surfaceId: path.surfaceId, topic: path.topic };
513
+ if (path.withdraw === undefined) {
514
+ findings.push({ ...where, kind: "withdrawal-unavailable", message: "declares a grant path and no withdraw path at all" });
515
+ continue;
516
+ }
517
+ if (path.withdraw.steps > path.grant.steps) {
518
+ findings.push({
519
+ ...where,
520
+ kind: "withdrawal-costs-more-steps",
521
+ message: `withdrawing takes ${path.withdraw.steps} step(s) against granting's ${path.grant.steps}`,
522
+ });
523
+ }
524
+ if (path.withdraw.requiresContact && !path.grant.requiresContact) {
525
+ findings.push({ ...where, kind: "withdrawal-requires-contact", message: "withdrawing requires contacting a human where granting did not" });
526
+ }
527
+ if (path.withdraw.requiresAccount && !path.grant.requiresAccount) {
528
+ findings.push({ ...where, kind: "withdrawal-requires-account", message: "withdrawing requires an account where granting did not" });
529
+ }
530
+ }
531
+
532
+ if (findings.length > 0) return { ok: false, reason: "withdrawal-harder-than-granting", pathsChecked: paths.length, findings };
533
+ return { ok: true, pathsChecked: paths.length, findings: [] };
534
+ }