@intx/hub-sessions 0.3.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.
Files changed (67) hide show
  1. package/dist/agent-repo.d.ts +14 -2
  2. package/dist/agent-repo.js +17 -4
  3. package/dist/agent-state-kind.js +14 -63
  4. package/dist/asset-service.js +14 -10
  5. package/dist/credential-push.d.ts +48 -4
  6. package/dist/credential-push.js +138 -6
  7. package/dist/event-collector-registry.d.ts +2 -1
  8. package/dist/event-collector-registry.js +38 -9
  9. package/dist/event-collector.d.ts +11 -1
  10. package/dist/event-collector.js +36 -3
  11. package/dist/hub-session-lookups.d.ts +1 -1
  12. package/dist/hub-session-lookups.js +68 -72
  13. package/dist/hub-session-orchestrator.d.ts +2 -3
  14. package/dist/hub-session-orchestrator.js +13 -12
  15. package/dist/index.d.ts +7 -6
  16. package/dist/index.js +7 -6
  17. package/dist/reconciliation-scheduler.d.ts +14 -0
  18. package/dist/reconciliation-scheduler.js +55 -0
  19. package/dist/repo-store/index.d.ts +1 -0
  20. package/dist/repo-store/index.js +1 -0
  21. package/dist/repo-store/user-principal-gate.d.ts +26 -0
  22. package/dist/repo-store/user-principal-gate.js +78 -0
  23. package/dist/session-service.d.ts +66 -121
  24. package/dist/session-service.js +444 -411
  25. package/dist/sidecar-allocation/capability-policy.d.ts +27 -0
  26. package/dist/sidecar-allocation/capability-policy.js +124 -0
  27. package/dist/sidecar-allocation/contracts.d.ts +29 -6
  28. package/dist/sidecar-allocation/contracts.js +7 -2
  29. package/dist/sidecar-allocation/index.d.ts +4 -3
  30. package/dist/sidecar-allocation/index.js +3 -2
  31. package/dist/sidecar-allocation/operation.d.ts +10 -0
  32. package/dist/sidecar-allocation/operation.js +54 -0
  33. package/dist/sidecar-allocation/plugin-registry.d.ts +16 -3
  34. package/dist/sidecar-allocation/plugin-registry.js +36 -12
  35. package/dist/sidecar-allocation/reconciler.d.ts +16 -4
  36. package/dist/sidecar-allocation/reconciler.js +486 -92
  37. package/dist/skill-kind.js +8 -62
  38. package/dist/substrate.d.ts +1 -1
  39. package/dist/substrate.js +1 -1
  40. package/dist/workflow-allocation-service.d.ts +21 -15
  41. package/dist/workflow-allocation-service.js +440 -125
  42. package/dist/workflow-dispatch-service.d.ts +4 -2
  43. package/dist/workflow-dispatch-service.js +89 -26
  44. package/dist/workflow-kind.d.ts +12 -0
  45. package/dist/workflow-kind.js +17 -60
  46. package/dist/workflow-probe-gate.d.ts +99 -27
  47. package/dist/workflow-probe-gate.js +196 -21
  48. package/dist/workflow-run-kind.d.ts +112 -19
  49. package/dist/workflow-run-kind.js +626 -210
  50. package/dist/workflow-run-restore.d.ts +1 -0
  51. package/dist/workflow-run-restore.js +5 -1
  52. package/dist/workflow-source-pins.d.ts +8 -0
  53. package/dist/workflow-source-pins.js +14 -0
  54. package/dist/ws/index.d.ts +1 -1
  55. package/dist/ws/index.js +1 -1
  56. package/dist/ws/pending-tracker.d.ts +93 -0
  57. package/dist/ws/pending-tracker.js +132 -0
  58. package/dist/ws/sidecar-events.d.ts +43 -29
  59. package/dist/ws/sidecar-events.js +0 -2
  60. package/dist/ws/sidecar-handler.d.ts +122 -85
  61. package/dist/ws/sidecar-handler.js +925 -878
  62. package/dist/ws/sidecar-handler.test-helpers.d.ts +38 -0
  63. package/dist/ws/sidecar-handler.test-helpers.js +95 -0
  64. package/dist/ws/sidecar-token-authenticator.js +37 -23
  65. package/package.json +13 -13
  66. package/dist/sidecar-allocation/placement-policy.d.ts +0 -11
  67. package/dist/sidecar-allocation/placement-policy.js +0 -21
