@sema-agent/core
Version:
Stateless, task-oriented AI agent core
404 lines (403 loc) • 26.8 kB
TypeScript
/**
* The settlement record and the gate outcome — the L0 vocabulary of what ONE tool-gate pass decided,
* what ask it settled and who asked. One structure, minted once (the gate's exit; the decide lane for a
* durable park) and projected three times without re-derivation: the `permissionDenied` observer's
* payload, the call's `tool_end` frame, and the durable row's resolved outcome. Lives beside
* `tool-policy.ts` rather than in it because it is a different concern from the permission verdict a
* policy composes: a policy says allow / ask / deny; this file says how the pass that consumed that
* verdict ended.
*/
import { type AskClass, type AskOrigin, type AssertAllKeysHandled, type ModeReleaseReason, type PermissionMode, type PersistedRuleMandate } from "./ask-origin.js";
import { type AutoModeClassifierRound } from "./auto-mode.js";
/**
* How the wait an ask was in ENDED — the closed set. One word per way a wait can end; the `who` on the
* record says which party ended it and is fixed BY the word (see {@link Settlement}).
* - `human_allowed` / `human_refused` — a person (or the approver acting for one) decided; the refusal's
* own note, when the decider attached one, rides `note`.
* - `policy_allowed` / `policy_refused` — the DEPLOYMENT'S OWN POLICY decided: the approval channel reported
* that a machine, not a person, answered this ask (an unattended lane answering by class, a headless lane
* with no interactive approval surface, a standing deployment posture). A person and a policy are the two
* DECIDERS: each answers either way, with the same capabilities, and differs only in whose decision the
* record names. A policy's refusal is distinct from `human_refused` because the model is told a different
* thing: a person's "no" is the user's direction, a policy's is a capability of this run. Distinct from
* `no_approver` because an approval channel WAS wired and answered — core did not fall back to its own
* headless posture. The refusal's own note, when the channel attached one, reaches the model as the
* policy's words and NOT as a person's; it is not on this record (the note seat belongs to the person's
* two words, whose ABSENCE is a control-flow fact the policy's do not have).
* - `approval_window_expired` — an approval window elapsed with no answer: the engine's own approval
* factory window (`createApprovalPolicy.approvalTimeoutMs`), or a synchronous host approver that
* reported its own window elapsed.
* - `denial_limit_window_expired` — the classifier denial-limit fallback's auto-deny window (core's own
* timer over the synchronous leg) elapsed with no answer.
* - `park_sla_expired` — a DURABLE park's SLA deadline passed and the host's sweep resolved it as a deny
* (FACET A). A store `expire`/`reap` produces no `tool_end` and therefore no settlement.
* - `no_approver` — headless: no approver (or no question face) is wired, or the deny posture string.
* - `approver_unavailable` — the approver (or an ancestor's frozen approver) answered the ROUTING question
* "nobody reachable", and no park took the ask.
* - `approver_error` — the approver threw.
* - `approver_contract` — the approver answered outside its contract (a non-boolean verdict, an
* unreadable member, a timeout-settled allow, a refused attribution…).
* - `presentation_failed` — the args or the edit could not be safely presented/adopted (unclonable).
* - `blanket_allow_refused` — an allow NO PERSON JUDGED met a `requiresRealApproval` ask: a blanket
* `onAsk:"allow"`, or an approver's allow it reported as the deployment's policy (`settledBy: "policy"`).
* - `task_aborted` — the wait's abort signal ended it (the run's own end, or a turn-level interrupt).
*/
export declare const SETTLEMENT_KINDS: readonly ["human_allowed", "human_refused", "approval_window_expired", "denial_limit_window_expired", "park_sla_expired", "no_approver", "approver_unavailable", "approver_error", "approver_contract", "presentation_failed", "blanket_allow_refused", "policy_allowed", "policy_refused", "task_aborted"];
export type SettlementKind = (typeof SETTLEMENT_KINDS)[number];
/** Membership test for {@link SettlementKind} — the word crosses process boundaries on `tool_end`, so a
* consumer validating one it received must not hand-roll the set. */
export declare function isSettlementKind(v: unknown): v is SettlementKind;
/**
* The settlement record: WHAT ended the wait (`kind`), WHO ended it (`who` — its shape is decided by the
* word: the person's two words carry a person, the policy's two words the policy, the window words say whose
* window, the park word the host, the fail-closed family nobody), WHEN (epoch ms, written at the settlement site), and for a person's
* refusal the decider's own NOTE when one was attached (a refusal WITHOUT a note is a bare "no", the arm
* the runner treats as a control-flow boundary for the issuing batch).
*
* It is a discriminated union and not a free product of `kind` × `who` on purpose: a free product can
* express "human_allowed, ended by the engine's window" — a structurally complete record that contradicts
* itself — and a consumer would then need the pairing rules this shape makes unwritable.
*
* @contract settlement.single_mint — every `Settlement` kind is composed by CORE at the site whose wait
* ended (the ask resolver's arms, the approval factory's arms, the decide lane over a host's decision
* FACTS), and the four DECIDER words by one composer ({@link settledByDecider}). A host never mints a kind: a synchronous approver reports `"human"`/`"timeout"`/`"policy"` on its
* outcome and a durable decide reports `hostDecision.decidedBy` (`"person"`/`"sla_timeout"`/`"policy"`) — facts
* core turns into the word. Nothing on the caller-composable policy face (`PermissionResult`) carries a settlement,
* so a policy cannot self-declare one; the engine's own settlement sites that hand a settled decision
* back through a policy fold attest it through {@link engineSettlementOf}.
*/
export type Settlement = {
kind: "human_allowed" | "human_refused";
who: {
party: "person";
approver?: string;
};
when: number;
note?: string;
} | {
kind: "policy_allowed" | "policy_refused";
who: {
party: "policy";
approver?: string;
};
when: number;
} | {
kind: "approval_window_expired";
who: {
party: "engine";
window: "approval_factory";
} | {
party: "host";
};
when: number;
} | {
kind: "denial_limit_window_expired";
who: {
party: "engine";
window: "denial_limit";
};
when: number;
} | {
kind: "park_sla_expired";
who: {
party: "host";
approver?: string;
};
when: number;
} | {
kind: "no_approver" | "approver_unavailable" | "approver_error" | "approver_contract" | "presentation_failed" | "blanket_allow_refused" | "task_aborted";
who: {
party: "none";
};
when: number;
};
/**
* WHO ended a wait — the closed set of `Settlement.who.party` words, one per party a record can name:
* `person` (a person decided), `policy` (the deployment's own policy decided), `engine` (one of the engine's
* own windows elapsed), `host` (the host's window or its park sweep), `none` (nobody: the engine ended the
* wait itself — no approver, a throw, an abort, an out-of-contract answer). Which party a word names is
* fixed by the word ({@link Settlement}); the word crosses process boundaries on `tool_end`, so a consumer
* validating a record it received reads this set rather than hand-rolling one.
*/
export declare const SETTLEMENT_PARTIES: readonly ["person", "policy", "engine", "host", "none"];
export type SettlementParty = (typeof SETTLEMENT_PARTIES)[number];
/** Compile-time fence: `never` while the closed set and the record's `who.party` union are the same words. */
export type SettlementPartiesMatchTheRecord = AssertAllKeysHandled<Exclude<Settlement["who"]["party"], SettlementParty> | Exclude<SettlementParty, Settlement["who"]["party"]>>;
/** Membership test for {@link SettlementParty}. */
export declare function isSettlementParty(v: unknown): v is SettlementParty;
/** The DECIDER arms of {@link Settlement} — a person's and the deployment policy's, the two parties that
* answer an ask either way. Named so a site that both composes one and quotes its note can read the seat
* off the record instead of re-deriving it. */
export type DeciderSettlement = Extract<Settlement, {
who: {
party: "person" | "policy";
};
}>;
/**
* A decider's NOTE, normalised — the one rule for "did the decider say anything": empty text is a
* decider who attached nothing and reads as absent wherever the note travels. Whitespace-only text is
* not empty by this rule and stays a note: that half is PINNED CURRENT BEHAVIOUR, not a promise —
* nothing here requires a note to carry content, and narrowing it is a separate decision with its own
* consumers to warn.
*/
export declare function decidersNote(text: string | undefined): string | undefined;
/**
* THE mint for a decider's settlement — the party (a person, or the deployment's own policy) and its verdict
* pick the word, and the channel's attribution is transcribed onto `who`. The synchronous resolver's arms,
* the approval factory's arms and the durable decide lane all compose their decider record here, so one
* answer cannot end as two different records depending on which path carried it, and the two parties are
* one rule with a parameter rather than two composers.
*
* @contract settlement.note — the `note` seat is the PERSON's: filled exactly when the person attached a
* reason, and its absence is how the record says the refusal was bare (the runner reads that absence, and
* nothing else, as the issuing batch's control-flow boundary). Empty text folds to absence HERE, once, for
* every path that fills the seat ({@link decidersNote}); a site that hand-builds the record instead is a
* second answer to the same question, which is how one path came to halt a batch the other let run. A
* policy's record has no note seat — its words reach the model inside the policy sentence, and a policy's
* refusal is never a control-flow boundary — so a note handed with the policy party is not filed here.
*/
export declare function settledByDecider(party: DeciderSettlement["who"]["party"], verdict: "allowed" | "refused", approver: string | undefined, note?: string): DeciderSettlement;
/** Which settlement kinds are REFUSALS (the ask ended as a deny). Total over the closed set — the fence
* below refuses a member without a row — so invariant I2 is a table lookup, not a list in a condition. */
export declare const SETTLEMENT_IS_REFUSAL: {
readonly human_allowed: false;
readonly human_refused: true;
readonly approval_window_expired: true;
readonly denial_limit_window_expired: true;
readonly park_sla_expired: true;
readonly no_approver: true;
readonly approver_unavailable: true;
readonly approver_error: true;
readonly approver_contract: true;
readonly presentation_failed: true;
readonly blanket_allow_refused: true;
readonly policy_allowed: false;
readonly policy_refused: true;
readonly task_aborted: true;
};
/** Compile-time fence: `never` while every settlement kind has a refusal row. */
export type SettlementRefusalTableCoversEveryKind = AssertAllKeysHandled<Exclude<SettlementKind, keyof typeof SETTLEMENT_IS_REFUSAL>>;
/**
* Which settlement kinds are a refusal NOBODY JUDGED — the wait ended because this run has no one who
* could answer the question, not because someone considered it and said no. The classifier's
* denial-limit fallback reads it: that fallback exists to bound a classifier that would otherwise deny
* without end, and its last resort is to STOP the run rather than let the model keep re-asking a
* question nothing in this deployment can answer.
*
* It is a TABLE and not a condition for the reason the sibling above is: this question used to be a
* hand-written two-word disjunction at one site and a three-word one at another, so a settlement word
* added later answered it by falling out of both — which is exactly what `policy_refused` did on the
* commit that introduced it (a headless lane that refused by policy stopped reaching the stop seat that
* the same lane reached when it refused by returning a bare `false`).
*
* The rows that say `false` each say something:
* - the person's two words, `policy_allowed` and the window words — someone WAS asked; a decider answered,
* or a window ran out. The run is not out of judges. (`policy_refused` is `true`: the deployment's own
* policy declined a question it exists to answer, so nothing in this run will say yes to it.)
* - `approver_unavailable` — a ROUTING answer, not a posture: the main gate owns that arm (it may park
* the ask on a durable queue instead), and the two sites that treat it as headless anyway say so at
* their own call, because they are the ones with no park route left.
* - `approver_error` / `approver_contract` / `presentation_failed` — the approver was there and the
* engine could not use it. A defect to report, not a deployment with nobody home.
* - `task_aborted` — the run ended; nothing is escalating anywhere.
*/
export declare const SETTLEMENT_NOBODY_JUDGED: {
readonly human_allowed: false;
readonly human_refused: false;
readonly approval_window_expired: false;
readonly denial_limit_window_expired: false;
readonly park_sla_expired: false;
readonly no_approver: true;
readonly approver_unavailable: false;
readonly approver_error: false;
readonly approver_contract: false;
readonly presentation_failed: false;
readonly blanket_allow_refused: true;
readonly policy_allowed: false;
readonly policy_refused: true;
readonly task_aborted: false;
};
/** Compile-time fence: `never` while every settlement kind has a nobody-judged row. */
export type NobodyJudgedTableCoversEveryKind = AssertAllKeysHandled<Exclude<SettlementKind, keyof typeof SETTLEMENT_NOBODY_JUDGED>>;
/**
* WHO REFUSED a call — the layer whose verdict is the deny. The other question a deny raises, "who ASKED",
* is answered by {@link GateOutcome.origin} (an {@link import("./ask-origin.js").AskOrigin}); the two used
* to share one seven-word list (`PermissionDeniedSource`) in which `classifier` meant both "the classifier
* asked" and "the classifier denied".
* - `policy` — the deployment `ToolPolicy` denied (directly, or re-checking an approved edit), or the
* approval-edit chain hit its round cap.
* - `hook` — a PreToolUse hook denied, threw, or never answered (the engine's fail-closed block on a
* hook that made no decision counts as the hook layer's refusal for attribution).
* - `org` — an organization policy rule denied; or, on a durable resume, the org's current verdict demands a person and
* the recorded decider is the deployment's policy, not a person.
* - `classifier` — the auto-mode classifier denied directly (its ASK-side role is `origin:
* "denial_limit_fallback"`).
* - `persisted_rule` — the person's persisted-rule lane refused: a deny row (live), or, on a durable resume, a deny
* row, a call unreadable against the rows, or a wait for them the task's cancel ended.
* - `compliance` — the compliance call-time lock.
* - `write_protection` — an approved edit was rewritten by the restriction chain onto a write-protected
* path that no approval covers.
* - `ask_resolution` — the ask's own settlement is the refusal (a person said no, a window elapsed,
* nobody could be asked…); the detail is on {@link GateOutcome.settlement}.
* - `read_boundary` — the shell read station refused: a deny-listed read (refused on every seat, with no
* ask), a read the read-only shell face may not make, or a rewritten / re-established command whose reads
* no longer are what a person approved.
* - `mode` — the permission MODE refused a surviving ask at the gate's arbitration point: `dontAsk` turns every ask
* but a hook's own into a refusal, and `bypassPermissions` refuses a write whose protection
* could not be judged. The disposition carries `mode`, `askClass` and `classes` beside the word.
*/
export declare const DENIED_BY_VALUES: readonly ["policy", "hook", "org", "persisted_rule", "classifier", "compliance", "write_protection", "ask_resolution", "read_boundary", "mode"];
export type DeniedBy = (typeof DENIED_BY_VALUES)[number];
/** Membership test for {@link DeniedBy}. */
export declare function isDeniedBy(v: unknown): v is DeniedBy;
/** Which layers may VETO an approval a decider already gave (invariant I3: an approval settlement — a
* non-refusal kind, a person's or a policy's — beside a `denied` disposition names one of these). The
* approval-edit re-screen (hook), the policy, org and persisted-rule re-checks, and the write-protection
* re-judge run AFTER the decider's yes; the
* classifier and compliance run before any ask exists, and `ask_resolution` IS the
* settlement, not a veto of it. `persisted_rule` = a person's own standing DENY row (their settings'
* deny list, imported) — the personal-store sibling of `org`, and like it re-judged on an edited
* command. `read_boundary` vetoes too: the rewrite re-judge and the pre-spawn re-establishment run after
* the decider's yes, on bytes or targets the decider never saw. */
export declare const DENIED_BY_MAY_VETO: {
readonly policy: true;
readonly hook: true;
readonly org: true;
readonly persisted_rule: true;
readonly classifier: false;
readonly compliance: false;
readonly write_protection: true;
readonly ask_resolution: false;
readonly read_boundary: true;
readonly mode: false;
};
/** Compile-time fence: `never` while every deny layer has a veto row. */
export type VetoTableCoversEveryDeniedBy = AssertAllKeysHandled<Exclude<DeniedBy, keyof typeof DENIED_BY_MAY_VETO>>;
/**
* The FORM of an auto-mode classifier deny — the one structural position a consumer branches on when a
* deny came through the classifier station. The classifier ends a round three ways, and until this
* member the wire carried the three as one layer word plus prose:
* - `unavailable` — the classifier could not run (the model leg threw / rejected / ran past the cap):
* a fail-closed refusal that SAYS SO — the call may simply be retried later (CC 2.1.250 `x1t`);
* - `parse_error` — the classifier answered outside its verdict contract: blocked for safety, counted by
* the denial limit like a ruling (CC 2.1.250 `Ure`);
* - ABSENT beside `deniedBy: "classifier"` — the classifier's own RULING (a `<block>yes` verdict).
* The WHY behind an `unavailable` (error vs timeout) is not a branching fact: the deny sentence spells it
* and the `auto_mode.classified` trace frame carries it (`cause: "error" | "timeout"`).
*/
export declare const CLASSIFIER_DENY_CAUSES: readonly ["unavailable", "parse_error"];
export type ClassifierDenyCause = (typeof CLASSIFIER_DENY_CAUSES)[number];
/** Membership test for {@link ClassifierDenyCause}. */
export declare function isClassifierDenyCause(v: unknown): v is ClassifierDenyCause;
/** Which deny layers a classifier cause may stand beside: the gate's own classifier station (`classifier`),
* and an ANCESTOR's frozen classifier reached through the inherited policy chain, whose deny the child's
* gate attributes to the chain (`policy`, station row 14) — the cause is attested by the station that
* minted the deny, so the word beside it is whichever layer word the child's ledger assigns. */
export declare const DENIED_BY_MAY_CARRY_CAUSE: {
readonly policy: true;
readonly hook: false;
readonly org: false;
readonly persisted_rule: false;
readonly classifier: true;
readonly compliance: false;
readonly write_protection: false;
readonly ask_resolution: false;
readonly read_boundary: false;
readonly mode: false;
};
/** Compile-time fence: `never` while every deny layer has a cause row. */
export type CauseTableCoversEveryDeniedBy = AssertAllKeysHandled<Exclude<DeniedBy, keyof typeof DENIED_BY_MAY_CARRY_CAUSE>>;
/** The final disposition of a gate pass. `cause` is present exactly when the deny came out of an auto-mode
* classifier round that did not RULE ({@link ClassifierDenyCause}); a ruling block and every non-classifier
* deny carry none. Invariant I5 ({@link screenGateOutcome}): a cause stands only beside a layer word in
* {@link DENIED_BY_MAY_CARRY_CAUSE}.
*
* `classifier` — the classification's model attribution, on BOTH arms (a classification ends either way,
* allow or deny) and from BOTH stations (this gate's own classifier step, and an ANCESTOR's frozen
* classifier reached through the inherited policy chain, whose allow and deny carry it out on the same seat
* that carries the FORM): which model it was for, which one answered, and — only when the ladder moved —
* the rung it left and why. ABSENT reads UNKNOWN, never "the seat answered" (that is `fallback` absent WITH
* the member present), and a consumer cannot tell its two causes apart from the record alone: no
* classification happened, or one ran on a decider given no candidate ladder and can name no model (a
* hand-built decider; a parked ancestor's cross-process rebuild, whose single fresh leg has none). A block
* at the DENIAL-LIMIT bound reaches no attribution seat: it becomes an ask, and the disposition names that
* resolution. Additive. Invariant I6 ({@link screenGateOutcome}) screens its shape. */
export type GateDisposition = {
kind: "allowed";
classifier?: AutoModeClassifierRound;
released?: ModeRelease;
} | {
kind: "denied";
deniedBy: DeniedBy;
cause?: ClassifierDenyCause;
classifier?: AutoModeClassifierRound;
mode?: ModeRefusal["mode"];
askClass?: AskClass;
classes?: readonly AskClass[];
};
/**
* A permission MODE released a surviving ask — the allowed disposition's account of the question it did not put
* to anyone: the mode, the disclosed class word (`askClass`, the first member of the folded set in `ASK_CLASSES`
* order — one of {@link RELEASABLE_ASK_CLASSES}), the whole set, and the origin / mandate words the card would
* have shown. `reason` is a member of {@link import("./ask-origin.js").MODE_RELEASE_REASONS} (`isModeReleaseReason`):
* `"rule_unreadable"` marks a release over a rule the call could not be read against; absent, the release names none.
*/
export interface ModeRelease {
mode: PermissionMode;
askClass: AskClass;
classes: readonly AskClass[];
origin?: AskOrigin;
mandate?: PersistedRuleMandate;
reason?: ModeReleaseReason;
}
/** The two modes that refuse on their own account (`deniedBy: "mode"`): `dontAsk` every ask but a hook's own,
* `bypassPermissions` a write whose protection could not be judged. `plan` refuses nothing here — its questions ask. */
export interface ModeRefusal {
mode: "bypassPermissions" | "dontAsk";
askClass: AskClass;
classes: readonly AskClass[];
}
/**
* The WHOLE record of one tool-gate pass: the final disposition, plus — when this pass settled an ask —
* the settlement it consumed and the ask's origin. Minted ONCE (the gate's exit; the decide lane for a
* durable park) and projected three times without re-derivation: the `permissionDenied` observer's
* payload, the call's `tool_end` frame, and the durable row's resolved outcome. The three faces are the
* same object graph, so they cannot tell different stories.
*
* The three members are orthogonal but bound by four invariants ({@link screenGateOutcome}):
* I1 `settlement` present ⇔ `origin` present (they describe the same ask);
* I2 `disposition.deniedBy === "ask_resolution"` ⇒ `settlement` present and a refusal kind;
* I3 `settlement` an approval (a kind {@link SETTLEMENT_IS_REFUSAL} says is not a refusal) ∧
* `disposition.kind === "denied"` ⇒ `deniedBy` is a veto layer ({@link DENIED_BY_MAY_VETO}) — a decider
* approved, a later re-check refused; the approver stays on the settlement (it is not the approver's
* refusal);
* I4 `disposition.kind === "allowed"` ⇒ `settlement` absent or an approval (not a refusal kind);
* I5 `disposition.cause` present ⇒ a member of {@link CLASSIFIER_DENY_CAUSES} beside a layer word
* {@link DENIED_BY_MAY_CARRY_CAUSE} admits (the classifier station, or the inherited chain's `policy`);
* I6 `disposition.classifier` present ⇒ both model ids are non-empty strings, and a `fallback` on it
* names a `to` that IS `modelUsed` (the ladder landed where the record says) with a cause in
* {@link import("./auto-mode.js").AUTO_MODE_FALLBACK_CAUSES} — a record whose `to` and `modelUsed`
* disagree would let a reader answer "which model decided this call?" two ways;
* I7 `disposition.released` present ⇒ `kind === "allowed"`, no settlement, `mode` is `bypassPermissions`,
* `askClass` is a releasable class, every member of `classes` is an ask class, `origin` / `reason`, when
* present, are members of their closed sets, and `reason` names a member of `classes` (the mint takes it from
* that set) — a mode releases nothing a person answered and nothing the table calls immune;
* I8 `deniedBy === "mode"` ⇔ `mode` present (`bypassPermissions` | `dontAsk`) with `askClass` and `classes`.
* Nothing here is read out of an ABSENCE: an ordinary allow with no ask is `{ disposition: { kind: "allowed" } }`,
* a direct policy deny is `{ disposition: { kind: "denied", deniedBy: "policy" } }`, and neither carries a
* settlement because neither settled one.
*/
export interface GateOutcome {
disposition: GateDisposition;
settlement?: Settlement;
origin?: AskOrigin;
}
/**
* The one invariant screen for a {@link GateOutcome} (I1–I8 above, plus the closed-set words each member carries —
* `settlement.who.party` among them, {@link SETTLEMENT_PARTIES}). Runs
* where an outcome enters a wire face — the engine's settlement sideband before the `tool_end` frame, and
* the decide lane before a durable row is resolved. Returns the defects as sentences (an empty list is a
* clean record); the caller decides the fail-loud form (report + withhold, or refuse), never silently
* repairs the record.
*/
export declare function screenGateOutcome(g: GateOutcome): string[];