@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,172 @@
1
+ /**
2
+ * Shared, dependency-free validation primitives for `schema.ts`. This
3
+ * package hand-rolls its entity validation rather than depending on a
4
+ * schema library — see `schema.ts`'s top-level doc comment for why, and
5
+ * `@clossys/strategist`'s `validation.ts` for the precedent this
6
+ * file follows: plain type guards over `unknown`, accumulating findings
7
+ * into an array, never throwing.
8
+ *
9
+ * Every `require*` helper below takes the same three leading arguments —
10
+ * `value` (the candidate, still `unknown`), `path` (an absolute,
11
+ * human-readable location like `"instructions[2].currency.days"`), and
12
+ * `issues` (the caller's shared, mutated-in-place array) — and returns
13
+ * either the narrowed value or `undefined`. A caller never has to inspect
14
+ * the return value to know whether something went wrong: every failure is
15
+ * also recorded into `issues` at the moment it is discovered, so the
16
+ * standard pattern (used throughout `schema.ts`) is to snapshot
17
+ * `issues.length` before validating an object's fields and compare it
18
+ * after — if it grew, something in this object failed, regardless of which
19
+ * field.
20
+ */
21
+
22
+ export interface ValidationIssue {
23
+ /** Absolute, dot/bracket-joined location, e.g. `"intents[0].confidence"`, or `"(root)"` for a whole-value shape problem. */
24
+ path: string;
25
+ message: string;
26
+ }
27
+
28
+ export type ValidationResult<T> = { ok: true; value: T } | { ok: false; issues: ValidationIssue[] };
29
+
30
+ /** A hand-rolled validator: takes an unknown value, returns a `ValidationResult<T>`. Never throws. */
31
+ export type Validator<T> = (value: unknown) => ValidationResult<T>;
32
+
33
+ export function isPlainObject(value: unknown): value is Record<string, unknown> {
34
+ return typeof value === "object" && value !== null && !Array.isArray(value);
35
+ }
36
+
37
+ /** A closed-vocabulary check. Membership is decided by the caller's own literal list, never by a shape heuristic. */
38
+ export function isOneOf<T extends string>(value: unknown, list: readonly T[]): value is T {
39
+ return typeof value === "string" && (list as readonly string[]).includes(value);
40
+ }
41
+
42
+ /** A short, readable description of an arbitrary value for an error message — never the value's full (potentially huge, potentially person-attributable) content. */
43
+ export function describeValue(value: unknown): string {
44
+ if (value === undefined) return "undefined";
45
+ if (value === null) return "null";
46
+ if (Array.isArray(value)) return `an array (${value.length} item(s))`;
47
+ const t = typeof value;
48
+ if (t === "object") return "an object";
49
+ if (t === "string") return JSON.stringify(value);
50
+ return String(value);
51
+ }
52
+
53
+ export function pushIssue(issues: ValidationIssue[], path: string, message: string): void {
54
+ issues.push({ path, message });
55
+ }
56
+
57
+ export function requireString(
58
+ value: unknown,
59
+ path: string,
60
+ issues: ValidationIssue[],
61
+ opts?: { minLength?: number },
62
+ ): string | undefined {
63
+ if (typeof value !== "string") {
64
+ pushIssue(issues, path, `must be a string, got ${describeValue(value)}`);
65
+ return undefined;
66
+ }
67
+ const minLength = opts?.minLength ?? 0;
68
+ if (value.trim().length < minLength) {
69
+ pushIssue(issues, path, `must be at least ${minLength} non-whitespace character(s) long`);
70
+ return undefined;
71
+ }
72
+ return value;
73
+ }
74
+
75
+ /** Same as `requireString`, except `undefined` is valid (the field is optional) and produces no issue. */
76
+ export function optionalString(
77
+ value: unknown,
78
+ path: string,
79
+ issues: ValidationIssue[],
80
+ opts?: { minLength?: number },
81
+ ): string | undefined {
82
+ if (value === undefined) return undefined;
83
+ return requireString(value, path, issues, opts);
84
+ }
85
+
86
+ /**
87
+ * A timestamp this package can actually order and subtract. A record whose
88
+ * time cannot be parsed is a validation failure, never a record silently
89
+ * treated as "now" — the currency gate's whole job is arithmetic on these
90
+ * values, and a fabricated one would make an expired instruction read as
91
+ * current.
92
+ */
93
+ export function requireTimestamp(value: unknown, path: string, issues: ValidationIssue[]): string | undefined {
94
+ const asString = requireString(value, path, issues, { minLength: 1 });
95
+ if (asString === undefined) return undefined;
96
+ if (Number.isNaN(Date.parse(asString))) {
97
+ pushIssue(issues, path, `must be a parseable timestamp, got ${describeValue(value)}`);
98
+ return undefined;
99
+ }
100
+ return asString;
101
+ }
102
+
103
+ /** Same as `requireTimestamp`, except `undefined` is valid (the field is optional) and produces no issue. */
104
+ export function optionalTimestamp(value: unknown, path: string, issues: ValidationIssue[]): string | undefined {
105
+ if (value === undefined) return undefined;
106
+ return requireTimestamp(value, path, issues);
107
+ }
108
+
109
+ export function requireNumber(
110
+ value: unknown,
111
+ path: string,
112
+ issues: ValidationIssue[],
113
+ opts?: { min?: number; max?: number; integer?: boolean },
114
+ ): number | undefined {
115
+ if (typeof value !== "number" || !Number.isFinite(value)) {
116
+ pushIssue(issues, path, `must be a finite number, got ${describeValue(value)}`);
117
+ return undefined;
118
+ }
119
+ if (opts?.integer === true && !Number.isInteger(value)) {
120
+ pushIssue(issues, path, `must be a whole number, got ${describeValue(value)}`);
121
+ return undefined;
122
+ }
123
+ if (opts?.min !== undefined && value < opts.min) {
124
+ pushIssue(issues, path, `must be at least ${opts.min}, got ${describeValue(value)}`);
125
+ return undefined;
126
+ }
127
+ if (opts?.max !== undefined && value > opts.max) {
128
+ pushIssue(issues, path, `must be at most ${opts.max}, got ${describeValue(value)}`);
129
+ return undefined;
130
+ }
131
+ return value;
132
+ }
133
+
134
+ /** A real boolean, never a truthy string or number — the same discipline the donor's `isGpcSignal` applies to `present`. */
135
+ export function requireBoolean(value: unknown, path: string, issues: ValidationIssue[]): boolean | undefined {
136
+ if (typeof value !== "boolean") {
137
+ pushIssue(issues, path, `must be a boolean, got ${describeValue(value)}`);
138
+ return undefined;
139
+ }
140
+ return value;
141
+ }
142
+
143
+ /**
144
+ * An array where each item is read by `itemReader` — the same
145
+ * `(value, path, issues) => T | undefined` shape every function in this
146
+ * file follows, so an entity's own per-object reader (e.g. `readIntent` in
147
+ * `schema.ts`) plugs directly into this without an adapter.
148
+ */
149
+ export function requireArrayOf<T>(
150
+ value: unknown,
151
+ path: string,
152
+ issues: ValidationIssue[],
153
+ itemReader: (item: unknown, itemPath: string, issues: ValidationIssue[]) => T | undefined,
154
+ ): T[] | undefined {
155
+ if (!Array.isArray(value)) {
156
+ pushIssue(issues, path, `must be an array, got ${describeValue(value)}`);
157
+ return undefined;
158
+ }
159
+ const out: T[] = [];
160
+ let ok = true;
161
+ value.forEach((item, i) => {
162
+ const result = itemReader(item, `${path}[${i}]`, issues);
163
+ if (result === undefined) ok = false;
164
+ else out.push(result);
165
+ });
166
+ return ok ? out : undefined;
167
+ }
168
+
169
+ /** Joins `ValidationIssue[]` into one-line-per-issue text a CLI can print directly. */
170
+ export function summarizeIssues(issues: readonly ValidationIssue[]): string {
171
+ return issues.map((issue) => `${issue.path}: ${issue.message}`).join("; ");
172
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * @clossys/butler/web — preference-surface state, built entirely on
3
+ * the root package's pure functions.
4
+ *
5
+ * `react`/`react-dom` are OPTIONAL peers of this subpath specifically (see
6
+ * package.json's `peerDependenciesMeta`) — importing
7
+ * `@clossys/butler` (the root) or `@clossys/butler/inbound`
8
+ * never pulls in React; only importing `@clossys/butler/web` does.
9
+ * This module asserts the installed `react` version against this package's
10
+ * declared range at import time, so an absent or incompatible React fails
11
+ * loudly here instead of crashing later inside a hook with no version named
12
+ * as the cause. See `internal/peer-version.ts` for the guard itself.
13
+ *
14
+ * This subpath ships no rendering and no copy. What a preference surface
15
+ * says to a person, and what it looks like, are the consumer's own values;
16
+ * what is offered here is the state machine underneath it, including the
17
+ * structural half of withdrawal parity — see `useStandingWants`.
18
+ */
19
+ import { version as reactVersion } from "react";
20
+ import { assertPeerVersion } from "./internal/peer-version.js";
21
+
22
+ /** Must match package.json's `peerDependencies.react` exactly — `peer-guard.test.ts` asserts that directly. */
23
+ export const REACT_DECLARED_RANGE = ">=18";
24
+ assertPeerVersion({ peer: "react", declaredRange: REACT_DECLARED_RANGE, foundVersion: reactVersion });
25
+
26
+ export { useStandingWants } from "./useStandingWants.js";
27
+ export type { StandingWantsClient, UseStandingWantsOptions, UseStandingWantsResult } from "./useStandingWants.js";
@@ -0,0 +1,159 @@
1
+ /**
2
+ * `assertPeerVersion` — the runtime half of `./web`'s "optional peer, no
3
+ * install-time signal in either direction" problem. `react`/`react-dom` are
4
+ * declared `peerDependenciesMeta: { optional: true }` (see package.json) so
5
+ * a consumer can install `@clossys/butler` (the root, provider-
6
+ * neutral core) without ever installing React — only `./web` needs it. But
7
+ * an ABSENT or OUT-OF-RANGE `react` produces no signal of any kind without
8
+ * this guard: a consumer on an incompatible React version would otherwise
9
+ * learn about it from whatever `./web` happened to crash on deep inside
10
+ * React itself, with nothing naming a version range as the cause. See
11
+ * `web/index.ts`'s own guard call, evaluated once at import time via
12
+ * `react`'s own exported `version`, for where this is wired in.
13
+ *
14
+ * This is the same obligation this repository's own contribution guide
15
+ * states for every requirement this workspace takes on: requiring a
16
+ * prerequisite is legitimate; failing silently when it is unmet is not,
17
+ * because that turns a setup error into a debugging session inside
18
+ * somebody else's codebase.
19
+ *
20
+ * PORTED, NOT SHARED, from `packages/consent/src/web/internal/peer-version.ts`
21
+ * — identical algorithm, copied rather than imported across a package
22
+ * boundary for the structural reason that file's own header gives: that
23
+ * package does not expose this as part of its public API surface, and even
24
+ * if it did, `@clossys/butler` would gain nothing by taking a real
25
+ * runtime dependency on a sibling just to reach one shared utility, and its
26
+ * "zero runtime dependencies" claim would then be wrong. Keep the copies in
27
+ * sync by hand if the ported range algorithm ever changes.
28
+ *
29
+ * DELIBERATELY PURE — NO `node:*` IMPORTS IN THIS FILE. `./web`'s entry
30
+ * point is reachable from a browser bundle (a client component rendering
31
+ * a preference surface), not just a Node process, so the version check reads
32
+ * `react`'s own exported `version` directly rather than any Node-only
33
+ * fs-based resolver.
34
+ */
35
+
36
+ // ------------------------------------------------------------- range parsing
37
+
38
+ interface Bound {
39
+ major: number;
40
+ minor: number;
41
+ patch: number;
42
+ }
43
+
44
+ /** Strict x.y.z only — same as scripts/check-workspace-links.mjs's parseVersion(). */
45
+ function parseVersion(version: string): Bound | null {
46
+ const m = /^(\d+)\.(\d+)\.(\d+)$/.exec(String(version).trim());
47
+ if (!m) return null;
48
+ return { major: Number(m[1]), minor: Number(m[2]), patch: Number(m[3]) };
49
+ }
50
+
51
+ function compareVersions(a: Bound, b: Bound): number {
52
+ return a.major - b.major || a.minor - b.minor || a.patch - b.patch;
53
+ }
54
+
55
+ /**
56
+ * An exact pin, a caret range, or a tilde range against a plain x.y.z. For
57
+ * `0.y.z`, BOTH `^` and `~` are minor-locked; above `0.y.z`, `^` is
58
+ * major-locked and only `~` is minor-locked. Returns `null` —
59
+ * unparseable — for anything else, including the `>=`/`<` forms
60
+ * `parseGteForm` below understands instead.
61
+ */
62
+ function parsePinCaretTilde(range: string): { lower: Bound; upper: Bound } | null {
63
+ const m = /^(\^|~)?(\d+)\.(\d+)\.(\d+)$/.exec(String(range).trim());
64
+ if (!m) return null;
65
+ const prefix = m[1] ?? "";
66
+ const major = Number(m[2]);
67
+ const minor = Number(m[3]);
68
+ const patch = Number(m[4]);
69
+ if (prefix === "") return { lower: { major, minor, patch }, upper: { major, minor, patch: patch + 1 } };
70
+ if (major === 0) return { lower: { major, minor, patch }, upper: { major, minor: minor + 1, patch: 0 } };
71
+ if (prefix === "^") return { lower: { major, minor, patch }, upper: { major: major + 1, minor: 0, patch: 0 } };
72
+ return { lower: { major, minor, patch }, upper: { major, minor: minor + 1, patch: 0 } }; // "~"
73
+ }
74
+
75
+ /**
76
+ * A bounded `>=x[.y[.z]] <a[.b[.c]]>` range or an unbounded `>=x[.y[.z]]`
77
+ * range — the shape this package's own `peerDependencies` uses for React
78
+ * (`">=18"`). A version segment omitted from either side defaults to its
79
+ * lowest value (`18` reads as `18.0.0`), matching ordinary semver range
80
+ * convention. Returns `null` — unparseable — for anything else.
81
+ */
82
+ function parseGteForm(range: string): { lower: Bound; upper: Bound | null } | null {
83
+ const trimmed = String(range).trim();
84
+ const segment = "(\\d+)(?:\\.(\\d+))?(?:\\.(\\d+))?";
85
+ const bounded = new RegExp(`^>=\\s*${segment}\\s+<\\s*${segment}$`).exec(trimmed);
86
+ if (bounded) {
87
+ const [, lMaj, lMin, lPat, uMaj, uMin, uPat] = bounded;
88
+ return {
89
+ lower: { major: Number(lMaj), minor: Number(lMin ?? "0"), patch: Number(lPat ?? "0") },
90
+ upper: { major: Number(uMaj), minor: Number(uMin ?? "0"), patch: Number(uPat ?? "0") },
91
+ };
92
+ }
93
+ const unbounded = new RegExp(`^>=\\s*${segment}$`).exec(trimmed);
94
+ if (unbounded) {
95
+ const [, maj, min, pat] = unbounded;
96
+ return { lower: { major: Number(maj), minor: Number(min ?? "0"), patch: Number(pat ?? "0") }, upper: null };
97
+ }
98
+ return null;
99
+ }
100
+
101
+ type RangeSatisfaction = { evaluated: true; ok: boolean } | { evaluated: false; reason: string };
102
+
103
+ /**
104
+ * Returns `{ evaluated: false, reason }` when either side could not be
105
+ * parsed — a finding, never assumed satisfied — or `{ evaluated: true, ok
106
+ * }` once both sides parsed cleanly.
107
+ */
108
+ function satisfiesRange(versionStr: string, rangeStr: string): RangeSatisfaction {
109
+ const bound = parsePinCaretTilde(rangeStr) ?? parseGteForm(rangeStr);
110
+ if (!bound) {
111
+ return {
112
+ evaluated: false,
113
+ reason: `"${rangeStr}" is not a range form this guard parses (an exact pin, ^x.y.z, ~x.y.z, ">=x.y.z <a.b.c>", or ">=x.y.z" are supported)`,
114
+ };
115
+ }
116
+ const version = parseVersion(versionStr);
117
+ if (!version) {
118
+ return { evaluated: false, reason: `the installed version "${versionStr}" is not a plain x.y.z semver this guard can compare` };
119
+ }
120
+ const geLower = compareVersions(version, bound.lower) >= 0;
121
+ const ltUpper = bound.upper === null ? true : compareVersions(version, bound.upper) < 0;
122
+ return { evaluated: true, ok: geLower && ltUpper };
123
+ }
124
+
125
+ // ------------------------------------------------------------------ the guard
126
+
127
+ export interface AssertPeerVersionInput {
128
+ /** The optional peer's package name, e.g. `"react"`. */
129
+ peer: string;
130
+ /** This package's own `peerDependencies` range for `peer`. */
131
+ declaredRange: string;
132
+ /** The peer's real installed version, or `undefined` if it could not be resolved at all. */
133
+ foundVersion: string | undefined;
134
+ }
135
+
136
+ /**
137
+ * Throws a named, actionable error naming the package, the declared range,
138
+ * and the version actually found. Never returns a boolean — a guard must
139
+ * state where control goes when it declines. A missing peer and an
140
+ * out-of-range peer throw genuinely DIFFERENT messages — "not installed"
141
+ * and "installed but incompatible" are different problems with different
142
+ * fixes. An unparseable declared range or installed version is a third,
143
+ * equally loud error, never an assumed pass.
144
+ */
145
+ export function assertPeerVersion(input: AssertPeerVersionInput): void {
146
+ const { peer, declaredRange, foundVersion } = input;
147
+
148
+ if (foundVersion === undefined) {
149
+ throw new Error(`${peer} is required for this import but is not installed. Install ${peer}@"${declaredRange}" — see this package's README for its optional-peer setup.`);
150
+ }
151
+
152
+ const outcome = satisfiesRange(foundVersion, declaredRange);
153
+ if (!outcome.evaluated) {
154
+ throw new Error(`Could not verify ${peer}@${foundVersion} against this package's declared range "${declaredRange}": ${outcome.reason}. Refusing to assume this is compatible.`);
155
+ }
156
+ if (!outcome.ok) {
157
+ throw new Error(`${peer}@${foundVersion} is installed, but this package requires ${peer}@"${declaredRange}". Installed but incompatible — install a version of ${peer} that satisfies "${declaredRange}".`);
158
+ }
159
+ }
@@ -0,0 +1,139 @@
1
+ import { useCallback, useEffect, useState } from "react";
2
+ import { evaluateStandingInstruction } from "../contract.js";
3
+ import type {
4
+ CurrencyWindow,
5
+ PolicyVersion,
6
+ StandingAction,
7
+ StandingEvaluation,
8
+ StandingEvaluationPolicy,
9
+ StandingInstruction,
10
+ StandingTopic,
11
+ } from "../schema.js";
12
+
13
+ /**
14
+ * The client-side counterpart to `StandingInstructionStore`.
15
+ *
16
+ * `StandingInstructionStore` is `Promise`-based host-implemented I/O, most
17
+ * naturally implemented behind a server boundary — a database read, a
18
+ * server-side session — that a browser cannot call directly as a plain
19
+ * async function. A hook that genuinely reads and writes standing
20
+ * instructions needs a client-shaped port instead: something a browser
21
+ * really can call, typically a `fetch` to a host-owned route that itself
22
+ * runs `decideStandingChange` and writes both the instruction and the
23
+ * audit event server-side. This is that port — still host-implemented,
24
+ * still carrying no opinion about transport, but shaped for the side of
25
+ * the boundary that actually runs in a browser.
26
+ */
27
+ export interface StandingWantsClient {
28
+ /** Reads every stored instruction for `subjectId`, however the host's own API/storage boundary is reached. */
29
+ read(subjectId: string): Promise<readonly StandingInstruction[]>;
30
+ /**
31
+ * Applies one decided action and returns the durably-stored instruction.
32
+ * The host is expected to run `decideStandingChange` (or equivalent) on
33
+ * its own server and write both the instruction and the audit event
34
+ * before resolving — this hook never computes or stores anything itself.
35
+ */
36
+ apply(subjectId: string, action: StandingAction): Promise<StandingInstruction>;
37
+ }
38
+
39
+ export interface UseStandingWantsOptions {
40
+ subjectId: string;
41
+ /** The topics this preference surface manages. Determines the keys of `evaluations`. */
42
+ topics: readonly StandingTopic[];
43
+ policyVersion: PolicyVersion;
44
+ /** The window written onto any instruction this surface decides. No default — see `CurrencyWindow`. */
45
+ currency: CurrencyWindow;
46
+ /** No default — see `StandingEvaluationPolicy`; the host must decide this explicitly. */
47
+ evaluationPolicy: StandingEvaluationPolicy;
48
+ /** The moment to evaluate currency against, as an ISO timestamp. Supplied by the caller so a surface renders the same answer on the server and on the client rather than drifting with an ambient clock. */
49
+ now: string;
50
+ client: StandingWantsClient;
51
+ }
52
+
53
+ export interface UseStandingWantsResult {
54
+ /** One evaluation per requested topic. A topic with no stored instruction evaluates to `absent`, never to a falsy value that could read as a denial or as permission. */
55
+ evaluations: Readonly<Record<StandingTopic, StandingEvaluation>>;
56
+ /** `true` while the initial `client.read` call is in flight. */
57
+ loading: boolean;
58
+ /** The error thrown by the most recent `client.read`/`client.apply` call, if any. Never thrown by this hook itself. */
59
+ error: unknown;
60
+ grant(topic: StandingTopic): Promise<void>;
61
+ deny(topic: StandingTopic): Promise<void>;
62
+ /**
63
+ * Withdraw is reachable through the SAME call shape as grant and deny —
64
+ * one topic, one promise, one function on the same object. That is
65
+ * withdrawal parity enforced structurally at the API surface, not
66
+ * asserted in prose: there is no separate, harder-to-reach function and
67
+ * no extra argument for revoking a want than for giving one. The
68
+ * `withdrawal-parity` gate measures the same property one layer out, in
69
+ * a consumer's real interface, where this hook cannot see.
70
+ */
71
+ withdraw(topic: StandingTopic): Promise<void>;
72
+ }
73
+
74
+ /**
75
+ * Manages a preference surface's standing wants: reads every stored
76
+ * instruction for `subjectId` once on mount, evaluates each of `topics`
77
+ * against `policyVersion` AND the supplied `now`, and exposes
78
+ * `grant`/`deny`/`withdraw` as identically-shaped async functions backed by
79
+ * the same `client.apply` call.
80
+ *
81
+ * The evaluation is currency-aware, which is the whole difference between
82
+ * this and a hook that reads a row: an instruction that exists, and was
83
+ * granted, and is a year past its own declared window comes back `stale`,
84
+ * not `granted`. Rendering a surface off `evaluations` therefore re-asks
85
+ * on its own rather than quietly continuing to act on an answer nobody has
86
+ * checked since it was written.
87
+ */
88
+ export function useStandingWants(options: UseStandingWantsOptions): UseStandingWantsResult {
89
+ const { subjectId, topics, policyVersion, currency, evaluationPolicy, now, client } = options;
90
+ const [instructions, setInstructions] = useState<Readonly<Record<StandingTopic, StandingInstruction>>>({});
91
+ const [loading, setLoading] = useState(true);
92
+ const [error, setError] = useState<unknown>(undefined);
93
+
94
+ useEffect(() => {
95
+ let cancelled = false;
96
+ setLoading(true);
97
+ setError(undefined);
98
+ client
99
+ .read(subjectId)
100
+ .then((all) => {
101
+ if (cancelled) return;
102
+ const byTopic: Record<StandingTopic, StandingInstruction> = {};
103
+ for (const instruction of all) byTopic[instruction.topic] = instruction;
104
+ setInstructions(byTopic);
105
+ })
106
+ .catch((caught: unknown) => {
107
+ if (!cancelled) setError(caught);
108
+ })
109
+ .finally(() => {
110
+ if (!cancelled) setLoading(false);
111
+ });
112
+ return () => {
113
+ cancelled = true;
114
+ };
115
+ }, [client, subjectId]);
116
+
117
+ const applyAction = useCallback(
118
+ async (action: StandingAction) => {
119
+ try {
120
+ const instruction = await client.apply(subjectId, action);
121
+ setInstructions((previous) => ({ ...previous, [instruction.topic]: instruction }));
122
+ } catch (caught: unknown) {
123
+ setError(caught);
124
+ }
125
+ },
126
+ [client, subjectId],
127
+ );
128
+
129
+ const grant = useCallback((topic: StandingTopic) => applyAction({ kind: "grant", topic, policyVersion, currency }), [applyAction, policyVersion, currency]);
130
+ const deny = useCallback((topic: StandingTopic) => applyAction({ kind: "deny", topic, policyVersion, currency }), [applyAction, policyVersion, currency]);
131
+ const withdraw = useCallback((topic: StandingTopic) => applyAction({ kind: "withdraw", topic, policyVersion, currency }), [applyAction, policyVersion, currency]);
132
+
133
+ const evaluations: Record<StandingTopic, StandingEvaluation> = {};
134
+ for (const topic of topics) {
135
+ evaluations[topic] = evaluateStandingInstruction(instructions[topic], policyVersion, evaluationPolicy, now);
136
+ }
137
+
138
+ return { evaluations, loading, error, grant, deny, withdraw };
139
+ }