zigmini 1.0.0-beta.3 → 1.1.0-beta.2

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 (138) hide show
  1. package/README.ko.md +29 -2
  2. package/README.md +29 -2
  3. package/assets/agent/gateway-request-rules.md +9 -2
  4. package/assets/agent/gateway-rules.md +13 -6
  5. package/assets/android/zigmini.apk +0 -0
  6. package/assets/android/zigmini.apk.json +3 -3
  7. package/assets/build.json +1 -1
  8. package/assets/document-reader/document-reader +0 -0
  9. package/assets/document-reader/host-selector +0 -0
  10. package/assets/document-reader/manifest.json +27 -0
  11. package/docs/manual.ko.md +114 -10
  12. package/docs/manual.md +117 -11
  13. package/host/document-reader/DocumentRead.swift +370 -0
  14. package/host/document-reader/HostSelector.swift +192 -0
  15. package/host/document-reader/Protocol.swift +647 -0
  16. package/host/document-reader/main.swift +22 -0
  17. package/host/omo-bridge/agent-aside-tool.ts +119 -0
  18. package/host/omo-bridge/agent-aside.ts +522 -0
  19. package/host/omo-bridge/agent-collaboration-files.ts +282 -0
  20. package/host/omo-bridge/agent-collaboration.ts +385 -0
  21. package/host/omo-bridge/agent-control.ts +400 -28
  22. package/host/omo-bridge/agent-execution.sb +42 -0
  23. package/host/omo-bridge/agent-execution.ts +158 -0
  24. package/host/omo-bridge/agent-tool-adapters.ts +388 -0
  25. package/host/omo-bridge/agent-tool-permissions.ts +96 -0
  26. package/host/omo-bridge/aside-tabs.ts +41 -0
  27. package/host/omo-bridge/attachment-read.ts +100 -0
  28. package/host/omo-bridge/bridge.ts +10 -0
  29. package/host/omo-bridge/managed-call.ts +46 -0
  30. package/host/omo-bridge/task-control.ts +39 -5
  31. package/host/omo-check/patches/attachments-293/README.md +138 -0
  32. package/host/omo-check/patches/attachments-293/document-content.js +234 -0
  33. package/host/omo-check/patches/attachments-293/document-content.types.txt +91 -0
  34. package/host/omo-check/patches/attachments-293/omo-5.1.51-documents.patch +831 -0
  35. package/host/omo-check/patches/attachments-293/omo-5.1.51-gateway-port.manifest.json +471 -0
  36. package/host/omo-check/patches/omo-5.1.51-agent-tool-authority.patch +471 -0
  37. package/host/omo-check/patches/omo-5.1.51-gateway-port.manifest.json +28 -6
  38. package/host/omo-check/patches/omo-5.1.51-memory-receipts.patch +5 -0
  39. package/package.json +5 -3
  40. package/src/agent/api.ts +345 -20
  41. package/src/agent/artifacts.ts +196 -0
  42. package/src/agent/aside-target-routes.ts +369 -0
  43. package/src/agent/aside-targets.ts +215 -0
  44. package/src/agent/collaboration-admission.ts +47 -0
  45. package/src/agent/collaboration-read.ts +117 -0
  46. package/src/agent/collaboration-session.ts +196 -0
  47. package/src/agent/collaboration-types.ts +258 -0
  48. package/src/agent/collaboration.ts +399 -0
  49. package/src/agent/definition.ts +65 -3
  50. package/src/agent/fields.ts +9 -3
  51. package/src/agent/file-core.ts +325 -0
  52. package/src/agent/files.ts +87 -8
  53. package/src/agent/list.ts +3 -3
  54. package/src/agent/managed-call.ts +144 -0
  55. package/src/agent/tool-catalog.ts +184 -0
  56. package/src/agent/tool-delivery.ts +359 -0
  57. package/src/agent/tool-permission-types.ts +81 -0
  58. package/src/agent/tool-permissions.ts +1366 -0
  59. package/src/attachments/api.ts +161 -0
  60. package/src/attachments/document-reader.ts +325 -0
  61. package/src/attachments/host-selector.ts +410 -0
  62. package/src/attachments/managed-binding.ts +83 -0
  63. package/src/attachments/prepare.ts +214 -0
  64. package/src/attachments/selector-port.ts +28 -0
  65. package/src/attachments/service.ts +729 -0
  66. package/src/attachments/store.ts +698 -0
  67. package/src/attachments/types.ts +123 -0
  68. package/src/cli/doctor.ts +30 -10
  69. package/src/decisions.ts +109 -10
  70. package/src/engine/apply-patch.ts +21 -0
  71. package/src/engine/compat.json +2 -1
  72. package/src/engine/install.ts +45 -4
  73. package/src/engine/sandbox-cell.ts +2 -0
  74. package/src/gc.ts +33 -225
  75. package/src/housekeeping/api.ts +163 -0
  76. package/src/housekeeping/audit.ts +61 -0
  77. package/src/housekeeping/collectors/agent-collaboration.ts +33 -0
  78. package/src/housekeeping/collectors/agent-drafts.ts +43 -0
  79. package/src/housekeeping/collectors/agent-file-trash.ts +36 -0
  80. package/src/housekeeping/collectors/agent-trash.ts +28 -0
  81. package/src/housekeeping/collectors/attachment-cleanup.ts +35 -0
  82. package/src/housekeeping/collectors/db-backup.ts +25 -0
  83. package/src/housekeeping/collectors/docker-build-cache.ts +29 -0
  84. package/src/housekeeping/collectors/docker-image.ts +135 -0
  85. package/src/housekeeping/collectors/omo-version.ts +32 -0
  86. package/src/housekeeping/collectors/orphan-test-process.ts +205 -0
  87. package/src/housekeeping/collectors/reconcile-work.ts +55 -0
  88. package/src/housekeeping/collectors/rpc-daemon.ts +39 -0
  89. package/src/housekeeping/collectors/shared.ts +149 -0
  90. package/src/housekeeping/collectors/temp-dir.ts +28 -0
  91. package/src/housekeeping/collectors/update-file.ts +25 -0
  92. package/src/housekeeping/collectors/worktree.ts +193 -0
  93. package/src/housekeeping/engine.ts +125 -0
  94. package/src/housekeeping/reconcile/work.ts +130 -0
  95. package/src/housekeeping/registry.ts +63 -0
  96. package/src/housekeeping/trash.ts +59 -0
  97. package/src/housekeeping/types.ts +31 -0
  98. package/src/hub.ts +1095 -41
  99. package/src/main.ts +228 -30
  100. package/src/message-references.ts +14 -5
  101. package/src/profile-prompt.ts +13 -1
  102. package/src/protocol.ts +41 -6
  103. package/src/security-allow.ts +79 -2
  104. package/src/security-check.ts +1 -1
  105. package/src/store.ts +1946 -23
  106. package/src/test-crash.ts +20 -0
  107. package/src/work-watch.ts +55 -7
  108. package/src/work.ts +192 -3
  109. package/web/DESIGN.md +33 -0
  110. package/web/agents.d.ts +189 -12
  111. package/web/agents.js +898 -39
  112. package/web/app.js +2634 -210
  113. package/web/attachments.d.ts +91 -0
  114. package/web/attachments.js +422 -0
  115. package/web/collaboration.d.ts +87 -0
  116. package/web/collaboration.js +290 -0
  117. package/web/decisions.d.ts +39 -2
  118. package/web/decisions.js +354 -2
  119. package/web/gc.d.ts +26 -5
  120. package/web/gc.js +113 -13
  121. package/web/i18n/agents.d.ts +4 -0
  122. package/web/i18n/agents.js +200 -8
  123. package/web/i18n/attachments.js +78 -0
  124. package/web/i18n/decisions.d.ts +4 -0
  125. package/web/i18n/decisions.js +68 -0
  126. package/web/i18n/gc.d.ts +4 -0
  127. package/web/i18n/gc.js +184 -2
  128. package/web/i18n/main.js +80 -8
  129. package/web/i18n/tour.d.ts +4 -0
  130. package/web/i18n/tour.js +37 -0
  131. package/web/index.html +20 -3
  132. package/web/profile.d.ts +2 -2
  133. package/web/profile.js +11 -6
  134. package/web/style.css +342 -21
  135. package/web/tooltip.d.ts +2 -0
  136. package/web/tooltip.js +12 -0
  137. package/web/tour.d.ts +57 -0
  138. package/web/tour.js +450 -0
package/src/store.ts CHANGED
@@ -1,4 +1,9 @@
1
1
  import { Database } from "bun:sqlite";
2
+ import { AttachmentStore } from "./attachments/store.ts";
3
+ import { AgentToolPermissionError, AgentToolPermissionStore, permissionOutcome, type AgentToolGrantAnswer, type AgentToolRequestInput, type AgentToolRequestRecord, type AnswerResult, type GrantEditInput, type GrantEditResult, type OwnerPrincipal } from "./agent/tool-permissions.ts";
4
+ import type { AgentToolMigrationCard } from "./agent/tool-permissions.ts";
5
+ import { SqliteAsideDecisions } from "./agent/aside-target-routes.ts";
6
+ import type { LegacyScopeItem } from "./agent/definition.ts";
2
7
  import { say, type Text } from "./server-text.ts";
3
8
  import { OMO_CHECK_BASELINE, omoCheckActionable, omoCheckDaytime } from "./omo-check-policy.ts";
4
9
  import { chmodSync, existsSync, mkdirSync } from "node:fs";
@@ -6,8 +11,10 @@ import { join } from "node:path";
6
11
  import { isDeepStrictEqual } from "node:util";
7
12
  import type { ConversationInfo, DecisionAnswerRequest, NotificationMode, ReplyNotification } from "./protocol.ts";
8
13
  import type { NativeTurnStats } from "./turn-stats.ts";
9
- import { SecurityRuntimeError, securityDecision, type AllowedValue, type HeldState, type SecurityApprover, type SecurityHoldRecord, type SecurityIntent, type SecurityReceipt } from "./security-allow.ts";
14
+ import { SecurityRuntimeError, securityDecision, type AllowedValue, type HeldState, type SecurityApprover, type MessageSecurityHoldRecord as SecurityHoldRecord, type SecurityIntent, type SecurityReceipt, type ArtifactSecurityHold, type ArtifactReviewReceipt, type ArtifactReleaseReceipt, type ArtifactReadBinding, type ArtifactReviewSummary } from "./security-allow.ts";
15
+ import { CollaborationStoreError, collaborationDigest, agentRequestTerminal, agentRequestRetryable, AGENT_REQUEST_FINAL_SQL, collaborationNever, collaborationStageDecision, type CollaborationBinding, type AgentCancelReceipt, type CollaborationGate, type CollaborationTarget, type AgentRequestInput, type AgentRequest, type AgentActivation, type AgentReceipt, type AgentDelivery, type AgentResultInput, type AgentResult, type AgentArtifact, type AgentArtifactStage, type AgentStageInput, type ArtifactGrant, type CollaborationPermissionOutcome, type CollaborationIntent } from "./agent/collaboration-types.ts";
10
16
  import { findPrivate, normalizeTerm, PUBLIC_ACCOUNT } from "./security/terms.ts";
17
+ import { testCrashPoint } from "./test-crash.ts";
11
18
 
