@sema-agent/core
Version:
Stateless, task-oriented AI agent core
378 lines (377 loc) • 26.6 kB
TypeScript
/**
* design/182 §7 / design/389 — the ORG partition of the permission-rule store: core's half of the contract.
*
* Org rules and personal rules are opposite in polarity (deny/ask vs allow), authority (a server-side
* administrator publish vs one human click per add) and evolution (whole-snapshot replacement vs
* convergent merge). So the org layer has NO CRDT, no tombstones and no join: the server is the single
* authority, a client holds a read-only versioned snapshot, an update is a whole replacement (revision
* strictly ordered), and a rollback is a NEW revision carrying old content. Cross-layer tombstones are
* structurally impossible — an org rule never enters `PersistedRule`, and an org snapshot replace
* never touches a personal store's bytes. The two layers meet only at the CONSUMPTION site, where org
* deny/ask folds strict-side: **org deny > org ask (non-dismissable) > personal allow rule > bare ask**.
*
* ## Availability semantics (the fail-closed shape, ruled through three review rounds)
*
* A deployment DECLARES governance by handing `createPermissionRuleStoreProvider` an `org` partition
* config — governance is never inferred from a provider happening to be wired somewhere, and a config
* naming no snapshot source is a loud boot contradiction instead of a silently ungoverned deployment.
* Under that declaration the org adjudication surface must ALWAYS be effective:
* - the last successfully installed snapshot persists durably (`PersistedOrgRuleState`) and keeps
* adjudicating while the provider is unreachable, up to a deployment-configured staleness bound;
* - beyond the bound (or before any snapshot was ever installed) the resolution is `unavailable`, and
* the CONSUMING gate must fail the ENTIRE decision boundary closed: every terminal allow — policy
* allow and default allow included — tightens to an ask carrying `decisionReason: "org_unavailable"`
* AND `requiresRealApproval: true` (the same non-dismissable bit an org ask itself carries; without
* it a blanket `onAsk: "allow"` deployment would clear the synthetic ask — a third fail-open path),
* with the personal-rule lane and the auto-mode classifier both disarmed. That obligation lives at
* the gate integration; THIS module's contract is to report `unavailable` honestly and never lapse
* into an empty-rules answer that reads as "not governed".
*
* ## Anti-rollback
*
* `revision` is an orderable server-monotone number, and the installed high-water mark persists with
* the snapshot in ONE atomic write: a replayed older snapshot (fresh `fetchedAtMs`, old content) is
* refused and disclosed — identity comparison alone cannot catch it, and an in-memory-only mark would
* accept revision N−1 after a restart. Signature / predecessor chaining belongs to the privilege-
* separation ticket; the high-water mark is the floor that needs no key distribution.
*
* ## One reading of a row
*
* A snapshot is installed and served on its ENVELOPE ({@link validateOrgSnapshot}: revision, observation
* time, a rules list) and the anti-rollback fence; what its rows say never decides which snapshot is in
* force. Whether the rules are a list is judged in ONE place ({@link listRefusalOf}) for the partition and for
* every store that hands a resolution over — rules that are not a list, or that carry more places than a rule set may
* (the same cap the rule compiler holds a set to), are governance nobody can read (the
* partition refuses the snapshot; the org lane answers unavailable), never a policy of zero rows — and ONE reader
* ({@link orgRuleListOf}) copies the whole list in one receipt: a member of a row that cannot be read is left out of
* that row's copy alone, and every row is judged on its copy. A snapshot the partition holds — fetched or read back
* from its persistence — is that same copy ({@link orgSnapshotCopyOf}). Every row is then read by ONE reader
* ({@link readOrgRules}), the same for every store that hands a resolution over, on its policy members
* ({@link ORG_ROW_POLICY_MEMBERS}): a row is READ when its behavior is `deny` or `ask` and its text is a shell command
* rule under that behavior. Any other row — another grammar's rule, a shell rule the validator refuses, a row
* with no text, a behavior this layer has no bucket for — is UNREADABLE, and stands at its strictest
* reading over the calls it may reach ({@link OrgRowReach}): an `ask` row asks a person, every other row
* refuses. Readable deny and ask rows hold as published beside it. One unreadable row therefore never
* takes the rest of the policy down with it, and never turns a published deny into a question; the
* snapshot is served DEGRADED, and every resolution and every adjudication over it names the unreadable
* rows ({@link describeUnreadableOrgRows}).
*/
import { type ParsedRule, type PersistedRule, type RuleRejectCode } from "./permission-rule-model.js";
import type { DenyFace } from "../tools/fs/read-deny.js";
import type { PermissionRuleStore } from "./permission-rule-provider.js";
/** One org rule. There is structurally no allow bucket (design/179 §9: the org layer only tightens). */
export interface OrgPermissionRule {
rule: string;
behavior: "deny" | "ask";
}
/** The read-only versioned snapshot a client holds. */
export interface OrgRuleSnapshot {
/** Server-monotone orderable revision. A snapshot below the persisted high-water mark is refused. */
revision: number;
rules: OrgPermissionRule[];
/** Reserved for the privilege-separation ticket's signed-snapshot contract. Carried, not verified here. */
signature?: string;
/** Observation timestamp (epoch ms) — staleness input only, never adjudication input. */
fetchedAtMs: number;
}
/** Where the deployment fetches the current snapshot. `undefined` = the provider has none to give. */
export interface OrgRuleSnapshotProvider {
current(): Promise<OrgRuleSnapshot | undefined>;
}
/**
* The durable org state: last-known-good snapshot + the anti-rollback high-water mark, installed in one
* atomic write. SQL twin note (server/org ticket): `revision_high_water` and `installed_at_ms` are
* structured columns (`_ms` suffix for epoch milliseconds); the snapshot itself is a JSON blob — it is
* installed and read whole, never row-queried.
*/
export interface PersistedOrgRuleState {
revisionHighWater: number;
snapshot: OrgRuleSnapshot;
installedAtMs: number;
}
/** The durable home of {@link PersistedOrgRuleState}. The file backend implements it over a
* deployment-level `org.json` published with the same O_EXCL/fsync/rename discipline as the rule files
* (`FileDurableRulePartitionProvider.orgStatePersistence()`); a SQL twin keeps one blob row.
*
* CONTRACT: `installOrgState` must REFUSE (throw) an install that would LOWER the persisted
* `revisionHighWater` — the overlay serializes its own resolves, but two overlay instances over one
* durable home can interleave read-then-write, and the backend's atomic write is the one place the
* lowering can be caught. The file backend enforces this under its write serialization. */
export interface OrgRuleStatePersistence {
readOrgState(): Promise<PersistedOrgRuleState | undefined>;
installOrgState(state: PersistedOrgRuleState): Promise<void>;
}
/** The `decisionReason` the consuming gate stamps on the synthetic asks it mints while org adjudication
* is unavailable. Exported so the gate integration and its tests share one spelling — and TYPED as the
* member of {@link import("./tool-policy.js").DecisionReason} it must equal, so the constant and the
* vocabulary cannot drift into two spellings of one word. */
export declare const ORG_UNAVAILABLE_DECISION_REASON: Extract<import("./tool-policy.js").DecisionReason, "org_unavailable">;
/**
* How long a single org adjudication may take before the consuming gate stops waiting and reads the
* answer as UNAVAILABLE (its fail-closed word).
*
* A bound is MANDATORY rather than optional because of where this call sits: on the hot path of every
* governed tool call, and — on the durable resume leg — after the checkpoint has been consumed and the
* start frame emitted. A provider that never settles would otherwise wedge the call past any task
* deadline (tasks carry no walltime by default), leaving the frame unpaired and the approval spent with
* nothing retryable. A hang is the one outcome worse than either verdict.
*
* The value matches the auto-mode classifier's per-round-trip cap — the other model/network call the
* permission path makes — because the reasoning is the same: a governance lookup that takes longer than
* this is indistinguishable from an outage, and an outage is exactly what the unavailable arm is for.
* The task's own abort signal still applies in parallel; whichever fires first ends the wait.
*/
export declare const ORG_ADJUDICATION_TIMEOUT_MS = 15000;
/**
* Await `p`, but settle with `fallback` if the deadline elapses or the signal fires first.
*
* Shared by both org consumption sites so one bound cannot drift into two. Deliberately NOT `.unref()`
* on the timer: this is a foreground rescue timer, and the caller is awaiting it inside the control
* flow — an unref'd timer stops firing in exactly the situation it exists for (an otherwise idle loop).
* A rejecting `p` also lands on the fallback: the callers' fail-closed word is the same either way, and
* an escaping rejection here would turn a dependency failure into a task crash.
*/
export declare function settleOrgVerdictWithin<T>(p: Promise<T>, fallback: T, opts: {
signal?: AbortSignal;
timeoutMs: number;
/**
* backlog #136④ — which arm WON, reported at the moment it won. A consumer that wants to tell a
* cancelled wait from an unreadable provider cannot get that by sampling `signal.aborted` after
* the await: a cancellation queued between the settlement and the continuation reads identically
* to one that actually ended the wait, and the operator-facing account would name the wrong
* cause. Called at most once, before `p` resolves, and only for the two non-provider arms —
* absent call ⇒ the awaited promise itself settled.
*/
onFallback?: (cause: "aborted" | "timeout") => void;
}): Promise<T>;
/** The `decisionReason` of a decision an ORG RULE produced (a deny, or a non-dismissable ask). Same
* single-spelling contract as {@link ORG_UNAVAILABLE_DECISION_REASON}. */
export declare const ORG_RULE_DECISION_REASON: Extract<import("./tool-policy.js").DecisionReason, "org_rule">;
/** How far into the future a snapshot's `fetchedAtMs` may sit before it is refused — ordinary NTP-level
* clock skew passes; a far-future timestamp (which would satisfy the staleness bound INDEFINITELY,
* turning fail-closed into evergreen freshness) does not. */
export declare const ORG_FETCHED_AT_SKEW_ALLOWANCE_MS: number;
/** The org partition's resolution word. `ungoverned` is the store's answer on a deployment that
* configured no org partition — a fourth member so "no org layer here" is never spelled as an
* empty `fresh` snapshot. */
export type OrgRuleStatus = "fresh" | "last-known-good" | "unavailable" | "ungoverned";
/** What one resolution reports. `rules` is empty ⟺ status `unavailable`/`ungoverned` or the snapshot
* is empty — the cases are distinguished by `status`, never by the emptiness of the list. */
export interface OrgRuleResolution {
source: "org";
status: OrgRuleStatus;
rules: OrgPermissionRule[];
revision?: number;
/** Honest-disclosure lines: source failures, refused snapshots, rollback refusals. */
disclosures: string[];
}
/** The org partition engine the unified store composes (design/389): fetch → validate → install-if-newer
* → judge freshness, one serialized chain per deployment. */
export interface OrgRulePartition {
resolve(): Promise<OrgRuleResolution>;
}
/** The partition config a deployment hands to `createPermissionRuleStoreProvider({ org })`. Its
* PRESENCE is the governance declaration. */
export interface OrgRulePartitionConfig {
/** Where the deployment fetches the current snapshot. Required — a governed declaration without a
* source is a configuration contradiction and refuses to boot. */
source: OrgRuleSnapshotProvider;
/** Durable LKG + high-water home. Omitted ⇒ process-local only, DISCLOSED on every resolution: a
* restart then forgets both the last-known-good snapshot and the anti-rollback mark. */
persistence?: OrgRuleStatePersistence;
stalenessBoundMs: number;
now?: () => number;
}
/** The ungoverned resolution — what a store with no org partition answers. */
export declare const UNGOVERNED_ORG_RESOLUTION: Readonly<OrgRuleResolution>;
/**
* Construct the org partition engine for a GOVERNED deployment. This constructor IS the boot gate:
* - a declaration WITHOUT a snapshot source is a configuration contradiction and refuses to boot —
* a silently ungoverned "governed" deployment is precisely the failure this bit exists to prevent
* (the type requires `source`; the check is executed for a JavaScript caller);
* - `stalenessBoundMs` follows the knob discipline: a non-number, NaN, non-finite or non-positive
* bound is refused loudly, never coerced.
*/
export declare function createOrgRulePartition(cfg: OrgRulePartitionConfig): OrgRulePartition;
/** Whether two snapshots carry the same POLICY: row for row, the same policy members ({@link ORG_ROW_POLICY_MEMBERS}) — never
* observation metadata, never a member no judgment reads (which a copy may carry or leave out). The ONE test of "the same
* revision, the same content": the partition's equal-revision guard asks it of two snapshots it holds in one form
* ({@link orgSnapshotCopyOf}), and a persistence backend asks it of what it stores. */
export declare function sameOrgPolicyContent(a: OrgRuleSnapshot, b: OrgRuleSnapshot): boolean;
/**
* Which calls an UNREADABLE row may reach — the calls its strictest reading stands over.
*
* `"every"` when the row names no tool this layer can place: no rule text, a head the rule syntax cannot read, the lone
* `*`, or a tool with no rule grammar (a misspelled tool name and a tool this build does not know look the same, and
* both may be the shell). Otherwise the rule WORD it names — a shell tool's head is the command word, as a call's is —
* and, for a tool whose path rules speak for a face ({@link PATH_RULE_FACES}), the faces of the column its strictest
* behavior reads: such a row reaches what a readable rule of its tool could reach, and nothing else.
*/
export type OrgRowReach = "every" | {
readonly words: readonly string[];
readonly faces: readonly DenyFace[];
};
/**
* A row the layer cannot read: its text (as published; the rendering of the scalar its `rule` holds when that is not a string;
* `<unreadable>` when it holds none — a structure is no value, {@link orgRowPolicyOf}), its 1-based place in the
* snapshot, why (`code`, and `detail` for a row whose shape — not its text — is at fault), the behavior it stands at, and
* the calls it reaches. `behavior` is the row's STRICTEST reading: `ask` only when the row says `ask`, `deny` for a row
* that says `deny` and for one whose behavior cannot be read.
*/
export interface UnreadableOrgRow {
readonly kind: "unreadable";
readonly index: number;
readonly rule: string;
readonly code: RuleRejectCode;
readonly detail?: string;
readonly behavior: "deny" | "ask";
readonly reach: OrgRowReach;
}
/** A row the layer reads: a shell command rule under the behavior it publishes. */
export interface ReadOrgRow {
readonly kind: "read";
readonly index: number;
readonly rule: string;
readonly behavior: "deny" | "ask";
readonly parsed: ParsedRule;
}
/** One call as the org layer judges it: the rule words it answers to (`laneToolNamesOf`; one word is enough), its shell
* command when it is the shell tool's, and the face of its path target when it has one (`callPathFaceOf`). */
export interface OrgRuleCall {
readonly tool: string | readonly string[];
readonly command?: string;
readonly face?: DenyFace;
}
/** Every row of a snapshot read once, and the one judgment of a call against them. */
export interface OrgRulesReading {
readonly rows: readonly (ReadOrgRow | UnreadableOrgRow)[];
readonly unreadable: readonly UnreadableOrgRow[];
verdictFor(call: OrgRuleCall): OrgRuleVerdict | undefined;
}
/**
* The members of an org row the layer reads to judge a call — the row's POLICY. A row IS these members: every reader holds and
* judges a row as its policy ({@link orgRowPolicyOf}), and two rows with the same policy members are the same policy
* ({@link sameOrgPolicyContent}). Any other member a row carries is never judged, held, served or persisted (the receipt reads
* each of a row's own members once — a getter on one runs once — and copies none of what it holds beyond a scalar).
*/
export declare const ORG_ROW_POLICY_MEMBERS: readonly ["rule", "behavior"];
/** A JSON scalar — the only value a member the partition keeps holds as data. */
type Scalar = string | number | boolean | null;
type ScalarMembers<K extends string> = {
[P in K]?: Scalar;
};
type OrgRowPolicy = Readonly<ScalarMembers<(typeof ORG_ROW_POLICY_MEMBERS)[number]>>;
/**
* The envelope's rules LIST, read once: `refused` when it is not a list of at most the cap's places ({@link listRefusalOf} —
* judged before the list is received, so an oversized or sparse list builds no row) or cannot be received at all;
* otherwise its rows, each as its policy ({@link orgRowPolicyOf}) — the list received whole in one receipt
* ({@link ORG_RULES_RECEIPT}), so a row's member that cannot be read is that row's alone, a row that cannot be read at all (and
* a hole) is a row with nothing in it (`{}`: no rule text, read at its strictest reading) and the rows beside it are untouched.
* The ONE reader of the list — the partition holds a snapshot's rows as these, fetched and read back alike
* ({@link orgSnapshotCopyOf}), and every reading of rows ({@link readOrgRules}) reads them through it — so no store's rows can be
* read two ways, and no row can take the reading of another down with it.
*/
export declare function orgRuleListOf(rules: unknown): {
readonly rows: readonly OrgRowPolicy[];
} | {
readonly refused: string;
};
/**
* The ONE reading of an org snapshot's rows: each row read once ({@link readOrgRow}), and the one judgment of a call
* against them. Every reader of org rows goes through it — the partition's degraded line, the lane's adjudication,
* {@link orgRuleVerdictFor}, {@link unenforceableOrgRules} and the shadow view — so no two of them can read one row two ways.
*
* A call meets the rows in three answers. DENY: a readable deny reaches one of its program runs, or an unreadable row
* standing at deny reaches the call — the first such row in snapshot order speaks. ASK: a readable rule the call cannot be
* READ against (the lexer's reason — a rule that cannot be judged may be a deny, so it outranks a reached ask), else a
* readable ask reaching it or an unreadable row standing at ask reaching it, the first in snapshot order. Otherwise none.
* An unreadable row's verdict carries its reason ({@link unreadableOrgRowReason}) beside its text.
*
* The rows are read off the one list reader's copy ({@link orgRuleListOf}): rules that are not a list come back `refused` —
* the org's rules cannot be read, which is never a policy of zero rows — and a row is judged on what its copy holds: a policy
* member that could not be read is one the row does not have (no `rule` ⇒ no rule text, every call; no `behavior` ⇒ its
* strictest reading over the calls of the tool it names), read like any other unreadable row while the rows beside it keep
* their effect.
*/
export declare function readOrgRules(rules: unknown): OrgRulesReading | {
readonly refused: string;
};
/**
* Which rows of an org snapshot this layer cannot read, each with its code — the rows that stand at their strictest
* reading instead of as published. Empty ⟺ every row is read. Every unreadable row is reported, a row with no rule text
* or a behavior other than deny / ask included (its text rendered when it is not a string). Rules that are not a list, or a
* list past the cap, throw the one list judgment's sentence ({@link readListedOrgRules}) — never a reading of fewer rows.
*/
export declare function unenforceableOrgRules(rules: readonly OrgPermissionRule[]): Array<{
rule: string;
code: RuleRejectCode;
}>;
/** The reason an unreadable row's verdict carries — its place in the snapshot, its code, and the repair. */
export declare function unreadableOrgRowReason(row: Pick<UnreadableOrgRow, "index" | "code" | "detail">): string;
/** The ONE clause for an org verdict that carries an `unreadable` reason — a rule the call could not be read against, or
* a row this layer cannot read — on a refusal and on a question alike. */
export declare function orgUnreadableClause(reason: string): string;
/**
* The ONE line naming a snapshot's unreadable rows (count, the first {@link DISCLOSED_UNREADABLE_ROWS} by place, text and
* code, the rest summarized), or `undefined` when every row is read. The partition puts it on every resolution that
* serves such a snapshot, and the org lane hands it to its operator sink on every adjudication over one, so the two seats
* describe one snapshot in one sentence. Each text is escaped — the characters that most often make a row unreadable are
* exactly the ones that do not render.
*/
export declare function describeUnreadableOrgRows(unreadable: readonly UnreadableOrgRow[], revision?: unknown): string | undefined;
/**
* design/182 §7.2 — which org rule speaks for this call, if any: {@link readOrgRules}'s judgment, over a snapshot read for
* this call alone. Matching uses the SAME parser and the SAME tightening reach the personal deny/ask rows use
* ({@link programRunReachOf}: an interpreter-headed prefix deny like `Bash(node:*)` is a legitimately wide tightening and
* matches; a segment hit is a hit; a call the lexer cannot read against a rule is an ASK with the reason — fail closed,
* never silence), and a row this layer cannot read stands at its strictest reading over the calls it may reach. A caller
* judging many calls against one snapshot reads it once with {@link readOrgRules} instead. Rules that are not a list, or a
* list past the cap, throw the one list judgment's sentence ({@link readListedOrgRules}) — never a verdict over fewer rows.
*/
export declare function orgRuleVerdictFor(rules: readonly OrgPermissionRule[], call: OrgRuleCall): OrgRuleVerdict | undefined;
/** The org layer's answer for one call: which rule spoke, with what behavior — and, when the rule could not be judged
* against the call, why (`unreadable`): either the call could not be READ against it (the lexer's reason; `behavior` is
* then `ask` — the org published a rule that may reach this call, and nobody can say), or the row is not one this layer
* reads ({@link unreadableOrgRowReason}; `behavior` is the row's strictest reading). */
export interface OrgRuleVerdict {
readonly behavior: "deny" | "ask";
readonly rule: string;
readonly unreadable?: string;
}
/**
* design/182 §7.3 / design/389 — the SHADOW predicate the unified store's read face applies to every
* personal allow row: does an org DENY reach this rule's own command pattern? An org deny does not
* delete a personal rule — the personal rule is the user's approval record, and the org's power is to
* OVERRIDE it, not to self-declare its removal — so a shadowed rule reports `shadowed-by-org` and comes
* back by itself when the org deny is withdrawn.
*
* The SAME reach the gate uses ({@link ruleReachesProgramRun}), so the view and the decision cannot
* disagree about whether a deny touches this rule at all. A personal rule may name a connector chain, and a deny on one
* of its segments shadows it exactly as a deny on the whole does.
*
* WHAT `shadowed-by-org` DOES AND DOES NOT CLAIM: the predicate asks whether the deny reaches the
* personal rule's own command PATTERN — one exemplar — not whether it covers every call that pattern
* admits. The two differ for a PREFIX personal rule under a narrower deny: `Bash(git status:*)` with an
* org deny of exactly `Bash(git status)` reports shadowed, yet `git status --short` is still admitted at
* the gate. The inverse partial overlap (personal `Bash(git:*)`, org deny `Bash(git status:*)`) reports
* live while part of its reach is denied. A truthful answer needs pattern CONTAINMENT and a third status
* for partial overlap, which is an API change with its own design; what the two-value view is honest
* about today is "this rule is touched by a deny" / "no deny mentions it". The gate is the authority
* either way. org ASK rules do not shadow: the personal lane never consumes a `requiresRealApproval`
* ask, so the two never actually meet on one call.
*
* An UNREADABLE row standing at deny refuses every call it reaches ({@link readOrgRules}), so it shadows every personal
* rule of a tool it reaches — by the rule word, or by the face that tool's calls are on — exactly as the gate refuses
* those calls; one standing at ask does not shadow, as a readable ask does not.
*
* design/382 §2.6 — no READABLE org row reaches a Read directory rule in this version: the org layer has no Read
* grammar, and the rule's `command` is a PATH that must not be fed to the shell lane's segment reader as though it
* were a command line.
*/
export declare function orgRuleShadows(orgRules: readonly OrgPermissionRule[], rule: Pick<PersistedRule, "tool" | "command">): boolean;
/** The shadow predicate with the org rows read ONCE — what a read over many rows uses. */
export declare function compileOrgShadowPredicate(orgRules: readonly OrgPermissionRule[]): (rule: Pick<PersistedRule, "tool" | "command">) => boolean;
/** Does this store carry a write face? Re-exported convenience for org-ticket integrations that need to
* distinguish a read-only projection from a writable personal bucket without importing the writer seam. */
export declare function isWritablePermissionRuleStore(store: PermissionRuleStore): boolean;
export {};