@staix/agent-hub 0.24.0 → 0.26.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/CHANGELOG.md +23 -0
- package/README.md +35 -8
- package/docs/agent-notes/README.md +12 -0
- package/docs/agent-notes/adapters.md +2 -1
- package/docs/agent-notes/bus.md +1 -0
- package/docs/agent-notes/daemon.md +18 -3
- package/docs/agent-notes/tasks.md +12 -4
- package/docs/agent-notes/tests.md +3 -1
- package/docs/dogfood.md +16 -2
- package/docs/memory.md +1 -1
- package/docs/operations.md +264 -14
- package/docs/profiles.md +2 -0
- package/docs/quickstart.md +23 -6
- package/docs/security.md +198 -3
- package/docs/specs/2026-09-19-agent-hub-design.md +25 -1
- package/docs/specs/2026-09-19-multi-project-design.md +12 -1
- package/docs/specs/2026-11-01-research-campaigns-design.md +354 -0
- package/docs/verification/2026-11-01-0.25.0.md +91 -0
- package/docs/verification/2026-11-01-0.26.0.md +54 -0
- package/docs/verified.json +45 -17
- package/package.json +4 -3
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +5294 -4797
- package/src/adapters/channel-peer.ts +4 -2
- package/src/adapters/claude-channel.ts +29 -6
- package/src/adapters/definitions/builtin-metadata.ts +6 -6
- package/src/adapters/definitions/native-cli-doctor.ts +30 -0
- package/src/adapters/definitions/qwen.ts +2 -0
- package/src/adapters/jcode.ts +3 -0
- package/src/adapters/registry-types.ts +2 -0
- package/src/adapters/registry.ts +2 -1
- package/src/cli/acon-register.ts +29 -0
- package/src/cli/conductor-recovery.ts +3 -2
- package/src/cli/handoff-args.ts +27 -0
- package/src/cli/handoff-output.ts +25 -0
- package/src/cli/help.ts +11 -8
- package/src/cli/identity.ts +7 -1
- package/src/cli/main.ts +138 -30
- package/src/cli/output.ts +8 -5
- package/src/cli/recovery-package.ts +94 -4
- package/src/cli/reset.ts +14 -4
- package/src/cli/runtime-version-warning.ts +28 -0
- package/src/cli/schedule-output.ts +8 -4
- package/src/cli/start-agents-run.ts +125 -0
- package/src/cli/study-output.ts +71 -0
- package/src/cli/study.ts +58 -0
- package/src/cli/task-output.ts +6 -0
- package/src/cli/upgrade-interactive.ts +4 -4
- package/src/cli/upgrade-runtime.ts +105 -57
- package/src/cli/upgrade.ts +90 -28
- package/src/hub/autonomy-defaults.ts +32 -7
- package/src/hub/board.ts +993 -8
- package/src/hub/budget.ts +3 -4
- package/src/hub/channel-project.ts +61 -0
- package/src/hub/child-process.ts +4 -3
- package/src/hub/conductor.ts +3 -4
- package/src/hub/config-apply.ts +13 -2
- package/src/hub/config-edit-host.ts +1 -0
- package/src/hub/config-tier.ts +3 -0
- package/src/hub/config-trust.ts +10 -2
- package/src/hub/control-client.ts +39 -10
- package/src/hub/daemon.ts +326 -25
- package/src/hub/db-schema.ts +143 -0
- package/src/hub/delivery-journal.ts +3 -4
- package/src/hub/envelope.ts +2 -0
- package/src/hub/events.ts +4 -1
- package/src/hub/execution-budget.ts +15 -4
- package/src/hub/folder-trigger.ts +3 -4
- package/src/hub/handoff.ts +126 -0
- package/src/hub/hub-tools.ts +12 -2
- package/src/hub/invocations.ts +3 -4
- package/src/hub/lifecycle.ts +87 -29
- package/src/hub/manager.ts +110 -87
- package/src/hub/no-writes.ts +1 -1
- package/src/hub/orca-env.ts +19 -0
- package/src/hub/path-policy.ts +12 -8
- package/src/hub/project-runtime-state.ts +139 -0
- package/src/hub/project-runtime.ts +62 -0
- package/src/hub/project.ts +14 -5
- package/src/hub/protocol-window.ts +19 -0
- package/src/hub/provenance.ts +30 -0
- package/src/hub/recovery-store.ts +102 -12
- package/src/hub/registry.ts +5 -0
- package/src/hub/research-campaign.ts +265 -0
- package/src/hub/research-experiments.ts +474 -0
- package/src/hub/research-runner.ts +172 -0
- package/src/hub/restart.ts +35 -0
- package/src/hub/runtime-capture-prune.ts +415 -0
- package/src/hub/runtime-prune-health.ts +112 -0
- package/src/hub/schedules.ts +213 -13
- package/src/hub/sdlc-merge.ts +7 -0
- package/src/hub/sdlc-queue.ts +13 -1
- package/src/hub/session-identity.ts +49 -0
- package/src/hub/settings.ts +4 -0
- package/src/hub/snapshots.ts +3 -4
- package/src/hub/start-agents.ts +44 -0
- package/src/hub/task-approval.ts +23 -0
- package/src/hub/tasks.ts +1005 -34
- package/src/hub/ui.ts +15 -6
- package/src/hub/usage-rollup.ts +3 -4
- package/src/memory/capture.ts +2 -2
- package/src/pi/tool-receipts.ts +3 -4
- package/src/ui/index.html +5 -3
- package/src/ui/office-space.ts +118 -0
- package/src/ui/plugin.ts +293 -0
- package/templates/AGENTS.block.md +1 -0
- package/templates/profiles/general.json +1 -1
- package/templates/profiles/research.json +4 -4
- package/templates/profiles/writing.json +1 -1
- package/templates/roles/research/analyst.md +1 -0
- package/templates/roles/research/fact-checker.md +1 -0
- package/templates/roles/research/research-lead.md +1 -0
- package/templates/roles/research/researcher.md +1 -0
- package/templates/roles/research/statistician.md +1 -0
|
@@ -0,0 +1,474 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { types } from "node:util";
|
|
3
|
+
import { looksSecret } from "@staix/agent-mem";
|
|
4
|
+
import { canonicalJson } from "./packs.ts";
|
|
5
|
+
import { parseAttemptReceipt, runnerSealing, type AttemptReceiptV1 } from "./research-runner.ts";
|
|
6
|
+
import { parseDeliverableClaims, parseDeliverableRecords, deliverableId, DELIVERABLE_LIMITS, type DeliverableClaim, type DeliverableRecord } from "./deliverables.ts";
|
|
7
|
+
|
|
8
|
+
export type ExperimentScalar = string | number | boolean | null;
|
|
9
|
+
export type ExperimentProvenance = "declared" | "host-observed" | "runner-attested" | "person-attested";
|
|
10
|
+
export type ExperimentValue<T> = { value: T; provenance: ExperimentProvenance; evidence?: string }
|
|
11
|
+
| { value: null; provenance: "unknown" };
|
|
12
|
+
export interface ExperimentProtocolContract {
|
|
13
|
+
campaignId: string;
|
|
14
|
+
campaignRevision: number;
|
|
15
|
+
lineage: string;
|
|
16
|
+
supersedes?: string;
|
|
17
|
+
claimIds: string[];
|
|
18
|
+
inputs: { sourceId: string; sha256: string }[];
|
|
19
|
+
code: { commit: string } | { deliverable: { id: string; sha256: string } };
|
|
20
|
+
/** Descriptor and optional text-hash claim are declared, never a measured host environment. */
|
|
21
|
+
environment: { descriptor: string; hash?: string };
|
|
22
|
+
method: string;
|
|
23
|
+
parameters: Record<string, ExperimentScalar | ExperimentScalar[]>;
|
|
24
|
+
seeds: number[];
|
|
25
|
+
evaluator: { id: string; version: string; hash?: string };
|
|
26
|
+
metrics: { name: string; unit: string; direction: "higher" | "lower" }[];
|
|
27
|
+
role: "baseline" | "variant";
|
|
28
|
+
variantOf?: string;
|
|
29
|
+
splits: string[];
|
|
30
|
+
resourceLimits: { name: string; value: number; unit: string }[];
|
|
31
|
+
sealed: boolean;
|
|
32
|
+
}
|
|
33
|
+
export interface ExperimentAttemptInput {
|
|
34
|
+
protocolHash: string;
|
|
35
|
+
taskId: number;
|
|
36
|
+
phase: "development" | "final";
|
|
37
|
+
idempotencyKey: string;
|
|
38
|
+
retryOf?: string;
|
|
39
|
+
reproducesAttemptId?: string;
|
|
40
|
+
}
|
|
41
|
+
export type ExperimentOutput = { id: string; sha256?: string } | DeliverableClaim;
|
|
42
|
+
export type DeclaredExperimentEventInput =
|
|
43
|
+
| { kind: "start"; body: { startedAt?: number } }
|
|
44
|
+
| { kind: "end"; body: { status: "succeeded" | "failed" | "cancelled"; exitStatus?: number; reason?: string; run?: string } }
|
|
45
|
+
| { kind: "metric"; body: { name: string; value: number } }
|
|
46
|
+
| { kind: "output"; body: ExperimentOutput }
|
|
47
|
+
| { kind: "note"; body: { text: string } };
|
|
48
|
+
/** Host supplies ordering/time; this T1 parser never admits caller-written evidence elevation. */
|
|
49
|
+
export type DeclaredExperimentEvent = DeclaredExperimentEventInput & { seq: number; at: number; provenance: "declared" };
|
|
50
|
+
export interface RunnerAttestationEvent { kind: "attestation"; body: { receiptId: string }; seq: number; at: number; provenance: "runner-attested" }
|
|
51
|
+
export type ExperimentEvent = DeclaredExperimentEvent | RunnerAttestationEvent;
|
|
52
|
+
export const declaredExperimentEvents = (events: readonly ExperimentEvent[]): DeclaredExperimentEvent[] =>
|
|
53
|
+
events.filter((event): event is DeclaredExperimentEvent => event.provenance === "declared");
|
|
54
|
+
export type ExperimentStatus = "declared" | "running" | "succeeded" | "failed" | "cancelled" | "unknown";
|
|
55
|
+
|
|
56
|
+
const ID = /^[a-z0-9][a-z0-9_-]{0,63}$/, HASH = /^[0-9a-f]{64}$/, SOURCE = /^src:[0-9a-f]{16}$/;
|
|
57
|
+
const CAMPAIGN = /^rc-[0-9a-f]{12}$/, ATTEMPT = /^ra-[0-9a-f]{12}$/, DELIVERABLE = /^d-[0-9a-f]{12}$/;
|
|
58
|
+
const RUN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
59
|
+
const SHORT = 512, LONG = 4096, BODY = 64 * 1024;
|
|
60
|
+
function fail(field: string, reason: string): never { throw new Error(`${field}: ${reason}`); }
|
|
61
|
+
function object(input: unknown, keys: readonly string[] | undefined, field: string): Record<string, unknown> {
|
|
62
|
+
if (!input || typeof input !== "object" || Array.isArray(input) || types.isProxy(input)
|
|
63
|
+
|| ![Object.prototype, null].includes(Object.getPrototypeOf(input)) || Object.getOwnPropertySymbols(input).length)
|
|
64
|
+
fail(field, "expected a plain data object");
|
|
65
|
+
const descriptors = Object.getOwnPropertyDescriptors(input);
|
|
66
|
+
if (Object.values(descriptors).some(value => !Object.hasOwn(value, "value"))) fail(field, "accessors are unavailable");
|
|
67
|
+
if (keys && Object.keys(descriptors).some(key => !keys.includes(key))) fail(field, "unknown field");
|
|
68
|
+
return Object.fromEntries(Object.entries(descriptors).map(([key, value]) => [key, value.value]));
|
|
69
|
+
}
|
|
70
|
+
function array(input: unknown, max: number, field: string): unknown[] {
|
|
71
|
+
if (!Array.isArray(input) || types.isProxy(input) || Object.getPrototypeOf(input) !== Array.prototype || input.length > max || Object.getOwnPropertySymbols(input).length)
|
|
72
|
+
fail(field, `expected a list of at most ${max} items`);
|
|
73
|
+
const descriptors = Object.getOwnPropertyDescriptors(input);
|
|
74
|
+
if (Object.keys(descriptors).some(key => key !== "length" && !/^(0|[1-9][0-9]*)$/.test(key))) fail(field, "unknown list fields");
|
|
75
|
+
return Array.from({ length: input.length }, (_, index) => {
|
|
76
|
+
const value = descriptors[String(index)];
|
|
77
|
+
if (!value || !Object.hasOwn(value, "value")) fail(field, "expected a dense data list");
|
|
78
|
+
return value.value;
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
function text(input: unknown, max: number, field: string): string {
|
|
82
|
+
if (typeof input !== "string" || !input.trim() || input.length > max || Buffer.byteLength(input, "utf8") > max
|
|
83
|
+
|| Buffer.from(input, "utf8").toString("utf8") !== input) fail(field, `expected non-empty UTF-8 text of at most ${max} bytes`);
|
|
84
|
+
return input;
|
|
85
|
+
}
|
|
86
|
+
function pattern(input: unknown, rule: RegExp, field: string): string {
|
|
87
|
+
if (typeof input !== "string" || rule.exec(input)?.[0] !== input) fail(field, "invalid identifier or hash");
|
|
88
|
+
return input;
|
|
89
|
+
}
|
|
90
|
+
function integer(input: unknown, field: string, min = 0): number {
|
|
91
|
+
if (typeof input !== "number" || !Number.isSafeInteger(input) || input < min) fail(field, "expected a bounded integer");
|
|
92
|
+
return input;
|
|
93
|
+
}
|
|
94
|
+
function number(input: unknown, field: string, nonnegative = false): number {
|
|
95
|
+
if (typeof input !== "number" || !Number.isFinite(input) || nonnegative && input < 0) fail(field, "expected a finite number");
|
|
96
|
+
return input;
|
|
97
|
+
}
|
|
98
|
+
function choice<T extends string>(input: unknown, values: readonly T[], field: string): T {
|
|
99
|
+
if (typeof input !== "string" || !values.includes(input as T)) fail(field, "unsupported value");
|
|
100
|
+
return input as T;
|
|
101
|
+
}
|
|
102
|
+
function unique<T>(values: T[], key: (value: T) => string | number, field: string): T[] {
|
|
103
|
+
if (new Set(values.map(key)).size !== values.length) fail(field, "duplicate values");
|
|
104
|
+
return values;
|
|
105
|
+
}
|
|
106
|
+
function strings(input: unknown, max: number, field: string, rule = ID): string[] {
|
|
107
|
+
return unique(array(input, max, field).map((value, index) => pattern(value, rule, `${field}[${index}]`)), value => value, field);
|
|
108
|
+
}
|
|
109
|
+
function bounded<T>(value: T, field: string): T {
|
|
110
|
+
if (Buffer.byteLength(canonicalJson(value), "utf8") > BODY) fail(field, "stored body exceeds 64 KiB");
|
|
111
|
+
return value;
|
|
112
|
+
}
|
|
113
|
+
function scalar(input: unknown, field: string): ExperimentScalar {
|
|
114
|
+
if (input === null || typeof input === "boolean") return input;
|
|
115
|
+
if (typeof input === "number") return number(input, field);
|
|
116
|
+
// Empty string is a useful scalar parameter, unlike an empty method or descriptor.
|
|
117
|
+
if (typeof input === "string" && input.length <= SHORT && Buffer.byteLength(input, "utf8") <= SHORT
|
|
118
|
+
&& Buffer.from(input, "utf8").toString("utf8") === input) return input;
|
|
119
|
+
return fail(field, "expected a finite scalar");
|
|
120
|
+
}
|
|
121
|
+
function environment(input: unknown): ExperimentProtocolContract["environment"] {
|
|
122
|
+
const value = object(input, ["descriptor", "hash"], "protocol.environment"), descriptor = text(value.descriptor, LONG, "protocol.environment.descriptor");
|
|
123
|
+
// ponytail: assignment/Bearer/known-secret forms only, never proof of secret absence. Upgrade to structured key/version fields or a #1018 runner receipt; flagged text is refused, never redacted and stored.
|
|
124
|
+
if (/(?:^|[\s,;{])["']?[A-Za-z_][A-Za-z0-9_]*["']?\s*(?:=|:)\s*\S/.test(descriptor) || /\bBearer\s+\S+/i.test(descriptor) || looksSecret(descriptor))
|
|
125
|
+
fail("protocol.environment.descriptor", "environment values and secret-shaped text are unavailable");
|
|
126
|
+
return { descriptor, ...(value.hash !== undefined ? { hash: pattern(value.hash, HASH, "protocol.environment.hash") } : {}) };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Structural validation only; campaign/source/task ownership and evidence reads belong to Tasks. */
|
|
130
|
+
export function parseExperimentProtocol(input: unknown): ExperimentProtocolContract {
|
|
131
|
+
const raw = object(input, ["campaignId", "campaignRevision", "lineage", "supersedes", "claimIds", "inputs", "code", "environment", "method", "parameters", "seeds", "evaluator", "metrics", "role", "variantOf", "splits", "resourceLimits", "sealed"], "protocol");
|
|
132
|
+
const inputs = unique(array(raw.inputs, 64, "protocol.inputs").map((value, index) => {
|
|
133
|
+
const field = `protocol.inputs[${index}]`, row = object(value, ["sourceId", "sha256"], field);
|
|
134
|
+
return { sourceId: pattern(row.sourceId, SOURCE, `${field}.sourceId`), sha256: pattern(row.sha256, HASH, `${field}.sha256`) };
|
|
135
|
+
}), value => value.sourceId, "protocol.inputs");
|
|
136
|
+
const rawCode = object(raw.code, ["commit", "deliverable"], "protocol.code");
|
|
137
|
+
if ((rawCode.commit !== undefined) === (rawCode.deliverable !== undefined)) fail("protocol.code", "choose one commit or deliverable");
|
|
138
|
+
let code: ExperimentProtocolContract["code"];
|
|
139
|
+
if (rawCode.commit !== undefined) code = { commit: pattern(rawCode.commit, /^[0-9a-f]{40}$/, "protocol.code.commit") };
|
|
140
|
+
else {
|
|
141
|
+
const value = object(rawCode.deliverable, ["id", "sha256"], "protocol.code.deliverable");
|
|
142
|
+
code = { deliverable: { id: pattern(value.id, DELIVERABLE, "protocol.code.deliverable.id"), sha256: pattern(value.sha256, HASH, "protocol.code.deliverable.sha256") } };
|
|
143
|
+
}
|
|
144
|
+
const rawParameters = object(raw.parameters, undefined, "protocol.parameters");
|
|
145
|
+
const entries = Object.entries(rawParameters);
|
|
146
|
+
if (entries.length > 64) fail("protocol.parameters", "at most 64 keys");
|
|
147
|
+
const parameters = Object.fromEntries(entries.map(([key, value]) => {
|
|
148
|
+
pattern(key, ID, "protocol.parameters.key");
|
|
149
|
+
if (["__proto__", "constructor", "prototype"].includes(key)) fail("protocol.parameters.key", "reserved key");
|
|
150
|
+
return [key, Array.isArray(value) ? array(value, 64, "protocol.parameters.value").map(item => scalar(item, "protocol.parameters.value")) : scalar(value, "protocol.parameters.value")];
|
|
151
|
+
}));
|
|
152
|
+
const evaluator = object(raw.evaluator, ["id", "version", "hash"], "protocol.evaluator");
|
|
153
|
+
const metrics = unique(array(raw.metrics, 32, "protocol.metrics").map((value, index) => {
|
|
154
|
+
const field = `protocol.metrics[${index}]`, row = object(value, ["name", "unit", "direction"], field);
|
|
155
|
+
return { name: text(row.name, SHORT, `${field}.name`), unit: text(row.unit, SHORT, `${field}.unit`), direction: choice(row.direction, ["higher", "lower"], `${field}.direction`) };
|
|
156
|
+
}), value => value.name, "protocol.metrics");
|
|
157
|
+
const resourceLimits = unique(array(raw.resourceLimits, 16, "protocol.resourceLimits").map((value, index) => {
|
|
158
|
+
const field = `protocol.resourceLimits[${index}]`, row = object(value, ["name", "value", "unit"], field);
|
|
159
|
+
return { name: text(row.name, SHORT, `${field}.name`), value: number(row.value, `${field}.value`, true), unit: text(row.unit, SHORT, `${field}.unit`) };
|
|
160
|
+
}), value => value.name, "protocol.resourceLimits");
|
|
161
|
+
const role = choice(raw.role, ["baseline", "variant"], "protocol.role");
|
|
162
|
+
if (raw.variantOf !== undefined && role !== "variant") fail("protocol.variantOf", "only a variant may name a baseline");
|
|
163
|
+
if (typeof raw.sealed !== "boolean") fail("protocol.sealed", "expected a declared boolean");
|
|
164
|
+
return bounded({ campaignId: pattern(raw.campaignId, CAMPAIGN, "protocol.campaignId"), campaignRevision: integer(raw.campaignRevision, "protocol.campaignRevision", 1),
|
|
165
|
+
lineage: pattern(raw.lineage, ID, "protocol.lineage"), ...(raw.supersedes !== undefined ? { supersedes: pattern(raw.supersedes, HASH, "protocol.supersedes") } : {}),
|
|
166
|
+
claimIds: strings(raw.claimIds, 32, "protocol.claimIds"), inputs, code, environment: environment(raw.environment), method: text(raw.method, LONG, "protocol.method"), parameters,
|
|
167
|
+
seeds: array(raw.seeds, 256, "protocol.seeds").map(value => integer(value, "protocol.seeds", Number.MIN_SAFE_INTEGER)),
|
|
168
|
+
evaluator: { id: pattern(evaluator.id, ID, "protocol.evaluator.id"), version: text(evaluator.version, SHORT, "protocol.evaluator.version"), ...(evaluator.hash !== undefined ? { hash: pattern(evaluator.hash, HASH, "protocol.evaluator.hash") } : {}) },
|
|
169
|
+
metrics, role, ...(raw.variantOf !== undefined ? { variantOf: pattern(raw.variantOf, HASH, "protocol.variantOf") } : {}),
|
|
170
|
+
splits: strings(raw.splits, 16, "protocol.splits"), resourceLimits, sealed: raw.sealed }, "protocol");
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function protocolHash(contract: ExperimentProtocolContract): string {
|
|
174
|
+
return createHash("sha256").update(canonicalJson(parseExperimentProtocol(contract))).digest("hex");
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function parseExperimentAttempt(input: unknown): ExperimentAttemptInput {
|
|
178
|
+
const raw = object(input, ["protocolHash", "taskId", "phase", "idempotencyKey", "retryOf", "reproducesAttemptId"], "attempt");
|
|
179
|
+
return bounded({ protocolHash: pattern(raw.protocolHash, HASH, "attempt.protocolHash"), taskId: integer(raw.taskId, "attempt.taskId", 1),
|
|
180
|
+
phase: choice(raw.phase, ["development", "final"], "attempt.phase"), idempotencyKey: text(raw.idempotencyKey, 64, "attempt.idempotencyKey"),
|
|
181
|
+
...(raw.retryOf !== undefined ? { retryOf: pattern(raw.retryOf, ATTEMPT, "attempt.retryOf") } : {}),
|
|
182
|
+
...(raw.reproducesAttemptId !== undefined ? { reproducesAttemptId: pattern(raw.reproducesAttemptId, ATTEMPT, "attempt.reproducesAttemptId") } : {}) }, "attempt");
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Caller input cannot set sequence, hub time or provenance; metric membership is checked against the named protocol. */
|
|
186
|
+
export function parseExperimentEvent(input: unknown, protocol?: ExperimentProtocolContract): DeclaredExperimentEventInput {
|
|
187
|
+
const raw = object(input, ["kind", "body"], "event");
|
|
188
|
+
switch (raw.kind) {
|
|
189
|
+
case "start": {
|
|
190
|
+
const body = object(raw.body, ["startedAt"], "event.start");
|
|
191
|
+
return { kind: "start", body: { ...(body.startedAt !== undefined ? { startedAt: integer(body.startedAt, "event.start.startedAt") } : {}) } };
|
|
192
|
+
}
|
|
193
|
+
case "end": {
|
|
194
|
+
const body = object(raw.body, ["status", "exitStatus", "reason", "run"], "event.end");
|
|
195
|
+
return { kind: "end", body: { status: choice(body.status, ["succeeded", "failed", "cancelled"], "event.end.status"),
|
|
196
|
+
...(body.exitStatus !== undefined ? { exitStatus: integer(body.exitStatus, "event.end.exitStatus", Number.MIN_SAFE_INTEGER) } : {}),
|
|
197
|
+
...(body.reason !== undefined ? { reason: text(body.reason, SHORT, "event.end.reason") } : {}), ...(body.run !== undefined ? { run: pattern(body.run, RUN, "event.end.run") } : {}) } };
|
|
198
|
+
}
|
|
199
|
+
case "metric": {
|
|
200
|
+
const body = object(raw.body, ["name", "value"], "event.metric"), name = text(body.name, SHORT, "event.metric.name");
|
|
201
|
+
if (protocol && !protocol.metrics.some(metric => metric.name === name)) fail("event.metric.name", "not declared by the protocol");
|
|
202
|
+
return { kind: "metric", body: { name, value: number(body.value, "event.metric.value") } };
|
|
203
|
+
}
|
|
204
|
+
case "output": {
|
|
205
|
+
const body = object(raw.body, ["id", "sha256", "path", "format", "version"], "event.output");
|
|
206
|
+
if (body.id !== undefined) {
|
|
207
|
+
if ([body.path, body.format, body.version].some(value => value !== undefined)) fail("event.output", "choose one declared reference or host hash request");
|
|
208
|
+
return { kind: "output", body: { id: pattern(body.id, DELIVERABLE, "event.output.id"), ...(body.sha256 !== undefined ? { sha256: pattern(body.sha256, HASH, "event.output.sha256") } : {}) } };
|
|
209
|
+
}
|
|
210
|
+
if (body.sha256 !== undefined) fail("event.output", "a host hash request cannot supply a hash");
|
|
211
|
+
text(body.path, SHORT, "event.output.path"); text(body.format, 32, "event.output.format");
|
|
212
|
+
if (body.version !== undefined) text(body.version, 64, "event.output.version");
|
|
213
|
+
const claim = parseDeliverableClaims([body])[0]!;
|
|
214
|
+
return { kind: "output", body: claim };
|
|
215
|
+
}
|
|
216
|
+
case "note": {
|
|
217
|
+
const body = object(raw.body, ["text"], "event.note");
|
|
218
|
+
return { kind: "note", body: { text: text(body.text, SHORT, "event.note.text") } };
|
|
219
|
+
}
|
|
220
|
+
default: return fail("event.kind", "unsupported declared event; attestation needs a separate authority path");
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function ordered(events: readonly DeclaredExperimentEvent[]): DeclaredExperimentEvent[] {
|
|
225
|
+
const rows = [...events].sort((a, b) => a.seq - b.seq), seen = new Set<number>();
|
|
226
|
+
for (const row of rows) {
|
|
227
|
+
integer(row.seq, "event.seq", 1); integer(row.at, "event.at");
|
|
228
|
+
if (seen.has(row.seq) || row.provenance !== "declared") fail("event", "invalid ordering or caller evidence elevation");
|
|
229
|
+
seen.add(row.seq);
|
|
230
|
+
}
|
|
231
|
+
if (rows.filter(row => row.kind === "end").length > 1) fail("event.end", "an attempt has at most one terminal event");
|
|
232
|
+
return rows;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Task completion is never experiment success; only a recorded end supplies a terminal experiment status. */
|
|
236
|
+
export function experimentStatus(events: readonly DeclaredExperimentEvent[], taskState: string | undefined): ExperimentStatus {
|
|
237
|
+
const rows = ordered(events), end = rows.find(row => row.kind === "end");
|
|
238
|
+
if (end?.kind === "end") return end.body.status;
|
|
239
|
+
if (["approved", "cancelled", "closed"].includes(taskState ?? "")) return "unknown";
|
|
240
|
+
return rows.some(row => row.kind === "start") ? "running" : "declared";
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const declared = <T>(value: T | undefined): ExperimentValue<T> => value === undefined ? { value: null, provenance: "unknown" } : { value, provenance: "declared" };
|
|
244
|
+
/** Declared-only projection. Verified host observations belong to an independent authority-bound evidence path. */
|
|
245
|
+
export function projectExperimentEvidence(protocol: ExperimentProtocolContract, events: readonly DeclaredExperimentEvent[]) {
|
|
246
|
+
const rows = ordered(events), starts = rows.filter(row => row.kind === "start"), end = rows.find(row => row.kind === "end");
|
|
247
|
+
return {
|
|
248
|
+
environment: { descriptor: declared(protocol.environment.descriptor), hash: declared(protocol.environment.hash), hashOf: "declared descriptor text" as const }, sealed: declared(protocol.sealed),
|
|
249
|
+
startedAt: declared(starts.at(-1)?.body.startedAt), endedAt: declared<number>(undefined),
|
|
250
|
+
exitStatus: declared(end?.kind === "end" ? end.body.exitStatus : undefined),
|
|
251
|
+
run: declared(end?.kind === "end" ? end.body.run : undefined),
|
|
252
|
+
durationMs: declared<number>(undefined), createdAt: declared<number>(undefined), timedOut: declared<boolean>(undefined), cancelled: declared<boolean>(undefined),
|
|
253
|
+
metrics: protocol.metrics.map(metric => {
|
|
254
|
+
const observations = rows.flatMap(row => row.kind === "metric" && row.body.name === metric.name ? [declared(row.body.value)] : []);
|
|
255
|
+
return { name: metric.name, unit: metric.unit, observations, latest: observations.at(-1) ?? declared<number>(undefined) };
|
|
256
|
+
}),
|
|
257
|
+
outputs: rows.flatMap(row => row.kind !== "output" ? [] : ["id" in row.body
|
|
258
|
+
? { id: declared(row.body.id), sha256: declared(row.body.sha256) }
|
|
259
|
+
: { id: declared<string>(undefined), sha256: declared<string>(undefined) }]),
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** Independent measured facts, linked to one declared event; caller record bodies never accept this shape. */
|
|
264
|
+
export interface ExperimentHostEvidence {
|
|
265
|
+
source: "check" | "deliverable";
|
|
266
|
+
evidence: string;
|
|
267
|
+
values: Partial<Record<"exitStatus" | "timedOut" | "cancelled" | "durationMs" | "createdAt" | "sha256" | "bytes" | "deliverableId", number | boolean | string>>;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Validate only measured fields from the host's receipt reader. This helper grants no authority to a supplied receipt. */
|
|
271
|
+
export function checkRunObservation(input: unknown, run: string, taskId: number, protocol: ExperimentProtocolContract): ExperimentHostEvidence | undefined {
|
|
272
|
+
try {
|
|
273
|
+
pattern(run, RUN, "check.run");
|
|
274
|
+
const row = object(input, undefined, "check");
|
|
275
|
+
if (!("commit" in protocol.code) || row.run !== run || row.task !== taskId || row.sha !== protocol.code.commit || row.status !== "finished") return undefined;
|
|
276
|
+
if (row.exit !== null && (typeof row.exit !== "number" || !Number.isSafeInteger(row.exit))) return undefined;
|
|
277
|
+
if (typeof row.timed_out !== "boolean" || typeof row.cancelled !== "boolean") return undefined;
|
|
278
|
+
const durationMs = number(row.duration_ms, "check.duration_ms", true), createdAt = number(row.created, "check.created", true);
|
|
279
|
+
return { source: "check", evidence: run, values: { ...(row.exit === null ? {} : { exitStatus: row.exit }), timedOut: row.timed_out, cancelled: row.cancelled, durationMs, createdAt } };
|
|
280
|
+
} catch { return undefined; }
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
/** Validate persisted measured facts. Only host-side Tasks code creates these rows, never an RPC record body. */
|
|
285
|
+
export function parseExperimentHostEvidence(input: unknown): ExperimentHostEvidence {
|
|
286
|
+
const row = object(input, ["source", "evidence", "values"], "observation");
|
|
287
|
+
const source = choice(row.source, ["check", "deliverable"], "observation.source");
|
|
288
|
+
const values = object(row.values, source === "check" ? ["exitStatus", "timedOut", "cancelled", "durationMs", "createdAt"] : ["deliverableId", "sha256", "bytes"], "observation.values");
|
|
289
|
+
if (source === "check") {
|
|
290
|
+
if (typeof values.timedOut !== "boolean" || typeof values.cancelled !== "boolean") fail("observation.values", "missing measured check flags");
|
|
291
|
+
return { source, evidence: pattern(row.evidence, RUN, "observation.evidence"), values: {
|
|
292
|
+
...(values.exitStatus === undefined ? {} : { exitStatus: integer(values.exitStatus, "observation.exitStatus", Number.MIN_SAFE_INTEGER) }),
|
|
293
|
+
timedOut: values.timedOut, cancelled: values.cancelled, durationMs: number(values.durationMs, "observation.durationMs", true), createdAt: number(values.createdAt, "observation.createdAt", true),
|
|
294
|
+
} };
|
|
295
|
+
}
|
|
296
|
+
const deliverableId = pattern(values.deliverableId, DELIVERABLE, "observation.deliverableId");
|
|
297
|
+
if (row.evidence !== deliverableId) fail("observation.evidence", "must name the measured deliverable");
|
|
298
|
+
const bytes = integer(values.bytes, "observation.bytes");
|
|
299
|
+
if (bytes > DELIVERABLE_LIMITS.fileBytes) fail("observation.bytes", "exceeds the deliverable file limit");
|
|
300
|
+
return { source, evidence: deliverableId, values: { deliverableId, sha256: pattern(values.sha256, HASH, "observation.sha256"), bytes } };
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** The record is the result of #509 host hashing, never an agent's output reference. */
|
|
304
|
+
export function deliverableObservation(record: DeliverableRecord): ExperimentHostEvidence {
|
|
305
|
+
const measured = parseDeliverableRecords([object(record, ["id", "path", "format", "version", "sha256", "bytes", "private"], "deliverable")])[0]!;
|
|
306
|
+
return parseExperimentHostEvidence({ source: "deliverable", evidence: measured.id, values: { deliverableId: measured.id, sha256: measured.sha256, bytes: measured.bytes } });
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** Keep conflicting declarations and measurements side by side; neither changes the attempt's declared terminal event. */
|
|
310
|
+
export function projectExperimentWithObservations(protocol: ExperimentProtocolContract, events: readonly ExperimentEvent[], observations: readonly { eventSeq: number; observation: ExperimentHostEvidence }[]) {
|
|
311
|
+
const declaredEvents = declaredExperimentEvents(events);
|
|
312
|
+
const declared = projectExperimentEvidence(protocol, declaredEvents), seqs = new Set(declaredEvents.map(event => event.seq));
|
|
313
|
+
return { declared, measured: observations.map(row => {
|
|
314
|
+
integer(row.eventSeq, "observation.eventSeq", 1);
|
|
315
|
+
if (!seqs.has(row.eventSeq)) fail("observation.eventSeq", "declared event is unavailable");
|
|
316
|
+
const observation = parseExperimentHostEvidence(row.observation), event = declaredEvents.find(event => event.seq === row.eventSeq)!;
|
|
317
|
+
if (observation.source === "check" && (event.kind !== "end" || event.body.run !== observation.evidence)) fail("observation", "check evidence must name its declared end run");
|
|
318
|
+
if (observation.source === "deliverable" && (event.kind !== "output" || !("path" in event.body) || deliverableId(event.body.path, event.body.format) !== observation.evidence)) fail("observation", "output evidence must match a host hash request");
|
|
319
|
+
return { eventSeq: row.eventSeq, source: observation.source, evidence: observation.evidence,
|
|
320
|
+
values: Object.fromEntries(Object.entries(observation.values).map(([field, value]) => [field, { value, provenance: "host-observed" as const, evidence: observation.evidence }])) };
|
|
321
|
+
}) };
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
export interface RunnerReceiptProjection {
|
|
325
|
+
receiptId: string;
|
|
326
|
+
runner: string;
|
|
327
|
+
provenance: "runner-attested";
|
|
328
|
+
values: Record<string, ExperimentValue<unknown>>;
|
|
329
|
+
sealing: ExperimentValue<boolean>;
|
|
330
|
+
codeMatch: ExperimentValue<boolean>;
|
|
331
|
+
}
|
|
332
|
+
const RUNNER_RECEIPT_FIELDS = ["codeCommit", "imageDigest", "containers", "mounts", "network", "resourceLimits", "startedAt", "endedAt", "exitStatus", "outputs", "metricsFile"] as const;
|
|
333
|
+
const runnerAttested = <T>(value: T | undefined, evidence: string): ExperimentValue<T> => value === undefined
|
|
334
|
+
? { value: null, provenance: "unknown" } : { value, provenance: "runner-attested", evidence };
|
|
335
|
+
|
|
336
|
+
/** Project stored signature-verified receipts only; this view authenticates nothing and changes no declared or host-observed evidence. */
|
|
337
|
+
export function projectRunnerReceipt(protocol: ExperimentProtocolContract, receipt: AttemptReceiptV1): RunnerReceiptProjection {
|
|
338
|
+
const parsed = parseAttemptReceipt(receipt);
|
|
339
|
+
if ("commit" in protocol.code && parsed.codeCommit !== undefined && parsed.codeCommit !== protocol.code.commit)
|
|
340
|
+
fail("receipt.codeCommit", "protocol commit mismatch");
|
|
341
|
+
return { receiptId: parsed.receiptId, runner: parsed.runner, provenance: "runner-attested",
|
|
342
|
+
values: Object.fromEntries(RUNNER_RECEIPT_FIELDS.map(field => [field, runnerAttested(parsed[field], parsed.receiptId)])),
|
|
343
|
+
sealing: runnerSealing(parsed) ? runnerAttested(true, parsed.receiptId) : declared(protocol.sealed),
|
|
344
|
+
codeMatch: "commit" in protocol.code && parsed.codeCommit !== undefined ? runnerAttested(true, parsed.receiptId) : runnerAttested<boolean>(undefined, parsed.receiptId) };
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** Storage records carry host-issued identity separately from the immutable caller body. */
|
|
348
|
+
export interface ExperimentProtocolRecord {
|
|
349
|
+
hash: string;
|
|
350
|
+
project: string;
|
|
351
|
+
recordedForTaskId: number;
|
|
352
|
+
protocol: ExperimentProtocolContract;
|
|
353
|
+
by: string;
|
|
354
|
+
at: number;
|
|
355
|
+
private: boolean;
|
|
356
|
+
}
|
|
357
|
+
export interface ExperimentAttemptRecord {
|
|
358
|
+
attemptId: string;
|
|
359
|
+
project: string;
|
|
360
|
+
attempt: ExperimentAttemptInput;
|
|
361
|
+
bodyHash: string;
|
|
362
|
+
by: string;
|
|
363
|
+
at: number;
|
|
364
|
+
private: boolean;
|
|
365
|
+
}
|
|
366
|
+
export interface ExperimentEventRecord {
|
|
367
|
+
attemptId: string;
|
|
368
|
+
idempotencyKey: string;
|
|
369
|
+
event: ExperimentEvent;
|
|
370
|
+
bodyHash: string;
|
|
371
|
+
by: string;
|
|
372
|
+
private: boolean;
|
|
373
|
+
observations: { eventSeq: number; observation: ExperimentHostEvidence }[];
|
|
374
|
+
}
|
|
375
|
+
export interface ExperimentWriteFence {
|
|
376
|
+
project: string;
|
|
377
|
+
taskId: number;
|
|
378
|
+
taskFlowRevision: number;
|
|
379
|
+
owner: string;
|
|
380
|
+
campaignId: string;
|
|
381
|
+
campaignRevision: number;
|
|
382
|
+
campaignHash: string;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
export type ComparisonVaryingField = "code" | "parameters" | "seeds" | "role";
|
|
386
|
+
export interface ComparisonPlanV1 {
|
|
387
|
+
campaignId: string;
|
|
388
|
+
fixed: Pick<ExperimentProtocolContract, "inputs" | "splits" | "evaluator" | "metrics">;
|
|
389
|
+
varying: ComparisonVaryingField[];
|
|
390
|
+
rule: { kind: "tolerance"; value: number } | { kind: "test"; name: string; alpha: number };
|
|
391
|
+
}
|
|
392
|
+
export type ComparisonCompatibility = { compatible: true }
|
|
393
|
+
| { compatible: false; field: string; side: "baseline" | "variant" | "pair" };
|
|
394
|
+
|
|
395
|
+
/** A plan declares comparison invariants and a rule; parsing never runs the statistical rule. */
|
|
396
|
+
export function parseComparisonPlan(input: unknown): ComparisonPlanV1 {
|
|
397
|
+
const raw = object(input, ["campaignId", "fixed", "varying", "rule"], "comparison");
|
|
398
|
+
const fixed = object(raw.fixed, ["inputs", "splits", "evaluator", "metrics"], "comparison.fixed");
|
|
399
|
+
const inputs = unique(array(fixed.inputs, 64, "comparison.fixed.inputs").map((value, index) => {
|
|
400
|
+
const field = `comparison.fixed.inputs[${index}]`, row = object(value, ["sourceId", "sha256"], field);
|
|
401
|
+
return { sourceId: pattern(row.sourceId, SOURCE, `${field}.sourceId`), sha256: pattern(row.sha256, HASH, `${field}.sha256`) };
|
|
402
|
+
}), value => value.sourceId, "comparison.fixed.inputs");
|
|
403
|
+
const evaluator = object(fixed.evaluator, ["id", "version", "hash"], "comparison.fixed.evaluator");
|
|
404
|
+
const metrics = unique(array(fixed.metrics, 32, "comparison.fixed.metrics").map((value, index) => {
|
|
405
|
+
const field = `comparison.fixed.metrics[${index}]`, row = object(value, ["name", "unit", "direction"], field);
|
|
406
|
+
return { name: text(row.name, SHORT, `${field}.name`), unit: text(row.unit, SHORT, `${field}.unit`), direction: choice(row.direction, ["higher", "lower"], `${field}.direction`) };
|
|
407
|
+
}), value => value.name, "comparison.fixed.metrics");
|
|
408
|
+
const varying = unique(array(raw.varying, 4, "comparison.varying").map(value => choice<ComparisonVaryingField>(value, ["code", "parameters", "seeds", "role"], "comparison.varying")), value => value, "comparison.varying");
|
|
409
|
+
const rawRule = object(raw.rule, ["kind", "value", "name", "alpha"], "comparison.rule");
|
|
410
|
+
let rule: ComparisonPlanV1["rule"];
|
|
411
|
+
if (rawRule.kind === "tolerance") {
|
|
412
|
+
object(raw.rule, ["kind", "value"], "comparison.rule");
|
|
413
|
+
rule = { kind: "tolerance", value: number(rawRule.value, "comparison.rule.value", true) };
|
|
414
|
+
} else if (rawRule.kind === "test") {
|
|
415
|
+
object(raw.rule, ["kind", "name", "alpha"], "comparison.rule");
|
|
416
|
+
const alpha = number(rawRule.alpha, "comparison.rule.alpha");
|
|
417
|
+
if (alpha <= 0 || alpha >= 1) fail("comparison.rule.alpha", "expected a probability strictly between zero and one");
|
|
418
|
+
rule = { kind: "test", name: text(rawRule.name, SHORT, "comparison.rule.name"), alpha };
|
|
419
|
+
} else fail("comparison.rule.kind", "expected tolerance or test");
|
|
420
|
+
return bounded({ campaignId: pattern(raw.campaignId, CAMPAIGN, "comparison.campaignId"), fixed: { inputs, splits: strings(fixed.splits, 16, "comparison.fixed.splits"),
|
|
421
|
+
evaluator: { id: pattern(evaluator.id, ID, "comparison.fixed.evaluator.id"), version: text(evaluator.version, SHORT, "comparison.fixed.evaluator.version"),
|
|
422
|
+
...(evaluator.hash !== undefined ? { hash: pattern(evaluator.hash, HASH, "comparison.fixed.evaluator.hash") } : {}) }, metrics }, varying, rule }, "comparison");
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
export function comparisonPlanHash(plan: ComparisonPlanV1): string {
|
|
426
|
+
return createHash("sha256").update(canonicalJson(parseComparisonPlan(plan))).digest("hex");
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/** Structural compatibility only. Identity metadata remains in each protocol hash and approval binding. */
|
|
430
|
+
export function compareExperimentProtocols(plan: ComparisonPlanV1, baseline: ExperimentProtocolContract, variant: ExperimentProtocolContract): ComparisonCompatibility {
|
|
431
|
+
const declared = parseComparisonPlan(plan), a = parseExperimentProtocol(baseline), b = parseExperimentProtocol(variant);
|
|
432
|
+
if (a.campaignId !== declared.campaignId) return { compatible: false, field: "campaignId", side: "baseline" };
|
|
433
|
+
if (b.campaignId !== declared.campaignId) return { compatible: false, field: "campaignId", side: "variant" };
|
|
434
|
+
for (const field of ["inputs", "splits", "evaluator", "metrics"] as const) {
|
|
435
|
+
const expected = canonicalJson(declared.fixed[field]);
|
|
436
|
+
if (canonicalJson(a[field]) !== expected) return { compatible: false, field, side: "baseline" };
|
|
437
|
+
if (canonicalJson(b[field]) !== expected) return { compatible: false, field, side: "variant" };
|
|
438
|
+
}
|
|
439
|
+
// Only campaignRevision, lineage and supersedes are omitted. Every experimental field outside varying stays exact.
|
|
440
|
+
for (const field of ["claimIds", "code", "environment", "method", "parameters", "seeds", "role", "resourceLimits", "sealed"] as const) {
|
|
441
|
+
if ((field === "code" || field === "parameters" || field === "seeds" || field === "role") && declared.varying.includes(field)) continue;
|
|
442
|
+
const left = field === "role" ? { role: a.role, variantOf: a.variantOf } : a[field];
|
|
443
|
+
const right = field === "role" ? { role: b.role, variantOf: b.variantOf } : b[field];
|
|
444
|
+
if (canonicalJson(left) !== canonicalJson(right)) return { compatible: false, field, side: "pair" };
|
|
445
|
+
}
|
|
446
|
+
return { compatible: true };
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/** Keep the pair's own identities visible even when compatibility ignores identity metadata; never rank either member. */
|
|
450
|
+
export function comparisonProjection(plan: ComparisonPlanV1, baseline: ExperimentProtocolContract, variant: ExperimentProtocolContract) {
|
|
451
|
+
const declared = parseComparisonPlan(plan), a = parseExperimentProtocol(baseline), b = parseExperimentProtocol(variant);
|
|
452
|
+
return {
|
|
453
|
+
planHash: comparisonPlanHash(declared), compatibility: compareExperimentProtocols(declared, a, b),
|
|
454
|
+
baseline: { protocolHash: protocolHash(a), campaignRevision: a.campaignRevision },
|
|
455
|
+
variant: { protocolHash: protocolHash(b), campaignRevision: b.campaignRevision },
|
|
456
|
+
identityNote: "campaignRevision, lineage and supersedes are excluded from compatibility only; each displayed protocol identity and approval binding remains separate.",
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
/** Identity of the declared evaluator object, not a measurement of evaluator execution. */
|
|
462
|
+
export function evaluatorIdentityHash(protocol: ExperimentProtocolContract): string {
|
|
463
|
+
return createHash("sha256").update(canonicalJson(parseExperimentProtocol(protocol).evaluator)).digest("hex");
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/** Matches host-read approval identity only; Tasks remains responsible for person authority and persistence. */
|
|
467
|
+
export function protocolApprovalBindingMatches(protocol: ExperimentProtocolContract, binding: { protocolHash: string; evaluatorHash: string } | undefined): boolean {
|
|
468
|
+
const hash = protocolHash(protocol), evaluator = evaluatorIdentityHash(protocol);
|
|
469
|
+
if (!binding) return false;
|
|
470
|
+
try {
|
|
471
|
+
const record = object(binding, undefined, "approval");
|
|
472
|
+
return record.protocolHash === hash && record.evaluatorHash === evaluator;
|
|
473
|
+
} catch { return false; }
|
|
474
|
+
}
|