@jungjaehoon/mama-os 0.49.2 → 0.50.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (135) hide show
  1. package/dist/agent/agent-loop.d.ts +5 -1
  2. package/dist/agent/agent-loop.d.ts.map +1 -1
  3. package/dist/agent/agent-loop.js +24 -4
  4. package/dist/agent/agent-loop.js.map +1 -1
  5. package/dist/agent/code-act/host-bridge.d.ts.map +1 -1
  6. package/dist/agent/code-act/host-bridge.js +39 -7
  7. package/dist/agent/code-act/host-bridge.js.map +1 -1
  8. package/dist/agent/codex-app-server-process.d.ts.map +1 -1
  9. package/dist/agent/codex-app-server-process.js +37 -0
  10. package/dist/agent/codex-app-server-process.js.map +1 -1
  11. package/dist/agent/gateway-tool-executor.d.ts +17 -6
  12. package/dist/agent/gateway-tool-executor.d.ts.map +1 -1
  13. package/dist/agent/gateway-tool-executor.js +512 -143
  14. package/dist/agent/gateway-tool-executor.js.map +1 -1
  15. package/dist/agent/gateway-tools.md +7 -6
  16. package/dist/agent/native-effect-observer.d.ts +19 -0
  17. package/dist/agent/native-effect-observer.d.ts.map +1 -0
  18. package/dist/agent/native-effect-observer.js +70 -0
  19. package/dist/agent/native-effect-observer.js.map +1 -0
  20. package/dist/agent/persistent-cli-process.d.ts.map +1 -1
  21. package/dist/agent/persistent-cli-process.js +6 -1
  22. package/dist/agent/persistent-cli-process.js.map +1 -1
  23. package/dist/agent/tool-registry.d.ts.map +1 -1
  24. package/dist/agent/tool-registry.js +15 -8
  25. package/dist/agent/tool-registry.js.map +1 -1
  26. package/dist/agent/types.d.ts +8 -2
  27. package/dist/agent/types.d.ts.map +1 -1
  28. package/dist/agent/types.js.map +1 -1
  29. package/dist/api/report-handler.d.ts +19 -3
  30. package/dist/api/report-handler.d.ts.map +1 -1
  31. package/dist/api/report-handler.js +123 -17
  32. package/dist/api/report-handler.js.map +1 -1
  33. package/dist/api/report-persistence.d.ts +3 -3
  34. package/dist/api/report-persistence.d.ts.map +1 -1
  35. package/dist/api/report-persistence.js +11 -21
  36. package/dist/api/report-persistence.js.map +1 -1
  37. package/dist/cli/commands/start.d.ts +4 -0
  38. package/dist/cli/commands/start.d.ts.map +1 -1
  39. package/dist/cli/commands/start.js +49 -157
  40. package/dist/cli/commands/start.js.map +1 -1
  41. package/dist/cli/runtime/agent-loop-init.d.ts.map +1 -1
  42. package/dist/cli/runtime/agent-loop-init.js +1 -0
  43. package/dist/cli/runtime/agent-loop-init.js.map +1 -1
  44. package/dist/cli/runtime/api-routes-init.d.ts +5 -19
  45. package/dist/cli/runtime/api-routes-init.d.ts.map +1 -1
  46. package/dist/cli/runtime/api-routes-init.js +7 -79
  47. package/dist/cli/runtime/api-routes-init.js.map +1 -1
  48. package/dist/db/migrations/operator-owner-action-candidates.d.ts +19 -0
  49. package/dist/db/migrations/operator-owner-action-candidates.d.ts.map +1 -0
  50. package/dist/db/migrations/operator-owner-action-candidates.js +254 -0
  51. package/dist/db/migrations/operator-owner-action-candidates.js.map +1 -0
  52. package/dist/db/migrations/operator-task-external-lifecycle.d.ts +9 -0
  53. package/dist/db/migrations/operator-task-external-lifecycle.d.ts.map +1 -1
  54. package/dist/db/migrations/operator-task-external-lifecycle.js +52 -22
  55. package/dist/db/migrations/operator-task-external-lifecycle.js.map +1 -1
  56. package/dist/db/migrations/operator-task-review-lifecycle.d.ts.map +1 -1
  57. package/dist/db/migrations/operator-task-review-lifecycle.js +3 -0
  58. package/dist/db/migrations/operator-task-review-lifecycle.js.map +1 -1
  59. package/dist/db/migrations/owner-action-effects.d.ts +18 -0
  60. package/dist/db/migrations/owner-action-effects.d.ts.map +1 -0
  61. package/dist/db/migrations/owner-action-effects.js +111 -0
  62. package/dist/db/migrations/owner-action-effects.js.map +1 -0
  63. package/dist/gateways/message-router.d.ts.map +1 -1
  64. package/dist/gateways/message-router.js +14 -4
  65. package/dist/gateways/message-router.js.map +1 -1
  66. package/dist/gateways/telegram-text-entities.d.ts +99 -0
  67. package/dist/gateways/telegram-text-entities.d.ts.map +1 -0
  68. package/dist/gateways/telegram-text-entities.js +189 -0
  69. package/dist/gateways/telegram-text-entities.js.map +1 -0
  70. package/dist/gateways/telegram.d.ts.map +1 -1
  71. package/dist/gateways/telegram.js +13 -2
  72. package/dist/gateways/telegram.js.map +1 -1
  73. package/dist/gateways/types.d.ts +8 -0
  74. package/dist/gateways/types.d.ts.map +1 -1
  75. package/dist/operator/board-read-views.d.ts +9 -4
  76. package/dist/operator/board-read-views.d.ts.map +1 -1
  77. package/dist/operator/board-read-views.js +11 -0
  78. package/dist/operator/board-read-views.js.map +1 -1
  79. package/dist/operator/external-lifecycle-candidates.d.ts +15 -1
  80. package/dist/operator/external-lifecycle-candidates.d.ts.map +1 -1
  81. package/dist/operator/external-lifecycle-candidates.js +164 -0
  82. package/dist/operator/external-lifecycle-candidates.js.map +1 -1
  83. package/dist/operator/external-lifecycle-discovery.d.ts +61 -0
  84. package/dist/operator/external-lifecycle-discovery.d.ts.map +1 -0
  85. package/dist/operator/external-lifecycle-discovery.js +103 -0
  86. package/dist/operator/external-lifecycle-discovery.js.map +1 -0
  87. package/dist/operator/external-lifecycle.d.ts +23 -0
  88. package/dist/operator/external-lifecycle.d.ts.map +1 -1
  89. package/dist/operator/owner-action-effects.d.ts +101 -0
  90. package/dist/operator/owner-action-effects.d.ts.map +1 -0
  91. package/dist/operator/owner-action-effects.js +335 -0
  92. package/dist/operator/owner-action-effects.js.map +1 -0
  93. package/dist/operator/owner-event-inbox.d.ts +1 -0
  94. package/dist/operator/owner-event-inbox.d.ts.map +1 -1
  95. package/dist/operator/owner-event-inbox.js +6 -0
  96. package/dist/operator/owner-event-inbox.js.map +1 -1
  97. package/dist/operator/owner-event-loop.d.ts +3 -0
  98. package/dist/operator/owner-event-loop.d.ts.map +1 -1
  99. package/dist/operator/owner-event-loop.js +34 -0
  100. package/dist/operator/owner-event-loop.js.map +1 -1
  101. package/dist/operator/owner-event-outcome.d.ts.map +1 -1
  102. package/dist/operator/owner-event-outcome.js +6 -0
  103. package/dist/operator/owner-event-outcome.js.map +1 -1
  104. package/dist/operator/owner-event-policy.d.ts.map +1 -1
  105. package/dist/operator/owner-event-policy.js +2 -26
  106. package/dist/operator/owner-event-policy.js.map +1 -1
  107. package/dist/operator/owner-event-prompt.d.ts.map +1 -1
  108. package/dist/operator/owner-event-prompt.js +2 -5
  109. package/dist/operator/owner-event-prompt.js.map +1 -1
  110. package/dist/operator/task-ledger.d.ts +93 -14
  111. package/dist/operator/task-ledger.d.ts.map +1 -1
  112. package/dist/operator/task-ledger.js +430 -103
  113. package/dist/operator/task-ledger.js.map +1 -1
  114. package/dist/operator/temporal-reconcile.d.ts.map +1 -1
  115. package/dist/operator/temporal-reconcile.js +6 -2
  116. package/dist/operator/temporal-reconcile.js.map +1 -1
  117. package/dist/operator/workorder-consumer.d.ts +2 -0
  118. package/dist/operator/workorder-consumer.d.ts.map +1 -1
  119. package/dist/operator/workorder-consumer.js +20 -5
  120. package/dist/operator/workorder-consumer.js.map +1 -1
  121. package/dist/scheduler/heartbeat.d.ts.map +1 -1
  122. package/dist/scheduler/heartbeat.js +1 -0
  123. package/dist/scheduler/heartbeat.js.map +1 -1
  124. package/dist/sqlite.d.ts +1 -1
  125. package/dist/sqlite.d.ts.map +1 -1
  126. package/dist/sqlite.js +3 -2
  127. package/dist/sqlite.js.map +1 -1
  128. package/dist/wiki/wiki-read.d.ts +2 -1
  129. package/dist/wiki/wiki-read.d.ts.map +1 -1
  130. package/dist/wiki/wiki-read.js +13 -2
  131. package/dist/wiki/wiki-read.js.map +1 -1
  132. package/dist/wiki/wiki-turn-contract.js +1 -1
  133. package/dist/wiki/wiki-turn-contract.js.map +1 -1
  134. package/package.json +1 -1
  135. package/public/viewer/operator/operator.js +3 -1
