@openshain/core 0.2.0 → 0.4.0

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.
package/src/index.ts CHANGED
@@ -1,11 +1,35 @@
1
1
  // @openshain/core: Contracts (provider interfaces), fundamental objects, and the work runtime
2
+
3
+ export {
4
+ AUTHORITY_DIR_NAME,
5
+ type Authority,
6
+ type AuthorityRequest,
7
+ DECISION_KINDS,
8
+ DECISIONS_DIR_NAME,
9
+ DELEGATIONS_FILE_NAME,
10
+ type Decision,
11
+ DecisionFileSchema,
12
+ type DecisionKind,
13
+ type DecisionRecord,
14
+ type Delegation,
15
+ DelegationsFileSchema,
16
+ evaluate,
17
+ loadAuthority,
18
+ matchGlob,
19
+ OPEN_AUTHORITY,
20
+ POLICY_FILE_NAME,
21
+ type PolicyFile,
22
+ PolicyFileSchema,
23
+ type Rule,
24
+ writeDecision,
25
+ } from "./authority/policy.ts";
2
26
  export {
3
27
  CONFIG_FILE_NAME,
4
28
  loadConfig,
5
29
  type ParseConfigOptions,
6
30
  parseConfig,
7
31
  } from "./config/load.ts";
8
- export type { Config, ToolProviderRef } from "./config/schema.ts";
32
+ export type { Config, ModelConfig, ToolProviderRef } from "./config/schema.ts";
9
33
  export { LANGUAGES, type Language } from "./config/schema.ts";
10
34
  export { ERROR_CODES, type ErrorCode, isOpenshainError, OpenshainError } from "./errors.ts";