12
19
  export interface SecurityBaselineTerm {
13
20
  readonly id: string;
@@ -35,7 +42,24 @@ export interface StoredEvent {
35
42
 
36
43
  export type MainInputSource =
37
44
  | { origin: "user"; input_ref: { message_seq: number } }
38
- | { origin: "work_update"; input_ref: { update_ids: string[]; notification_id: string } };
45
+ | { origin: "work_update"; input_ref: { update_ids: string[]; notification_id: string } }
46
+ | { readonly origin: "agent_collaboration"; readonly input_ref: {
47
+ readonly request_id: string; readonly activation_id: string; readonly direction: AgentActivation["direction"]; readonly message_seq: number } }
48
+ | { readonly origin: "agent_tool_permission"; readonly input_ref: CollaborationPermissionOutcome & { readonly activation_id: string } }
49
+ | { readonly origin: "collaboration_artifact_read"; readonly input_ref: {
50
+ readonly holdId: string; readonly readOperationId: string; readonly continuationId: string; readonly reviewedScopeDigest: string } };
51
+
52
+ /** Preserve the discriminant when projecting a persisted input to a turn source. */
53
+ export function mainInputSource(input: MainInputSource): MainInputSource {
54
+ switch (input.origin) {
55
+ case "user": return { origin: input.origin, input_ref: input.input_ref };
56
+ case "work_update": return { origin: input.origin, input_ref: input.input_ref };
57
+ case "agent_collaboration": return { origin: input.origin, input_ref: input.input_ref };
58
+ case "agent_tool_permission": return { origin: input.origin, input_ref: input.input_ref };
59
+ case "collaboration_artifact_read": return { origin: input.origin, input_ref: input.input_ref };
60
+ default: return collaborationNever(input);
61
+ }
62
+ }
39
63
 
40
64
  export type MainInput = MainInputSource & {
41
65
  session_path: string;
@@ -44,6 +68,7 @@ export type MainInput = MainInputSource & {
44
68
  input_id?: string;
45
69
  wire_message?: string;
46
70
  native_identity?: boolean;
71
+ readonly authored_binding?: CollaborationBinding;
47
72
  };
48
73
 
49
74
  export interface MainTurn {
@@ -263,7 +288,7 @@ function terminalWork(status: WorkStatus): boolean {
263
288
  export type DecisionMethod = "select" | "confirm" | "input" | "editor" | "question" | "unknown";
264
289
  export type DecisionBinding = "live" | "expired" | "closed" | "unknown";
265
290
  export type DecisionStatus = "pending" | "submitted" | "delivered" | "needs_review" | "closed";
266
- export type DecisionRoute = "live_response" | "follow_up_command";
291
+ export type DecisionRoute = "live_response" | "follow_up_command" | "collaboration_stage";
267
292
 
268
293
  export interface Decision {
269
294
  readonly id: string;
@@ -353,13 +378,26 @@ export interface DecisionClaim {
353
378
  /**
354
379
  * A required question (no idle deadline) that its owner's turn, main's or a work's, is blocked on. Input queued behind
355
380
  * that turn (main chat, or a work instruction sent as a steer) cannot reach the owner before the answer, so it does not
356
- * change the question's context, and a recheck must not be queued there either.
381
+ * change the question's context, and a recheck must not be queued there either. Bug 236: a queued work instruction ends
382
+ * the wait instead (releasedWorkQuestion).
357
383
  */
358
384
  export function blockedQuestion(decision: Pick<Decision, "method" | "permission" | "bindingState" | "deadlineAt">): boolean {
359
385
  return decision.method === "question" && !decision.permission &&
360
386
  decision.bindingState === "live" && decision.deadlineAt === null;
361
387
  }
362
388
 
389
+ /**
390
+ * Bug 236 (operator decision 2026-11-05): a work's required question whose native wait the gateway ended because a newer
391
+ * instruction was queued behind it. The question stays open in the inbox, takes its answer as a follow-up command, and a
392
+ * newer instruction does not change its context: the step that depends on the answer still waits for it.
393
+ */
394
+ export const INSTRUCTION_RELEASED = "instruction_released";
395
+
396
+ export function releasedWorkQuestion(decision: Pick<Decision, "workId" | "method" | "permission" | "bindingState" | "reason">): boolean {
397
+ return decision.workId !== null && decision.method === "question" && !decision.permission &&
398
+ decision.bindingState === "closed" && decision.reason === INSTRUCTION_RELEASED;
399
+ }
400
+
363
401
  /** blockedQuestion owned by main. */
364
402
  export function blockedMainQuestion(decision: Pick<Decision, "workId" | "method" | "permission" | "bindingState" | "deadlineAt">): boolean {
365
403
  return decision.workId === null && blockedQuestion(decision);
@@ -377,7 +415,7 @@ export interface DecisionDismissal {
377
415
  }
378
416
 
379
417
  export class DecisionStoreError extends Error {
380
- constructor(readonly code: "security_endpoint_required" | "decision_not_found" | "decision_conflict" | "submission_conflict" | "source_conflict" | "invalid_decision" | "invalid_decision_answer" | "invalid_decision_cursor" | "invalid_decision_transition", readonly decision: Decision | null = null) {
418
+ constructor(readonly code: "security_endpoint_required" | "decision_not_found" | "decision_conflict" | "submission_conflict" | "source_conflict" | "invalid_decision" | "invalid_decision_answer" | "invalid_decision_cursor" | "invalid_decision_transition" | "tool_grant_response_required" | "tool_grant_principal_required", readonly decision: Decision | null = null) {
381
419
  super(code);
382
420
  }
383
421
  }
@@ -445,7 +483,7 @@ export function replacementQuestionPayload(payload: Record<string, unknown>): Re
445
483
  * async_wait_closed (a waitForAnswer false question whose native wait ended) and recheck_incomplete (a recheck turn
446
484
  * ended without a replacement; the question stands as asked). A permission is never resumed (submitDecision, info).
447
485
  */
448
- export const RESUMABLE_CLOSED: ReadonlySet<string> = new Set(["report_only", "main_released", "async_wait_closed", "recheck_incomplete"]);
486
+ export const RESUMABLE_CLOSED: ReadonlySet<string> = new Set(["report_only", "main_released", INSTRUCTION_RELEASED, "async_wait_closed", "recheck_incomplete"]);
449
487
 
450
488
  /** Checks structural completeness, not the semantic appropriateness of a historical answer. */
451
489
  function completeDecisionAnswer(method: DecisionMethod, payload: unknown, response: unknown): boolean {
@@ -478,19 +516,24 @@ function completeDecisionAnswer(method: DecisionMethod, payload: unknown, respon
478
516
  }
479
517
  }
480
518
 
519
+ // allow: SIZE_OK - this existing Store owns the shared SQLite writer; T2 explicitly owns only its scoped transaction hunks.
481
520
  export class Store {
482
521
  private readonly db: Database;
522
+ readonly attachments: AttachmentStore;
483
523
  private readonly securityReceiptListeners = new Set<() => void>();
484
524
 
485
525
  constructor(dataDir: string) {
486
526
  mkdirSync(dataDir, { recursive: true, mode: 0o700 });
487
527
  chmodSync(dataDir, 0o700);
488
528
  this.db = new Database(join(dataDir, "gateway.sqlite"), { create: true });
529
+ // Independent writers serialize admission instead of failing immediately during another short transaction.
530
+ this.db.exec("PRAGMA busy_timeout = 5000;");
489
531
  this.db.exec("PRAGMA journal_mode = WAL;");
490
532
  // Linux builds of bun:sqlite default to synchronous=FULL, which fsyncs every appended event (~80x slower,
491
533
  // and a stalled disk stalls the gateway). WAL + NORMAL cannot corrupt the database; a power cut may drop
492
534
  // only the last few commits. This also matches the macOS default.
493
535
  this.db.exec("PRAGMA synchronous = NORMAL;");
536
+ this.db.exec("PRAGMA busy_timeout = 5000;");
494
537
  this.db.exec(`
495
538
  CREATE TABLE IF NOT EXISTS conversations (
496
539
  id TEXT PRIMARY KEY,
@@ -675,6 +718,14 @@ export class Store {
675
718
  this.db.close();
676
719
  throw error;
677
720
  }
721
+ this.toolPermissions = new AgentToolPermissionStore(this.db);
722
+ this.asideTargets = new SqliteAsideDecisions(this.db, (id) => this.getDecision(id));
723
+ try {
724
+ this.toolPermissions.migrate();
725
+ } catch (error) {
726
+ this.db.close();
727
+ throw error;
728
+ }
678
729
  this.db.exec(`
679
730
  CREATE TABLE IF NOT EXISTS security_baseline_terms (
680
731
  id TEXT PRIMARY KEY, value TEXT COLLATE BINARY NOT NULL UNIQUE, data TEXT NOT NULL
@@ -690,12 +741,1628 @@ export class Store {
690
741
  id TEXT PRIMARY KEY, source_key TEXT UNIQUE, intent_submission TEXT UNIQUE, data TEXT NOT NULL
691
742
  );
692
743
  `);
744
+ this.attachments = new AttachmentStore(this.db, (conv, data, receipt) => this.acceptMainUser(conv, data, receipt));
745
+ try {
746
+ this.db.transaction(() => {
747
+ const schema = this.db.query<{ sql: string }, []>("SELECT sql FROM sqlite_master WHERE type='table' AND name='decisions'").get();
748
+ if (schema && !schema.sql.includes("'collaboration_stage'")) {
749
+ const indexes = this.db.query<{ sql: string }, []>("SELECT sql FROM sqlite_master WHERE type='index' AND tbl_name='decisions' AND sql IS NOT NULL").all();
750
+ this.db.exec("ALTER TABLE decisions RENAME TO collaboration_legacy_decisions");
751
+ this.db.exec(schema.sql.replace("'live_response','follow_up_command'", "'live_response','follow_up_command','collaboration_stage'"));
752
+ this.db.exec("INSERT INTO decisions SELECT * FROM collaboration_legacy_decisions; DROP TABLE collaboration_legacy_decisions");
753
+ for (const index of indexes) this.db.exec(index.sql);
754
+ }
755
+ // JSON is the typed record; generated columns cannot drift from its relational keys or revisions.
756
+ this.db.exec(`
757
+ CREATE TABLE IF NOT EXISTS agent_lifecycle (agent_id TEXT PRIMARY KEY, epoch INTEGER NOT NULL CHECK(epoch > 0));
758
+ CREATE TABLE IF NOT EXISTS agent_requests (
759
+ id TEXT PRIMARY KEY, data TEXT NOT NULL CHECK(json_valid(data)),
760
+ root_id TEXT GENERATED ALWAYS AS (json_extract(data,'$.rootId')) STORED NOT NULL REFERENCES agent_requests(id),
761
+ parent_id TEXT GENERATED ALWAYS AS (json_extract(data,'$.parentId')) STORED REFERENCES agent_requests(id),
762
+ requester_conv TEXT GENERATED ALWAYS AS (json_extract(data,'$.requesterConv')) STORED NOT NULL,
763
+ requester_agent TEXT GENERATED ALWAYS AS (json_extract(data,'$.requesterAgent')) STORED,
764
+ target_conv TEXT GENERATED ALWAYS AS (json_extract(data,'$.targetConv')) STORED NOT NULL,
765
+ target_agent TEXT GENERATED ALWAYS AS (json_extract(data,'$.targetAgent')) STORED NOT NULL,
766
+ kind TEXT GENERATED ALWAYS AS (json_extract(data,'$.kind')) STORED CHECK(kind IN ('task','question')),
767
+ payload_json TEXT GENERATED ALWAYS AS (json_extract(data,'$.payload')) STORED NOT NULL,
768
+ payload_sha TEXT GENERATED ALWAYS AS (json_extract(data,'$.payloadSha')) STORED NOT NULL,
769
+ submission_key TEXT GENERATED ALWAYS AS (json_extract(data,'$.submissionKey')) STORED NOT NULL UNIQUE,
770
+ fingerprint TEXT GENERATED ALWAYS AS (json_extract(data,'$.fingerprint')) STORED NOT NULL,
771
+ state TEXT GENERATED ALWAYS AS (json_extract(data,'$.state')) STORED NOT NULL CHECK(state IN ('queued','running','waiting_peer','waiting_owner','result_preparing','completed','failed','interrupted','cancel_requested','cancelled','unresolved_delivery','version_conflict')),
772
+ revision INTEGER GENERATED ALWAYS AS (json_extract(data,'$.revision')) STORED NOT NULL CHECK(revision > 0),
773
+ created_at TEXT GENERATED ALWAYS AS (json_extract(data,'$.createdAt')) STORED NOT NULL,
774
+ result_id TEXT GENERATED ALWAYS AS (json_extract(data,'$.resultId')) STORED,
775
+ cancel_reason TEXT GENERATED ALWAYS AS (json_extract(data,'$.cancelReason')) STORED,
776
+ CHECK(id = json_extract(data,'$.id'))
777
+ );
778
+ CREATE INDEX IF NOT EXISTS agent_requests_target ON agent_requests(target_conv,state,created_at);
779
+ CREATE INDEX IF NOT EXISTS agent_requests_tree ON agent_requests(root_id,parent_id);
780
+ CREATE UNIQUE INDEX IF NOT EXISTS agent_requests_child ON agent_requests(parent_id) WHERE parent_id IS NOT NULL AND state NOT IN ${AGENT_REQUEST_FINAL_SQL};
781
+ CREATE TABLE IF NOT EXISTS agent_activations (
782
+ id TEXT PRIMARY KEY, data TEXT NOT NULL CHECK(json_valid(data)),
783
+ request_id TEXT GENERATED ALWAYS AS (json_extract(data,'$.requestId')) STORED NOT NULL REFERENCES agent_requests(id),
784
+ direction TEXT GENERATED ALWAYS AS (json_extract(data,'$.direction')) STORED NOT NULL CHECK(direction IN ('request','reply','owner_answer')),
785
+ ordinal INTEGER GENERATED ALWAYS AS (json_extract(data,'$.ordinal')) STORED NOT NULL,
786
+ input_id TEXT GENERATED ALWAYS AS (json_extract(data,'$.inputId')) STORED NOT NULL UNIQUE,
787
+ input_seq INTEGER GENERATED ALWAYS AS (json_extract(data,'$.inputSeq')) STORED REFERENCES events(seq),
788
+ target_conv TEXT GENERATED ALWAYS AS (json_extract(data,'$.targetConv')) STORED NOT NULL,
789
+ session_path TEXT GENERATED ALWAYS AS (json_extract(data,'$.binding.sessionPath')) STORED,
790
+ durable_session_id TEXT GENERATED ALWAYS AS (json_extract(data,'$.binding.durableSessionId')) STORED,
791
+ gateway_generation TEXT GENERATED ALWAYS AS (json_extract(data,'$.binding.gatewayGeneration')) STORED,
792
+ definition_sha TEXT GENERATED ALWAYS AS (json_extract(data,'$.binding.definitionSha')) STORED,
793
+ state TEXT GENERATED ALWAYS AS (json_extract(data,'$.state')) STORED NOT NULL CHECK(state IN ('queued','attempted','acknowledged','consumed','rejected','unknown')),
794
+ revision INTEGER GENERATED ALWAYS AS (json_extract(data,'$.revision')) STORED NOT NULL CHECK(revision > 0),
795
+ consumed_entry TEXT GENERATED ALWAYS AS (json_extract(data,'$.consumedEntry')) STORED,
796
+ quiesced INTEGER GENERATED ALWAYS AS (json_extract(data,'$.quiesced')) STORED NOT NULL,
797
+ UNIQUE(request_id,direction,ordinal), CHECK(id = json_extract(data,'$.id'))
798
+ );
799
+ CREATE TABLE IF NOT EXISTS agent_results (
800
+ id TEXT PRIMARY KEY, data TEXT NOT NULL CHECK(json_valid(data)),
801
+ request_id TEXT GENERATED ALWAYS AS (json_extract(data,'$.requestId')) STORED NOT NULL UNIQUE REFERENCES agent_requests(id),
802
+ activation_id TEXT GENERATED ALWAYS AS (json_extract(data,'$.activationId')) STORED NOT NULL REFERENCES agent_activations(id),
803
+ submission_key TEXT GENERATED ALWAYS AS (json_extract(data,'$.submissionKey')) STORED NOT NULL UNIQUE,
804
+ payload_sha TEXT GENERATED ALWAYS AS (json_extract(data,'$.payloadSha')) STORED NOT NULL,
805
+ text TEXT GENERATED ALWAYS AS (json_extract(data,'$.text')) STORED NOT NULL,
806
+ outcome TEXT GENERATED ALWAYS AS (json_extract(data,'$.outcome')) STORED CHECK(outcome IN ('completed','failed')),
807
+ manifest_json TEXT GENERATED ALWAYS AS (json_extract(data,'$.manifest')) STORED NOT NULL,
808
+ committed_at TEXT GENERATED ALWAYS AS (json_extract(data,'$.committedAt')) STORED NOT NULL
809
+ );
810
+ CREATE TABLE IF NOT EXISTS agent_artifacts (
811
+ id TEXT PRIMARY KEY, data TEXT NOT NULL CHECK(json_valid(data)),
812
+ producer_conv TEXT GENERATED ALWAYS AS (json_extract(data,'$.producerConv')) STORED NOT NULL,
813
+ request_id TEXT GENERATED ALWAYS AS (json_extract(data,'$.requestId')) STORED REFERENCES agent_requests(id),
814
+ sha256 TEXT GENERATED ALWAYS AS (json_extract(data,'$.sha256')) STORED NOT NULL,
815
+ bytes INTEGER GENERATED ALWAYS AS (json_extract(data,'$.bytes')) STORED NOT NULL CHECK(bytes BETWEEN 0 AND 10485760),
816
+ media_type TEXT GENERATED ALWAYS AS (json_extract(data,'$.mediaType')) STORED NOT NULL,
817
+ original_name TEXT GENERATED ALWAYS AS (json_extract(data,'$.originalName')) STORED NOT NULL,
818
+ sealed_name TEXT GENERATED ALWAYS AS (json_extract(data,'$.sealedName')) STORED NOT NULL,
819
+ state TEXT GENERATED ALWAYS AS (json_extract(data,'$.state')) STORED CHECK(state IN ('staged','committed','trashed')),
820
+ lifecycle_epoch INTEGER GENERATED ALWAYS AS (json_extract(data,'$.lifecycleEpoch')) STORED NOT NULL,
821
+ storage_class TEXT GENERATED ALWAYS AS (json_extract(data,'$.storageClass')) STORED CHECK(storage_class IN ('source_copy','scoped_document')),
822
+ stage_operation_key TEXT GENERATED ALWAYS AS (json_extract(data,'$.stageOperationKey')) STORED NOT NULL UNIQUE,
823
+ stage_payload_sha TEXT GENERATED ALWAYS AS (json_extract(data,'$.stagePayloadSha')) STORED NOT NULL,
824
+ provenance_json TEXT GENERATED ALWAYS AS (json_extract(data,'$.provenance')) STORED NOT NULL
825
+ );
826
+ CREATE TABLE IF NOT EXISTS agent_artifact_grants (
827
+ grant_id TEXT PRIMARY KEY, data TEXT NOT NULL CHECK(json_valid(data)),
828
+ artifact_id TEXT GENERATED ALWAYS AS (json_extract(data,'$.artifactId')) STORED NOT NULL REFERENCES agent_artifacts(id),
829
+ grantee_conv TEXT GENERATED ALWAYS AS (json_extract(data,'$.granteeConv')) STORED NOT NULL,
830
+ request_id TEXT GENERATED ALWAYS AS (json_extract(data,'$.requestId')) STORED NOT NULL REFERENCES agent_requests(id),
831
+ permission TEXT GENERATED ALWAYS AS (json_extract(data,'$.permission')) STORED CHECK(permission IN ('read','transfer')),
832
+ revoked INTEGER GENERATED ALWAYS AS (json_extract(data,'$.revoked')) STORED NOT NULL CHECK(revoked IN (0,1)),
833
+ revision INTEGER GENERATED ALWAYS AS (json_extract(data,'$.revision')) STORED NOT NULL CHECK(revision > 0),
834
+ grantee_lifecycle_epoch INTEGER GENERATED ALWAYS AS (json_extract(data,'$.granteeLifecycleEpoch')) STORED NOT NULL,
835
+ recipient_authority_epoch INTEGER GENERATED ALWAYS AS (json_extract(data,'$.recipientAuthorityEpoch')) STORED,
836
+ UNIQUE(artifact_id,grantee_conv,request_id,permission)
837
+ );
838
+ CREATE TABLE IF NOT EXISTS agent_artifact_stages (
839
+ transfer_id TEXT PRIMARY KEY, data TEXT NOT NULL CHECK(json_valid(data)),
840
+ operation_key TEXT GENERATED ALWAYS AS (json_extract(data,'$.operationKey')) STORED NOT NULL UNIQUE,
841
+ payload_sha TEXT GENERATED ALWAYS AS (json_extract(data,'$.payloadSha')) STORED NOT NULL,
842
+ source_ref_json TEXT GENERATED ALWAYS AS (json_extract(data,'$.source')) STORED NOT NULL,
843
+ target_ref_json TEXT GENERATED ALWAYS AS (json_extract(data,'$.target')) STORED NOT NULL,
844
+ scope_digest TEXT GENERATED ALWAYS AS (json_extract(data,'$.scopeDigest')) STORED NOT NULL,
845
+ owner_principal_ref TEXT GENERATED ALWAYS AS (json_extract(data,'$.ownerPrincipalRef')) STORED NOT NULL,
846
+ source_input_id TEXT GENERATED ALWAYS AS (json_extract(data,'$.sourceBinding.inputId')) STORED NOT NULL,
847
+ source_activation_id TEXT GENERATED ALWAYS AS (json_extract(data,'$.sourceBinding.activationId')) STORED,
848
+ origin_request_id TEXT GENERATED ALWAYS AS (json_extract(data,'$.originRequestId')) STORED REFERENCES agent_requests(id),
849
+ decision_id TEXT GENERATED ALWAYS AS (json_extract(data,'$.decisionId')) STORED UNIQUE REFERENCES decisions(id),
850
+ decision_revision INTEGER GENERATED ALWAYS AS (json_extract(data,'$.decisionRevision')) STORED,
851
+ continuation_id TEXT GENERATED ALWAYS AS (json_extract(data,'$.continuationId')) STORED NOT NULL UNIQUE,
852
+ state TEXT GENERATED ALWAYS AS (json_extract(data,'$.state')) STORED CHECK(state IN ('awaiting_transfer_authority','ready','staging','staged','denied','cancelled','invalidated','unknown')),
853
+ revision INTEGER GENERATED ALWAYS AS (json_extract(data,'$.revision')) STORED NOT NULL CHECK(revision > 0),
854
+ artifact_id TEXT GENERATED ALWAYS AS (json_extract(data,'$.artifactId')) STORED UNIQUE REFERENCES agent_artifacts(id),
855
+ authority_ref TEXT GENERATED ALWAYS AS (json_extract(data,'$.authority')) STORED,
856
+ export_receipt TEXT GENERATED ALWAYS AS (json_extract(data,'$.exportReceipt')) STORED,
857
+ error_code TEXT GENERATED ALWAYS AS (json_extract(data,'$.errorCode')) STORED
858
+ );
859
+ CREATE TABLE IF NOT EXISTS agent_question_links (
860
+ decision_id TEXT PRIMARY KEY REFERENCES decisions(id), request_id TEXT REFERENCES agent_requests(id),
861
+ activation_id TEXT REFERENCES agent_activations(id), transfer_id TEXT REFERENCES agent_artifact_stages(transfer_id),
862
+ read_operation_id TEXT, origin_input_seq INTEGER REFERENCES events(seq)
863
+ );
864
+ CREATE UNIQUE INDEX IF NOT EXISTS agent_collaboration_operation ON events(json_extract(data,'$.key')) WHERE type='agent.collaboration.operation';
865
+ `);
866
+ // An index built with an older final-state list is rebuilt; the current list only frees more rows, so it cannot fail.
867
+ const child = this.db.query<{ sql: string }, []>("SELECT sql FROM sqlite_master WHERE type='index' AND name='agent_requests_child'").get();
868
+ if (child && !child.sql.includes(`state NOT IN ${AGENT_REQUEST_FINAL_SQL}`)) {
869
+ this.db.exec(`DROP INDEX agent_requests_child; CREATE UNIQUE INDEX agent_requests_child ON agent_requests(parent_id) WHERE parent_id IS NOT NULL AND state NOT IN ${AGENT_REQUEST_FINAL_SQL}`);
870
+ }
871
+ }).immediate();
872
+ this.attachments.migrate();
873
+ } catch (error) {
874
+ this.db.close();
875
+ throw error;
876
+ }
877
+ this.db.exec("PRAGMA foreign_keys = ON;");
693
878
  for (const suffix of ["", "-wal", "-shm"]) {
694
879
  const path = join(dataDir, `gateway.sqlite${suffix}`);
695
880
  if (existsSync(path)) chmodSync(path, 0o600);
696
881
  }
697
882
  }
698
883
 
884
+ ensureAgentLifecycle(agentId: string): number {
885
+ this.db.query("INSERT OR IGNORE INTO agent_lifecycle VALUES (?, 1)").run(agentId);
886
+ const row = this.db.query<{ epoch: number }, [string]>("SELECT epoch FROM agent_lifecycle WHERE agent_id=?").get(agentId);
887
+ if (!row) throw new CollaborationStoreError("agent_not_found", 404);
888
+ return row.epoch;
889
+ }
890
+
891
+ /** A cancelled request's undispatched inputs are definitely unsent: they close as rejected and never dispatch. */
892
+ private closeQueuedAgentInputs(requestId: string): void {
893
+ for (const row of this.db.query<{ data: string }, [string]>("SELECT data FROM agent_activations WHERE request_id=? AND state='queued'").all(requestId)) {
894
+ const activation: AgentActivation = JSON.parse(row.data);
895
+ if (activation.direction === "reply") continue;
896
+ this.writeAgentActivation({ ...activation, state: "rejected", quiesced: true, revision: activation.revision + 1 }, activation.revision);
897
+ }
898
+ }
899
+
900
+ /**
901
+ * cancel_requested becomes cancelled only when its own executions are quiescent and every child is terminal; the
902
+ * check then repeats for the parent that waited on it. Anything still uncertain stays cancel_requested.
903
+ */
904
+ private settleCancelledAgentRequest(requestId: string): void {
905
+ let current = this.getAgentRequest(requestId);
906
+ while (current && current.state === "cancel_requested") {
907
+ const id = current.id;
908
+ const running = this.db.query<{ data: string }, []>("SELECT data FROM agent_activations WHERE state NOT IN ('queued','rejected') AND quiesced=0").all()
909
+ .some(row => this.agentExecutionRequest(JSON.parse(row.data) as AgentActivation)?.id === id);
910
+ if (running || this.db.query(`SELECT id FROM agent_requests WHERE parent_id=? AND state NOT IN ${AGENT_REQUEST_FINAL_SQL}`).get(id)) return;
911
+ this.replyWithoutResult(this.writeAgentRequest({ ...current, state: "cancelled", revision: current.revision + 1 }, current.revision), current.cancelReason ?? "cancelled");
912
+ current = current.parentId ? this.getAgentRequest(current.parentId) : null;
913
+ }
914
+ }
915
+
916
+ /** Executions to interrupt cooperatively for requests left in cancel_requested: conversation and activation of each. */
917
+ cancelRequestedExecutions(): readonly { readonly conv: string; readonly activationId: string }[] {
918
+ return this.db.query<{ data: string }, []>("SELECT data FROM agent_activations WHERE state IN ('attempted','acknowledged','consumed') AND quiesced=0").all()
919
+ .map(row => JSON.parse(row.data) as AgentActivation)
920
+ .filter(activation => this.agentExecutionRequest(activation)?.state === "cancel_requested")
921
+ .map(activation => ({ conv: activation.targetConv, activationId: activation.id }));
922
+ }
923
+
924
+ /** Delete waits while the conversation owes or holds collaboration work: open requests, undelivered inputs or uncertain runs. */
925
+ agentCollaborationBusy(convId: string): boolean {
926
+ return this.db.query(`SELECT 1 FROM agent_requests WHERE (requester_conv=?1 OR target_conv=?1)
927
+ AND state NOT IN ${AGENT_REQUEST_FINAL_SQL}
928
+ UNION ALL SELECT 1 FROM agent_activations WHERE target_conv=?1 AND quiesced=0 AND state IN ('queued','attempted','acknowledged','consumed','unknown') LIMIT 1`).get(convId) !== null;
929
+ }
930
+
931
+ /**
932
+ * After the native connection was lost, an input that crossed the RPC boundary without consumption evidence is
933
+ * uncertain. It is never replayed: the activation becomes unknown and its request unresolved_delivery, which only an
934
+ * evidence recheck may resolve.
935
+ */
936
+ markUncertainAgentDeliveries(): number {
937
+ return this.db.transaction(() => {
938
+ let marked = 0;
939
+ for (const row of this.db.query<{ data: string }, []>("SELECT data FROM agent_activations WHERE state IN ('attempted','acknowledged') AND quiesced=0").all()) {
940
+ const activation: AgentActivation = JSON.parse(row.data);
941
+ this.writeAgentActivation({ ...activation, state: "unknown", revision: activation.revision + 1 }, activation.revision);
942
+ const request = this.agentExecutionRequest(activation) ?? this.getAgentRequest(activation.requestId);
943
+ if (request && !agentRequestTerminal(request.state) && request.state !== "cancel_requested") {
944
+ this.writeAgentRequest({ ...request, state: "unresolved_delivery", revision: request.revision + 1 }, request.revision);
945
+ }
946
+ this.appendEvent(activation.targetConv, "agent.collaboration.delivery_unknown", { activationId: activation.id, requestId: request?.id ?? null });
947
+ marked += 1;
948
+ }
949
+ return marked;
950
+ }).immediate();
951
+ }
952
+
953
+ advanceAgentLifecycle(agentId: string, expectedEpoch: number): number {
954
+ if (this.db.query("UPDATE agent_lifecycle SET epoch=epoch+1 WHERE agent_id=? AND epoch=?").run(agentId, expectedEpoch).changes !== 1) {
955
+ throw new CollaborationStoreError("lifecycle_conflict");
956
+ }
957
+ return expectedEpoch + 1;
958
+ }
959
+
960
+ /** The Hub records its current binding, not a model-supplied identity. No session is opened here. */
961
+ recordAgentBinding(binding: CollaborationBinding): void {
962
+ this.db.transaction(() => {
963
+ const current = this.agentBinding(binding.convId);
964
+ const conv = this.getConversation(binding.convId);
965
+ if (!conv || conv.threadId !== binding.sessionPath || (conv.agentId ?? null) !== binding.agentId ||
966
+ (current && current.ownerInputRevision > binding.ownerInputRevision)) throw new CollaborationStoreError("binding_conflict");
967
+ if (binding.agentId !== null && this.ensureAgentLifecycle(binding.agentId) !== binding.lifecycleEpoch) throw new CollaborationStoreError("lifecycle_conflict");
968
+ if (current && isDeepStrictEqual(current, binding)) return;
969
+ if (current) {
970
+ if (current.activationId !== null && binding.activationId === null && binding.ownerInputRevision > current.ownerInputRevision) {
971
+ const activation = this.getAgentActivation(current.activationId);
972
+ const request = activation && this.agentExecutionRequest(activation);
973
+ if (request && !agentRequestTerminal(request.state) && request.state !== "cancel_requested") {
974
+ this.replyWithoutResult(this.writeAgentRequest({ ...request, state: "interrupted", revision: request.revision + 1 }, request.revision), "owner_input");
975
+ }
976
+ }
977
+ for (const row of this.db.query<{ data: string }, [string]>("SELECT data FROM security_holds WHERE json_extract(data,'$.source')='collaboration_artifact' AND json_extract(data,'$.readScope.granteeConv')=?").all(binding.convId)) {
978
+ const hold: ArtifactSecurityHold = JSON.parse(row.data);
979
+ const intendedRead = binding.readContinuationId === hold.continuation.id && hold.continuation.state === "queued" &&
980
+ binding.ownerInputRevision === hold.continuation.originOwnerInputRevision;
981
+ if (!intendedRead && hold.continuation.releaseReceiptId === null && hold.state !== "invalidated") {
982
+ this.writeArtifactHold({ ...hold, revision: hold.revision + 1, state: "invalidated", invalidationReason: "binding_changed",
983
+ continuation: { ...hold.continuation, state: "invalidated" } });
984
+ }
985
+ }
986
+ for (const row of this.db.query<{ data: string }, [string]>("SELECT data FROM agent_artifact_stages WHERE json_extract(data,'$.sourceBinding.convId')=? AND state IN ('awaiting_transfer_authority','ready','staging','staged')").all(binding.convId)) {
987
+ const stage: AgentArtifactStage = JSON.parse(row.data);
988
+ if (stage.artifactId && this.agentArtifact(stage.artifactId)?.requestId !== null) continue;
989
+ this.db.query("UPDATE agent_artifact_stages SET data=? WHERE transfer_id=?").run(JSON.stringify({ ...stage,
990
+ state: stage.state === "staging" ? "unknown" : "invalidated", revision: stage.revision + 1, errorCode: "binding_changed" }), stage.transferId);
991
+ }
992
+ }
993
+ this.appendEvent(binding.convId, "agent.collaboration.binding", binding);
994
+ }).immediate();
995
+ }
996
+
997
+ agentBinding(conv: string): CollaborationBinding | null {
998
+ const row = this.db.query<{ data: string }, [string]>("SELECT data FROM events WHERE conv=? AND type='agent.collaboration.binding' ORDER BY seq DESC LIMIT 1").get(conv);
999
+ return row ? JSON.parse(row.data) : null;
1000
+ }
1001
+
1002
+ private assertAgentBinding(binding: CollaborationBinding): void {
1003
+ if (!isDeepStrictEqual(this.agentBinding(binding.convId), binding) || this.getConversation(binding.convId)?.threadId !== binding.sessionPath) throw new CollaborationStoreError("binding_conflict");
1004
+ if (binding.agentId !== null && this.ensureAgentLifecycle(binding.agentId) !== binding.lifecycleEpoch) throw new CollaborationStoreError("lifecycle_conflict");
1005
+ }
1006
+
1007
+ private agentTarget(agentId: string): CollaborationTarget {
1008
+ const conv = this.db.query<{ id: string }, [string]>("SELECT id FROM conversations WHERE agent_id=? AND hidden=0").get(agentId);
1009
+ if (!conv) throw new CollaborationStoreError("agent_not_found", 404);
1010
+ return { agentId, convId: conv.id, lifecycleEpoch: this.ensureAgentLifecycle(agentId) };
1011
+ }
1012
+
1013
+ private checkAgentAccess(gate: CollaborationGate, binding: CollaborationBinding, target: CollaborationTarget): number | undefined {
1014
+ this.assertAgentBinding(binding);
1015
+ if (!isDeepStrictEqual(this.agentTarget(target.agentId), target)) throw new CollaborationStoreError("lifecycle_conflict");
1016
+ gate.assertAccess(this.db, binding, target);
1017
+ switch (gate.mode) {
1018
+ case "standalone": return undefined;
1019
+ case "authority": {
1020
+ for (const actor of [target, ...(binding.agentId === null ? [] : [{ ...binding, agentId: binding.agentId }])]) {
1021
+ const access = gate.access(this.db, actor.agentId);
1022
+ if (access.accessState !== "open") throw new CollaborationStoreError("agent_access_blocked", 403);
1023
+ if (access.lifecycleEpoch !== actor.lifecycleEpoch) throw new CollaborationStoreError("lifecycle_conflict");
1024
+ }
1025
+ return gate.access(this.db, target.agentId).authorityEpoch;
1026
+ }
1027
+ default: return collaborationNever(gate);
1028
+ }
1029
+ }
1030
+
1031
+ getAgentRequest(id: string): AgentRequest | null {
1032
+ const row = this.db.query<{ data: string }, [string]>("SELECT data FROM agent_requests WHERE id=?").get(id);
1033
+ return row ? JSON.parse(row.data) : null;
1034
+ }
1035
+
1036
+ getAgentActivation(id: string): AgentActivation | null {
1037
+ const row = this.db.query<{ data: string }, [string]>("SELECT data FROM agent_activations WHERE id=?").get(id);
1038
+ return row ? JSON.parse(row.data) : null;
1039
+ }
1040
+
1041
+ agentActivations(requestId: string): AgentActivation[] {
1042
+ return this.db.query<{ data: string }, [string]>("SELECT data FROM agent_activations WHERE request_id=? ORDER BY ordinal").all(requestId).map(row => JSON.parse(row.data));
1043
+ }
1044
+
1045
+ /** Durable FIFO, excluding only requests whose own dependency is blocked. */
1046
+ nextAgentInput(conv: string): { readonly activation: AgentActivation; readonly request: AgentRequest } | null {
1047
+ const rows = this.db.query<{ data: string }, [string]>(
1048
+ "SELECT data FROM agent_activations WHERE target_conv=? AND state='queued' ORDER BY json_extract(data,'$.inputSeq'),id").all(conv);
1049
+ for (const row of rows) {
1050
+ const activation: AgentActivation = JSON.parse(row.data);
1051
+ const request = this.getAgentRequest(activation.requestId);
1052
+ const execution = this.agentExecutionRequest(activation);
1053
+ // A reply reports its child's final state, cancelled included; only its execution (the parent) can stop it.
1054
+ if (!request || (activation.direction !== "reply" && (request.state === "cancel_requested" || request.state === "cancelled")) ||
1055
+ this.agentRequestBlocked(request.id) || (activation.direction === "request" && request.state !== "queued") ||
1056
+ (execution && (agentRequestTerminal(execution.state) || execution.state === "cancel_requested" || this.agentRequestBlocked(execution.id)))) continue;
1057
+ return { activation, request };
1058
+ }
1059
+ return null;
1060
+ }
1061
+
1062
+ /** A definite pre-dispatch denial is not unknown delivery and must not block independent FIFO work. */
1063
+ rejectQueuedAgentInput(activationId: string, code: string): void {
1064
+ this.db.transaction(() => {
1065
+ const activation = this.getAgentActivation(activationId);
1066
+ if (!activation || activation.state !== "queued") return;
1067
+ this.writeAgentActivation({ ...activation, state: "rejected", quiesced: true, revision: activation.revision + 1 }, activation.revision);
1068
+ const request = this.agentExecutionRequest(activation);
1069
+ if (request && !agentRequestTerminal(request.state) && request.state !== "cancel_requested") {
1070
+ this.replyWithoutResult(this.writeAgentRequest({ ...request, state: code === "version_conflict" ? "version_conflict" : "interrupted", revision: request.revision + 1 }, request.revision), code);
1071
+ }
1072
+ this.appendEvent(activation.targetConv, "agent.collaboration.rejected", { activationId, code });
1073
+ }).immediate();
1074
+ }
1075
+
1076
+ /** The binding, claim and common input journal cross one writer boundary before the RPC write. */
1077
+ admitAgentInput(input: { readonly activationId: string; readonly activationRevision: number; readonly requestRevision: number;
1078
+ readonly previousBinding: CollaborationBinding | null; readonly wireMessage: string; readonly nativeIdentity: boolean }, binding: CollaborationBinding, gate: CollaborationGate): StoredEvent {
1079
+ return this.db.transaction(() => {
1080
+ const next = this.nextAgentInput(binding.convId);
1081
+ if (!next || next.activation.id !== input.activationId || next.activation.revision !== input.activationRevision ||
1082
+ next.request.revision !== input.requestRevision) throw new CollaborationStoreError("activation_revision_conflict");
1083
+ if (!isDeepStrictEqual(this.agentBinding(binding.convId), input.previousBinding)) throw new CollaborationStoreError("binding_conflict");
1084
+ if (this.lastSeq(binding.convId, "message.user") !== binding.ownerInputRevision ||
1085
+ this.pendingMainUsers(binding.convId).length > 0 || this.mainTurn(binding.convId)) throw new CollaborationStoreError("conversation_busy");
1086
+ const queued = this.getEvent(next.activation.inputSeq);
1087
+ if (!queued) throw new CollaborationStoreError("input_source_missing");
1088
+ const intent: CollaborationIntent = JSON.parse(JSON.stringify(queued.data));
1089
+ if (intent.origin === "agent_tool_permission") {
1090
+ const original = this.getAgentActivation(intent.outcome.blockedActivationId)?.binding;
1091
+ if (!original || original.ownerInputRevision !== binding.ownerInputRevision) throw new CollaborationStoreError("permission_outcome_conflict");
1092
+ this.assertCollaborationPermission(intent.outcome, original, gate);
1093
+ }
1094
+ this.recordAgentBinding(binding);
1095
+ const activation = this.claimAgentActivation({ activationId: next.activation.id, expectedRevision: input.activationRevision,
1096
+ requestRevision: input.requestRevision }, binding, gate);
1097
+ let source: MainInputSource;
1098
+ switch (intent.origin) {
1099
+ case "agent_collaboration": source = { origin: intent.origin, input_ref: { request_id: intent.requestId,
1100
+ activation_id: activation.id, direction: intent.direction, message_seq: intent.messageSeq } }; break;
1101
+ case "agent_tool_permission": source = { origin: intent.origin, input_ref: { ...intent.outcome, activation_id: activation.id } }; break;
1102
+ case "collaboration_artifact_read": case "collaboration_stage": throw new CollaborationStoreError("input_source_mismatch");
1103
+ default: return collaborationNever(intent);
1104
+ }
1105
+ const journal = this.appendEvent(binding.convId, "main.input", { ...source, session_path: binding.sessionPath,
1106
+ handoff: null, input_id: activation.inputId, wire_message: input.wireMessage, native_identity: input.nativeIdentity });
1107
+ this.appendEvent(binding.convId, "agent.collaboration.admitted", { activationId: activation.id, intentSeq: activation.inputSeq, inputSeq: journal.seq });
1108
+ this.writeAgentActivation({ ...activation, inputSeq: journal.seq, revision: activation.revision + 1 }, activation.revision);
1109
+ return journal;
1110
+ }).immediate();
1111
+ }
1112
+
1113
+ /** Delivery correlation stays on the child; a reply executes only its parent, never that completed child. */
1114
+ private agentExecutionRequest(activation: AgentActivation): AgentRequest | null {
1115
+ const correlated = this.getAgentRequest(activation.requestId);
1116
+ if (!correlated) throw new CollaborationStoreError("request_not_found", 404);
1117
+ switch (activation.direction) {
1118
+ case "request": case "owner_answer": return correlated;
1119
+ case "reply": return correlated.parentId === null ? null : this.getAgentRequest(correlated.parentId);
1120
+ default: return collaborationNever(activation.direction);
1121
+ }
1122
+ }
1123
+
1124
+ private agentOperation<T>(key: string, payload: unknown): T | null {
1125
+ const row = this.db.query<{ data: string }, [string]>("SELECT data FROM events WHERE type='agent.collaboration.operation' AND json_extract(data,'$.key')=?").get(key);
1126
+ if (!row) return null;
1127
+ const operation: { payloadSha: string; receipt: T } = JSON.parse(row.data);
1128
+ if (operation.payloadSha !== collaborationDigest(payload)) throw new CollaborationStoreError("submission_conflict");
1129
+ return operation.receipt;
1130
+ }
1131
+
1132
+ private saveAgentOperation<T>(conv: string, operation: { readonly key: string; readonly payload: unknown; readonly receipt: T }): T {
1133
+ this.appendEvent(conv, "agent.collaboration.operation", { key: operation.key, payloadSha: collaborationDigest(operation.payload), receipt: operation.receipt });
1134
+ return operation.receipt;
1135
+ }
1136
+
1137
+ private writeAgentRequest(request: AgentRequest, expectedRevision: number): AgentRequest {
1138
+ if (this.db.query("UPDATE agent_requests SET data=? WHERE id=? AND revision=?").run(JSON.stringify(request), request.id, expectedRevision).changes !== 1) throw new CollaborationStoreError("request_revision_conflict");
1139
+ this.queueCollaborationEvent(this.appendEvent(request.requesterConv, "agent.collaboration.status", { requestId: request.id, state: request.state, revision: request.revision }));
1140
+ return request;
1141
+ }
1142
+
1143
+ /**
1144
+ * F3 D1: the stored collaboration events an open page must see as they happen - every request state change (the
1145
+ * requester's `agent.collaboration.status`) and the two reply messages of a committed result - wait here for the Hub's
1146
+ * publication. The Hub takes them at its own points (admission, result finalization, the reply wake) and, through
1147
+ * onCollaborationEvents, one microtask after any other transaction, so no caller has to know which Store write moved a
1148
+ * request. The microtask runs after the transaction ended: a rolled-back write leaves no event to publish.
1149
+ */
1150
+ private readonly collaborationOutbox = new Set<number>();
1151
+ private collaborationFlushPending = false;
1152
+ /** Set by the Hub; called once per tick after transactions queued collaboration events. close() clears it. */
1153
+ onCollaborationEvents: (() => void) | null = null;
1154
+
1155
+ private queueCollaborationEvent(event: StoredEvent): void {
1156
+ this.collaborationOutbox.add(event.seq);
1157
+ if (this.collaborationFlushPending) return;
1158
+ this.collaborationFlushPending = true;
1159
+ queueMicrotask(() => {
1160
+ this.collaborationFlushPending = false;
1161
+ this.onCollaborationEvents?.();
1162
+ });
1163
+ }
1164
+
1165
+ /** The queued status and reply events, oldest first, each once; one whose transaction rolled back is gone. close() empties the queue. */
1166
+ takeCollaborationEvents(): StoredEvent[] {
1167
+ const seqs = [...this.collaborationOutbox].sort((a, b) => a - b);
1168
+ this.collaborationOutbox.clear();
1169
+ return seqs.flatMap(seq => {
1170
+ const event = this.getEvent(seq);
1171
+ return event && (event.type === "agent.collaboration.status" || event.type === "message.collaboration") ? [event] : [];
1172
+ });
1173
+ }
1174
+
1175
+ /**
1176
+ * A request that became final without a result still reaches its requester once, on the same reply path as a result:
1177
+ * its final state and reason, never text or files. A parent that is itself final or being cancelled gets nothing.
1178
+ */
1179
+ private replyWithoutResult(request: AgentRequest, reason: string): void {
1180
+ const parent = request.parentId === null ? null : this.getAgentRequest(request.parentId);
1181
+ if (parent && (agentRequestTerminal(parent.state) || parent.state === "cancel_requested")) return;
1182
+ const message = this.appendEvent(request.requesterConv, "agent.collaboration.final_reply", { requestId: request.id, rootId: request.rootId,
1183
+ direction: "reply", trust: "delegated", state: request.state, reason, resultId: null, manifest: [] });
1184
+ this.newAgentActivation(request, "reply", message.seq);
1185
+ this.finalReplyOutbox.add(message.seq);
1186
+ }
1187
+
1188
+ private finalReplyOutbox = new Set<number>();
1189
+
1190
+ /**
1191
+ * F4-2 (C-4): the result-less final replies appended since the last call, oldest first, each once; every path that appends
1192
+ * one then wakes the replies, where the Hub sends their fallback notices. A reply whose transaction rolled back is gone.
1193
+ */
1194
+ takeFinalReplies(): StoredEvent[] {
1195
+ const seqs = [...this.finalReplyOutbox].sort((a, b) => a - b);
1196
+ this.finalReplyOutbox.clear();
1197
+ return seqs.flatMap(seq => {
1198
+ const event = this.getEvent(seq);
1199
+ return event?.type === "agent.collaboration.final_reply" ? [event] : [];
1200
+ });
1201
+ }
1202
+
1203
+ /** Conversations owed a queued reply; dispatch itself keeps the owner's turns first. */
1204
+ queuedAgentReplyConvs(): string[] {
1205
+ return this.db.query<{ conv: string }, []>("SELECT DISTINCT target_conv conv FROM agent_activations WHERE state='queued' AND direction='reply'").all().map(row => row.conv);
1206
+ }
1207
+
1208
+ private writeAgentActivation(activation: AgentActivation, expectedRevision: number): AgentActivation {
1209
+ if (this.db.query("UPDATE agent_activations SET data=? WHERE id=? AND revision=?").run(JSON.stringify(activation), activation.id, expectedRevision).changes !== 1) throw new CollaborationStoreError("activation_revision_conflict");
1210
+ return activation;
1211
+ }
1212
+
1213
+ private newAgentActivation(request: AgentRequest, direction: AgentActivation["direction"], source: number | { readonly seq: number }): AgentActivation {
1214
+ const id = crypto.randomUUID();
1215
+ const targetConv = direction === "reply" ? request.requesterConv : request.targetConv;
1216
+ const ordinal = this.db.query<{ n: number }, [string, string]>("SELECT COALESCE(max(ordinal),0)+1 n FROM agent_activations WHERE request_id=? AND direction=?").get(request.id, direction)?.n ?? 1;
1217
+ const input = typeof source === "number" ? this.appendEvent(targetConv, "agent.collaboration.input", {
1218
+ origin: "agent_collaboration", requestId: request.id, activationId: id, direction, messageSeq: source } satisfies CollaborationIntent) : source;
1219
+ const activation: AgentActivation = { id, requestId: request.id, direction, ordinal, inputId: crypto.randomUUID(), inputSeq: input.seq, targetConv, binding: null, state: "queued", revision: 1, consumedEntry: null, quiesced: false };
1220
+ this.db.query("INSERT INTO agent_activations(id,data) VALUES (?,?)").run(id, JSON.stringify(activation));
1221
+ return activation;
1222
+ }
1223
+
1224
+ acceptAgentRequest(input: AgentRequestInput, binding: CollaborationBinding, gate: CollaborationGate): AgentReceipt {
1225
+ return this.db.transaction(() => {
1226
+ if (binding.readContinuationId !== undefined) throw new CollaborationStoreError("read_only_continuation", 403);
1227
+ if (!input.toolCallId || !input.text.trim() || !["task", "question"].includes(input.kind) ||
1228
+ new Set(input.files).size !== input.files.length) throw new CollaborationStoreError("invalid_request", 400);
1229
+ if (Buffer.byteLength(input.text) > 65536 || input.files.length > 10) throw new CollaborationStoreError("request_too_large", 413);
1230
+ const key = JSON.stringify([binding.durableSessionId, input.toolCallId, "request"]);
1231
+ const payload = { ...input, text: input.text.trim(), files: [...input.files].sort() };
1232
+ const operationPayload = { payload, source: binding.convId, activationId: binding.activationId };
1233
+ const prior = this.agentOperation<AgentReceipt>(key, operationPayload);
1234
+ if (prior) return prior;
1235
+ const target = this.agentTarget(input.targetAgentId);
1236
+ const authorityEpoch = this.checkAgentAccess(gate, binding, target);
1237
+ if (binding.agentId !== null && input.kind !== "question") throw new CollaborationStoreError("agent_question_only", 403);
1238
+ if (binding.agentId === target.agentId) throw new CollaborationStoreError("cycle_detected");
1239
+ let parent: AgentRequest | null = null;
1240
+ if (binding.activationId !== null) {
1241
+ const active = this.getAgentActivation(binding.activationId);
1242
+ if (!active || active.state !== "consumed" || !isDeepStrictEqual(active.binding, binding)) throw new CollaborationStoreError("parent_conflict");
1243
+ parent = this.agentExecutionRequest(active);
1244
+ if (!parent || parent.targetConv !== binding.convId || binding.lifecycleEpoch !== parent.targetLifecycleEpoch ||
1245
+ !["running", "waiting_peer"].includes(parent.state) || this.agentRequestBlocked(parent.id)) throw new CollaborationStoreError("parent_conflict");
1246
+ const ancestors = this.db.query<{ requester_agent: string | null; target_agent: string }, [string]>(`
1247
+ WITH RECURSIVE ancestry AS (SELECT * FROM agent_requests WHERE id=? UNION ALL SELECT p.* FROM agent_requests p JOIN ancestry c ON c.parent_id=p.id)
1248
+ SELECT requester_agent,target_agent FROM ancestry`).all(parent.id);
1249
+ if (ancestors.some(row => row.requester_agent === target.agentId || row.target_agent === target.agentId)) throw new CollaborationStoreError("cycle_detected");
1250
+ }
1251
+ const id = crypto.randomUUID();
1252
+ const rootId = parent?.rootId ?? id;
1253
+ const artifacts = input.files.map(artifactId => this.agentArtifact(artifactId));
1254
+ if (artifacts.some(artifact => artifact === null)) throw new CollaborationStoreError("artifact_not_found", 404);
1255
+ const digests = artifacts.map(artifact => artifact?.sha256).sort();
1256
+ const fingerprint = collaborationDigest([rootId, target.agentId, input.kind, input.text.normalize("NFC").replace(/\r\n/g, "\n").trim(), digests]);
1257
+ const duplicateRow = this.db.query<{ data: string }, [string]>("SELECT data FROM agent_requests WHERE fingerprint=? ORDER BY created_at DESC LIMIT 1").get(fingerprint);
1258
+ if (duplicateRow) {
1259
+ const duplicate: AgentRequest = JSON.parse(duplicateRow.data);
1260
+ if (!agentRequestRetryable(duplicate.state)) return this.saveAgentOperation(binding.convId, { key, payload: operationPayload, receipt: { requestId: duplicate.id, state: duplicate.state, revision: duplicate.revision } });
1261
+ if (binding.agentId !== null || input.retryOf !== duplicate.id) throw new CollaborationStoreError("semantic_retry_required");
1262
+ }
1263
+ if (input.retryOf) {
1264
+ const original = this.getAgentRequest(input.retryOf);
1265
+ if (binding.agentId !== null || !original || !agentRequestRetryable(original.state) || original.requesterConv !== binding.convId) throw new CollaborationStoreError("invalid_retry");
1266
+ }
1267
+ if (parent && this.db.query(`SELECT id FROM agent_requests WHERE parent_id=? AND state NOT IN ${AGENT_REQUEST_FINAL_SQL}`).get(parent.id)) throw new CollaborationStoreError("unfinished_child");
1268
+ const request: AgentRequest = { id, rootId, parentId: parent?.id ?? null, requesterConv: binding.convId, requesterAgent: binding.agentId, targetConv: target.convId, targetAgent: target.agentId,
1269
+ requesterLifecycleEpoch: binding.lifecycleEpoch, targetLifecycleEpoch: target.lifecycleEpoch,
1270
+ ...(authorityEpoch === undefined ? {} : { recipientAuthorityEpoch: authorityEpoch }),
1271
+ ...(gate.mode === "authority" && binding.agentId !== null ? { requesterAuthorityEpoch: gate.access(this.db, binding.agentId).authorityEpoch } : {}),
1272
+ kind: input.kind, payload, payloadSha: collaborationDigest({ payload, parentId: parent?.id ?? null, rootId, digests }), submissionKey: key, fingerprint, state: "queued", revision: 1, createdAt: new Date().toISOString(), resultId: null, cancelReason: null };
1273
+ for (const artifact of artifacts) {
1274
+ if (!artifact || artifact.state !== "staged" || artifact.requestId !== null || artifact.producerConv !== binding.convId) throw new CollaborationStoreError("artifact_already_bound");
1275
+ const stage = this.agentStageByOperation(artifact.stageOperationKey);
1276
+ if (!stage || stage.state !== "staged" || stage.artifactId !== artifact.id || stage.target.convId !== target.convId || artifact.sha256 !== this.stageDigest(stage)) throw new CollaborationStoreError("transfer_scope_changed");
1277
+ this.assertAgentStage(stage, gate);
1278
+ }
1279
+ this.db.query("INSERT INTO agent_requests(id,data) VALUES (?,?)").run(id, JSON.stringify(request));
1280
+ for (const artifact of artifacts) {
1281
+ if (!artifact) throw new CollaborationStoreError("artifact_not_found", 404);
1282
+ const committed: AgentArtifact = { ...artifact, requestId: id, state: "committed", provenance: { ...artifact.provenance, requestId: id, rootId } };
1283
+ this.db.query("UPDATE agent_artifacts SET data=? WHERE id=? AND request_id IS NULL").run(JSON.stringify(committed), artifact.id);
1284
+ this.createAgentGrant(committed, { convId: target.convId, lifecycleEpoch: target.lifecycleEpoch, authorityEpoch });
1285
+ }
1286
+ const data = { requestId: id, rootId, direction: "request", text: payload.text, files: input.files, trust: "delegated",
1287
+ src: { channel: "gateway", adapter: "agent-collaboration" }, actor: { kind: binding.agentId === null ? "main" : "agent", id: binding.agentId } };
1288
+ this.appendEvent(binding.convId, "message.collaboration", { ...data, outgoing: true });
1289
+ const message = this.appendEvent(target.convId, "message.collaboration", { ...data, outgoing: false });
1290
+ this.newAgentActivation(request, "request", message.seq);
1291
+ if (parent) this.writeAgentRequest({ ...parent, state: "waiting_peer", revision: parent.revision + 1 }, parent.revision);
1292
+ const receipt = this.saveAgentOperation(binding.convId, { key, payload: operationPayload, receipt: { requestId: id, state: request.state, revision: request.revision } });
1293
+ testCrashPoint("collaboration.admission.before-commit");
1294
+ return receipt;
1295
+ }).immediate();
1296
+ }
1297
+
1298
+ agentArtifact(id: string): AgentArtifact | null {
1299
+ const row = this.db.query<{ data: string }, [string]>("SELECT data FROM agent_artifacts WHERE id=?").get(id);
1300
+ return row ? JSON.parse(row.data) : null;
1301
+ }
1302
+
1303
+ agentGrant(id: string): ArtifactGrant | null {
1304
+ const row = this.db.query<{ data: string }, [string]>("SELECT data FROM agent_artifact_grants WHERE grant_id=?").get(id);
1305
+ return row ? JSON.parse(row.data) : null;
1306
+ }
1307
+
1308
+ agentStage(transferId: string): AgentArtifactStage | null {
1309
+ const row = this.db.query<{ data: string }, [string]>("SELECT data FROM agent_artifact_stages WHERE transfer_id=?").get(transferId);
1310
+ return row ? JSON.parse(row.data) : null;
1311
+ }
1312
+
1313
+ private agentStageByOperation(operationKey: string): AgentArtifactStage | null {
1314
+ const row = this.db.query<{ data: string }, [string]>("SELECT data FROM agent_artifact_stages WHERE operation_key=?").get(operationKey);
1315
+ return row ? JSON.parse(row.data) : null;
1316
+ }
1317
+
1318
+ private stageDigest(stage: AgentArtifactStage): string {
1319
+ switch (stage.source.kind) {
1320
+ case "agent_path": case "artifact": return stage.source.sha256;
1321
+ case "attachment": return stage.source.origin.originalSha256;
1322
+ default: return collaborationNever(stage.source);
1323
+ }
1324
+ }
1325
+
1326
+ private assertAgentStage(stage: AgentArtifactStage, gate: CollaborationGate): void {
1327
+ const targetEpoch = this.checkAgentAccess(gate, stage.sourceBinding, stage.target);
1328
+ if (stage.targetAuthorityEpoch !== targetEpoch || (gate.mode === "authority" && stage.sourceBinding.agentId !== null &&
1329
+ stage.sourceAuthorityEpoch !== gate.access(this.db, stage.sourceBinding.agentId).authorityEpoch)) throw new CollaborationStoreError("authority_epoch_mismatch");
1330
+ if (stage.originRequestId !== null) {
1331
+ const request = this.getAgentRequest(stage.originRequestId);
1332
+ if (!request || agentRequestTerminal(request.state) || request.state === "cancel_requested" ||
1333
+ this.agentRequestBlocked(request.id, stage.decisionId ?? "")) throw new CollaborationStoreError("transfer_scope_changed");
1334
+ }
1335
+ if (stage.sourceBinding.activationId !== null) {
1336
+ const activation = this.getAgentActivation(stage.sourceBinding.activationId);
1337
+ if (!activation || activation.state !== "consumed" || !isDeepStrictEqual(activation.binding, stage.sourceBinding) ||
1338
+ this.agentExecutionRequest(activation)?.id !== (stage.originRequestId ?? undefined)) throw new CollaborationStoreError("transfer_scope_changed");
1339
+ }
1340
+ switch (stage.source.kind) {
1341
+ case "agent_path":
1342
+ if (stage.originRequestId !== null && stage.source.path.split(/[\\/]/)[0] === "outputs" &&
1343
+ this.deriveOutputLineage(stage.originRequestId).storageClass === "scoped_document") throw new CollaborationStoreError("scoped_path_requires_artifact", 403);
1344
+ return;
1345
+ case "attachment":
1346
+ if (!gate.assertSelectedExportEligible) throw new CollaborationStoreError("attachment_eligibility_unavailable", 503);
1347
+ gate.assertSelectedExportEligible(this.db, stage.source.origin);
1348
+ break;
1349
+ case "artifact": {
1350
+ const grant = this.agentGrant(stage.source.grantId);
1351
+ const artifact = this.agentArtifact(stage.source.artifactId);
1352
+ if (!grant || grant.revoked || !artifact || artifact.state === "trashed") throw new CollaborationStoreError("source_revoked", 410);
1353
+ if (grant.requestId !== stage.source.requestId || grant.artifactId !== artifact.id || grant.revision !== stage.source.grantRevision ||
1354
+ grant.granteeConv !== stage.sourceBinding.convId || grant.granteeLifecycleEpoch !== stage.sourceBinding.lifecycleEpoch ||
1355
+ artifact.sha256 !== stage.source.sha256) throw new CollaborationStoreError("transfer_scope_changed");
1356
+ if (gate.mode === "authority" && stage.sourceBinding.agentId !== null &&
1357
+ grant.recipientAuthorityEpoch !== gate.access(this.db, stage.sourceBinding.agentId).authorityEpoch) throw new CollaborationStoreError("authority_epoch_mismatch");
1358
+ break;
1359
+ }
1360
+ default: return collaborationNever(stage.source);
1361
+ }
1362
+ if (stage.state === "awaiting_transfer_authority") return;
1363
+ if (!stage.authority || stage.authority.scopeDigest !== stage.scopeDigest) throw new CollaborationStoreError("transfer_authority_required", 403);
1364
+ switch (stage.authority.kind) {
1365
+ case "owner_decision": {
1366
+ const decision = this.getDecision(stage.authority.decisionId);
1367
+ if (!decision || decision.revision !== stage.authority.decisionRevision || decision.submissionId !== stage.authority.submissionId ||
1368
+ stage.authority.transferId !== stage.transferId || decision.status !== "delivered") throw new CollaborationStoreError("transfer_scope_changed");
1369
+ break;
1370
+ }
1371
+ case "tool_policy":
1372
+ if (gate.mode !== "authority" || !gate.assertTransferAuthority) throw new CollaborationStoreError("transfer_authority_unavailable", 503);
1373
+ gate.assertTransferAuthority(this.db, stage);
1374
+ break;
1375
+ default: return collaborationNever(stage.authority);
1376
+ }
1377
+ }
1378
+
1379
+ beginAgentStage(input: AgentStageInput, binding: CollaborationBinding, gate: CollaborationGate): AgentArtifactStage {
1380
+ return this.db.transaction(() => {
1381
+ if (binding.readContinuationId !== undefined) throw new CollaborationStoreError("read_only_continuation", 403);
1382
+ const payloadSha = collaborationDigest({ input, binding });
1383
+ const existing = this.agentStageByOperation(input.operationKey);
1384
+ if (existing) {
1385
+ if (existing.payloadSha !== payloadSha) throw new CollaborationStoreError("submission_conflict");
1386
+ return existing;
1387
+ }
1388
+ const targetAuthorityEpoch = this.checkAgentAccess(gate, binding, input.target);
1389
+ const transferId = input.transferId ?? crypto.randomUUID();
1390
+ const scopeDigest = collaborationDigest({ source: input.source, target: input.target, binding, operationKey: input.operationKey, transferId, purpose: "collaboration_attachment_transfer" });
1391
+ const needsAuthority = input.source.kind !== "agent_path" && input.authority === null;
1392
+ const decision = needsAuthority ? this.createDecision({
1393
+ ownerConv: binding.convId, scope: `conv:${binding.convId}`, sourceSessionPath: null, sourceRequestId: transferId,
1394
+ method: "confirm", required: true, bindingState: "closed",
1395
+ payload: { kind: "collaboration-transfer", purpose: "collaboration_attachment_transfer", transferId, scopeDigest },
1396
+ }) : null;
1397
+ const origin = binding.activationId === null ? null : this.getAgentActivation(binding.activationId);
1398
+ const originRequest = origin === null ? null : this.agentExecutionRequest(origin);
1399
+ const stage: AgentArtifactStage = { ...input, transferId, payloadSha, scopeDigest, sourceBinding: binding, originRequestId: originRequest?.id ?? null,
1400
+ ...(targetAuthorityEpoch === undefined ? {} : { targetAuthorityEpoch }),
1401
+ ...(gate.mode === "authority" && binding.agentId !== null ? { sourceAuthorityEpoch: gate.access(this.db, binding.agentId).authorityEpoch } : {}),
1402
+ decisionId: decision?.id ?? null, decisionRevision: decision?.revision ?? null, continuationId: crypto.randomUUID(),
1403
+ state: needsAuthority ? "awaiting_transfer_authority" : "ready", revision: 1, artifactId: null, exportReceipt: null, errorCode: null };
1404
+ this.assertAgentStage(stage, gate);
1405
+ this.db.query("INSERT INTO agent_artifact_stages(transfer_id,data) VALUES (?,?)").run(transferId, JSON.stringify(stage));
1406
+ if (decision) this.db.query("INSERT INTO agent_question_links(decision_id,request_id,activation_id,transfer_id) VALUES (?,?,?,?)").run(decision.id, stage.originRequestId, binding.activationId, transferId);
1407
+ return stage;
1408
+ }).immediate();
1409
+ }
1410
+
1411
+ answerAgentStage(input: { readonly transferId: string; readonly expectedRevision: number; readonly submissionId: string; readonly scopeDigest: string; readonly choice: "allow" | "deny" | "cancel"; readonly ownerPrincipalRef: string }, gate: CollaborationGate): AgentArtifactStage {
1412
+ return this.db.transaction(() => {
1413
+ const stage = this.agentStage(input.transferId);
1414
+ if (!stage || !stage.decisionId) throw new CollaborationStoreError("stage_not_found", 404);
1415
+ if (stage.ownerPrincipalRef !== input.ownerPrincipalRef) throw new CollaborationStoreError("owner_mismatch", 403);
1416
+ const key = JSON.stringify(["stage-answer", input.ownerPrincipalRef, input.submissionId]);
1417
+ const prior = this.agentOperation<AgentArtifactStage>(key, input);
1418
+ if (prior) return prior;
1419
+ const decision = this.getDecision(stage.decisionId);
1420
+ if (!decision || decision.revision !== input.expectedRevision || decision.submissionId !== null ||
1421
+ stage.state !== "awaiting_transfer_authority" || stage.scopeDigest !== input.scopeDigest) throw new CollaborationStoreError("transfer_scope_changed");
1422
+ this.checkAgentAccess(gate, stage.sourceBinding, stage.target);
1423
+ let state: AgentArtifactStage["state"];
1424
+ switch (input.choice) {
1425
+ case "allow": state = "ready"; break;
1426
+ case "deny": state = "denied"; break;
1427
+ case "cancel": state = "cancelled"; break;
1428
+ default: return collaborationNever(input.choice);
1429
+ }
1430
+ const decisionRevision = decision.revision + 1;
1431
+ const next: AgentArtifactStage = { ...stage, state, revision: stage.revision + 1, decisionRevision,
1432
+ authority: input.choice === "allow" ? { kind: "owner_decision", decisionId: decision.id, decisionRevision, submissionId: input.submissionId, scopeDigest: stage.scopeDigest, transferId: stage.transferId } : null,
1433
+ errorCode: input.choice === "allow" ? null : input.choice === "deny" ? "transfer_denied" : "transfer_cancelled" };
1434
+ this.db.query("UPDATE decisions SET revision=?,submission_id=?,submission_revision=?,submission_mode='answer',answer=?,answer_route='collaboration_stage',status=?,updated_at=? WHERE id=?")
1435
+ .run(decisionRevision, input.submissionId, input.expectedRevision, JSON.stringify({ collaborationTransfer: { choice: input.choice, transferId: stage.transferId, scopeDigest: stage.scopeDigest } }), input.choice === "allow" ? "delivered" : "closed", new Date().toISOString(), decision.id);
1436
+ if (input.choice === "allow") {
1437
+ this.assertAgentStage(next, gate);
1438
+ const intent: CollaborationIntent = { origin: "collaboration_stage", transferId: stage.transferId, continuationId: stage.continuationId };
1439
+ this.appendEvent(stage.sourceBinding.convId, "agent.collaboration.input", intent);
1440
+ }
1441
+ this.db.query("UPDATE agent_artifact_stages SET data=? WHERE transfer_id=? AND revision=?").run(JSON.stringify(next), stage.transferId, stage.revision);
1442
+ this.appendEvent(stage.sourceBinding.convId, "decision.operation", { decisionId: decision.id, request: { expectedRevision: input.expectedRevision, submissionId: input.submissionId, mode: "answer", response: { collaborationTransfer: { choice: input.choice, transferId: stage.transferId, scopeDigest: stage.scopeDigest } } }, targetScope: decision.scope, commandId: null, mainInputSeq: null });
1443
+ return this.saveAgentOperation(stage.sourceBinding.convId, { key, payload: input, receipt: next });
1444
+ }).immediate();
1445
+ }
1446
+
1447
+ claimAgentStage(transferId: string, expectedRevision: number, gate: CollaborationGate): AgentArtifactStage {
1448
+ return this.db.transaction(() => {
1449
+ const stage = this.agentStage(transferId);
1450
+ if (!stage || stage.revision !== expectedRevision || stage.state !== "ready") throw new CollaborationStoreError("stage_conflict");
1451
+ this.assertAgentStage(stage, gate);
1452
+ const next: AgentArtifactStage = { ...stage, revision: stage.revision + 1, state: "staging" };
1453
+ this.db.query("UPDATE agent_artifact_stages SET data=? WHERE transfer_id=? AND revision=?").run(JSON.stringify(next), transferId, expectedRevision);
1454
+ return next;
1455
+ }).immediate();
1456
+ }
1457
+
1458
+ sealAgentStage(input: { readonly transferId: string; readonly expectedRevision: number; readonly sha256: string; readonly bytes: number; readonly mediaType: string; readonly name: string; readonly sealedName: string }, gate: CollaborationGate): AgentArtifact {
1459
+ return this.db.transaction(() => {
1460
+ const stage = this.agentStage(input.transferId);
1461
+ if (!stage || stage.state !== "staging" || stage.revision !== input.expectedRevision) throw new CollaborationStoreError("stage_conflict");
1462
+ this.assertAgentStage(stage, gate);
1463
+ if (!/^[a-f0-9]{64}$/.test(input.sha256) || input.sha256 !== this.stageDigest(stage) ||
1464
+ !Number.isSafeInteger(input.bytes) || input.bytes < 0 || input.bytes > 10485760) throw new CollaborationStoreError("artifact_digest_conflict");
1465
+ const sourceArtifact = stage.source.kind === "artifact" ? this.agentArtifact(stage.source.artifactId) : null;
1466
+ const artifact: AgentArtifact = { id: crypto.randomUUID(), producerConv: stage.sourceBinding.convId, requestId: null, sha256: input.sha256, bytes: input.bytes,
1467
+ mediaType: input.mediaType, originalName: input.name, sealedName: input.sealedName, state: "staged", lifecycleEpoch: stage.sourceBinding.lifecycleEpoch,
1468
+ storageClass: stage.source.kind === "agent_path" ? "source_copy" : "scoped_document", stageOperationKey: stage.operationKey, stagePayloadSha: stage.payloadSha,
1469
+ provenance: { transferId: stage.transferId, transferAuthorityRef: stage.authority, targetAgentId: stage.target.agentId, targetConvId: stage.target.convId, targetLifecycleEpoch: stage.target.lifecycleEpoch,
1470
+ sourceInputId: stage.sourceBinding.inputId, sourceActivationId: stage.sourceBinding.activationId, requestId: null, rootId: null,
1471
+ ...(stage.source.kind === "attachment" ? { originAttachment: stage.source.origin } : {}),
1472
+ ...(sourceArtifact && stage.source.kind === "artifact" ? { ...sourceArtifact.provenance.originAttachment ? { originAttachment: sourceArtifact.provenance.originAttachment } : {},
1473
+ sourceArtifactId: sourceArtifact.id, sourceArtifactGrantRevision: stage.source.grantRevision,
1474
+ ...(sourceArtifact.provenance.transferId ? { parentTransferId: sourceArtifact.provenance.transferId } : {}) } : {}),
1475
+ } };
1476
+ this.db.query("INSERT INTO agent_artifacts(id,data) VALUES (?,?)").run(artifact.id, JSON.stringify(artifact));
1477
+ const next: AgentArtifactStage = { ...stage, state: "staged", revision: stage.revision + 1, artifactId: artifact.id, exportReceipt: stage.source.kind === "attachment" ? stage.source.origin : null };
1478
+ this.db.query("UPDATE agent_artifact_stages SET data=? WHERE transfer_id=? AND revision=?").run(JSON.stringify(next), stage.transferId, stage.revision);
1479
+ return artifact;
1480
+ }).immediate();
1481
+ }
1482
+
1483
+ private createAgentGrant(artifact: AgentArtifact, recipient: { readonly convId: string; readonly lifecycleEpoch: number; readonly authorityEpoch: number | undefined }): ArtifactGrant {
1484
+ if (artifact.requestId === null) throw new CollaborationStoreError("artifact_unbound");
1485
+ const grant: ArtifactGrant = { grantId: crypto.randomUUID(), artifactId: artifact.id, requestId: artifact.requestId, granteeConv: recipient.convId,
1486
+ permission: "read", revoked: false, revision: 1, granteeLifecycleEpoch: recipient.lifecycleEpoch,
1487
+ ...(recipient.authorityEpoch === undefined ? {} : { recipientAuthorityEpoch: recipient.authorityEpoch }) };
1488
+ this.db.query("INSERT INTO agent_artifact_grants(grant_id,data) VALUES (?,?)").run(grant.grantId, JSON.stringify(grant));
1489
+ return grant;
1490
+ }
1491
+
1492
+ claimAgentActivation(input: { readonly activationId: string; readonly expectedRevision: number; readonly requestRevision: number }, binding: CollaborationBinding, gate: CollaborationGate): AgentActivation {
1493
+ return this.db.transaction(() => {
1494
+ const activation = this.getAgentActivation(input.activationId);
1495
+ const request = activation ? this.getAgentRequest(activation.requestId) : null;
1496
+ if (!activation || !request || activation.state !== "queued" || activation.revision !== input.expectedRevision || request.revision !== input.requestRevision) throw new CollaborationStoreError("activation_revision_conflict");
1497
+ this.assertAgentBinding(binding);
1498
+ if (binding.convId !== activation.targetConv || binding.activationId !== activation.id || binding.inputId !== activation.inputId) throw new CollaborationStoreError("binding_conflict");
1499
+ const expectedEpoch = activation.direction === "reply" ? request.requesterLifecycleEpoch : request.targetLifecycleEpoch;
1500
+ if (binding.lifecycleEpoch !== expectedEpoch) throw new CollaborationStoreError("lifecycle_conflict");
1501
+ if (gate.mode === "authority" && binding.agentId !== null) {
1502
+ const epoch = activation.direction === "reply" ? request.requesterAuthorityEpoch : request.recipientAuthorityEpoch;
1503
+ if (gate.access(this.db, binding.agentId).authorityEpoch !== epoch) throw new CollaborationStoreError("authority_epoch_mismatch");
1504
+ }
1505
+ if ((activation.direction !== "reply" && (request.state === "cancel_requested" || request.state === "cancelled")) || this.agentRequestBlocked(request.id)) throw new CollaborationStoreError("request_blocked");
1506
+ if (activation.direction === "request" && request.state !== "queued") throw new CollaborationStoreError("request_blocked");
1507
+ const execution = this.agentExecutionRequest(activation);
1508
+ if (execution && (agentRequestTerminal(execution.state) || execution.state === "cancel_requested" ||
1509
+ this.agentRequestBlocked(execution.id))) throw new CollaborationStoreError("request_blocked");
1510
+ // A started execution continues only on the definition it started with; a re-applied one is never reinterpreted.
1511
+ const origin = execution && activation.direction !== "request" ?
1512
+ this.agentActivations(execution.id).find(row => row.direction === "request")?.binding : undefined;
1513
+ if (origin && origin.definitionSha !== binding.definitionSha) throw new CollaborationStoreError("version_conflict", 409);
1514
+ const target = this.agentTarget(request.targetAgent);
1515
+ this.checkAgentAccess(gate, binding, target);
1516
+ if (activation.direction === "request") for (const artifactId of request.payload.files) {
1517
+ const grant = this.agentArtifactGrant(artifactId, binding.convId, request.id);
1518
+ if (!grant) throw new CollaborationStoreError("artifact_forbidden", 403);
1519
+ this.assertArtifactGrant(grant, binding, gate);
1520
+ }
1521
+ if (this.db.query("SELECT id FROM agent_activations WHERE target_conv=? AND id<>? AND state IN ('attempted','acknowledged','consumed','unknown') AND quiesced=0").get(binding.convId, activation.id)) throw new CollaborationStoreError("conversation_busy");
1522
+ const next = this.writeAgentActivation({ ...activation, binding, state: "attempted", revision: activation.revision + 1 }, activation.revision);
1523
+ if (activation.direction === "request" || (activation.direction === "owner_answer" && request.state === "waiting_owner")) {
1524
+ this.writeAgentRequest({ ...request, state: "running", revision: request.revision + 1 }, request.revision);
1525
+ }
1526
+ return next;
1527
+ }).immediate();
1528
+ }
1529
+
1530
+ recordAgentActivationEvidence(input: { readonly activationId: string; readonly expectedRevision: number; readonly state: Exclude<AgentDelivery, "queued" | "attempted">; readonly consumedEntry?: string }, binding: CollaborationBinding): AgentActivation {
1531
+ return this.db.transaction(() => {
1532
+ const activation = this.getAgentActivation(input.activationId);
1533
+ if (!activation || !isDeepStrictEqual(activation.binding, binding)) throw new CollaborationStoreError("binding_conflict");
1534
+ if (activation.state === "consumed") return activation;
1535
+ if (activation.revision !== input.expectedRevision || activation.state === "queued" || activation.state === "rejected") throw new CollaborationStoreError("activation_revision_conflict");
1536
+ if (input.state === "consumed" && !input.consumedEntry) throw new CollaborationStoreError("consumption_evidence_required", 400);
1537
+ if (input.state === "unknown" && activation.state === "acknowledged") return activation;
1538
+ const next = this.writeAgentActivation({ ...activation, state: input.state, consumedEntry: input.consumedEntry ?? activation.consumedEntry, revision: activation.revision + 1 }, activation.revision);
1539
+ const execution = this.agentExecutionRequest(activation);
1540
+ const current = isDeepStrictEqual(this.agentBinding(binding.convId), binding) &&
1541
+ this.getConversation(binding.convId)?.threadId === binding.sessionPath &&
1542
+ (binding.agentId === null || this.ensureAgentLifecycle(binding.agentId) === binding.lifecycleEpoch);
1543
+ if (input.state === "consumed") {
1544
+ const resumed = execution;
1545
+ if (resumed && ["unresolved_delivery", "waiting_peer", "waiting_owner"].includes(resumed.state) &&
1546
+ !this.agentRequestBlocked(resumed.id) && current) {
1547
+ this.writeAgentRequest({ ...resumed, state: "running", revision: resumed.revision + 1 }, resumed.revision);
1548
+ }
1549
+ }
1550
+ if (input.state === "unknown") {
1551
+ const request = execution;
1552
+ if (request && current && !agentRequestTerminal(request.state) && request.state !== "cancel_requested") this.writeAgentRequest({ ...request, state: "unresolved_delivery", revision: request.revision + 1 }, request.revision);
1553
+ }
1554
+ return next;
1555
+ }).immediate();
1556
+ }
1557
+
1558
+ settleAgentActivation(input: { readonly activationId: string; readonly expectedRevision: number; readonly settlementId: string; readonly ownedInFlight: number; readonly reconciled: boolean }, binding: CollaborationBinding): AgentActivation {
1559
+ return this.db.transaction(() => {
1560
+ const activation = this.getAgentActivation(input.activationId);
1561
+ if (!activation || !isDeepStrictEqual(activation.binding, binding) || activation.revision !== input.expectedRevision ||
1562
+ !input.settlementId || !input.reconciled || input.ownedInFlight !== 0) throw new CollaborationStoreError("settlement_conflict");
1563
+ if (activation.quiesced) return activation;
1564
+ this.appendEvent(binding.convId, "agent.collaboration.settled", { ...input, binding });
1565
+ return this.writeAgentActivation({ ...activation, quiesced: true, revision: activation.revision + 1 }, activation.revision);
1566
+ }).immediate();
1567
+ }
1568
+
1569
+ /** Conversations holding queued collaboration input in the request tree that a resolved decision may have unblocked. */
1570
+ agentDecisionDependents(decisionId: string): string[] {
1571
+ return this.db.query<{ conv: string }, [string]>(`SELECT DISTINCT a.target_conv conv FROM agent_question_links l
1572
+ JOIN agent_requests linked ON linked.id=l.request_id JOIN agent_requests r ON r.root_id=linked.root_id
1573
+ JOIN agent_activations a ON a.request_id=r.id WHERE l.decision_id=? AND a.state='queued'`).all(decisionId).map(row => row.conv);
1574
+ }
1575
+
1576
+ private agentRequestBlocked(requestId: string, exceptDecisionId = ""): boolean {
1577
+ return this.db.query(`WITH RECURSIVE ancestry AS (SELECT id,parent_id FROM agent_requests WHERE id=? UNION ALL SELECT p.id,p.parent_id FROM agent_requests p JOIN ancestry c ON p.id=c.parent_id)
1578
+ SELECT d.id FROM agent_question_links l JOIN decisions d ON d.id=l.decision_id JOIN ancestry a ON a.id=l.request_id
1579
+ WHERE d.id<>? AND (d.status IN ('pending','needs_review') OR (d.status='submitted' AND COALESCE(d.command_id,'') NOT LIKE 'collaboration:%') OR EXISTS (
1580
+ SELECT 1 FROM security_holds h WHERE h.id=d.id AND json_extract(h.data,'$.source')='collaboration_artifact'
1581
+ AND json_extract(h.data,'$.state') IN ('pending','allowed') AND json_extract(h.data,'$.continuation.state')<>'released')) LIMIT 1`).get(requestId, exceptDecisionId) !== null;
1582
+ }
1583
+
1584
+ /**
1585
+ * Links a question observed during a collaboration execution to that request once. An owner turn (no activation) is
1586
+ * the owner's own context and is never linked; a replayed observation of the same decision is a no-op.
1587
+ */
1588
+ linkCurrentAgentDecision(conv: string, decisionId: string): boolean {
1589
+ const activationId = this.agentBinding(conv)?.activationId;
1590
+ if (!activationId) return false;
1591
+ if (this.db.query("SELECT 1 FROM agent_question_links WHERE decision_id=?").get(decisionId)) return false;
1592
+ this.linkAgentDecision(decisionId, activationId);
1593
+ return true;
1594
+ }
1595
+
1596
+ /** The native consumption of the owner's answer in its request continuation is the decision's delivery receipt. */
1597
+ private deliverCollaborationAnswer(activationId: string): void {
1598
+ const decision = this.db.query<{ id: string }, [string]>("SELECT id FROM decisions WHERE command_id=? AND status='submitted'").get(`collaboration:${activationId}`);
1599
+ const current = decision ? this.getDecision(decision.id) : null;
1600
+ if (current) this.transitionDecision(current.id, current.revision, { status: "delivered", reason: "collaboration_answer_consumed" });
1601
+ }
1602
+
1603
+ linkAgentDecision(decisionId: string, activationId: string): void {
1604
+ this.db.transaction(() => {
1605
+ const activation = this.getAgentActivation(activationId);
1606
+ const decision = this.getDecision(decisionId);
1607
+ const request = activation ? this.agentExecutionRequest(activation) : null;
1608
+ // The executing input may still await its consumption evidence (recorded at settle); the exact current binding proves it runs.
1609
+ if (!activation || !activation.binding || !decision || decision.ownerConv !== activation.targetConv ||
1610
+ !["attempted", "acknowledged", "consumed"].includes(activation.state) || activation.quiesced ||
1611
+ !isDeepStrictEqual(this.agentBinding(activation.targetConv), activation.binding) ||
1612
+ this.getConversation(activation.targetConv)?.threadId !== activation.binding.sessionPath ||
1613
+ (activation.binding.agentId !== null && this.ensureAgentLifecycle(activation.binding.agentId) !== activation.binding.lifecycleEpoch) ||
1614
+ (request && (request.targetConv !== activation.targetConv || agentRequestTerminal(request.state) || request.state === "cancel_requested"))) throw new CollaborationStoreError("decision_link_conflict");
1615
+ this.db.query("INSERT INTO agent_question_links(decision_id,request_id,activation_id,origin_input_seq) VALUES (?,?,?,?)").run(decisionId, request?.id ?? null, activation.id, activation.inputSeq);
1616
+ if (request) this.writeAgentRequest({ ...request, state: "waiting_owner", revision: request.revision + 1 }, request.revision);
1617
+ }).immediate();
1618
+ }
1619
+
1620
+ agentResult(id: string): AgentResult | null {
1621
+ const row = this.db.query<{ data: string }, [string]>("SELECT data FROM agent_results WHERE id=?").get(id);
1622
+ return row ? JSON.parse(row.data) : null;
1623
+ }
1624
+
1625
+ /** The explicit result the executing activation submitted, committed only after that activation settles. */
1626
+ agentResultIntent(activationId: string): AgentResultInput | null {
1627
+ const row = this.db.query<{ data: string }, [string]>("SELECT data FROM events WHERE type='agent.collaboration.result_intent' AND json_extract(data,'$.activationId')=? ORDER BY seq DESC LIMIT 1").get(activationId);
1628
+ return row ? (JSON.parse(row.data) as { input: AgentResultInput }).input : null;
1629
+ }
1630
+
1631
+ /** One explicit result per activation: the same submission returns its receipt, a different one is a conflict. */
1632
+ recordAgentResultIntent(input: AgentResultInput, binding: CollaborationBinding): AgentReceipt {
1633
+ return this.db.transaction(() => {
1634
+ if (binding.readContinuationId !== undefined) throw new CollaborationStoreError("read_only_continuation", 403);
1635
+ this.assertAgentBinding(binding);
1636
+ const activation = binding.activationId === null ? null : this.getAgentActivation(binding.activationId);
1637
+ const request = this.getAgentRequest(input.requestId);
1638
+ if (!activation || !request || this.agentExecutionRequest(activation)?.id !== request.id || request.targetConv !== binding.convId ||
1639
+ !isDeepStrictEqual(activation.binding, binding) || activation.state !== "consumed") throw new CollaborationStoreError("result_binding_conflict");
1640
+ const prior = this.agentResultIntent(activation.id);
1641
+ if (prior) {
1642
+ if (prior.toolCallId !== input.toolCallId || collaborationDigest({ ...prior, expectedRevision: 0 }) !== collaborationDigest({ ...input, expectedRevision: 0 })) {
1643
+ throw new CollaborationStoreError("result_conflict");
1644
+ }
1645
+ return { requestId: request.id, state: request.state, revision: request.revision };
1646
+ }
1647
+ if (request.state !== "result_preparing" || request.revision !== input.expectedRevision || request.resultId !== null ||
1648
+ activation.quiesced) throw new CollaborationStoreError("result_conflict");
1649
+ if (Buffer.byteLength(input.text) > 65536 || input.artifacts.length > 10) throw new CollaborationStoreError("result_too_large", 413);
1650
+ for (const id of input.artifacts) {
1651
+ const artifact = this.agentArtifact(id);
1652
+ if (!artifact || artifact.requestId !== request.id || artifact.provenance.producedBy?.activationId !== activation.id || artifact.state !== "staged") {
1653
+ throw new CollaborationStoreError("result_artifact_conflict");
1654
+ }
1655
+ }
1656
+ this.appendEvent(binding.convId, "agent.collaboration.result_intent", { activationId: activation.id, input });
1657
+ return { requestId: request.id, state: request.state, revision: request.revision };
1658
+ }).immediate();
1659
+ }
1660
+
1661
+ /**
1662
+ * After the executing activation settled: commit its submitted result (and the reply outbox) once, or mark a running
1663
+ * request that ended without a result as interrupted/result_missing. Waiting requests keep waiting; nothing reruns.
1664
+ */
1665
+ finalizeAgentActivation(activationId: string, gateFor: (request: AgentRequest) => CollaborationGate): { readonly result: AgentResult | null; readonly missing: boolean } {
1666
+ return this.db.transaction(() => {
1667
+ const none = { result: null, missing: false };
1668
+ const activation = this.getAgentActivation(activationId);
1669
+ if (!activation?.binding || !activation.quiesced || activation.state !== "consumed") return none;
1670
+ const request = this.agentExecutionRequest(activation);
1671
+ if (!request || request.targetConv !== activation.targetConv) return none;
1672
+ if (request.state === "cancel_requested") {
1673
+ // The cancel won before any result commit: a late result stays an audited intent and wakes nobody.
1674
+ this.settleCancelledAgentRequest(request.id);
1675
+ return none;
1676
+ }
1677
+ const intent = this.agentResultIntent(activation.id);
1678
+ if (intent) {
1679
+ if (request.state !== "result_preparing" || request.resultId !== null) return none;
1680
+ return { result: this.commitAgentResult({ ...intent, expectedRevision: request.revision }, activation.binding, gateFor(request)), missing: false };
1681
+ }
1682
+ if ((request.state !== "running" && request.state !== "result_preparing") || this.agentRequestBlocked(request.id) ||
1683
+ this.db.query(`SELECT id FROM agent_requests WHERE parent_id=? AND state NOT IN ${AGENT_REQUEST_FINAL_SQL}`).get(request.id)) return none;
1684
+ this.replyWithoutResult(this.writeAgentRequest({ ...request, state: "interrupted", revision: request.revision + 1, cancelReason: "result_missing" }, request.revision), "result_missing");
1685
+ this.appendEvent(request.targetConv, "agent.collaboration.result_missing", { requestId: request.id, activationId: activation.id });
1686
+ return { result: null, missing: true };
1687
+ }).immediate();
1688
+ }
1689
+
1690
+ commitAgentResult(input: AgentResultInput, binding: CollaborationBinding, gate: CollaborationGate): AgentResult {
1691
+ return this.db.transaction(() => {
1692
+ if (binding.readContinuationId !== undefined) throw new CollaborationStoreError("read_only_continuation", 403);
1693
+ const key = JSON.stringify([binding.durableSessionId, input.toolCallId, "result"]);
1694
+ const prior = this.agentOperation<AgentResult>(key, { input, binding });
1695
+ if (prior) return prior;
1696
+ const request = this.getAgentRequest(input.requestId);
1697
+ const activation = binding.activationId === null ? null : this.getAgentActivation(binding.activationId);
1698
+ if (!request || !activation || this.agentExecutionRequest(activation)?.id !== request.id || request.targetConv !== binding.convId ||
1699
+ !isDeepStrictEqual(activation.binding, binding)) throw new CollaborationStoreError("result_binding_conflict");
1700
+ this.checkAgentAccess(gate, binding, this.agentTarget(request.targetAgent));
1701
+ if (binding.lifecycleEpoch !== request.targetLifecycleEpoch) throw new CollaborationStoreError("lifecycle_conflict");
1702
+ if (request.revision !== input.expectedRevision || request.resultId !== null || !["running", "result_preparing"].includes(request.state) ||
1703
+ activation.state !== "consumed" || !activation.quiesced || this.agentRequestBlocked(request.id) ||
1704
+ this.db.query(`SELECT id FROM agent_requests WHERE parent_id=? AND state NOT IN ${AGENT_REQUEST_FINAL_SQL}`).get(request.id)) throw new CollaborationStoreError("result_conflict");
1705
+ if (Buffer.byteLength(input.text) > 65536 || input.artifacts.length > 10) throw new CollaborationStoreError("result_too_large", 413);
1706
+ const resultId = crypto.randomUUID();
1707
+ const manifest = input.artifacts.map(id => {
1708
+ const artifact = this.agentArtifact(id);
1709
+ if (!artifact || artifact.producerConv !== binding.convId || artifact.requestId !== request.id || artifact.provenance.producedBy?.activationId !== activation.id || artifact.state !== "staged") throw new CollaborationStoreError("result_artifact_conflict");
1710
+ const committed: AgentArtifact = { ...artifact, state: "committed", resultId };
1711
+ this.db.query("UPDATE agent_artifacts SET data=? WHERE id=?").run(JSON.stringify(committed), artifact.id);
1712
+ const recipient = this.getConversation(request.requesterConv);
1713
+ if (!recipient) throw new CollaborationStoreError("requester_not_found", 404);
1714
+ this.createAgentGrant(committed, { convId: request.requesterConv, lifecycleEpoch: recipient.agentId ? this.ensureAgentLifecycle(recipient.agentId) : 1,
1715
+ authorityEpoch: gate.mode === "authority" && recipient.agentId ? gate.access(this.db, recipient.agentId).authorityEpoch : undefined });
1716
+ return committed;
1717
+ });
1718
+ const result: AgentResult = { id: resultId, requestId: request.id, activationId: activation.id, submissionKey: key, payloadSha: collaborationDigest(input), text: input.text, outcome: input.outcome, manifest, committedAt: new Date().toISOString() };
1719
+ this.db.query("INSERT INTO agent_results(id,data) VALUES (?,?)").run(result.id, JSON.stringify(result));
1720
+ const completed = this.writeAgentRequest({ ...request, state: input.outcome, revision: request.revision + 1, resultId: result.id }, request.revision);
1721
+ const message = this.appendEvent(request.requesterConv, "message.collaboration", { requestId: request.id, rootId: request.rootId, resultId: result.id, direction: "reply", trust: "delegated", text: input.text, manifest });
1722
+ this.queueCollaborationEvent(message);
1723
+ this.queueCollaborationEvent(this.appendEvent(request.targetConv, "message.collaboration", { requestId: request.id, resultId: result.id, direction: "reply", outgoing: true, trust: "delegated" }));
1724
+ const parent = request.parentId === null ? null : this.getAgentRequest(request.parentId);
1725
+ if (!parent || (!agentRequestTerminal(parent.state) && parent.state !== "cancel_requested")) this.newAgentActivation(completed, "reply", message.seq);
1726
+ return this.saveAgentOperation(binding.convId, { key, payload: { input, binding }, receipt: result });
1727
+ }).immediate();
1728
+ }
1729
+
1730
+ cancelAgentRequest(input: { readonly requestId: string; readonly expectedRevision: number; readonly reason: string }, binding: CollaborationBinding): AgentCancelReceipt {
1731
+ return this.db.transaction(() => {
1732
+ this.assertAgentBinding(binding);
1733
+ const request = this.getAgentRequest(input.requestId);
1734
+ if (!request) throw new CollaborationStoreError("request_not_found", 404);
1735
+ if (request.requesterConv !== binding.convId) throw new CollaborationStoreError("cancel_forbidden", 403);
1736
+ return this.cancelAgentRequestTree(request, input);
1737
+ }).immediate();
1738
+ }
1739
+
1740
+ /** The owner (authenticated at the caller boundary) may cancel any request tree; the same CAS rules apply. */
1741
+ cancelAgentRequestAsOwner(input: { readonly requestId: string; readonly expectedRevision: number; readonly reason: string }): AgentCancelReceipt {
1742
+ return this.db.transaction(() => {
1743
+ const request = this.getAgentRequest(input.requestId);
1744
+ if (!request) throw new CollaborationStoreError("request_not_found", 404);
1745
+ return this.cancelAgentRequestTree(request, input);
1746
+ }).immediate();
1747
+ }
1748
+
1749
+ /**
1750
+ * The owner pressed stop on the conversation's current run, which executes this activation's request: the same owner
1751
+ * cancel as the cancel route, from the request's current revision. Null when that run executes no open request.
1752
+ */
1753
+ cancelStoppedAgentExecution(convId: string, activationId: string, reason: string): AgentCancelReceipt | null {
1754
+ return this.db.transaction(() => {
1755
+ const activation = this.getAgentActivation(activationId);
1756
+ if (!activation || activation.targetConv !== convId || this.agentBinding(convId)?.activationId !== activationId || activation.quiesced ||
1757
+ !["attempted", "acknowledged", "consumed"].includes(activation.state)) return null;
1758
+ const request = this.agentExecutionRequest(activation);
1759
+ if (!request || agentRequestTerminal(request.state) || request.state === "cancel_requested") return null;
1760
+ return this.cancelAgentRequestTree(request, { expectedRevision: request.revision, reason });
1761
+ }).immediate();
1762
+ }
1763
+
1764
+ /** A reply delivered to a root requester executes no request: once its run ends, nothing else waits to settle it. */
1765
+ agentReplyWithoutExecution(activationId: string): boolean {
1766
+ const activation = this.getAgentActivation(activationId);
1767
+ return activation?.direction === "reply" && this.agentExecutionRequest(activation) === null;
1768
+ }
1769
+
1770
+ /** A final request (interrupted and version_conflict included) is never rewritten; the receipt reports each descendant's state. */
1771
+ private cancelAgentRequestTree(request: AgentRequest, input: { readonly expectedRevision: number; readonly reason: string }): AgentCancelReceipt {
1772
+ return this.db.transaction(() => {
1773
+ if (request.revision !== input.expectedRevision || agentRequestTerminal(request.state)) throw new CollaborationStoreError("request_revision_conflict");
1774
+ const rows = this.db.query<{ data: string }, [string]>(`WITH RECURSIVE tree AS (SELECT * FROM agent_requests WHERE id=? UNION ALL SELECT c.* FROM agent_requests c JOIN tree p ON c.parent_id=p.id) SELECT data FROM tree`).all(request.id);
1775
+ const descendants = rows.map(row => (JSON.parse(row.data) as AgentRequest).id)
1776
+ .filter(id => id !== request.id);
1777
+ let receipt: AgentReceipt = { requestId: request.id, state: request.state, revision: request.revision };
1778
+ const cancelled: AgentRequest[] = [];
1779
+ for (const row of rows.reverse()) {
1780
+ const child: AgentRequest = JSON.parse(row.data);
1781
+ if (agentRequestTerminal(child.state)) continue;
1782
+ const active = this.db.query<{ data: string }, [string, string]>(`SELECT a.data FROM agent_activations a JOIN agent_requests r ON r.id=a.request_id
1783
+ WHERE a.state NOT IN ('queued','rejected') AND a.quiesced=0 AND (a.request_id=? OR r.parent_id=?)`).all(child.id, child.id)
1784
+ .some(row => { const activation: AgentActivation = JSON.parse(row.data); return this.agentExecutionRequest(activation)?.id === child.id; }) ||
1785
+ this.db.query<{ data: string }, []>(`SELECT r.data FROM events r WHERE r.type='agent.collaboration.reader'
1786
+ AND NOT EXISTS (SELECT 1 FROM events s WHERE s.type='agent.collaboration.reader.settled' AND json_extract(s.data,'$.readId')=json_extract(r.data,'$.readId'))`).all()
1787
+ .some(row => {
1788
+ const reader: { grant: ArtifactGrant; binding: CollaborationBinding } = JSON.parse(row.data);
1789
+ const activation = reader.binding.activationId === null ? null : this.getAgentActivation(reader.binding.activationId);
1790
+ return (activation === null ? reader.grant.requestId : this.agentExecutionRequest(activation)?.id) === child.id;
1791
+ }) ||
1792
+ this.db.query(`SELECT h.id FROM security_holds h JOIN agent_question_links l ON l.decision_id=h.id WHERE l.request_id=?
1793
+ AND json_extract(h.data,'$.source')='collaboration_artifact' AND json_extract(h.data,'$.continuation.dispatchReceiptId') IS NOT NULL
1794
+ AND NOT EXISTS (SELECT 1 FROM events e WHERE e.type='agent.collaboration.read.settled' AND json_extract(e.data,'$.holdId')=h.id)`).get(child.id) !== null ||
1795
+ this.db.query("SELECT transfer_id FROM agent_artifact_stages WHERE origin_request_id=? AND state IN ('staging','unknown')").get(child.id) !== null;
1796
+ const unsettledChild = this.db.query(`SELECT id FROM agent_requests WHERE parent_id=? AND state NOT IN ${AGENT_REQUEST_FINAL_SQL}`).get(child.id);
1797
+ const next = this.writeAgentRequest({ ...child, state: active || unsettledChild ? "cancel_requested" : "cancelled", revision: child.revision + 1, cancelReason: input.reason }, child.revision);
1798
+ this.closeQueuedAgentInputs(child.id);
1799
+ if (next.state === "cancelled") cancelled.push(next);
1800
+ if (child.id === request.id) receipt = { requestId: child.id, state: next.state, revision: next.revision };
1801
+ }
1802
+ // After the whole tree changed, so a child inside it never replies to a parent cancelled with it.
1803
+ for (const next of cancelled) this.replyWithoutResult(next, input.reason);
1804
+ return { ...receipt, children: descendants.map(id => {
1805
+ const child = this.getAgentRequest(id);
1806
+ if (!child) throw new CollaborationStoreError("request_not_found", 404);
1807
+ return { requestId: child.id,
1808
+ state: child.state, revision: child.revision };
1809
+ }) };
1810
+ }).immediate();
1811
+ }
1812
+
1813
+ listAgentRequests(conv: string, cursor?: string): { readonly requests: readonly AgentRequest[]; readonly nextCursor: string | null } {
1814
+ let after: { createdAt: string; id: string } | null = null;
1815
+ if (cursor !== undefined) {
1816
+ let value: unknown;
1817
+ try { value = JSON.parse(Buffer.from(cursor, "base64url").toString()); }
1818
+ catch (error) {
1819
+ if (error instanceof SyntaxError) throw new CollaborationStoreError("invalid_request_cursor", 400);
1820
+ throw error;
1821
+ }
1822
+ if (!object(value) || typeof value.createdAt !== "string" || typeof value.id !== "string") throw new CollaborationStoreError("invalid_request_cursor", 400);
1823
+ after = { createdAt: value.createdAt, id: value.id };
1824
+ }
1825
+ const rows = this.db.query<{ data: string; id: string; created_at: string }, [string, string, string, string, string, string]>("SELECT data,id,created_at FROM agent_requests WHERE (requester_conv=? OR target_conv=?) AND (?='' OR created_at<? OR (created_at=? AND id<?)) ORDER BY created_at DESC,id DESC LIMIT 51").all(conv, conv, after?.createdAt ?? "", after?.createdAt ?? "", after?.createdAt ?? "", after?.id ?? "");
1826
+ const page = rows.slice(0, 50);
1827
+ const last = page.at(-1);
1828
+ return { requests: page.map(row => JSON.parse(row.data)), nextCursor: rows.length > 50 && last ? Buffer.from(JSON.stringify({ createdAt: last.created_at, id: last.id })).toString("base64url") : null };
1829
+ }
1830
+
1831
+ /** Writer-transaction lineage: admitted inputs, actual release claims, and consumed child replies only. */
1832
+ private deriveOutputLineage(requestId: string): { readonly storageClass: AgentArtifact["storageClass"]; readonly inputArtifactIds: readonly string[] } {
1833
+ const inputs = new Set<string>();
1834
+ const pending = new Set([requestId]);
1835
+ for (const id of pending) {
1836
+ const request = this.getAgentRequest(id);
1837
+ if (!request) throw new CollaborationStoreError("request_not_found", 404);
1838
+ for (const artifactId of request.payload.files) inputs.add(artifactId);
1839
+ const activations = this.db.query<{ data: string }, [string, string, string]>(`SELECT a.data FROM agent_activations a
1840
+ JOIN agent_requests r ON r.id=a.request_id WHERE a.target_conv=? AND a.state='consumed' AND (a.request_id=? OR r.parent_id=?)`)
1841
+ .all(request.targetConv, id, id).map(row => {
1842
+ const activation: AgentActivation = JSON.parse(row.data);
1843
+ return activation;
1844
+ });
1845
+ // Both raw and held release use the original reader operation. A fresh held binding does not change its owner.
1846
+ const readers = this.db.query<{ data: string }, [string]>(`SELECT r.data FROM events r JOIN events e
1847
+ ON json_extract(e.data,'$.readOperationId')=json_extract(r.data,'$.readId')
1848
+ LEFT JOIN security_holds h ON h.id=json_extract(e.data,'$.holdId')
1849
+ WHERE r.type='agent.collaboration.reader' AND r.conv=? AND e.type='agent.collaboration.release'
1850
+ AND (json_extract(e.data,'$.holdId') IS NULL OR json_extract(h.data,'$.continuation.releaseReceiptId')=CAST(e.seq AS TEXT))`)
1851
+ .all(request.targetConv).map(row => {
1852
+ const reader: { readonly grant: ArtifactGrant; readonly binding: CollaborationBinding } = JSON.parse(row.data);
1853
+ return reader;
1854
+ });
1855
+ for (const activation of activations) {
1856
+ if (this.agentExecutionRequest(activation)?.id !== id) continue;
1857
+ for (const reader of readers) {
1858
+ if (isDeepStrictEqual(reader.binding, activation.binding)) inputs.add(reader.grant.artifactId);
1859
+ }
1860
+ switch (activation.direction) {
1861
+ case "request": case "owner_answer": break;
1862
+ case "reply": {
1863
+ // A reply to a child that ended without a result carries no files.
1864
+ if (this.getAgentRequest(activation.requestId)?.resultId != null) {
1865
+ const row = this.db.query<{ data: string }, [string]>("SELECT data FROM agent_results WHERE request_id=?").get(activation.requestId);
1866
+ if (!row) throw new CollaborationStoreError("result_not_found", 404);
1867
+ const result: AgentResult = JSON.parse(row.data);
1868
+ for (const artifact of result.manifest) inputs.add(artifact.id);
1869
+ }
1870
+ // The child's inputs remain relevant even if its committed result has no files.
1871
+ pending.add(activation.requestId);
1872
+ break;
1873
+ }
1874
+ default: collaborationNever(activation.direction);
1875
+ }
1876
+ }
1877
+ }
1878
+ const inputArtifactIds = [...inputs].sort();
1879
+ return { inputArtifactIds, storageClass: inputArtifactIds.some(id => this.agentArtifact(id)?.storageClass === "scoped_document") ? "scoped_document" : "source_copy" };
1880
+ }
1881
+
1882
+ prepareAgentResult(input: { readonly requestId: string; readonly expectedRevision: number; readonly files: readonly { readonly sha256: string; readonly bytes: number; readonly mediaType: string; readonly name: string; readonly sealedName: string }[] }, binding: CollaborationBinding): AgentArtifact[] {
1883
+ return this.db.transaction(() => {
1884
+ if (binding.readContinuationId !== undefined) throw new CollaborationStoreError("read_only_continuation", 403);
1885
+ this.assertAgentBinding(binding);
1886
+ const request = this.getAgentRequest(input.requestId);
1887
+ const activation = binding.activationId === null ? null : this.getAgentActivation(binding.activationId);
1888
+ if (!request || !activation || request.targetConv !== binding.convId || this.agentExecutionRequest(activation)?.id !== request.id ||
1889
+ !isDeepStrictEqual(activation.binding, binding) || binding.lifecycleEpoch !== request.targetLifecycleEpoch ||
1890
+ activation.state !== "consumed" || request.state !== "running" || request.revision !== input.expectedRevision || this.agentRequestBlocked(request.id)) throw new CollaborationStoreError("result_conflict");
1891
+ if (input.files.length > 10) throw new CollaborationStoreError("result_too_large", 413);
1892
+ const lineage = this.deriveOutputLineage(request.id);
1893
+ const artifacts = input.files.map((file, ordinal): AgentArtifact => {
1894
+ if (!/^[a-f0-9]{64}$/.test(file.sha256) || !Number.isSafeInteger(file.bytes) || file.bytes < 0 || file.bytes > 10485760 || !file.sealedName) throw new CollaborationStoreError("invalid_artifact", 400);
1895
+ const artifact: AgentArtifact = { id: crypto.randomUUID(), producerConv: binding.convId, requestId: request.id, sha256: file.sha256, bytes: file.bytes, mediaType: file.mediaType,
1896
+ originalName: file.name, sealedName: file.sealedName, state: "staged", lifecycleEpoch: binding.lifecycleEpoch, storageClass: lineage.storageClass,
1897
+ stageOperationKey: JSON.stringify(["result", activation.id, ordinal]), stagePayloadSha: collaborationDigest(file),
1898
+ provenance: { transferId: null, transferAuthorityRef: null, targetAgentId: request.requesterAgent ?? "main", targetConvId: request.requesterConv,
1899
+ targetLifecycleEpoch: request.requesterAgent ? this.ensureAgentLifecycle(request.requesterAgent) : 1,
1900
+ sourceInputId: binding.inputId, sourceActivationId: activation.id, requestId: request.id, rootId: request.rootId,
1901
+ producedBy: { requestId: request.id, activationId: activation.id, producerConv: binding.convId }, inputArtifactIds: lineage.inputArtifactIds } };
1902
+ this.db.query("INSERT INTO agent_artifacts(id,data) VALUES (?,?)").run(artifact.id, JSON.stringify(artifact));
1903
+ return artifact;
1904
+ });
1905
+ this.writeAgentRequest({ ...request, state: "result_preparing", revision: request.revision + 1 }, request.revision);
1906
+ return artifacts;
1907
+ }).immediate();
1908
+ }
1909
+
1910
+ agentArtifactGrant(artifactId: string, conv: string, requestId: string): ArtifactGrant | null {
1911
+ const row = this.db.query<{ data: string }, [string, string, string]>("SELECT data FROM agent_artifact_grants WHERE artifact_id=? AND grantee_conv=? AND request_id=? AND permission='read'").get(artifactId, conv, requestId);
1912
+ return row ? JSON.parse(row.data) : null;
1913
+ }
1914
+
1915
+ private assertArtifactGrant(grant: ArtifactGrant, binding: CollaborationBinding, gate: CollaborationGate): AgentArtifact {
1916
+ this.assertAgentBinding(binding);
1917
+ const artifact = this.agentArtifact(grant.artifactId);
1918
+ const current = this.agentGrant(grant.grantId);
1919
+ if (!artifact || artifact.state === "trashed" || !current || current.revoked) throw new CollaborationStoreError("artifact_revoked", 410);
1920
+ if (!isDeepStrictEqual(current, grant) || grant.granteeConv !== binding.convId || grant.granteeLifecycleEpoch !== binding.lifecycleEpoch || artifact.requestId !== grant.requestId) throw new CollaborationStoreError("artifact_review_stale");
1921
+ const request = this.getAgentRequest(grant.requestId);
1922
+ if (!request || request.state === "cancel_requested" || request.state === "cancelled") throw new CollaborationStoreError("read_cancelled");
1923
+ if (gate.mode === "authority" && binding.agentId !== null) {
1924
+ const access = gate.access(this.db, binding.agentId);
1925
+ if (access.accessState === "blocked") throw new CollaborationStoreError("agent_access_blocked", 403);
1926
+ if (access.authorityEpoch !== grant.recipientAuthorityEpoch || access.lifecycleEpoch !== binding.lifecycleEpoch) throw new CollaborationStoreError("authority_epoch_mismatch");
1927
+ }
1928
+ return artifact;
1929
+ }
1930
+
1931
+ private assertAgentReadExecution(binding: CollaborationBinding, hold?: ArtifactSecurityHold): AgentRequest | null {
1932
+ const activationId = hold ? hold.readScope.activationId : binding.activationId;
1933
+ if (activationId === null) return null;
1934
+ const activation = this.getAgentActivation(activationId);
1935
+ if (!activation || activation.state !== "consumed" ||
1936
+ (!hold?.continuation.targetBinding && !isDeepStrictEqual(activation.binding, binding))) throw new CollaborationStoreError("binding_conflict");
1937
+ const execution = this.agentExecutionRequest(activation);
1938
+ if (execution && (execution.state === "cancel_requested" || execution.state === "cancelled")) throw new CollaborationStoreError("read_cancelled");
1939
+ if (execution && (execution.targetConv !== binding.convId || execution.targetLifecycleEpoch !== binding.lifecycleEpoch)) throw new CollaborationStoreError("binding_conflict");
1940
+ if (execution && (execution.state === "result_preparing" || agentRequestTerminal(execution.state) || this.agentRequestBlocked(execution.id, hold?.decisionId ?? ""))) throw new CollaborationStoreError("request_blocked");
1941
+ return execution;
1942
+ }
1943
+
1944
+ beginAgentArtifactRead(input: { readonly requestId: string; readonly artifactId: string; readonly readOperationId: string }, binding: CollaborationBinding, gate: CollaborationGate): { readonly readId: string; readonly grant: ArtifactGrant } {
1945
+ return this.db.transaction(() => {
1946
+ if (binding.readContinuationId !== undefined) throw new CollaborationStoreError("read_only_continuation", 403);
1947
+ const key = JSON.stringify(["artifact-read", input.readOperationId]);
1948
+ const prior = this.agentOperation<{ readId: string; grant: ArtifactGrant }>(key, { input, binding });
1949
+ if (prior) throw new CollaborationStoreError("read_already_started");
1950
+ const grant = this.agentArtifactGrant(input.artifactId, binding.convId, input.requestId);
1951
+ if (!grant) throw new CollaborationStoreError("artifact_forbidden", 403);
1952
+ this.assertArtifactGrant(grant, binding, gate);
1953
+ this.assertAgentReadExecution(binding);
1954
+ const receipt = { readId: input.readOperationId, grant };
1955
+ this.appendEvent(binding.convId, "agent.collaboration.reader", { readId: input.readOperationId, grant, binding });
1956
+ return this.saveAgentOperation(binding.convId, { key, payload: { input, binding }, receipt });
1957
+ }).immediate();
1958
+ }
1959
+
1960
+ settleAgentArtifactReader(readId: string, evidence: { readonly helperStopped: boolean; readonly leaseReleased: boolean }): void {
1961
+ this.db.transaction(() => {
1962
+ if (!evidence.helperStopped || !evidence.leaseReleased) throw new CollaborationStoreError("reader_not_settled");
1963
+ const row = this.db.query<{ conv: string }, [string]>("SELECT conv FROM events WHERE type='agent.collaboration.reader' AND json_extract(data,'$.readId')=?").get(readId);
1964
+ if (!row) throw new CollaborationStoreError("read_not_found", 404);
1965
+ if (this.db.query("SELECT seq FROM events WHERE type='agent.collaboration.reader.settled' AND json_extract(data,'$.readId')=?").get(readId)) return;
1966
+ this.appendEvent(row.conv, "agent.collaboration.reader.settled", { readId, ...evidence });
1967
+ }).immediate();
1968
+ }
1969
+
1970
+ claimAgentArtifactReadRelease(readId: string, binding: CollaborationBinding, gate: CollaborationGate): { readonly readId: string; readonly grantRevision: number; readonly artifactId: string; readonly sha256: string; readonly releaseSeq: number } {
1971
+ return this.db.transaction(() => {
1972
+ const row = this.db.query<{ data: string }, [string]>("SELECT data FROM events WHERE type='agent.collaboration.reader' AND json_extract(data,'$.readId')=?").get(readId);
1973
+ if (!row) throw new CollaborationStoreError("read_not_found", 404);
1974
+ const reader: { grant: ArtifactGrant; binding: CollaborationBinding } = JSON.parse(row.data);
1975
+ if (!isDeepStrictEqual(reader.binding, binding)) throw new CollaborationStoreError("binding_conflict");
1976
+ if (this.db.query("SELECT id FROM security_holds WHERE json_extract(data,'$.readOperationId')=?").get(readId)) throw new CollaborationStoreError("artifact_review_required", 403);
1977
+ if (this.db.query("SELECT seq FROM events WHERE type='agent.collaboration.release' AND json_extract(data,'$.readOperationId')=?").get(readId)) throw new CollaborationStoreError("read_already_released");
1978
+ const artifact = this.assertArtifactGrant(reader.grant, binding, gate);
1979
+ this.assertAgentReadExecution(binding);
1980
+ const event = this.appendEvent(binding.convId, "agent.collaboration.release", { kind: "collaboration_artifact", readOperationId: readId, artifactId: artifact.id,
1981
+ requestId: reader.grant.requestId, grantRevision: reader.grant.revision, sha256: artifact.sha256, releaseState: "claimed" });
1982
+ return { readId, grantRevision: reader.grant.revision, artifactId: artifact.id, sha256: artifact.sha256, releaseSeq: event.seq };
1983
+ }).immediate();
1984
+ }
1985
+
1986
+ private agentReaders(grantId: string): { readId: string; state: "stop_requested" | "release_preceded_revoke" | "unknown" }[] {
1987
+ return this.db.query<{ readId: string }, [string]>(`SELECT json_extract(r.data,'$.readId') readId FROM events r WHERE r.type='agent.collaboration.reader' AND json_extract(r.data,'$.grant.grantId')=?
1988
+ AND NOT EXISTS (SELECT 1 FROM events s WHERE s.type='agent.collaboration.reader.settled' AND json_extract(s.data,'$.readId')=json_extract(r.data,'$.readId'))`).all(grantId).map(row => {
1989
+ const release = this.db.query("SELECT seq FROM events WHERE type='agent.collaboration.release' AND json_extract(data,'$.readOperationId')=?").get(row.readId);
1990
+ return { readId: row.readId, state: release ? "release_preceded_revoke" : "stop_requested" };
1991
+ });
1992
+ }
1993
+
1994
+ revokeAgentArtifactGrant(input: { readonly requestId: string; readonly artifactId: string; readonly grantId: string; readonly expectedRevision: number; readonly operationId: string; readonly ownerPrincipalRef: string; readonly granteeConv: string }) {
1995
+ return this.db.transaction(() => {
1996
+ const key = JSON.stringify(["grant-revoke", input.ownerPrincipalRef, input.operationId]);
1997
+ type Receipt = { operationId: string; requestId: string; artifactId: string; grantId: string; previousRevision: number; revision: number; state: "revoked"; changed: boolean; requestState: string; activeReaders: ReturnType<Store["agentReaders"]>; affectedTransfers: { transferId: string; state: string }[] };
1998
+ const prior = this.agentOperation<Receipt>(key, input);
1999
+ if (prior) return prior;
2000
+ const grant = this.agentGrant(input.grantId);
2001
+ const artifact = this.agentArtifact(input.artifactId);
2002
+ const request = this.getAgentRequest(input.requestId);
2003
+ if (!grant || !artifact || !request || grant.artifactId !== artifact.id || grant.requestId !== request.id || artifact.requestId !== request.id) throw new CollaborationStoreError("grant_not_found", 404);
2004
+ if (grant.granteeConv !== input.granteeConv || !input.ownerPrincipalRef) throw new CollaborationStoreError("grant_owner_mismatch", 403);
2005
+ if (artifact.state === "trashed") throw new CollaborationStoreError("artifact_revoked", 410);
2006
+ if (grant.revision !== input.expectedRevision) throw new CollaborationStoreError("grant_revision_conflict");
2007
+ const next = grant.revoked ? grant : { ...grant, revoked: true, revision: grant.revision + 1 };
2008
+ this.db.query("UPDATE agent_artifact_grants SET data=? WHERE grant_id=? AND revision=?").run(JSON.stringify(next), grant.grantId, grant.revision);
2009
+ const affectedTransfers: Receipt["affectedTransfers"] = [];
2010
+ for (const row of this.db.query<{ data: string }, [string]>("SELECT data FROM agent_artifact_stages WHERE json_extract(data,'$.source.grantId')=? AND state IN ('awaiting_transfer_authority','ready','staging','staged','unknown')").all(grant.grantId)) {
2011
+ const stage: AgentArtifactStage = JSON.parse(row.data);
2012
+ if (stage.artifactId && this.agentArtifact(stage.artifactId)?.requestId !== null) continue;
2013
+ const stopped: AgentArtifactStage = { ...stage, state: stage.state === "staging" || stage.state === "unknown" ? "unknown" : "invalidated", revision: stage.revision + 1, errorCode: "source_revoked" };
2014
+ this.db.query("UPDATE agent_artifact_stages SET data=? WHERE transfer_id=?").run(JSON.stringify(stopped), stage.transferId);
2015
+ affectedTransfers.push({ transferId: stage.transferId, state: stopped.state });
2016
+ }
2017
+ for (const row of this.db.query<{ data: string }, [string]>("SELECT data FROM security_holds WHERE json_extract(data,'$.source')='collaboration_artifact' AND json_extract(data,'$.readScope.artifactGrantId')=?").all(grant.grantId)) {
2018
+ const hold: ArtifactSecurityHold = JSON.parse(row.data);
2019
+ if (hold.continuation.releaseReceiptId === null) this.writeArtifactHold({ ...hold, state: "invalidated", revision: hold.revision + 1, invalidationReason: "grant_revoked",
2020
+ continuation: { ...hold.continuation, state: "invalidated" } });
2021
+ }
2022
+ return this.saveAgentOperation(input.granteeConv, { key, payload: input, receipt: { operationId: input.operationId, requestId: request.id, artifactId: artifact.id, grantId: grant.grantId,
2023
+ previousRevision: grant.revision, revision: next.revision, state: "revoked" as const, changed: !grant.revoked, requestState: request.state, activeReaders: this.agentReaders(grant.grantId), affectedTransfers } });
2024
+ }).immediate();
2025
+ }
2026
+
2027
+ artifactSecurityHold(id: string): ArtifactSecurityHold | null {
2028
+ const row = this.db.query<{ data: string }, [string]>("SELECT data FROM security_holds WHERE id=? AND json_extract(data,'$.source')='collaboration_artifact'").get(id);
2029
+ return row ? JSON.parse(row.data) : null;
2030
+ }
2031
+
2032
+ artifactHoldByContinuation(continuationId: string): ArtifactSecurityHold | null {
2033
+ const row = this.db.query<{ data: string }, [string]>("SELECT data FROM security_holds WHERE json_extract(data,'$.source')='collaboration_artifact' AND json_extract(data,'$.continuation.id')=?").get(continuationId);
2034
+ return row ? JSON.parse(row.data) : null;
2035
+ }
2036
+
2037
+ /** The exact binding that began this read; owner review and resume are checked against it, never a later binding. */
2038
+ artifactReaderBinding(readOperationId: string): CollaborationBinding {
2039
+ const row = this.db.query<{ data: string }, [string]>("SELECT data FROM events WHERE type='agent.collaboration.reader' AND json_extract(data,'$.readId')=?").get(readOperationId);
2040
+ if (!row) throw new CollaborationStoreError("read_not_found", 404);
2041
+ const reader: { binding: CollaborationBinding } = JSON.parse(row.data);
2042
+ return reader.binding;
2043
+ }
2044
+
2045
+ unsettledArtifactHolds(activationId: string): readonly ArtifactSecurityHold[] {
2046
+ return this.db.query<{ data: string }, [string]>(`SELECT data FROM security_holds WHERE json_extract(data,'$.source')='collaboration_artifact'
2047
+ AND json_extract(data,'$.readScope.activationId')=? AND json_extract(data,'$.continuation.settledReceipt') IS NULL
2048
+ AND json_extract(data,'$.continuation.targetBinding') IS NULL ORDER BY rowid`).all(activationId).map(row => JSON.parse(row.data));
2049
+ }
2050
+
2051
+ private writeArtifactHold(hold: ArtifactSecurityHold): ArtifactSecurityHold {
2052
+ this.db.query("UPDATE security_holds SET data=?,intent_submission=? WHERE id=?").run(JSON.stringify(hold), hold.answerSubmissionId, hold.id);
2053
+ this.db.query("UPDATE decisions SET revision=?,status=?,updated_at=? WHERE id=?").run(hold.revision,
2054
+ hold.state === "pending" ? "pending" : hold.state === "allowed" ? "delivered" : "closed", new Date().toISOString(), hold.decisionId);
2055
+ return hold;
2056
+ }
2057
+
2058
+ createArtifactSecurityHold(input: { readonly readOperationId: string; readonly representation: ArtifactSecurityHold["representation"]; readonly originInputSeq: number; readonly ownerPrincipalRef: string; readonly readArguments: ArtifactSecurityHold["readArguments"]; readonly summary?: ArtifactReviewSummary }, binding: CollaborationBinding, gate: CollaborationGate): ArtifactSecurityHold {
2059
+ return this.db.transaction(() => {
2060
+ const readerRow = this.db.query<{ data: string }, [string]>("SELECT data FROM events WHERE type='agent.collaboration.reader' AND json_extract(data,'$.readId')=?").get(input.readOperationId);
2061
+ if (!readerRow) throw new CollaborationStoreError("read_not_found", 404);
2062
+ const reader: { grant: ArtifactGrant; binding: CollaborationBinding } = JSON.parse(readerRow.data);
2063
+ if (!isDeepStrictEqual(reader.binding, binding)) throw new CollaborationStoreError("binding_conflict");
2064
+ const artifact = this.assertArtifactGrant(reader.grant, binding, gate);
2065
+ const request = artifact.requestId === null ? null : this.getAgentRequest(artifact.requestId);
2066
+ if (!request?.resultId || artifact.resultId !== request.resultId || this.getEvent(input.originInputSeq)?.conv !== binding.convId ||
2067
+ input.readArguments.requestId !== request.id || input.readArguments.artifactId !== artifact.id) throw new CollaborationStoreError("artifact_review_stale");
2068
+ const resourceRef = { kind: "collaboration_artifact" as const, requestId: request.id, artifactId: artifact.id, resultId: request.resultId, originalSha256: artifact.sha256 };
2069
+ const readScope: ArtifactSecurityHold["readScope"] = { artifactGrantId: reader.grant.grantId, grantRevision: reader.grant.revision, granteeConv: binding.convId, lifecycleEpoch: binding.lifecycleEpoch,
2070
+ inputId: binding.inputId, activationId: binding.activationId, managedBindingDigest: collaborationDigest(binding),
2071
+ ...(reader.grant.recipientAuthorityEpoch === undefined ? {} : { recipientAuthorityEpoch: reader.grant.recipientAuthorityEpoch }) };
2072
+ const scopeDigest = collaborationDigest([resourceRef, input.representation, readScope, "collaboration_artifact_review"]);
2073
+ const prior = this.db.query<{ data: string }, [string]>("SELECT data FROM security_holds WHERE source_key=?").get(`artifact:${scopeDigest}`);
2074
+ if (prior) return JSON.parse(prior.data);
2075
+ const dependent = this.assertAgentReadExecution(binding);
2076
+ const id = crypto.randomUUID();
2077
+ const hold: ArtifactSecurityHold = { id, decisionId: id, revision: 1, source: "collaboration_artifact", purpose: "collaboration_artifact_review", state: "pending", resourceRef, ...(input.summary ? { summary: input.summary } : {}),
2078
+ representation: input.representation, readScope, scopeDigest, readOperationId: input.readOperationId, ownerPrincipalRef: input.ownerPrincipalRef,
2079
+ answerSubmissionId: null, reviewReceipt: null, readArguments: input.readArguments, invalidationReason: null,
2080
+ continuation: { id: crypto.randomUUID(), originInputSeq: input.originInputSeq, originOwnerInputRevision: binding.ownerInputRevision, settledReceipt: null, state: "unrequested",
2081
+ resumeSubmissionId: null, reviewedScopeDigest: null, targetBinding: null, boundScopeDigest: null, dispatchReceiptId: null, releaseReceiptId: null } };
2082
+ this.db.query("INSERT INTO security_holds(id,source_key,data) VALUES (?,?,?)").run(id, `artifact:${scopeDigest}`, JSON.stringify(hold));
2083
+ const now = new Date().toISOString();
2084
+ this.insertDecision({ id, revision: 1, ownerConv: binding.convId, scope: `security:${binding.convId}`, workId: null, sourceSessionPath: null, sourceRequestId: id, sourceCommandSeq: null, legacyActionId: null,
2085
+ method: "confirm", permission: false, required: false, payload: { kind: "security-check", purpose: "collaboration_artifact_review", holdId: id, scopeDigest }, bindingState: "closed", deadlineAt: null,
2086
+ status: "pending", submissionId: null, submissionRevision: null, submissionMode: null, answer: null, commandId: null, answerRoute: null, supersededBy: null, reason: null, createdAt: now, updatedAt: now });
2087
+ if (binding.activationId !== null) {
2088
+ const activation = this.getAgentActivation(binding.activationId);
2089
+ if (!activation) throw new CollaborationStoreError("binding_conflict");
2090
+ this.db.query("INSERT INTO agent_question_links(decision_id,request_id,activation_id,read_operation_id,origin_input_seq) VALUES (?,?,?,?,?)").run(id, dependent?.id ?? null, activation.id, input.readOperationId, input.originInputSeq);
2091
+ if (dependent && !agentRequestTerminal(dependent.state)) this.writeAgentRequest({ ...dependent, state: "waiting_owner", revision: dependent.revision + 1 }, dependent.revision);
2092
+ }
2093
+ return hold;
2094
+ }).immediate();
2095
+ }
2096
+
2097
+ private assertArtifactHold(hold: ArtifactSecurityHold, binding: CollaborationBinding, gate: CollaborationGate): void {
2098
+ const grant = this.agentGrant(hold.readScope.artifactGrantId);
2099
+ if (!grant || grant.revoked) throw new CollaborationStoreError("artifact_revoked", 410);
2100
+ const artifact = this.assertArtifactGrant(grant, binding, gate);
2101
+ if (grant.revision !== hold.readScope.grantRevision || artifact.sha256 !== hold.resourceRef.originalSha256 ||
2102
+ binding.ownerInputRevision !== hold.continuation.originOwnerInputRevision || hold.state === "invalidated" ||
2103
+ hold.continuation.state === "invalidated" || hold.continuation.state === "cancelled") throw new CollaborationStoreError("artifact_review_stale");
2104
+ const actual: ArtifactReadBinding = { inputId: binding.inputId, activationId: binding.activationId, managedBindingDigest: collaborationDigest(binding) };
2105
+ const expected = hold.continuation.targetBinding ?? { inputId: hold.readScope.inputId, activationId: hold.readScope.activationId, managedBindingDigest: hold.readScope.managedBindingDigest };
2106
+ if (!isDeepStrictEqual(actual, expected)) throw new CollaborationStoreError("artifact_review_stale");
2107
+ }
2108
+
2109
+ reviewArtifactHold(input: { readonly holdId: string; readonly expectedRevision: number; readonly submissionId: string; readonly scopeDigest: string; readonly choice: "allow" | "deny" | "cancel"; readonly ownerPrincipalRef: string }, binding: CollaborationBinding, gate: CollaborationGate): ArtifactReviewReceipt {
2110
+ return this.db.transaction(() => {
2111
+ const hold = this.artifactSecurityHold(input.holdId);
2112
+ if (!hold) throw new CollaborationStoreError("hold_not_found", 404);
2113
+ if (hold.ownerPrincipalRef !== input.ownerPrincipalRef) throw new CollaborationStoreError("owner_mismatch", 403);
2114
+ const key = JSON.stringify(["artifact-review", input.submissionId]);
2115
+ const prior = this.agentOperation<ArtifactReviewReceipt>(key, input);
2116
+ if (prior) return prior;
2117
+ if (hold.revision !== input.expectedRevision || hold.state !== "pending" || hold.scopeDigest !== input.scopeDigest) throw new CollaborationStoreError("artifact_review_conflict");
2118
+ this.assertArtifactHold(hold, binding, gate);
2119
+ let state: ArtifactReviewReceipt["reviewState"];
2120
+ switch (input.choice) {
2121
+ case "allow": state = "allowed"; break;
2122
+ case "deny": state = "denied"; break;
2123
+ case "cancel": state = "cancelled"; break;
2124
+ default: return collaborationNever(input.choice);
2125
+ }
2126
+ const revision = hold.revision + 1;
2127
+ this.writeArtifactHold({ ...hold, state, revision, answerSubmissionId: input.submissionId, reviewReceipt: { decisionRevision: revision, choice: input.choice } });
2128
+ const receipt: ArtifactReviewReceipt = { kind: "collaboration_artifact_review", holdId: hold.id, decisionId: hold.decisionId, revision, submissionId: input.submissionId,
2129
+ scopeDigest: hold.scopeDigest, reviewState: state, resource: { requestId: hold.resourceRef.requestId, artifactId: hold.resourceRef.artifactId, resultId: hold.resourceRef.resultId }, effect: "review_recorded", contentReleased: false };
2130
+ return this.saveAgentOperation(binding.convId, { key, payload: input, receipt });
2131
+ }).immediate();
2132
+ }
2133
+
2134
+ settleArtifactRead(holdId: string, evidence: { readonly settlementId: string; readonly ownedInFlight: number; readonly reconciled: boolean }, binding: CollaborationBinding): ArtifactSecurityHold {
2135
+ return this.db.transaction(() => {
2136
+ const hold = this.artifactSecurityHold(holdId);
2137
+ if (hold && hold.continuation.targetBinding !== null) {
2138
+ const actual: ArtifactReadBinding = { inputId: binding.inputId, activationId: binding.activationId, managedBindingDigest: collaborationDigest(binding) };
2139
+ if (!evidence.settlementId || evidence.ownedInFlight !== 0 || !evidence.reconciled || binding.readContinuationId !== hold.continuation.id ||
2140
+ !isDeepStrictEqual(actual, hold.continuation.targetBinding)) throw new CollaborationStoreError("artifact_read_not_settled");
2141
+ // Settlement reconciles this exact dispatched input even after owner steer; it grants no release.
2142
+ if (!this.db.query("SELECT seq FROM events WHERE type='agent.collaboration.read.settled' AND json_extract(data,'$.holdId')=?").get(holdId)) {
2143
+ this.appendEvent(binding.convId, "agent.collaboration.read.settled", { holdId, ...evidence, binding: actual });
2144
+ }
2145
+ return hold;
2146
+ }
2147
+ if (!hold || !evidence.settlementId || evidence.ownedInFlight !== 0 || !evidence.reconciled ||
2148
+ hold.readScope.inputId !== binding.inputId || hold.readScope.activationId !== binding.activationId || hold.readScope.managedBindingDigest !== collaborationDigest(binding)) throw new CollaborationStoreError("artifact_read_not_settled");
2149
+ this.assertAgentBinding(binding);
2150
+ if (hold.continuation.settledReceipt !== null) return hold;
2151
+ return this.writeArtifactHold({ ...hold, revision: hold.revision + 1, continuation: { ...hold.continuation, settledReceipt: { inputId: binding.inputId, activationId: binding.activationId, managedBindingDigest: collaborationDigest(binding), settlementId: evidence.settlementId } } });
2152
+ }).immediate();
2153
+ }
2154
+
2155
+ resumeArtifactRead(input: { readonly holdId: string; readonly expectedRevision: number; readonly resumeSubmissionId: string; readonly readOperationId: string; readonly scopeDigest: string; readonly ownerPrincipalRef: string }, binding: CollaborationBinding, gate: CollaborationGate) {
2156
+ return this.db.transaction(() => {
2157
+ const hold = this.artifactSecurityHold(input.holdId);
2158
+ if (!hold) throw new CollaborationStoreError("hold_not_found", 404);
2159
+ if (hold.ownerPrincipalRef !== input.ownerPrincipalRef || binding.convId !== hold.readScope.granteeConv) throw new CollaborationStoreError("owner_mismatch", 403);
2160
+ type Receipt = { holdId: string; readOperationId: string; continuationId: string; revision: number; resumeSubmissionId: string; scopeDigest: string; resumeState: ArtifactSecurityHold["continuation"]["state"]; contentReleased: false };
2161
+ const key = JSON.stringify(["artifact-resume", input.resumeSubmissionId]);
2162
+ const prior = this.agentOperation<Receipt>(key, input);
2163
+ if (prior) return { ...prior, resumeState: hold.continuation.state };
2164
+ if (hold.revision !== input.expectedRevision || hold.scopeDigest !== input.scopeDigest || hold.readOperationId !== input.readOperationId || hold.continuation.resumeSubmissionId !== null) throw new CollaborationStoreError("artifact_resume_conflict");
2165
+ switch (hold.state) {
2166
+ case "pending": throw new CollaborationStoreError("document_review_pending");
2167
+ case "denied": throw new CollaborationStoreError("document_review_denied", 403);
2168
+ case "cancelled": throw new CollaborationStoreError("document_review_cancelled");
2169
+ case "invalidated": throw new CollaborationStoreError("artifact_review_stale");
2170
+ case "allowed": break;
2171
+ default: return collaborationNever(hold.state);
2172
+ }
2173
+ this.assertArtifactHold(hold, binding, gate);
2174
+ if (!hold.continuation.settledReceipt) throw new CollaborationStoreError("artifact_read_not_settled");
2175
+ this.assertAgentReadExecution(binding, hold);
2176
+ const released = hold.continuation.releaseReceiptId !== null;
2177
+ if (!released && hold.continuation.state !== "unrequested") throw new CollaborationStoreError("artifact_resume_conflict");
2178
+ const next = this.writeArtifactHold({ ...hold, revision: hold.revision + 1, continuation: { ...hold.continuation,
2179
+ state: released ? hold.continuation.state : "queued", resumeSubmissionId: input.resumeSubmissionId, reviewedScopeDigest: hold.scopeDigest } });
2180
+ if (!released) {
2181
+ const intent: CollaborationIntent = { origin: "collaboration_artifact_read", holdId: hold.id, readOperationId: hold.readOperationId, continuationId: hold.continuation.id, reviewedScopeDigest: hold.scopeDigest };
2182
+ this.appendEvent(binding.convId, "agent.collaboration.input", intent);
2183
+ }
2184
+ const receipt: Receipt = { holdId: hold.id, readOperationId: hold.readOperationId, continuationId: hold.continuation.id, revision: next.revision, resumeSubmissionId: input.resumeSubmissionId, scopeDigest: hold.scopeDigest, resumeState: next.continuation.state, contentReleased: false };
2185
+ return this.saveAgentOperation(binding.convId, { key, payload: input, receipt });
2186
+ }).immediate();
2187
+ }
2188
+
2189
+ nextArtifactReadInput(conv: string): { readonly hold: ArtifactSecurityHold; readonly seq: number } | null {
2190
+ const row = this.db.query<{ data: string; seq: number }, [string]>(`
2191
+ SELECT h.data,e.seq FROM events e JOIN security_holds h ON h.id=json_extract(e.data,'$.holdId')
2192
+ WHERE e.conv=? AND e.type='agent.collaboration.input' AND json_extract(e.data,'$.origin')='collaboration_artifact_read'
2193
+ AND json_extract(h.data,'$.state')='allowed' AND json_extract(h.data,'$.continuation.state')='queued'
2194
+ ORDER BY e.seq LIMIT 1`).get(conv);
2195
+ return row ? { hold: JSON.parse(row.data), seq: row.seq } : null;
2196
+ }
2197
+
2198
+ admitArtifactRead(input: { readonly holdId: string; readonly expectedRevision: number; readonly previousBinding: CollaborationBinding;
2199
+ readonly wireMessage: string }, fresh: CollaborationBinding, gate: CollaborationGate): StoredEvent {
2200
+ return this.db.transaction(() => {
2201
+ const next = this.nextArtifactReadInput(fresh.convId);
2202
+ if (!next || next.hold.id !== input.holdId || next.hold.revision !== input.expectedRevision ||
2203
+ !isDeepStrictEqual(this.agentBinding(fresh.convId), input.previousBinding)) throw new CollaborationStoreError("artifact_resume_conflict");
2204
+ if (this.lastSeq(fresh.convId, "message.user") !== fresh.ownerInputRevision ||
2205
+ this.pendingMainUsers(fresh.convId).length || this.mainTurn(fresh.convId)) throw new CollaborationStoreError("conversation_busy");
2206
+ const hold = this.dispatchArtifactRead({ holdId: input.holdId, fresh }, input.previousBinding, gate);
2207
+ const source: MainInputSource = { origin: "collaboration_artifact_read", input_ref: { holdId: hold.id, readOperationId: hold.readOperationId,
2208
+ continuationId: hold.continuation.id, reviewedScopeDigest: hold.scopeDigest } };
2209
+ return this.appendEvent(fresh.convId, "main.input", { ...source, session_path: fresh.sessionPath, handoff: null,
2210
+ input_id: fresh.inputId, wire_message: input.wireMessage, native_identity: true, authored_binding: fresh });
2211
+ }).immediate();
2212
+ }
2213
+
2214
+ dispatchArtifactRead(input: string | { readonly holdId: string; readonly fresh: CollaborationBinding }, binding: CollaborationBinding, gate: CollaborationGate): ArtifactSecurityHold {
2215
+ return this.db.transaction(() => {
2216
+ const holdId = typeof input === "string" ? input : input.holdId;
2217
+ const hold = this.artifactSecurityHold(holdId);
2218
+ if (!hold || hold.state !== "allowed" || hold.continuation.state !== "queued" || !hold.continuation.settledReceipt || hold.continuation.targetBinding !== null) throw new CollaborationStoreError("artifact_resume_conflict");
2219
+ this.assertArtifactHold(hold, binding, gate);
2220
+ this.assertAgentReadExecution(binding, hold);
2221
+ if (binding.activationId !== null) {
2222
+ const active = this.getAgentActivation(binding.activationId);
2223
+ if (!active?.quiesced) throw new CollaborationStoreError("request_blocked");
2224
+ }
2225
+ const fresh: CollaborationBinding = { ...binding, inputId: typeof input === "string" ? crypto.randomUUID() : input.fresh.inputId,
2226
+ activationId: null, readContinuationId: hold.continuation.id };
2227
+ if (typeof input !== "string" && (!isDeepStrictEqual(fresh, input.fresh) || fresh.inputId === binding.inputId)) throw new CollaborationStoreError("artifact_review_stale");
2228
+ const targetBinding: ArtifactReadBinding = { inputId: fresh.inputId, activationId: fresh.activationId, managedBindingDigest: collaborationDigest(fresh) };
2229
+ const dispatch = this.appendEvent(binding.convId, "agent.collaboration.read.dispatch", { holdId, readOperationId: hold.readOperationId, continuationId: hold.continuation.id, targetBinding });
2230
+ this.recordAgentBinding(fresh);
2231
+ return this.writeArtifactHold({ ...hold, revision: hold.revision + 1, continuation: { ...hold.continuation, state: "dispatched", targetBinding,
2232
+ boundScopeDigest: collaborationDigest([hold.scopeDigest, hold.continuation.id, targetBinding]), dispatchReceiptId: String(dispatch.seq) } });
2233
+ }).immediate();
2234
+ }
2235
+
2236
+ claimArtifactRelease(input: { readonly holdId: string; readonly expectedRevision: number; readonly representation: ArtifactSecurityHold["representation"] }, binding: CollaborationBinding, gate: CollaborationGate): ArtifactReleaseReceipt {
2237
+ return this.db.transaction(() => {
2238
+ const hold = this.artifactSecurityHold(input.holdId);
2239
+ if (!hold || hold.revision !== input.expectedRevision || hold.state !== "allowed" || hold.continuation.releaseReceiptId !== null ||
2240
+ !isDeepStrictEqual(input.representation, hold.representation) || !["unrequested", "dispatched"].includes(hold.continuation.state)) throw new CollaborationStoreError("artifact_review_stale");
2241
+ if (this.db.query("SELECT seq FROM events WHERE type='agent.collaboration.read.settled' AND json_extract(data,'$.holdId')=?").get(hold.id)) throw new CollaborationStoreError("artifact_read_not_settled");
2242
+ this.assertArtifactHold(hold, binding, gate);
2243
+ this.assertAgentReadExecution(binding, hold);
2244
+ if (hold.continuation.targetBinding === null && hold.continuation.settledReceipt !== null) throw new CollaborationStoreError("artifact_resume_required");
2245
+ const receipt: ArtifactReleaseReceipt = { kind: "collaboration_artifact", readOperationId: hold.readOperationId, holdId: hold.id, requestId: hold.resourceRef.requestId, artifactId: hold.resourceRef.artifactId,
2246
+ grantRevision: hold.readScope.grantRevision, representationSha256: hold.representation.sha256, scopeDigest: hold.scopeDigest,
2247
+ continuationId: hold.continuation.targetBinding === null ? null : hold.continuation.id, boundScopeDigest: hold.continuation.boundScopeDigest, releaseState: "claimed" };
2248
+ const event = this.appendEvent(binding.convId, "agent.collaboration.release", receipt);
2249
+ this.writeArtifactHold({ ...hold, revision: hold.revision + 1, continuation: { ...hold.continuation, releaseReceiptId: String(event.seq) } });
2250
+ return receipt;
2251
+ }).immediate();
2252
+ }
2253
+
2254
+ recordArtifactDelivery(input: { readonly holdId: string; readonly dispatchReceiptId: string | null; readonly releaseReceiptId: string | null; readonly state: "released" | "unknown" }, binding: ArtifactReadBinding): ArtifactSecurityHold {
2255
+ return this.db.transaction(() => {
2256
+ const hold = this.artifactSecurityHold(input.holdId);
2257
+ if (!hold || hold.continuation.dispatchReceiptId !== input.dispatchReceiptId || hold.continuation.releaseReceiptId !== input.releaseReceiptId ||
2258
+ !isDeepStrictEqual(hold.continuation.targetBinding ?? { inputId: hold.readScope.inputId, activationId: hold.readScope.activationId, managedBindingDigest: hold.readScope.managedBindingDigest }, binding)) throw new CollaborationStoreError("artifact_delivery_conflict");
2259
+ if (hold.continuation.state === "released" || hold.continuation.state === "invalidated" || hold.continuation.state === "cancelled") return hold;
2260
+ if (input.state === "released" && input.releaseReceiptId === null) throw new CollaborationStoreError("artifact_delivery_conflict");
2261
+ if (input.dispatchReceiptId === null && input.releaseReceiptId === null) throw new CollaborationStoreError("artifact_delivery_conflict");
2262
+ this.appendEvent(hold.readScope.granteeConv, "agent.collaboration.release.evidence", { ...input, binding });
2263
+ return this.writeArtifactHold({ ...hold, revision: hold.revision + 1, continuation: { ...hold.continuation, state: input.state } });
2264
+ }).immediate();
2265
+ }
2266
+
2267
+ markAgentStageUnknown(transferId: string, expectedRevision: number): AgentArtifactStage {
2268
+ return this.db.transaction(() => {
2269
+ const stage = this.agentStage(transferId);
2270
+ if (!stage || stage.state !== "staging" || stage.revision !== expectedRevision) throw new CollaborationStoreError("stage_conflict");
2271
+ const next: AgentArtifactStage = { ...stage, state: "unknown", revision: stage.revision + 1, errorCode: "export_unknown" };
2272
+ this.db.query("UPDATE agent_artifact_stages SET data=? WHERE transfer_id=? AND revision=?").run(JSON.stringify(next), transferId, expectedRevision);
2273
+ return next;
2274
+ }).immediate();
2275
+ }
2276
+
2277
+ /**
2278
+ * Housekeeping candidates: sealed collaboration files of finished requests that no live grant keeps. Listing changes
2279
+ * nothing; trashAgentArtifact repeats every reference check at the operator's selection.
2280
+ */
2281
+ agentArtifactCleanupRows(): readonly { readonly artifactId: string; readonly sealedName: string; readonly bytes: number; readonly state: string }[] {
2282
+ return this.db.query<{ data: string }, []>(`SELECT a.data FROM agent_artifacts a JOIN agent_requests r ON r.id=a.request_id
2283
+ WHERE a.state<>'trashed' AND r.state IN ${AGENT_REQUEST_FINAL_SQL}
2284
+ AND NOT EXISTS (SELECT 1 FROM agent_artifact_grants g WHERE g.artifact_id=a.id AND g.revoked=0) ORDER BY a.id`).all()
2285
+ .map(row => JSON.parse(row.data) as AgentArtifact)
2286
+ .map(artifact => ({ artifactId: artifact.id, sealedName: artifact.sealedName, bytes: artifact.bytes, state: artifact.state }));
2287
+ }
2288
+
2289
+ trashAgentArtifact(artifactId: string): AgentArtifact {
2290
+ return this.db.transaction(() => {
2291
+ const artifact = this.agentArtifact(artifactId);
2292
+ if (!artifact) throw new CollaborationStoreError("artifact_not_found", 404);
2293
+ if (artifact.state === "trashed") return artifact;
2294
+ const request = artifact.requestId === null ? null : this.getAgentRequest(artifact.requestId);
2295
+ const ended = request !== null && agentRequestTerminal(request.state);
2296
+ if (request && (!ended || this.agentActivations(request.id).some(a => a.state === "unknown" ||
2297
+ (a.state !== "queued" && a.state !== "rejected" && !a.quiesced)))) throw new CollaborationStoreError("artifact_busy");
2298
+ for (const row of this.db.query<{ data: string }, [string]>("SELECT data FROM agent_artifact_grants WHERE artifact_id=?").all(artifactId)) {
2299
+ const grant: ArtifactGrant = JSON.parse(row.data);
2300
+ if (!grant.revoked || this.agentReaders(grant.grantId).length > 0) throw new CollaborationStoreError("artifact_busy");
2301
+ }
2302
+ if (this.db.query("SELECT transfer_id FROM agent_artifact_stages WHERE (artifact_id=? OR json_extract(data,'$.source.artifactId')=?) AND state IN ('awaiting_transfer_authority','ready','staging','unknown')").get(artifactId, artifactId)) throw new CollaborationStoreError("artifact_busy");
2303
+ if (this.db.query("SELECT id FROM security_holds WHERE json_extract(data,'$.resourceRef.artifactId')=? AND json_extract(data,'$.continuation.state') IN ('queued','dispatched','unknown')").get(artifactId)) throw new CollaborationStoreError("artifact_busy");
2304
+ const tombstone: AgentArtifact = { ...artifact, state: "trashed" };
2305
+ this.db.query("UPDATE agent_artifacts SET data=? WHERE id=?").run(JSON.stringify(tombstone), artifactId);
2306
+ return tombstone;
2307
+ }).immediate();
2308
+ }
2309
+
2310
+ acceptCollaborationPermissionOutcome(outcome: CollaborationPermissionOutcome, binding: CollaborationBinding, gate: CollaborationGate) {
2311
+ return this.db.transaction(() => {
2312
+ const key = JSON.stringify(["permission", outcome.decisionId, outcome.decisionRevision, outcome.continuationId]);
2313
+ type Receipt = { readonly decisionId: string; readonly continuationId: string; readonly inputSeq: number };
2314
+ const prior = this.agentOperation<Receipt>(key, outcome);
2315
+ if (prior) return prior;
2316
+ this.assertCollaborationPermission(outcome, binding, gate);
2317
+ const intent: CollaborationIntent = { origin: "agent_tool_permission", outcome };
2318
+ const input = this.appendEvent(binding.convId, "agent.collaboration.input", intent);
2319
+ return this.saveAgentOperation(binding.convId, { key, payload: outcome, receipt: { decisionId: outcome.decisionId, continuationId: outcome.continuationId, inputSeq: input.seq } });
2320
+ }).immediate();
2321
+ }
2322
+
2323
+ /** Only the shared dispatcher allocates the continuation, once per exact stored outcome. */
2324
+ dispatchCollaborationPermission(outcome: CollaborationPermissionOutcome, binding: CollaborationBinding, gate: CollaborationGate): AgentActivation {
2325
+ return this.db.transaction(() => {
2326
+ const key = JSON.stringify(["permission-dispatch", outcome.decisionId, outcome.decisionRevision, outcome.continuationId]);
2327
+ const prior = this.agentOperation<{ readonly activationId: string }>(key, outcome);
2328
+ if (prior) {
2329
+ const activation = this.getAgentActivation(prior.activationId);
2330
+ if (!activation) throw new CollaborationStoreError("activation_not_found");
2331
+ return activation;
2332
+ }
2333
+ this.assertCollaborationPermission(outcome, binding, gate);
2334
+ const blocked = this.getAgentActivation(outcome.blockedActivationId), request = this.getAgentRequest(outcome.collaborationRequestId);
2335
+ if (!blocked?.quiesced || !request) throw new CollaborationStoreError("request_blocked");
2336
+ const accepted = this.acceptCollaborationPermissionOutcome(outcome, binding, gate);
2337
+ const activation = this.newAgentActivation(request, "owner_answer", { seq: accepted.inputSeq });
2338
+ this.saveAgentOperation(binding.convId, { key, payload: outcome, receipt: { activationId: activation.id } });
2339
+ return activation;
2340
+ }).immediate();
2341
+ }
2342
+
2343
+ private assertCollaborationPermission(outcome: CollaborationPermissionOutcome, binding: CollaborationBinding, gate: CollaborationGate): void {
2344
+ if (gate.mode !== "authority" || binding.agentId === null) throw new CollaborationStoreError("authority_adapter_required", 503);
2345
+ if (!gate.permissionOutcome || !isDeepStrictEqual(gate.permissionOutcome(this.db, outcome.decisionId), outcome)) throw new CollaborationStoreError("permission_outcome_conflict");
2346
+ this.assertAgentBinding(binding);
2347
+ const decision = this.getDecision(outcome.decisionId);
2348
+ const activation = this.getAgentActivation(outcome.blockedActivationId);
2349
+ const request = this.getAgentRequest(outcome.collaborationRequestId);
2350
+ const link = this.db.query<{ request_id: string; activation_id: string; origin_input_seq: number }, [string]>("SELECT request_id,activation_id,origin_input_seq FROM agent_question_links WHERE decision_id=?").get(outcome.decisionId);
2351
+ const access = gate.access(this.db, binding.agentId);
2352
+ // Q-07 (294 card values): the decision is a 294 grant card (permission:false, purpose agent_tool_grant); the link names
2353
+ // the asking execution's input; the outcome's origin input is that same execution input.
2354
+ const purpose = this.db.query<{ purpose: string }, [string]>("SELECT purpose FROM decision_purposes WHERE decision_id=?").get(outcome.decisionId)?.purpose;
2355
+ if (!decision || decision.revision !== outcome.decisionRevision || decision.submissionId === null ||
2356
+ !["delivered", "closed"].includes(decision.status) || decision.permission || purpose !== "agent_tool_grant" ||
2357
+ !activation || !request || !link || this.agentExecutionRequest(activation)?.id !== request.id || link.request_id !== request.id || link.activation_id !== activation.id ||
2358
+ link.origin_input_seq !== activation.inputSeq || activation.inputSeq !== outcome.originInputSeq ||
2359
+ !isDeepStrictEqual(activation.binding, binding) || activation.state !== "consumed" ||
2360
+ binding.lifecycleEpoch !== outcome.lifecycleEpoch || access.lifecycleEpoch !== outcome.lifecycleEpoch ||
2361
+ access.authorityEpoch !== outcome.authorityEpoch || access.policyRevision !== outcome.policyRevision) throw new CollaborationStoreError("permission_outcome_conflict");
2362
+ if (access.accessState !== "open") throw new CollaborationStoreError("agent_access_blocked", 403);
2363
+ if (agentRequestTerminal(request.state) || request.state === "cancel_requested" || this.agentRequestBlocked(request.id)) throw new CollaborationStoreError("request_blocked");
2364
+ }
2365
+
699
2366
  securityAllowedValues(): { revision: number; values: AllowedValue[] } {
700
2367
  return this.db.transaction(() => ({
701
2368
  revision: this.db.query<{ revision: number }, []>("SELECT revision FROM security_allow_revision WHERE id = 1").get()!.revision,
@@ -765,16 +2432,16 @@ export class Store {
765
2432
  }
766
2433
 
767
2434
  securityHolds(): SecurityHoldRecord[] {
768
- return this.db.query<{ data: string }, []>("SELECT data FROM security_holds ORDER BY rowid").all().map((row) => JSON.parse(row.data));
2435
+ return this.db.query<{ data: string }, []>("SELECT data FROM security_holds WHERE json_extract(data, '$.state.source') IN ('message','answer') ORDER BY rowid").all().map((row) => JSON.parse(row.data));
769
2436
  }
770
2437
 
771
2438
  securityHold(id: string): SecurityHoldRecord | null {
772
- const row = this.db.query<{ data: string }, [string]>("SELECT data FROM security_holds WHERE id = ?").get(id);
2439
+ const row = this.db.query<{ data: string }, [string]>("SELECT data FROM security_holds WHERE id = ? AND json_extract(data, '$.state.source') IN ('message','answer')").get(id);
773
2440
  return row ? JSON.parse(row.data) : null;
774
2441
  }
775
2442
 
776
2443
  securityHoldByKey(key: string): SecurityHoldRecord | null {
777
- const row = this.db.query<{ data: string }, [string]>("SELECT data FROM security_holds WHERE source_key = ?").get(key);
2444
+ const row = this.db.query<{ data: string }, [string]>("SELECT data FROM security_holds WHERE source_key = ? AND json_extract(data, '$.state.source') IN ('message','answer')").get(key);
778
2445
  return row ? JSON.parse(row.data) : null;
779
2446
  }
780
2447
 
@@ -871,6 +2538,7 @@ export class Store {
871
2538
  }
872
2539
 
873
2540
  securityReceipt(ref: SecurityReceipt): "sent" | "unconfirmed" | null {
2541
+ if (ref.kind === "collaboration_artifact" || ref.kind === "collaboration_artifact_review") return null;
874
2542
  if (ref.kind === "answer") {
875
2543
  const decision = this.getDecision(ref.decisionId);
876
2544
  if (decision?.submissionId !== ref.submissionId) return null;
@@ -1127,14 +2795,25 @@ export class Store {
1127
2795
  const sources = (inputs.length ? inputs : events.filter((event) => event.type === "turn.started"))
1128
2796
  .map((event) => event.data as Partial<MainInputSource>);
1129
2797
  const explicit = [...sources].reverse().find((source) => source.origin === "user")
1130
- ?? sources.find((source) => source.origin === "work_update");
1131
- const source: MainInputSource = explicit?.origin === "work_update" && explicit.input_ref
1132
- ? { origin: "work_update", input_ref: explicit.input_ref }
1133
- : {
1134
- origin: "user",
1135
- input_ref: explicit?.origin === "user" && explicit.input_ref
1136
- ? explicit.input_ref : { message_seq: this.lastSeq(conv, "message.user") },
1137
- };
2798
+ ?? sources.find((source) => source.origin !== undefined);
2799
+ let source: MainInputSource;
2800
+ switch (explicit?.origin) {
2801
+ case "user": source = { origin: explicit.origin, input_ref: explicit.input_ref ?? { message_seq: this.lastSeq(conv, "message.user") } }; break;
2802
+ case "work_update":
2803
+ if (!explicit.input_ref) throw new CollaborationStoreError("input_source_missing");
2804
+ source = { origin: explicit.origin, input_ref: explicit.input_ref }; break;
2805
+ case "agent_collaboration":
2806
+ if (!explicit.input_ref) throw new CollaborationStoreError("input_source_missing");
2807
+ source = { origin: explicit.origin, input_ref: explicit.input_ref }; break;
2808
+ case "agent_tool_permission":
2809
+ if (!explicit.input_ref) throw new CollaborationStoreError("input_source_missing");
2810
+ source = { origin: explicit.origin, input_ref: explicit.input_ref }; break;
2811
+ case "collaboration_artifact_read":
2812
+ if (!explicit.input_ref) throw new CollaborationStoreError("input_source_missing");
2813
+ source = { origin: explicit.origin, input_ref: explicit.input_ref }; break;
2814
+ case undefined: source = { origin: "user", input_ref: { message_seq: this.lastSeq(conv, "message.user") } }; break;
2815
+ default: return collaborationNever(explicit);
2816
+ }
1138
2817
  const report = events.filter((event) => event.type === "message.final"
1139
2818
  && Array.isArray((event.data as { work_update_ids?: unknown }).work_update_ids)).at(-1);
1140
2819
  return { seq: first.seq, source, inputs, reportEventSeq: report?.seq ?? null };
@@ -1146,11 +2825,48 @@ export class Store {
1146
2825
  }
1147
2826
 
1148
2827
  /** Exact native message/history proves consumption; a queue ACK alone does not. */
1149
- consumeMainInput(input: StoredEvent): StoredEvent | null {
2828
+ consumeMainInput(input: StoredEvent, native?: { readonly entry: LedgerEntry; readonly durableId: string }): StoredEvent | null {
1150
2829
  return this.db.transaction(() => {
2830
+ const source: MainInput = JSON.parse(JSON.stringify(input.data));
2831
+ switch (source.origin) {
2832
+ case "user": case "work_update": break;
2833
+ case "agent_collaboration": case "agent_tool_permission": {
2834
+ const activation = this.getAgentActivation(source.input_ref.activation_id);
2835
+ const ledger = this.getLedger(input.conv);
2836
+ const stored = source.input_id ? this.ledgerUser(source.session_path, { clientInputId: source.input_id }) : null;
2837
+ if (!activation?.binding || !native || native.entry.sessionPath !== source.session_path ||
2838
+ native.durableId !== activation.binding.durableSessionId || native.entry.role !== "user" ||
2839
+ ledger?.durableId !== native.durableId || ledger.sessionPath !== source.session_path ||
2840
+ (source.native_identity && stored?.entryId !== native.entry.entryId) ||
2841
+ (source.native_identity ? native.entry.clientInputId !== source.input_id : native.entry.text !== source.wire_message)) {
2842
+ throw new CollaborationStoreError("consumption_evidence_required");
2843
+ }
2844
+ this.recordAgentActivationEvidence({ activationId: activation.id, expectedRevision: activation.revision,
2845
+ state: "consumed", consumedEntry: native.entry.entryId }, activation.binding);
2846
+ if (activation.direction === "owner_answer") this.deliverCollaborationAnswer(activation.id);
2847
+ break;
2848
+ }
2849
+ case "collaboration_artifact_read": {
2850
+ const hold = this.artifactSecurityHold(source.input_ref.holdId), binding = source.authored_binding;
2851
+ const ledger = this.getLedger(input.conv);
2852
+ if (!native || !binding || !hold || hold.readOperationId !== source.input_ref.readOperationId ||
2853
+ hold.continuation.id !== source.input_ref.continuationId || hold.scopeDigest !== source.input_ref.reviewedScopeDigest ||
2854
+ hold.continuation.targetBinding?.managedBindingDigest !== collaborationDigest(binding) ||
2855
+ hold.continuation.targetBinding.inputId !== source.input_id || native.durableId !== binding.durableSessionId ||
2856
+ ledger?.durableId !== native.durableId || native.entry.sessionPath !== source.session_path ||
2857
+ native.entry.clientInputId !== source.input_id || native.entry.role !== "user" ||
2858
+ this.ledgerUser(source.session_path, { clientInputId: binding.inputId })?.entryId !== native.entry.entryId) {
2859
+ throw new CollaborationStoreError("consumption_evidence_required");
2860
+ }
2861
+ break;
2862
+ }
2863
+ default: return collaborationNever(source);
2864
+ }
1151
2865
  this.ackMainInput(input);
1152
2866
  if (this.mainInputConsumed(input)) return null;
1153
- return this.appendEvent(input.conv, "main.input.consumed", { input_seq: input.seq });
2867
+ return this.appendEvent(input.conv, "main.input.consumed", { input_seq: input.seq,
2868
+ ...(native ? { input_id: source.input_id, entry_id: native.entry.entryId, session_path: native.entry.sessionPath,
2869
+ durable_session_id: native.durableId, source: mainInputSource(source) } : {}) });
1154
2870
  }).immediate();
1155
2871
  }
1156
2872
 
@@ -1158,7 +2874,7 @@ export class Store {
1158
2874
  mainUserInput(conv: string, sessionPath: string, text: string, clientInputId?: string): StoredEvent | null {
1159
2875
  return this.mainTurn(conv)?.inputs.find((input) => {
1160
2876
  const data = input.data as MainInput;
1161
- return data.origin === "user" && data.session_path === sessionPath &&
2877
+ return data.origin !== "work_update" && data.session_path === sessionPath &&
1162
2878
  typeof data.input_id === "string" && (data.native_identity
1163
2879
  ? data.input_id === clientInputId : data.wire_message === text);
1164
2880
  }) ?? null;
@@ -1169,8 +2885,7 @@ export class Store {
1169
2885
  if (!turn) return null;
1170
2886
  const inputs = turn.inputs.filter((input) => this.mainInputConsumed(input));
1171
2887
  const data = ([...inputs].reverse().find((input) => (input.data as MainInput).origin === "user") ?? inputs[0])?.data as MainInput | undefined;
1172
- if (data?.origin === "user") return { origin: "user", input_ref: data.input_ref };
1173
- if (data?.origin === "work_update") return { origin: "work_update", input_ref: data.input_ref };
2888
+ if (data) return mainInputSource(data);
1174
2889
  // Older persisted turn.started events predate the durable input ledger.
1175
2890
  return turn.inputs.length === 0 ? turn.source : null;
1176
2891
  }
@@ -1244,6 +2959,11 @@ export class Store {
1244
2959
  this.db.query("UPDATE conversations SET hidden = ? WHERE id = ?").run(Number(hidden), id);
1245
2960
  }
1246
2961
 
2962
+ /** Binds a conversation to another agent id: an archived agent restored under a new id keeps its history. */
2963
+ setConversationAgent(id: string, agentId: string): void {
2964
+ this.db.query("UPDATE conversations SET agent_id = ? WHERE id = ?").run(agentId, id);
2965
+ }
2966
+
1247
2967
  setThread(convId: string, threadId: string): void {
1248
2968
  this.db.query("UPDATE conversations SET thread_id = ? WHERE id = ?").run(threadId, convId);
1249
2969
  }
@@ -1549,6 +3269,35 @@ export class Store {
1549
3269
  }).immediate();
1550
3270
  }
1551
3271
 
3272
+ /**
3273
+ * Bug 282: housekeeping reconcile closes an unfinished work from recorded evidence (`reconcile_*` reason). Nothing changes
3274
+ * unless `expectedSeq` is still the latest command and the work is unfinished, not waiting_input and has no unresolved
3275
+ * decision. In the same transaction every pending/sending/unknown command becomes rejected with the reason as its error,
3276
+ * so it is never re-sent and a later instruct is not refused. The change is an observation, not a terminal report:
3277
+ * it does not wake main, and a report on a newer command still sets the final state.
3278
+ */
3279
+ closeByReconcile(workId: string, expectedSeq: number, status: "failed" | "cancelled", reason: string, evidence: Record<string, unknown>): WorkItem | null {
3280
+ return this.db.transaction(() => {
3281
+ const work = this.getWork(workId);
3282
+ if (!work || terminalWork(work.status) || work.status === "waiting_input") return null;
3283
+ const commands = this.listWorkCommands(workId);
3284
+ const latest = commands.at(-1);
3285
+ if (latest?.seq !== expectedSeq || this.unresolvedWorkDecisions(workId) > 0) return null;
3286
+ const settled = commands.filter((c) => c.delivery === "pending" || c.delivery === "sending" || c.delivery === "unknown").map((c) => c.id);
3287
+ for (const id of settled) this.setWorkCommandDelivery(id, "rejected", { error: reason });
3288
+ const observation = this.recordWorkUpdate(workId, {
3289
+ sourceKey: `reconcile:${reason}:${latest.id}`,
3290
+ kind: "observation",
3291
+ commandSeq: expectedSeq,
3292
+ summary: reason,
3293
+ details: { status, previousStatus: work.status, previousReason: work.statusReason, evidence, settledCommandIds: settled },
3294
+ });
3295
+ this.db.query("UPDATE work_items SET status = ?, status_reason = ?, updated_at = ? WHERE id = ?")
3296
+ .run(status, reason, observation.receivedAt, workId);
3297
+ return this.getWork(workId);
3298
+ }).immediate();
3299
+ }
3300
+
1552
3301
  /** Unresolved decisions (questions, permissions) of one work. */
1553
3302
  unresolvedWorkDecisions(workId: string): number {
1554
3303
  return this.db.query<{ n: number }, [string]>(`SELECT COUNT(*) AS n FROM decisions WHERE work_id = ? AND ${unresolvedDecisions}`).get(workId)?.n ?? 0;
@@ -1834,6 +3583,159 @@ export class Store {
1834
3583
  }).immediate();
1835
3584
  }
1836
3585
 
3586
+ /** 294 permission ledger on this connection (src/agent/tool-permissions.ts). */
3587
+ readonly toolPermissions: AgentToolPermissionStore;
3588
+
3589
+ /** 294 D4: the Aside tab picker's card rebind and bound-call reads on this connection (no second connection). */
3590
+ readonly asideTargets: SqliteAsideDecisions;
3591
+
3592
+ /** Records the tool request and its gateway-owned inbox card (purpose agent_tool_grant) in one transaction. */
3593
+ createAgentToolRequest(input: AgentToolRequestInput): AgentToolRequestRecord {
3594
+ return this.db.transaction(() => {
3595
+ const existing = this.toolPermissions.existingRequest(input);
3596
+ if (existing) return existing;
3597
+ const proposal = this.toolPermissions.proposal(input);
3598
+ const decision = this.createDecision({
3599
+ ownerConv: input.convId, scope: `conv:${input.convId}`, sourceSessionPath: null,
3600
+ sourceRequestId: `agent-tool:${proposal.requestId}`, method: "confirm", permission: false, required: true,
3601
+ payload: proposal.payload, bindingState: "closed",
3602
+ });
3603
+ return this.toolPermissions.insertRequest(input, proposal, decision.id);
3604
+ }).immediate();
3605
+ }
3606
+
3607
+ /**
3608
+ * Q-07: the 294 grant card of a call made inside a collaboration execution. The signed activation must be the
3609
+ * conversation's current binding and the named request the one it executes. Its original input is that execution's
3610
+ * input (a target the owner never messaged has no owner message to name); the card is linked to that execution in
3611
+ * the same transaction, so the request and every request below it wait for the owner while independent ones run.
3612
+ */
3613
+ createCollaborationToolRequest(input: Omit<AgentToolRequestInput, "originInputSeq">): AgentToolRequestRecord {
3614
+ return this.db.transaction(() => {
3615
+ const binding = this.agentBinding(input.convId);
3616
+ const activation = binding?.activationId ? this.getAgentActivation(binding.activationId) : null;
3617
+ if (!activation || activation.id !== input.activationId || this.agentExecutionRequest(activation)?.id !== input.collaborationRequestId) {
3618
+ throw new AgentToolPermissionError("tool_request_invalid");
3619
+ }
3620
+ const request = this.createAgentToolRequest({ ...input, originInputSeq: activation.inputSeq });
3621
+ if (!this.db.query("SELECT 1 FROM agent_question_links WHERE decision_id=?").get(request.decisionId)) this.linkAgentDecision(request.decisionId, activation.id);
3622
+ return request;
3623
+ }).immediate();
3624
+ }
3625
+
3626
+ /** Q-07: the owner's stored outcome of a collaboration 294 card (null for any other decision or before the answer). */
3627
+ collaborationPermissionOutcome(decisionId: string): CollaborationPermissionOutcome | null {
3628
+ return permissionOutcome(this.db, decisionId);
3629
+ }
3630
+
3631
+ /** Q-07: the answered collaboration 294 cards an execution asked, oldest first. */
3632
+ collaborationPermissionDecisions(activationId: string): string[] {
3633
+ return this.db.query<{ decision_id: string }, [string]>(`SELECT l.decision_id FROM agent_question_links l JOIN agent_tool_outcomes o ON o.decision_id=l.decision_id
3634
+ WHERE l.activation_id=? AND json_extract(o.data,'$.collaborationRequestId') IS NOT NULL ORDER BY l.rowid`).all(activationId).map(row => row.decision_id);
3635
+ }
3636
+
3637
+ /**
3638
+ * 294 C8: the one-shot confirmation card (purpose agent_tool_action) of a click/fill on a granted target, with the request
3639
+ * row, in one transaction. The same logical call returns the same card. Without an active grant naming the operation
3640
+ * nothing is recorded (grant_conflict): an action card never stands in for the tool grant itself.
3641
+ */
3642
+ createAgentToolAction(input: AgentToolRequestInput): AgentToolRequestRecord {
3643
+ return this.db.transaction(() => {
3644
+ const existing = this.toolPermissions.existingRequest(input);
3645
+ if (existing) return existing;
3646
+ if (!this.toolPermissions.actionGrant(input.agentId, input.toolKey, input.operation)) throw new AgentToolPermissionError("grant_conflict");
3647
+ const proposal = this.toolPermissions.proposal(input, "agent_tool_action");
3648
+ const decision = this.createDecision({
3649
+ ownerConv: input.convId, scope: `conv:${input.convId}`, sourceSessionPath: null,
3650
+ sourceRequestId: `agent-tool-action:${proposal.requestId}`, method: "confirm", permission: false, required: true,
3651
+ payload: proposal.payload, bindingState: "closed",
3652
+ });
3653
+ return this.toolPermissions.insertRequest(input, proposal, decision.id, "agent_tool_action");
3654
+ }).immediate();
3655
+ }
3656
+
3657
+ /**
3658
+ * 294 C5: an execution whose outcome cannot be proven (started without a stored result, a lost execute answer, an engine
3659
+ * that reported unknown) is marked outcome_unknown and shown once on the existing needs_review surface with the original
3660
+ * operation and its state. Nothing is re-run; the owner decides what to do next. The same execution keeps one card.
3661
+ */
3662
+ reviewAgentToolOutcome(invocationId: string, reason: string): Decision | null {
3663
+ return this.db.transaction(() => {
3664
+ const target = this.toolPermissions.deliveryTarget(invocationId);
3665
+ if (!target) return null;
3666
+ this.toolPermissions.markOutcomeUnknown(invocationId);
3667
+ const source = { scope: `conv:${target.convId}`, sourceSessionPath: null, sourceRequestId: `agent-tool-outcome:${invocationId}` };
3668
+ const existing = this.getDecisionBySource(source);
3669
+ if (existing) return existing;
3670
+ const request = this.db.query<{ tool_key_json: string; operation: string }, [string]>(
3671
+ "SELECT r.tool_key_json, r.operation FROM agent_tool_invocations i JOIN agent_tool_requests r ON r.id = i.request_id WHERE i.id = ?").get(invocationId)!;
3672
+ const toolKey = JSON.parse(request.tool_key_json) as { originalName?: unknown };
3673
+ const tool = typeof toolKey.originalName === "string" ? toolKey.originalName : "tool";
3674
+ return this.createDecision({
3675
+ ownerConv: target.convId, ...source, method: "confirm", permission: false, required: true, bindingState: "unknown",
3676
+ payload: {
3677
+ kind: "agent_tool_outcome_unknown", title: `Tool result unknown: ${tool} ${request.operation}`,
3678
+ message: "The tool may have run, but its result was not recorded. It will not run again by itself. Check the target before asking again.",
3679
+ agentId: target.agentId, invocationId, operationId: target.operationId, toolKey, operation: request.operation,
3680
+ state: "outcome_unknown", reason,
3681
+ },
3682
+ });
3683
+ }).immediate();
3684
+ }
3685
+
3686
+ /**
3687
+ * 294 item 11: the migration card of a legacy agent (purpose agent_tool_migration) in one transaction. The open card is
3688
+ * returned again (one card per agent and lifecycle); a managed agent gets none. Creating it grants nothing.
3689
+ */
3690
+ createAgentToolMigration(agentId: string, convId: string, items: readonly LegacyScopeItem[]): AgentToolMigrationCard | null {
3691
+ return this.db.transaction(() => {
3692
+ const current = this.toolPermissions.currentMigration(agentId);
3693
+ if (current && current.state !== "applied") return current;
3694
+ if (this.toolPermissions.mode(agentId) === "managed") return null;
3695
+ const proposal = this.toolPermissions.migrationProposal(agentId, items);
3696
+ const decision = this.createDecision({
3697
+ ownerConv: convId, scope: `conv:${convId}`, sourceSessionPath: null,
3698
+ sourceRequestId: `agent-tool-migration:${agentId}:${crypto.randomUUID()}`, method: "confirm", permission: false, required: true,
3699
+ payload: proposal.payload, bindingState: "closed",
3700
+ });
3701
+ return this.toolPermissions.insertMigration(agentId, convId, items, proposal, decision.id);
3702
+ }).immediate();
3703
+ }
3704
+
3705
+ /** 294 grant scope edit: a narrowing applies at once; a widening opens its agent_tool_grant inbox card. One transaction. */
3706
+ editAgentToolGrant(agentId: string, grantId: string, input: GrantEditInput, principal: OwnerPrincipal): GrantEditResult {
3707
+ return this.db.transaction(() => this.toolPermissions.editGrant(agentId, grantId, input, principal, (ownerConv, sourceRequestId, payload) => this.createDecision({
3708
+ ownerConv, scope: `conv:${ownerConv}`, sourceSessionPath: null, sourceRequestId, method: "confirm", permission: false, required: true,
3709
+ payload, bindingState: "closed",
3710
+ }).id)).immediate();
3711
+ }
3712
+
3713
+ /**
3714
+ * The owner answer to a tool purpose decision: answer, grant, policy revision and execution intent commit together.
3715
+ * The same submission with the same body returns the stored result; another body is submission_conflict.
3716
+ */
3717
+ answerAgentToolDecision(id: string, request: AgentToolGrantAnswer, principal: OwnerPrincipal): { decision: Decision; fresh: boolean; result: AnswerResult | null } {
3718
+ return this.db.transaction(() => {
3719
+ const decision = this.getDecision(id);
3720
+ if (!decision) throw new DecisionStoreError("decision_not_found");
3721
+ if (!this.toolPermissions.isToolPurpose(id)) throw new DecisionStoreError("invalid_decision_answer", decision);
3722
+ const previous = this.decisionOperation(request.submissionId);
3723
+ if (previous) {
3724
+ if (previous.data.decisionId !== id || !isDeepStrictEqual(previous.data.request, request)) throw new DecisionStoreError("submission_conflict", decision);
3725
+ return { decision, fresh: false, result: null };
3726
+ }
3727
+ if (decision.revision !== request.expectedRevision || decision.status !== "pending" || decision.submissionId !== null) {
3728
+ throw new DecisionStoreError("decision_conflict", decision);
3729
+ }
3730
+ const result = this.toolPermissions.answerToolDecision(id, decision.revision + 1, request, principal);
3731
+ this.db.query(`UPDATE decisions SET status = 'closed', reason = ?, revision = revision + 1, submission_id = ?, submission_revision = ?,
3732
+ submission_mode = 'answer', answer = ?, updated_at = ? WHERE id = ?`)
3733
+ .run(`tool_grant_${result.outcome}`, request.submissionId, request.expectedRevision, JSON.stringify(request.response), new Date().toISOString(), id);
3734
+ this.appendEvent(decision.ownerConv, "decision.operation", { decisionId: id, request, targetScope: decision.scope, commandId: null, mainInputSeq: null });
3735
+ return { decision: this.getDecision(id)!, fresh: true, result };
3736
+ }).immediate();
3737
+ }
3738
+
1837
3739
  getDecision(id: string): Decision | null {
1838
3740
  const row = this.db.query<DecisionRow, [string]>(`SELECT ${decisionColumns} FROM decisions WHERE id = ?`).get(id);
1839
3741
  return row ? toDecision(row) : null;
@@ -1882,7 +3784,7 @@ export class Store {
1882
3784
  if (!work) return "owner_unknown";
1883
3785
  if (terminalWork(work.status)) return `work_${work.status}`;
1884
3786
  if (decision.sourceSessionPath !== work.sessionPath || decision.sourceCommandSeq === null) return "owner_unknown";
1885
- if (blockedQuestion(decision)) return null;
3787
+ if (blockedQuestion(decision) || releasedWorkQuestion(decision)) return null;
1886
3788
  const newer = this.listWorkCommands(work.id).some((command) =>
1887
3789
  command.kind === "instruct" && command.seq > decision.sourceCommandSeq! &&
1888
3790
  (!object(command.payload) || !Object.hasOwn(command.payload, "decision_operation")));
@@ -1909,6 +3811,7 @@ export class Store {
1909
3811
  const result = this.db.transaction((): DecisionClaim | DecisionStoreError => {
1910
3812
  let decision = this.getDecision(id);
1911
3813
  if (!decision) throw new DecisionStoreError("decision_not_found");
3814
+ if (collaborationStageDecision(decision)) throw new CollaborationStoreError("collaboration_stage_endpoint_required", 403);
1912
3815
  if (securityDecision(decision)) throw new DecisionStoreError("security_endpoint_required");
1913
3816
  if (!Number.isSafeInteger(request.expectedRevision) || request.expectedRevision < 1 ||
1914
3817
  !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(request.submissionId)) {
@@ -1932,6 +3835,7 @@ export class Store {
1932
3835
  decision = this.submitDecision(id, { ...request, liveBinding });
1933
3836
  return { decision, operation: null, fresh: false };
1934
3837
  }
3838
+ if (this.toolPermissions.isToolPurpose(id)) throw new DecisionStoreError("tool_grant_response_required", decision);
1935
3839
  if (decision.revision !== request.expectedRevision || decision.status === "closed" || decision.status === "delivered") {
1936
3840
  throw new DecisionStoreError("decision_conflict", decision);
1937
3841
  }
@@ -1965,6 +3869,9 @@ export class Store {
1965
3869
  let mainInputSeq: number | null = null;
1966
3870
  const work = decision.workId === null ? null : this.getWork(decision.workId);
1967
3871
  const knownWork = work !== null && decision.sourceSessionPath === work.sessionPath && decision.sourceCommandSeq !== null;
3872
+ const linkedId = decision.permission ? null : this.db.query<{ request_id: string | null }, [string]>(
3873
+ "SELECT request_id FROM agent_question_links WHERE decision_id=? AND transfer_id IS NULL AND read_operation_id IS NULL").get(decision.id)?.request_id ?? null;
3874
+ const linked = linkedId ? this.getAgentRequest(linkedId) : null;
1968
3875
  const targetScope = knownWork ? decision.scope : `conv:${decision.ownerConv}`;
1969
3876
  if (control || decision.answerRoute === "follow_up_command") {
1970
3877
  const operation = request.mode === "resume" ? "answer" : request.mode;
@@ -1984,6 +3891,15 @@ export class Store {
1984
3891
  kind: "instruct", requestKey: `decision:${request.submissionId}`,
1985
3892
  sourceSessionPath: work.sessionPath, payload,
1986
3893
  }).id;
3894
+ } else if (!control && linked) {
3895
+ // The owner's answer resumes the request that asked, through its own continuation, never a plain owner turn.
3896
+ // A request that already ended keeps the answer in history only and wakes no model.
3897
+ const message = this.appendEvent(decision.ownerConv, "message.collaboration.answer", {
3898
+ text: `ZGW_DECISION_COMMAND ${JSON.stringify(payload)}`, src: { channel: "web", adapter: "decision-inbox" }, trust: "owner",
3899
+ decision_id: decision.id, decision_operation: operation, requestId: linked.id,
3900
+ });
3901
+ const open = !agentRequestTerminal(linked.state) && linked.state !== "cancel_requested";
3902
+ commandId = open ? `collaboration:${this.newAgentActivation(linked, "owner_answer", message.seq).id}` : "collaboration:ended";
1987
3903
  } else {
1988
3904
  const input = this.acceptMainUser(decision.ownerConv, {
1989
3905
  text: `ZGW_DECISION_COMMAND ${JSON.stringify(payload)}`,
@@ -2000,6 +3916,7 @@ export class Store {
2000
3916
  decision = this.getDecision(id)!;
2001
3917
  }
2002
3918
  }
3919
+ if (commandId === "collaboration:ended") decision = this.transitionDecision(id, decision.revision, { status: "closed", reason: "collaboration_request_ended" });
2003
3920
  const data: DecisionOperation = { decisionId: id, request, targetScope, commandId, mainInputSeq };
2004
3921
  const operation = this.appendEvent(decision.ownerConv, "decision.operation", data) as DecisionOperationEvent;
2005
3922
  return { decision, operation, fresh: true };
@@ -2378,6 +4295,7 @@ export class Store {
2378
4295
  private decisionAtRevision(id: string, revision: number): Decision {
2379
4296
  const decision = this.getDecision(id);
2380
4297
  if (!decision) throw new DecisionStoreError("decision_not_found");
4298
+ if (collaborationStageDecision(decision)) throw new CollaborationStoreError("collaboration_stage_endpoint_required", 403);
2381
4299
  if (decision.revision !== revision) throw new DecisionStoreError("decision_conflict", decision);
2382
4300
  return decision;
2383
4301
  }
@@ -2487,6 +4405,7 @@ export class Store {
2487
4405
  return this.db.transaction(() => {
2488
4406
  const d = this.getDecision(id);
2489
4407
  if (!d) throw new DecisionStoreError("decision_not_found");
4408
+ if (collaborationStageDecision(d) || securityDecision(d)) throw new CollaborationStoreError("purpose_endpoint_required", 403);
2490
4409
  const prior = this.db.query<{ id: string }, [string]>("SELECT id FROM decisions WHERE submission_id = ?").get(request.submissionId);
2491
4410
  if (prior) {
2492
4411
  if (prior.id !== id || d.submissionRevision !== request.expectedRevision || d.submissionMode !== request.mode || !isDeepStrictEqual(d.answer, request.response)) {
@@ -2560,6 +4479,7 @@ export class Store {
2560
4479
  return this.db.transaction((): DecisionDismissal => {
2561
4480
  const d = this.getDecision(id);
2562
4481
  if (!d) throw new DecisionStoreError("decision_not_found");
4482
+ if (collaborationStageDecision(d)) throw new CollaborationStoreError("collaboration_stage_endpoint_required", 403);
2563
4483
  if (securityDecision(d)) throw new DecisionStoreError("security_endpoint_required");
2564
4484
  if (d.revision !== expectedRevision || d.status === "delivered" || d.status === "closed" || d.status === "submitted") {
2565
4485
  throw new DecisionStoreError("decision_conflict", d);
@@ -2721,6 +4641,9 @@ export class Store {
2721
4641
  }
2722
4642
 
2723
4643
  close(): void {
4644
+ // A late reply wake or flush after close must not read this database: nothing is left to publish and nobody is called.
4645
+ this.collaborationOutbox.clear();
4646
+ this.onCollaborationEvents = null;
2724
4647
  this.db.close();
2725
4648
  }
2726
4649
  }