@@ -26,15 +26,17 @@ export type WorkflowDispatchServiceDeps = {
26
26
  /** Resolve the deployment anchor's durable routing address. */
27
27
  readonly resolveAnchorAddress: (anchorRunId: string) => Promise<string | null>;
28
28
  readonly leaseDurationMs?: number;
29
+ /** Bounds pending claims and unfinished deliveries across all drains. */
30
+ readonly maxConcurrentDispatches?: number;
29
31
  readonly retryDelayMs?: (attempt: number) => number;
30
32
  readonly now?: () => Date;
31
33
  readonly createLeaseId?: () => string;
32
34
  };
33
35
  /**
34
- * Drives Hub-owned workflow triggers onto exclusive sidecars. The database
36
+ * Drives Hub-owned workflow triggers onto provisioned sidecars. The database
35
37
  * row is the delivery authority: websocket acceptance never deletes the raw
36
38
  * payload, and a generation replacement requeues every row that has not been
37
39
  * settled by the workflow-run Git claim-check.
38
40
  */
39
- export declare function createWorkflowDispatchService({ dispatchStore, allocationStore, router, resolveAnchorAddress, leaseDurationMs, retryDelayMs, now, createLeaseId, }: WorkflowDispatchServiceDeps): WorkflowDispatchService;
41
+ export declare function createWorkflowDispatchService({ dispatchStore, allocationStore, router, resolveAnchorAddress, leaseDurationMs, maxConcurrentDispatches, retryDelayMs, now, createLeaseId, }: WorkflowDispatchServiceDeps): WorkflowDispatchService;
40
42
  export {};
@@ -1,8 +1,10 @@
1
1
  import { getLogger } from "@intx/log";
2
- import { base64Encode, hexEncode } from "@intx/types";
2
+ import { base64Encode, deriveWorkflowRunId, hexEncode } from "@intx/types";
3
3
  import { SignalDeliverFrame } from "@intx/types/sidecar";
4
+ import { runSidecarOperation, SidecarOperationTimeoutError, } from "./sidecar-allocation/operation.js";
4
5
  const logger = getLogger(["hub", "workflow-dispatch"]);
5
6
  const DEFAULT_LEASE_DURATION_MS = 30_000;
7
+ const DEFAULT_MAX_CONCURRENT_DISPATCHES = 8;
6
8
  function defaultRetryDelay(attempt) {
7
9
  return Math.min(500 * 2 ** Math.min(attempt, 6), 30_000);
8
10
  }
