@clossys/butler
v0.3.1
Published
The butler role: do we have what this person wants — this request in their own confirmation, and their standing instructions, still current? Dependency-free machinery for a consumer's own want records: a three-state standing model (absent/denied/granted)
Maintainers
Readme
@clossys/butler
Everything about what a person wants — now, and standing.
The question this role answers, and no other role does:
Do we have what this person wants — this request in their own confirmation, and their standing instructions, still current?
Ships the schema and the checkers; every consumer authors its own values.
The loop this closes
A stated want is the setpoint. Acting on it is the act. Reading the interpretation back, and asking whether the standing answer is still current, is the observation. Unconfirmed intents and expired instructions are the comparison. Re-asking is the correction.
A preference written to a row and never re-checked has no observation and no comparison. It is an open loop, and closing it is the reason this package exists.
The defect this exists to prevent
A weaker tool checks that a consent row exists. It passes on a row three policy versions old, and on a row a year past the window its own author declared, because a boolean read of "granted" cannot see age. Presence is not currency.
Two rules follow, and both are structural here rather than advisory:
- Consent is three states, never two.
absent(never asked) is a distinct value fromdenied(asked, refused), and neither is a boolean. Absence can therefore never be read as permission — including when the record store is unreachable, when the subject was never asked, and when the only thing on file is an inference nobody confirmed. - A gate that cannot run must say so.
butler-checkexits2when the record store cannot be read or there was nothing to scan, and2is never collapsed into0or1.
Install
npm install @clossys/butlerThis package is published to https://registry.npmjs.org with public
access; installing it requires no authentication.
import { checkCurrency, evaluateStandingInstruction } from "@clossys/butler";Confirmed current intent rate
Independent consumer evidence shows the position's owned metric meets its
setpoint over the declared review cadence. The owned metric is confirmed
current intent rate, computed by assessConfirmedCurrentIntentRate(). An
empty evaluated set is indeterminate, never a perfect rate of 1.
checkConfirmationCompleteness and checkCurrency remain the gates they
are; neither is this rate. This package does not measure consumer evidence
and does not close the loop. A green run of this package's tests is not a
close.
import { assessConfirmedCurrentIntentRate } from "@clossys/butler";
const report = assessConfirmedCurrentIntentRate(input);butler-rate-check assessment.jsonThe command prints JSON and exits 0 for satisfied, 1 for violated, and
2 for indeterminate, unreadable, or invalid input.
This package declares that command as its first-day assessment surface in its own manifest:
"foundry": { "assessment": { "bin": "butler-rate-check", "invocation": "single-json-input" } }Onboarding discovers that declaration from the installed manifest and never
infers a surface. butler-check remains the three-gate CLI and is not the
assessment surface. Butler is not a required first-day role; Advisor
remains the only required first-day assessment.
The three gates
All three are reachable from one installed bin, butler-check, dispatched
on the first argument matching a gate name exactly. The charter assessment
is a second mapped bin, butler-rate-check, and is not a fourth gate on
this dispatcher.
butler-check confirmation-completeness ./intents.json ./confirmations.json --floor 0.8
butler-check currency ./instructions.json ./usages.json --invalidate-denial-on-policy-bump false
butler-check withdrawal-parity ./paths.jsonconfirmation-completeness
Every acted-on intent has the subject's own confirmation record, or an
explicit below-floor hand-off. It fails on an intent dispositioned acted
with no confirmation at all, on one acted against a misread or unclear
read-back, on a reading below the declared floor acted on with neither a
hand-off nor a confirmation, and on a read-back answering an intent outside
the set being checked.
--floor is required and has no default. The number below which a reading
is too weak to act on is one of the consumer's own values.
currency
No standing instruction is used past its declared window. It reads usages, not instructions alone: a set of instructions nobody relies on proves nothing, while a usage record is the loop actually being closed or not. Each usage carries the policy version in force at the moment it happened, so the gate replays a real decision rather than re-deriving one against today's policy. It fails on a usage past the window, on a usage after the answered policy version was superseded, on a usage of an instruction with no answer on record — including an unconfirmed inference — and on a usage naming an instruction outside the set being checked.
--invalidate-denial-on-policy-bump is required and has no default in
either direction. Whether a policy bump also invalidates a prior refusal is
a jurisdiction judgment this package does not make.
withdrawal-parity
Withdrawing is no harder than granting. It compares the grant route and the withdraw route a consumer measured itself, using three coarse countable facts rather than a score, and fails when withdrawing takes more steps, demands contacting a human that granting did not, demands an account that granting did not, or is not offered at all.
Reopening is not a degraded path, and this gate is only the measurement
half of that. The API half is structural and lives in ./web.
Exit codes
| Code | Meaning |
| --- | --- |
| 0 | Ran against a non-empty record set and found nothing. |
| 1 | Ran and found at least one real violation. |
| 2 | Could not run: a missing, unreadable, unparseable or schema-invalid record store; an empty record set; a required declared value that was not supplied; or no gate selected at all. |
2 is not a variant of failure. "I checked and it is fine" and "I never
checked" are different answers, and a gate that reports the second as the
first is worse than no gate.
A bare butler-check with no subcommand exits 2, not 0. Nothing was
selected, so nothing was checked, and a CI step with a dropped argument
must go red rather than green on the strength of having examined nothing.
An explicitly requested --help is the one exception, and exits 0,
because asking for help is a run that did exactly what was asked.
API
Everything below is exported from the package root. The ./inbound,
./web, ./browser-consent and ./browser-consent/local-storage
subpaths are documented in their own sections after it.
Calendar windows
| Export | What it does |
| --- | --- |
| addCalendarMonthsUtc(from: Date, months: number): Date | Adds whole calendar months in UTC, clamping to the target month's last day and preserving hours, minutes, seconds and milliseconds. Returns a new Date. |
| isWithinWindow(start: Date, end: Date, now: Date): boolean | Tests start <= now < end; the end is exclusive. Empty and reversed windows return false. |
| CalendarMonthsError | A RangeError subclass thrown for invalid Dates, negative, fractional or non-finite month counts, or a result outside the supported Date range. |
import { addCalendarMonthsUtc, isWithinWindow } from "@clossys/butler";
const start = new Date("2026-08-31T13:14:15.678Z");
const end = addCalendarMonthsUtc(start, 6); // 2027-02-28T13:14:15.678Z
isWithinWindow(start, end, end); // falseBoth helpers are pure and use caller-supplied dates; from is not modified.
Evaluating and deciding
| Export | What it does |
| --- | --- |
| evaluateStandingInstruction | Compares one stored answer against the policy version in force and the clock. Returns granted, denied, absent, or stale. |
| decideStandingChange | The pure decision core for one change: returns the new instruction and its audit event. Takes the actor and the subject as separate arguments. |
| recordReopened | Builds the audit event for a subject reopening their preference surface, independent of any decision made inside it. |
| recordStaleness | Builds the audit event for an answer found stale, with the reason as the event type. |
The gates, as pure functions
| Export | What it does |
| --- | --- |
| checkConfirmationCompleteness | Gate 1, over intents, confirmations, and a declared floor. |
| checkCurrency | Gate 2, over instructions, usages, and the caller's denial-invalidation decision. |
| checkWithdrawalParity | Gate 3, over measured preference paths. |
| assessConfirmedCurrentIntentRate | Charter close metric. Returns ConfirmedCurrentIntentRateAssessment from consumer-supplied independent observations. Not checkConfirmationCompleteness or checkCurrency. |
| ConfirmedCurrentIntentRateAssessment, ConfirmedCurrentIntentRateFinding, ConfirmedCurrentIntentRateState | Its result shape |
Validators and guards
| Export | What it does |
| --- | --- |
| validateStandingInstruction | Validates one untyped standing instruction. |
| validateStandingInstructions | Validates an untyped array of them. |
| validateIntentRecord | Validates one untyped intent. |
| validateIntentRecords | Validates an untyped array of them. |
| validateConfirmationRecord | Validates one untyped read-back answer. |
| validateConfirmationRecords | Validates an untyped array of them. |
| validateInstructionUsages | Validates an untyped array of usage records. |
| validatePreferencePaths | Validates an untyped array of measured preference paths. |
| validateConfidenceFloor | Validates a declared confidence floor. |
| validatePolicyVersion | Validates a policy-version reference. |
| isStandingInstruction | Boolean guard over validateStandingInstruction. |
| isIntentRecord | Boolean guard over validateIntentRecord. |
| isConfirmationRecord | Boolean guard over validateConfirmationRecord. |
Vocabularies
STANDING_PROVENANCES, INTENT_DISPOSITIONS, CONFIRMATION_VERDICTS, and
STANDING_AUDIT_EVENT_TYPES are the closed lists a caller validating
untyped input needs.
Types
The record types are StandingInstruction, StandingState,
StandingTopic, StandingProvenance, CurrencyWindow, PolicyVersion,
IntentRecord, IntentDisposition, ConfirmationRecord,
ConfirmationVerdict, ConfidenceFloor, InstructionUsage,
PreferencePath and PathCost.
The evaluation and decision types are StandingEvaluation,
StandingEvaluationPolicy, StandingAction, StandingAuditEvent and
StandingAuditEventType.
The host-implemented ports are StandingInstructionStore and
StandingAuditLedger. No implementation of either ships here.
Each gate returns its own result type — ConfirmationCompletenessResult,
CurrencyResult, WithdrawalParityResult — carrying findings typed as
ConfirmationFinding, CurrencyFinding and WithdrawalParityFinding,
whose kinds are ConfirmationFindingKind, CurrencyFindingKind and
WithdrawalParityFindingKind, and whose non-clean outcomes are named by
ConfirmationFailureReason, CurrencyFailureReason and
WithdrawalParityFailureReason.
Validation surfaces ValidationIssue, ValidationResult and Validator.
The ./inbound subpath
Admission on any channel, and deliberately not an HTTP handler. The consumer owns the route, the raw body, and signature verification — signature schemes are provider-specific and this package must not pretend to verify what it cannot exercise. This package owns the admission decision: dedupe, ack/reject doctrine, replay tolerance, and ordering tolerance, as a pure function of the caller's own verification result plus a host ledger's dedupe answer.
import { admitInboundEvent } from "@clossys/butler/inbound";The decision is never a bare boolean: acknowledging and processing are two
separate questions. A replay is an acknowledgement with action: "ignore",
never an error. A rejection is reserved for a failed signature, so a
provider is never told to keep retrying data that will never become
processable. And a throwing ledger rejects the promise rather than
acknowledging — if durable dedupe could not run, nothing was durably
accepted, and saying otherwise would silently discard an event.
The ./web subpath
useStandingWants is a currency-aware preference-surface hook: it reads
every stored instruction for a subject once, then evaluates each requested
topic against the policy version in force and a caller-supplied now. An
instruction that exists, and was granted, and is past its own window comes
back stale, so a surface rendered from it re-asks rather than quietly
continuing.
import { useStandingWants } from "@clossys/butler/web";withdraw shares grant's and deny's exact call shape — one topic, one
promise, one function on the same object. That is withdrawal parity
enforced structurally at the API surface rather than asserted in prose.
React and React DOM are optional peers of this subpath specifically.
Importing the package root, ./inbound, ./browser-consent or
./browser-consent/local-storage never pulls in React. Importing
./web asserts the installed version against the declared range at import
time, so an absent or incompatible React fails loudly, by name, instead of
crashing later inside a hook with no version named as the cause.
The ./browser-consent subpath
The browser consent lifecycle: the stored record, the pure decision rules and a lifecycle object a host creates on the client. It renders nothing and ships no wording. Importing it performs no storage read or write, no network request and no timer; it reaches no adapter.
import { createConsentLifecycle } from "@clossys/butler/browser-consent";| Export | What it does |
| --- | --- |
| createConsentLifecycle(options) | Creates a lifecycle over a host-supplied synchronous storage port, an optional evidence port, a policy, a regime, the Global Privacy Control signal and a clock. Its getSnapshot, subscribe, grant, refuse, refresh and dispose methods match the structural ConsentLifecyclePort that @clossys/publisher/web/consent declares. |
| NO_DECISION_SNAPSHOT | The server and pre-mount snapshot: no choice, analytics not allowed. |
| decideChoice(status, now, policy, signals) | Builds the stored record for one explicit choice, with its expiry fixed at that moment. Throws for a grant the policy does not let override the signal. |
| parseStoredChoice(raw, policy) | Reads a raw stored value into a record, or null for a corrupt or unknown shape. |
| isLiveChoice(choice, policy, now) | Whether a parsed record is inside its window and under a policy version that still applies to it. |
| effectiveChoice / isAllowed / shouldPromptAutomatically | The choice in force, whether analytics may run, and whether the notice opens by itself. |
| shouldApplyEvidence(incoming, current) | Whether an acknowledged choice may replace the one in force: a denial always applies, a grant only when strictly newer. |
| normalizeRegime(value) | "notice" only for the exact string "notice", otherwise "prompt". |
| BrowserConsentError | Thrown for an invalid policy, an invalid instant, or a grant the signal forbids. |
Types: ConsentLifecycle, ConsentLifecycleOptions, ConsentSnapshot,
ConsentStoragePort, ConsentEvidencePort, EvidenceResult,
EvidenceStatus, StorageRead, StorageWrite, ConsentPolicy,
ConsentRegime, ConsentSignals, EffectiveChoice, StoredChoice,
StoredInput, SequencedChoice and BrowserConsentErrorCode.
The ./browser-consent/local-storage subpath
The browser storage adapter, alone, in its own subpath so that importing the
lifecycle never reaches it. Nothing constructs it except the host's own call,
and it touches localStorage only inside the functions it returns. A blocked
or throwing storage reads as unavailable rather than throwing.
import { createLocalStorageConsentPort } from "@clossys/butler/browser-consent/local-storage";createLocalStorageConsentPort(options: LocalStorageConsentPortOptions) returns
a ConsentStoragePort that stores the current record shape under the key the
host chooses. The optional storage option has type () => Storage | undefined;
the adapter reads it at each call. When omitted, it reads
globalThis.localStorage at each call.
Requirements
Node 20 or newer. Zero runtime dependencies. React and React DOM are
optional peers, needed only by the ./web subpath.
What this package is not
- It carries no topics, no confidence floor, no currency window, no jurisdiction logic, and no obligations. Every one of those is a value, and values belong in each consumer's own repository.
- It makes no claim of legal compliance. It is record machinery, not advice.
- It ships no storage and no audit implementation. Both are ports the host implements, and no person-attributable record is written into this repository.
subjectIdandactorIdare opaque host-owned references. Neither ever carries an email address, a name, a phone number, an address, or an IP, and they are separate identifiers in every signature — an actor recording a subject's own decision and an actor deciding on a subject's behalf must stay distinguishable in the audit trail.
Licence
MIT. See LICENSE.
Changelog
Release notes for every version are in the changelog, kept in the public repository rather than in the installed package.