@@ -27,6 +27,7 @@ const operator_task_temporal_js_1 = require("../db/migrations/operator-task-temp
27
27
  const operator_task_external_lifecycle_js_1 = require("../db/migrations/operator-task-external-lifecycle.js");
28
28
  const operator_task_review_lifecycle_js_1 = require("../db/migrations/operator-task-review-lifecycle.js");
29
29
  const operator_task_classification_js_1 = require("../db/migrations/operator-task-classification.js");
30
+ const operator_owner_action_candidates_js_1 = require("../db/migrations/operator-owner-action-candidates.js");
30
31
  const effects_js_1 = require("../evidence/effects.js");
31
32
  const temporal_effect_js_1 = require("./temporal-effect.js");
32
33
  const task_temporal_js_1 = require("./task-temporal.js");
@@ -129,6 +130,40 @@ function isoToEpochMs(iso) {
129
130
  const ms = Date.parse(`${iso}T00:00:00Z`);
130
131
  return Number.isNaN(ms) ? null : ms;
131
132
  }
133
+ const OWNER_ACTION_IDENTITY_MAX_LENGTH = 512;
134
+ function requireOwnerActionIdentity(value, field) {
135
+ if (typeof value !== 'string') {
136
+ throw new Error(`owner action ${field} must be a non-blank string`);
137
+ }
138
+ if (value.trim().length === 0 || value.trim() !== value) {
139
+ throw new Error(`owner action ${field} must be a non-blank string without surrounding space`);
140
+ }
141
+ if (value.length > OWNER_ACTION_IDENTITY_MAX_LENGTH) {
142
+ throw new Error(`owner action ${field} must be at most ${OWNER_ACTION_IDENTITY_MAX_LENGTH} characters`);
143
+ }
144
+ return value;
145
+ }
146
+ /**
147
+ * Same identity rules as OwnerActionEffectLedger: every field is a trusted
148
+ * host identity, never model text. Checked before any storage is touched.
149
+ */
150
+ function requireOwnerActionContext(context) {
151
+ if (context === null || typeof context !== 'object') {
152
+ throw new Error('owner action context is required');
153
+ }
154
+ const workOrderAttemptId = context.workOrderAttemptId;
155
+ if (workOrderAttemptId !== undefined &&
156
+ (!Number.isSafeInteger(workOrderAttemptId) || workOrderAttemptId <= 0)) {
157
+ throw new Error('owner action workOrderAttemptId must be a positive integer when present');
158
+ }
159
+ return {
160
+ ownerScope: requireOwnerActionIdentity(context.ownerScope, 'ownerScope'),
161
+ occurrenceKey: requireOwnerActionIdentity(context.occurrenceKey, 'occurrenceKey'),
162
+ modelRunId: requireOwnerActionIdentity(context.modelRunId, 'modelRunId'),
163
+ envelopeHash: requireOwnerActionIdentity(context.envelopeHash, 'envelopeHash'),
164
+ workOrderAttemptId: workOrderAttemptId ?? null,
165
+ };
166
+ }
132
167
  function assertEnum(value, allowed, field) {
133
168
  if (!allowed.includes(value)) {
134
169
  throw new Error(`${field} must be one of ${allowed.join('|')}, got: ${value}`);
@@ -189,6 +224,7 @@ function rowToRecord(row, now, timeZone) {
189
224
  }, now, timeZone),
190
225
  reviewStartedAt: row.review_started_at,
191
226
  reviewAnchorEventId: row.review_anchor_event_id,
227
+ reviewAnchorSourceChannel: row.review_anchor_source_channel ?? null,
192
228
  completionCriteria: row.completion_criteria ?? null,
193
229
  resolutionKind: (row.resolution_kind ?? null),
194
230
  createdAt: row.created_at,
@@ -199,10 +235,14 @@ class TaskLedger {
199
235
  db;
200
236
  now;
201
237
  timeZone;
238
+ onOwnerTaskChangeCommitted;
239
+ pendingOwnerTaskGeneration = null;
240
+ ownerTaskNotificationScheduled = false;
202
241
  constructor(db, options = {}) {
203
242
  this.db = db;
204
243
  this.now = options.now ?? Date.now;
205
244
  this.timeZone = options.timeZone ?? Intl.DateTimeFormat().resolvedOptions().timeZone;
245
+ this.onOwnerTaskChangeCommitted = options.onOwnerTaskChangeCommitted;
206
246
  // Validate the injected or boot-resolved zone once, before reads depend on it.
207
247
  new Intl.DateTimeFormat('en-US', { timeZone: this.timeZone }).format(0);
208
248
  this.runMigration();
@@ -270,6 +310,7 @@ class TaskLedger {
270
310
  last_temporal_attempt_id INTEGER,
271
311
  review_started_at INTEGER,
272
312
  review_anchor_event_id TEXT,
313
+ review_anchor_source_channel TEXT,
273
314
  completion_criteria TEXT,
274
315
  resolution_kind TEXT,
275
316
  created_at INTEGER NOT NULL,
@@ -337,6 +378,7 @@ class TaskLedger {
337
378
  }
338
379
  (0, operator_task_temporal_js_1.applyOperatorTaskTemporalMigration)(this.db);
339
380
  (0, operator_task_external_lifecycle_js_1.applyOperatorTaskExternalLifecycleMigration)(this.db);
381
+ (0, operator_owner_action_candidates_js_1.applyOperatorOwnerActionCandidatesMigration)(this.db);
340
382
  (0, operator_task_review_lifecycle_js_1.applyOperatorTaskReviewLifecycleMigration)(this.db);
341
383
  (0, operator_task_classification_js_1.applyOperatorTaskClassificationMigration)(this.db);
342
384
  // Old-predicate unique index (no terminal exclusion) -> swap in place.
@@ -596,6 +638,37 @@ class TaskLedger {
596
638
  .digest('base64url')
597
639
  .slice(0, 22);
598
640
  }
641
+ notifyOwnerTaskCommit(previousGeneration) {
642
+ if (!this.onOwnerTaskChangeCommitted) {
643
+ return;
644
+ }
645
+ const generation = this.readGeneration();
646
+ if (generation === previousGeneration) {
647
+ return;
648
+ }
649
+ this.pendingOwnerTaskGeneration = generation;
650
+ if (this.ownerTaskNotificationScheduled) {
651
+ return;
652
+ }
653
+ this.ownerTaskNotificationScheduled = true;
654
+ queueMicrotask(() => this.flushOwnerTaskNotification());
655
+ }
656
+ flushOwnerTaskNotification() {
657
+ this.ownerTaskNotificationScheduled = false;
658
+ const expected = this.pendingOwnerTaskGeneration;
659
+ this.pendingOwnerTaskGeneration = null;
660
+ if (!expected || !this.onOwnerTaskChangeCommitted) {
661
+ return;
662
+ }
663
+ const current = this.readGeneration();
664
+ if (current !== expected) {
665
+ this.pendingOwnerTaskGeneration = current;
666
+ this.ownerTaskNotificationScheduled = true;
667
+ queueMicrotask(() => this.flushOwnerTaskNotification());
668
+ return;
669
+ }
670
+ this.onOwnerTaskChangeCommitted(current);
671
+ }
599
672
  /**
600
673
  * Aggregate counts over the same owner predicate `listPage` filters by. Facets
601
674
  * come from SQL GROUP BY (each partitions total); due buckets are computed in
@@ -679,7 +752,8 @@ class TaskLedger {
679
752
  getExternalBinding(taskId) {
680
753
  const row = this.db
681
754
  .prepare(`SELECT id, revision, task_id, connector, source_type, external_source_id,
682
- last_observation_seq, created_by_attempt_id
755
+ last_observation_seq, created_by_attempt_id, created_by_run_id,
756
+ created_by_owner_scope, created_at
683
757
  FROM operator_external_task_bindings
684
758
  WHERE task_id = ? AND active = 1`)
685
759
  .get(taskId);
@@ -818,6 +892,218 @@ class TaskLedger {
818
892
  if (all.length === 0) {
819
893
  return { disposition: 'none' };
820
894
  }
895
+ // Receipts are global by candidate id, so a decision settled by an owner
896
+ // run is seen here exactly like one settled by this attempt.
897
+ return this.candidateReceiptDisposition(all);
898
+ }
899
+ /**
900
+ * Resolve one candidate decision's authority (Task B2).
901
+ *
902
+ * A plain attempt id is the legacy Board wrapper. A discriminated source is
903
+ * either that same Board path or a verified owner run whose host-attested
904
+ * snapshot must match the caller's scope, run and envelope exactly. Both
905
+ * return the SAME decision inputs; the receipt records which one settled it.
906
+ */
907
+ resolveExternalCandidateAuthority(source, kind, origin) {
908
+ const resolved = typeof source === 'number' ? { kind: 'board', attemptId: source } : source;
909
+ const label = kind === 'binding' ? 'external binding' : 'external lifecycle';
910
+ if (resolved.kind === 'board') {
911
+ const attemptId = resolved.attemptId;
912
+ if (origin.workOrderAttemptId !== attemptId) {
913
+ throw new Error(`${label} decision requires trusted attempt origin ${attemptId}`);
914
+ }
915
+ return {
916
+ source: resolved,
917
+ receiptOrigin: {
918
+ attemptId,
919
+ runId: origin.runId ?? null,
920
+ ownerScope: null,
921
+ envelopeHash: null,
922
+ },
923
+ load: (candidateId) => {
924
+ const attempt = this.getWorkOrderById(attemptId);
925
+ return {
926
+ candidate: this.loadBoardCandidate(attemptId, candidateId, kind),
927
+ snapshotAt: attempt?.createdAt ?? 0,
928
+ };
929
+ },
930
+ };
931
+ }
932
+ const context = requireOwnerActionContext(resolved.context);
933
+ if (origin.runId !== context.modelRunId) {
934
+ throw new Error(`${label} decision requires trusted owner run origin ${context.modelRunId}`);
935
+ }
936
+ if ((origin.workOrderAttemptId ?? null) !== context.workOrderAttemptId) {
937
+ throw new Error(`${label} decision attempt ${String(origin.workOrderAttemptId)} does not match the attesting owner run`);
938
+ }
939
+ if (context.workOrderAttemptId !== null) {
940
+ this.assertWorkOrderAttemptActive(label, context.workOrderAttemptId);
941
+ }
942
+ return {
943
+ source: resolved,
944
+ receiptOrigin: {
945
+ attemptId: context.workOrderAttemptId,
946
+ runId: context.modelRunId,
947
+ ownerScope: context.ownerScope,
948
+ envelopeHash: context.envelopeHash,
949
+ },
950
+ load: (candidateId) => {
951
+ const attested = this.loadOwnerAttestedCandidate(context, candidateId, kind);
952
+ return { candidate: attested.candidate, snapshotAt: attested.attestedAt };
953
+ },
954
+ };
955
+ }
956
+ /** Same contract as loadBoardCandidate, for either authority source. */
957
+ loadExternalCandidate(source, candidateId, kind) {
958
+ const resolved = typeof source === 'number' ? { kind: 'board', attemptId: source } : source;
959
+ return resolved.kind === 'board'
960
+ ? this.loadBoardCandidate(resolved.attemptId, candidateId, kind)
961
+ : this.loadOwnerActionCandidate(resolved.context, candidateId, kind);
962
+ }
963
+ /**
964
+ * Persist host-built candidate snapshots under a verified owner run.
965
+ *
966
+ * Every snapshot is re-validated as host-built (exact shape, Kagemusha
967
+ * mapping, fixed evidence summary, content-derived id) before it is stored;
968
+ * model-authored bytes never enter this table. Rows are immutable. The same
969
+ * run attesting the same candidate again with identical bytes is a no-op;
970
+ * different bytes under the same id are a conflict, not a second row.
971
+ */
972
+ attestOwnerActionCandidates(context, candidates) {
973
+ const verified = requireOwnerActionContext(context);
974
+ const prepared = [
975
+ ...candidates.bindingCandidates.map((candidate) => (0, external_lifecycle_candidates_js_1.parseHostBuiltExternalLifecycleCandidate)('binding', candidate)),
976
+ ...candidates.lifecycleCandidates.map((candidate) => (0, external_lifecycle_candidates_js_1.parseHostBuiltExternalLifecycleCandidate)('lifecycle', candidate)),
977
+ ].map((candidate) => {
978
+ const json = (0, external_lifecycle_candidates_js_1.canonicalExternalLifecycleCandidateJson)(candidate);
979
+ return { candidate, json, sha256: (0, node_crypto_1.createHash)('sha256').update(json).digest('hex') };
980
+ });
981
+ let attested = 0;
982
+ let alreadyAttested = 0;
983
+ this.db.exec('BEGIN IMMEDIATE');
984
+ try {
985
+ for (const entry of prepared) {
986
+ const existing = this.db
987
+ .prepare(`SELECT owner_scope, envelope_hash, occurrence_key, workorder_attempt_id,
988
+ candidate_kind, candidate_sha256
989
+ FROM ${operator_owner_action_candidates_js_1.OWNER_ACTION_CANDIDATES_TABLE}
990
+ WHERE model_run_id = ? AND candidate_id = ?`)
991
+ .get(verified.modelRunId, entry.candidate.candidateId);
992
+ if (existing) {
993
+ if (existing.owner_scope !== verified.ownerScope ||
994
+ existing.envelope_hash !== verified.envelopeHash ||
995
+ existing.occurrence_key !== verified.occurrenceKey ||
996
+ existing.workorder_attempt_id !== verified.workOrderAttemptId ||
997
+ existing.candidate_kind !== entry.candidate.kind ||
998
+ existing.candidate_sha256 !== entry.sha256) {
999
+ throw new Error(`owner action candidate ${entry.candidate.candidateId} attestation conflict: run ${verified.modelRunId} already holds a snapshot that differs`);
1000
+ }
1001
+ alreadyAttested += 1;
1002
+ continue;
1003
+ }
1004
+ const task = this.getRowById(entry.candidate.taskId);
1005
+ if (!task || task.kind !== 'owner') {
1006
+ throw new Error(`owner action candidate ${entry.candidate.candidateId} references no owner task`);
1007
+ }
1008
+ this.db
1009
+ .prepare(`INSERT INTO ${operator_owner_action_candidates_js_1.OWNER_ACTION_CANDIDATES_TABLE}
1010
+ (model_run_id, candidate_id, owner_scope, envelope_hash, occurrence_key,
1011
+ workorder_attempt_id, candidate_kind, task_id, event_id, candidate_json,
1012
+ candidate_sha256, created_at)
1013
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
1014
+ .run(verified.modelRunId, entry.candidate.candidateId, verified.ownerScope, verified.envelopeHash, verified.occurrenceKey, verified.workOrderAttemptId, entry.candidate.kind, entry.candidate.taskId, entry.candidate.eventId, entry.json, entry.sha256, this.now());
1015
+ attested += 1;
1016
+ }
1017
+ this.db.exec('COMMIT');
1018
+ }
1019
+ catch (error) {
1020
+ this.db.exec('ROLLBACK');
1021
+ throw error;
1022
+ }
1023
+ return { attested, alreadyAttested };
1024
+ }
1025
+ /** Bounded, intent-free listing of what one owner run attested. */
1026
+ listOwnerActionCandidates(context) {
1027
+ const verified = requireOwnerActionContext(context);
1028
+ return this.db
1029
+ .prepare(`SELECT candidate_id, candidate_kind, task_id, event_id
1030
+ FROM ${operator_owner_action_candidates_js_1.OWNER_ACTION_CANDIDATES_TABLE}
1031
+ WHERE model_run_id = ? AND owner_scope = ? AND envelope_hash = ?
1032
+ AND occurrence_key = ? AND workorder_attempt_id IS ?
1033
+ ORDER BY created_at ASC, candidate_kind ASC, candidate_id ASC`)
1034
+ .all(verified.modelRunId, verified.ownerScope, verified.envelopeHash, verified.occurrenceKey, verified.workOrderAttemptId).map((row) => ({
1035
+ candidateId: row.candidate_id,
1036
+ kind: row.candidate_kind,
1037
+ taskId: row.task_id,
1038
+ eventId: row.event_id,
1039
+ }));
1040
+ }
1041
+ /**
1042
+ * Receipt view of one owner run's attested candidates, in the same shape as
1043
+ * inspectBoardCandidateAttempt so recovery can treat both sources alike.
1044
+ */
1045
+ inspectOwnerActionCandidateRun(context) {
1046
+ const all = this.listOwnerActionCandidates(context);
1047
+ if (all.length === 0) {
1048
+ return { disposition: 'none' };
1049
+ }
1050
+ return this.candidateReceiptDisposition(all);
1051
+ }
1052
+ loadOwnerActionCandidate(context, candidateId, kind) {
1053
+ return this.loadOwnerAttestedCandidate(requireOwnerActionContext(context), candidateId, kind)
1054
+ .candidate;
1055
+ }
1056
+ /**
1057
+ * Recover a candidate only from the attestation the exact owner run made.
1058
+ * Stored bytes are revalidated: sha256 over the canonical JSON, exact shape,
1059
+ * and the content-derived candidate id must all still agree.
1060
+ */
1061
+ loadOwnerAttestedCandidate(context, candidateId, kind) {
1062
+ const row = this.db
1063
+ .prepare(`SELECT owner_scope, envelope_hash, occurrence_key, workorder_attempt_id,
1064
+ candidate_kind, task_id, event_id, candidate_json, candidate_sha256, created_at
1065
+ FROM ${operator_owner_action_candidates_js_1.OWNER_ACTION_CANDIDATES_TABLE}
1066
+ WHERE model_run_id = ? AND candidate_id = ?`)
1067
+ .get(context.modelRunId, candidateId);
1068
+ if (!row) {
1069
+ throw new Error(`external lifecycle ${kind} candidate ${candidateId} is not attested for owner run ${context.modelRunId}`);
1070
+ }
1071
+ if (row.owner_scope !== context.ownerScope) {
1072
+ throw new Error(`external lifecycle ${kind} candidate ${candidateId} was attested under a different owner scope`);
1073
+ }
1074
+ if (row.envelope_hash !== context.envelopeHash) {
1075
+ throw new Error(`external lifecycle ${kind} candidate ${candidateId} was attested under a different envelope`);
1076
+ }
1077
+ if (row.occurrence_key !== context.occurrenceKey) {
1078
+ throw new Error(`external lifecycle ${kind} candidate ${candidateId} was attested under a different occurrence`);
1079
+ }
1080
+ if (row.workorder_attempt_id !== context.workOrderAttemptId) {
1081
+ throw new Error(`external lifecycle ${kind} candidate ${candidateId} was attested under a different workorder attempt`);
1082
+ }
1083
+ if (row.candidate_kind !== kind) {
1084
+ throw new Error(`external lifecycle candidate ${candidateId} has the wrong kind (attested as ${row.candidate_kind})`);
1085
+ }
1086
+ const digest = (0, node_crypto_1.createHash)('sha256').update(row.candidate_json).digest('hex');
1087
+ if (digest !== row.candidate_sha256) {
1088
+ throw new Error(`external lifecycle ${kind} candidate ${candidateId} failed attestation integrity (sha256 mismatch)`);
1089
+ }
1090
+ let parsed;
1091
+ try {
1092
+ parsed = JSON.parse(row.candidate_json);
1093
+ }
1094
+ catch {
1095
+ throw new Error(`external lifecycle ${kind} candidate ${candidateId} failed attestation integrity (unreadable)`);
1096
+ }
1097
+ const candidate = (0, external_lifecycle_candidates_js_1.parseHostBuiltExternalLifecycleCandidate)(kind, parsed);
1098
+ if (candidate.candidateId !== candidateId ||
1099
+ candidate.taskId !== row.task_id ||
1100
+ candidate.eventId !== row.event_id ||
1101
+ (0, external_lifecycle_candidates_js_1.canonicalExternalLifecycleCandidateJson)(candidate) !== row.candidate_json) {
1102
+ throw new Error(`external lifecycle ${kind} candidate ${candidateId} failed attestation integrity (identity mismatch)`);
1103
+ }
1104
+ return { candidate, attestedAt: row.created_at };
1105
+ }
1106
+ candidateReceiptDisposition(all) {
821
1107
  const outcomes = [];
822
1108
  const missingCandidateIds = [];
823
1109
  for (const candidate of all) {
@@ -839,15 +1125,14 @@ class TaskLedger {
839
1125
  }
840
1126
  return { disposition: 'partial', missingCandidateIds };
841
1127
  }
842
- applyExternalBindingDecision(attemptId, input, origin) {
1128
+ applyExternalBindingDecision(source, input, origin) {
843
1129
  (0, external_lifecycle_candidates_js_1.validateExternalLifecycleDecision)('binding', input);
844
- if (origin.workOrderAttemptId !== attemptId) {
845
- throw new Error(`external binding decision requires trusted attempt origin ${attemptId}`);
846
- }
1130
+ const authority = this.resolveExternalCandidateAuthority(source, 'binding', origin);
1131
+ const receiptOrigin = authority.receiptOrigin;
847
1132
  let receipt = null;
848
1133
  this.db.exec('BEGIN IMMEDIATE');
849
1134
  try {
850
- const candidate = this.loadBoardCandidate(attemptId, input.candidate_id, 'binding');
1135
+ const { candidate } = authority.load(input.candidate_id);
851
1136
  if (candidate.kind !== 'binding') {
852
1137
  throw new Error(`external lifecycle candidate ${input.candidate_id} has the wrong kind`);
853
1138
  }
@@ -888,16 +1173,19 @@ class TaskLedger {
888
1173
  const inserted = this.db
889
1174
  .prepare(`INSERT INTO operator_external_task_bindings
890
1175
  (task_id, connector, source_type, external_source_id, last_observation_seq,
891
- created_by_attempt_id, active, created_at, updated_at)
892
- VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?)`)
893
- .run(candidate.taskId, candidate.connector, candidate.sourceType, candidate.externalSourceId, candidate.operatorObservationSeq, attemptId, now, now);
1176
+ created_by_attempt_id, created_by_run_id, created_by_owner_scope,
1177
+ created_by_envelope_hash, active, created_at, updated_at)
1178
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?)`)
1179
+ .run(candidate.taskId, candidate.connector, candidate.sourceType, candidate.externalSourceId, candidate.operatorObservationSeq, receiptOrigin.attemptId, receiptOrigin.runId, receiptOrigin.ownerScope, receiptOrigin.envelopeHash, now, now);
894
1180
  bindingId = Number(inserted.lastInsertRowid);
895
1181
  }
896
- this.insertBindingReceipt(candidate, attemptId, input, outcome, bindingId, origin);
1182
+ this.insertBindingReceipt(candidate, receiptOrigin, input, outcome, bindingId, origin);
897
1183
  receipt = {
898
1184
  kind: 'binding',
899
1185
  candidateId: candidate.candidateId,
900
- workOrderAttemptId: attemptId,
1186
+ workOrderAttemptId: receiptOrigin.attemptId,
1187
+ originRunId: receiptOrigin.runId,
1188
+ originOwnerScope: receiptOrigin.ownerScope,
901
1189
  taskId: candidate.taskId,
902
1190
  outcome,
903
1191
  reason: input.reason,
@@ -912,15 +1200,15 @@ class TaskLedger {
912
1200
  }
913
1201
  return receipt;
914
1202
  }
915
- applyExternalLifecycleDecision(attemptId, input, origin) {
1203
+ applyExternalLifecycleDecision(source, input, origin) {
916
1204
  (0, external_lifecycle_candidates_js_1.validateExternalLifecycleDecision)('lifecycle', input);
917
- if (origin.workOrderAttemptId !== attemptId) {
918
- throw new Error(`external lifecycle decision requires trusted attempt origin ${attemptId}`);
919
- }
1205
+ const authority = this.resolveExternalCandidateAuthority(source, 'lifecycle', origin);
1206
+ const receiptOrigin = authority.receiptOrigin;
920
1207
  let receipt = null;
1208
+ const previousGeneration = this.onOwnerTaskChangeCommitted ? this.readGeneration() : null;
921
1209
  this.db.exec('BEGIN IMMEDIATE');
922
1210
  try {
923
- const candidate = this.loadBoardCandidate(attemptId, input.candidate_id, 'lifecycle');
1211
+ const { candidate, snapshotAt } = authority.load(input.candidate_id);
924
1212
  if (candidate.kind !== 'lifecycle') {
925
1213
  throw new Error(`external lifecycle candidate ${input.candidate_id} has the wrong kind`);
926
1214
  }
@@ -944,12 +1232,19 @@ class TaskLedger {
944
1232
  if (!task || task.kind !== 'owner' || !binding) {
945
1233
  throw new Error(`external lifecycle candidate ${candidate.candidateId} has no active binding`);
946
1234
  }
1235
+ // A binding that came into being after this candidate's snapshot cannot
1236
+ // be the binding the snapshot described. Board attempts keep the exact
1237
+ // legacy ordering rule; when either side lacks an attempt id (owner-run
1238
+ // decisions), the ledger clock decides.
1239
+ const bindingPostdatesSnapshot = authority.source.kind === 'board' && binding.createdByAttemptId !== null
1240
+ ? binding.createdByAttemptId >= authority.source.attemptId
1241
+ : binding.createdAt > snapshotAt;
947
1242
  const superseded = binding.id !== candidate.bindingId ||
948
1243
  binding.revision !== candidate.bindingRevision ||
949
1244
  binding.connector !== candidate.connector ||
950
1245
  binding.sourceType !== candidate.sourceType ||
951
1246
  binding.externalSourceId !== candidate.externalSourceId ||
952
- binding.createdByAttemptId >= attemptId ||
1247
+ bindingPostdatesSnapshot ||
953
1248
  task.revision !== candidate.taskRevision ||
954
1249
  candidate.operatorObservationSeq <= binding.lastObservationSeq;
955
1250
  const taskRevisionBefore = task.revision;
@@ -984,11 +1279,13 @@ class TaskLedger {
984
1279
  throw new Error(`external lifecycle candidate ${candidate.candidateId} binding watermark changed`);
985
1280
  }
986
1281
  }
987
- this.insertLifecycleReceipt(candidate, attemptId, input, outcome, taskRevisionBefore, taskRevisionAfter, origin);
1282
+ this.insertLifecycleReceipt(candidate, receiptOrigin, input, outcome, taskRevisionBefore, taskRevisionAfter, origin);
988
1283
  receipt = {
989
1284
  kind: 'lifecycle',
990
1285
  candidateId: candidate.candidateId,
991
- workOrderAttemptId: attemptId,
1286
+ workOrderAttemptId: receiptOrigin.attemptId,
1287
+ originRunId: receiptOrigin.runId,
1288
+ originOwnerScope: receiptOrigin.ownerScope,
992
1289
  taskId: candidate.taskId,
993
1290
  outcome,
994
1291
  reason: input.reason,
@@ -1002,16 +1299,18 @@ class TaskLedger {
1002
1299
  this.db.exec('ROLLBACK');
1003
1300
  throw error;
1004
1301
  }
1302
+ this.notifyOwnerTaskCommit(previousGeneration);
1005
1303
  return receipt;
1006
1304
  }
1007
1305
  getExternalCandidateReceipt(candidateId) {
1008
1306
  const binding = this.db
1009
- .prepare(`SELECT candidate_id, workorder_attempt_id, task_id, outcome, reason, binding_id
1307
+ .prepare(`SELECT candidate_id, workorder_attempt_id, origin_run_id, origin_owner_scope, task_id,
1308
+ outcome, reason, binding_id
1010
1309
  FROM operator_external_binding_receipts WHERE candidate_id = ?`)
1011
1310
  .get(candidateId);
1012
1311
  const lifecycle = this.db
1013
- .prepare(`SELECT candidate_id, workorder_attempt_id, task_id, outcome, reason,
1014
- task_revision_before, task_revision_after
1312
+ .prepare(`SELECT candidate_id, workorder_attempt_id, origin_run_id, origin_owner_scope, task_id,
1313
+ outcome, reason, task_revision_before, task_revision_after
1015
1314
  FROM operator_external_lifecycle_receipts WHERE candidate_id = ?`)
1016
1315
  .get(candidateId);
1017
1316
  if (binding && lifecycle) {
@@ -1035,6 +1334,8 @@ class TaskLedger {
1035
1334
  kind: 'binding',
1036
1335
  candidateId: binding.candidate_id,
1037
1336
  workOrderAttemptId: binding.workorder_attempt_id,
1337
+ originRunId: binding.origin_run_id,
1338
+ originOwnerScope: binding.origin_owner_scope,
1038
1339
  taskId: binding.task_id,
1039
1340
  outcome: binding.outcome,
1040
1341
  reason: binding.reason,
@@ -1046,6 +1347,8 @@ class TaskLedger {
1046
1347
  kind: 'lifecycle',
1047
1348
  candidateId: lifecycle.candidate_id,
1048
1349
  workOrderAttemptId: lifecycle.workorder_attempt_id,
1350
+ originRunId: lifecycle.origin_run_id,
1351
+ originOwnerScope: lifecycle.origin_owner_scope,
1049
1352
  taskId: lifecycle.task_id,
1050
1353
  outcome: lifecycle.outcome,
1051
1354
  reason: lifecycle.reason,
@@ -1064,6 +1367,9 @@ class TaskLedger {
1064
1367
  externalSourceId: row.external_source_id,
1065
1368
  lastObservationSeq: row.last_observation_seq,
1066
1369
  createdByAttemptId: row.created_by_attempt_id,
1370
+ createdByRunId: row.created_by_run_id,
1371
+ createdByOwnerScope: row.created_by_owner_scope,
1372
+ createdAt: row.created_at,
1067
1373
  };
1068
1374
  }
1069
1375
  assertBindingReplayMatches(receipt, candidate, input) {
@@ -1077,15 +1383,16 @@ class TaskLedger {
1077
1383
  throw new Error(`external binding receipt ${candidate.candidateId} conflicts with replay decision`);
1078
1384
  }
1079
1385
  }
1080
- insertBindingReceipt(candidate, attemptId, input, outcome, bindingId, origin) {
1386
+ insertBindingReceipt(candidate, receiptOrigin, input, outcome, bindingId, origin) {
1081
1387
  this.db
1082
1388
  .prepare(`INSERT INTO operator_external_binding_receipts
1083
1389
  (candidate_id, decision, workorder_attempt_id, task_id, event_id, connector, source_type,
1084
1390
  external_source_id, channel_partition, content_sha256, source_timestamp_ms,
1085
1391
  operator_ingest_seq, operator_observation_seq, task_revision, outcome, reason, binding_id,
1086
- origin_run_id, origin_cause_event_ids, created_at)
1087
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
1088
- .run(candidate.candidateId, input.decision, attemptId, candidate.taskId, candidate.eventId, candidate.connector, candidate.sourceType, candidate.externalSourceId, candidate.channelPartition, candidate.contentSha256, candidate.sourceTimestampMs, candidate.operatorIngestSeq, candidate.operatorObservationSeq, candidate.taskRevision, outcome, input.reason, bindingId ?? null, origin.runId ?? null, JSON.stringify(origin.causeEventIds ?? []), this.now());
1392
+ origin_run_id, origin_cause_event_ids, origin_owner_scope, origin_envelope_hash,
1393
+ created_at)
1394
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
1395
+ .run(candidate.candidateId, input.decision, receiptOrigin.attemptId, candidate.taskId, candidate.eventId, candidate.connector, candidate.sourceType, candidate.externalSourceId, candidate.channelPartition, candidate.contentSha256, candidate.sourceTimestampMs, candidate.operatorIngestSeq, candidate.operatorObservationSeq, candidate.taskRevision, outcome, input.reason, bindingId ?? null, receiptOrigin.runId, JSON.stringify(origin.causeEventIds ?? []), receiptOrigin.ownerScope, receiptOrigin.envelopeHash, this.now());
1089
1396
  }
1090
1397
  assertLifecycleReplayMatches(receipt, candidate, input) {
1091
1398
  const stored = this.db
@@ -1098,16 +1405,16 @@ class TaskLedger {
1098
1405
  throw new Error(`external lifecycle receipt ${candidate.candidateId} conflicts with replay decision`);
1099
1406
  }
1100
1407
  }
1101
- insertLifecycleReceipt(candidate, attemptId, input, outcome, taskRevisionBefore, taskRevisionAfter, origin) {
1408
+ insertLifecycleReceipt(candidate, receiptOrigin, input, outcome, taskRevisionBefore, taskRevisionAfter, origin) {
1102
1409
  this.db
1103
1410
  .prepare(`INSERT INTO operator_external_lifecycle_receipts
1104
1411
  (candidate_id, decision, workorder_attempt_id, task_id, event_id, connector, source_type,
1105
1412
  external_source_id, channel_partition, content_sha256, source_timestamp_ms,
1106
1413
  operator_ingest_seq, operator_observation_seq, binding_id, binding_revision,
1107
1414
  task_revision_before, task_revision_after, outcome, reason, origin_run_id,
1108
- origin_cause_event_ids, created_at)
1109
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
1110
- .run(candidate.candidateId, input.decision, attemptId, candidate.taskId, candidate.eventId, candidate.connector, candidate.sourceType, candidate.externalSourceId, candidate.channelPartition, candidate.contentSha256, candidate.sourceTimestampMs, candidate.operatorIngestSeq, candidate.operatorObservationSeq, candidate.bindingId, candidate.bindingRevision, taskRevisionBefore, taskRevisionAfter, outcome, input.reason, origin.runId ?? null, JSON.stringify(origin.causeEventIds ?? []), this.now());
1415
+ origin_cause_event_ids, origin_owner_scope, origin_envelope_hash, created_at)
1416
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
1417
+ .run(candidate.candidateId, input.decision, receiptOrigin.attemptId, candidate.taskId, candidate.eventId, candidate.connector, candidate.sourceType, candidate.externalSourceId, candidate.channelPartition, candidate.contentSha256, candidate.sourceTimestampMs, candidate.operatorIngestSeq, candidate.operatorObservationSeq, candidate.bindingId, candidate.bindingRevision, taskRevisionBefore, taskRevisionAfter, outcome, input.reason, receiptOrigin.runId, JSON.stringify(origin.causeEventIds ?? []), receiptOrigin.ownerScope, receiptOrigin.envelopeHash, this.now());
1111
1418
  }
1112
1419
  /**
1113
1420
  * Create a task. Idempotent under at-least-once delivery: a duplicate
@@ -1194,8 +1501,8 @@ class TaskLedger {
1194
1501
  }
1195
1502
  }
1196
1503
  let createdId = 0;
1197
- this.db.exec('BEGIN IMMEDIATE');
1198
- try {
1504
+ const previousGeneration = this.onOwnerTaskChangeCommitted ? this.readGeneration() : null;
1505
+ const write = this.db.transaction(() => {
1199
1506
  const result = this.db
1200
1507
  .prepare(`INSERT INTO operator_tasks
1201
1508
  (title, status, priority, assignee, deadline, due_at, deadline_offset_minutes,
@@ -1247,12 +1554,9 @@ class TaskLedger {
1247
1554
  },
1248
1555
  atMs: now,
1249
1556
  });
1250
- this.db.exec('COMMIT');
1251
- }
1252
- catch (error) {
1253
- this.db.exec('ROLLBACK');
1254
- throw error;
1255
- }
1557
+ }, 'immediate');
1558
+ write();
1559
+ this.notifyOwnerTaskCommit(previousGeneration);
1256
1560
  const created = this.getById(createdId);
1257
1561
  if (!created)
1258
1562
  throw new Error('task_create: inserted row could not be read back');
@@ -1294,16 +1598,10 @@ class TaskLedger {
1294
1598
  }
1295
1599
  update(id, patch, origin = {}) {
1296
1600
  this.validateTaskUpdatePatch(patch);
1297
- this.db.exec('BEGIN IMMEDIATE');
1298
- try {
1299
- const task = this.transitionTaskInTransaction(id, patch, origin);
1300
- this.db.exec('COMMIT');
1301
- return task;
1302
- }
1303
- catch (error) {
1304
- this.db.exec('ROLLBACK');
1305
- throw error;
1306
- }
1601
+ const previousGeneration = this.onOwnerTaskChangeCommitted ? this.readGeneration() : null;
1602
+ const updated = this.db.transaction(() => this.transitionTaskInTransaction(id, patch, origin), 'immediate')();
1603
+ this.notifyOwnerTaskCommit(previousGeneration);
1604
+ return updated;
1307
1605
  }
1308
1606
  /**
1309
1607
  * Recorrect an existing owner row with a NAMED disposition.
@@ -1314,9 +1612,12 @@ class TaskLedger {
1314
1612
  * owner rows and could only be "closed" as if they had been done.
1315
1613
  *
1316
1614
  * Every path requires the exact current revision, a non-empty bounded reason
1317
- * preserved as `latest_event`, an OWNER row, and the same candidate/workorder
1318
- * authority checks as task_update (TG-03/TG-04/TG-06) - a terminal or foreign
1319
- * board attempt cannot mutate an unrelated row. The whole transition, its
1615
+ * preserved as `latest_event`, an OWNER row, and the same workorder-attempt
1616
+ * currency and causal-provenance checks as task_update (TG-03/TG-04/TG-06) -
1617
+ * a terminal attempt of any kind cannot mutate a row, and an owner-event
1618
+ * decision cannot rest on a fabricated cause. Scheduler hint pages and the
1619
+ * causal batch's channel select what the host READS; they do not narrow the
1620
+ * authenticated owner's decisions. The whole transition, its
1320
1621
  * `resolution_kind` and its effect receipt commit as ONE transaction.
1321
1622
  */
1322
1623
  reclassify(id, input, origin = {}) {
@@ -1334,6 +1635,8 @@ class TaskLedger {
1334
1635
  if (!Number.isSafeInteger(input.expected_revision) || input.expected_revision < 0) {
1335
1636
  throw new Error('task_reclassify: expected_revision must be a non-negative integer');
1336
1637
  }
1638
+ const previousGeneration = this.onOwnerTaskChangeCommitted ? this.readGeneration() : null;
1639
+ let record;
1337
1640
  this.db.exec('BEGIN IMMEDIATE');
1338
1641
  try {
1339
1642
  const existing = this.db.prepare('SELECT * FROM operator_tasks WHERE id = ?').get(id);
@@ -1345,54 +1648,67 @@ class TaskLedger {
1345
1648
  if (input.expected_revision !== existing.revision) {
1346
1649
  throw new Error(`task_reclassify: expected revision ${input.expected_revision}, current ${existing.revision}`);
1347
1650
  }
1348
- this.assertTaskReclassificationAuthorized(existing, origin);
1651
+ this.assertTaskReclassificationAuthorized(origin);
1349
1652
  const patch = this.buildReclassifyPatch(existing, disposition, reason);
1350
1653
  // Same primitive as task_update: candidate guard, board-attempt liveness,
1351
1654
  // revision-checked UPDATE, effect receipt, temporal generation supersession.
1352
- const record = this.transitionTaskInTransaction(id, patch, origin);
1655
+ record = this.transitionTaskInTransaction(id, patch, origin);
1353
1656
  this.db.exec('COMMIT');
1354
- return record;
1355
1657
  }
1356
1658
  catch (error) {
1357
1659
  this.db.exec('ROLLBACK');
1358
1660
  throw error;
1359
1661
  }
1662
+ this.notifyOwnerTaskCommit(previousGeneration);
1663
+ return record;
1360
1664
  }
1361
- assertTaskReclassificationAuthorized(existing, origin) {
1665
+ assertTaskReclassificationAuthorized(origin) {
1362
1666
  if (origin.workOrderAttemptId !== undefined) {
1363
- const attempt = this.getWorkOrderById(origin.workOrderAttemptId);
1364
- if (!attempt || attempt.workKind !== 'board' || attempt.status !== 'in_progress') {
1365
- throw new Error(`task_reclassify: Board workorder ${String(origin.workOrderAttemptId)} is no longer active`);
1366
- }
1367
- const { attempts: _attempts, ...payload } = attempt.payload;
1368
- (0, workorder_publishers_js_1.validateWorkOrderPayload)('board', payload);
1369
- const candidates = Array.isArray(payload.reclassificationCandidates)
1370
- ? payload.reclassificationCandidates
1371
- : [];
1372
- const candidate = candidates.find((item) => item.taskId === existing.id);
1373
- if (!candidate || candidate.taskRevision !== existing.revision) {
1374
- throw new Error(`task_reclassify: task ${existing.id} is outside this host-issued Board candidate set`);
1375
- }
1667
+ // A carried attempt must be CURRENT. Its kind is scheduling metadata: a
1668
+ // wiki or temporal run that discovers a record-shaped owner row may
1669
+ // reclassify it under the same CAS rules as a Board run. Its candidate
1670
+ // page is a bounded-read hint, so a row outside the page is not refused.
1671
+ this.assertWorkOrderAttemptActive('task_reclassify', origin.workOrderAttemptId);
1376
1672
  }
1377
1673
  if (origin.reclassificationCauseBound) {
1378
1674
  const eventIds = (origin.causeEventIds ?? []).filter(effects_js_1.isUsableCause);
1379
1675
  if (eventIds.length === 0) {
1380
1676
  throw new Error('task_reclassify: owner-event reclassification requires causal events');
1381
1677
  }
1382
- if (existing.source_event_id && eventIds.includes(existing.source_event_id))
1383
- return;
1384
- const placeholders = eventIds.map(() => '?').join(',');
1385
- const channels = this.db
1386
- .prepare(`SELECT DISTINCT inbox.channel_key
1387
- FROM owner_event_inbox AS inbox, json_each(inbox.event_ids_json) AS event
1388
- WHERE event.value IN (${placeholders})`)
1389
- .all(...eventIds);
1390
- if (existing.source_channel === null ||
1391
- !channels.some((row) => row.channel_key === existing.source_channel)) {
1392
- throw new Error(`task_reclassify: task ${existing.id} is outside this owner-event source channel`);
1678
+ // Provenance: every cause must be an event a durable inbox batch actually
1679
+ // carried. The batch's channel is not compared with the task's source -
1680
+ // cross-source relevance is MAMA's judgment under the owner's grant.
1681
+ const unknown = this.findCausalEventsWithoutInboxBatch(eventIds);
1682
+ if (unknown.length > 0) {
1683
+ throw new Error(`task_reclassify: causal event(s) ${unknown.join(', ')} were not carried by any owner-event inbox batch`);
1393
1684
  }
1394
1685
  }
1395
1686
  }
1687
+ /**
1688
+ * A retained attempt context outlives its workorder: a late direct or nested
1689
+ * tool call can still carry it after the row reached a terminal status. Any
1690
+ * lifecycle judgment made under a carried attempt requires that attempt to be
1691
+ * the claimed, in-progress one, whatever its work kind.
1692
+ */
1693
+ assertWorkOrderAttemptActive(tool, attemptId) {
1694
+ const attempt = this.getWorkOrderById(attemptId);
1695
+ if (!attempt) {
1696
+ throw new Error(`${tool}: workorder ${String(attemptId)} is no longer active (missing)`);
1697
+ }
1698
+ if (attempt.status !== 'in_progress') {
1699
+ throw new Error(`${tool}: ${attempt.workKind} workorder ${String(attemptId)} is no longer active (${attempt.status})`);
1700
+ }
1701
+ return attempt;
1702
+ }
1703
+ findCausalEventsWithoutInboxBatch(eventIds) {
1704
+ const placeholders = eventIds.map(() => '?').join(',');
1705
+ const known = new Set(this.db
1706
+ .prepare(`SELECT DISTINCT event.value AS event_id
1707
+ FROM owner_event_inbox AS inbox, json_each(inbox.event_ids_json) AS event
1708
+ WHERE event.value IN (${placeholders})`)
1709
+ .all(...eventIds).map((row) => row.event_id));
1710
+ return eventIds.filter((eventId) => !known.has(eventId));
1711
+ }
1396
1712
  buildReclassifyPatch(existing, disposition, reason) {
1397
1713
  const base = {
1398
1714
  latest_event: reason,
@@ -1463,18 +1779,21 @@ class TaskLedger {
1463
1779
  if (existing.kind === 'system') {
1464
1780
  throw new Error(`task_update: task ${id} is a system workorder row (host-managed)`);
1465
1781
  }
1466
- const revisionAttempt = origin.requiresExpectedRevision
1467
- ? this.getWorkOrderById(origin.workOrderAttemptId ?? -1)
1468
- : null;
1469
1782
  const hasLifecyclePatch = Object.prototype.hasOwnProperty.call(patch, 'status') ||
1470
1783
  Object.prototype.hasOwnProperty.call(patch, 'due_at') ||
1471
1784
  Object.prototype.hasOwnProperty.call(patch, 'latest_event') ||
1472
1785
  Object.prototype.hasOwnProperty.call(patch, 'completion_criteria');
1473
- const isBoardWorkorderLifecycleJudgment = revisionAttempt?.workKind === 'board' && hasLifecyclePatch;
1474
- if (isBoardWorkorderLifecycleJudgment && revisionAttempt.status !== 'in_progress') {
1475
- throw new Error(`task_update: board workorder ${revisionAttempt.id} is no longer active (${revisionAttempt.status})`);
1476
- }
1477
- const isWorkorderLifecycleJudgment = isBoardWorkorderLifecycleJudgment && revisionAttempt.status === 'in_progress';
1786
+ // Direct-ABI mutation under a carried attempt: the attempt must be current
1787
+ // for EVERY patch shape (a stale attempt renaming a task is as stale as one
1788
+ // closing it). Work kind is scheduling metadata, not owner authority.
1789
+ // Internal receipted primitives (external lifecycle apply) do not set the
1790
+ // direct-ABI guard and validate their own claimed attempt separately.
1791
+ const isCarriedDirectMutation = origin.requiresExpectedRevision === true && origin.workOrderAttemptId !== undefined;
1792
+ if (isCarriedDirectMutation) {
1793
+ this.assertWorkOrderAttemptActive('task_update', origin.workOrderAttemptId);
1794
+ }
1795
+ // A lifecycle judgment additionally proves the revision actually read plus a reason.
1796
+ const isWorkorderLifecycleJudgment = isCarriedDirectMutation && hasLifecyclePatch;
1478
1797
  if (isWorkorderLifecycleJudgment) {
1479
1798
  if (!Number.isSafeInteger(patch.expected_revision) || patch.expected_revision < 0) {
1480
1799
  throw new Error('task_update: lifecycle workorder requires expected_revision');
@@ -1521,6 +1840,7 @@ class TaskLedger {
1521
1840
  last_temporal_attempt_id: existing.last_temporal_attempt_id,
1522
1841
  review_started_at: existing.review_started_at,
1523
1842
  review_anchor_event_id: existing.review_anchor_event_id,
1843
+ review_anchor_source_channel: existing.review_anchor_source_channel,
1524
1844
  completion_criteria: existing.completion_criteria,
1525
1845
  resolution_kind: existing.resolution_kind,
1526
1846
  };
@@ -1551,6 +1871,7 @@ class TaskLedger {
1551
1871
  if (leavingVerifiedReview) {
1552
1872
  next.review_started_at = null;
1553
1873
  next.review_anchor_event_id = null;
1874
+ next.review_anchor_source_channel = null;
1554
1875
  next.due_at = null;
1555
1876
  next.deadline = null;
1556
1877
  next.deadline_offset_minutes = null;
@@ -1569,14 +1890,15 @@ class TaskLedger {
1569
1890
  if (!evidence) {
1570
1891
  throw new Error('task_update: review requires host-verified submission evidence');
1571
1892
  }
1572
- if (existing.source_channel !== evidence.sourceChannel) {
1573
- throw new Error('task_update: review evidence is outside the task source boundary');
1893
+ if (!evidence.sourceChannel.trim()) {
1894
+ throw new Error('task_update: review evidence source is required');
1574
1895
  }
1575
1896
  if (!Number.isSafeInteger(evidence.sourceTimestampMs) || evidence.sourceTimestampMs < 0) {
1576
1897
  throw new Error('task_update: review evidence timestamp is invalid');
1577
1898
  }
1578
1899
  next.review_started_at = evidence.sourceTimestampMs;
1579
1900
  next.review_anchor_event_id = evidence.eventIndexId;
1901
+ next.review_anchor_source_channel = evidence.sourceChannel;
1580
1902
  next.due_at = evidence.sourceTimestampMs + 14 * 24 * 60 * 60 * 1000;
1581
1903
  next.deadline = new Date(next.due_at).toISOString().slice(0, 10);
1582
1904
  next.deadline_offset_minutes = 0;
@@ -1643,6 +1965,7 @@ class TaskLedger {
1643
1965
  'last_temporal_attempt_id',
1644
1966
  'review_started_at',
1645
1967
  'review_anchor_event_id',
1968
+ 'review_anchor_source_channel',
1646
1969
  'completion_criteria',
1647
1970
  'resolution_kind',
1648
1971
  ];
@@ -1700,23 +2023,18 @@ class TaskLedger {
1700
2023
  // candidate payload must remain authoritative in that case. Decision
1701
2024
  // application still calls loadBoardCandidate(), which requires a claimed
1702
2025
  // in-progress attempt.
2026
+ //
2027
+ // The payload's reclassificationCandidates page is NOT consulted here: it is
2028
+ // the scheduler's bounded-read hint, and qualifying a legacy row the owner
2029
+ // discovered outside that page is an ordinary owner decision guarded by
2030
+ // attempt currency, expected_revision and a plain reason (see
2031
+ // transitionTaskInTransaction). Only host-attested external binding /
2032
+ // lifecycle candidates keep their receipted-decision-only rule below.
1703
2033
  if (!attempt || attempt.workKind !== 'board') {
1704
2034
  return;
1705
2035
  }
1706
2036
  const { attempts: _attempts, ...payload } = attempt.payload;
1707
2037
  (0, workorder_publishers_js_1.validateWorkOrderPayload)('board', payload);
1708
- if (Object.prototype.hasOwnProperty.call(patch, 'completion_criteria') &&
1709
- !origin.taskCreateUpsert) {
1710
- const reclassificationCandidates = Array.isArray(payload.reclassificationCandidates)
1711
- ? payload.reclassificationCandidates
1712
- : [];
1713
- const qualificationCandidate = reclassificationCandidates.find((candidate) => candidate.taskId === taskId);
1714
- if (!qualificationCandidate ||
1715
- (patch.expected_revision !== undefined &&
1716
- qualificationCandidate.taskRevision !== patch.expected_revision)) {
1717
- throw new Error(`task_update: task ${taskId} is outside this host-issued qualification candidate set`);
1718
- }
1719
- }
1720
2038
  if (payload.mode !== 'reconcile' || !payload.candidates) {
1721
2039
  return;
1722
2040
  }
@@ -1966,7 +2284,9 @@ class TaskLedger {
1966
2284
  currentOccurrence !== input.occurrenceKey) {
1967
2285
  throw new Error(`temporal generation: task occurrence no longer matches enqueue input`);
1968
2286
  }
1969
- if (this.temporalSourceIdentifierRef(task.sourceChannel) !== sourceChannelRef ||
2287
+ if (this.temporalSourceIdentifierRef(task.status === 'review'
2288
+ ? (task.reviewAnchorSourceChannel ?? task.sourceChannel)
2289
+ : task.sourceChannel) !== sourceChannelRef ||
1970
2290
  this.temporalSourceIdentifierRef(task.status === 'review' && task.reviewAnchorEventId
1971
2291
  ? task.reviewAnchorEventId
1972
2292
  : task.sourceEventId) !== sourceEventIdRef) {
@@ -2328,6 +2648,7 @@ class TaskLedger {
2328
2648
  throw new Error('temporal effect requires a valid host evidence attestation');
2329
2649
  }
2330
2650
  let receipt = null;
2651
+ const previousGeneration = this.onOwnerTaskChangeCommitted ? this.readGeneration() : null;
2331
2652
  this.db.exec('BEGIN IMMEDIATE');
2332
2653
  try {
2333
2654
  const context = this.assertTemporalWorkContextActive(suppliedContext);
@@ -2352,6 +2673,7 @@ class TaskLedger {
2352
2673
  last_temporal_attempt_id: context.attemptId,
2353
2674
  review_started_at: existing.review_started_at,
2354
2675
  review_anchor_event_id: existing.review_anchor_event_id,
2676
+ review_anchor_source_channel: existing.review_anchor_source_channel,
2355
2677
  };
2356
2678
  const auditParts = [`reason=${input.reason.trim()}`];
2357
2679
  if (input.outcome === 'final_no_update') {
@@ -2369,6 +2691,7 @@ class TaskLedger {
2369
2691
  // as the task_update path clears them. A later reopen re-verifies.
2370
2692
  next.review_started_at = null;
2371
2693
  next.review_anchor_event_id = null;
2694
+ next.review_anchor_source_channel = null;
2372
2695
  if (existing.review_started_at !== null &&
2373
2696
  !Object.prototype.hasOwnProperty.call(input, 'due_at')) {
2374
2697
  next.due_at = null;
@@ -2417,6 +2740,7 @@ class TaskLedger {
2417
2740
  'last_temporal_attempt_id',
2418
2741
  'review_started_at',
2419
2742
  'review_anchor_event_id',
2743
+ 'review_anchor_source_channel',
2420
2744
  ];
2421
2745
  const changedFields = persistedColumns.filter((column) => next[column] !== existing[column]);
2422
2746
  const afterRevision = existing.revision + 1;
@@ -2503,6 +2827,7 @@ class TaskLedger {
2503
2827
  this.db.exec('ROLLBACK');
2504
2828
  throw error;
2505
2829
  }
2830
+ this.notifyOwnerTaskCommit(previousGeneration);
2506
2831
  if (!receipt)
2507
2832
  throw new Error(`temporal effect produced no receipt`);
2508
2833
  return receipt;
@@ -2912,7 +3237,9 @@ class TaskLedger {
2912
3237
  }
2913
3238
  if (task.temporalEpoch !== temporalEpoch ||
2914
3239
  (0, task_temporal_js_1.occurrenceKeyForTask)(task) !== occurrenceKey ||
2915
- this.temporalSourceIdentifierRef(task.sourceChannel) !== sourceChannel ||
3240
+ this.temporalSourceIdentifierRef(task.status === 'review'
3241
+ ? (task.reviewAnchorSourceChannel ?? task.sourceChannel)
3242
+ : task.sourceChannel) !== sourceChannel ||
2916
3243
  this.temporalSourceIdentifierRef(task.status === 'review' && task.reviewAnchorEventId
2917
3244
  ? task.reviewAnchorEventId
2918
3245
  : task.sourceEventId) !== sourceEventId ||