@@ -12,6 +14,7 @@ function randomLeaseId() {
12
14
  function targetForReadyAllocation(allocation) {
13
15
  if (allocation.status !== "allocated" ||
14
16
  allocation.ensureAcceptedGeneration !== allocation.generation ||
17
+ allocation.initializationLeaseId !== undefined ||
15
18
  allocation.connectDeadline !== undefined) {
16
19
  return null;
17
20
  }
@@ -21,16 +24,22 @@ function targetForReadyAllocation(allocation) {
21
24
  };
22
25
  }
23
26
  /**
24
- * Drives Hub-owned workflow triggers onto exclusive sidecars. The database
27
+ * Drives Hub-owned workflow triggers onto provisioned sidecars. The database
25
28
  * row is the delivery authority: websocket acceptance never deletes the raw
26
29
  * payload, and a generation replacement requeues every row that has not been
27
30
  * settled by the workflow-run Git claim-check.
28
31
  */
29
- export function createWorkflowDispatchService({ dispatchStore, allocationStore, router, resolveAnchorAddress, leaseDurationMs = DEFAULT_LEASE_DURATION_MS, retryDelayMs = defaultRetryDelay, now = () => new Date(), createLeaseId = randomLeaseId, }) {
30
- if (leaseDurationMs <= 0) {
31
- throw new Error("leaseDurationMs must be positive");
32
+ export function createWorkflowDispatchService({ dispatchStore, allocationStore, router, resolveAnchorAddress, leaseDurationMs = DEFAULT_LEASE_DURATION_MS, maxConcurrentDispatches = DEFAULT_MAX_CONCURRENT_DISPATCHES, retryDelayMs = defaultRetryDelay, now = () => new Date(), createLeaseId = randomLeaseId, }) {
33
+ if (!Number.isSafeInteger(leaseDurationMs) || leaseDurationMs <= 0) {
34
+ throw new Error("leaseDurationMs must be a positive integer");
35
+ }
36
+ if (!Number.isSafeInteger(maxConcurrentDispatches) ||
37
+ maxConcurrentDispatches <= 0) {
38
+ throw new Error("maxConcurrentDispatches must be a positive integer");
32
39
  }
33
40
  let drainPromise = null;
41
+ let admittedDispatches = 0;
42
+ const activeDispatches = new Set();
34
43
  function retryAt(attempt) {
35
44
  return new Date(now().getTime() + retryDelayMs(attempt));
36
45
  }
@@ -45,53 +54,107 @@ export function createWorkflowDispatchService({ dispatchStore, allocationStore,
45
54
  });
46
55
  }
47
56
  async function reconcileNext() {
48
- const leaseId = createLeaseId();
49
- const dispatch = await dispatchStore.claimNextPending({
50
- leaseId,
51
- leaseDurationMs,
52
- });
53
- if (dispatch === null)
57
+ if (admittedDispatches >= maxConcurrentDispatches)
54
58
  return false;
59
+ admittedDispatches += 1;
60
+ let delivery;
61
+ let activeDispatchId;
62
+ try {
63
+ const leaseId = createLeaseId();
64
+ const claimStartedAt = performance.now();
65
+ const dispatch = await dispatchStore.claimNextPending({
66
+ leaseId,
67
+ leaseDurationMs,
68
+ excludedDispatchIds: [...activeDispatches],
69
+ });
70
+ if (dispatch === null)
71
+ return false;
72
+ // A concurrent claim can outlive both its exclusion snapshot and the old
73
+ // lease. Do not start another delivery while its original I/O is pending.
74
+ if (activeDispatches.has(dispatch.id))
75
+ return true;
76
+ const remaining = leaseDurationMs - (performance.now() - claimStartedAt);
77
+ if (remaining <= 0)
78
+ return true;
79
+ activeDispatchId = dispatch.id;
80
+ activeDispatches.add(dispatch.id);
81
+ try {
82
+ await runSidecarOperation("Workflow dispatch", Math.ceil(remaining), (signal) => {
83
+ delivery = deliver(dispatch, leaseId, signal);
84
+ return delivery;
85
+ });
86
+ }
87
+ catch (error) {
88
+ if (!(error instanceof SidecarOperationTimeoutError))
89
+ throw error;
90
+ // The durable lease expires independently. Do not start another database
91
+ // write here: a stuck retry write must not occupy the freed drain either.
92
+ logger.warn `Dispatch ${dispatch.id} stopped at its delivery deadline`;
93
+ }
94
+ return true;
95
+ }
96
+ finally {
97
+ const release = () => {
98
+ admittedDispatches -= 1;
99
+ if (activeDispatchId !== undefined)
100
+ activeDispatches.delete(activeDispatchId);
101
+ };
102
+ // Timeouts free the drain, but the underlying I/O retains its admission
103
+ // reservation and dispatch exclusion until it actually settles.
104
+ if (delivery === undefined)
105
+ release();
106
+ else
107
+ void delivery.then(release, release);
108
+ }
109
+ }
110
+ async function deliver(dispatch, leaseId, signal) {
55
111
  const allocation = await allocationStore.findByAnchorRunId(dispatch.anchorRunId);
112
+ signal.throwIfAborted();
56
113
  if (allocation === null) {
57
114
  await retry(dispatch, leaseId, "allocation_missing", `No sidecar allocation exists for workflow anchor ${dispatch.anchorRunId}`);
58
- return true;
115
+ return;
59
116
  }
60
117
  const target = targetForReadyAllocation(allocation);
61
118
  if (target === null) {
62
119
  await retry(dispatch, leaseId, "allocation_not_ready", `Sidecar allocation ${allocation.id} is not ready for delivery`);
63
- return true;
120
+ return;
64
121
  }
65
122
  const agentAddress = await resolveAnchorAddress(dispatch.anchorRunId);
123
+ signal.throwIfAborted();
66
124
  if (agentAddress === null) {
67
125
  await retry(dispatch, leaseId, "anchor_address_missing", `Workflow anchor ${dispatch.anchorRunId} has no routing address`);
68
- return true;
126
+ return;
69
127
  }
70
128
  try {
71
129
  if (dispatch.kind === "signal") {
72
- const signal = SignalDeliverFrame.assert(JSON.parse(new TextDecoder().decode(dispatch.rawMessage)));
130
+ const frame = SignalDeliverFrame.assert(JSON.parse(new TextDecoder().decode(dispatch.rawMessage)));
73
131
  await router.sendSignalDeliverToAllocation(target, {
74
- agentAddress: signal.agentAddress,
75
- runId: signal.runId,
76
- signalName: signal.signalName,
77
- signalId: signal.signalId,
78
- payload: signal.payload,
79
- });
132
+ agentAddress: frame.agentAddress,
133
+ runId: frame.runId,
134
+ signalName: frame.signalName,
135
+ signalId: frame.signalId,
136
+ payload: frame.payload,
137
+ }, signal);
80
138
  }
81
139
  else {
82
- await router.sendWorkflowRunDispatchToAllocation(target, agentAddress,
83
- // Every trigger of a deployment uses its stable mail address as the
84
- // supervisor run id.
85
- agentAddress, dispatch.stepGrants, base64Encode(dispatch.rawMessage), dispatch.messageId);
140
+ // A deliverable mail dispatch always carries the sender persisted at
141
+ // enqueue (the workflow_run_dispatch mail-sender check enforces it).
142
+ // A null here means the row bypassed that invariant, so fail loudly
143
+ // rather than deliver with no authenticated sender or fall back to the
144
+ // MIME From.
145
+ if (dispatch.senderAddress === null) {
146
+ throw new Error(`mail dispatch ${dispatch.id} has no persisted authenticated sender`);
147
+ }
148
+ await router.sendWorkflowRunDispatchToAllocation(target, agentAddress, deriveWorkflowRunId(agentAddress), dispatch.stepGrants, base64Encode(dispatch.rawMessage), dispatch.senderAddress, dispatch.messageId, signal);
86
149
  }
87
150
  // Keep the delivery lease until the sidecar acknowledges its durable
88
151
  // inbox write. If that ack never arrives, lease expiry makes the same
89
152
  // immutable payload claimable again.
90
153
  }
91
154
  catch (error) {
155
+ signal.throwIfAborted();
92
156
  await retry(dispatch, leaseId, "dispatch_unroutable", error instanceof Error ? error.message : String(error));
93
157
  }
94
- return true;
95
158
  }
96
159
  async function reconcileUntilIdle(maxIterations = 100) {
97
160
  let reconciled = 0;
@@ -32,6 +32,18 @@ export declare const workflowDefinitionEnvelopeSchema: import("arktype/internal/
32
32
  locator: "tenant";
33
33
  name?: string;
34
34
  }[];
35
+ sidecarPlacement?: {
36
+ capabilities?: {
37
+ capability: string;
38
+ effect: "require" | "block";
39
+ }[];
40
+ };
41
+ inboundMailPolicy?: {
42
+ untrustedFrom?: "reject" | "admit";
43
+ invalid?: "reject" | "admit";
44
+ missing?: "reject" | "admit";
45
+ unknown?: "reject" | "admit";
46
+ };
35
47
  }, {}>;
36
48
  export declare const workflowKindHandler: KindHandler;
37
49
  export declare const workflowAuthorize: AuthorizeFn;
@@ -23,10 +23,10 @@
23
23
  // used by skill assets.
24
24
  import { type } from "arktype";
25
25
  import { getLogger } from "@intx/log";
26
- import { CredentialBinding, GrantRequirement } from "@intx/types";
26
+ import { CredentialBinding, GrantRequirement, SidecarCapabilityPolicy, } from "@intx/types";
27
+ import { InboundMailPolicy } from "@intx/types/runtime";
27
28
  import { PackageJSON, isContainedEntryPath } from "@intx/types/package-json";
28
- import { glob, repoActionToGrantVerb } from "@intx/hub-common";
29
- import { UserPrincipal, } from "./repo-store/index.js";
29
+ import { authorizeUserPrincipal, } from "./repo-store/index.js";
30
30
  const logger = getLogger(["hub-sessions", "workflow-kind"]);
31
31
  export const WORKFLOW_JSON_PATH = "workflow.json";
32
32
  export const CAPABILITY_DECLARATIONS_JSON_PATH = "capability-declarations.json";
@@ -78,6 +78,13 @@ export const workflowDefinitionEnvelopeSchema = type({
78
78
  // authority, or handle) is rejected at the deploy boundary rather than
79
79
  // passed through to launch-time resolution unchecked.
80
80
  "credentialBindings?": CredentialBinding.array(),
81
+ "sidecarPlacement?": SidecarCapabilityPolicy,
82
+ // `inboundMailPolicy` is validated here too -- same defense-in-depth
83
+ // rationale as credentialBindings above: a malformed policy (an unknown
84
+ // outcome key or a value that is not reject/admit) is rejected at the deploy
85
+ // boundary rather than passed through to later admission resolution
86
+ // unchecked.
87
+ "inboundMailPolicy?": InboundMailPolicy,
81
88
  }).onUndeclaredKey("ignore");
82
89
  const SidecarPrincipal = type({
83
90
  kind: "'sidecar'",
@@ -241,63 +248,13 @@ export const workflowAuthorize = (principal, repoId, ref, action) => {
241
248
  }
242
249
  }
243
250
  if (principal.kind === "user") {
244
- // The route layer has already pre-resolved the grant verdict and
245
- // attached it as `authz`. The substrate does NOT re-query the
246
- // grant store here; it (a) checks the bearer-token's claims
247
- // bound the requested (ref, action) and have not expired, and
248
- // (b) sanity-checks that the pre-resolved verdict targets this
249
- // exact resource and grant verb. Both gates must pass before the
250
- // verdict's `effect` is honoured.
251
- const parsed = UserPrincipal(principal);
252
- if (parsed instanceof type.errors) {
253
- return {
254
- allowed: false,
255
- reason: `user principal is malformed: ${parsed.summary}`,
256
- };
257
- }
258
- if (!parsed.tokenClaims.actions.includes(action)) {
259
- return {
260
- allowed: false,
261
- reason: `token does not grant action ${action}`,
262
- };
263
- }
264
- // `ref === "*"` is the substrate's sentinel for the bulk read
265
- // performed by `listRefs`. Per-ref filtering is the advertise-refs
266
- // layer's responsibility, so the bulk read is gated on action and
267
- // expiry alone.
268
- if (ref !== "*" && !glob.match(parsed.tokenClaims.refPattern, ref)) {
269
- return {
270
- allowed: false,
271
- reason: `token refPattern ${parsed.tokenClaims.refPattern} does not match ${ref}`,
272
- };
273
- }
274
- if (Date.now() >= parsed.tokenClaims.expiresAt) {
275
- return {
276
- allowed: false,
277
- reason: `token expired at ${parsed.tokenClaims.expiresAt}`,
278
- };
279
- }
280
- const expectedResource = `asset:${repoId.id}`;
281
- if (parsed.authz.resource !== expectedResource) {
282
- return {
283
- allowed: false,
284
- reason: `authz verdict resource ${parsed.authz.resource} does not match ${expectedResource}`,
285
- };
286
- }
287
- const expectedGrantVerb = repoActionToGrantVerb(action);
288
- if (parsed.authz.grantVerb !== expectedGrantVerb) {
289
- return {
290
- allowed: false,
291
- reason: `authz verdict grantVerb ${parsed.authz.grantVerb} does not match ${expectedGrantVerb}`,
292
- };
293
- }
294
- if (parsed.authz.effect === "allow") {
295
- return { allowed: true };
296
- }
297
- return {
298
- allowed: false,
299
- reason: `authz verdict denied for ${expectedResource} ${expectedGrantVerb}`,
300
- };
251
+ return authorizeUserPrincipal({
252
+ principal,
253
+ repoId,
254
+ ref,
255
+ action,
256
+ resourcePrefix: "asset",
257
+ });
301
258
  }
302
259
  // Fail closed on any kind not handled above. The tenant-level
303
260
  // `workflow` principal kind (`@intx/types` principalKinds) is a
@@ -1,26 +1,37 @@
1
1
  import type { DBExecutor } from "@intx/db";
2
- import type { GrantWalkSnapshot } from "@intx/types";
2
+ import type { ApprovalItem, GrantRequirement, GrantWalkSnapshot } from "@intx/types";
3
3
  import type { PackumentFetcher, RegistryConfig } from "@intx/tool-packaging";
4
4
  import type { WorkflowProjectionDefinition } from "@intx/types/sidecar";
5
5
  import type { ToolPackageManifest } from "@intx/types/tool-packages";
6
6
  import type { WorkflowDefinitionAssetSource, WorkflowDefinitionRegistrySource } from "@intx/types/workflow-sources";
7
- import type { ApprovalSet } from "@intx/workflow-deploy";
7
+ import { type ApprovalSet } from "@intx/workflow-deploy";
8
8
  import { type ResolveAssetAttachmentFn } from "./workflow-closure-resolution.js";
9
9
  import type { SourceTreeReads } from "./workflow-source-closure.js";
10
- import type { SidecarRouter, WorkflowProbeResult } from "./ws/sidecar-handler.js";
10
+ import type { SendProbeArgs, WorkflowProbeResult } from "./ws/sidecar-handler.js";
11
+ type WorkflowProbeRouter = {
12
+ sendProbe(args: SendProbeArgs): Promise<WorkflowProbeResult>;
13
+ };
11
14
  /**
12
15
  * The frozen record an approval writes: the definition's asset selector, the
13
- * approved wire hash (the freeze anchor), the approved grant set, and the
14
- * grant-walk snapshot the run path materializes grants from. The grant set is a
15
- * deterministic projection of the content the hash addresses and rides the
16
- * deploy hand-off in memory; the snapshot is persisted onto the version row so a
17
- * run derives its grants from the frozen walk without re-reading and re-walking
18
- * the workflow's `workflow.json`.
16
+ * approved wire hash (the freeze anchor), the approved surface, and the
17
+ * grant-walk snapshot the run path materializes grants from. The approved
18
+ * surface is a deterministic projection of the content the hash addresses and
19
+ * rides the deploy hand-off in memory; the snapshot is persisted onto the
20
+ * version row so a run derives its grants from the frozen walk without
21
+ * re-reading and re-walking the workflow's `workflow.json`.
22
+ *
23
+ * `approvedGrants` carries both kinds of approved item: the walk's grant-shape
24
+ * strings and the definition's declared grant requirements. The requirements
25
+ * belong in the same record because the run path mints real grant rows from
26
+ * them, so an account of the approval that listed only the walk strings would
27
+ * under-report the authority the definition will actually carry. It is the flat
28
+ * `ApprovalItem` list rather than the gate's partitioned `ApprovalSet` because
29
+ * this record is the input to persistence, and the persisted form is flat.
19
30
  */
20
31
  export type FrozenApproval = {
21
32
  readonly assetId: string;
22
33
  readonly approvedWireHash: string;
23
- readonly approvedGrants: readonly string[];
34
+ readonly approvedGrants: readonly ApprovalItem[];
24
35
  readonly grantSnapshot: GrantWalkSnapshot;
25
36
  };
26
37
  /**
@@ -33,15 +44,17 @@ export type PersistFrozenApprovalFn = (approval: FrozenApproval) => Promise<{
33
44
  }>;
34
45
  /**
35
46
  * The outcome of gating and freezing a probe result. `ok: true` is the frozen
36
- * approval the deploy hand-off consumes. The `ok: false` arms name the two
47
+ * approval the deploy hand-off consumes. The `ok: false` arms name the five
37
48
  * fail-closed paths: a shipped hash that does not match the hub recompute
38
- * (tamper-evidence), and advisory grants the operator did not approve.
49
+ * (tamper-evidence), advisory grants the operator did not approve, declared
50
+ * grant requirements the operator did not approve, a trigger type the runtime
51
+ * does not implement, and an executable step the grant walk left no record for.
39
52
  */
40
53
  export type ProbeGateResult = {
41
54
  readonly ok: true;
42
55
  readonly definitionId: string;
43
56
  readonly approvedWireHash: string;
44
- readonly approvedGrants: ReadonlySet<string>;
57
+ readonly approvedSurface: ApprovalSet;
45
58
  /**
46
59
  * The inert wire projection the freeze hashed. Rides the ok-arm so the
47
60
  * deploy hand-off carries the exact content the frozen hash addresses,
@@ -57,6 +70,54 @@ export type ProbeGateResult = {
57
70
  readonly ok: false;
58
71
  readonly reason: "grants_not_approved";
59
72
  readonly unapprovedGrants: readonly string[];
73
+ } | {
74
+ readonly ok: false;
75
+ readonly reason: "grant_requirements_not_approved";
76
+ /**
77
+ * The declared requirements the operator's approval does not cover, in
78
+ * the order the probe declared them. Named in full so the operator can
79
+ * see exactly which authority the definition asked to delegate.
80
+ */
81
+ readonly unapprovedGrantRequirements: readonly GrantRequirement[];
82
+ } | {
83
+ readonly ok: false;
84
+ readonly reason: "unimplemented_trigger";
85
+ /** The distinct reserved-but-unimplemented trigger types the projection declared. */
86
+ readonly unimplementedTriggerTypes: readonly string[];
87
+ } | {
88
+ readonly ok: false;
89
+ readonly reason: "steps_without_grant_record";
90
+ /** Every executable step the grant-walk snapshot accounts for nothing at. */
91
+ readonly stepsWithoutGrantRecord: readonly StepWithoutGrantRecord[];
92
+ /**
93
+ * One sentence naming every miss. A deploy that trips this is a defect in
94
+ * the deploy path rather than in the author's workflow, so the sentence
95
+ * carries what whoever maintains that path needs: the step, the position,
96
+ * and the record that was absent.
97
+ */
98
+ readonly message: string;
99
+ };
100
+ /**
101
+ * One step the deployment can execute that the probe's grant-walk snapshot
102
+ * carries no record for.
103
+ */
104
+ export type StepWithoutGrantRecord = {
105
+ /** The executable step id with no approved grants behind it. */
106
+ readonly stepId: string;
107
+ /**
108
+ * The chain of step ids the executable walk reached `stepId` through,
109
+ * outermost first and `stepId` itself last. Two nested bodies may
110
+ * legitimately carry the same step id, so the chain -- not the id alone --
111
+ * is what names the position in the closure.
112
+ */
113
+ readonly reachedThrough: readonly string[];
114
+ /**
115
+ * The top-level step whose snapshot record was supposed to account for
116
+ * `stepId`. The capability walk folds every nested body's grants into the
117
+ * record of the top-level step that carries the body, so this names the
118
+ * `perStep` key the absent record would have had.
119
+ */
120
+ readonly recordStepId: string;
60
121
  };
61
122
  /**
62
123
  * Build the production persistence step of the freeze. Records identity through
@@ -71,7 +132,8 @@ export declare function createDbFrozenApprovalWriter(db: DBExecutor): PersistFro
71
132
  /**
72
133
  * Approve exactly the grant surface the probe reports, without a pre-walked
73
134
  * operator `ApprovalSet` to gate against. Under this mode the gate skips the
74
- * per-grant membership check and freezes exactly what the probe advertised.
135
+ * per-grant and per-requirement membership checks and freezes exactly what the
136
+ * probe advertised.
75
137
  *
76
138
  * This is the code-sourced analogue of the live-authored self-approve: the hub
77
139
  * has no live definition to pre-walk, so the probe's advertised grants ARE the
@@ -79,13 +141,13 @@ export declare function createDbFrozenApprovalWriter(db: DBExecutor): PersistFro
79
141
  * still runs and can still fail closed.
80
142
  */
81
143
  export type ApproveProbedGrants = {
82
- readonly mode: "approve-probed";
144
+ readonly kind: "approve-probed";
83
145
  };
84
146
  /**
85
- * How the gate turns the probe's advisory grant set into an approved set.
86
- * Either an explicit operator `ApprovalSet` -- every advertised grant must
87
- * appear in it or the gate fails closed -- or `approve-probed`, which approves
88
- * exactly the surface the probe reported.
147
+ * How the gate turns the probe's advertised surface into an approved one.
148
+ * Either an explicit operator `ApprovalSet` -- every advertised grant and every
149
+ * declared grant requirement must appear in it or the gate fails closed -- or
150
+ * `approve-probed`, which approves exactly the surface the probe reported.
89
151
  */
90
152
  export type ProbeApprovalPolicy = ApprovalSet | ApproveProbedGrants;
91
153
  export type GateAndFreezeArgs = {
@@ -94,9 +156,9 @@ export type GateAndFreezeArgs = {
94
156
  /** The sidecar's inert probe answer: projection, advisory grants, shipped hash. */
95
157
  readonly probeResult: WorkflowProbeResult;
96
158
  /**
97
- * The approval policy. An `ApprovalSet` gates the advisory set against the
98
- * operator-approved grant-shape strings; `approve-probed` approves exactly
99
- * the surface the probe reported.
159
+ * The approval policy. An `ApprovalSet` gates the advisory set and the
160
+ * declared grant requirements against the operator-approved items;
161
+ * `approve-probed` approves exactly the surface the probe reported.
100
162
  */
101
163
  readonly approvals: ProbeApprovalPolicy;
102
164
  /** Persistence step for the freeze; `createDbFrozenApprovalWriter` in production. */
@@ -107,11 +169,19 @@ export type GateAndFreezeArgs = {
107
169
  * inert projection and grant set -- no author code runs here and the capability
108
170
  * walk is never re-run.
109
171
  *
110
- * Fails closed on the two security-load-bearing checks before it writes
172
+ * Fails closed on the three security-load-bearing checks before it writes
111
173
  * anything: the recomputed wire hash must match the hash the sidecar shipped
112
- * (tamper-evidence), and every advisory grant must be operator-approved. Only
113
- * then does it freeze the recomputed hash onto the version row and return the
114
- * approved grant set.
174
+ * (tamper-evidence), every advisory grant must be operator-approved, and every
175
+ * declared grant requirement must be operator-approved. It also refuses a
176
+ * projection whose triggers include a reserved-but-unimplemented type -- not a
177
+ * security check, but the layer a pinned closure cannot carry a stale copy of,
178
+ * so it is where a workflow that could only sit inert is caught -- and one
179
+ * whose executable closure reaches a step the grant walk left no record for --
180
+ * the layer holding both halves of the probe answer at once, and a presence
181
+ * check on those records rather than a check that any record's contents are
182
+ * sufficient.
183
+ * Only then does it freeze the recomputed hash onto the version row and return
184
+ * the approved grant set.
115
185
  */
116
186
  export declare function gateAndFreezeProbeResult(args: GateAndFreezeArgs): Promise<ProbeGateResult>;
117
187
  type InstallAndApproveCommonArgs = {
@@ -126,9 +196,11 @@ type InstallAndApproveCommonArgs = {
126
196
  */
127
197
  readonly approvals: ProbeApprovalPolicy;
128
198
  /** The sidecar router carrying the probe transport. */
129
- readonly router: Pick<SidecarRouter, "sendProbe">;
199
+ readonly router: WorkflowProbeRouter;
130
200
  /** Executor the freeze writes through. */
131
201
  readonly db: DBExecutor;
202
+ /** Optional durable handoff invoked before the approval gate writes. */
203
+ readonly onProbeResult?: (result: WorkflowProbeResult) => Promise<void>;
132
204
  };
133
205
  /** Install a definition published to an npm registry. */
134
206
  export type InstallAndApproveRegistryArgs = InstallAndApproveCommonArgs & {