@arnilo/prism 0.0.12 → 0.0.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/dist/agents.js +21 -2
  3. package/dist/contracts.d.ts +25 -1
  4. package/dist/extensions.d.ts +11 -0
  5. package/dist/extensions.js +15 -0
  6. package/dist/identity.d.ts +92 -0
  7. package/dist/identity.js +257 -0
  8. package/dist/index.d.ts +6 -2
  9. package/dist/index.js +3 -1
  10. package/dist/persistence-lifecycle.d.ts +103 -0
  11. package/dist/persistence-lifecycle.js +204 -0
  12. package/dist/providers/openai-compatible.d.ts +5 -1
  13. package/dist/providers/openai-compatible.js +15 -6
  14. package/dist/secure-agent.js +7 -1
  15. package/dist/testing/persistence-schema.d.ts +2 -2
  16. package/dist/testing/persistence-schema.js +35 -2
  17. package/dist/tools.d.ts +2 -0
  18. package/dist/tools.js +6 -0
  19. package/docs/a2a.md +2 -0
  20. package/docs/agent-identity.md +111 -0
  21. package/docs/credential-storage.md +3 -0
  22. package/docs/database-persistence.md +18 -7
  23. package/docs/extensions.md +1 -0
  24. package/docs/guardrails.md +3 -0
  25. package/docs/host-security.md +5 -1
  26. package/docs/index.md +17 -8
  27. package/docs/mcp-tools.md +2 -0
  28. package/docs/migration.md +27 -0
  29. package/docs/model-routing.md +102 -0
  30. package/docs/observability.md +2 -0
  31. package/docs/performance.md +19 -0
  32. package/docs/policy-and-audit.md +127 -0
  33. package/docs/postgres-persistence.md +1 -1
  34. package/docs/provider-packages.md +8 -1
  35. package/docs/provider-request-policies.md +2 -0
  36. package/docs/providers/azure.md +74 -0
  37. package/docs/providers/bedrock.md +72 -0
  38. package/docs/providers/google.md +1 -0
  39. package/docs/providers/openai-compatible.md +3 -1
  40. package/docs/providers/openrouter.md +2 -0
  41. package/docs/providers/vertex.md +71 -0
  42. package/docs/public-contracts.md +3 -1
  43. package/docs/release-and-install.md +78 -6
  44. package/docs/review-coverage-2026-07-23-phase-8.md +245 -0
  45. package/docs/runs-and-usage.md +2 -0
  46. package/docs/server.md +33 -4
  47. package/docs/sqlite-persistence.md +1 -1
  48. package/docs/supervisors.md +2 -0
  49. package/docs/work-connectors.md +28 -0
  50. package/docs/work-tools.md +114 -0
  51. package/package.json +4 -1
