@sema-agent/core
Version:
Stateless, task-oriented AI agent core
130 lines (129 loc) • 8.57 kB
TypeScript
/**
* design/382 §4.3 / design/389 — the SESSION partition of the permission-rule store: where a
* `{kind:"session"}` scoped allow rule lives.
*
* A session authorization's home is the session's own lifetime — never the durable CRDT partition. The
* rows are ordinary {@link PersistedRule}s (scope `session`), and the unified store's ONE read
* query (`PermissionRuleStore.effective({ sessionId })`) splices this partition's rows IN FRONT of the
* durable partition's (the §4.1 narrowest-first reporting order: session > project > global), so every
* read-side consumer — the gate's conjunction arm, the coverage table, the consent prepare — takes them
* through the ONE eligibility predicate with zero second judges. The store's routing writer lands a
* session-scope `redemption-add` here; the durable partition never sees one.
*
* ## The contract (design/382 §8-Q6, settled here)
*
* TWO verbs, and deliberately no third:
* · `read(sessionId)` — the live rows of ONE session, session-scoped, that session's only.
* · `apply(sessionId, add)` — land one redeemed authorization. Idempotent BY DOT (the redemption
* replay contract: `alreadyRedeemed` marks the dot as RECORDED, not the overlay as LANDED, so a
* replay must RE-APPLY, and this verb is what makes re-applying safe); refuses `session_ended`
* once the session's lifetime epoch is sealed — a late in-flight write after termination must not
* revive an authorization for a session that no longer exists.
*
* There is deliberately NO delete verb: a session row is not individually deletable — it dies with
* its session (design/382 §10, ruled). Termination is the HOST's act on its own implementation (see
* {@link InMemorySessionRulePartition.endSession}), not an engine verb.
*
* ## Lifecycle and durability
*
* The overlay lives WITH the session: a host that parks/checkpoints a session serializes its rows
* beside it and rehydrates them on resume (`snapshotSession`/`restoreSession` on the reference
* implementation) — that is how a session authorization survives a park without ever touching the
* persisted store, and why it still ends when the session ends. `sessionId` uniqueness is the host's
* duty (the `principal` posture): reusing an id declares "the same session".
*
* ## What this is NOT
*
* Not a durable backend: no dots are minted here (the redemption leg mints the audit dot exactly as the
* store leg does), no tombstones exist here, nothing here syncs. Every durable entrance — write
* deltas, sync in both directions, at-rest bytes — refuses or drops a session row
* (`permission-rule-store.ts`, the durable two-member face); this module is the OTHER side of that
* wall, and the wall is the design.
*/
import type { PersistedRule, RuleAdd, RuleBehavior, PersistedRuleMatch, PersistedRuleTool } from "./permission-rule-model.js";
/** One redeemed authorization landing in a session's overlay: the rule's canonical shape plus the
* redemption's own add (dot + provenance) — the same fields a `redemption-add` delta carries, minus
* the scope (the verb's `sessionId` IS the scope, constructed here so a caller cannot land a row
* whose scope names a different session than the one it addressed). */
export interface SessionRuleAdd {
behavior: RuleBehavior;
rule: string;
tool: PersistedRuleTool;
match: PersistedRuleMatch;
command: string;
add: RuleAdd;
}
/** What one overlay apply answered. `session_ended` is the epoch fence speaking: the write arrived at
* (or after) termination and landed nothing — the redemption reports the member `refused`. */
export type SessionRuleApplyResult = {
landed: true;
} | {
refused: "session_ended";
};
/**
* The session partition a deployment hands to `createPermissionRuleStoreProvider({ session })`. See the
* module doc for the contract; {@link InMemorySessionRulePartition} is the reference implementation and
* the semantics. A backend that keeps a session's rows beside its own session record (a host whose
* sessions are durable) may implement this over that storage — the law is LIFETIME-bound, not
* medium-bound: no tombstones, no sync, nothing outlives the session.
*/
export interface SessionRulePartition {
/** The live session-scoped rows of ONE session. An ended (or never-written) session reads as `[]`. */
read(sessionId: string): Promise<readonly PersistedRule[]>;
/** Land one redeemed authorization — idempotent by dot, refused once the session ended. */
apply(sessionId: string, add: SessionRuleAdd): Promise<SessionRuleApplyResult>;
}
/**
* The reference overlay — in-memory, per-process, the semantics every host implementation must keep:
*
* · APPLY is idempotent by dot identity (a replayed redemption re-submits its recorded dot and
* changes nothing — the store's `foldDelta` discipline, verbatim);
* · TERMINATION seals FIRST, clears SECOND ({@link endSession}): the sealed-set membership is the
* session's lifetime epoch, flipped atomically before any state is dropped, so an in-flight write
* that lost the race lands on the seal and answers `session_ended` — never on a half-cleared
* session, and never as a row that outlives its session (design/382 §4.3, the r2-⑥ fence);
* · a sealed session never reopens — `apply` and `restoreSession` both refuse it, so "session ended"
* is a one-way fact exactly like a quarantined dot's;
* · SNAPSHOT/RESTORE ({@link snapshotSession}/{@link restoreSession}) are the park/resume carriage:
* the host serializes a session's rows into its checkpoint and rehydrates them on resume. Restore
* is fail-closed on shape — every row must be a session row of THE session being restored (a
* checkpoint is host-owned bytes, and a global row smuggled through this door would ride the
* merge point straight into adjudication).
*/
export declare class InMemorySessionRulePartition implements SessionRulePartition {
private readonly rows;
private readonly sealed;
/** How many times one session's row set has CHANGED. Reading a caller-owned value runs caller code,
* so a verb that reads its argument and then replaces a session's rows wholesale must be able to
* tell that something landed in between — otherwise it overwrites a grant that was already answered
* `landed:true`, and a consent nobody withdrew stops existing. Bumped by every landing, restore and
* termination; read across `restoreSession`'s snapshot. */
private readonly epochs;
private bumpEpoch;
read(sessionId: string): Promise<readonly PersistedRule[]>;
apply(sessionId: string, add: SessionRuleAdd): Promise<SessionRuleApplyResult>;
/**
* The termination transfer: SEAL the lifetime epoch first (one atomic membership flip — after this
* line every in-flight `apply` holding this id answers `session_ended`), THEN drop the rows. The
* order is the whole fence: clear-then-seal would leave a window in which a late write re-grows a
* rule set for a session that is already gone. Idempotent; a never-seen id seals to the same place.
*/
endSession(sessionId: string): void;
/** The park half of the checkpoint carriage: this session's rows, serializable as-is. */
snapshotSession(sessionId: string): Promise<readonly PersistedRule[]>;
/**
* The resume half. Fail-closed on every axis: a sealed session never rehydrates (termination is
* one-way), and every row must be a well-formed SESSION row of exactly this session — anything else
* in the checkpoint bytes is refused whole, loudly, because the merge point trusts this overlay to
* serve only what its contract says it holds.
*
* The rows also pass the SAME `normalizePersistedRule` re-projection the sync face runs on store
* rows (rule text parses, canonical spelling, stored tool/match/command agree with the text): this
* door takes host-owned checkpoint bytes with no redemption validator in front of them, and a row
* the engine could not re-project would otherwise sit one merge away from adjudication. Rows this
* overlay landed itself always pass — {@link InMemorySessionRulePartition.apply} runs the same screen,
* so the two doors agree on what a session row is and a snapshot of this overlay always restores —
* which is what makes a refusal here foreign damage, refused whole.
*/
restoreSession(sessionId: string, rows: readonly PersistedRule[]): Promise<void>;
}