@sema-agent/core
Version:
Stateless, task-oriented AI agent core
248 lines (247 loc) • 16.9 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.
*/
import { type PersistedRule, type RuleRejectCode } from "./permission-rule-model.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;
/**
* Which entries of an org snapshot the CURRENT rule validator refuses — the published deny/ask rules
* that can match nothing on this build. Empty ⟺ every rule is enforceable.
*
* Separate from {@link validateOrgSnapshot} because BOTH gates need it: the install gate (a fetched
* snapshot) and the serving gate (backlog #177 — a snapshot persisted by an earlier build is read back
* and served without ever having met this validator). It reports every offender rather than the first,
* since a refusal an administrator cannot act on names no line to fix.
*/
export declare function unenforceableOrgRules(rules: readonly OrgPermissionRule[]): Array<{
rule: string;
code: RuleRejectCode;
}>;
/**
* design/182 §7.2 — which org rule speaks for this command, if any. Deny outranks ask; within a
* behavior the first textual match wins (reporting order only — all denies are the same one answer).
* 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).
*
* The skip below is a defensive floor, and what stands behind it is worth stating precisely: rules that
* arrive from the org partition's `resolve` ({@link createOrgRulePartition}) have passed {@link validateOrgSnapshot} on BOTH the install
* and the serving path (backlog #177), so an unenforceable entry cannot reach here through the overlay
* — the whole snapshot is refused, or the resolution reports `unavailable`, and either way it is
* disclosed. A caller that hands this function a snapshot it obtained ELSEWHERE gets no such guarantee:
* the skip is silent, and naming the dead entries is that caller's job — {@link unenforceableOrgRules}
* is the shared way to do it.
*/
export declare function orgRuleVerdictFor(rules: readonly OrgPermissionRule[], call: {
tool: string;
command: string;
}): OrgRuleVerdict | undefined;
/** The org layer's answer for one call: which rule spoke, with what behavior — and, when the call could
* not be READ against the org's rules, the lexer's reason beside the first rule it could not judge
* (`behavior` is then `ask`: the org published a rule that may reach this call, and nobody can say). */
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.
*
* design/382 §2.6 — a Read directory rule is never org-shadowed in this version: the org layer has no
* Read grammar (a snapshot carrying one refuses whole at the validator), so there is no deny that could
* reach it, and its `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 denies parsed 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;