@@ -0,0 +1,103 @@
1
+ import type { OwnershipScope, PersistencePage, PersistenceQuery, RetentionPolicy } from "./contracts.js";
2
+ /** Resource classes covered by holds, quotas, and retention sweeps. */
3
+ export type PersistenceResourceKind = "session" | "entry" | "run" | "event" | "tool_call" | "usage" | "checkpoint" | "feedback" | "audit" | "work_artifact" | "connector_operation";
4
+ export declare const DEFAULT_LIFECYCLE_PAGE_SIZE = 100;
5
+ export declare const HARD_LIFECYCLE_PAGE_SIZE = 500;
6
+ export declare const DEFAULT_MAX_HOLD_REASON_BYTES = 1024;
7
+ export declare const HARD_MAX_HOLD_REASON_BYTES: number;
8
+ export declare class PersistenceLifecycleError extends Error {
9
+ readonly code: string;
10
+ constructor(message: string, code?: string);
11
+ }
12
+ export interface LegalHoldRecord extends OwnershipScope {
13
+ readonly id: string;
14
+ readonly resourceKind: PersistenceResourceKind;
15
+ readonly resourceId: string;
16
+ readonly reason: string;
17
+ readonly createdAt: string;
18
+ readonly createdBy?: string;
19
+ readonly metadata?: Readonly<Record<string, unknown>>;
20
+ }
21
+ export interface PutLegalHoldInput extends OwnershipScope {
22
+ readonly resourceKind: PersistenceResourceKind;
23
+ readonly resourceId: string;
24
+ readonly reason: string;
25
+ readonly id?: string;
26
+ readonly createdBy?: string;
27
+ readonly metadata?: Readonly<Record<string, unknown>>;
28
+ readonly signal?: AbortSignal;
29
+ }
30
+ export interface ReleaseLegalHoldInput extends OwnershipScope {
31
+ readonly id: string;
32
+ readonly signal?: AbortSignal;
33
+ }
34
+ export interface LegalHoldQuery extends PersistenceQuery, OwnershipScope {
35
+ readonly resourceKind?: PersistenceResourceKind;
36
+ readonly resourceId?: string;
37
+ readonly holdId?: string;
38
+ readonly signal?: AbortSignal;
39
+ }
40
+ export interface ApplyRetentionInput extends OwnershipScope {
41
+ readonly policy: RetentionPolicy;
42
+ /** When omitted, adapters discover expired/over-limit session ids for this ownership page. */
43
+ readonly candidates?: readonly string[];
44
+ readonly cursor?: string;
45
+ readonly limit?: number;
46
+ readonly signal?: AbortSignal;
47
+ }
48
+ export interface ApplyRetentionResult {
49
+ readonly deleted: readonly string[];
50
+ readonly skippedHeld: readonly string[];
51
+ readonly nextCursor?: string;
52
+ }
53
+ export interface LegalHoldExportItem {
54
+ readonly holdId: string;
55
+ readonly resourceKind: PersistenceResourceKind;
56
+ readonly resourceId: string;
57
+ readonly reason: string;
58
+ readonly createdAt: string;
59
+ readonly redacted: true;
60
+ }
61
+ export interface ExportUnderHoldInput extends OwnershipScope {
62
+ readonly holdId?: string;
63
+ readonly resourceKind?: PersistenceResourceKind;
64
+ readonly cursor?: string;
65
+ readonly limit?: number;
66
+ readonly signal?: AbortSignal;
67
+ }
68
+ export interface TenantQuota extends OwnershipScope {
69
+ readonly resourceKind: PersistenceResourceKind;
70
+ readonly limit: number;
71
+ readonly used: number;
72
+ readonly updatedAt: string;
73
+ }
74
+ export interface SetTenantQuotaInput extends OwnershipScope {
75
+ readonly resourceKind: PersistenceResourceKind;
76
+ readonly limit: number;
77
+ readonly signal?: AbortSignal;
78
+ }
79
+ export interface ConsumeTenantQuotaInput extends OwnershipScope {
80
+ readonly resourceKind: PersistenceResourceKind;
81
+ /** Units to consume. Default 1. */
82
+ readonly delta?: number;
83
+ readonly signal?: AbortSignal;
84
+ }
85
+ /** Optional persistence lifecycle capability (retention / hold / export / quota). */
86
+ export interface PersistenceLifecycleStore {
87
+ putLegalHold(input: PutLegalHoldInput): Promise<LegalHoldRecord>;
88
+ releaseLegalHold(input: ReleaseLegalHoldInput): Promise<boolean>;
89
+ listLegalHolds(query: LegalHoldQuery): Promise<PersistencePage<LegalHoldRecord>>;
90
+ /** Deletes candidates not under legal hold. Hold always wins over retention. */
91
+ applyRetention(input: ApplyRetentionInput): Promise<ApplyRetentionResult>;
92
+ exportUnderHold(input: ExportUnderHoldInput): Promise<PersistencePage<LegalHoldExportItem>>;
93
+ setTenantQuota(input: SetTenantQuotaInput): Promise<TenantQuota>;
94
+ getTenantQuota(input: OwnershipScope & {
95
+ readonly resourceKind: PersistenceResourceKind;
96
+ readonly signal?: AbortSignal;
97
+ }): Promise<TenantQuota | null>;
98
+ /** Fails closed when used + delta would exceed limit. */
99
+ consumeTenantQuota(input: ConsumeTenantQuotaInput): Promise<TenantQuota>;
100
+ }
101
+ export declare function createMemoryPersistenceLifecycle(): PersistenceLifecycleStore;
102
+ /** True when a session/resource id is under an active legal hold in the page of holds. */
103
+ export declare function isResourceHeld(holds: readonly Pick<LegalHoldRecord, "resourceKind" | "resourceId">[], resourceKind: PersistenceResourceKind, resourceId: string): boolean;
@@ -0,0 +1,204 @@
1
+ export const DEFAULT_LIFECYCLE_PAGE_SIZE = 100;
2
+ export const HARD_LIFECYCLE_PAGE_SIZE = 500;
3
+ export const DEFAULT_MAX_HOLD_REASON_BYTES = 1024;
4
+ export const HARD_MAX_HOLD_REASON_BYTES = 8 * 1024;
5
+ export class PersistenceLifecycleError extends Error {
6
+ code;
7
+ constructor(message, code = "ERR_PRISM_PERSISTENCE_LIFECYCLE") {
8
+ super(message);
9
+ this.code = code;
10
+ this.name = "PersistenceLifecycleError";
11
+ }
12
+ }
13
+ export function createMemoryPersistenceLifecycle() {
14
+ const holds = new Map();
15
+ const quotas = new Map();
16
+ const deleted = new Set();
17
+ return {
18
+ async putLegalHold(input) {
19
+ throwIfAborted(input.signal);
20
+ assertOwnership(input);
21
+ const reason = assertReason(input.reason);
22
+ if (!input.resourceId || !input.resourceKind) {
23
+ throw new PersistenceLifecycleError("resourceKind and resourceId are required", "ERR_PRISM_LIFECYCLE_HOLD");
24
+ }
25
+ const id = input.id ?? crypto.randomUUID();
26
+ const record = {
27
+ id,
28
+ resourceKind: input.resourceKind,
29
+ resourceId: input.resourceId,
30
+ reason,
31
+ createdAt: new Date().toISOString(),
32
+ ...(input.createdBy === undefined ? {} : { createdBy: input.createdBy }),
33
+ ...(input.metadata === undefined ? {} : { metadata: input.metadata }),
34
+ ...ownership(input),
35
+ };
36
+ holds.set(id, record);
37
+ return record;
38
+ },
39
+ async releaseLegalHold(input) {
40
+ throwIfAborted(input.signal);
41
+ assertOwnership(input);
42
+ const current = holds.get(input.id);
43
+ if (!current)
44
+ return false;
45
+ assertSameOwnership(input, current);
46
+ holds.delete(input.id);
47
+ return true;
48
+ },
49
+ async listLegalHolds(query) {
50
+ throwIfAborted(query.signal);
51
+ assertOwnership(query);
52
+ const limit = pageLimit(query.limit);
53
+ const items = [...holds.values()]
54
+ .filter((hold) => sameOwnership(query, hold))
55
+ .filter((hold) => query.holdId === undefined || hold.id === query.holdId)
56
+ .filter((hold) => query.resourceKind === undefined || hold.resourceKind === query.resourceKind)
57
+ .filter((hold) => query.resourceId === undefined || hold.resourceId === query.resourceId)
58
+ .sort((a, b) => a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id));
59
+ const start = query.cursor ? items.findIndex((item) => item.id === query.cursor) + 1 : 0;
60
+ const slice = items.slice(Math.max(0, start), Math.max(0, start) + limit);
61
+ const next = start + limit < items.length ? slice.at(-1)?.id : undefined;
62
+ return { items: slice, ...(next === undefined ? {} : { nextCursor: next }) };
63
+ },
64
+ async applyRetention(input) {
65
+ throwIfAborted(input.signal);
66
+ assertOwnership(input);
67
+ const held = new Set([...holds.values()]
68
+ .filter((hold) => sameOwnership(input, hold) && hold.resourceKind === "session")
69
+ .map((hold) => hold.resourceId));
70
+ const candidates = input.candidates ?? [];
71
+ const deletedIds = [];
72
+ const skippedHeld = [];
73
+ for (const id of candidates) {
74
+ if (held.has(id)) {
75
+ skippedHeld.push(id);
76
+ continue;
77
+ }
78
+ deleted.add(id);
79
+ deletedIds.push(id);
80
+ }
81
+ return { deleted: deletedIds, skippedHeld };
82
+ },
83
+ async exportUnderHold(input) {
84
+ throwIfAborted(input.signal);
85
+ assertOwnership(input);
86
+ const page = await this.listLegalHolds({
87
+ ...ownership(input),
88
+ holdId: input.holdId,
89
+ resourceKind: input.resourceKind,
90
+ cursor: input.cursor,
91
+ limit: input.limit,
92
+ signal: input.signal,
93
+ });
94
+ return {
95
+ items: page.items.map((hold) => ({
96
+ holdId: hold.id,
97
+ resourceKind: hold.resourceKind,
98
+ resourceId: hold.resourceId,
99
+ reason: hold.reason,
100
+ createdAt: hold.createdAt,
101
+ redacted: true,
102
+ })),
103
+ ...(page.nextCursor === undefined ? {} : { nextCursor: page.nextCursor }),
104
+ };
105
+ },
106
+ async setTenantQuota(input) {
107
+ throwIfAborted(input.signal);
108
+ assertOwnership(input);
109
+ if (!Number.isSafeInteger(input.limit) || input.limit < 0) {
110
+ throw new PersistenceLifecycleError("limit must be a non-negative safe integer", "ERR_PRISM_LIFECYCLE_QUOTA");
111
+ }
112
+ const key = quotaKey(input, input.resourceKind);
113
+ const previous = quotas.get(key);
114
+ const record = {
115
+ ...ownership(input),
116
+ resourceKind: input.resourceKind,
117
+ limit: input.limit,
118
+ used: previous?.used ?? 0,
119
+ updatedAt: new Date().toISOString(),
120
+ };
121
+ if (record.used > record.limit) {
122
+ throw new PersistenceLifecycleError("quota already exceeded", "ERR_PRISM_LIFECYCLE_QUOTA");
123
+ }
124
+ quotas.set(key, record);
125
+ return record;
126
+ },
127
+ async getTenantQuota(input) {
128
+ throwIfAborted(input.signal);
129
+ assertOwnership(input);
130
+ return quotas.get(quotaKey(input, input.resourceKind)) ?? null;
131
+ },
132
+ async consumeTenantQuota(input) {
133
+ throwIfAborted(input.signal);
134
+ assertOwnership(input);
135
+ const delta = input.delta ?? 1;
136
+ if (!Number.isSafeInteger(delta) || delta < 1) {
137
+ throw new PersistenceLifecycleError("delta must be a positive safe integer", "ERR_PRISM_LIFECYCLE_QUOTA");
138
+ }
139
+ const key = quotaKey(input, input.resourceKind);
140
+ const current = quotas.get(key);
141
+ if (!current) {
142
+ throw new PersistenceLifecycleError("tenant quota not configured", "ERR_PRISM_LIFECYCLE_QUOTA");
143
+ }
144
+ if (current.used + delta > current.limit) {
145
+ throw new PersistenceLifecycleError(`tenant quota exhausted for ${input.resourceKind}`, "ERR_PRISM_LIFECYCLE_QUOTA_EXHAUSTED");
146
+ }
147
+ const next = {
148
+ ...current,
149
+ used: current.used + delta,
150
+ updatedAt: new Date().toISOString(),
151
+ };
152
+ quotas.set(key, next);
153
+ return next;
154
+ },
155
+ };
156
+ }
157
+ /** True when a session/resource id is under an active legal hold in the page of holds. */
158
+ export function isResourceHeld(holds, resourceKind, resourceId) {
159
+ return holds.some((hold) => hold.resourceKind === resourceKind && hold.resourceId === resourceId);
160
+ }
161
+ function ownership(input) {
162
+ return {
163
+ ...(input.tenantId === undefined ? {} : { tenantId: input.tenantId }),
164
+ ...(input.accountId === undefined ? {} : { accountId: input.accountId }),
165
+ ...(input.userId === undefined ? {} : { userId: input.userId }),
166
+ };
167
+ }
168
+ function sameOwnership(a, b) {
169
+ return a.tenantId === b.tenantId && a.accountId === b.accountId && a.userId === b.userId;
170
+ }
171
+ function assertSameOwnership(expected, actual) {
172
+ if (!sameOwnership(expected, actual)) {
173
+ throw new PersistenceLifecycleError("ownership mismatch", "ERR_PRISM_LIFECYCLE_OWNERSHIP");
174
+ }
175
+ }
176
+ function assertOwnership(input) {
177
+ if (![input.tenantId, input.accountId, input.userId].some((value) => typeof value === "string" && value.length > 0)) {
178
+ throw new PersistenceLifecycleError("ownership required", "ERR_PRISM_LIFECYCLE_OWNERSHIP");
179
+ }
180
+ }
181
+ function assertReason(reason) {
182
+ if (typeof reason !== "string" || !reason.trim()) {
183
+ throw new PersistenceLifecycleError("reason is required", "ERR_PRISM_LIFECYCLE_HOLD");
184
+ }
185
+ if (Buffer.byteLength(reason, "utf8") > HARD_MAX_HOLD_REASON_BYTES) {
186
+ throw new PersistenceLifecycleError("reason exceeds limit", "ERR_PRISM_LIFECYCLE_HOLD");
187
+ }
188
+ return reason;
189
+ }
190
+ function pageLimit(limit) {
191
+ const resolved = limit ?? DEFAULT_LIFECYCLE_PAGE_SIZE;
192
+ if (!Number.isSafeInteger(resolved) || resolved < 1 || resolved > HARD_LIFECYCLE_PAGE_SIZE) {
193
+ throw new PersistenceLifecycleError(`limit must be 1..${HARD_LIFECYCLE_PAGE_SIZE}`, "ERR_PRISM_LIFECYCLE_LIMITS");
194
+ }
195
+ return resolved;
196
+ }
197
+ function quotaKey(ownership, kind) {
198
+ return `${ownership.tenantId ?? ""}\0${ownership.accountId ?? ""}\0${ownership.userId ?? ""}\0${kind}`;
199
+ }
200
+ function throwIfAborted(signal) {
201
+ if (signal?.aborted)
202
+ throw signal.reason ?? new DOMException("Aborted", "AbortError");
203
+ }
204
+ //# sourceMappingURL=persistence-lifecycle.js.map
@@ -1,9 +1,13 @@
1
- import type { AIProvider } from "../contracts.js";
1
+ import type { AIProvider, ProviderRequest } from "../contracts.js";
2
2
  import { type CredentialValueSource } from "../credentials.js";