11
35
  export {
@@ -25,16 +49,20 @@ export type {
25
49
  UserPart,
26
50
  } from "./model/types.ts";
27
51
  export {
52
+ type CallOptions,
28
53
  type CreateRuntimeOptions,
29
54
  createRuntime,
30
55
  createToolCaller,
31
56
  createToolRegistry,
32
57
  MAX_TOOL_TEXT_CHARS,
58
+ type PendingApprovalResult,
59
+ REVIEW_DIR_NAME,
33
60
  type Runtime,
34
61
  type RuntimeProviders,
35
62
  type ToolSummary,
36
63
  } from "./runtime.ts";
37
64
  export { jsonSchemas, type SchemaName } from "./schemas.ts";
65
+ export { ASK_USER, RUNTIME_PROVIDER_ID } from "./tool/ask-user.ts";
38
66
  export { loadToolModule } from "./tool/load-module.ts";
39
67
  export { RESERVED_PATHS, resolveWorkspacePath } from "./tool/paths.ts";
40
68
  export {
@@ -71,14 +99,28 @@ export {
71
99
  type EventType,
72
100
  eventFromFile,
73
101
  eventToFile,
102
+ isKnownEventType,
74
103
  type ModelUsage,
104
+ parsePayloadFile,
75
105
  payloadFileSchemas,
106
+ type ReviewPackage,
76
107
  type StopReason,
77
108
  TOOL_REJECTION_CODES,
78
109
  type ToolContent,
79
110
  type ToolRejectionCode,
80
111
  type UnknownEvent,
81
112
  } from "./work/events.ts";
113
+ export {
114
+ countToolCalls,
115
+ type FailureReason,
116
+ type HistoryCall,
117
+ type PendingApproval,
118
+ type PendingQuestion,
119
+ pendingApprovals,
120
+ pendingQuestions,
121
+ type WorkHistory,
122
+ workHistory,
123
+ } from "./work/history.ts";
82
124
  export { acquireLock, LOCK_FILE_NAME, type Lock } from "./work/lock.ts";
83
125
  export { buildProjection, type Projection, type ProjectionInput } from "./work/projection.ts";
84
126
  export {
package/src/runtime.ts CHANGED
@@ -1,18 +1,28 @@
1
+ import { mkdir, writeFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import {
4
+ type Authority,
5
+ evaluate,
6
+ loadAuthority,
7
+ OPEN_AUTHORITY,
8
+ type Rule,
9
+ } from "./authority/policy.ts";
1
10
  import { loadConfig } from "./config/load.ts";
2
- import type { Config } from "./config/schema.ts";
11
+ import type { Config, ModelConfig } from "./config/schema.ts";
3
12
  import { isOpenshainError, OpenshainError } from "./errors.ts";
4
13
  import type { ModelProvider } from "./model/types.ts";
5
14
  import { loadToolModule } from "./tool/load-module.ts";
6
15
  import type { HiddenTool } from "./tool/registry.ts";
7
16
  import { type RegisteredTool, ToolRegistry } from "./tool/registry.ts";
8
17
  import type { ToolCall, ToolDefinition, ToolProvider, ToolResult } from "./tool/types.ts";
9
- import type { ToolContent } from "./work/events.ts";
18
+ import { uuidv7 } from "./uuid.ts";
19
+ import type { Event, ReviewPackage, ToolContent } from "./work/events.ts";
10
20
  import { TOOL_REJECTION_CODES, type ToolRejectionCode } from "./work/events.ts";
11
- import { type WorkHandle, WorkStore } from "./work/store.ts";
21
+ import { WORK_DIR_NAME, type WorkHandle, WorkStore } from "./work/store.ts";
12
22
 
13
23
  export interface RuntimeProviders {
14
24
  /** Model providers by the id used in openshain.yaml. */
15
- models: Record<string, (model: Config["model"]) => ModelProvider>;
25
+ models: Record<string, (model: ModelConfig) => ModelProvider>;
16
26
  /** Tool providers by the id used in openshain.yaml. Modules are loaded from the config directly. */
17
27
  tools: Record<string, () => ToolProvider>;
18
28
  }
@@ -31,6 +41,9 @@ export interface ToolSummary {
31
41
  /** Longer tool output is cut here so that one tool cannot flood the model's context. */
32
42
  export const MAX_TOOL_TEXT_CHARS = 50_000;
33
43
 
44
+ /** Where a work keeps the review packages a person sends to a reviewer. */
45
+ export const REVIEW_DIR_NAME = "review";
46
+
34
47
  export interface Runtime {
35
48
  readonly workspaceRoot: string;
36
49
  readonly config: Config;
@@ -50,6 +63,12 @@ export async function createRuntime(options: CreateRuntimeOptions): Promise<Runt
50
63
  const { workspaceRoot, providers } = options;
51
64
  const config = await loadConfig(workspaceRoot, { modelProviders: Object.keys(providers.models) });
52
65
 
66
+ if (!config.model) {
67
+ throw new OpenshainError(
68
+ "config",
69
+ "this needs a model: add a model section to openshain.yaml (only the interactive CLI needs one)",
70
+ );
71
+ }
53
72
  const modelFactory = Object.hasOwn(providers.models, config.model.provider)
54
73
  ? providers.models[config.model.provider]
55
74
  : undefined;
@@ -66,6 +85,7 @@ export async function createRuntime(options: CreateRuntimeOptions): Promise<Runt
66
85
  }
67
86
 
68
87
  const registry = await createToolRegistry(workspaceRoot, config, providers.tools);
88
+ const authority = await loadAuthority(workspaceRoot);
69
89
  const works = new WorkStore(workspaceRoot);
70
90
  return {
71
91
  workspaceRoot,
@@ -75,7 +95,7 @@ export async function createRuntime(options: CreateRuntimeOptions): Promise<Runt
75
95
  tools: {
76
96
  list: () => registry.list().map(({ definition, providerId }) => ({ definition, providerId })),
77
97
  hidden: () => registry.hiddenTools(),
78
- call: createToolCaller({ registry, config, workspaceRoot }),
98
+ call: createToolCaller({ registry, config, workspaceRoot, authority }),
79
99
  },
80
100
  };
81
101
  }
@@ -85,8 +105,32 @@ export function createToolCaller(input: {
85
105
  registry: ToolRegistry;
86
106
  config: Config;
87
107
  workspaceRoot: string;
88
- }): (work: WorkHandle, call: ToolCall) => Promise<ToolResult> {
89
- return (work, call) => callTool({ ...input, work, call });
108
+ /**
109
+ * Who may do what. Pass a function when it can change while the server runs, as it does when
110
+ * a reviewer writes a decision. Omitted: the workspace is open, as one without authority/ is.
111
+ */
112
+ authority?: Authority | (() => Authority);
113
+ }): (work: WorkHandle, call: ToolCall, options?: CallOptions) => Promise<ToolResult> {
114
+ const given = input.authority;
115
+ const current = typeof given === "function" ? given : () => given ?? OPEN_AUTHORITY;
116
+ return (work, call, options) =>
117
+ callTool({ ...input, authority: current(), work, call, ...options });
118
+ }
119
+
120
+ export interface CallOptions {
121
+ /** The approval that lets this call run: the policy is not consulted again. */
122
+ approvedBy?: string;
123
+ }
124
+
125
+ /** What a held call answers with: the client shows it to the person and the turn stops there. */
126
+ export interface PendingApprovalResult {
127
+ pending: "approval" | "review";
128
+ approval_id: string;
129
+ rule_id: string;
130
+ approvers?: string[];
131
+ reviewer?: { role: string; name?: string };
132
+ /** For a review: why the policy asks for one, when a cited decision did not cover the call. */
133
+ why?: string;
90
134
  }
91
135
 
92
136
  /** Registers the tool providers the config names: the caller's factories by id, and modules from the workspace. Needs no model. */
@@ -115,8 +159,8 @@ export async function createToolRegistry(
115
159
  }
116
160
 
117
161
  /**
118
- * The one place that allows or refuses a call before it runs. At this stage it knows only
119
- * the allow lists; a later authority engine plugs in here.
162
+ * The one place that allows or refuses a call before it runs by name alone. What the workspace
163
+ * allows a call to do is decided after this, by the policy in `authority/`.
120
164
  */
121
165
  function authorize(
122
166
  registry: ToolRegistry,
@@ -137,10 +181,12 @@ async function callTool(input: {
137
181
  registry: ToolRegistry;
138
182
  config: Config;
139
183
  workspaceRoot: string;
184
+ authority: Authority;
140
185
  work: WorkHandle;
141
186
  call: ToolCall;
187
+ approvedBy?: string;
142
188
  }): Promise<ToolResult> {
143
- const { registry, config, workspaceRoot, work, call } = input;
189
+ const { registry, config, workspaceRoot, authority, work, call } = input;
144
190
  const reject = async (code: ToolRejectionCode, reason: string): Promise<ToolResult> => {
145
191
  await work.append({
146
192
  type: "tool.rejected",
@@ -159,6 +205,72 @@ async function callTool(input: {
159
205
  `input does not match the schema of ${call.name}: ${validation.reason}`,
160
206
  );
161
207
  }
208
+ // The policy judges after the allow list, unless a person already approved this very call.
209
+ if (input.approvedBy === undefined) {
210
+ const path = pathOf(call.input);
211
+ const judged = evaluate(authority, {
212
+ tool: call.name,
213
+ effect: tool.definition.effect,
214
+ ...(path !== undefined && { path }),
215
+ principal: config.principal.id,
216
+ profession: config.profession.id,
217
+ workType: (await work.current()).type,
218
+ businessDate: businessDate(),
219
+ });
220
+ if (judged.kind === "deny") return reject("denied", judged.reason);
221
+ if (judged.kind === "approval_required" || judged.kind === "review_required") {
222
+ const review = judged.kind === "review_required";
223
+ const approvalId = `apr_${uuidv7()}`;
224
+ const approvers = judged.rule.approvers ?? [config.principal.id];
225
+ const declared = judged.rule.reviewer;
226
+ const reviewer = declared
227
+ ? { role: declared.role, ...(declared.name !== undefined && { name: declared.name }) }
228
+ : undefined;
229
+ await work.append({
230
+ type: "approval.requested",
231
+ payload: {
232
+ approvalId,
233
+ call: { callId: call.id, name: call.name, input: call.input },
234
+ ruleId: judged.rule.id,
235
+ kind: review ? "review" : "approval",
236
+ ...(review ? reviewer && { reviewer } : { approvers }),
237
+ },
238
+ });
239
+ if (review) {
240
+ const built = await reviewPackage(work, {
241
+ approvalId,
242
+ call,
243
+ rule: judged.rule,
244
+ principal: config.principal.id,
245
+ });
246
+ await work.append({ type: "review.requested", payload: { approvalId, package: built } });
247
+ // A copy the person can send to the reviewer, next to the work's own record.
248
+ const dir = join(workspaceRoot, WORK_DIR_NAME, work.id, REVIEW_DIR_NAME);
249
+ await mkdir(dir, { recursive: true });
250
+ await writeFile(join(dir, `${approvalId}.json`), `${JSON.stringify(built, null, 2)}\n`, {
251
+ flag: "wx",
252
+ });
253
+ }
254
+ await work.transition(
255
+ "waiting_approval",
256
+ `rule ${judged.rule.id} needs ${review ? "a review" : "approval"}`,
257
+ );
258
+ const held: PendingApprovalResult = {
259
+ pending: review ? "review" : "approval",
260
+ approval_id: approvalId,
261
+ rule_id: judged.rule.id,
262
+ ...(review ? reviewer && { reviewer } : { approvers }),
263
+ ...(review && judged.why !== undefined && { why: judged.why }),
264
+ };
265
+ return { content: [{ type: "json", value: held }] };
266
+ }
267
+ if (judged.kind === "allow" && judged.decision) {
268
+ await work.append({
269
+ type: "decision.applied",
270
+ payload: { callId: call.id, decisionId: judged.decision.id },
271
+ });
272
+ }
273
+ }
162
274
 
163
275
  await work.append({
164
276
  type: "tool.called",
@@ -197,6 +309,73 @@ async function callTool(input: {
197
309
  return result;
198
310
  }
199
311
 
312
+ /**
313
+ * What the reviewer is asked to decide on, from the work's own record: the call, the tool calls
314
+ * that came before it, and the agent's last words as the proposal. Sources and company rules stay
315
+ * empty until knowledge is in.
316
+ */
317
+ async function reviewPackage(
318
+ work: WorkHandle,
319
+ input: { approvalId: string; call: ToolCall; rule: Rule; principal: string },
320
+ ): Promise<ReviewPackage> {
321
+ const events = await work.events();
322
+ const facts: string[] = [];
323
+ let proposal = "";
324
+ for (const event of events) {
325
+ if (event.type === "tool.called") {
326
+ const { name, input: called } = (event as Event<"tool.called">).payload;
327
+ const path = pathOf(called);
328
+ facts.push(path === undefined ? name : `${name} ${path}`);
329
+ } else if (event.type === "model.completed") {
330
+ const text = (event as Event<"model.completed">).payload.content
331
+ .filter((part) => part.type === "text")
332
+ .map((part) => (part as { text: string }).text)
333
+ .join("\n")
334
+ .trim();
335
+ if (text !== "") proposal = text;
336
+ }
337
+ }
338
+ const current = await work.current();
339
+ return {
340
+ approvalId: input.approvalId,
341
+ workId: work.id,
342
+ action: {
343
+ name: input.rule.match.action?.toString() ?? input.call.name,
344
+ tool: input.call.name,
345
+ input: input.call.input,
346
+ },
347
+ facts,
348
+ sources: [],
349
+ companyRules: [],
350
+ proposal,
351
+ question:
352
+ input.rule.reason ??
353
+ `${current.objective} のために ${input.call.name} を実行してよいか、判断をお願いします。`,
354
+ requestedBy: input.principal,
355
+ requestedAt: new Date().toISOString(),
356
+ };
357
+ }
358
+
359
+ /** The path a call names, normalized to a workspace-relative posix path, when its input has one. */
360
+ function pathOf(input: unknown): string | undefined {
361
+ const path = (input as { path?: unknown } | null)?.path;
362
+ if (typeof path !== "string" || path === "") return undefined;
363
+ const segments: string[] = [];
364
+ for (const segment of path.replaceAll("\\", "/").split("/")) {
365
+ if (segment === "" || segment === ".") continue;
366
+ if (segment === "..") segments.pop();
367
+ else segments.push(segment);
368
+ }
369
+ return segments.join("/");
370
+ }
371
+
372
+ /** Today's date on this machine's clock, YYYY-MM-DD. */
373
+ function businessDate(): string {
374
+ const now = new Date();
375
+ const pad = (n: number) => String(n).padStart(2, "0");
376
+ return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
377
+ }
378
+
200
379
  function isRejectionCode(code: string): code is ToolRejectionCode {
201
380
  return (TOOL_REJECTION_CODES as readonly string[]).includes(code);
202
381
  }
package/src/schemas.ts CHANGED
@@ -1,10 +1,16 @@
1
1
  import { z } from "zod";
2
+ import { DelegationsFileSchema, PolicyFileSchema } from "./authority/policy.ts";
2
3
  import { ConfigFileSchema } from "./config/schema.ts";
3
4
  import type { JsonSchema } from "./tool/types.ts";
4
5
  import { EventFileSchema, payloadFileSchemas } from "./work/events.ts";
5
6
  import { WorkFileSchema } from "./work/work.ts";
6
7
 
7
- export type SchemaName = "config.v1" | "events.v1" | "work.v1";
8
+ export type SchemaName =
9
+ | "config.v1"
10
+ | "events.v1"
11
+ | "work.v1"
12
+ | "authority-policy.v1"
13
+ | "authority-delegations.v1";
8
14
 
9
15
  /**
10
16
  * The JSON Schemas (draft 2020-12) of the files openshain reads and writes, derived from the zod
@@ -25,6 +31,16 @@ export function jsonSchemas(): Record<SchemaName, JsonSchema> {
25
31
  "work.json",
26
32
  "The state of a work as projected from its event log. Never the source of truth.",
27
33
  ),
34
+ "authority-policy.v1": describe(
35
+ PolicyFileSchema,
36
+ "authority/policy.yaml",
37
+ "The rules that judge tool calls: the first matching rule decides, else the default.",
38
+ ),
39
+ "authority-delegations.v1": describe(
40
+ DelegationsFileSchema,
41
+ "authority/delegations.yaml",
42
+ "Who the agent may act for, as which profession, and when.",
43
+ ),
28
44
  };
29
45
  }
30
46
 
@@ -0,0 +1,25 @@
1
+ import { ASK_USER_TOOL_NAME, type ToolDefinition } from "./types.ts";
2
+
3
+ /** The provider id the runtime records for the tools it runs itself. */
4
+ export const RUNTIME_PROVIDER_ID = "runtime";
5
+
6
+ /** The one tool the runtime itself provides: stop and ask the person. */
7
+ export const ASK_USER: Readonly<ToolDefinition> = Object.freeze({
8
+ name: ASK_USER_TOOL_NAME,
9
+ description:
10
+ "Ask the person you work for a question when you cannot proceed without their answer. Use it sparingly; prefer the workspace over guessing. The work waits until the answer is recorded with work_answer.",
11
+ inputSchema: {
12
+ type: "object",
13
+ properties: {
14
+ question: {
15
+ type: "string",
16
+ minLength: 1,
17
+ maxLength: 10_000,
18
+ description: "The question, in the person's language.",
19
+ },
20
+ },
21
+ required: ["question"],
22
+ additionalProperties: false,
23
+ },
24
+ effect: "observe",
25
+ });
package/src/tool/paths.ts CHANGED
@@ -3,7 +3,7 @@ import { dirname, isAbsolute, join, normalize, relative, resolve, sep } from "no
3
3
  import { OpenshainError } from "../errors.ts";
4
4
 
5
5
  /** Paths the runtime keeps for itself. Tools may not read or write them. */
6
- export const RESERVED_PATHS = ["openshain.yaml", "work"] as const;
6
+ export const RESERVED_PATHS = ["openshain.yaml", "work", "principals", "authority"] as const;
7
7
 
8
8
  const MAX_SYMLINK_HOPS = 32;
9
9
 
package/src/tool/types.ts CHANGED
@@ -20,6 +20,12 @@ export const RESERVED_TOOL_NAMES: readonly string[] = [
20
20
  "work_list",
21
21
  "work_complete",
22
22
  "work_fail",
23
+ "work_answer",
24
+ "work_record",
25
+ "context",
26
+ "approval_list",
27
+ "approval_decide",
28
+ "review_decide",
23
29
  "work_run",
24
30
  "work_show",
25
31
  ];
@@ -41,6 +41,9 @@ export const TOOL_REJECTION_CODES = [
41
41
  "reserved_path",
42
42
  "outside_workspace",
43
43
  "invalid_path",
44
+ "limit_reached",
45
+ "denied",
46
+ "rejected_by_person",
44
47
  ] as const;
45
48
 
46
49
  export type ToolRejectionCode = (typeof TOOL_REJECTION_CODES)[number];
@@ -71,8 +74,33 @@ export interface EventPayloads {
71
74
  "tool.rejected": { callId: string; name: string; code: ToolRejectionCode; reason: string };
72
75
  "human.input_requested": { callId: string; question: string };
73
76
  "human.input_provided": { callId: string; answer: string };
77
+ /** A tool call the policy holds for a person's or a reviewer's approval. The work waits. */
78
+ "approval.requested": {
79
+ approvalId: string;
80
+ call: { callId: string; name: string; input: unknown };
81
+ ruleId: string;
82
+ kind: "approval" | "review";
83
+ approvers?: string[];
84
+ reviewer?: { role: string; name?: string };
85
+ };
86
+ /** The package handed to the reviewer: the call, what the work established, and the question. */
87
+ "review.requested": { approvalId: string; package: ReviewPackage };
88
+ /** The reviewer's answer. A decision id when they wrote one; absent when they refused. */
89
+ "review.decided": { approvalId: string; decisionId?: string };
90
+ /** A call that ran because an approved decision covers it. */
91
+ "decision.applied": { callId: string; decisionId: string };
92
+ /** The answer to an approval: approve runs the call, reject refuses it, modify runs it with the reviewer's input. */
93
+ "approval.decided": {
94
+ approvalId: string;
95
+ decision: "approve" | "reject" | "modify";
96
+ by: string;
97
+ comment?: string;
98
+ modifiedInput?: unknown;
99
+ };
74
100
  /** What the person said in a session. Becomes a user message in the projection. */
75
101
  "human.message": { text: string };
102
+ /** A prompt command expanded for the model: its name, where it came from, and the text handed over. */
103
+ "prompt.expanded": { name: string; source: string; text: string };
76
104
  "usage.recorded":
77
105
  | { kind: "model_inference"; provider: string; model: string; usage: ModelUsage }
78
106
  | { kind: "tool_execution"; provider: string; usage: { durationMs: number } };
@@ -81,6 +109,23 @@ export interface EventPayloads {
81
109
  "work.failed": { reason: string; detail: string };
82
110
  }
83
111
 
112
+ /** What a reviewer is asked to decide on, built from the work's own record. */
113
+ export interface ReviewPackage {
114
+ approvalId: string;
115
+ workId: string;
116
+ action: { name: string; tool: string; input: unknown };
117
+ /** What the work established before this call: the tool calls it made. */
118
+ facts: string[];
119
+ /** Sources and company rules the work cited. Empty until knowledge is in. */
120
+ sources: { id: string; locator?: string; version?: string }[];
121
+ companyRules: { id: string; statement: string }[];
122
+ /** What the agent proposes, in its own words. */
123
+ proposal: string;
124
+ question: string;
125
+ requestedBy: string;
126
+ requestedAt: string;
127
+ }
128
+
84
129
  export type EventType = keyof EventPayloads;
85
130
 
86
131
  interface Envelope {
@@ -178,7 +223,49 @@ export const payloadFileSchemas = {
178
223
  }),
179
224
  "human.input_requested": z.looseObject({ call_id: z.string(), question: z.string() }),
180
225
  "human.input_provided": z.looseObject({ call_id: z.string(), answer: z.string() }),
226
+ "approval.requested": z.looseObject({
227
+ approval_id: z.string(),
228
+ call: z.looseObject({ call_id: z.string(), name: z.string(), input: z.unknown() }),
229
+ rule_id: z.string(),
230
+ kind: z.enum(["approval", "review"]),
231
+ approvers: z.array(z.string()).optional(),
232
+ reviewer: z.looseObject({ role: z.string(), name: z.string().optional() }).optional(),
233
+ }),
234
+ "review.requested": z.looseObject({
235
+ approval_id: z.string(),
236
+ package: z.looseObject({
237
+ approval_id: z.string(),
238
+ work_id: z.string(),
239
+ action: z.looseObject({ name: z.string(), tool: z.string(), input: z.unknown() }),
240
+ facts: z.array(z.string()),
241
+ sources: z.array(
242
+ z.looseObject({
243
+ id: z.string(),
244
+ locator: z.string().optional(),
245
+ version: z.string().optional(),
246
+ }),
247
+ ),
248
+ company_rules: z.array(z.looseObject({ id: z.string(), statement: z.string() })),
249
+ proposal: z.string(),
250
+ question: z.string(),
251
+ requested_by: z.string(),
252
+ requested_at: z.iso.datetime(),
253
+ }),
254
+ }),
255
+ "review.decided": z.looseObject({
256
+ approval_id: z.string(),
257
+ decision_id: z.string().optional(),
258
+ }),
259
+ "decision.applied": z.looseObject({ call_id: z.string(), decision_id: z.string() }),
260
+ "approval.decided": z.looseObject({
261
+ approval_id: z.string(),
262
+ decision: z.enum(["approve", "reject", "modify"]),
263
+ by: z.string(),
264
+ comment: z.string().optional(),
265
+ modified_input: z.unknown().optional(),
266
+ }),
181
267
  "human.message": z.looseObject({ text: z.string() }),
268
+ "prompt.expanded": z.looseObject({ name: z.string(), source: z.string(), text: z.string() }),
182
269
  "usage.recorded": z.discriminatedUnion("kind", [
183
270
  z.looseObject({
184
271
  kind: z.literal("model_inference"),
@@ -309,6 +396,22 @@ export function eventFromFile(input: unknown): AnyEvent {
309
396
  } as Event;
310
397
  }
311
398
 
399
+ /**
400
+ * Validates a payload given in the file form (snake_case, as in spec/schemas/events.v1.json) and
401
+ * returns it in the in-memory form. For events a client hands the runtime to record.
402
+ */
403
+ export function parsePayloadFile<T extends EventType>(type: T, payload: unknown): EventPayloads[T] {
404
+ const parsed = payloadFileSchemas[type].safeParse(payload);
405
+ if (!parsed.success) {
406
+ throw new OpenshainError("invalid_event", `${type} payload: ${describeIssues(parsed.error)}`);
407
+ }
408
+ return payloadFromFile(type, parsed.data as FilePayload<T>);
409
+ }
410
+
411
+ export function isKnownEventType(type: string): type is EventType {
412
+ return isKnownType(type);
413
+ }
414
+
312
415
  function isKnownType(type: string): type is EventType {
313
416
  return Object.hasOwn(payloadFileSchemas, type);
314
417
  }
@@ -435,6 +538,95 @@ const codecs: { [T in EventType]?: Codec<T> } = {
435
538
  toFile: (p) => ({ call_id: p.callId, answer: p.answer }),
436
539
  fromFile: (p) => ({ callId: p.call_id, answer: p.answer }),
437
540
  },
541
+ "approval.requested": {
542
+ toFile: (p) => ({
543
+ approval_id: p.approvalId,
544
+ call: { call_id: p.call.callId, name: p.call.name, input: p.call.input },
545
+ rule_id: p.ruleId,
546
+ kind: p.kind,
547
+ ...(p.approvers && { approvers: p.approvers }),
548
+ ...(p.reviewer && { reviewer: p.reviewer }),
549
+ }),
550
+ fromFile: (p) => ({
551
+ approvalId: p.approval_id,
552
+ call: { callId: p.call.call_id, name: p.call.name, input: p.call.input },
553
+ ruleId: p.rule_id,
554
+ kind: p.kind,
555
+ ...(p.approvers && { approvers: p.approvers }),
556
+ ...(p.reviewer && {
557
+ reviewer: {
558
+ role: p.reviewer.role,
559
+ ...(p.reviewer.name !== undefined && { name: p.reviewer.name }),
560
+ },
561
+ }),
562
+ }),
563
+ },
564
+ "review.requested": {
565
+ toFile: (p) => ({
566
+ approval_id: p.approvalId,
567
+ package: {
568
+ approval_id: p.package.approvalId,
569
+ work_id: p.package.workId,
570
+ action: p.package.action,
571
+ facts: p.package.facts,
572
+ sources: p.package.sources,
573
+ company_rules: p.package.companyRules,
574
+ proposal: p.package.proposal,
575
+ question: p.package.question,
576
+ requested_by: p.package.requestedBy,
577
+ requested_at: p.package.requestedAt,
578
+ },
579
+ }),
580
+ fromFile: (p) => ({
581
+ approvalId: p.approval_id,
582
+ package: {
583
+ approvalId: p.package.approval_id,
584
+ workId: p.package.work_id,
585
+ action: p.package.action,
586
+ facts: p.package.facts,
587
+ sources: p.package.sources.map((source) => ({
588
+ id: source.id,
589
+ ...(source.locator !== undefined && { locator: source.locator }),
590
+ ...(source.version !== undefined && { version: source.version }),
591
+ })),
592
+ companyRules: p.package.company_rules,
593
+ proposal: p.package.proposal,
594
+ question: p.package.question,
595
+ requestedBy: p.package.requested_by,
596
+ requestedAt: p.package.requested_at,
597
+ },
598
+ }),
599
+ },
600
+ "review.decided": {
601
+ toFile: (p) => ({
602
+ approval_id: p.approvalId,
603
+ ...(p.decisionId !== undefined && { decision_id: p.decisionId }),
604
+ }),
605
+ fromFile: (p) => ({
606
+ approvalId: p.approval_id,
607
+ ...(p.decision_id !== undefined && { decisionId: p.decision_id }),
608
+ }),
609
+ },
610
+ "decision.applied": {
611
+ toFile: (p) => ({ call_id: p.callId, decision_id: p.decisionId }),
612
+ fromFile: (p) => ({ callId: p.call_id, decisionId: p.decision_id }),
613
+ },
614
+ "approval.decided": {
615
+ toFile: (p) => ({
616
+ approval_id: p.approvalId,
617
+ decision: p.decision,
618
+ by: p.by,
619
+ ...(p.comment !== undefined && { comment: p.comment }),
620
+ ...(p.modifiedInput !== undefined && { modified_input: p.modifiedInput }),
621
+ }),
622
+ fromFile: (p) => ({
623
+ approvalId: p.approval_id,
624
+ decision: p.decision,
625
+ by: p.by,
626
+ ...(p.comment !== undefined && { comment: p.comment }),
627
+ ...(p.modified_input !== undefined && { modifiedInput: p.modified_input }),
628
+ }),
629
+ },
438
630
  "usage.recorded": {
439
631
  toFile: (p) =>
440
632
  p.kind === "tool_execution"