@sema-agent/core
Version:
Stateless, task-oriented AI agent core
170 lines (169 loc) • 9.57 kB
TypeScript
/**
* design/182 §4 — the cloud-sync client for persisted allow rules.
*
* One round = one RTT, full state both directions. The data model is already a state CRDT (OR-Set:
* per-add dots + observed-remove tombstones), so sync is the JOIN of two states — no cursors, no
* baselines, no three-way reconcile, and the whole cursor-retention/prefill/echo family of defenses the
* memory sync face carries is structurally absent here (a registered, deliberate divergence). What IS
* inherited from that face is the contract style: an injected transport (core bundles no fetch), the
* transport running BEFORE any local write (a throw aborts the round with zero local effects), a
* picked-not-spread response parse, and a pure join both ends share.
*
* The safety polarity is the opposite of memory's: a memory sync bug loses data; a rule sync bug
* WIDENS what runs without asking. Every ambiguous call here therefore resolves toward the tightening
* side — the tighten-only partial application (§4.3), the frontier fence (§5.2), and the rule that a
* LOCAL row only ever leaves the live view through an explicit, disclosed quarantine instruction.
*
* ## Trust domain (stated, not defended against)
*
* Connecting a principal's store to a sync endpoint merges that principal's devices, the server and
* every device's transport into ONE consent trust domain: any compromised member can mint persisting
* allow rules for the whole domain (an injected add rides the next full-state exchange everywhere).
* Per-add provenance keeps the minting replica auditable, resurrection/landing disclosures keep changes
* visible, and the single validator still refuses every illegal SHAPE from the wire — but the
* "one human click behind every add" guarantee is domain-level, not device-level, until the
* privilege-separation ticket adds device-bound signatures. Deployments should connect knowing which
* level they are buying.
*/
import type { PersistedRule, RuleBehavior, RuleDot, RuleRejectCode, RuleScope, RuleTombstone } from "./permission-rule-model.js";
import type { PermissionRuleStoreProvider } from "./permission-rule-provider.js";
import type { RuleOwner, RuleSyncDrop, RuleSyncFrontier, RuleSyncState } from "./permission-rule-store.js";
import { type TracerHook } from "./trace.js";
/** The injected HTTP seam — core never bundles a fetch. The deployment owns base URL, auth, TLS and
* retries; a non-2xx / network failure should THROW (the round then aborts with zero local effects —
* the transport runs before any local write). */
export type PermissionRuleSyncTransport = (path: string, body: unknown) => Promise<unknown>;
/** The one wire path of this protocol. */
export declare const PERMISSION_RULE_SYNC_PATH = "/v1/permission-rules/sync";
/** The wire request: the local raw state, whole. `replica` is this store's actor id — an observation /
* frontier-accounting fact, NEVER an identity input; `principal` is an envelope echo — authentication
* lives on the transport's authenticated channel, and the server must never trust this field. */
export interface RuleSyncRequestBody {
principal: string;
replica: string;
state: RuleSyncState;
observedVector?: RuleSyncFrontier;
}
/** The wire response (server twin: join its persisted state with the request state, answer the whole
* merged form). `gcFrontier` is the COLLECTED watermark (§5.1) — absent in v2.0 by protocol. */
export interface RuleSyncResponseBody {
principal: string;
merged: RuleSyncState;
gcFrontier?: RuleSyncFrontier;
/** Server-side refusals of rows we sent. `behavior` is part of the row identity the twin names. */
dropped?: Array<{
behavior: import("./permission-rule-model.js").RuleBehavior;
rule: string;
scope: RuleScope;
dot: RuleDot;
reason: string;
}>;
}
export interface PermissionRuleSyncResult {
/** True ⟺ the round landed with zero drops, zero quarantines and zero warnings. */
ok: boolean;
/** Local set sizes at push time (full-state protocol: the wire carries the whole set). */
pushed: {
addDots: number;
tombstones: number;
};
/** What the landing added relative to the local pre-state. */
landed: {
newAdds: number;
newTombstones: number;
};
/** (behavior, rule, scope) identities that went removed → live in this round — add-wins made visible (§4.6). */
resurrected: Array<{
behavior: import("./permission-rule-model.js").RuleBehavior;
rule: string;
scope: RuleScope;
byDot: RuleDot;
}>;
/** Inbound refusals and local quarantine moves, typed (closed reason set, governance-codes family). */
dropped: RuleSyncDrop[];
/** The store revision after landing (the pre-round revision when nothing landed). */
rev: number;
/** Honest disclosures for tolerated protocol irregularities (withheld adds, unshapeable entries…). */
warnings?: string[];
}
/** A typed, DISCRIMINABLE refusal: a local-owner bucket has no cloud home until it is adopted. Distinct
* by `code` from a missing principal, so "not signed in" and "this bucket cannot sync" never collapse
* into one indistinguishable failure shape. */
export declare const LOCAL_OWNER_UNSYNCABLE_CODE = "permission_rules.local_owner_unsyncable";
/**
* Run one sync round for one principal's bucket.
*
* Order of operations is the contract: read raw → transport (no local write yet; a throw aborts with
* zero local effects) → validate the response (picked-not-spread; principal echo mismatch throws) →
* partition tighten-only (§4.3) → land the WHOLE round as ONE `sync-join` delta under optimistic
* concurrency — a conflict re-reads and RE-JOINS (the join's commutativity/idempotence makes the
* recompute correct by construction; adds and tombstones that landed during the RTT survive it).
*/
export declare function syncPermissionRules(opts: {
provider: PermissionRuleStoreProvider;
principal: string;
/** Accepted only as `kind: "principal"` (and it must agree with `principal`). A local-owner value is
* refused with the typed {@link LOCAL_OWNER_UNSYNCABLE_CODE} error — see design/182 §4.5. */
owner?: RuleOwner;
transport: PermissionRuleSyncTransport;
/** Injected clock — observation timestamps only, never adjudication input. */
now?: () => number;
/** design/182 §4.6 — optional trace sink for the round's DISCLOSURES: one
* `permission.rule_sync_resurrected` per removed→live transition and one
* `permission.rule_sync_dropped` per refused/quarantined row. The same facts are already on the
* returned result; this is the operator channel for a deployment that drives sync outside a task
* (where there is no `RunnerDeps.tracer` in scope) and does not read the result itself. Absent ⇒ no
* events, and the round is byte-identical. */
tracer?: TracerHook;
}): Promise<PermissionRuleSyncResult>;
interface ParsedRuleSyncResponse {
principal: string;
merged: {
rules: PersistedRule[];
tombstones: Array<RuleTombstone | undefined>;
};
gcFrontier?: RuleSyncFrontier;
dropped: Array<{
behavior: RuleBehavior;
rule: string;
scope: RuleScope;
dot: RuleDot;
reason: string;
}>;
/** Tombstones whose SHAPE is whole but whose rule TEXT the current validator refuses — the third
* outcome, kept OUT of `merged.tombstones` so it does not read as malformed. See {@link
* parseRuleSyncResponse} for why this one does not withhold the round's adds (backlog #176). */
textRefusedTombstones: Array<{
behavior: RuleBehavior;
rule: string;
scope: RuleScope;
dot: RuleDot;
code: RuleRejectCode;
}>;
/** design/382 §4.3 — SESSION-scope tombstones, dropped at the parse as per-row refusals (never
* `undefined`-malformed): a session row can never land regardless of its dots, so classifying a
* DAMAGED one as generic-malformed would withhold every valid inbound add on every round — one
* peer row poisoning sync forever, the exact shape the per-row drop arm exists to avoid. */
sessionScopeTombstones: Array<{
behavior: RuleBehavior;
rule: string;
scope: RuleScope;
dot: RuleDot;
}>;
warnings: string[];
}
/**
* Parse one wire response. Envelope violations (missing/mistyped principal, echo mismatch, no merged
* state, an unparseable frontier) THROW — a protocol error aborts the round with zero local effects.
* ENTRY-level violations degrade per §4.3: an unshapeable rule/add is skipped with a warning (it never
* reaches the local disk); an unshapeable tombstone is surfaced as `undefined` so the caller's
* tighten-only partitioning withholds the round's adds. Unknown keys never flow anywhere — every field
* below is PICKED off the response, the object itself is discarded.
*
* A tombstone has THREE outcomes, not two (backlog #176): sound; malformed (bad shape, or a spelling
* that parses but is not canonical — the withholding arm); and shape-whole-but-TEXT-refused, reported
* on {@link ParsedRuleSyncResponse.textRefusedTombstones}. The third is a legacy row a tightened
* validator no longer admits — permanent, so treating it as the second would stall inbound adds forever.
*/
export declare function parseRuleSyncResponse(raw: unknown, expectedPrincipal: string): ParsedRuleSyncResponse;
export {};