3
3
  export interface OpenAICompatibleProviderOptions {
4
4
  readonly id?: string;
5
5
  readonly baseUrl: string;
6
6
  readonly apiKey?: CredentialValueSource;
7
7
  readonly fetch?: typeof fetch;
8
+ /** Override chat-completions URL (default `${baseUrl}/chat/completions`). */
9
+ readonly chatCompletionsUrl?: string | ((request: ProviderRequest) => string);
10
+ /** Default `bearer`. Azure resource keys use `api-key`; host-signed fetches may use `none`. */
11
+ readonly authStyle?: "bearer" | "api-key" | "none";
8
12
  }
9
13
  export declare function createOpenAICompatibleProvider(options: OpenAICompatibleProviderOptions): AIProvider;
@@ -16,13 +16,22 @@ export function createOpenAICompatibleProvider(options) {
16
16
  const secrets = [apiKey];
17
17
  const tools = new Map();
18
18
  try {
19
- const response = await fetchImpl(`${options.baseUrl.replace(/\/$/, "")}/chat/completions`, {
19
+ const url = typeof options.chatCompletionsUrl === "function"
20
+ ? options.chatCompletionsUrl(request)
21
+ : options.chatCompletionsUrl
22
+ ?? `${options.baseUrl.replace(/\/$/, "")}/chat/completions`;
23
+ const authStyle = options.authStyle ?? "bearer";
24
+ const headers = {
25
+ ...Object.fromEntries(Object.entries(request.options?.headers ?? {}).filter((entry) => typeof entry[1] === "string")),
26
+ "content-type": "application/json",
27
+ };
28
+ if (apiKey && authStyle === "api-key")
29
+ headers["api-key"] = apiKey;
30
+ if (apiKey && authStyle === "bearer")
31
+ headers.authorization = `Bearer ${apiKey}`;
32
+ const response = await fetchImpl(url, {
20
33
  method: "POST",
21
- headers: {
22
- ...request.options?.headers,
23
- "content-type": "application/json",
24
- ...(apiKey ? { authorization: `Bearer ${apiKey}` } : {}),
25
- },
34
+ headers,
26
35
  body: JSON.stringify(toOpenAIRequest(request)),
27
36
  signal: request.signal,
28
37
  });
@@ -1,5 +1,6 @@
1
1
  import { createAgent } from "./agents.js";
2
2
  import { validateRunStateOptions } from "./agent-run-state.js";
3
+ import { assertIdentityActive, assertIdentityMatchesOwnership } from "./identity.js";
3
4
  import { resolveRunLimits } from "./run-limits.js";
4
5
  import { createToolParameterValidator, createToolRegistry } from "./tools.js";
5
6
  /** Build an opt-in agent whose security-critical defaults cannot be replaced per run. */
@@ -27,6 +28,10 @@ export function createSecureAgent(options) {
27
28
  throw new TypeError(`Secure agent tool ${tool.name} requires a non-empty parameters schema`);
28
29
  }
29
30
  resolveRunLimits(options.limits);
31
+ if (options.identity) {
32
+ assertIdentityActive(options.identity);
33
+ assertIdentityMatchesOwnership(options.identity, options.ownership);
34
+ }
30
35
  const runState = Object.freeze({ ...options.runState, definitionRevision: options.definitionRevision, interruptBeforeTool: true });
31
36
  validateRunStateOptions(runState);
32
37
  const config = Object.freeze({
@@ -38,6 +43,7 @@ export function createSecureAgent(options) {
38
43
  permission: options.permission,
39
44
  trust: options.trust,
40
45
  ownership: Object.freeze({ ...options.ownership }),
46
+ ...(options.identity ? { identity: Object.freeze({ ...options.identity, scopes: Object.freeze([...options.identity.scopes]) }) } : {}),
41
47
  limits: Object.freeze({ ...options.limits }),
42
48
  guardrails: freezeGuardrails(options.guardrails),
43
49
  runState,
@@ -46,7 +52,7 @@ export function createSecureAgent(options) {
46
52
  return createAgent(config);
47
53
  }
48
54
  function withoutSecureFields(options) {
49
- const { tools: _tools, toolArgumentValidator: _validator, redactor: _redactor, permission: _permission, trust: _trust, ownership: _ownership, limits: _limits, guardrails: _guardrails, definitionRevision: _revision, runState: _runState, ...config } = options;
55
+ const { tools: _tools, toolArgumentValidator: _validator, redactor: _redactor, permission: _permission, trust: _trust, ownership: _ownership, identity: _identity, limits: _limits, guardrails: _guardrails, definitionRevision: _revision, runState: _runState, ...config } = options;
50
56
  return config;
51
57
  }
52
58
  function freezeGuardrails(guardrails) {
@@ -1,7 +1,7 @@
1
1
  import type { PersistencePage, SessionEntry, SessionEntryQuery } from "../contracts.js";
2
2
  /** Current shared persistence schema version for production database adapters. */
3
- export declare const PERSISTENCE_SCHEMA_VERSION = 4;
4
- export type PersistenceTableName = "prism_tenants" | "prism_accounts" | "prism_users" | "prism_agent_definitions" | "prism_sessions" | "prism_branches" | "prism_session_entries" | "prism_session_append_idempotency" | "prism_runs" | "prism_agent_events" | "prism_tool_calls" | "prism_usage" | "prism_run_feedback" | "prism_retention_policies" | "prism_migrations";
3
+ export declare const PERSISTENCE_SCHEMA_VERSION = 5;
4
+ export type PersistenceTableName = "prism_tenants" | "prism_accounts" | "prism_users" | "prism_agent_definitions" | "prism_sessions" | "prism_branches" | "prism_session_entries" | "prism_session_append_idempotency" | "prism_runs" | "prism_agent_events" | "prism_tool_calls" | "prism_usage" | "prism_run_feedback" | "prism_retention_policies" | "prism_legal_holds" | "prism_tenant_quotas" | "prism_migrations";
5
5
  export type PersistenceColumnType = "text" | "integer" | "number" | "boolean" | "json" | "timestamp";
6
6
  export interface PersistenceColumnDefinition {
7
7
  readonly name: string;
@@ -4,7 +4,7 @@ import { createHash } from "node:crypto";
4
4
  // this module defines the shared table/index/pagination/migration expectations
5
5
  // adapter authors implement and test against before shipping dialect-specific DDL.
6
6
  /** Current shared persistence schema version for production database adapters. */
7
- export const PERSISTENCE_SCHEMA_VERSION = 4;
7
+ export const PERSISTENCE_SCHEMA_VERSION = 5;
8
8
  /** Guidance adapters must follow: values are bound parameters, never interpolated. */
9
9
  export const PARAMETERIZED_QUERY_GUIDANCE = "Bind every user-supplied value (session ids, idempotency keys, tenant ids, timestamps, JSON payloads) as a query parameter. Quote/validate schema and table identifiers only; never interpolate untrusted strings into SQL text.";
10
10
  const TENANT_COLUMNS = [
@@ -258,6 +258,33 @@ export function createPersistenceSchemaModel() {
258
258
  { name: "metadata", type: "json", nullable: true },
259
259
  ],
260
260
  },
261
+ {
262
+ name: "prism_legal_holds",
263
+ primaryKey: ["id"],
264
+ columns: [
265
+ { name: "id", type: "text" },
266
+ ...TENANT_COLUMNS,
267
+ { name: "resource_kind", type: "text" },
268
+ { name: "resource_id", type: "text" },
269
+ { name: "reason", type: "text" },
270
+ { name: "created_at", type: "timestamp" },
271
+ { name: "created_by", type: "text", nullable: true },
272
+ { name: "metadata", type: "json", nullable: true },
273
+ ],
274
+ },
275
+ {
276
+ name: "prism_tenant_quotas",
277
+ primaryKey: ["id"],
278
+ columns: [
279
+ { name: "id", type: "text" },
280
+ ...TENANT_COLUMNS,
281
+ { name: "resource_kind", type: "text" },
282
+ { name: "limit_count", type: "integer" },
283
+ { name: "used_count", type: "integer" },
284
+ { name: "updated_at", type: "timestamp" },
285
+ ],
286
+ uniqueKeys: [["tenant_id", "account_id", "user_id", "resource_kind"]],
287
+ },
261
288
  {
262
289
  name: "prism_migrations",
263
290
  primaryKey: ["id"],
@@ -298,6 +325,9 @@ export function createPersistenceSchemaModel() {
298
325
  { name: "prism_run_feedback_run_created_idx", table: "prism_run_feedback", columns: ["run_id", "created_at", "id"], purpose: "run feedback lookup" },
299
326
  { name: "prism_run_feedback_trace_created_idx", table: "prism_run_feedback", columns: ["trace_id", "created_at", "id"], purpose: "trace feedback lookup" },
300
327
  { name: "prism_agent_definitions_name_version_idx", table: "prism_agent_definitions", columns: ["name", "version"], purpose: "definition lookup" },
328
+ { name: "prism_legal_holds_owner_resource_idx", table: "prism_legal_holds", columns: ["tenant_id", "account_id", "user_id", "resource_kind", "resource_id"], purpose: "hold lookup by owned resource" },
329
+ { name: "prism_legal_holds_created_id_idx", table: "prism_legal_holds", columns: ["created_at", "id"], purpose: "hold export pagination" },
330
+ { name: "prism_tenant_quotas_owner_kind_idx", table: "prism_tenant_quotas", columns: ["tenant_id", "account_id", "user_id", "resource_kind"], purpose: "tenant quota lookup" },
301
331
  { name: "prism_migrations_name_version_idx", table: "prism_migrations", columns: ["name", "version"], purpose: "applied-migration lookup (table constraint enforces uniqueness)" },
302
332
  ],
303
333
  };
@@ -320,7 +350,9 @@ function migrationStep(version, name, description) {
320
350
  : version === 4
321
351
  // Adapter-local FTS objects (SQLite FTS5 / Postgres tsvector) map to this canonical name.
322
352
  ? { search: ["prism_session_search"], indexes: ["prism_sessions_updated_id_idx"] }
323
- : (() => { throw new Error(`Unknown migration version ${version}`); })();
353
+ : version === 5
354
+ ? { tables: ["prism_legal_holds", "prism_tenant_quotas"], indexes: ["prism_legal_holds_owner_resource_idx", "prism_legal_holds_created_id_idx", "prism_tenant_quotas_owner_kind_idx"] }
355
+ : (() => { throw new Error(`Unknown migration version ${version}`); })();
324
356
  return {
325
357
  version,
326
358
  name,
@@ -338,6 +370,7 @@ export function createPersistenceMigrationContract() {
338
370
  migrationStep(2, "002_usage_scope", "Distinguish provider-turn usage from aggregate run totals."),
339
371
  migrationStep(3, "003_run_feedback", "Add immutable ownership-scoped run/trace feedback and evaluation links."),
340
372
  migrationStep(4, "004_session_search", "Add bounded session search indexes and adapter-local FTS objects."),
373
+ migrationStep(5, "005_lifecycle_hold_quota", "Add legal-hold and tenant-quota tables for retention lifecycle."),
341
374
  ],
342
375
  lockGuidance: "Acquire a dialect-specific migration lock before applying steps (PostgreSQL advisory lock; SQLite exclusive transaction). Only one process should migrate at a time.",
343
376
  leastPrivilegeGuidance: "Run migrations with a DDL-capable role; use a separate least-privilege runtime role limited to INSERT/SELECT/UPDATE on adapter tables. Never grant migration credentials to the agent runtime.",
package/dist/tools.d.ts CHANGED
@@ -43,6 +43,8 @@ export interface DispatchToolCallOptions {
43
43
  readonly redactor?: SecretRedactor;
44
44
  readonly ledger?: RunLedger;
45
45
  readonly ownership?: OwnershipScope;
46
+ /** Host-verified identity; asserted active before tool side effects when present. */
47
+ readonly identity?: import("./identity.js").AgentIdentity;
46
48
  /** Tool stages run after middleware normalization and before side effects/exposure. */
47
49
  readonly guardrails?: Guardrails;
48
50
  /** Shared run tracker; direct hosts may supply one for their call scope. */
package/dist/tools.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { isJsonObject } from "./config.js";
2
2
  import { createId } from "./ids.js";
3
3
  import { GuardrailError, runGuardrails } from "./guardrails.js";
4
+ import { assertIdentityActive, assertIdentityMatchesOwnership } from "./identity.js";
4
5
  import { errorToErrorInfo, redactRunLedgerRecord, redactSecrets } from "./redaction.js";
5
6
  import { assertCanRegister } from "./registry-options.js";
6
7
  import { assertPermission, assertTrusted } from "./security.js";
@@ -89,6 +90,7 @@ export async function dispatchToolCall(options) {
89
90
  const context = {
90
91
  ...options.context,
91
92
  toolCallId: mediatedCall.id,
93
+ identity: options.identity ?? options.context.identity,
92
94
  progress: async (progress, metadata) => {
93
95
  await options.context.progress?.(progress, metadata);
94
96
  await options.emit?.({
@@ -108,6 +110,10 @@ export async function dispatchToolCall(options) {
108
110
  },
109
111
  };
110
112
  try {
113
+ if (context.identity) {
114
+ assertIdentityActive(context.identity);
115
+ assertIdentityMatchesOwnership(context.identity, options.ownership);
116
+ }
111
117
  await assertTrusted(options.trust, { kind: "tool", target: mediatedCall.name, capability: "execute", metadata: options.context.metadata });
112
118
  await assertPermission(options.permission, { kind: "tool", action: "execute", target: mediatedCall.name, metadata: options.context.metadata });
113
119
  }
package/docs/a2a.md CHANGED
@@ -80,6 +80,7 @@ Defaults/hard caps include: request 64 KiB/1 MiB; response 1/8 MiB; event 64 KiB
80
80
  ## Security and performance notes
81
81
 
82
82
  - Authorize every operation; lifecycle/push adapters enforce exact owner again at durable storage boundary. Missing and foreign tasks/configs share `-32001`.
83
+ - Optional `A2AAuthorization.identity` is host-verified; the handler asserts activity/ownership match and forwards identity into `session.run`. Cross-tenant or widened scopes fail closed.
83
84
  - URL policy must reject private, loopback, link-local, rebound, redirected, or otherwise disallowed destinations. Package never fetches file URLs. Host push delivery must repeat equivalent checks for every attempt/redirect and process event IDs idempotently.
84
85
  - Push token/auth credentials are accepted only into host adapter input and removed from protocol reads/responses. Keep them out of task parts, events, telemetry, ledgers, and errors.
85
86
  - Known-secret redaction applies before handler JSON/SSE output. Client redacts mapped text/errors. Raw/data/url content remains explicitly untrusted.
@@ -88,6 +89,7 @@ Defaults/hard caps include: request 64 KiB/1 MiB; response 1/8 MiB; event 64 KiB
88
89
 
89
90
  ## Related APIs
90
91
 
92
+ - [Agent identity](agent-identity.md)
91
93
  - [Supervisor delegation](supervisors.md)
92
94
  - [Agent/session runtime](agent-session-runtime.md)
93
95
  - [Workflows](workflows.md)
@@ -0,0 +1,111 @@
1
+ # Agent identity
2
+
3
+ ## What it does
4
+
5
+ Authenticated `Principal` / `AgentIdentity` contracts let hosts attach verified tenant, sponsor/owner, delegated actor, scopes, credential references, issued/expiry, and revocation metadata to runs and tools. Core helpers assert activity, narrow scopes for delegation, project onto `OwnershipScope`, refuse silent widening, and emit redacted telemetry attributes. Prism does not store identities or verify tokens itself — hosts supply an `IdentityVerifier`.
6
+
7
+ ## When to use it
8
+
9
+ Use these APIs when embedding Prism in multi-tenant or enterprise hosts that already authenticate callers (for example Microsoft Entra Agent ID governance). Use them before tools, providers, MCP, A2A, workflows, or persistence that must carry attributable identity.
10
+
11
+ Do not treat optional `ownership` strings as identity provenance. Do not accept caller-asserted identity headers without a host verifier. Do not put JWTs or secret credential material on identity records.
12
+
13
+ ## Inputs / request
14
+
15
+ | Field | Meaning |
16
+ | --- | --- |
17
+ | `Principal` | Actor id/kind (user, service, agent) plus optional display name |
18
+ | `AgentIdentity` | Verified context: required `tenantId`, optional account/user, principal, sponsor/owner, scopes, credential refs, issued/expiry/revocation, `verified: true` |
19
+ | `IdentityVerifier.verify(input)` | Host-owned authentication → `AgentIdentity` |
20
+ | `RunOptions.identity` / `AgentConfig.identity` | Optional verified identity for a run or agent default |
21
+ | Server/MCP/A2A `authorization.identity` | Optional verified identity on authorize results |
22
+
23
+ Frozen caps (defaults / hard): scopes `64 / 256`, scope bytes `128 / 512`, metadata `4 KiB / 16 KiB`, credential ref / principal id `256 B / 2 KiB`.
24
+
25
+ ## Outputs / response / events
26
+
27
+ - `assertIdentityActive` — fail closed on unverified/expired/revoked/wrong-tenant/over-limit shapes (sync, no network).
28
+ - `narrowIdentity` — child scopes ⊆ parent; tenant and ownership ids immutable; expiry cannot extend.
29
+ - `ownershipFromIdentity` — projects tenant/account/user onto existing ownership seams.
30
+ - `assertIdentityMatchesOwnership` / `assertIdentityPropagation` — refuse widen across ownership or boundary hop.
31
+ - `identityTelemetryAttributes` — redacted refs for metadata/OTel (`prism.identity.*`); never includes credential secrets or raw tokens.
32
+ - Tool `ToolExecutionContext.identity` — set when a run carries verified identity.
33
+
34
+ ## Request/response example
35
+
36
+ ```json
37
+ {
38
+ "tenantId": "tenant-1",
39
+ "userId": "user-1",
40
+ "principal": { "kind": "agent", "id": "agent-42" },
41
+ "sponsor": { "kind": "user", "id": "sponsor-7" },
42
+ "scopes": ["mail.read", "mail.draft"],
43
+ "credentialRefs": ["m365:tenant-1:user-1"],
44
+ "issuedAt": "2026-07-23T00:00:00.000Z",
45
+ "expiresAt": "2026-07-23T01:00:00.000Z",
46
+ "verified": true
47
+ }
48
+ ```
49
+
50
+ ## Implementation example
51
+
52
+ ```ts
53
+ import {
54
+ assertIdentityActive,
55
+ createAgent,
56
+ identityTelemetryAttributes,
57
+ narrowIdentity,
58
+ ownershipFromIdentity,
59
+ type AgentIdentity,
60
+ type IdentityVerifier,
61
+ } from "@arnilo/prism";
62
+
63
+ const verifier: IdentityVerifier = {
64
+ async verify(request) {
65
+ // Host validates JWT/session, then returns AgentIdentity with verified: true
66
+ return hostVerifiedIdentityFrom(request);
67
+ },
68
+ };
69
+
70
+ const identity = await verifier.verify(incomingRequest);
71
+ assertIdentityActive(identity);
72
+ const child = narrowIdentity(identity, { scopes: ["mail.read"] });
73
+
74
+ const agent = createAgent({
75
+ model,
76
+ provider,
77
+ ownership: ownershipFromIdentity(identity),
78
+ identity,
79
+ });
80
+
81
+ await agent.createSession().run("Summarize inbox", {
82
+ identity: child,
83
+ ownership: ownershipFromIdentity(child),
84
+ metadata: identityTelemetryAttributes(child),
85
+ });
86
+ ```
87
+
88
+ Server / MCP / A2A authorize callbacks may include the same `identity` beside `ownership`. Handlers assert activity and ownership match before admitting work.
89
+
90
+ ## Extension and configuration notes
91
+
92
+ Identity is optional. Hosts that only set `ownership` keep prior behavior. When identity is present, run start and tool dispatch assert it before side effects. Workflows forward `RunWorkflowOptions.identity` into agent nodes. Credential values stay behind `CredentialResolver` keys listed in `credentialRefs`.
93
+
94
+ ## Security and performance notes
95
+
96
+ - Caller-asserted identity without `IdentityVerifier` is unsupported at trust boundaries.
97
+ - Delegation only narrows scopes; tenant/account/user cannot widen on propagation.
98
+ - Credential refs never expand to secrets in events, ledgers, or telemetry attributes.
99
+ - Checks are O(fields) and network-free in core; remote auth stays in the host verifier.
100
+ - Raising hard caps requires updating `docs/review-coverage-2026-07-23-phase-8.md`, tests, and docs.
101
+
102
+ ## Related APIs
103
+
104
+ - [Policy and audit](policy-and-audit.md)
105
+ - [Host security guide](host-security.md)
106
+ - [Public contracts](public-contracts.md)
107
+ - [Server](server.md)
108
+ - [Supervisors](supervisors.md) / [A2A](a2a.md)
109
+ - [MCP tools](mcp-tools.md)
110
+ - [Observability](observability.md)
111
+ - [Runs and usage ledger](runs-and-usage.md)