@hasna/todos 0.15.28 → 0.15.32

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 (72) hide show
  1. package/dist/cli/cloud-router.d.ts +15 -0
  2. package/dist/cli/cloud-router.d.ts.map +1 -1
  3. package/dist/cli/commands/project-registration-commands.d.ts +5 -0
  4. package/dist/cli/commands/project-registration-commands.d.ts.map +1 -0
  5. package/dist/cli/commands/task-commands.d.ts.map +1 -1
  6. package/dist/cli/commands/task-manifest-commands.d.ts.map +1 -1
  7. package/dist/cli/index.js +13040 -10368
  8. package/dist/contracts.js +64 -2
  9. package/dist/db/task-crud.d.ts.map +1 -1
  10. package/dist/index.d.ts +1 -1
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +1489 -149
  13. package/dist/lib/task-parent-integrity.d.ts +14 -0
  14. package/dist/lib/task-parent-integrity.d.ts.map +1 -0
  15. package/dist/mcp/index.js +2073 -140
  16. package/dist/mcp/tools/task-crud.d.ts.map +1 -1
  17. package/dist/mcp.js +1 -1
  18. package/dist/project-registration/adoption-validation.d.ts +3 -0
  19. package/dist/project-registration/adoption-validation.d.ts.map +1 -0
  20. package/dist/project-registration/authority.d.ts +3 -1
  21. package/dist/project-registration/authority.d.ts.map +1 -1
  22. package/dist/project-registration/backend.d.ts +26 -1
  23. package/dist/project-registration/backend.d.ts.map +1 -1
  24. package/dist/project-registration/http.d.ts +3 -1
  25. package/dist/project-registration/http.d.ts.map +1 -1
  26. package/dist/project-registration/index.d.ts +2 -1
  27. package/dist/project-registration/index.d.ts.map +1 -1
  28. package/dist/project-registration/page-validation.d.ts +5 -0
  29. package/dist/project-registration/page-validation.d.ts.map +1 -0
  30. package/dist/project-registration/postgres.d.ts +13 -1
  31. package/dist/project-registration/postgres.d.ts.map +1 -1
  32. package/dist/project-registration/schema.d.ts.map +1 -1
  33. package/dist/project-registration/sqlite.d.ts +13 -1
  34. package/dist/project-registration/sqlite.d.ts.map +1 -1
  35. package/dist/project-registration/types.d.ts +68 -1
  36. package/dist/project-registration/types.d.ts.map +1 -1
  37. package/dist/project-registration.js +873 -63
  38. package/dist/registry.js +64 -2
  39. package/dist/release-provenance.json +5 -5
  40. package/dist/sdk/index.d.ts +1 -1
  41. package/dist/sdk/index.d.ts.map +1 -1
  42. package/dist/sdk/index.js +151 -0
  43. package/dist/sdk/v1.generated.d.ts +282 -0
  44. package/dist/sdk/v1.generated.d.ts.map +1 -1
  45. package/dist/server/index.js +2064 -131
  46. package/dist/server/openapi.d.ts +2856 -1357
  47. package/dist/server/openapi.d.ts.map +1 -1
  48. package/dist/server/v1.d.ts.map +1 -1
  49. package/dist/storage/postgres-adapter.d.ts.map +1 -1
  50. package/dist/storage/postgres-sync.d.ts +8 -0
  51. package/dist/storage/postgres-sync.d.ts.map +1 -1
  52. package/dist/storage.js +244 -19
  53. package/dist/task-manifest/authority.d.ts +13 -1
  54. package/dist/task-manifest/authority.d.ts.map +1 -1
  55. package/dist/task-manifest/backend.d.ts +5 -0
  56. package/dist/task-manifest/backend.d.ts.map +1 -1
  57. package/dist/task-manifest/index.d.ts +2 -2
  58. package/dist/task-manifest/index.d.ts.map +1 -1
  59. package/dist/task-manifest/plan-slug.d.ts +20 -0
  60. package/dist/task-manifest/plan-slug.d.ts.map +1 -1
  61. package/dist/task-manifest/postgres.d.ts +1 -0
  62. package/dist/task-manifest/postgres.d.ts.map +1 -1
  63. package/dist/task-manifest/schema-sql.d.ts.map +1 -1
  64. package/dist/task-manifest/schema.d.ts.map +1 -1
  65. package/dist/task-manifest/sqlite.d.ts +1 -0
  66. package/dist/task-manifest/sqlite.d.ts.map +1 -1
  67. package/dist/task-manifest/types.d.ts +21 -1
  68. package/dist/task-manifest/types.d.ts.map +1 -1
  69. package/dist/task-manifest.js +592 -61
  70. package/dist/types/index.d.ts +4 -2
  71. package/dist/types/index.d.ts.map +1 -1
  72. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -77,6 +77,12 @@ function sqliteTodosProjectRegistrationSchemaSql() {
77
77
  authority_id, tenant_id, corpus_id, operation_id, step_id,
78
78
  resource_kind, direction, idempotency_key
79
79
  );
80
+ CREATE INDEX IF NOT EXISTS idx_todos_project_registration_receipts_source_identity
81
+ ON todos_project_registration_receipts (
82
+ authority_id, tenant_id, corpus_id, route, package_version,
83
+ operation_id, step_id, resource_kind, direction, idempotency_key,
84
+ target_selector
85
+ );
80
86
  CREATE INDEX IF NOT EXISTS idx_todos_project_registration_receipts_step
81
87
  ON todos_project_registration_receipts (
82
88
  authority_id, tenant_id, corpus_id, operation_id, step_id,
@@ -176,6 +182,12 @@ function postgresTodosProjectRegistrationSchemaSql() {
176
182
  authority_id, tenant_id, corpus_id, operation_id, step_id,
177
183
  resource_kind, direction, idempotency_key
178
184
  )`,
185
+ `CREATE INDEX IF NOT EXISTS todos_project_registration_receipts_source_identity_idx
186
+ ON todos_project_registration_receipts (
187
+ authority_id, tenant_id, corpus_id, route, package_version,
188
+ operation_id, step_id, resource_kind, direction, idempotency_key,
189
+ target_selector
190
+ )`,
179
191
  `CREATE INDEX IF NOT EXISTS todos_project_registration_receipts_step_idx
180
192
  ON todos_project_registration_receipts (
181
193
  authority_id, tenant_id, corpus_id, operation_id, step_id,
@@ -7905,6 +7917,46 @@ function guardPlanRowsSqlite(planIds, db) {
7905
7917
  }
7906
7918
  }
7907
7919
 
7920
+ // src/lib/task-parent-integrity.ts
7921
+ function parentCycleError(taskId, parentId) {
7922
+ return new ResourceConflictError("TASK_PARENT_CYCLE", `TASK_PARENT_CYCLE: assigning parent ${parentId} to task ${taskId} would create or retain a parent cycle`);
7923
+ }
7924
+ function assertTaskParentIntegrity(taskId, parentId, getTask) {
7925
+ if (parentId === undefined || parentId === null)
7926
+ return;
7927
+ const visited = new Set;
7928
+ let cursor = parentId;
7929
+ while (cursor) {
7930
+ if (cursor === taskId || visited.has(cursor)) {
7931
+ throw parentCycleError(taskId, parentId);
7932
+ }
7933
+ visited.add(cursor);
7934
+ const parent = getTask(cursor);
7935
+ if (!parent)
7936
+ throw new TaskNotFoundError(cursor);
7937
+ cursor = parent.parent_id;
7938
+ }
7939
+ }
7940
+ async function assertTaskParentIntegrityAsync(taskId, parentId, getTask) {
7941
+ if (parentId === undefined || parentId === null)
7942
+ return;
7943
+ const visited = new Set;
7944
+ let cursor = parentId;
7945
+ while (cursor) {
7946
+ if (cursor === taskId || visited.has(cursor)) {
7947
+ throw parentCycleError(taskId, parentId);
7948
+ }
7949
+ visited.add(cursor);
7950
+ const parent = await getTask(cursor);
7951
+ if (!parent)
7952
+ throw new TaskNotFoundError(cursor);
7953
+ cursor = parent.parent_id;
7954
+ }
7955
+ }
7956
+ var init_task_parent_integrity = __esm(() => {
7957
+ init_types();
7958
+ });
7959
+
7908
7960
  // src/lib/creator-identity.ts
7909
7961
  function canonicalAgentRef(value) {
7910
7962
  return value.trim().toLowerCase();
@@ -9428,6 +9480,7 @@ function createTaskStored(input, d) {
9428
9480
  let id = uuid();
9429
9481
  for (let attempt = 0;attempt < 3; attempt++) {
9430
9482
  try {
9483
+ assertTaskParentIntegrity(id, input.parent_id, (candidateId) => getTask(candidateId, d));
9431
9484
  d.run(`INSERT INTO tasks (id, short_id, project_id, parent_id, plan_id, task_list_id, cycle_id, title, description, status, priority, agent_id, assigned_to, session_id, working_dir, tags, metadata, version, created_at, updated_at, due_at, estimated_minutes, sla_minutes, confidence, retry_count, max_retries, retry_after, requires_approval, approved_by, approved_at, recurrence_rule, recurrence_parent_id, spawns_template_id, reason, spawned_from_session, assigned_by, created_by, assigned_from_project, task_type, machine_id)
9432
9485
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
9433
9486
  id,
@@ -9806,6 +9859,7 @@ function updateTaskStored(id, input, db) {
9806
9859
  throw new VersionConflictError(id, input.version, task.version);
9807
9860
  }
9808
9861
  input = sanitizeUpdateTaskInput(input);
9862
+ assertTaskParentIntegrity(id, input.parent_id, (candidateId) => getTask(candidateId, d));
9809
9863
  const effectivePlanId = input.plan_id !== undefined ? input.plan_id : task.plan_id;
9810
9864
  const linkedProjectId = linkedPlanProjectId(effectivePlanId, d);
9811
9865
  if (linkedProjectId) {
@@ -9859,6 +9913,10 @@ function updateTaskStored(id, input, db) {
9859
9913
  sets.push("project_id = ?");
9860
9914
  params.push(input.project_id);
9861
9915
  }
9916
+ if (input.parent_id !== undefined) {
9917
+ sets.push("parent_id = ?");
9918
+ params.push(input.parent_id);
9919
+ }
9862
9920
  if (input.assigned_to !== undefined) {
9863
9921
  sets.push("assigned_to = ?");
9864
9922
  params.push(input.assigned_to);
@@ -9974,6 +10032,8 @@ function updateTaskStored(id, input, db) {
9974
10032
  logTaskChange(id, "update", "priority", task.priority, input.priority, agentId, d);
9975
10033
  if (input.title !== undefined && input.title !== task.title)
9976
10034
  logTaskChange(id, "update", "title", task.title, input.title, agentId, d);
10035
+ if (input.parent_id !== undefined && input.parent_id !== task.parent_id)
10036
+ logTaskChange(id, "update", "parent_id", task.parent_id, input.parent_id, agentId, d);
9977
10037
  if (input.assigned_to !== undefined && input.assigned_to !== task.assigned_to)
9978
10038
  logTaskChange(id, "update", "assigned_to", task.assigned_to, input.assigned_to, agentId, d);
9979
10039
  if (input.working_dir !== undefined && input.working_dir !== task.working_dir)
@@ -10031,7 +10091,8 @@ function updateTask(id, input, db) {
10031
10091
  if (!before)
10032
10092
  throw new TaskNotFoundError(id);
10033
10093
  const guardedPlanIds = [before.plan_id, input.plan_id];
10034
- if (!guardedPlanIds.some(Boolean))
10094
+ const needsSerializedWrite = input.parent_id !== undefined || guardedPlanIds.some(Boolean);
10095
+ if (!needsSerializedWrite)
10035
10096
  return updateTaskStored(id, input, d);
10036
10097
  return d.transaction(() => {
10037
10098
  guardPlanRowsSqlite(guardedPlanIds, d);
@@ -10067,6 +10128,7 @@ var init_task_crud = __esm(() => {
10067
10128
  init_checklists();
10068
10129
  init_storage_tombstones();
10069
10130
  init_prewrite_secrets();
10131
+ init_task_parent_integrity();
10070
10132
  });
10071
10133
 
10072
10134
  // src/db/task-status.ts
@@ -12771,7 +12833,7 @@ var init_dispatches = __esm(() => {
12771
12833
  // package.json
12772
12834
  var package_default = {
12773
12835
  name: "@hasna/todos",
12774
- version: "0.15.28",
12836
+ version: "0.15.32",
12775
12837
  description: "Universal task management for AI coding agents - CLI + MCP server + interactive TUI",
12776
12838
  type: "module",
12777
12839
  main: "dist/index.js",
@@ -27493,6 +27555,7 @@ function assertSafeIdentifier(value) {
27493
27555
 
27494
27556
  // src/storage/postgres-adapter.ts
27495
27557
  init_redaction();
27558
+ init_task_parent_integrity();
27496
27559
 
27497
27560
  // src/task-manifest/canonical.ts
27498
27561
  import { createHash as createHash11 } from "crypto";
@@ -27536,8 +27599,8 @@ function createPostgresTodosStorageAdapter(options) {
27536
27599
  resolveRef: (ref) => store.resolveTaskRef(ref),
27537
27600
  list: (filter = {}) => store.listTasks(filter),
27538
27601
  count: (filter = {}) => store.countTasks(filter),
27539
- update: (id, input) => updateTask2(id, input, store),
27540
- delete: (id, context) => store.delete("tasks", id, context),
27602
+ update: (id, input, context) => updateTask2(id, input, store, context),
27603
+ delete: (id, context) => store.deleteTaskHierarchy(id, context),
27541
27604
  start: (id, agentId) => startTask2(id, agentId, store),
27542
27605
  complete: (id, agentId, options2) => completeTask2(id, agentId, options2, store),
27543
27606
  fail: (id, agentId, reason, options2) => failTask2(id, agentId, reason, options2, store),
@@ -28070,22 +28133,70 @@ class PostgresJsonRecordStore {
28070
28133
  return "identical";
28071
28134
  throw new Error(divergentAuditHistoryReplayError(value.id));
28072
28135
  }
28073
- async upsertTaskWithPlanMembershipGuard(value, guardedPlanIds, explicitProject, context = {}) {
28136
+ async withTaskParentIntegrityTransaction(fn) {
28137
+ if (typeof this.options.client.transaction !== "function") {
28138
+ throw new Error("TASK_PARENT_ATOMICITY_UNAVAILABLE: PostgreSQL parent writes and task deletion require transaction(callback)");
28139
+ }
28140
+ return this.options.client.transaction(async (client) => {
28141
+ await client.query("/* todos:task-parent-integrity-lock */ SELECT pg_advisory_xact_lock(hashtextextended($1 || ':task-parent-integrity', 0))", [this.service]);
28142
+ return fn(client);
28143
+ });
28144
+ }
28145
+ async upsertTaskWithPlanMembershipGuard(value, guardedPlanIds, explicitProject, context = {}, parentGuard, queryClient) {
28074
28146
  const planIds = [...new Set(guardedPlanIds.filter(Boolean))].sort();
28075
- if (planIds.length === 0)
28147
+ if (planIds.length === 0 && !parentGuard)
28076
28148
  return this.upsert("tasks", value, context);
28077
28149
  await this.ensureSchema();
28150
+ if (parentGuard && !queryClient) {
28151
+ return this.withTaskParentIntegrityTransaction((client2) => this.upsertTaskWithPlanMembershipGuard(value, guardedPlanIds, explicitProject, context, parentGuard, client2));
28152
+ }
28153
+ const client = queryClient ?? this.options.client;
28078
28154
  const updatedAt = value.updated_at;
28079
28155
  const targetPlanId = value.plan_id;
28080
- const result = await this.options.client.query(`/* todos:task-plan-membership-guard */ WITH
28156
+ const result = await client.query(`/* todos:task-plan-membership-guard todos:task-parent-integrity-guard */ WITH RECURSIVE
28157
+ locked_task AS MATERIALIZED (
28158
+ SELECT payload FROM ${this.tableName}
28159
+ WHERE service = $1 AND object_type = 'tasks' AND object_id = $2 AND deleted_at IS NULL
28160
+ FOR UPDATE
28161
+ ),
28081
28162
  locked_plans AS MATERIALIZED (
28082
28163
  SELECT object_id, payload FROM ${this.tableName}
28083
28164
  WHERE service = $1 AND object_type = 'plans' AND deleted_at IS NULL
28084
28165
  AND object_id IN (SELECT value FROM jsonb_array_elements_text($7::jsonb))
28085
28166
  ORDER BY object_id
28086
28167
  FOR UPDATE
28168
+ ), parent_chain(object_id, payload, path, cycle) AS (
28169
+ SELECT parent.object_id, parent.payload, ARRAY[parent.object_id], false
28170
+ FROM ${this.tableName} AS parent
28171
+ WHERE $10::boolean
28172
+ AND $11::text IS NOT NULL
28173
+ AND parent.service = $1
28174
+ AND parent.object_type = 'tasks'
28175
+ AND parent.object_id = $11
28176
+ AND parent.deleted_at IS NULL
28177
+ UNION ALL
28178
+ SELECT ancestor.object_id,
28179
+ ancestor.payload,
28180
+ chain.path || ancestor.object_id,
28181
+ ancestor.object_id = ANY(chain.path)
28182
+ FROM parent_chain AS chain
28183
+ JOIN ${this.tableName} AS ancestor
28184
+ ON ancestor.service = $1
28185
+ AND ancestor.object_type = 'tasks'
28186
+ AND ancestor.object_id = chain.payload->>'parent_id'
28187
+ AND ancestor.deleted_at IS NULL
28188
+ WHERE NOT chain.cycle
28087
28189
  ), validation AS (
28088
28190
  SELECT
28191
+ (NOT $10::boolean OR NOT $13::boolean OR EXISTS (SELECT 1 FROM locked_task)) AS task_found,
28192
+ (NOT $10::boolean OR NOT $13::boolean
28193
+ OR (SELECT (payload->>'version')::integer FROM locked_task) = $12::integer) AS version_matches,
28194
+ (NOT $10::boolean OR $11::text IS NULL
28195
+ OR EXISTS (SELECT 1 FROM parent_chain WHERE object_id = $11)) AS parent_found,
28196
+ (NOT $10::boolean OR $11::text IS NULL
28197
+ OR ($11::text <> $2
28198
+ AND NOT EXISTS (SELECT 1 FROM parent_chain WHERE object_id = $2)
28199
+ AND NOT EXISTS (SELECT 1 FROM parent_chain WHERE cycle))) AS parent_acyclic,
28089
28200
  (SELECT count(*) FROM locked_plans) = jsonb_array_length($7::jsonb) AS all_plans_found,
28090
28201
  ($8::text IS NULL OR EXISTS (SELECT 1 FROM locked_plans WHERE object_id = $8)) AS target_plan_found,
28091
28202
  (SELECT payload->>'project_id' FROM locked_plans WHERE object_id = $8) AS target_project_id
@@ -28106,7 +28217,13 @@ class PostgresJsonRecordStore {
28106
28217
  )
28107
28218
  SELECT $1, 'tasks', $2, guarded.payload, $4::timestamptz, NULL, $5, $6
28108
28219
  FROM guarded
28109
- WHERE guarded.all_plans_found AND guarded.target_plan_found AND NOT guarded.project_conflict
28220
+ WHERE guarded.task_found
28221
+ AND guarded.version_matches
28222
+ AND guarded.parent_found
28223
+ AND guarded.parent_acyclic
28224
+ AND guarded.all_plans_found
28225
+ AND guarded.target_plan_found
28226
+ AND NOT guarded.project_conflict
28110
28227
  ON CONFLICT (service, object_type, object_id) DO UPDATE SET
28111
28228
  payload = EXCLUDED.payload,
28112
28229
  updated_at = EXCLUDED.updated_at,
@@ -28119,8 +28236,10 @@ class PostgresJsonRecordStore {
28119
28236
  AND COALESCE(${this.tableName}.version, 0) <= COALESCE(EXCLUDED.version, 0))
28120
28237
  RETURNING payload
28121
28238
  )
28122
- SELECT guarded.all_plans_found, guarded.target_plan_found, guarded.project_conflict,
28123
- (SELECT payload FROM stored) AS payload
28239
+ SELECT guarded.task_found, guarded.version_matches, guarded.parent_found, guarded.parent_acyclic,
28240
+ guarded.all_plans_found, guarded.target_plan_found, guarded.project_conflict,
28241
+ (SELECT payload FROM stored) AS payload,
28242
+ (SELECT payload FROM locked_task) AS current_payload
28124
28243
  FROM guarded`, [
28125
28244
  this.service,
28126
28245
  value.id,
@@ -28130,9 +28249,26 @@ class PostgresJsonRecordStore {
28130
28249
  numberValue3(value.version),
28131
28250
  jsonbParam(planIds),
28132
28251
  targetPlanId,
28133
- explicitProject
28252
+ explicitProject,
28253
+ Boolean(parentGuard),
28254
+ parentGuard?.parentId ?? null,
28255
+ parentGuard?.expectedVersion ?? null,
28256
+ parentGuard?.operation === "update"
28134
28257
  ]);
28135
28258
  const row = result.rows[0];
28259
+ if (parentGuard && !row?.task_found) {
28260
+ throw new TaskNotFoundError(value.id);
28261
+ }
28262
+ if (parentGuard && !row?.version_matches) {
28263
+ const current = row?.current_payload ? payloadRecord2(row.current_payload) : await this.get("tasks", value.id);
28264
+ throw new VersionConflictError(value.id, parentGuard.expectedVersion, current?.version ?? -1);
28265
+ }
28266
+ if (parentGuard && !row?.parent_found && parentGuard.parentId) {
28267
+ throw new TaskNotFoundError(parentGuard.parentId);
28268
+ }
28269
+ if (parentGuard && !row?.parent_acyclic && parentGuard.parentId) {
28270
+ throw new ResourceConflictError("TASK_PARENT_CYCLE", `TASK_PARENT_CYCLE: assigning parent ${parentGuard.parentId} to task ${value.id} would create or retain a parent cycle`);
28271
+ }
28136
28272
  if (!row?.all_plans_found || !row.target_plan_found) {
28137
28273
  throw new PlanProjectLinkError("PLAN_PROJECT_LINK_PLAN_NOT_FOUND", `Plan membership changed through a missing plan: ${targetPlanId ?? planIds.join(", ")}`, { plan_ids: planIds, target_plan_id: targetPlanId });
28138
28274
  }
@@ -28140,6 +28276,8 @@ class PostgresJsonRecordStore {
28140
28276
  throw new ResourceConflictError("PLAN_PROJECT_LINK_CONFLICT", `Task project conflicts with linked plan ${targetPlanId}`);
28141
28277
  }
28142
28278
  if (!row.payload) {
28279
+ if (row.current_payload)
28280
+ return payloadRecord2(row.current_payload);
28143
28281
  return await requireRecord("tasks", value.id, this);
28144
28282
  }
28145
28283
  return payloadRecord2(row.payload);
@@ -28465,6 +28603,80 @@ class PostgresJsonRecordStore {
28465
28603
  version: numberValue3(existing["version"])
28466
28604
  }, context);
28467
28605
  }
28606
+ async deleteTaskHierarchy(id, context = {}) {
28607
+ await this.ensureSchema();
28608
+ return this.withTaskParentIntegrityTransaction(async (client) => {
28609
+ const timestamp3 = new Date().toISOString();
28610
+ const result = await client.query(`/* todos:task-parent-integrity-delete */ WITH RECURSIVE
28611
+ task_tree(object_id, path, cycle) AS (
28612
+ SELECT task.object_id, ARRAY[task.object_id], false
28613
+ FROM ${this.tableName} AS task
28614
+ WHERE task.service = $1
28615
+ AND task.object_type = 'tasks'
28616
+ AND task.object_id = $2
28617
+ AND task.deleted_at IS NULL
28618
+ UNION ALL
28619
+ SELECT child.object_id,
28620
+ tree.path || child.object_id,
28621
+ child.object_id = ANY(tree.path)
28622
+ FROM task_tree AS tree
28623
+ JOIN ${this.tableName} AS child
28624
+ ON child.service = $1
28625
+ AND child.object_type = 'tasks'
28626
+ AND child.payload->>'parent_id' = tree.object_id
28627
+ AND child.deleted_at IS NULL
28628
+ WHERE NOT tree.cycle
28629
+ ), tombstoned AS (
28630
+ UPDATE ${this.tableName} AS task
28631
+ SET deleted_at = $3::timestamptz,
28632
+ updated_at = $3::timestamptz,
28633
+ source_machine_id = $4
28634
+ WHERE task.service = $1
28635
+ AND task.object_type = 'tasks'
28636
+ AND task.deleted_at IS NULL
28637
+ AND task.object_id IN (
28638
+ SELECT object_id FROM task_tree WHERE NOT cycle
28639
+ )
28640
+ RETURNING task.object_id
28641
+ ), tombstoned_related AS (
28642
+ UPDATE ${this.tableName} AS related
28643
+ SET deleted_at = $3::timestamptz,
28644
+ updated_at = $3::timestamptz,
28645
+ source_machine_id = $4
28646
+ WHERE related.service = $1
28647
+ AND related.deleted_at IS NULL
28648
+ AND (
28649
+ (
28650
+ related.object_type = 'dependencies'
28651
+ AND (
28652
+ related.payload->>'task_id' IN (
28653
+ SELECT object_id FROM task_tree WHERE NOT cycle
28654
+ )
28655
+ OR related.payload->>'depends_on' IN (
28656
+ SELECT object_id FROM task_tree WHERE NOT cycle
28657
+ )
28658
+ )
28659
+ )
28660
+ OR (
28661
+ related.object_type IN ('comments', 'verifications', 'commits', 'refs')
28662
+ AND related.payload->>'task_id' IN (
28663
+ SELECT object_id FROM task_tree WHERE NOT cycle
28664
+ )
28665
+ )
28666
+ )
28667
+ RETURNING related.object_id
28668
+ )
28669
+ SELECT EXISTS (SELECT 1 FROM task_tree WHERE object_id = $2) AS found,
28670
+ (SELECT count(*) FROM tombstoned) AS deleted_count,
28671
+ (SELECT count(*) FROM tombstoned_related) AS related_deleted_count`, [
28672
+ this.service,
28673
+ id,
28674
+ timestamp3,
28675
+ context.requestId ?? this.sourceMachineId ?? null
28676
+ ]);
28677
+ return Boolean(result.rows[0]?.found);
28678
+ });
28679
+ }
28468
28680
  async getPlanProjectLinkReceipt(receiptId) {
28469
28681
  const value = await this.get("plan_project_link_receipts", receiptId);
28470
28682
  return value ? assertPlanProjectLinkReceipt(value) : null;
@@ -28885,9 +29097,8 @@ class PostgresJsonRecordStore {
28885
29097
  }
28886
29098
  async function createTask2(input, store, context) {
28887
29099
  const timestamp3 = new Date().toISOString();
28888
- if (input.parent_id && !await store.get("tasks", input.parent_id)) {
28889
- throw new TaskNotFoundError(input.parent_id);
28890
- }
29100
+ const taskId = randomUUID3();
29101
+ await assertTaskParentIntegrityAsync(taskId, input.parent_id, (id) => store.get("tasks", id));
28891
29102
  const linkedPlan = input.plan_id ? await store.get("plans", input.plan_id) : null;
28892
29103
  const requestedProjectId = input.project_id ?? context?.projectId ?? null;
28893
29104
  if (linkedPlan?.project_id && requestedProjectId && requestedProjectId !== linkedPlan.project_id) {
@@ -28896,7 +29107,7 @@ async function createTask2(input, store, context) {
28896
29107
  const effectiveProjectId = linkedPlan?.project_id ?? requestedProjectId;
28897
29108
  const shortId = effectiveProjectId ? await nextTaskShortId2(effectiveProjectId, store, context) : null;
28898
29109
  const task2 = {
28899
- id: randomUUID3(),
29110
+ id: taskId,
28900
29111
  short_id: shortId,
28901
29112
  project_id: effectiveProjectId,
28902
29113
  parent_id: input.parent_id ?? null,
@@ -28952,15 +29163,16 @@ async function createTask2(input, store, context) {
28952
29163
  synced_at: null,
28953
29164
  archived_at: null
28954
29165
  };
28955
- const storedTask = await store.upsertTaskWithPlanMembershipGuard(task2, task2.plan_id ? [task2.plan_id] : [], input.project_id !== undefined || context?.projectId !== undefined, context);
29166
+ const storedTask = await store.upsertTaskWithPlanMembershipGuard(task2, task2.plan_id ? [task2.plan_id] : [], input.project_id !== undefined || context?.projectId !== undefined, context, input.parent_id ? { operation: "create", expectedVersion: 0, parentId: input.parent_id } : undefined);
28956
29167
  await logTaskChange2(storedTask.id, "created", "status", null, storedTask.status, storedTask.assigned_by ?? storedTask.agent_id, store, context);
28957
29168
  return storedTask;
28958
29169
  }
28959
- async function updateTask2(id, input, store) {
29170
+ async function updateTask2(id, input, store, context) {
28960
29171
  const existing = await requireRecord("tasks", id, store);
28961
29172
  if (existing.version !== input.version) {
28962
- throw new Error(`Task ${id} version conflict: expected ${existing.version}, got ${input.version}`);
29173
+ throw new VersionConflictError(id, input.version, existing.version);
28963
29174
  }
29175
+ await assertTaskParentIntegrityAsync(id, input.parent_id, (candidateId) => store.get("tasks", candidateId));
28964
29176
  const effectivePlanId = input.plan_id !== undefined ? input.plan_id : existing.plan_id;
28965
29177
  const linkedPlan = effectivePlanId ? await store.get("plans", effectivePlanId) : null;
28966
29178
  if (linkedPlan?.project_id) {
@@ -28985,10 +29197,19 @@ async function updateTask2(id, input, store) {
28985
29197
  metadata: input.metadata ?? existing.metadata,
28986
29198
  requires_approval: input.requires_approval ?? existing.requires_approval,
28987
29199
  task_list_id: input.task_list_id !== undefined ? input.task_list_id : existing.task_list_id,
29200
+ parent_id: input.parent_id !== undefined ? input.parent_id : existing.parent_id,
28988
29201
  created_by: existing.created_by,
28989
29202
  completed_at: reopened ? null : input.completed_at !== undefined ? input.completed_at : existing.completed_at
28990
29203
  };
28991
- return store.upsertTaskWithPlanMembershipGuard(task2, [existing.plan_id, effectivePlanId].filter((planId) => Boolean(planId)), input.project_id !== undefined);
29204
+ const storedTask = await store.upsertTaskWithPlanMembershipGuard(task2, [existing.plan_id, effectivePlanId].filter((planId) => Boolean(planId)), input.project_id !== undefined, context, {
29205
+ operation: "update",
29206
+ expectedVersion: input.version,
29207
+ parentId: input.parent_id !== undefined ? input.parent_id : existing.parent_id
29208
+ });
29209
+ if (input.parent_id !== undefined && input.parent_id !== existing.parent_id) {
29210
+ await logTaskChange2(id, "update", "parent_id", existing.parent_id, input.parent_id, existing.assigned_to ?? existing.agent_id, store, context);
29211
+ }
29212
+ return storedTask;
28992
29213
  }
28993
29214
  async function startTask2(id, agentId, store) {
28994
29215
  const task2 = await requireRecord("tasks", id, store);
@@ -29052,7 +29273,11 @@ async function patchTask(task2, patch, store) {
29052
29273
  version: task2.version + 1,
29053
29274
  updated_at: new Date().toISOString()
29054
29275
  };
29055
- return store.upsertTaskWithPlanMembershipGuard(updated, [task2.plan_id, updated.plan_id].filter((planId) => Boolean(planId)), Object.prototype.hasOwnProperty.call(patch, "project_id"));
29276
+ return store.upsertTaskWithPlanMembershipGuard(updated, [task2.plan_id, updated.plan_id].filter((planId) => Boolean(planId)), Object.prototype.hasOwnProperty.call(patch, "project_id"), {}, {
29277
+ operation: "update",
29278
+ expectedVersion: task2.version,
29279
+ parentId: updated.parent_id
29280
+ });
29056
29281
  }
29057
29282
  var CLOUD_LOCK_EXPIRY_MINUTES = 30;
29058
29283
  function sameCloudLockHolder(stored, incoming) {
@@ -34172,7 +34397,7 @@ function createLocalPrGroupLedger(db = getDatabase()) {
34172
34397
  return new PrGroupLedger(new SqlitePrGroupLedgerPersistence(db));
34173
34398
  }
34174
34399
  // src/project-registration/authority.ts
34175
- import { createHash as createHash14 } from "crypto";
34400
+ import { createHash as createHash15 } from "crypto";
34176
34401
  // src/project-registration/types.ts
34177
34402
  var TODOS_PROJECT_REGISTRATION_ROUTE = "todos.project-registration.v1";
34178
34403
  var TODOS_PROJECT_REGISTRATION_CALLER_ROUTE = "projects.full-registration.v1";
@@ -34252,8 +34477,9 @@ class PostgresTodosProjectRegistrationTransaction {
34252
34477
  const result = await this.client.query(`
34253
34478
  SELECT * FROM todos_project_registration_receipts
34254
34479
  WHERE authority_id = $1 AND tenant_id = $2 AND corpus_id = $3
34255
- AND operation_id = $4 AND step_id = $5 AND resource_kind = $6
34256
- AND direction = $7 AND idempotency_key = $8 AND target_selector = $9
34480
+ AND route = $4 AND package_version = $5
34481
+ AND operation_id = $6 AND step_id = $7 AND resource_kind = $8
34482
+ AND direction = $9 AND idempotency_key = $10 AND target_selector = $11
34257
34483
  ORDER BY CASE outcome
34258
34484
  WHEN 'terminal_nonacceptance' THEN 0
34259
34485
  WHEN 'duplicate_of_accepted' THEN 1
@@ -34264,6 +34490,8 @@ class PostgresTodosProjectRegistrationTransaction {
34264
34490
  identity.authority_id,
34265
34491
  identity.tenant_id,
34266
34492
  identity.corpus_id,
34493
+ identity.route,
34494
+ identity.package_version,
34267
34495
  identity.operation_id,
34268
34496
  identity.step_id,
34269
34497
  identity.resource_kind,
@@ -34463,6 +34691,7 @@ class PostgresTodosProjectRegistrationTransaction {
34463
34691
  AND (payload->>'path' = $2 OR payload->>'task_list_id' = $3)
34464
34692
  ORDER BY payload->>'created_at' ASC, object_id ASC
34465
34693
  LIMIT 1
34694
+ FOR UPDATE
34466
34695
  `, [this.service, path, taskListSlug]);
34467
34696
  return result.rows[0] ? parsePayload3(result.rows[0].payload) : null;
34468
34697
  }
@@ -34473,6 +34702,7 @@ class PostgresTodosProjectRegistrationTransaction {
34473
34702
  AND payload->>'project_id' = $2 AND payload->>'slug' = $3
34474
34703
  ORDER BY payload->>'created_at' ASC, object_id ASC
34475
34704
  LIMIT 1
34705
+ FOR UPDATE
34476
34706
  `, [this.service, projectId, slug]);
34477
34707
  return result.rows[0] ? parsePayload3(result.rows[0].payload) : null;
34478
34708
  }
@@ -34483,10 +34713,24 @@ class PostgresTodosProjectRegistrationTransaction {
34483
34713
  return await this.storage.taskLists.create(input);
34484
34714
  }
34485
34715
  async getProject(id) {
34486
- return await this.storage.projects.get(id);
34716
+ const result = await this.client.query(`
34717
+ SELECT payload FROM ${this.tableName}
34718
+ WHERE service = $1 AND object_type = 'projects' AND object_id = $2
34719
+ AND deleted_at IS NULL
34720
+ LIMIT 1
34721
+ FOR SHARE
34722
+ `, [this.service, id]);
34723
+ return result.rows[0] ? parsePayload3(result.rows[0].payload) : null;
34487
34724
  }
34488
34725
  async getTaskList(id) {
34489
- return await this.storage.taskLists.get(id);
34726
+ const result = await this.client.query(`
34727
+ SELECT payload FROM ${this.tableName}
34728
+ WHERE service = $1 AND object_type = 'task_lists' AND object_id = $2
34729
+ AND deleted_at IS NULL
34730
+ LIMIT 1
34731
+ FOR SHARE
34732
+ `, [this.service, id]);
34733
+ return result.rows[0] ? parsePayload3(result.rows[0].payload) : null;
34490
34734
  }
34491
34735
  async lockCompensationWrites() {
34492
34736
  await this.client.query(`LOCK TABLE ${this.tableName} IN SHARE ROW EXCLUSIVE MODE`);
@@ -34566,11 +34810,102 @@ class PostgresTodosProjectRegistrationBackend {
34566
34810
  async getTaskList(id) {
34567
34811
  return (await this.direct()).getTaskList(id);
34568
34812
  }
34813
+ async getProjectResourceCollectionRevision(input) {
34814
+ await this.ensureSchema();
34815
+ const result = await this.client.query(`
34816
+ WITH resources(kind_rank, target_id, revision) AS (
34817
+ SELECT 0, object_id, COALESCE(payload->>'updated_at', updated_at::text)
34818
+ FROM ${this.tableName}
34819
+ WHERE service = $1 AND object_type = 'projects'
34820
+ AND deleted_at IS NULL AND object_id = $2
34821
+ UNION ALL
34822
+ SELECT 1, object_id, COALESCE(payload->>'updated_at', updated_at::text)
34823
+ FROM ${this.tableName}
34824
+ WHERE service = $1 AND object_type = 'task_lists'
34825
+ AND deleted_at IS NULL AND object_id = $3
34826
+ AND payload->>'project_id' = $2
34827
+ UNION ALL
34828
+ SELECT 2, object_id, COALESCE(payload->>'updated_at', updated_at::text)
34829
+ FROM ${this.tableName}
34830
+ WHERE $4::boolean AND service = $1 AND object_type = 'plans'
34831
+ AND deleted_at IS NULL AND payload->>'project_id' = $2
34832
+ UNION ALL
34833
+ SELECT 3, object_id, COALESCE(payload->>'updated_at', updated_at::text)
34834
+ FROM ${this.tableName}
34835
+ WHERE $4::boolean AND service = $1 AND object_type = 'tasks'
34836
+ AND deleted_at IS NULL AND payload->>'project_id' = $2
34837
+ )
34838
+ SELECT 'md5:' || md5(COALESCE(string_agg(
34839
+ kind_rank::text || chr(31) || target_id || chr(31) || revision,
34840
+ chr(30) ORDER BY kind_rank ASC, target_id ASC
34841
+ ), '')) AS revision
34842
+ FROM resources
34843
+ `, [
34844
+ this.service,
34845
+ input.todos_project_id,
34846
+ input.task_list_id,
34847
+ input.include_anchors
34848
+ ]);
34849
+ const revision = result.rows[0]?.revision;
34850
+ if (!revision) {
34851
+ throw new TodosProjectRegistrationError("TODOS_PROJECT_REGISTRATION_RECORD_NOT_FOUND", "could not derive the hosted project-resource collection revision");
34852
+ }
34853
+ return revision;
34854
+ }
34855
+ async listProjectResourceCandidates(input) {
34856
+ await this.ensureSchema();
34857
+ const afterRank = input.after?.kind_rank ?? -1;
34858
+ const afterId = input.after?.target_id ?? "";
34859
+ const result = await this.client.query(`
34860
+ WITH resources(kind, kind_rank, target_id, parent_id, revision) AS (
34861
+ SELECT 'project'::text, 0, object_id, NULL::text,
34862
+ COALESCE(payload->>'updated_at', updated_at::text)
34863
+ FROM ${this.tableName}
34864
+ WHERE service = $1 AND object_type = 'projects'
34865
+ AND deleted_at IS NULL AND object_id = $2
34866
+ UNION ALL
34867
+ SELECT 'task_list'::text, 1, object_id, payload->>'project_id',
34868
+ COALESCE(payload->>'updated_at', updated_at::text)
34869
+ FROM ${this.tableName}
34870
+ WHERE service = $1 AND object_type = 'task_lists'
34871
+ AND deleted_at IS NULL AND object_id = $3
34872
+ AND payload->>'project_id' = $2
34873
+ UNION ALL
34874
+ SELECT 'plan'::text, 2, object_id, payload->>'project_id',
34875
+ COALESCE(payload->>'updated_at', updated_at::text)
34876
+ FROM ${this.tableName}
34877
+ WHERE $4::boolean AND service = $1 AND object_type = 'plans'
34878
+ AND deleted_at IS NULL AND payload->>'project_id' = $2
34879
+ UNION ALL
34880
+ SELECT 'task'::text, 3, object_id,
34881
+ COALESCE(payload->>'plan_id', payload->>'project_id'),
34882
+ COALESCE(payload->>'updated_at', updated_at::text)
34883
+ FROM ${this.tableName}
34884
+ WHERE $4::boolean AND service = $1 AND object_type = 'tasks'
34885
+ AND deleted_at IS NULL AND payload->>'project_id' = $2
34886
+ )
34887
+ SELECT kind, kind_rank, target_id, parent_id, revision
34888
+ FROM resources
34889
+ WHERE kind_rank > $5 OR (kind_rank = $5 AND target_id > $6)
34890
+ ORDER BY kind_rank ASC, target_id ASC
34891
+ LIMIT $7
34892
+ `, [
34893
+ this.service,
34894
+ input.todos_project_id,
34895
+ input.task_list_id,
34896
+ input.include_anchors,
34897
+ afterRank,
34898
+ afterId,
34899
+ input.limit
34900
+ ]);
34901
+ return result.rows;
34902
+ }
34569
34903
  }
34570
34904
 
34571
34905
  // src/project-registration/sqlite.ts
34572
34906
  init_database();
34573
34907
  init_storage_tombstones();
34908
+ import { createHash as createHash14 } from "crypto";
34574
34909
  var sqliteTransactionTails2 = new WeakMap;
34575
34910
  var PROJECT_REFERENCE_COLUMNS = new Set([
34576
34911
  "project_id",
@@ -34671,6 +35006,7 @@ class SqliteTodosProjectRegistrationTransaction {
34671
35006
  const row = this.db.query(`
34672
35007
  SELECT * FROM todos_project_registration_receipts
34673
35008
  WHERE authority_id = ? AND tenant_id = ? AND corpus_id = ?
35009
+ AND route = ? AND package_version = ?
34674
35010
  AND operation_id = ? AND step_id = ? AND resource_kind = ?
34675
35011
  AND direction = ? AND idempotency_key = ? AND target_selector = ?
34676
35012
  ORDER BY CASE outcome
@@ -34679,7 +35015,7 @@ class SqliteTodosProjectRegistrationTransaction {
34679
35015
  ELSE 2
34680
35016
  END, created_at DESC, receipt_id DESC
34681
35017
  LIMIT 1
34682
- `).get(identity.authority_id, identity.tenant_id, identity.corpus_id, identity.operation_id, identity.step_id, identity.resource_kind, identity.direction, identity.idempotency_key, identity.target_selector);
35018
+ `).get(identity.authority_id, identity.tenant_id, identity.corpus_id, identity.route, identity.package_version, identity.operation_id, identity.step_id, identity.resource_kind, identity.direction, identity.idempotency_key, identity.target_selector);
34683
35019
  return row ? receiptFromRow2(row) : null;
34684
35020
  }
34685
35021
  async getReceiptById(receiptId) {
@@ -34833,7 +35169,7 @@ class StagedSqliteTodosProjectRegistrationTransaction {
34833
35169
  }
34834
35170
  async lockStep(_identity) {}
34835
35171
  async getReceiptForLookup(identity) {
34836
- const staged = [...this.receipts.values()].filter((receipt) => receipt.authority_id === identity.authority_id && receipt.tenant_id === identity.tenant_id && receipt.corpus_id === identity.corpus_id && receipt.operation_id === identity.operation_id && receipt.step_id === identity.step_id && receipt.resource_kind === identity.resource_kind && receipt.direction === identity.direction && receipt.idempotency_key === identity.idempotency_key && receipt.target_selector === identity.target_selector);
35172
+ const staged = [...this.receipts.values()].filter((receipt) => receipt.authority_id === identity.authority_id && receipt.tenant_id === identity.tenant_id && receipt.corpus_id === identity.corpus_id && receipt.route === identity.route && receipt.package_version === identity.package_version && receipt.operation_id === identity.operation_id && receipt.step_id === identity.step_id && receipt.resource_kind === identity.resource_kind && receipt.direction === identity.direction && receipt.idempotency_key === identity.idempotency_key && receipt.target_selector === identity.target_selector);
34837
35173
  const stored = await this.direct.getReceiptForLookup(identity);
34838
35174
  if (stored)
34839
35175
  staged.push(stored);
@@ -35223,6 +35559,64 @@ class SqliteTodosProjectRegistrationBackend {
35223
35559
  getTaskList(id) {
35224
35560
  return this.direct.getTaskList(id);
35225
35561
  }
35562
+ async getProjectResourceCollectionRevision(input) {
35563
+ const digest = createHash14("sha256");
35564
+ const rows = this.db.query(`
35565
+ WITH resources(kind_rank, target_id, revision) AS (
35566
+ SELECT 0, id, updated_at
35567
+ FROM projects
35568
+ WHERE id = ?
35569
+ UNION ALL
35570
+ SELECT 1, id, updated_at
35571
+ FROM task_lists
35572
+ WHERE id = ? AND project_id = ?
35573
+ UNION ALL
35574
+ SELECT 2, id, updated_at
35575
+ FROM plans
35576
+ WHERE ? = 1 AND project_id = ?
35577
+ UNION ALL
35578
+ SELECT 3, id, updated_at
35579
+ FROM tasks
35580
+ WHERE ? = 1 AND project_id = ?
35581
+ )
35582
+ SELECT kind_rank, target_id, revision
35583
+ FROM resources
35584
+ ORDER BY kind_rank ASC, target_id ASC
35585
+ `).iterate(input.todos_project_id, input.task_list_id, input.todos_project_id, input.include_anchors ? 1 : 0, input.todos_project_id, input.include_anchors ? 1 : 0, input.todos_project_id);
35586
+ for (const row of rows) {
35587
+ digest.update(`${row.kind_rank}\x00${row.target_id}\x00${row.revision}
35588
+ `);
35589
+ }
35590
+ return `sha256:${digest.digest("hex")}`;
35591
+ }
35592
+ async listProjectResourceCandidates(input) {
35593
+ const afterRank = input.after?.kind_rank ?? -1;
35594
+ const afterId = input.after?.target_id ?? "";
35595
+ return this.db.query(`
35596
+ WITH resources(kind, kind_rank, target_id, parent_id, revision) AS (
35597
+ SELECT 'project', 0, id, NULL, updated_at
35598
+ FROM projects
35599
+ WHERE id = ?
35600
+ UNION ALL
35601
+ SELECT 'task_list', 1, id, project_id, updated_at
35602
+ FROM task_lists
35603
+ WHERE id = ? AND project_id = ?
35604
+ UNION ALL
35605
+ SELECT 'plan', 2, id, project_id, updated_at
35606
+ FROM plans
35607
+ WHERE ? = 1 AND project_id = ?
35608
+ UNION ALL
35609
+ SELECT 'task', 3, id, COALESCE(plan_id, project_id), updated_at
35610
+ FROM tasks
35611
+ WHERE ? = 1 AND project_id = ?
35612
+ )
35613
+ SELECT kind, kind_rank, target_id, parent_id, revision
35614
+ FROM resources
35615
+ WHERE kind_rank > ? OR (kind_rank = ? AND target_id > ?)
35616
+ ORDER BY kind_rank ASC, target_id ASC
35617
+ LIMIT ?
35618
+ `).all(input.todos_project_id, input.task_list_id, input.todos_project_id, input.include_anchors ? 1 : 0, input.todos_project_id, input.include_anchors ? 1 : 0, input.todos_project_id, afterRank, afterRank, afterId, input.limit);
35619
+ }
35226
35620
  }
35227
35621
 
35228
35622
  // src/project-registration/authority.ts
@@ -35230,8 +35624,12 @@ var UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-
35230
35624
  var WORKSPACE_ID_PATTERN = /^wks_[A-Za-z0-9][A-Za-z0-9_-]{11,}$/;
35231
35625
  var OPERATION_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$/;
35232
35626
  var STEP_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{2,127}$/;
35627
+ var AUTHORITY_ROUTE_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{2,127}$/;
35628
+ var PACKAGE_VERSION_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._+-]{0,127}$/;
35233
35629
  var SHA256_PATTERN = /^[0-9a-f]{64}$/;
35234
35630
  var IDEMPOTENCY_PATTERN = /^prk_[0-9a-f]{48}$/;
35631
+ var PROJECT_RESOURCE_PAGE_LIMIT = 500;
35632
+ var PROJECT_RESOURCE_CURSOR_VERSION = 1;
35235
35633
 
35236
35634
  class WriteBoundaryError extends Error {
35237
35635
  point;
@@ -35259,7 +35657,7 @@ function canonicalize4(value) {
35259
35657
  return out;
35260
35658
  }
35261
35659
  function digestProjectRegistrationValue(value) {
35262
- return createHash14("sha256").update(canonicalProjectRegistrationJson(value)).digest("hex");
35660
+ return createHash15("sha256").update(canonicalProjectRegistrationJson(value)).digest("hex");
35263
35661
  }
35264
35662
  function deriveTodosProjectRegistrationIdempotencyKey(input) {
35265
35663
  return `prk_${digestProjectRegistrationValue({
@@ -35371,35 +35769,68 @@ function projectRecord(project) {
35371
35769
  return {
35372
35770
  target_id: project.id,
35373
35771
  revision: project.updated_at,
35374
- digest: digestProjectRegistrationValue({
35375
- id: project.id,
35376
- name: project.name,
35377
- path: project.path,
35378
- description: project.description,
35379
- task_list_id: project.task_list_id,
35380
- task_prefix: project.task_prefix,
35381
- task_counter: project.task_counter,
35382
- created_at: project.created_at,
35383
- updated_at: project.updated_at
35384
- })
35772
+ digest: projectRegistrationDigest(project)
35385
35773
  };
35386
35774
  }
35387
35775
  function taskListRecord(taskList) {
35388
35776
  return {
35389
35777
  target_id: taskList.id,
35390
35778
  revision: taskList.updated_at,
35391
- digest: digestProjectRegistrationValue({
35392
- id: taskList.id,
35393
- project_id: taskList.project_id,
35394
- slug: taskList.slug,
35395
- name: taskList.name,
35396
- description: taskList.description,
35397
- metadata: taskList.metadata,
35398
- created_at: taskList.created_at,
35399
- updated_at: taskList.updated_at
35779
+ digest: taskListRegistrationDigest(taskList)
35780
+ };
35781
+ }
35782
+ function boundExistingProjectRecord(project) {
35783
+ return {
35784
+ target_id: project.id,
35785
+ revision: project.created_at,
35786
+ digest: projectRegistrationDigest({
35787
+ ...project,
35788
+ updated_at: project.created_at
35789
+ })
35790
+ };
35791
+ }
35792
+ function boundExistingTaskListRecord(taskList) {
35793
+ return {
35794
+ target_id: taskList.id,
35795
+ revision: taskList.created_at,
35796
+ digest: taskListRegistrationDigest({
35797
+ ...taskList,
35798
+ updated_at: taskList.created_at
35400
35799
  })
35401
35800
  };
35402
35801
  }
35802
+ function projectRegistrationDigest(project) {
35803
+ return digestProjectRegistrationValue({
35804
+ id: project.id,
35805
+ name: project.name,
35806
+ path: project.path,
35807
+ description: project.description,
35808
+ task_list_id: project.task_list_id,
35809
+ task_prefix: project.task_prefix,
35810
+ task_counter: project.task_counter,
35811
+ created_at: project.created_at,
35812
+ updated_at: project.updated_at
35813
+ });
35814
+ }
35815
+ function taskListRegistrationDigest(taskList) {
35816
+ return digestProjectRegistrationValue({
35817
+ id: taskList.id,
35818
+ project_id: taskList.project_id,
35819
+ slug: taskList.slug,
35820
+ name: taskList.name,
35821
+ description: taskList.description,
35822
+ metadata: taskList.metadata,
35823
+ created_at: taskList.created_at,
35824
+ updated_at: taskList.updated_at
35825
+ });
35826
+ }
35827
+ function canonicalValuesEqual(left, right) {
35828
+ try {
35829
+ return canonicalProjectRegistrationJson(left) === canonicalProjectRegistrationJson(right);
35830
+ } catch {
35831
+ return false;
35832
+ }
35833
+ }
35403
35834
  function receiptId(input) {
35404
35835
  return `tpr_${digestProjectRegistrationValue(input).slice(0, 40)}`;
35405
35836
  }
@@ -35419,6 +35850,29 @@ function assertCapabilityRequest(request, capability2) {
35419
35850
  }
35420
35851
  }
35421
35852
  function normalizedCallDigest(request) {
35853
+ return digestProjectRegistrationValue({
35854
+ authority_route: request.authority_route,
35855
+ package_version: request.package_version,
35856
+ authority_id: request.authority_id,
35857
+ tenant_id: request.tenant_id,
35858
+ corpus_id: request.corpus_id,
35859
+ operation_id: request.operation_id,
35860
+ step_id: request.step_id,
35861
+ resource_kind: request.resource_kind,
35862
+ direction: request.direction,
35863
+ target_selector: request.target_selector,
35864
+ idempotency_key: request.idempotency_key,
35865
+ request_digest: request.request_digest,
35866
+ precondition_digest: request.precondition_digest,
35867
+ project_id: request.project_id,
35868
+ project_slug: request.project_slug,
35869
+ project_name: request.project_name,
35870
+ desired: request.desired,
35871
+ bind_existing: request.bind_existing === true,
35872
+ accepted_receipt_id: request.accepted_receipt?.receipt_id ?? null
35873
+ });
35874
+ }
35875
+ function legacyNormalizedCallDigestBeforeBindExisting(request) {
35422
35876
  return digestProjectRegistrationValue({
35423
35877
  authority_route: request.authority_route,
35424
35878
  package_version: request.package_version,
@@ -35440,6 +35894,11 @@ function normalizedCallDigest(request) {
35440
35894
  accepted_receipt_id: request.accepted_receipt?.receipt_id ?? null
35441
35895
  });
35442
35896
  }
35897
+ function acceptedCallMatches(request, accepted, callDigest = normalizedCallDigest(request)) {
35898
+ if (accepted.normalized_call_digest === callDigest)
35899
+ return true;
35900
+ return request.bind_existing !== true && accepted.normalized_call_digest === legacyNormalizedCallDigestBeforeBindExisting(request);
35901
+ }
35443
35902
  function assertCommonRequest(request, capability2) {
35444
35903
  assertBounds(request);
35445
35904
  assertResourceKind(request.resource_kind);
@@ -35481,6 +35940,9 @@ function assertCommonRequest(request, capability2) {
35481
35940
  if (!request.desired || typeof request.desired !== "object" || Array.isArray(request.desired)) {
35482
35941
  throw new TodosProjectRegistrationError("TODOS_PROJECT_REGISTRATION_INVALID_INPUT", "desired must be a JSON object");
35483
35942
  }
35943
+ if (request.bind_existing !== undefined && typeof request.bind_existing !== "boolean") {
35944
+ throw new TodosProjectRegistrationError("TODOS_PROJECT_REGISTRATION_INVALID_INPUT", "bind_existing must be boolean when supplied");
35945
+ }
35484
35946
  const expectedKey = deriveTodosProjectRegistrationIdempotencyKey({
35485
35947
  operation_id: request.operation_id,
35486
35948
  step_id: request.step_id,
@@ -35502,7 +35964,7 @@ function assertForwardRequest(request, capability2) {
35502
35964
  const expectedRequestDigest = digestProjectRegistrationValue(request.desired);
35503
35965
  const expectedPreconditionDigest = digestProjectRegistrationValue({
35504
35966
  target_selector: request.target_selector,
35505
- expected: "absent"
35967
+ expected: request.bind_existing === true ? "absent_or_matching_existing" : "absent"
35506
35968
  });
35507
35969
  if (request.request_digest !== expectedRequestDigest || request.precondition_digest !== expectedPreconditionDigest) {
35508
35970
  throw new TodosProjectRegistrationError("TODOS_PROJECT_REGISTRATION_DIGEST_MISMATCH", "request_digest or precondition_digest does not match normalized forward semantics", {
@@ -35581,7 +36043,7 @@ function receiptBase(request, callDigest, capability2) {
35581
36043
  normalized_call_digest: callDigest
35582
36044
  };
35583
36045
  }
35584
- function makeAcceptedReceipt(request, callDigest, capability2, record, createdAt2) {
36046
+ function makeAcceptedReceipt(request, callDigest, capability2, record, createdAt2, createdByOperation = true) {
35585
36047
  return makeReceipt({
35586
36048
  ...receiptBase(request, callDigest, capability2),
35587
36049
  outcome: "accepted",
@@ -35591,7 +36053,7 @@ function makeAcceptedReceipt(request, callDigest, capability2, record, createdAt
35591
36053
  result_digest: record.digest,
35592
36054
  duplicate_of_receipt_id: null,
35593
36055
  accepted_receipt_id: request.direction === "inverse" ? request.accepted_receipt.receipt_id : null,
35594
- created_by_operation: true
36056
+ created_by_operation: createdByOperation
35595
36057
  }, createdAt2);
35596
36058
  }
35597
36059
  function makeDuplicateReceipt(request, callDigest, capability2, accepted, createdAt2) {
@@ -35653,6 +36115,53 @@ function bindingFor(request, callDigest, timestamp3, capability2) {
35653
36115
  updated_at: timestamp3
35654
36116
  };
35655
36117
  }
36118
+ function encodeProjectResourceCursor(input) {
36119
+ return Buffer.from(JSON.stringify({
36120
+ version: PROJECT_RESOURCE_CURSOR_VERSION,
36121
+ ...input
36122
+ }), "utf8").toString("base64url");
36123
+ }
36124
+ function decodeProjectResourceCursor(cursor, expected) {
36125
+ if (!cursor)
36126
+ return null;
36127
+ let parsed;
36128
+ try {
36129
+ parsed = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8"));
36130
+ } catch {
36131
+ throw new TodosProjectRegistrationError("TODOS_PROJECT_REGISTRATION_INVALID_INPUT", "cursor is not a valid project-resource cursor");
36132
+ }
36133
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
36134
+ throw new TodosProjectRegistrationError("TODOS_PROJECT_REGISTRATION_INVALID_INPUT", "cursor is not a valid project-resource cursor");
36135
+ }
36136
+ const value = parsed;
36137
+ if (value["version"] !== PROJECT_RESOURCE_CURSOR_VERSION || value["source_project_id"] !== expected.source_project_id || value["include_anchors"] !== expected.include_anchors || !Number.isSafeInteger(value["kind_rank"]) || Number(value["kind_rank"]) < 0 || Number(value["kind_rank"]) > 3 || typeof value["target_id"] !== "string" || !UUID_PATTERN.test(value["target_id"]) || typeof value["collection_revision"] !== "string" || value["collection_revision"].length < 16 || value["collection_revision"].length > 128) {
36138
+ throw new TodosProjectRegistrationError("TODOS_PROJECT_REGISTRATION_INVALID_INPUT", "cursor does not match this project-resource query");
36139
+ }
36140
+ return {
36141
+ kind_rank: Number(value["kind_rank"]),
36142
+ target_id: value["target_id"],
36143
+ collection_revision: value["collection_revision"]
36144
+ };
36145
+ }
36146
+ function projectResourceFromCandidate(sourceProjectId, candidate) {
36147
+ const scope = candidate.kind === "project" || candidate.kind === "task_list" ? "collection" : "resource";
36148
+ return {
36149
+ source_project_id: sourceProjectId,
36150
+ kind: candidate.kind,
36151
+ scope,
36152
+ target_id: candidate.target_id,
36153
+ parent_id: candidate.parent_id,
36154
+ revision: candidate.revision,
36155
+ digest: digestProjectRegistrationValue({
36156
+ source_project_id: sourceProjectId,
36157
+ kind: candidate.kind,
36158
+ scope,
36159
+ target_id: candidate.target_id,
36160
+ parent_id: candidate.parent_id,
36161
+ revision: candidate.revision
36162
+ })
36163
+ };
36164
+ }
35656
36165
 
35657
36166
  class PackageOwnedTodosProjectRegistrationAuthority {
35658
36167
  backend;
@@ -35674,6 +36183,10 @@ class PackageOwnedTodosProjectRegistrationAuthority {
35674
36183
  immutable_receipts: true,
35675
36184
  exact_terminal_lookup: true,
35676
36185
  exact_readback: true,
36186
+ bind_existing_adoption: true,
36187
+ prior_registration_adoption_validation: true,
36188
+ project_resource_enumeration: true,
36189
+ project_resource_page_limit: PROJECT_RESOURCE_PAGE_LIMIT,
35677
36190
  conditional_inverse: true,
35678
36191
  ambiguous_outcome_reconciliation: true
35679
36192
  };
@@ -35718,6 +36231,8 @@ class PackageOwnedTodosProjectRegistrationAuthority {
35718
36231
  async existingForwardResolution(transaction, request, callDigest) {
35719
36232
  const exact = await transaction.getReceiptForLookup({
35720
36233
  ...authorityScope(this.capabilityValue),
36234
+ route: this.capabilityValue.route,
36235
+ package_version: this.capabilityValue.package_version,
35721
36236
  operation_id: request.operation_id,
35722
36237
  step_id: request.step_id,
35723
36238
  resource_kind: request.resource_kind,
@@ -35732,7 +36247,7 @@ class PackageOwnedTodosProjectRegistrationAuthority {
35732
36247
  if (!accepted2) {
35733
36248
  throw new TodosProjectRegistrationError("TODOS_PROJECT_REGISTRATION_CONFLICT", "duplicate receipt points to a missing accepted receipt");
35734
36249
  }
35735
- if (accepted2.normalized_call_digest !== callDigest) {
36250
+ if (!acceptedCallMatches(request, accepted2, callDigest)) {
35736
36251
  return this.terminalFor(transaction, request, callDigest, "operation_step_semantics_changed", { targetId: accepted2.target_id });
35737
36252
  }
35738
36253
  return this.duplicateFor(transaction, request, callDigest, accepted2);
@@ -35746,7 +36261,7 @@ class PackageOwnedTodosProjectRegistrationAuthority {
35746
36261
  });
35747
36262
  if (!accepted)
35748
36263
  return null;
35749
- if (accepted.normalized_call_digest === callDigest) {
36264
+ if (acceptedCallMatches(request, accepted, callDigest)) {
35750
36265
  return this.duplicateFor(transaction, request, callDigest, accepted);
35751
36266
  }
35752
36267
  return this.terminalFor(transaction, request, callDigest, "operation_step_semantics_changed", { targetId: accepted.target_id });
@@ -35757,6 +36272,12 @@ class PackageOwnedTodosProjectRegistrationAuthority {
35757
36272
  const slug2 = taskListSlug(request.project_slug);
35758
36273
  const conflict2 = await transaction.findProjectConflict(path, slug2);
35759
36274
  if (conflict2) {
36275
+ if (request.bind_existing === true && conflict2.path === path && conflict2.task_list_id === slug2) {
36276
+ return {
36277
+ record: boundExistingProjectRecord(conflict2),
36278
+ created_by_operation: false
36279
+ };
36280
+ }
35760
36281
  return this.terminalFor(transaction, request, normalizedCallDigest(request), "target_already_exists", { targetId: conflict2.id });
35761
36282
  }
35762
36283
  await this.fault("before_object_write", request);
@@ -35768,7 +36289,10 @@ class PackageOwnedTodosProjectRegistrationAuthority {
35768
36289
  task_prefix: deterministicTaskPrefix(request.project_slug)
35769
36290
  });
35770
36291
  await this.fault("after_object_write", request);
35771
- return projectRecord(project);
36292
+ return {
36293
+ record: projectRecord(project),
36294
+ created_by_operation: true
36295
+ };
35772
36296
  }
35773
36297
  const todosProjectId = String(request.desired["todos_project_id"]);
35774
36298
  const sourceBinding = await transaction.getBinding(authorityScope(this.capabilityValue), "project", request.project_id);
@@ -35782,6 +36306,12 @@ class PackageOwnedTodosProjectRegistrationAuthority {
35782
36306
  const slug = taskListSlug(request.project_slug);
35783
36307
  const conflict = await transaction.findTaskListConflict(todosProjectId, slug);
35784
36308
  if (conflict) {
36309
+ if (request.bind_existing === true && conflict.project_id === todosProjectId && conflict.slug === slug) {
36310
+ return {
36311
+ record: boundExistingTaskListRecord(conflict),
36312
+ created_by_operation: false
36313
+ };
36314
+ }
35785
36315
  return this.terminalFor(transaction, request, normalizedCallDigest(request), "target_already_exists", { targetId: conflict.id });
35786
36316
  }
35787
36317
  await this.fault("before_object_write", request);
@@ -35798,7 +36328,10 @@ class PackageOwnedTodosProjectRegistrationAuthority {
35798
36328
  if (taskList.project_id !== todosProjectId) {
35799
36329
  throw new TodosProjectRegistrationError("TODOS_PROJECT_REGISTRATION_CONFLICT", "task-list create did not preserve the exact full Todos project id");
35800
36330
  }
35801
- return taskListRecord(taskList);
36331
+ return {
36332
+ record: taskListRecord(taskList),
36333
+ created_by_operation: true
36334
+ };
35802
36335
  }
35803
36336
  async create(request) {
35804
36337
  const startedAt = Date.now();
@@ -35820,9 +36353,9 @@ class PackageOwnedTodosProjectRegistrationAuthority {
35820
36353
  const claimed = await transaction.claimBinding(bindingFor(request, callDigest, timestamp3, this.capabilityValue));
35821
36354
  if (!claimed) {
35822
36355
  const binding = await transaction.getBinding(authorityScope(this.capabilityValue), request.resource_kind, request.target_selector);
35823
- if (binding?.state === "accepted" && binding.normalized_call_digest === callDigest && binding.accepted_receipt_id) {
36356
+ if (binding?.state === "accepted" && binding.accepted_receipt_id) {
35824
36357
  const accepted2 = await transaction.getReceiptById(binding.accepted_receipt_id);
35825
- if (accepted2) {
36358
+ if (accepted2 && binding.normalized_call_digest === accepted2.normalized_call_digest && acceptedCallMatches(request, accepted2, callDigest)) {
35826
36359
  return this.duplicateFor(transaction, request, callDigest, accepted2);
35827
36360
  }
35828
36361
  }
@@ -35833,15 +36366,15 @@ class PackageOwnedTodosProjectRegistrationAuthority {
35833
36366
  await transaction.setBindingTerminal(authorityScope(this.capabilityValue), request.resource_kind, request.target_selector, this.now());
35834
36367
  return recordOrTerminal;
35835
36368
  }
35836
- const accepted = makeAcceptedReceipt(request, callDigest, this.capabilityValue, recordOrTerminal, this.now());
36369
+ const accepted = makeAcceptedReceipt(request, callDigest, this.capabilityValue, recordOrTerminal.record, this.now(), recordOrTerminal.created_by_operation);
35837
36370
  await this.fault("before_receipt_write", request);
35838
36371
  const stored = await insertDeterministicReceipt(transaction, accepted);
35839
36372
  await this.fault("after_receipt_write", request);
35840
36373
  await transaction.setBindingAccepted(authorityScope(this.capabilityValue), request.resource_kind, request.target_selector, {
35841
- target_id: recordOrTerminal.target_id,
36374
+ target_id: recordOrTerminal.record.target_id,
35842
36375
  accepted_receipt_id: stored.receipt_id,
35843
- result_revision: recordOrTerminal.revision,
35844
- result_digest: recordOrTerminal.digest,
36376
+ result_revision: recordOrTerminal.record.revision,
36377
+ result_digest: recordOrTerminal.record.digest,
35845
36378
  updated_at: this.now()
35846
36379
  });
35847
36380
  return stored;
@@ -35870,6 +36403,8 @@ class PackageOwnedTodosProjectRegistrationAuthority {
35870
36403
  });
35871
36404
  const exact = await transaction.getReceiptForLookup({
35872
36405
  ...authorityScope(this.capabilityValue),
36406
+ route: this.capabilityValue.route,
36407
+ package_version: this.capabilityValue.package_version,
35873
36408
  operation_id: request.operation_id,
35874
36409
  step_id: request.step_id,
35875
36410
  resource_kind: request.resource_kind,
@@ -35887,7 +36422,7 @@ class PackageOwnedTodosProjectRegistrationAuthority {
35887
36422
  direction: request.direction
35888
36423
  });
35889
36424
  if (accepted) {
35890
- return accepted.normalized_call_digest === callDigest ? this.duplicateFor(transaction, request, callDigest, accepted) : this.terminalFor(transaction, request, callDigest, "operation_step_semantics_changed", { targetId: accepted.target_id });
36425
+ return acceptedCallMatches(request, accepted, callDigest) ? this.duplicateFor(transaction, request, callDigest, accepted) : this.terminalFor(transaction, request, callDigest, "operation_step_semantics_changed", { targetId: accepted.target_id });
35891
36426
  }
35892
36427
  const timestamp3 = this.now();
35893
36428
  const claimed = await transaction.claimBinding(bindingFor(request, callDigest, timestamp3, this.capabilityValue));
@@ -35920,9 +36455,18 @@ class PackageOwnedTodosProjectRegistrationAuthority {
35920
36455
  if (request.max_items !== 1) {
35921
36456
  throw new TodosProjectRegistrationError("TODOS_PROJECT_REGISTRATION_INVALID_BOUNDS", "max_items must be exactly 1 for terminal receipt lookup");
35922
36457
  }
35923
- if (request.authority !== "todos" || request.authority_route !== this.capabilityValue.route || request.package_version !== this.capabilityValue.package_version || request.authority_id !== this.capabilityValue.authority_id || request.tenant_id !== this.capabilityValue.tenant_id || request.corpus_id !== this.capabilityValue.corpus_id) {
36458
+ if (request.authority !== "todos" || request.authority_id !== this.capabilityValue.authority_id || request.tenant_id !== this.capabilityValue.tenant_id || request.corpus_id !== this.capabilityValue.corpus_id) {
35924
36459
  throw new TodosProjectRegistrationError("TODOS_PROJECT_REGISTRATION_CAPABILITY_MISMATCH", "receipt lookup does not match this authority capability identity");
35925
36460
  }
36461
+ requireString(request.authority_route, "authority_route", {
36462
+ min: 3,
36463
+ max: 128,
36464
+ pattern: AUTHORITY_ROUTE_PATTERN
36465
+ });
36466
+ requireString(request.package_version, "package_version", {
36467
+ max: 128,
36468
+ pattern: PACKAGE_VERSION_PATTERN
36469
+ });
35926
36470
  requireString(request.operation_id, "operation_id", {
35927
36471
  min: 8,
35928
36472
  max: 128,
@@ -35944,6 +36488,8 @@ class PackageOwnedTodosProjectRegistrationAuthority {
35944
36488
  }
35945
36489
  const receipt = await this.backend.getReceiptForLookup({
35946
36490
  ...authorityScope(this.capabilityValue),
36491
+ route: request.authority_route,
36492
+ package_version: request.package_version,
35947
36493
  operation_id: request.operation_id,
35948
36494
  step_id: request.step_id,
35949
36495
  resource_kind: request.resource_kind,
@@ -35956,6 +36502,164 @@ class PackageOwnedTodosProjectRegistrationAuthority {
35956
36502
  }
35957
36503
  return withResponseControl({ receipt: publicReceipt(receipt) }, request, startedAt);
35958
36504
  }
36505
+ async listProjectResources(request) {
36506
+ const sourceProjectId = requireString(request.source_project_id, "source_project_id", { min: 16, max: 128, pattern: WORKSPACE_ID_PATTERN });
36507
+ if (!Number.isSafeInteger(request.limit) || request.limit <= 0 || request.limit > PROJECT_RESOURCE_PAGE_LIMIT) {
36508
+ throw new TodosProjectRegistrationError("TODOS_PROJECT_REGISTRATION_INVALID_BOUNDS", `limit must be an integer from 1 to ${PROJECT_RESOURCE_PAGE_LIMIT}`);
36509
+ }
36510
+ if (request.include_anchors !== undefined && typeof request.include_anchors !== "boolean") {
36511
+ throw new TodosProjectRegistrationError("TODOS_PROJECT_REGISTRATION_INVALID_INPUT", "include_anchors must be boolean when supplied");
36512
+ }
36513
+ const includeAnchors = request.include_anchors === true;
36514
+ const projectBinding = await this.backend.getBinding(authorityScope(this.capabilityValue), "project", sourceProjectId);
36515
+ if (!projectBinding || projectBinding.state !== "accepted" || !projectBinding.target_id) {
36516
+ throw new TodosProjectRegistrationError("TODOS_PROJECT_REGISTRATION_RECORD_NOT_FOUND", "no accepted Todos project binding exists for this exact Projects workspace id", { source_project_id: sourceProjectId });
36517
+ }
36518
+ const taskListBinding = await this.backend.getBinding(authorityScope(this.capabilityValue), "task_list", `${projectBinding.target_id}:default`);
36519
+ if (!taskListBinding || taskListBinding.state !== "accepted" || !taskListBinding.target_id) {
36520
+ throw new TodosProjectRegistrationError("TODOS_PROJECT_REGISTRATION_RECORD_NOT_FOUND", "no accepted canonical task-list binding exists for this exact Todos project id", {
36521
+ source_project_id: sourceProjectId,
36522
+ todos_project_id: projectBinding.target_id
36523
+ });
36524
+ }
36525
+ const cursor = decodeProjectResourceCursor(request.cursor, {
36526
+ source_project_id: sourceProjectId,
36527
+ include_anchors: includeAnchors
36528
+ });
36529
+ const collectionInput = {
36530
+ todos_project_id: projectBinding.target_id,
36531
+ task_list_id: taskListBinding.target_id,
36532
+ include_anchors: includeAnchors
36533
+ };
36534
+ const collectionRevision = await this.backend.getProjectResourceCollectionRevision(collectionInput);
36535
+ if (cursor && cursor.collection_revision !== collectionRevision) {
36536
+ throw new TodosProjectRegistrationError("TODOS_PROJECT_REGISTRATION_COLLECTION_CHANGED", "project-resource collection changed during pagination; restart from the first page", {
36537
+ source_project_id: sourceProjectId,
36538
+ expected_collection_revision: cursor.collection_revision,
36539
+ current_collection_revision: collectionRevision
36540
+ });
36541
+ }
36542
+ const candidates = await this.backend.listProjectResourceCandidates({
36543
+ ...collectionInput,
36544
+ after: cursor ? { kind_rank: cursor.kind_rank, target_id: cursor.target_id } : null,
36545
+ limit: request.limit + 1
36546
+ });
36547
+ const verifiedCollectionRevision = await this.backend.getProjectResourceCollectionRevision(collectionInput);
36548
+ if (verifiedCollectionRevision !== collectionRevision) {
36549
+ throw new TodosProjectRegistrationError("TODOS_PROJECT_REGISTRATION_COLLECTION_CHANGED", "project-resource collection changed while producing a page; restart from the first page", {
36550
+ source_project_id: sourceProjectId,
36551
+ expected_collection_revision: collectionRevision,
36552
+ current_collection_revision: verifiedCollectionRevision
36553
+ });
36554
+ }
36555
+ const hasMore = candidates.length > request.limit;
36556
+ const pageCandidates = candidates.slice(0, request.limit);
36557
+ const resources = pageCandidates.map((candidate) => projectResourceFromCandidate(sourceProjectId, candidate));
36558
+ const last = pageCandidates.at(-1);
36559
+ return {
36560
+ authority: "todos",
36561
+ route: this.capabilityValue.route,
36562
+ package_version: this.capabilityValue.package_version,
36563
+ authority_id: this.capabilityValue.authority_id,
36564
+ tenant_id: this.capabilityValue.tenant_id,
36565
+ corpus_id: this.capabilityValue.corpus_id,
36566
+ source_project_id: sourceProjectId,
36567
+ todos_project_id: projectBinding.target_id,
36568
+ task_list_id: taskListBinding.target_id,
36569
+ include_anchors: includeAnchors,
36570
+ collection_revision: collectionRevision,
36571
+ limit: request.limit,
36572
+ count: resources.length,
36573
+ resources,
36574
+ has_more: hasMore,
36575
+ next_cursor: hasMore && last ? encodeProjectResourceCursor({
36576
+ source_project_id: sourceProjectId,
36577
+ include_anchors: includeAnchors,
36578
+ collection_revision: collectionRevision,
36579
+ kind_rank: last.kind_rank,
36580
+ target_id: last.target_id
36581
+ }) : null,
36582
+ complete: !hasMore,
36583
+ truncated: false
36584
+ };
36585
+ }
36586
+ async validatePriorRegistrationAdoption(sourceRequest, sourceReceipt, currentRecord) {
36587
+ const startedAt = Date.now();
36588
+ if (!sourceRequest || typeof sourceRequest !== "object" || Array.isArray(sourceRequest) || !sourceReceipt || typeof sourceReceipt !== "object" || Array.isArray(sourceReceipt) || !currentRecord || typeof currentRecord !== "object" || Array.isArray(currentRecord)) {
36589
+ throw new TodosProjectRegistrationError("TODOS_PROJECT_REGISTRATION_ADOPTION_REJECTED", "source request, source receipt, and current record must be present objects");
36590
+ }
36591
+ requireString(sourceRequest.package_version, "package_version", {
36592
+ max: 128,
36593
+ pattern: PACKAGE_VERSION_PATTERN
36594
+ });
36595
+ assertForwardRequest(sourceRequest, {
36596
+ ...this.capabilityValue,
36597
+ package_version: sourceRequest.package_version
36598
+ });
36599
+ const validation = await this.backend.transaction(async (transaction) => {
36600
+ const storedSource = await transaction.getReceiptById(sourceReceipt.receipt_id);
36601
+ if (!storedSource || !canonicalValuesEqual(publicReceipt(storedSource), sourceReceipt) || storedSource.outcome !== "accepted" && storedSource.outcome !== "duplicate_of_accepted") {
36602
+ throw new TodosProjectRegistrationError("TODOS_PROJECT_REGISTRATION_ADOPTION_REJECTED", "source receipt is not an exact immutable accepted or duplicate receipt", { receipt_id: sourceReceipt.receipt_id });
36603
+ }
36604
+ const accepted = storedSource.outcome === "accepted" ? storedSource : storedSource.duplicate_of_receipt_id ? await transaction.getReceiptById(storedSource.duplicate_of_receipt_id) : null;
36605
+ if (!accepted || accepted.outcome !== "accepted" || !accepted.target_id || !accepted.result_revision || !accepted.result_digest) {
36606
+ throw new TodosProjectRegistrationError("TODOS_PROJECT_REGISTRATION_ADOPTION_REJECTED", "source receipt does not resolve to one complete accepted receipt", { receipt_id: sourceReceipt.receipt_id });
36607
+ }
36608
+ const receiptLineageMatches = (receipt) => receipt.authority === "todos" && receipt.route === sourceRequest.authority_route && receipt.package_version === sourceRequest.package_version && receipt.authority_id === sourceRequest.authority_id && receipt.tenant_id === sourceRequest.tenant_id && receipt.corpus_id === sourceRequest.corpus_id && receipt.operation_id === sourceRequest.operation_id && receipt.step_id === sourceRequest.step_id && receipt.resource_kind === sourceRequest.resource_kind && receipt.direction === "forward" && receipt.target_selector === sourceRequest.target_selector && receipt.idempotency_key === sourceRequest.idempotency_key && receipt.request_digest === sourceRequest.request_digest && receipt.precondition_digest === sourceRequest.precondition_digest && acceptedCallMatches(sourceRequest, receipt);
36609
+ if (!receiptLineageMatches(storedSource) || !receiptLineageMatches(accepted) || storedSource.outcome === "duplicate_of_accepted" && (storedSource.duplicate_of_receipt_id !== accepted.receipt_id || storedSource.target_id !== accepted.target_id || storedSource.result_revision !== accepted.result_revision || storedSource.result_digest !== accepted.result_digest)) {
36610
+ throw new TodosProjectRegistrationError("TODOS_PROJECT_REGISTRATION_ADOPTION_REJECTED", "receipt authority, operation, request, precondition, or normalized-call lineage changed");
36611
+ }
36612
+ const binding = await transaction.getBinding(authorityScope(this.capabilityValue), sourceRequest.resource_kind, sourceRequest.target_selector);
36613
+ if (!binding || binding.state !== "accepted" || binding.operation_id !== sourceRequest.operation_id || binding.step_id !== sourceRequest.step_id || binding.direction !== "forward" || binding.idempotency_key !== sourceRequest.idempotency_key || binding.request_digest !== sourceRequest.request_digest || binding.precondition_digest !== sourceRequest.precondition_digest || binding.normalized_call_digest !== accepted.normalized_call_digest || binding.target_id !== accepted.target_id || binding.accepted_receipt_id !== accepted.receipt_id || binding.result_revision !== accepted.result_revision || binding.result_digest !== accepted.result_digest) {
36614
+ throw new TodosProjectRegistrationError("TODOS_PROJECT_REGISTRATION_ADOPTION_REJECTED", "accepted binding does not match the exact accepted receipt and source lineage");
36615
+ }
36616
+ const current = sourceRequest.resource_kind === "project" ? await transaction.getProject(accepted.target_id) : await transaction.getTaskList(accepted.target_id);
36617
+ if (!current || !canonicalValuesEqual(current, currentRecord) || current.id !== accepted.target_id || current.created_at !== accepted.result_revision) {
36618
+ throw new TodosProjectRegistrationError("TODOS_PROJECT_REGISTRATION_ADOPTION_REJECTED", "current record does not match the accepted target incarnation", { target_id: accepted.target_id });
36619
+ }
36620
+ let stableMatch = false;
36621
+ if (sourceRequest.resource_kind === "task_list") {
36622
+ stableMatch = taskListRegistrationDigest({
36623
+ ...current,
36624
+ updated_at: accepted.result_revision
36625
+ }) === accepted.result_digest;
36626
+ } else {
36627
+ const project = current;
36628
+ if (!Number.isSafeInteger(project.task_counter) || project.task_counter < 0) {
36629
+ throw new TodosProjectRegistrationError("TODOS_PROJECT_REGISTRATION_ADOPTION_REJECTED", "current project task counter is not a valid monotonic registration field");
36630
+ }
36631
+ for (let priorTaskCounter = 0;priorTaskCounter <= project.task_counter; priorTaskCounter += 1) {
36632
+ if (Date.now() - startedAt > sourceRequest.time_budget_ms) {
36633
+ throw new TodosProjectRegistrationError("TODOS_PROJECT_REGISTRATION_TIME_BUDGET_EXCEEDED", "prior registration adoption validation exceeded its time budget");
36634
+ }
36635
+ if (projectRegistrationDigest({
36636
+ ...project,
36637
+ task_counter: priorTaskCounter,
36638
+ updated_at: accepted.result_revision
36639
+ }) === accepted.result_digest) {
36640
+ stableMatch = true;
36641
+ break;
36642
+ }
36643
+ }
36644
+ }
36645
+ if (!stableMatch) {
36646
+ throw new TodosProjectRegistrationError("TODOS_PROJECT_REGISTRATION_ADOPTION_REJECTED", "stable project-registration fields changed after the accepted receipt", { target_id: accepted.target_id });
36647
+ }
36648
+ return {
36649
+ valid: true,
36650
+ resource_kind: sourceRequest.resource_kind,
36651
+ target_id: accepted.target_id,
36652
+ source_receipt_id: storedSource.receipt_id,
36653
+ accepted_receipt_id: accepted.receipt_id,
36654
+ source_outcome: storedSource.outcome,
36655
+ created_at: current.created_at,
36656
+ current_revision: current.updated_at,
36657
+ accepted_result_digest: accepted.result_digest
36658
+ };
36659
+ });
36660
+ assertWithinBounds(validation, sourceRequest, startedAt);
36661
+ return validation;
36662
+ }
35959
36663
  async storedAcceptedReceipt(request, supplied) {
35960
36664
  const stored = await this.backend.getReceiptById(supplied.receipt_id);
35961
36665
  if (!stored || stored.outcome !== "accepted" || !stored.created_by_operation || canonicalProjectRegistrationJson(publicReceipt(stored)) !== canonicalProjectRegistrationJson(supplied)) {
@@ -35982,6 +36686,8 @@ class PackageOwnedTodosProjectRegistrationAuthority {
35982
36686
  });
35983
36687
  const exact = await transaction.getReceiptForLookup({
35984
36688
  ...authorityScope(this.capabilityValue),
36689
+ route: this.capabilityValue.route,
36690
+ package_version: this.capabilityValue.package_version,
35985
36691
  operation_id: request.operation_id,
35986
36692
  step_id: request.step_id,
35987
36693
  resource_kind: request.resource_kind,
@@ -36076,6 +36782,8 @@ class PackageOwnedTodosProjectRegistrationAuthority {
36076
36782
  await this.storedAcceptedReceipt(request, accepted);
36077
36783
  const receipt = await this.backend.getReceiptForLookup({
36078
36784
  ...authorityScope(this.capabilityValue),
36785
+ route: this.capabilityValue.route,
36786
+ package_version: this.capabilityValue.package_version,
36079
36787
  operation_id: request.operation_id,
36080
36788
  step_id: request.step_id,
36081
36789
  resource_kind: request.resource_kind,
@@ -36123,6 +36831,65 @@ function createPostgresTodosProjectRegistrationAuthority(client, options = {}) {
36123
36831
  cursorTableName
36124
36832
  }), authorityOptions);
36125
36833
  }
36834
+ // src/project-registration/adoption-validation.ts
36835
+ var VALIDATION_KEYS = [
36836
+ "valid",
36837
+ "resource_kind",
36838
+ "target_id",
36839
+ "source_receipt_id",
36840
+ "accepted_receipt_id",
36841
+ "source_outcome",
36842
+ "created_at",
36843
+ "current_revision",
36844
+ "accepted_result_digest"
36845
+ ];
36846
+ function isRecord2(value) {
36847
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
36848
+ }
36849
+ function isNonEmptyString(value) {
36850
+ return typeof value === "string" && value.length > 0;
36851
+ }
36852
+ function hasExactKeys(value, expected) {
36853
+ const actual = Object.keys(value).sort();
36854
+ const wanted = [...expected].sort();
36855
+ return actual.length === wanted.length && actual.every((key, index) => key === wanted[index]);
36856
+ }
36857
+ function adoptionRejected(message) {
36858
+ throw new TodosProjectRegistrationError("TODOS_PROJECT_REGISTRATION_ADOPTION_REJECTED", `TODOS_PROJECT_REGISTRATION_ADOPTION_REJECTED: ${message}`);
36859
+ }
36860
+ function assertTodosPriorRegistrationAdoptionValidationEnvelope(value, input) {
36861
+ if (!isRecord2(input) || !hasExactKeys(input, [
36862
+ "source_request",
36863
+ "source_receipt",
36864
+ "current_record"
36865
+ ])) {
36866
+ adoptionRejected("prior-adoption validation input is incomplete");
36867
+ }
36868
+ const request = input["source_request"];
36869
+ const receipt = input["source_receipt"];
36870
+ const current = input["current_record"];
36871
+ if (!isRecord2(request) || !isRecord2(receipt) || !isRecord2(current)) {
36872
+ adoptionRejected("prior-adoption validation input records are incomplete");
36873
+ }
36874
+ const resourceKind = request["resource_kind"];
36875
+ const sourceOutcome = receipt["outcome"];
36876
+ const acceptedReceiptId = sourceOutcome === "accepted" ? receipt["receipt_id"] : sourceOutcome === "duplicate_of_accepted" ? receipt["duplicate_of_receipt_id"] : null;
36877
+ if (resourceKind !== "project" && resourceKind !== "task_list" || request["direction"] !== "forward" || sourceOutcome !== "accepted" && sourceOutcome !== "duplicate_of_accepted" || !isNonEmptyString(acceptedReceiptId) || !isNonEmptyString(receipt["receipt_id"]) || !isNonEmptyString(receipt["target_id"]) || !isNonEmptyString(receipt["result_revision"]) || !isNonEmptyString(receipt["result_digest"]) || !isNonEmptyString(current["id"]) || !isNonEmptyString(current["created_at"]) || !isNonEmptyString(current["updated_at"]) || receipt["authority"] !== "todos" || receipt["route"] !== request["authority_route"] || receipt["package_version"] !== request["package_version"] || receipt["authority_id"] !== request["authority_id"] || receipt["tenant_id"] !== request["tenant_id"] || receipt["corpus_id"] !== request["corpus_id"] || receipt["operation_id"] !== request["operation_id"] || receipt["step_id"] !== request["step_id"] || receipt["resource_kind"] !== resourceKind || receipt["direction"] !== "forward" || receipt["idempotency_key"] !== request["idempotency_key"] || receipt["request_digest"] !== request["request_digest"] || receipt["precondition_digest"] !== request["precondition_digest"] || receipt["accepted_receipt_id"] !== null || receipt["target_id"] !== current["id"] || receipt["result_revision"] !== current["created_at"]) {
36878
+ adoptionRejected("prior-adoption validation input does not carry one complete accepted receipt and current target incarnation");
36879
+ }
36880
+ if (sourceOutcome === "accepted" && receipt["duplicate_of_receipt_id"] !== null || sourceOutcome === "duplicate_of_accepted" && receipt["duplicate_of_receipt_id"] !== acceptedReceiptId) {
36881
+ adoptionRejected("prior-adoption validation source receipt lineage is incomplete");
36882
+ }
36883
+ if (!isRecord2(value) || !hasExactKeys(value, ["validation"]) || !isRecord2(value["validation"]) || !hasExactKeys(value["validation"], VALIDATION_KEYS)) {
36884
+ adoptionRejected("prior-adoption validation response envelope is incomplete");
36885
+ }
36886
+ const validation = value["validation"];
36887
+ if (validation["valid"] !== true || validation["resource_kind"] !== resourceKind || validation["target_id"] !== current["id"] || validation["source_receipt_id"] !== receipt["receipt_id"] || validation["accepted_receipt_id"] !== acceptedReceiptId || validation["source_outcome"] !== sourceOutcome || validation["created_at"] !== current["created_at"] || validation["current_revision"] !== current["updated_at"] || validation["accepted_result_digest"] !== receipt["result_digest"]) {
36888
+ adoptionRejected("prior-adoption validation response does not prove the exact accepted receipt and current target");
36889
+ }
36890
+ return validation;
36891
+ }
36892
+
36126
36893
  // src/project-registration/http.ts
36127
36894
  var JSON_HEADERS = { "Content-Type": "application/json" };
36128
36895
  function json(body, status = 200) {
@@ -36169,6 +36936,20 @@ async function handleTodosProjectRegistrationHttpRequest(req, url, authority, ba
36169
36936
  if ((action === "" || action === "capability") && method === "GET") {
36170
36937
  return json({ capability: await authority.capability() });
36171
36938
  }
36939
+ if (action === "resources" && method === "GET") {
36940
+ const sourceProjectId = url.searchParams.get("source_project_id");
36941
+ const limit = Number(url.searchParams.get("limit") ?? "100");
36942
+ const includeAnchorsRaw = url.searchParams.get("include_anchors");
36943
+ const includeAnchors = includeAnchorsRaw === null ? false : includeAnchorsRaw === "true" ? true : includeAnchorsRaw === "false" ? false : includeAnchorsRaw;
36944
+ return json({
36945
+ page: await authority.listProjectResources({
36946
+ source_project_id: sourceProjectId,
36947
+ limit,
36948
+ include_anchors: includeAnchors,
36949
+ cursor: url.searchParams.get("cursor") ?? undefined
36950
+ })
36951
+ });
36952
+ }
36172
36953
  if (method !== "POST")
36173
36954
  return json({ error: "method not allowed" }, 405);
36174
36955
  const body = await readJson(req);
@@ -36191,6 +36972,12 @@ async function handleTodosProjectRegistrationHttpRequest(req, url, authority, ba
36191
36972
  record: await authority.readExact(body)
36192
36973
  });
36193
36974
  }
36975
+ if (action === "validate-prior-adoption") {
36976
+ const input = body;
36977
+ return json({
36978
+ validation: await authority.validatePriorRegistrationAdoption(input.source_request, input.source_receipt, input.current_record)
36979
+ });
36980
+ }
36194
36981
  if (action === "compensate") {
36195
36982
  return json({
36196
36983
  receipt: await authority.compensate(body)
@@ -36265,6 +37052,28 @@ class TodosProjectRegistrationHttpClient {
36265
37052
  async lookupReceipt(request) {
36266
37053
  return this.request("/receipts/lookup", { method: "POST", body: JSON.stringify(request) });
36267
37054
  }
37055
+ async listProjectResources(request) {
37056
+ const query = new URLSearchParams({
37057
+ source_project_id: request.source_project_id,
37058
+ limit: String(request.limit),
37059
+ include_anchors: String(request.include_anchors === true),
37060
+ ...request.cursor ? { cursor: request.cursor } : {}
37061
+ });
37062
+ const body = await this.request(`/resources?${query.toString()}`);
37063
+ return body.page;
37064
+ }
37065
+ async validatePriorRegistrationAdoption(sourceRequest, sourceReceipt, currentRecord) {
37066
+ const input = {
37067
+ source_request: withoutTarget(sourceRequest),
37068
+ source_receipt: sourceReceipt,
37069
+ current_record: currentRecord
37070
+ };
37071
+ const body = await this.request("/validate-prior-adoption", {
37072
+ method: "POST",
37073
+ body: JSON.stringify(input)
37074
+ });
37075
+ return assertTodosPriorRegistrationAdoptionValidationEnvelope(body, input);
37076
+ }
36268
37077
  async compensate(request) {
36269
37078
  const body = await this.request("/compensate", { method: "POST", body: JSON.stringify(withoutTarget(request)) });
36270
37079
  return body.receipt;
@@ -40258,7 +41067,9 @@ init_types();
40258
41067
 
40259
41068
  // src/task-manifest/types.ts
40260
41069
  var TODOS_TASK_MANIFEST_ROUTE = "todos.task-manifest.v1";
41070
+ var TODOS_TASK_MANIFEST_CALLER_ROUTE = "accounts.task-manifest.v1";
40261
41071
  var TODOS_TASK_MANIFEST_SCHEMA_VERSION = 1;
41072
+ var TODOS_TASK_MANIFEST_PLAN_SLUG_PROVENANCE = "deterministic-v1";
40262
41073
  function supportsIdempotentOutboxDelivery(capability2) {
40263
41074
  return capability2 !== null && typeof capability2 === "object" && capability2["idempotent_outbox_delivery"] === true;
40264
41075
  }
@@ -40288,6 +41099,8 @@ var TODOS_TASK_MANIFEST_BOUNDS = {
40288
41099
  };
40289
41100
  var key = exports_external.string().min(1).max(96).regex(/^[a-z][a-z0-9_-]*$/);
40290
41101
  var identifier = exports_external.string().min(1).max(200).regex(/^[A-Za-z0-9][A-Za-z0-9._:-]*$/);
41102
+ var digest = exports_external.string().length(64).regex(/^[0-9a-f]{64}$/);
41103
+ var idempotencyKey = exports_external.string().length(52).regex(/^tmk_[0-9a-f]{48}$/);
40291
41104
  var uuid2 = exports_external.string().uuid();
40292
41105
  var scalar = exports_external.union([exports_external.string().max(4096), exports_external.number().finite(), exports_external.boolean(), exports_external.null()]);
40293
41106
  var boundedScalarRecord = (limit, field2) => exports_external.record(exports_external.string().max(200), scalar).superRefine((value, context) => {
@@ -40330,7 +41143,9 @@ var effect = exports_external.object({
40330
41143
  var schema = exports_external.object({
40331
41144
  version: exports_external.literal(1),
40332
41145
  operation_id: identifier,
40333
- idempotency_key: identifier,
41146
+ step_id: identifier,
41147
+ idempotency_key: idempotencyKey,
41148
+ precondition_digest: digest,
40334
41149
  project_id: uuid2,
40335
41150
  task_list_id: uuid2.optional(),
40336
41151
  if_binding_version: exports_external.number().int().min(0).optional(),
@@ -40346,7 +41161,10 @@ var schema = exports_external.object({
40346
41161
  }).strict();
40347
41162
  var compensationSchema = exports_external.object({
40348
41163
  receipt_id: uuid2,
40349
- idempotency_key: identifier,
41164
+ operation_id: identifier,
41165
+ step_id: identifier,
41166
+ idempotency_key: idempotencyKey,
41167
+ precondition_digest: digest,
40350
41168
  if_binding_version: exports_external.number().int().min(1).max(Number.MAX_SAFE_INTEGER)
40351
41169
  }).strict();
40352
41170
  var bindingLookupSchema = exports_external.object({
@@ -40431,10 +41249,31 @@ function parseTodosTaskManifestBindingLookup(input) {
40431
41249
  }
40432
41250
 
40433
41251
  // src/task-manifest/plan-slug.ts
41252
+ var TASK_MANIFEST_DETERMINISTIC_SLUG_PROVENANCE = "deterministic-v1";
40434
41253
  function taskManifestPlanSlug(manifest, planId) {
40435
41254
  const base = normalizeSlug(manifest.plan.key) || normalizeSlug(manifest.plan.name) || "plan";
40436
41255
  return `${base}-${planId}`;
40437
41256
  }
41257
+ function sqliteLegacyTaskManifestPlanSlug(rows, planId, targetBase) {
41258
+ const target = rows.find((row) => row.id === planId);
41259
+ if (!target)
41260
+ return null;
41261
+ const used = new Set;
41262
+ const ordered = [...rows].filter((row) => row.project_id === target.project_id).sort((left, right) => left.created_at.localeCompare(right.created_at) || left.id.localeCompare(right.id));
41263
+ for (const row of ordered) {
41264
+ const base = row.id === planId ? normalizeSlug(targetBase ?? row.name) || "plan" : normalizeSlug(row.slug || row.name) || "plan";
41265
+ let candidate = base;
41266
+ let suffix = 2;
41267
+ while (used.has(candidate)) {
41268
+ candidate = `${base}-${suffix}`;
41269
+ suffix += 1;
41270
+ }
41271
+ if (row.id === planId)
41272
+ return candidate;
41273
+ used.add(candidate);
41274
+ }
41275
+ return null;
41276
+ }
40438
41277
 
40439
41278
  // src/task-manifest/backend.ts
40440
41279
  var UUID_PATTERN2 = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
@@ -40448,11 +41287,13 @@ function validateTaskManifestBindingLookupRows(rows, tenantId, planId) {
40448
41287
  const row = rows[0];
40449
41288
  const bindingVersion = Number(row.binding_version);
40450
41289
  const state = row.state;
40451
- if (row.binding_tenant_id !== tenantId || row.receipt_tenant_id !== tenantId || row.binding_plan_id !== planId || row.receipt_plan_id !== planId || row.receipt_authority !== "todos" || row.receipt_route !== "todos.task-manifest.v1" || Number(row.receipt_schema_version) !== 1 || row.receipt_kind !== "apply" || row.binding_operation_id !== row.receipt_operation_id || typeof row.apply_receipt_id !== "string" || !UUID_PATTERN2.test(row.apply_receipt_id) || !Number.isSafeInteger(bindingVersion) || bindingVersion < 1 || state !== "applied" && state !== "compensated") {
41290
+ if (row.binding_tenant_id !== tenantId || row.receipt_tenant_id !== tenantId || row.binding_plan_id !== planId || row.receipt_plan_id !== planId || row.receipt_authority !== "todos" || row.receipt_route !== "todos.task-manifest.v1" || Number(row.receipt_schema_version) !== 1 || row.receipt_kind !== "apply" || row.binding_operation_id !== row.receipt_operation_id || row.binding_step_id !== row.receipt_step_id || typeof row.binding_operation_id !== "string" || typeof row.binding_step_id !== "string" || typeof row.apply_receipt_id !== "string" || !UUID_PATTERN2.test(row.apply_receipt_id) || !Number.isSafeInteger(bindingVersion) || bindingVersion < 1 || state !== "applied" && state !== "compensated") {
40452
41291
  throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_LOOKUP_CONFLICT", "Task-manifest binding and immutable apply receipt disagree", { plan_id: planId });
40453
41292
  }
40454
41293
  return {
40455
41294
  plan_id: planId,
41295
+ operation_id: row.binding_operation_id,
41296
+ step_id: row.binding_step_id,
40456
41297
  apply_receipt_id: row.apply_receipt_id,
40457
41298
  binding_version: bindingVersion,
40458
41299
  state
@@ -40560,9 +41401,15 @@ function sqliteTodosTaskManifestSchemaSql() {
40560
41401
  schema_version INTEGER NOT NULL CHECK(schema_version = 1),
40561
41402
  kind TEXT NOT NULL CHECK(kind IN ('apply', 'compensate')),
40562
41403
  operation_id TEXT NOT NULL,
41404
+ step_id TEXT NOT NULL,
40563
41405
  idempotency_key TEXT NOT NULL,
40564
41406
  request_digest TEXT NOT NULL,
41407
+ precondition_digest TEXT NOT NULL,
40565
41408
  result_digest TEXT NOT NULL,
41409
+ slug_provenance TEXT,
41410
+ outcome TEXT NOT NULL CHECK(outcome IN ('accepted', 'terminal_nonacceptance')),
41411
+ reason TEXT,
41412
+ duplicate_of_receipt_id TEXT,
40566
41413
  binding_version INTEGER NOT NULL,
40567
41414
  apply_receipt_id TEXT,
40568
41415
  manifest_json TEXT,
@@ -40573,9 +41420,13 @@ function sqliteTodosTaskManifestSchemaSql() {
40573
41420
  CREATE TABLE IF NOT EXISTS todos_task_manifest_bindings (
40574
41421
  operation_id TEXT PRIMARY KEY,
40575
41422
  tenant_id TEXT NOT NULL,
41423
+ step_id TEXT NOT NULL,
40576
41424
  idempotency_key TEXT NOT NULL UNIQUE,
40577
41425
  request_digest TEXT NOT NULL,
41426
+ precondition_digest TEXT NOT NULL,
40578
41427
  result_digest TEXT NOT NULL,
41428
+ slug_provenance TEXT,
41429
+ outcome TEXT NOT NULL CHECK(outcome IN ('accepted', 'terminal_nonacceptance')),
40579
41430
  apply_receipt_id TEXT NOT NULL UNIQUE REFERENCES todos_task_manifest_receipts(receipt_id),
40580
41431
  manifest_json TEXT NOT NULL,
40581
41432
  result_json TEXT NOT NULL,
@@ -40596,6 +41447,27 @@ function sqliteTodosTaskManifestSchemaSql() {
40596
41447
  created_at TEXT NOT NULL,
40597
41448
  delivered_at TEXT
40598
41449
  );
41450
+ CREATE TABLE IF NOT EXISTS todos_task_manifest_terminal_receipts (
41451
+ receipt_id TEXT PRIMARY KEY,
41452
+ tenant_id TEXT NOT NULL,
41453
+ authority TEXT NOT NULL CHECK(authority = 'todos'),
41454
+ route TEXT NOT NULL,
41455
+ schema_version INTEGER NOT NULL CHECK(schema_version = 1),
41456
+ kind TEXT NOT NULL CHECK(kind IN ('apply', 'compensate')),
41457
+ operation_id TEXT NOT NULL,
41458
+ step_id TEXT NOT NULL,
41459
+ idempotency_key TEXT NOT NULL,
41460
+ request_digest TEXT NOT NULL,
41461
+ precondition_digest TEXT NOT NULL,
41462
+ result_digest TEXT NOT NULL,
41463
+ outcome TEXT NOT NULL CHECK(outcome = 'terminal_nonacceptance'),
41464
+ reason TEXT NOT NULL,
41465
+ binding_version INTEGER NOT NULL,
41466
+ apply_receipt_id TEXT,
41467
+ manifest_json TEXT,
41468
+ result_json TEXT NOT NULL,
41469
+ created_at TEXT NOT NULL
41470
+ );
40599
41471
  CREATE INDEX IF NOT EXISTS idx_todos_task_manifest_outbox_receipt
40600
41472
  ON todos_task_manifest_outbox(apply_receipt_id, status);
40601
41473
  CREATE TRIGGER IF NOT EXISTS todos_task_manifest_receipts_immutable_update
@@ -40606,6 +41478,14 @@ function sqliteTodosTaskManifestSchemaSql() {
40606
41478
  BEFORE DELETE ON todos_task_manifest_receipts BEGIN
40607
41479
  SELECT RAISE(ABORT, 'todos task manifest receipts are immutable');
40608
41480
  END;
41481
+ CREATE TRIGGER IF NOT EXISTS todos_task_manifest_terminal_receipts_immutable_update
41482
+ BEFORE UPDATE ON todos_task_manifest_terminal_receipts BEGIN
41483
+ SELECT RAISE(ABORT, 'todos task manifest terminal receipts are immutable');
41484
+ END;
41485
+ CREATE TRIGGER IF NOT EXISTS todos_task_manifest_terminal_receipts_immutable_delete
41486
+ BEFORE DELETE ON todos_task_manifest_terminal_receipts BEGIN
41487
+ SELECT RAISE(ABORT, 'todos task manifest terminal receipts are immutable');
41488
+ END;
40609
41489
  `;
40610
41490
  }
40611
41491
  function sqliteTableHasColumn(db, tableName, columnName) {
@@ -40622,6 +41502,24 @@ function ensureSqliteTodosTaskManifestSchema(db, tenantId) {
40622
41502
  if (!sqliteTableHasColumn(db, tableName, "tenant_id")) {
40623
41503
  db.exec(`ALTER TABLE "${tableName}" ADD COLUMN tenant_id TEXT NOT NULL DEFAULT ${tenantDefault}`);
40624
41504
  }
41505
+ if (!sqliteTableHasColumn(db, tableName, "slug_provenance")) {
41506
+ db.exec(`ALTER TABLE "${tableName}" ADD COLUMN slug_provenance TEXT`);
41507
+ }
41508
+ }
41509
+ const defaults = [
41510
+ ["todos_task_manifest_receipts", "step_id", "TEXT NOT NULL DEFAULT 'legacy-apply'"],
41511
+ ["todos_task_manifest_receipts", "precondition_digest", `TEXT NOT NULL DEFAULT '${"0".repeat(64)}'`],
41512
+ ["todos_task_manifest_receipts", "outcome", "TEXT NOT NULL DEFAULT 'accepted'"],
41513
+ ["todos_task_manifest_receipts", "reason", "TEXT"],
41514
+ ["todos_task_manifest_receipts", "duplicate_of_receipt_id", "TEXT"],
41515
+ ["todos_task_manifest_bindings", "step_id", "TEXT NOT NULL DEFAULT 'legacy-apply'"],
41516
+ ["todos_task_manifest_bindings", "precondition_digest", `TEXT NOT NULL DEFAULT '${"0".repeat(64)}'`],
41517
+ ["todos_task_manifest_bindings", "outcome", "TEXT NOT NULL DEFAULT 'accepted'"]
41518
+ ];
41519
+ for (const [tableName, columnName, definition] of defaults) {
41520
+ if (!sqliteTableHasColumn(db, tableName, columnName)) {
41521
+ db.exec(`ALTER TABLE "${tableName}" ADD COLUMN "${columnName}" ${definition}`);
41522
+ }
40625
41523
  }
40626
41524
  db.exec(`
40627
41525
  CREATE INDEX IF NOT EXISTS idx_todos_task_manifest_receipts_tenant
@@ -40631,6 +41529,12 @@ function ensureSqliteTodosTaskManifestSchema(db, tenantId) {
40631
41529
  tenant_id,
40632
41530
  json_extract(result_json, '$.graph.plan_id')
40633
41531
  );
41532
+ DROP INDEX IF EXISTS idx_todos_task_manifest_terminal_receipts_lookup;
41533
+ DROP INDEX IF EXISTS idx_todos_task_manifest_terminal_receipts_identity;
41534
+ CREATE INDEX IF NOT EXISTS idx_todos_task_manifest_terminal_receipts_lookup
41535
+ ON todos_task_manifest_terminal_receipts(tenant_id, kind, operation_id, step_id);
41536
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_todos_task_manifest_terminal_receipts_identity
41537
+ ON todos_task_manifest_terminal_receipts(tenant_id, kind, operation_id, step_id);
40634
41538
  `);
40635
41539
  }
40636
41540
  function postgresTodosTaskManifestSchemaSql(tenantId = "default") {
@@ -40644,9 +41548,15 @@ function postgresTodosTaskManifestSchemaSql(tenantId = "default") {
40644
41548
  schema_version integer NOT NULL CHECK(schema_version = 1),
40645
41549
  kind text NOT NULL CHECK(kind IN ('apply', 'compensate')),
40646
41550
  operation_id text NOT NULL,
41551
+ step_id text NOT NULL,
40647
41552
  idempotency_key text NOT NULL,
40648
41553
  request_digest text NOT NULL,
41554
+ precondition_digest text NOT NULL,
40649
41555
  result_digest text NOT NULL,
41556
+ slug_provenance text,
41557
+ outcome text NOT NULL CHECK(outcome IN ('accepted', 'terminal_nonacceptance')),
41558
+ reason text,
41559
+ duplicate_of_receipt_id text,
40650
41560
  binding_version integer NOT NULL,
40651
41561
  apply_receipt_id text,
40652
41562
  manifest_json jsonb,
@@ -40658,12 +41568,28 @@ function postgresTodosTaskManifestSchemaSql(tenantId = "default") {
40658
41568
  ADD COLUMN IF NOT EXISTS tenant_id text NOT NULL DEFAULT ${tenantDefault}`,
40659
41569
  `ALTER TABLE todos_task_manifest_receipts
40660
41570
  ALTER COLUMN tenant_id DROP DEFAULT`,
41571
+ `ALTER TABLE todos_task_manifest_receipts
41572
+ ADD COLUMN IF NOT EXISTS slug_provenance text`,
41573
+ `ALTER TABLE todos_task_manifest_receipts
41574
+ ADD COLUMN IF NOT EXISTS step_id text NOT NULL DEFAULT 'legacy-apply'`,
41575
+ `ALTER TABLE todos_task_manifest_receipts
41576
+ ADD COLUMN IF NOT EXISTS precondition_digest text NOT NULL DEFAULT '${"0".repeat(64)}'`,
41577
+ `ALTER TABLE todos_task_manifest_receipts
41578
+ ADD COLUMN IF NOT EXISTS outcome text NOT NULL DEFAULT 'accepted'`,
41579
+ `ALTER TABLE todos_task_manifest_receipts
41580
+ ADD COLUMN IF NOT EXISTS reason text`,
41581
+ `ALTER TABLE todos_task_manifest_receipts
41582
+ ADD COLUMN IF NOT EXISTS duplicate_of_receipt_id text`,
40661
41583
  `CREATE TABLE IF NOT EXISTS todos_task_manifest_bindings (
40662
41584
  operation_id text PRIMARY KEY,
40663
41585
  tenant_id text NOT NULL,
41586
+ step_id text NOT NULL,
40664
41587
  idempotency_key text NOT NULL UNIQUE,
40665
41588
  request_digest text NOT NULL,
41589
+ precondition_digest text NOT NULL,
40666
41590
  result_digest text NOT NULL,
41591
+ slug_provenance text,
41592
+ outcome text NOT NULL CHECK(outcome IN ('accepted', 'terminal_nonacceptance')),
40667
41593
  apply_receipt_id text NOT NULL UNIQUE REFERENCES todos_task_manifest_receipts(receipt_id),
40668
41594
  manifest_json jsonb NOT NULL,
40669
41595
  result_json jsonb NOT NULL,
@@ -40677,6 +41603,14 @@ function postgresTodosTaskManifestSchemaSql(tenantId = "default") {
40677
41603
  ADD COLUMN IF NOT EXISTS tenant_id text NOT NULL DEFAULT ${tenantDefault}`,
40678
41604
  `ALTER TABLE todos_task_manifest_bindings
40679
41605
  ALTER COLUMN tenant_id DROP DEFAULT`,
41606
+ `ALTER TABLE todos_task_manifest_bindings
41607
+ ADD COLUMN IF NOT EXISTS slug_provenance text`,
41608
+ `ALTER TABLE todos_task_manifest_bindings
41609
+ ADD COLUMN IF NOT EXISTS step_id text NOT NULL DEFAULT 'legacy-apply'`,
41610
+ `ALTER TABLE todos_task_manifest_bindings
41611
+ ADD COLUMN IF NOT EXISTS precondition_digest text NOT NULL DEFAULT '${"0".repeat(64)}'`,
41612
+ `ALTER TABLE todos_task_manifest_bindings
41613
+ ADD COLUMN IF NOT EXISTS outcome text NOT NULL DEFAULT 'accepted'`,
40680
41614
  `CREATE TABLE IF NOT EXISTS todos_task_manifest_outbox (
40681
41615
  id text PRIMARY KEY,
40682
41616
  apply_receipt_id text NOT NULL REFERENCES todos_task_manifest_receipts(receipt_id),
@@ -40688,10 +41622,37 @@ function postgresTodosTaskManifestSchemaSql(tenantId = "default") {
40688
41622
  created_at timestamptz NOT NULL,
40689
41623
  delivered_at timestamptz
40690
41624
  )`,
41625
+ `CREATE TABLE IF NOT EXISTS todos_task_manifest_terminal_receipts (
41626
+ receipt_id text PRIMARY KEY,
41627
+ tenant_id text NOT NULL,
41628
+ authority text NOT NULL CHECK(authority = 'todos'),
41629
+ route text NOT NULL,
41630
+ schema_version integer NOT NULL CHECK(schema_version = 1),
41631
+ kind text NOT NULL CHECK(kind IN ('apply', 'compensate')),
41632
+ operation_id text NOT NULL,
41633
+ step_id text NOT NULL,
41634
+ idempotency_key text NOT NULL,
41635
+ request_digest text NOT NULL,
41636
+ precondition_digest text NOT NULL,
41637
+ result_digest text NOT NULL,
41638
+ outcome text NOT NULL CHECK(outcome = 'terminal_nonacceptance'),
41639
+ reason text NOT NULL,
41640
+ binding_version integer NOT NULL,
41641
+ apply_receipt_id text,
41642
+ manifest_json jsonb,
41643
+ result_json jsonb NOT NULL,
41644
+ created_at timestamptz NOT NULL
41645
+ )`,
40691
41646
  `CREATE INDEX IF NOT EXISTS todos_task_manifest_outbox_receipt_idx
40692
41647
  ON todos_task_manifest_outbox(apply_receipt_id, status)`,
40693
41648
  `CREATE INDEX IF NOT EXISTS todos_task_manifest_receipts_tenant_idx
40694
41649
  ON todos_task_manifest_receipts(tenant_id, receipt_id, kind)`,
41650
+ `DROP INDEX IF EXISTS todos_task_manifest_terminal_receipts_lookup_idx`,
41651
+ `DROP INDEX IF EXISTS todos_task_manifest_terminal_receipts_identity_idx`,
41652
+ `CREATE INDEX IF NOT EXISTS todos_task_manifest_terminal_receipts_lookup_idx
41653
+ ON todos_task_manifest_terminal_receipts(tenant_id, kind, operation_id, step_id)`,
41654
+ `CREATE UNIQUE INDEX IF NOT EXISTS todos_task_manifest_terminal_receipts_identity_idx
41655
+ ON todos_task_manifest_terminal_receipts(tenant_id, kind, operation_id, step_id)`,
40695
41656
  `CREATE INDEX IF NOT EXISTS todos_task_manifest_bindings_tenant_plan_idx
40696
41657
  ON todos_task_manifest_bindings(
40697
41658
  tenant_id,
@@ -40704,6 +41665,10 @@ function postgresTodosTaskManifestSchemaSql(tenantId = "default") {
40704
41665
  `DROP TRIGGER IF EXISTS todos_task_manifest_receipts_immutable ON todos_task_manifest_receipts`,
40705
41666
  `CREATE TRIGGER todos_task_manifest_receipts_immutable
40706
41667
  BEFORE UPDATE OR DELETE ON todos_task_manifest_receipts
41668
+ FOR EACH ROW EXECUTE FUNCTION todos_task_manifest_receipts_immutable()`,
41669
+ `DROP TRIGGER IF EXISTS todos_task_manifest_terminal_receipts_immutable ON todos_task_manifest_terminal_receipts`,
41670
+ `CREATE TRIGGER todos_task_manifest_terminal_receipts_immutable
41671
+ BEFORE UPDATE OR DELETE ON todos_task_manifest_terminal_receipts
40707
41672
  FOR EACH ROW EXECUTE FUNCTION todos_task_manifest_receipts_immutable()`
40708
41673
  ];
40709
41674
  }
@@ -40715,7 +41680,76 @@ function fault(faults, point) {
40715
41680
  throw new Error(`Injected task-manifest fault at ${point}`);
40716
41681
  }
40717
41682
  function parseApplyResult(value, duplicate) {
40718
- return { ...JSON.parse(value), duplicate };
41683
+ const parsed = JSON.parse(value);
41684
+ return {
41685
+ ...parsed,
41686
+ duplicate,
41687
+ receipt: {
41688
+ ...parsed.receipt,
41689
+ step_id: parsed.receipt.step_id ?? "legacy-apply",
41690
+ precondition_digest: parsed.receipt.precondition_digest ?? "0".repeat(64),
41691
+ outcome: parsed.receipt.outcome ?? "accepted",
41692
+ reason: parsed.receipt.reason ?? null,
41693
+ duplicate_of_receipt_id: parsed.receipt.duplicate_of_receipt_id ?? null
41694
+ }
41695
+ };
41696
+ }
41697
+ function terminalApplyResult(input, reason) {
41698
+ const receipt = {
41699
+ receipt_id: input.terminal_receipt_id,
41700
+ authority: "todos",
41701
+ route: "todos.task-manifest.v1",
41702
+ schema_version: 1,
41703
+ kind: "apply",
41704
+ operation_id: input.manifest.operation_id,
41705
+ step_id: input.manifest.step_id,
41706
+ idempotency_key: input.manifest.idempotency_key,
41707
+ request_digest: input.request_digest,
41708
+ precondition_digest: input.manifest.precondition_digest,
41709
+ result_digest: canonicalDigest({
41710
+ outcome: "terminal_nonacceptance",
41711
+ reason,
41712
+ operation_id: input.manifest.operation_id,
41713
+ step_id: input.manifest.step_id,
41714
+ request_digest: input.request_digest
41715
+ }),
41716
+ outcome: "terminal_nonacceptance",
41717
+ reason,
41718
+ duplicate_of_receipt_id: null,
41719
+ binding_version: 0,
41720
+ apply_receipt_id: null,
41721
+ created_at: input.now
41722
+ };
41723
+ return {
41724
+ duplicate: false,
41725
+ receipt,
41726
+ graph: input.graph,
41727
+ readback: { plans: 0, tasks: 0, dependencies: 0, comments: 0, verifications: 0, complete: true },
41728
+ outbox_ids: [],
41729
+ result_digest: receipt.result_digest
41730
+ };
41731
+ }
41732
+ function validateCompensationPlanSlug(db, manifest, planId, slugProvenance) {
41733
+ const plan = db.query("SELECT id, project_id, name, slug, created_at FROM plans WHERE id = ? LIMIT 1").get(planId);
41734
+ if (!plan) {
41735
+ throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_COMPENSATION_REFUSED", "Compensation refused: plan changed since apply");
41736
+ }
41737
+ if (slugProvenance === TASK_MANIFEST_DETERMINISTIC_SLUG_PROVENANCE) {
41738
+ if (plan.slug !== taskManifestPlanSlug(manifest, planId)) {
41739
+ throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_COMPENSATION_REFUSED", "Compensation refused: plan changed since apply");
41740
+ }
41741
+ return;
41742
+ }
41743
+ if (slugProvenance !== null && slugProvenance !== undefined) {
41744
+ throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_COMPENSATION_REFUSED", "Compensation refused: unknown plan slug provenance");
41745
+ }
41746
+ if (plan.slug === null)
41747
+ return;
41748
+ const rows = db.query("SELECT id, project_id, name, slug, created_at FROM plans WHERE project_id IS ? ORDER BY created_at ASC, id ASC").all(plan.project_id);
41749
+ const expected = sqliteLegacyTaskManifestPlanSlug(rows, planId, manifest.plan.key || manifest.plan.name);
41750
+ if (expected === null || plan.slug !== expected) {
41751
+ throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_COMPENSATION_REFUSED", "Compensation refused: legacy plan slug was not produced by SQLite allocation");
41752
+ }
40719
41753
  }
40720
41754
 
40721
41755
  class SqliteTodosTaskManifestBackend {
@@ -40755,33 +41789,49 @@ class SqliteTodosTaskManifestBackend {
40755
41789
  async apply(input, faults) {
40756
41790
  return this.serialized(() => {
40757
41791
  const { manifest } = input;
41792
+ const terminal = this.db.query(`SELECT result_json
41793
+ FROM todos_task_manifest_terminal_receipts
41794
+ WHERE tenant_id = ?
41795
+ AND kind = 'apply'
41796
+ AND (receipt_id = ? OR (operation_id = ? AND step_id = ?))
41797
+ ORDER BY created_at ASC, receipt_id ASC
41798
+ LIMIT 1`).get(this.tenantId, input.terminal_receipt_id, manifest.operation_id, manifest.step_id);
41799
+ if (terminal)
41800
+ return parseApplyResult(terminal.result_json, true);
40758
41801
  const binding = this.db.query("SELECT * FROM todos_task_manifest_bindings WHERE tenant_id = ? AND operation_id = ? LIMIT 1").get(this.tenantId, manifest.operation_id);
40759
41802
  if (binding) {
40760
- if (binding["idempotency_key"] !== manifest.idempotency_key || binding["request_digest"] !== input.request_digest) {
40761
- throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_IDEMPOTENCY_CONFLICT", "Operation is already bound to a different request");
41803
+ if (binding["idempotency_key"] !== manifest.idempotency_key || binding["request_digest"] !== input.request_digest || binding["step_id"] !== manifest.step_id || binding["precondition_digest"] !== manifest.precondition_digest) {
41804
+ return this.persistTerminal(input, "TODOS_TASK_MANIFEST_IDEMPOTENCY_CONFLICT");
41805
+ }
41806
+ if (binding["outcome"] === "terminal_nonacceptance") {
41807
+ const terminalResult = this.persistTerminal(input, "TODOS_TASK_MANIFEST_GRAPH_CONFLICT");
41808
+ return { ...terminalResult, duplicate: true };
40762
41809
  }
40763
41810
  if (binding["state"] !== "applied") {
40764
- throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_GRAPH_CONFLICT", "Operation was already compensated");
41811
+ return this.persistTerminal(input, "TODOS_TASK_MANIFEST_GRAPH_CONFLICT");
40765
41812
  }
40766
41813
  return parseApplyResult(String(binding["result_json"]), true);
40767
41814
  }
40768
41815
  const idempotency = this.db.query("SELECT operation_id, request_digest FROM todos_task_manifest_bindings WHERE tenant_id = ? AND idempotency_key = ? LIMIT 1").get(this.tenantId, manifest.idempotency_key);
40769
41816
  if (idempotency)
40770
- throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_IDEMPOTENCY_CONFLICT", "Idempotency key is already used by another operation");
41817
+ return this.persistTerminal(input, "TODOS_TASK_MANIFEST_IDEMPOTENCY_CONFLICT");
41818
+ if (manifest.idempotency_key !== input.expected_idempotency_key) {
41819
+ return this.persistTerminal(input, "TODOS_TASK_MANIFEST_IDEMPOTENCY_MISMATCH");
41820
+ }
40771
41821
  if (manifest.if_binding_version !== undefined && manifest.if_binding_version !== 0) {
40772
- throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_CAS_CONFLICT", "New manifest binding version must be 0");
41822
+ return this.persistTerminal(input, "TODOS_TASK_MANIFEST_CAS_CONFLICT");
40773
41823
  }
40774
41824
  if (!this.db.query("SELECT 1 AS found FROM projects WHERE id = ? LIMIT 1").get(manifest.project_id)) {
40775
- throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_FOREIGN_REFERENCE", "Project does not exist");
41825
+ return this.persistTerminal(input, "TODOS_TASK_MANIFEST_FOREIGN_REFERENCE");
40776
41826
  }
40777
41827
  if (manifest.task_list_id && !this.db.query("SELECT 1 AS found FROM task_lists WHERE id = ? AND project_id = ? LIMIT 1").get(manifest.task_list_id, manifest.project_id)) {
40778
- throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_FOREIGN_REFERENCE", "Task list does not belong to the project");
41828
+ return this.persistTerminal(input, "TODOS_TASK_MANIFEST_FOREIGN_REFERENCE");
40779
41829
  }
40780
41830
  const allIds = [input.graph.plan_id, ...Object.values(input.graph.task_ids), ...input.graph.comment_ids, ...input.graph.verification_ids];
40781
41831
  for (const id of allIds) {
40782
41832
  for (const table of ["plans", "tasks", "task_comments", "task_verifications"]) {
40783
41833
  if (this.db.query(`SELECT 1 AS found FROM ${table} WHERE id = ? LIMIT 1`).get(id)) {
40784
- throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_GRAPH_CONFLICT", `Deterministic id already exists: ${id}`);
41834
+ return this.persistTerminal(input, "TODOS_TASK_MANIFEST_GRAPH_CONFLICT");
40785
41835
  }
40786
41836
  }
40787
41837
  }
@@ -40837,9 +41887,14 @@ class SqliteTodosTaskManifestBackend {
40837
41887
  schema_version: 1,
40838
41888
  kind: "apply",
40839
41889
  operation_id: manifest.operation_id,
41890
+ step_id: manifest.step_id,
40840
41891
  idempotency_key: manifest.idempotency_key,
40841
41892
  request_digest: input.request_digest,
41893
+ precondition_digest: manifest.precondition_digest,
40842
41894
  result_digest: input.result_digest,
41895
+ outcome: "accepted",
41896
+ reason: null,
41897
+ duplicate_of_receipt_id: null,
40843
41898
  binding_version: 1,
40844
41899
  apply_receipt_id: null,
40845
41900
  created_at: input.now
@@ -40855,9 +41910,10 @@ class SqliteTodosTaskManifestBackend {
40855
41910
  const resultJson = canonicalJson(result);
40856
41911
  const manifestJson = canonicalJson(manifest);
40857
41912
  this.db.query(`INSERT INTO todos_task_manifest_receipts (
40858
- receipt_id, tenant_id, authority, route, schema_version, kind, operation_id, idempotency_key,
40859
- request_digest, result_digest, binding_version, apply_receipt_id, manifest_json, result_json, created_at
40860
- ) VALUES (?, ?, 'todos', 'todos.task-manifest.v1', 1, 'apply', ?, ?, ?, ?, 1, NULL, ?, ?, ?)`).run(input.receipt_id, this.tenantId, manifest.operation_id, manifest.idempotency_key, input.request_digest, input.result_digest, manifestJson, resultJson, input.now);
41913
+ receipt_id, tenant_id, authority, route, schema_version, kind, operation_id, step_id, idempotency_key,
41914
+ request_digest, precondition_digest, result_digest, slug_provenance, outcome, reason,
41915
+ duplicate_of_receipt_id, binding_version, apply_receipt_id, manifest_json, result_json, created_at
41916
+ ) VALUES (?, ?, 'todos', 'todos.task-manifest.v1', 1, 'apply', ?, ?, ?, ?, ?, ?, ?, 'accepted', NULL, NULL, 1, NULL, ?, ?, ?)`).run(input.receipt_id, this.tenantId, manifest.operation_id, manifest.step_id, manifest.idempotency_key, input.request_digest, manifest.precondition_digest, input.result_digest, TASK_MANIFEST_DETERMINISTIC_SLUG_PROVENANCE, manifestJson, resultJson, input.now);
40861
41917
  for (const entry2 of input.outbox) {
40862
41918
  this.db.query(`INSERT INTO todos_task_manifest_outbox (
40863
41919
  id, apply_receipt_id, topic, payload, payload_digest, status, created_at
@@ -40865,18 +41921,37 @@ class SqliteTodosTaskManifestBackend {
40865
41921
  }
40866
41922
  fault(faults, "after_outbox_write");
40867
41923
  this.db.query(`INSERT INTO todos_task_manifest_bindings (
40868
- operation_id, tenant_id, idempotency_key, request_digest, result_digest, apply_receipt_id,
40869
- manifest_json, result_json, state, version, created_at, updated_at
40870
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'applied', 1, ?, ?)`).run(manifest.operation_id, this.tenantId, manifest.idempotency_key, input.request_digest, input.result_digest, input.receipt_id, manifestJson, resultJson, input.now, input.now);
41924
+ operation_id, tenant_id, step_id, idempotency_key, request_digest, precondition_digest, result_digest,
41925
+ slug_provenance, outcome, apply_receipt_id, manifest_json, result_json, state, version, created_at, updated_at
41926
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'accepted', ?, ?, ?, 'applied', 1, ?, ?)`).run(manifest.operation_id, this.tenantId, manifest.step_id, manifest.idempotency_key, input.request_digest, manifest.precondition_digest, input.result_digest, TASK_MANIFEST_DETERMINISTIC_SLUG_PROVENANCE, input.receipt_id, manifestJson, resultJson, input.now, input.now);
40871
41927
  fault(faults, "after_receipt_write");
40872
41928
  return result;
40873
41929
  });
40874
41930
  }
41931
+ persistTerminal(input, reason) {
41932
+ const result = terminalApplyResult(input, reason);
41933
+ const resultJson = canonicalJson(result);
41934
+ this.db.query(`INSERT OR IGNORE INTO todos_task_manifest_terminal_receipts (
41935
+ receipt_id, tenant_id, authority, route, schema_version, kind, operation_id, step_id,
41936
+ idempotency_key, request_digest, precondition_digest, result_digest, outcome, reason,
41937
+ binding_version, apply_receipt_id, manifest_json, result_json, created_at
41938
+ ) VALUES (?, ?, 'todos', 'todos.task-manifest.v1', 1, 'apply', ?, ?, ?, ?, ?, ?, 'terminal_nonacceptance', ?, 0, NULL, ?, ?, ?)`).run(result.receipt.receipt_id, this.tenantId, input.manifest.operation_id, input.manifest.step_id, input.manifest.idempotency_key, input.request_digest, input.manifest.precondition_digest, result.receipt.result_digest, reason, canonicalJson(input.manifest), resultJson, input.now);
41939
+ const stored = this.db.query(`SELECT receipt_id, result_json
41940
+ FROM todos_task_manifest_terminal_receipts
41941
+ WHERE tenant_id = ? AND kind = 'apply'
41942
+ AND (receipt_id = ? OR (operation_id = ? AND step_id = ?))
41943
+ ORDER BY created_at ASC, receipt_id ASC
41944
+ LIMIT 1`).get(this.tenantId, result.receipt.receipt_id, input.manifest.operation_id, input.manifest.step_id);
41945
+ return stored ? parseApplyResult(stored.result_json, stored.receipt_id !== result.receipt.receipt_id) : result;
41946
+ }
40875
41947
  async readExact(receiptId2) {
40876
41948
  const row = this.db.query("SELECT result_json FROM todos_task_manifest_receipts WHERE tenant_id = ? AND receipt_id = ? AND kind = 'apply' LIMIT 1").get(this.tenantId, receiptId2);
40877
- if (!row)
41949
+ if (row)
41950
+ return parseApplyResult(row.result_json, false);
41951
+ const terminal = this.db.query("SELECT result_json FROM todos_task_manifest_terminal_receipts WHERE tenant_id = ? AND receipt_id = ? AND kind = 'apply' LIMIT 1").get(this.tenantId, receiptId2);
41952
+ if (!terminal)
40878
41953
  throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_RECEIPT_NOT_FOUND", `Apply receipt not found: ${receiptId2}`);
40879
- return parseApplyResult(row.result_json, false);
41954
+ return parseApplyResult(terminal.result_json, false);
40880
41955
  }
40881
41956
  async lookupBindingByPlanId(planId) {
40882
41957
  const rows = this.db.query(`
@@ -40886,6 +41961,7 @@ class SqliteTodosTaskManifestBackend {
40886
41961
  b.version AS binding_version,
40887
41962
  b.tenant_id AS binding_tenant_id,
40888
41963
  b.operation_id AS binding_operation_id,
41964
+ b.step_id AS binding_step_id,
40889
41965
  json_extract(b.result_json, '$.graph.plan_id') AS binding_plan_id,
40890
41966
  r.tenant_id AS receipt_tenant_id,
40891
41967
  r.authority AS receipt_authority,
@@ -40893,6 +41969,7 @@ class SqliteTodosTaskManifestBackend {
40893
41969
  r.schema_version AS receipt_schema_version,
40894
41970
  r.kind AS receipt_kind,
40895
41971
  r.operation_id AS receipt_operation_id,
41972
+ r.step_id AS receipt_step_id,
40896
41973
  json_extract(r.result_json, '$.graph.plan_id') AS receipt_plan_id
40897
41974
  FROM todos_task_manifest_bindings b
40898
41975
  LEFT JOIN todos_task_manifest_receipts r
@@ -40955,6 +42032,12 @@ class SqliteTodosTaskManifestBackend {
40955
42032
  if (!binding || Number(binding["version"]) !== input.if_binding_version) {
40956
42033
  throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_CAS_CONFLICT", "Binding version changed before compensation");
40957
42034
  }
42035
+ const storedStepId = String(row["step_id"] ?? "legacy-apply");
42036
+ const storedRequestDigest = String(row["request_digest"]);
42037
+ const storedPreconditionDigest = String(row["precondition_digest"] ?? "0".repeat(64));
42038
+ if (String(row["receipt_id"]) !== input.receipt_id || String(row["operation_id"]) !== input.operation_id || String(binding["operation_id"]) !== String(row["operation_id"]) || String(binding["step_id"] ?? "legacy-apply") !== storedStepId || String(binding["idempotency_key"]) !== String(row["idempotency_key"]) || String(binding["request_digest"]) !== storedRequestDigest || String(binding["precondition_digest"] ?? "0".repeat(64)) !== storedPreconditionDigest || String(binding["apply_receipt_id"]) !== input.receipt_id || binding["slug_provenance"] !== row["slug_provenance"]) {
42039
+ throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_COMPENSATION_REFUSED", "Compensation refused: receipt and binding identity disagree");
42040
+ }
40958
42041
  if (binding["state"] !== "applied")
40959
42042
  throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_COMPENSATION_REFUSED", "Graph is not in applied state");
40960
42043
  const delivered = this.db.query(`SELECT o.id FROM todos_task_manifest_outbox o
@@ -40965,10 +42048,16 @@ class SqliteTodosTaskManifestBackend {
40965
42048
  throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_COMPENSATION_REFUSED", "Compensation refused: delivered outbox row exists");
40966
42049
  const applyResult = parseApplyResult(String(row["result_json"]), false);
40967
42050
  const manifest = JSON.parse(String(row["manifest_json"]));
42051
+ const manifestRecord = manifest;
42052
+ const applyStepId = typeof manifestRecord["step_id"] === "string" ? String(manifestRecord["step_id"]) : null;
40968
42053
  const expectedEffects = [
40969
42054
  {
40970
42055
  topic: "todos.task-manifest.applied",
40971
- payload: { operation_id: manifest.operation_id, project_id: manifest.project_id }
42056
+ payload: {
42057
+ operation_id: manifest.operation_id,
42058
+ ...applyStepId ? { step_id: applyStepId } : {},
42059
+ project_id: manifest.project_id
42060
+ }
40972
42061
  },
40973
42062
  ...(manifest.effects ?? []).map((effect2) => ({ topic: effect2.topic, payload: effect2.payload }))
40974
42063
  ];
@@ -41008,8 +42097,10 @@ class SqliteTodosTaskManifestBackend {
41008
42097
  if (canonicalJson(actualReadback) !== canonicalJson(applyResult.readback)) {
41009
42098
  throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_COMPENSATION_REFUSED", "Compensation refused: graph changed since apply", { actualReadback });
41010
42099
  }
41011
- const plan = this.db.query("SELECT project_id, name, description, status, task_list_id, slug FROM plans WHERE id = ? LIMIT 1").get(applyResult.graph.plan_id);
41012
- if (!plan || plan["project_id"] !== manifest.project_id || plan["name"] !== manifest.plan.name || plan["description"] !== (manifest.plan.description ?? null) || plan["status"] !== (manifest.plan.status ?? "active") || plan["task_list_id"] !== (manifest.task_list_id ?? null) || plan["slug"] !== taskManifestPlanSlug(manifest, applyResult.graph.plan_id)) {
42100
+ const slugProvenance = row["slug_provenance"];
42101
+ validateCompensationPlanSlug(this.db, manifest, applyResult.graph.plan_id, slugProvenance);
42102
+ const plan = this.db.query("SELECT project_id, name, description, status, task_list_id FROM plans WHERE id = ? LIMIT 1").get(applyResult.graph.plan_id);
42103
+ if (!plan || plan["project_id"] !== manifest.project_id || plan["name"] !== manifest.plan.name || plan["description"] !== (manifest.plan.description ?? null) || plan["status"] !== (manifest.plan.status ?? "active") || plan["task_list_id"] !== (manifest.task_list_id ?? null)) {
41013
42104
  throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_COMPENSATION_REFUSED", "Compensation refused: plan changed since apply");
41014
42105
  }
41015
42106
  for (const task3 of manifest.tasks) {
@@ -41086,9 +42177,10 @@ class SqliteTodosTaskManifestBackend {
41086
42177
  const result = { duplicate: false, receipt, absent: true, readback };
41087
42178
  const resultJson = canonicalJson(result);
41088
42179
  this.db.query(`INSERT INTO todos_task_manifest_receipts (
41089
- receipt_id, tenant_id, authority, route, schema_version, kind, operation_id, idempotency_key,
41090
- request_digest, result_digest, binding_version, apply_receipt_id, manifest_json, result_json, created_at
41091
- ) VALUES (?, ?, 'todos', 'todos.task-manifest.v1', 1, 'compensate', ?, ?, ?, ?, ?, ?, NULL, ?, ?)`).run(compensationReceiptId, this.tenantId, receipt.operation_id, input.idempotency_key, requestDigest, receipt.result_digest, receipt.binding_version, input.receipt_id, resultJson, now4);
42180
+ receipt_id, tenant_id, authority, route, schema_version, kind, operation_id, step_id, idempotency_key,
42181
+ request_digest, precondition_digest, result_digest, slug_provenance, outcome, reason,
42182
+ duplicate_of_receipt_id, binding_version, apply_receipt_id, manifest_json, result_json, created_at
42183
+ ) VALUES (?, ?, 'todos', 'todos.task-manifest.v1', 1, 'compensate', ?, ?, ?, ?, ?, ?, NULL, 'accepted', NULL, NULL, ?, ?, NULL, ?, ?)`).run(compensationReceiptId, this.tenantId, receipt.operation_id, receipt.step_id, input.idempotency_key, requestDigest, input.precondition_digest, receipt.result_digest, receipt.binding_version, input.receipt_id, resultJson, now4);
41092
42184
  const updated = this.db.query(`UPDATE todos_task_manifest_bindings SET state = 'compensated', version = ?, compensation_receipt_id = ?, updated_at = ?
41093
42185
  WHERE tenant_id = ? AND operation_id = ? AND state = 'applied' AND version = ?`).run(receipt.binding_version, compensationReceiptId, now4, this.tenantId, receipt.operation_id, input.if_binding_version);
41094
42186
  if (updated.changes !== 1) {
@@ -41122,6 +42214,39 @@ function safeIdentifier2(value, field2) {
41122
42214
  function parseJson2(value) {
41123
42215
  return typeof value === "string" ? JSON.parse(value) : value;
41124
42216
  }
42217
+ function parseApplyResult2(value, duplicate) {
42218
+ const parsed = parseJson2(value);
42219
+ return {
42220
+ ...parsed,
42221
+ duplicate,
42222
+ receipt: {
42223
+ ...parsed.receipt,
42224
+ step_id: parsed.receipt.step_id ?? "legacy-apply",
42225
+ precondition_digest: parsed.receipt.precondition_digest ?? "0".repeat(64),
42226
+ outcome: parsed.receipt.outcome ?? "accepted",
42227
+ reason: parsed.receipt.reason ?? null,
42228
+ duplicate_of_receipt_id: parsed.receipt.duplicate_of_receipt_id ?? null
42229
+ }
42230
+ };
42231
+ }
42232
+ function validatePostgresPlanSlug(manifest, planId, slug, provenance) {
42233
+ if (provenance === TASK_MANIFEST_DETERMINISTIC_SLUG_PROVENANCE) {
42234
+ const expected = taskManifestPlanSlug(manifest, planId);
42235
+ if (slug !== expected) {
42236
+ throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_COMPENSATION_REFUSED", "Compensation refused: plan slug changed since apply");
42237
+ }
42238
+ return expected;
42239
+ }
42240
+ if (provenance !== null && provenance !== undefined) {
42241
+ throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_COMPENSATION_REFUSED", "Compensation refused: unknown plan slug provenance");
42242
+ }
42243
+ if (slug === null || slug === undefined)
42244
+ return null;
42245
+ if (slug !== null && slug !== undefined) {
42246
+ throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_COMPENSATION_REFUSED", "Compensation refused: legacy PostgreSQL plan slug must be NULL");
42247
+ }
42248
+ return null;
42249
+ }
41125
42250
  function timestamp3(value) {
41126
42251
  return value instanceof Date ? value.toISOString() : new Date(String(value)).toISOString();
41127
42252
  }
@@ -41129,6 +42254,41 @@ function fault2(faults, point) {
41129
42254
  if (faults.points.has(point))
41130
42255
  throw new Error(`Injected task-manifest fault at ${point}`);
41131
42256
  }
42257
+ function terminalApplyResult2(input, reason) {
42258
+ const receipt = {
42259
+ receipt_id: input.terminal_receipt_id,
42260
+ authority: "todos",
42261
+ route: "todos.task-manifest.v1",
42262
+ schema_version: 1,
42263
+ kind: "apply",
42264
+ operation_id: input.manifest.operation_id,
42265
+ step_id: input.manifest.step_id,
42266
+ idempotency_key: input.manifest.idempotency_key,
42267
+ request_digest: input.request_digest,
42268
+ precondition_digest: input.manifest.precondition_digest,
42269
+ result_digest: canonicalDigest({
42270
+ outcome: "terminal_nonacceptance",
42271
+ reason,
42272
+ operation_id: input.manifest.operation_id,
42273
+ step_id: input.manifest.step_id,
42274
+ request_digest: input.request_digest
42275
+ }),
42276
+ outcome: "terminal_nonacceptance",
42277
+ reason,
42278
+ duplicate_of_receipt_id: null,
42279
+ binding_version: 0,
42280
+ apply_receipt_id: null,
42281
+ created_at: input.now
42282
+ };
42283
+ return {
42284
+ duplicate: false,
42285
+ receipt,
42286
+ graph: input.graph,
42287
+ readback: { plans: 0, tasks: 0, dependencies: 0, comments: 0, verifications: 0, complete: true },
42288
+ outbox_ids: [],
42289
+ result_digest: receipt.result_digest
42290
+ };
42291
+ }
41132
42292
  function receiptFromRow3(row) {
41133
42293
  return {
41134
42294
  receipt_id: String(row["receipt_id"]),
@@ -41137,9 +42297,14 @@ function receiptFromRow3(row) {
41137
42297
  schema_version: 1,
41138
42298
  kind: row["kind"],
41139
42299
  operation_id: String(row["operation_id"]),
42300
+ step_id: String(row["step_id"] ?? "legacy-apply"),
41140
42301
  idempotency_key: String(row["idempotency_key"]),
41141
42302
  request_digest: String(row["request_digest"]),
42303
+ precondition_digest: String(row["precondition_digest"] ?? "0".repeat(64)),
41142
42304
  result_digest: String(row["result_digest"]),
42305
+ outcome: row["outcome"] ?? "accepted",
42306
+ reason: row["reason"] == null ? null : row["reason"],
42307
+ duplicate_of_receipt_id: row["duplicate_of_receipt_id"] == null ? null : String(row["duplicate_of_receipt_id"]),
41143
42308
  binding_version: Number(row["binding_version"]),
41144
42309
  apply_receipt_id: row["apply_receipt_id"] == null ? null : String(row["apply_receipt_id"]),
41145
42310
  created_at: timestamp3(row["created_at"])
@@ -41257,46 +42422,89 @@ class PostgresTodosTaskManifestBackend {
41257
42422
  now4
41258
42423
  ]);
41259
42424
  }
42425
+ async persistTerminal(tx, input, reason) {
42426
+ const result = terminalApplyResult2(input, reason);
42427
+ const resultJson = canonicalJson(result);
42428
+ await tx.query(`INSERT INTO todos_task_manifest_terminal_receipts (
42429
+ receipt_id, tenant_id, authority, route, schema_version, kind, operation_id, step_id,
42430
+ idempotency_key, request_digest, precondition_digest, result_digest, outcome, reason,
42431
+ binding_version, apply_receipt_id, manifest_json, result_json, created_at
42432
+ ) VALUES ($1, $2, 'todos', 'todos.task-manifest.v1', 1, 'apply', $3, $4, $5, $6, $7, $8,
42433
+ 'terminal_nonacceptance', $9, 0, NULL, $10::jsonb, $11::jsonb, $12)
42434
+ ON CONFLICT (tenant_id, kind, operation_id, step_id) DO NOTHING`, [
42435
+ result.receipt.receipt_id,
42436
+ this.tenantId,
42437
+ input.manifest.operation_id,
42438
+ input.manifest.step_id,
42439
+ input.manifest.idempotency_key,
42440
+ input.request_digest,
42441
+ input.manifest.precondition_digest,
42442
+ result.receipt.result_digest,
42443
+ reason,
42444
+ canonicalJson(input.manifest),
42445
+ resultJson,
42446
+ input.now
42447
+ ]);
42448
+ const stored = await tx.query(`SELECT receipt_id, result_json
42449
+ FROM todos_task_manifest_terminal_receipts
42450
+ WHERE tenant_id = $1 AND kind = 'apply'
42451
+ AND (receipt_id = $2 OR (operation_id = $3 AND step_id = $4))
42452
+ ORDER BY created_at ASC, receipt_id ASC
42453
+ LIMIT 1`, [this.tenantId, result.receipt.receipt_id, input.manifest.operation_id, input.manifest.step_id]);
42454
+ return stored.rows[0] ? parseApplyResult2(stored.rows[0]["result_json"], stored.rows[0]["receipt_id"] !== result.receipt.receipt_id) : result;
42455
+ }
41260
42456
  async apply(input, faults) {
41261
42457
  await this.ensureSchema();
41262
42458
  return this.client.transaction(async (tx) => {
41263
42459
  const { manifest } = input;
41264
42460
  await tx.query("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))", [`${this.service}\x1F${manifest.operation_id}`]);
41265
42461
  await tx.query("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))", [`${this.service}\x1Fidempotency\x1F${manifest.idempotency_key}`]);
42462
+ const terminal = await tx.query(`SELECT result_json FROM todos_task_manifest_terminal_receipts
42463
+ WHERE tenant_id = $1
42464
+ AND kind = 'apply'
42465
+ AND (receipt_id = $2 OR (operation_id = $3 AND step_id = $4))
42466
+ ORDER BY created_at ASC, receipt_id ASC
42467
+ LIMIT 1`, [this.tenantId, input.terminal_receipt_id, manifest.operation_id, manifest.step_id]);
42468
+ if (terminal.rows[0]) {
42469
+ return parseApplyResult2(terminal.rows[0]["result_json"], true);
42470
+ }
41266
42471
  const existing = await tx.query("SELECT * FROM todos_task_manifest_bindings WHERE tenant_id = $1 AND operation_id = $2 LIMIT 1 FOR UPDATE", [this.tenantId, manifest.operation_id]);
41267
42472
  if (existing.rows[0]) {
41268
42473
  const binding = existing.rows[0];
41269
- if (binding["idempotency_key"] !== manifest.idempotency_key || binding["request_digest"] !== input.request_digest) {
41270
- throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_IDEMPOTENCY_CONFLICT", "Operation is already bound to a different request");
42474
+ if (binding["idempotency_key"] !== manifest.idempotency_key || binding["request_digest"] !== input.request_digest || binding["step_id"] !== manifest.step_id || binding["precondition_digest"] !== manifest.precondition_digest) {
42475
+ return this.persistTerminal(tx, input, "TODOS_TASK_MANIFEST_IDEMPOTENCY_CONFLICT");
41271
42476
  }
41272
42477
  if (binding["state"] !== "applied") {
41273
- throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_GRAPH_CONFLICT", "Operation was already compensated");
42478
+ return this.persistTerminal(tx, input, "TODOS_TASK_MANIFEST_GRAPH_CONFLICT");
41274
42479
  }
41275
- return { ...parseJson2(binding["result_json"]), duplicate: true };
42480
+ return parseApplyResult2(binding["result_json"], true);
41276
42481
  }
41277
42482
  const reused = await tx.query("SELECT operation_id FROM todos_task_manifest_bindings WHERE tenant_id = $1 AND idempotency_key = $2 LIMIT 1", [this.tenantId, manifest.idempotency_key]);
41278
42483
  if (reused.rows[0])
41279
- throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_IDEMPOTENCY_CONFLICT", "Idempotency key is already used");
42484
+ return this.persistTerminal(tx, input, "TODOS_TASK_MANIFEST_IDEMPOTENCY_CONFLICT");
42485
+ if (manifest.idempotency_key !== input.expected_idempotency_key) {
42486
+ return this.persistTerminal(tx, input, "TODOS_TASK_MANIFEST_IDEMPOTENCY_MISMATCH");
42487
+ }
41280
42488
  if (manifest.if_binding_version !== undefined && manifest.if_binding_version !== 0) {
41281
- throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_CAS_CONFLICT", "New manifest binding version must be 0");
42489
+ return this.persistTerminal(tx, input, "TODOS_TASK_MANIFEST_CAS_CONFLICT");
41282
42490
  }
41283
42491
  const project = await tx.query(`SELECT 1 AS found FROM ${this.tableName}
41284
42492
  WHERE service = $1 AND object_type = 'projects' AND object_id = $2 AND deleted_at IS NULL LIMIT 1`, [this.service, manifest.project_id]);
41285
42493
  if (!project.rows[0])
41286
- throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_FOREIGN_REFERENCE", "Project does not exist");
42494
+ return this.persistTerminal(tx, input, "TODOS_TASK_MANIFEST_FOREIGN_REFERENCE");
41287
42495
  if (manifest.task_list_id) {
41288
42496
  const taskList = await tx.query(`SELECT payload FROM ${this.tableName}
41289
42497
  WHERE service = $1 AND object_type = 'task_lists' AND object_id = $2 AND deleted_at IS NULL LIMIT 1`, [this.service, manifest.task_list_id]);
41290
42498
  const payload = taskList.rows[0] ? parseJson2(taskList.rows[0]["payload"]) : null;
41291
42499
  if (!payload || payload["project_id"] !== manifest.project_id) {
41292
- throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_FOREIGN_REFERENCE", "Task list does not belong to the project");
42500
+ return this.persistTerminal(tx, input, "TODOS_TASK_MANIFEST_FOREIGN_REFERENCE");
41293
42501
  }
41294
42502
  }
41295
42503
  const objectIds = [input.graph.plan_id, ...Object.values(input.graph.task_ids), ...input.graph.comment_ids, ...input.graph.verification_ids, ...input.graph.dependency_ids];
41296
42504
  const conflict = await tx.query(`SELECT object_id FROM ${this.tableName}
41297
42505
  WHERE service = $1 AND object_id IN (${placeholders2(2, objectIds.length)}) LIMIT 1`, [this.service, ...objectIds]);
41298
42506
  if (conflict.rows[0])
41299
- throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_GRAPH_CONFLICT", "A deterministic graph id already exists");
42507
+ return this.persistTerminal(tx, input, "TODOS_TASK_MANIFEST_GRAPH_CONFLICT");
41300
42508
  await this.insertSync(tx, "plans", input.graph.plan_id, planPayload(input), input.now);
41301
42509
  fault2(faults, "after_plan_write");
41302
42510
  for (const task3 of manifest.tasks) {
@@ -41366,9 +42574,14 @@ class PostgresTodosTaskManifestBackend {
41366
42574
  schema_version: 1,
41367
42575
  kind: "apply",
41368
42576
  operation_id: manifest.operation_id,
42577
+ step_id: manifest.step_id,
41369
42578
  idempotency_key: manifest.idempotency_key,
41370
42579
  request_digest: input.request_digest,
42580
+ precondition_digest: manifest.precondition_digest,
41371
42581
  result_digest: input.result_digest,
42582
+ outcome: "accepted",
42583
+ reason: null,
42584
+ duplicate_of_receipt_id: null,
41372
42585
  binding_version: 1,
41373
42586
  apply_receipt_id: null,
41374
42587
  created_at: input.now
@@ -41385,14 +42598,18 @@ class PostgresTodosTaskManifestBackend {
41385
42598
  const resultJson = canonicalJson(result);
41386
42599
  await tx.query(`INSERT INTO todos_task_manifest_receipts (
41387
42600
  receipt_id, tenant_id, authority, route, schema_version, kind, operation_id, idempotency_key,
41388
- request_digest, result_digest, binding_version, apply_receipt_id, manifest_json, result_json, created_at
41389
- ) VALUES ($1, $2, 'todos', 'todos.task-manifest.v1', 1, 'apply', $3, $4, $5, $6, 1, NULL, $7::jsonb, $8::jsonb, $9)`, [
42601
+ step_id, request_digest, precondition_digest, result_digest, slug_provenance, outcome,
42602
+ reason, duplicate_of_receipt_id, binding_version, apply_receipt_id, manifest_json, result_json, created_at
42603
+ ) VALUES ($1, $2, 'todos', 'todos.task-manifest.v1', 1, 'apply', $3, $4, $5, $6, $7, $8, $9, 'accepted', NULL, NULL, 1, NULL, $10::jsonb, $11::jsonb, $12)`, [
41390
42604
  input.receipt_id,
41391
42605
  this.tenantId,
41392
42606
  manifest.operation_id,
41393
42607
  manifest.idempotency_key,
42608
+ manifest.step_id,
41394
42609
  input.request_digest,
42610
+ manifest.precondition_digest,
41395
42611
  input.result_digest,
42612
+ TASK_MANIFEST_DETERMINISTIC_SLUG_PROVENANCE,
41396
42613
  manifestJson,
41397
42614
  resultJson,
41398
42615
  input.now
@@ -41411,14 +42628,18 @@ class PostgresTodosTaskManifestBackend {
41411
42628
  }
41412
42629
  fault2(faults, "after_outbox_write");
41413
42630
  await tx.query(`INSERT INTO todos_task_manifest_bindings (
41414
- operation_id, tenant_id, idempotency_key, request_digest, result_digest, apply_receipt_id,
41415
- manifest_json, result_json, state, version, created_at, updated_at
41416
- ) VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8::jsonb, 'applied', 1, $9, $9)`, [
42631
+ operation_id, tenant_id, step_id, idempotency_key, request_digest, precondition_digest,
42632
+ result_digest, slug_provenance, outcome, apply_receipt_id, manifest_json, result_json,
42633
+ state, version, created_at, updated_at
42634
+ ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'accepted', $9, $10::jsonb, $11::jsonb, 'applied', 1, $12, $12)`, [
41417
42635
  manifest.operation_id,
41418
42636
  this.tenantId,
42637
+ manifest.step_id,
41419
42638
  manifest.idempotency_key,
41420
42639
  input.request_digest,
42640
+ manifest.precondition_digest,
41421
42641
  input.result_digest,
42642
+ TASK_MANIFEST_DETERMINISTIC_SLUG_PROVENANCE,
41422
42643
  input.receipt_id,
41423
42644
  manifestJson,
41424
42645
  resultJson,
@@ -41431,9 +42652,12 @@ class PostgresTodosTaskManifestBackend {
41431
42652
  async readExact(receiptId2) {
41432
42653
  await this.ensureSchema();
41433
42654
  const result = await this.client.query("SELECT result_json FROM todos_task_manifest_receipts WHERE tenant_id = $1 AND receipt_id = $2 AND kind = 'apply' LIMIT 1", [this.tenantId, receiptId2]);
41434
- if (!result.rows[0])
42655
+ if (result.rows[0])
42656
+ return parseApplyResult2(result.rows[0]["result_json"], false);
42657
+ const terminal = await this.client.query("SELECT result_json FROM todos_task_manifest_terminal_receipts WHERE tenant_id = $1 AND receipt_id = $2 AND kind = 'apply' LIMIT 1", [this.tenantId, receiptId2]);
42658
+ if (!terminal.rows[0])
41435
42659
  throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_RECEIPT_NOT_FOUND", `Apply receipt not found: ${receiptId2}`);
41436
- return { ...parseJson2(result.rows[0]["result_json"]), duplicate: false };
42660
+ return parseApplyResult2(terminal.rows[0]["result_json"], false);
41437
42661
  }
41438
42662
  async lookupBindingByPlanId(planId) {
41439
42663
  await this.ensureSchema();
@@ -41444,6 +42668,7 @@ class PostgresTodosTaskManifestBackend {
41444
42668
  b.version AS binding_version,
41445
42669
  b.tenant_id AS binding_tenant_id,
41446
42670
  b.operation_id AS binding_operation_id,
42671
+ b.step_id AS binding_step_id,
41447
42672
  b.result_json #>> '{graph,plan_id}' AS binding_plan_id,
41448
42673
  r.tenant_id AS receipt_tenant_id,
41449
42674
  r.authority AS receipt_authority,
@@ -41451,6 +42676,7 @@ class PostgresTodosTaskManifestBackend {
41451
42676
  r.schema_version AS receipt_schema_version,
41452
42677
  r.kind AS receipt_kind,
41453
42678
  r.operation_id AS receipt_operation_id,
42679
+ r.step_id AS receipt_step_id,
41454
42680
  r.result_json #>> '{graph,plan_id}' AS receipt_plan_id
41455
42681
  FROM todos_task_manifest_bindings b
41456
42682
  LEFT JOIN todos_task_manifest_receipts r
@@ -41537,6 +42763,10 @@ class PostgresTodosTaskManifestBackend {
41537
42763
  if (!binding || Number(binding["version"]) !== input.if_binding_version) {
41538
42764
  throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_CAS_CONFLICT", "Binding version changed before compensation");
41539
42765
  }
42766
+ const appliedReceipt = receiptFromRow3(applyRow);
42767
+ if (appliedReceipt.receipt_id !== input.receipt_id || appliedReceipt.operation_id !== input.operation_id || String(binding["operation_id"]) !== appliedReceipt.operation_id || String(binding["step_id"] ?? "legacy-apply") !== appliedReceipt.step_id || String(binding["idempotency_key"]) !== appliedReceipt.idempotency_key || String(binding["request_digest"]) !== appliedReceipt.request_digest || String(binding["precondition_digest"] ?? "0".repeat(64)) !== appliedReceipt.precondition_digest || String(binding["apply_receipt_id"]) !== input.receipt_id || binding["slug_provenance"] !== applyRow["slug_provenance"]) {
42768
+ throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_COMPENSATION_REFUSED", "Compensation refused: receipt and binding identity disagree");
42769
+ }
41540
42770
  if (binding["state"] !== "applied")
41541
42771
  throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_COMPENSATION_REFUSED", "Graph is not applied");
41542
42772
  const delivered = await tx.query(`SELECT o.id FROM todos_task_manifest_outbox o
@@ -41551,12 +42781,18 @@ class PostgresTodosTaskManifestBackend {
41551
42781
  LIMIT 1`, [this.tenantId, input.receipt_id]);
41552
42782
  if (delivered.rows[0])
41553
42783
  throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_COMPENSATION_REFUSED", "Compensation refused: delivered outbox row exists");
41554
- const applyResult = parseJson2(applyRow["result_json"]);
42784
+ const applyResult = parseApplyResult2(applyRow["result_json"], false);
41555
42785
  const manifest = parseJson2(applyRow["manifest_json"]);
42786
+ const manifestRecord = manifest;
42787
+ const applyStepId = typeof manifestRecord["step_id"] === "string" ? String(manifestRecord["step_id"]) : null;
41556
42788
  const expectedEffects = [
41557
42789
  {
41558
42790
  topic: "todos.task-manifest.applied",
41559
- payload: { operation_id: manifest.operation_id, project_id: manifest.project_id }
42791
+ payload: {
42792
+ operation_id: manifest.operation_id,
42793
+ ...applyStepId ? { step_id: applyStepId } : {},
42794
+ project_id: manifest.project_id
42795
+ }
41560
42796
  },
41561
42797
  ...(manifest.effects ?? []).map((effect2) => ({ topic: effect2.topic, payload: effect2.payload }))
41562
42798
  ];
@@ -41604,9 +42840,15 @@ class PostgresTodosTaskManifestBackend {
41604
42840
  }
41605
42841
  const appliedAt = receiptFromRow3(applyRow).created_at;
41606
42842
  const expectedPayloads = new Map;
42843
+ const planRow = await tx.query(`SELECT payload FROM ${this.tableName}
42844
+ WHERE service = $1 AND object_type = 'plans' AND object_id = $2
42845
+ LIMIT 1`, [this.service, applyResult.graph.plan_id]);
42846
+ const actualPlan = planRow.rows[0] ? parseJson2(planRow.rows[0]["payload"]) : null;
42847
+ const planExpected = planPayload({ manifest, graph: applyResult.graph, now: appliedAt });
42848
+ planExpected.slug = validatePostgresPlanSlug(manifest, applyResult.graph.plan_id, actualPlan?.["slug"], applyRow["slug_provenance"]);
41607
42849
  expectedPayloads.set(applyResult.graph.plan_id, {
41608
42850
  type: "plans",
41609
- payload: canonicalJson(planPayload({ manifest, graph: applyResult.graph, now: appliedAt }))
42851
+ payload: canonicalJson(planExpected)
41610
42852
  });
41611
42853
  for (const task3 of manifest.tasks)
41612
42854
  expectedPayloads.set(applyResult.graph.task_ids[task3.key], {
@@ -41706,14 +42948,17 @@ class PostgresTodosTaskManifestBackend {
41706
42948
  const readback = await this.readback(tx, applyResult.graph);
41707
42949
  const result = { duplicate: false, receipt, absent: true, readback };
41708
42950
  await tx.query(`INSERT INTO todos_task_manifest_receipts (
41709
- receipt_id, tenant_id, authority, route, schema_version, kind, operation_id, idempotency_key,
41710
- request_digest, result_digest, binding_version, apply_receipt_id, manifest_json, result_json, created_at
41711
- ) VALUES ($1, $2, 'todos', 'todos.task-manifest.v1', 1, 'compensate', $3, $4, $5, $6, $7, $8, NULL, $9::jsonb, $10)`, [
42951
+ receipt_id, tenant_id, authority, route, schema_version, kind, operation_id, step_id, idempotency_key,
42952
+ request_digest, precondition_digest, result_digest, slug_provenance, outcome, reason,
42953
+ duplicate_of_receipt_id, binding_version, apply_receipt_id, manifest_json, result_json, created_at
42954
+ ) VALUES ($1, $2, 'todos', 'todos.task-manifest.v1', 1, 'compensate', $3, $4, $5, $6, $7, $8, NULL, 'accepted', NULL, NULL, $9, $10, NULL, $11::jsonb, $12)`, [
41712
42955
  compensationReceiptId,
41713
42956
  this.tenantId,
41714
42957
  receipt.operation_id,
42958
+ receipt.step_id,
41715
42959
  input.idempotency_key,
41716
42960
  requestDigest,
42961
+ input.precondition_digest,
41717
42962
  receipt.result_digest,
41718
42963
  receipt.binding_version,
41719
42964
  input.receipt_id,
@@ -41773,36 +43018,83 @@ function resolveTenantId(value) {
41773
43018
  }
41774
43019
  return tenantId;
41775
43020
  }
43021
+ function taskManifestRequestDigest(manifest) {
43022
+ const { idempotency_key: _idempotencyKey, ...request } = manifest;
43023
+ return canonicalDigest(request);
43024
+ }
43025
+ function taskManifestCompensationRequestDigest(request) {
43026
+ return canonicalDigest(request);
43027
+ }
43028
+ function deriveTodosTaskManifestApplyPreconditionDigest(input) {
43029
+ return canonicalDigest({
43030
+ route: TODOS_TASK_MANIFEST_CALLER_ROUTE,
43031
+ direction: "apply",
43032
+ operation_id: input.operation_id,
43033
+ step_id: input.step_id,
43034
+ project_id: input.project_id,
43035
+ task_list_id: input.task_list_id ?? null,
43036
+ expected_binding_version: input.if_binding_version ?? 0
43037
+ });
43038
+ }
43039
+ function deriveTodosTaskManifestCompensationPreconditionDigest(input) {
43040
+ return canonicalDigest({
43041
+ route: TODOS_TASK_MANIFEST_CALLER_ROUTE,
43042
+ direction: "compensate",
43043
+ operation_id: input.operation_id,
43044
+ step_id: input.step_id,
43045
+ apply_receipt_id: input.receipt_id,
43046
+ expected_binding_version: input.if_binding_version
43047
+ });
43048
+ }
43049
+ function deriveTodosTaskManifestIdempotencyKey(input) {
43050
+ return `tmk_${canonicalDigest({
43051
+ route: TODOS_TASK_MANIFEST_CALLER_ROUTE,
43052
+ ...input
43053
+ }).slice(0, 48)}`;
43054
+ }
41776
43055
  function normalize(input, now4) {
41777
43056
  const parsed = parseTodosTaskManifest(input);
41778
43057
  const requestBytes = Buffer.byteLength(canonicalJson(parsed), "utf8");
41779
43058
  if (requestBytes > TODOS_TASK_MANIFEST_BOUNDS.request_bytes) {
41780
43059
  throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_BOUNDS_EXCEEDED", `Task manifest requires ${requestBytes} bytes but the bound is ${TODOS_TASK_MANIFEST_BOUNDS.request_bytes}`, { request_bytes: requestBytes, request_byte_limit: TODOS_TASK_MANIFEST_BOUNDS.request_bytes });
41781
43060
  }
43061
+ const { idempotency_key: _idempotencyKey, ...request } = parsed;
43062
+ const request_digest = taskManifestRequestDigest(request);
41782
43063
  const manifest = sanitizeManifest(parsed);
43064
+ const expectedPreconditionDigest = deriveTodosTaskManifestApplyPreconditionDigest(manifest);
43065
+ if (manifest.precondition_digest !== expectedPreconditionDigest) {
43066
+ throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_DIGEST_MISMATCH", "precondition_digest does not match the exact apply target and binding version", { expected_precondition_digest: expectedPreconditionDigest });
43067
+ }
43068
+ const expectedIdempotencyKey = deriveTodosTaskManifestIdempotencyKey({
43069
+ operation_id: manifest.operation_id,
43070
+ step_id: manifest.step_id,
43071
+ direction: "apply",
43072
+ target_selector: manifest.project_id,
43073
+ request_digest,
43074
+ precondition_digest: manifest.precondition_digest
43075
+ });
41783
43076
  const task_ids = Object.fromEntries(manifest.tasks.map((task3) => [
41784
43077
  task3.key,
41785
- deterministicUuid(TODOS_TASK_MANIFEST_ROUTE, manifest.operation_id, "task", task3.key)
43078
+ deterministicUuid(TODOS_TASK_MANIFEST_ROUTE, manifest.operation_id, manifest.step_id, "task", task3.key)
41786
43079
  ]));
41787
43080
  const graph = {
41788
- plan_id: deterministicUuid(TODOS_TASK_MANIFEST_ROUTE, manifest.operation_id, "plan", manifest.plan.key),
43081
+ plan_id: deterministicUuid(TODOS_TASK_MANIFEST_ROUTE, manifest.operation_id, manifest.step_id, "plan", manifest.plan.key),
41789
43082
  task_ids,
41790
- comment_ids: manifest.tasks.flatMap((task3) => (task3.comments ?? []).map((_, index) => deterministicUuid(TODOS_TASK_MANIFEST_ROUTE, manifest.operation_id, "comment", task3.key, String(index)))),
41791
- verification_ids: manifest.tasks.flatMap((task3) => (task3.verifications ?? []).map((_, index) => deterministicUuid(TODOS_TASK_MANIFEST_ROUTE, manifest.operation_id, "verification", task3.key, String(index)))),
43083
+ comment_ids: manifest.tasks.flatMap((task3) => (task3.comments ?? []).map((_, index) => deterministicUuid(TODOS_TASK_MANIFEST_ROUTE, manifest.operation_id, manifest.step_id, "comment", task3.key, String(index)))),
43084
+ verification_ids: manifest.tasks.flatMap((task3) => (task3.verifications ?? []).map((_, index) => deterministicUuid(TODOS_TASK_MANIFEST_ROUTE, manifest.operation_id, manifest.step_id, "verification", task3.key, String(index)))),
41792
43085
  dependency_ids: (manifest.dependencies ?? []).map((edge) => `${task_ids[edge.task]}::${task_ids[edge.depends_on]}`)
41793
43086
  };
41794
- const request_digest = canonicalDigest(parsed);
41795
43087
  const effectInputs = [
41796
43088
  {
41797
43089
  topic: "todos.task-manifest.applied",
41798
- payload: { operation_id: manifest.operation_id, project_id: manifest.project_id }
43090
+ payload: { operation_id: manifest.operation_id, step_id: manifest.step_id, project_id: manifest.project_id }
41799
43091
  },
41800
43092
  ...manifest.effects ?? []
41801
43093
  ];
41802
43094
  const outbox = effectInputs.map((effect2, index) => {
41803
43095
  const payload = { ...effect2.payload };
41804
43096
  return {
41805
- id: deterministicUuid(TODOS_TASK_MANIFEST_ROUTE, manifest.operation_id, "outbox", String(index)),
43097
+ id: deterministicUuid(TODOS_TASK_MANIFEST_ROUTE, manifest.operation_id, manifest.step_id, "outbox", String(index)),
41806
43098
  topic: effect2.topic,
41807
43099
  payload,
41808
43100
  digest: canonicalDigest({ topic: effect2.topic, payload })
@@ -41812,11 +43104,14 @@ function normalize(input, now4) {
41812
43104
  return {
41813
43105
  manifest,
41814
43106
  request_digest,
43107
+ expected_idempotency_key: expectedIdempotencyKey,
41815
43108
  result_digest,
41816
- receipt_id: deterministicUuid(TODOS_TASK_MANIFEST_ROUTE, "apply", manifest.operation_id, manifest.idempotency_key, request_digest),
43109
+ receipt_id: deterministicUuid(TODOS_TASK_MANIFEST_ROUTE, "apply", manifest.operation_id, manifest.step_id, manifest.idempotency_key, request_digest),
43110
+ terminal_receipt_id: deterministicUuid(TODOS_TASK_MANIFEST_ROUTE, "terminal", "apply", manifest.operation_id, manifest.step_id, manifest.idempotency_key, request_digest),
41817
43111
  graph,
41818
43112
  outbox,
41819
- now: now4
43113
+ now: now4,
43114
+ plan_slug_provenance: TODOS_TASK_MANIFEST_PLAN_SLUG_PROVENANCE
41820
43115
  };
41821
43116
  }
41822
43117
  function sanitizeManifest(manifest) {
@@ -41874,6 +43169,10 @@ class PackageOwnedTodosTaskManifestAuthority {
41874
43169
  tenant_id: this.tenantId,
41875
43170
  backend: this.backend.kind,
41876
43171
  deterministic_ids: true,
43172
+ operation_step_identity: true,
43173
+ deterministic_idempotency_keys: true,
43174
+ terminal_nonacceptance_receipts: true,
43175
+ plan_slug_provenance: TODOS_TASK_MANIFEST_PLAN_SLUG_PROVENANCE,
41877
43176
  immutable_receipts: true,
41878
43177
  transactional_outbox: true,
41879
43178
  idempotent_outbox_delivery: true,
@@ -41903,7 +43202,11 @@ class PackageOwnedTodosTaskManifestAuthority {
41903
43202
  async apply(input) {
41904
43203
  const normalized = normalize(input, this.now());
41905
43204
  const faults = await this.prepareFaults();
41906
- return this.bounded(await this.backend.apply(normalized, faults));
43205
+ const result = this.bounded(await this.backend.apply(normalized, faults));
43206
+ if (result.receipt.outcome === "terminal_nonacceptance") {
43207
+ throw new TodosTaskManifestError(result.receipt.reason ?? "TODOS_TASK_MANIFEST_GRAPH_CONFLICT", "Task-manifest apply reached an immutable terminal nonacceptance", { receipt: result.receipt });
43208
+ }
43209
+ return result;
41907
43210
  }
41908
43211
  readExact(receiptId2) {
41909
43212
  if (!receiptId2 || receiptId2.length > 200) {
@@ -41936,18 +43239,48 @@ class PackageOwnedTodosTaskManifestAuthority {
41936
43239
  async compensate(input) {
41937
43240
  const request = parseTodosTaskManifestCompensation(input);
41938
43241
  const applied = await this.backend.readExact(request.receipt_id);
41939
- const requestDigest = canonicalDigest(request);
41940
- const compensationReceiptId = deterministicUuid(TODOS_TASK_MANIFEST_ROUTE, "compensate", applied.receipt.operation_id, request.idempotency_key, requestDigest);
43242
+ if (applied.receipt.outcome !== "accepted") {
43243
+ throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_COMPENSATION_REFUSED", "Compensation refused: apply receipt is terminal nonacceptance");
43244
+ }
43245
+ if (request.operation_id !== applied.receipt.operation_id) {
43246
+ throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_IDEMPOTENCY_CONFLICT", "Compensation operation_id must match the accepted apply operation");
43247
+ }
43248
+ if (request.step_id === applied.receipt.step_id) {
43249
+ throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_INVALID_INPUT", "Compensation must use a distinct step_id from apply");
43250
+ }
43251
+ const expectedPreconditionDigest = deriveTodosTaskManifestCompensationPreconditionDigest(request);
43252
+ if (request.precondition_digest !== expectedPreconditionDigest) {
43253
+ throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_DIGEST_MISMATCH", "precondition_digest does not match the exact compensation receipt and binding version", { expected_precondition_digest: expectedPreconditionDigest });
43254
+ }
43255
+ const { idempotency_key: _requestIdempotencyKey, ...compensationRequestWithoutKey } = request;
43256
+ const requestDigest = taskManifestCompensationRequestDigest(compensationRequestWithoutKey);
43257
+ const expectedIdempotencyKey = deriveTodosTaskManifestIdempotencyKey({
43258
+ operation_id: request.operation_id,
43259
+ step_id: request.step_id,
43260
+ direction: "compensate",
43261
+ target_selector: request.receipt_id,
43262
+ request_digest: requestDigest,
43263
+ precondition_digest: request.precondition_digest
43264
+ });
43265
+ if (request.idempotency_key !== expectedIdempotencyKey) {
43266
+ throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_IDEMPOTENCY_MISMATCH", "idempotency_key does not match the deterministic operation/step/compensation semantics", { expected_idempotency_key: expectedIdempotencyKey });
43267
+ }
43268
+ const compensationReceiptId = deterministicUuid(TODOS_TASK_MANIFEST_ROUTE, "compensate", request.operation_id, request.step_id, request.idempotency_key, requestDigest);
41941
43269
  const receipt = {
41942
43270
  receipt_id: compensationReceiptId,
41943
43271
  authority: "todos",
41944
43272
  route: TODOS_TASK_MANIFEST_ROUTE,
41945
43273
  schema_version: 1,
41946
43274
  kind: "compensate",
41947
- operation_id: applied.receipt.operation_id,
43275
+ operation_id: request.operation_id,
43276
+ step_id: request.step_id,
41948
43277
  idempotency_key: request.idempotency_key,
41949
43278
  request_digest: requestDigest,
43279
+ precondition_digest: request.precondition_digest,
41950
43280
  result_digest: canonicalDigest({ absent: true, apply_receipt_id: applied.receipt.receipt_id }),
43281
+ outcome: "accepted",
43282
+ reason: null,
43283
+ duplicate_of_receipt_id: null,
41951
43284
  binding_version: request.if_binding_version + 1,
41952
43285
  apply_receipt_id: applied.receipt.receipt_id,
41953
43286
  created_at: this.now()
@@ -42129,7 +43462,7 @@ function createTodosTaskManifestHttpClient(options) {
42129
43462
  return new TodosTaskManifestHttpClient(options);
42130
43463
  }
42131
43464
  // src/ai-tools.ts
42132
- import { createHash as createHash15 } from "crypto";
43465
+ import { createHash as createHash16 } from "crypto";
42133
43466
  // src/cli/cloud-router.ts
42134
43467
  import { resolveStorageClient } from "@hasna/contracts/client/storage";
42135
43468
  import { normalizeStorageMode } from "@hasna/contracts/mode";
@@ -43437,7 +44770,7 @@ function deriveTodosAiUpdateTaskApprovalIdentity(input) {
43437
44770
  expected_version: input.expected_version,
43438
44771
  patch
43439
44772
  });
43440
- const payloadDigest = createHash15("sha256").update(canonical).digest("hex");
44773
+ const payloadDigest = createHash16("sha256").update(canonical).digest("hex");
43441
44774
  return {
43442
44775
  ref: `todos-ai:update_task:${payloadDigest}`,
43443
44776
  payload_digest: payloadDigest
@@ -43592,9 +44925,9 @@ function normalizeUpdateTaskInput(input) {
43592
44925
  if (!Object.hasOwn(record, "expected_version")) {
43593
44926
  throw new Error("expected_version is required");
43594
44927
  }
43595
- const idempotencyKey = boundedRequiredString(record, "idempotency_key", TODOS_AI_UPDATE_TASK_LIMITS.max_idempotency_key_bytes);
43596
- const idempotencyBytes = ENCODER.encode(idempotencyKey).byteLength;
43597
- if (idempotencyBytes < TODOS_AI_UPDATE_TASK_LIMITS.min_idempotency_key_bytes || !/^[A-Za-z0-9._:-]+$/.test(idempotencyKey)) {
44928
+ const idempotencyKey2 = boundedRequiredString(record, "idempotency_key", TODOS_AI_UPDATE_TASK_LIMITS.max_idempotency_key_bytes);
44929
+ const idempotencyBytes = ENCODER.encode(idempotencyKey2).byteLength;
44930
+ if (idempotencyBytes < TODOS_AI_UPDATE_TASK_LIMITS.min_idempotency_key_bytes || !/^[A-Za-z0-9._:-]+$/.test(idempotencyKey2)) {
43598
44931
  throw new Error("idempotency_key must be a bounded stable identifier");
43599
44932
  }
43600
44933
  const patchValue = record["patch"];
@@ -43613,7 +44946,7 @@ function normalizeUpdateTaskInput(input) {
43613
44946
  expected_version: expectedVersion,
43614
44947
  patch,
43615
44948
  changed_fields: changedFields,
43616
- idempotency_key: idempotencyKey,
44949
+ idempotency_key: idempotencyKey2,
43617
44950
  payload_digest: identity.payload_digest,
43618
44951
  approval_ref: identity.ref
43619
44952
  };
@@ -44294,7 +45627,7 @@ init_task_lifecycle();
44294
45627
  init_task_crud();
44295
45628
  init_redaction();
44296
45629
  import { Database as Database3 } from "bun:sqlite";
44297
- import { createHash as createHash16 } from "crypto";
45630
+ import { createHash as createHash17 } from "crypto";
44298
45631
  import { existsSync as existsSync9, readdirSync as readdirSync2, statSync as statSync4 } from "fs";
44299
45632
  import { basename as basename2, dirname as dirname6, join as join9, resolve as resolve10 } from "path";
44300
45633
 
@@ -44577,8 +45910,8 @@ function normalizePath3(input) {
44577
45910
  return resolve10(input);
44578
45911
  }
44579
45912
  function sourceStoreId(sourceDbPath) {
44580
- const digest = createHash16("sha256").update(sourceDbPath).digest("hex").slice(0, 16);
44581
- return `sqlite:${digest}`;
45913
+ const digest2 = createHash17("sha256").update(sourceDbPath).digest("hex").slice(0, 16);
45914
+ return `sqlite:${digest2}`;
44582
45915
  }
44583
45916
  function inferSourceRepoPath(sourceDbPath) {
44584
45917
  const normalized = normalizePath3(sourceDbPath);
@@ -46539,7 +47872,7 @@ init_comments();
46539
47872
 
46540
47873
  // src/db/api-keys.ts
46541
47874
  init_database();
46542
- import { createHash as createHash17, randomBytes as randomBytes2, timingSafeEqual as timingSafeEqual3 } from "crypto";
47875
+ import { createHash as createHash18, randomBytes as randomBytes2, timingSafeEqual as timingSafeEqual3 } from "crypto";
46543
47876
  function rowToRecord(row) {
46544
47877
  return {
46545
47878
  id: row.id,
@@ -46553,7 +47886,7 @@ function rowToRecord(row) {
46553
47886
  };
46554
47887
  }
46555
47888
  function hashApiKey(key2) {
46556
- return createHash17("sha256").update(key2).digest("hex");
47889
+ return createHash18("sha256").update(key2).digest("hex");
46557
47890
  }
46558
47891
  function safeEqualHex(a, b) {
46559
47892
  if (a.length !== b.length)
@@ -50454,7 +51787,7 @@ init_database();
50454
51787
  init_tasks();
50455
51788
  import { existsSync as existsSync17, readFileSync as readFileSync14 } from "fs";
50456
51789
  import { basename as basename5 } from "path";
50457
- import { createHash as createHash18 } from "crypto";
51790
+ import { createHash as createHash19 } from "crypto";
50458
51791
  init_secret_redaction();
50459
51792
  var INBOX_INTAKE_SCHEMA = "todos.inbox_intake.v1";
50460
51793
  var INTAKE_SOURCE_TYPES = [
@@ -50467,7 +51800,7 @@ var INTAKE_SOURCE_TYPES = [
50467
51800
  ];
50468
51801
  var INTAKE_TRIAGE_STATUSES = ["preview", "triaged", "duplicate", "created"];
50469
51802
  function fingerprint2(text) {
50470
- return createHash18("sha256").update(text).digest("hex").slice(0, 16);
51803
+ return createHash19("sha256").update(text).digest("hex").slice(0, 16);
50471
51804
  }
50472
51805
  function loadRawContent(input) {
50473
51806
  if (input.github_url) {
@@ -55927,7 +57260,7 @@ init_database();
55927
57260
  init_tasks();
55928
57261
  init_redaction();
55929
57262
  init_sync_utils();
55930
- import { createHash as createHash19 } from "crypto";
57263
+ import { createHash as createHash20 } from "crypto";
55931
57264
  import { existsSync as existsSync22, readFileSync as readFileSync20, statSync as statSync9 } from "fs";
55932
57265
  import { hostname as hostname3, platform, arch } from "os";
55933
57266
  import { dirname as dirname15, join as join18, resolve as resolve17 } from "path";
@@ -55949,7 +57282,7 @@ var CONFIG_FILES = [
55949
57282
  "dashboard/vite.config.ts"
55950
57283
  ];
55951
57284
  function sha2566(value) {
55952
- return createHash19("sha256").update(value).digest("hex");
57285
+ return createHash20("sha256").update(value).digest("hex");
55953
57286
  }
55954
57287
  function fileRecord(root, relativePath) {
55955
57288
  const path = join18(root, relativePath);
@@ -56064,8 +57397,8 @@ function defaultSnapshotDir() {
56064
57397
  return join18(dirname15(resolve17(dbPath)), "environment-snapshots");
56065
57398
  }
56066
57399
  function snapshotWithId(snapshot) {
56067
- const digest = sha2566(JSON.stringify(snapshot)).slice(0, 24);
56068
- return { id: `env_${digest}`, ...snapshot };
57400
+ const digest2 = sha2566(JSON.stringify(snapshot)).slice(0, 24);
57401
+ return { id: `env_${digest2}`, ...snapshot };
56069
57402
  }
56070
57403
  function captureEnvironmentSnapshot(input = {}) {
56071
57404
  const root = resolve17(input.root || process.cwd());
@@ -56201,7 +57534,7 @@ function compareEnvironmentSnapshotFiles(leftPath, rightPath) {
56201
57534
  init_database();
56202
57535
  init_projects();
56203
57536
  init_plans();
56204
- import { createHash as createHash20 } from "crypto";
57537
+ import { createHash as createHash21 } from "crypto";
56205
57538
  import { mkdirSync as mkdirSync18, writeFileSync as writeFileSync16 } from "fs";
56206
57539
  import { dirname as dirname16, join as join19 } from "path";
56207
57540
  var DECISION_RECORD_SCHEMA = "todos.decision_record.v1";
@@ -56257,7 +57590,7 @@ function rowToDecisionRecord(row) {
56257
57590
  }
56258
57591
  function stableSnapshotHash(payload) {
56259
57592
  const { captured_at: _capturedAt, ...rest } = payload;
56260
- return createHash20("sha256").update(JSON.stringify(rest)).digest("hex");
57593
+ return createHash21("sha256").update(JSON.stringify(rest)).digest("hex");
56261
57594
  }
56262
57595
  function createDecisionRecord(input, db) {
56263
57596
  const d = db || getDatabase();
@@ -60750,7 +62083,7 @@ function syncWithAgents(agents, taskListIdByAgent, projectId, direction = "both"
60750
62083
  init_tasks();
60751
62084
  init_task_files();
60752
62085
  import { existsSync as existsSync27, readFileSync as readFileSync24, statSync as statSync10 } from "fs";
60753
- import { createHash as createHash21 } from "crypto";
62086
+ import { createHash as createHash22 } from "crypto";
60754
62087
  import { relative as relative6, resolve as resolve18, join as join25 } from "path";
60755
62088
  var EXTRACT_TAGS = ["TODO", "FIXME", "HACK", "XXX", "BUG", "NOTE"];
60756
62089
  var DEFAULT_EXTENSIONS = new Set([
@@ -60815,7 +62148,7 @@ var SKIP_DIRS2 = new Set([
60815
62148
  ".parcel-cache"
60816
62149
  ]);
60817
62150
  function stableHash(value) {
60818
- return createHash21("sha256").update(value).digest("hex");
62151
+ return createHash22("sha256").update(value).digest("hex");
60819
62152
  }
60820
62153
  function normalizePathForMatch(value) {
60821
62154
  return value.replace(/\\/g, "/").replace(/^\.\//, "");
@@ -61713,7 +63046,7 @@ function renderWorkflowStatesMarkdown(states = listWorkflowStates()) {
61713
63046
  }
61714
63047
  // src/lib/agent-replay-simulator.ts
61715
63048
  init_redaction();
61716
- import { createHash as createHash22 } from "crypto";
63049
+ import { createHash as createHash23 } from "crypto";
61717
63050
  import { readFileSync as readFileSync25 } from "fs";
61718
63051
  function isObject(value) {
61719
63052
  return Boolean(value && typeof value === "object" && !Array.isArray(value));
@@ -61735,7 +63068,7 @@ function stable2(value) {
61735
63068
  return Object.fromEntries(Object.keys(value).sort().map((key2) => [key2, stable2(value[key2])]));
61736
63069
  }
61737
63070
  function fingerprint3(value) {
61738
- return createHash22("sha256").update(JSON.stringify(stable2(value))).digest("hex");
63071
+ return createHash23("sha256").update(JSON.stringify(stable2(value))).digest("hex");
61739
63072
  }
61740
63073
  function unpackFixture(input) {
61741
63074
  if (!isObject(input))
@@ -61974,7 +63307,7 @@ function renderAgentReplaySimulationMarkdown(simulation) {
61974
63307
  }
61975
63308
  // src/lib/local-extensions.ts
61976
63309
  init_config2();
61977
- import { createHash as createHash23, createVerify } from "crypto";
63310
+ import { createHash as createHash24, createVerify } from "crypto";
61978
63311
  import { existsSync as existsSync28, readdirSync as readdirSync6, readFileSync as readFileSync26, statSync as statSync11 } from "fs";
61979
63312
  import { basename as basename6, join as join26, resolve as resolve19 } from "path";
61980
63313
  init_redaction();
@@ -62062,7 +63395,7 @@ function parseJson3(path) {
62062
63395
  return JSON.parse(readFileSync26(path, "utf8"));
62063
63396
  }
62064
63397
  function sha2567(bytes) {
62065
- return `sha256:${createHash23("sha256").update(bytes).digest("hex")}`;
63398
+ return `sha256:${createHash24("sha256").update(bytes).digest("hex")}`;
62066
63399
  }
62067
63400
  function compareVersions(a, b) {
62068
63401
  const left = a.split(".").map((part) => Number.parseInt(part, 10) || 0);
@@ -64677,6 +66010,8 @@ export {
64677
66010
  testTerminalNotificationRule,
64678
66011
  testLocalEventHook,
64679
66012
  tasksFromTemplate,
66013
+ taskManifestRequestDigest,
66014
+ taskManifestCompensationRequestDigest,
64680
66015
  taskFromTemplate,
64681
66016
  tagToPriority,
64682
66017
  syncWithAgents,
@@ -65411,6 +66746,9 @@ export {
65411
66746
  detectInboxSourceType,
65412
66747
  detectCyclesFromEdges,
65413
66748
  describeTerminalNotificationRule,
66749
+ deriveTodosTaskManifestIdempotencyKey,
66750
+ deriveTodosTaskManifestCompensationPreconditionDigest,
66751
+ deriveTodosTaskManifestApplyPreconditionDigest,
65414
66752
  deriveTodosProjectRegistrationIdempotencyKey,
65415
66753
  deriveTodosAiUpdateTaskApprovalIdentity,
65416
66754
  deriveInboxTitle,
@@ -65655,6 +66993,8 @@ export {
65655
66993
  TODOS_TASK_ROUTE_STATE_SCHEMA_VERSION,
65656
66994
  TODOS_TASK_MANIFEST_SCHEMA_VERSION,
65657
66995
  TODOS_TASK_MANIFEST_ROUTE,
66996
+ TODOS_TASK_MANIFEST_PLAN_SLUG_PROVENANCE,
66997
+ TODOS_TASK_MANIFEST_CALLER_ROUTE,
65658
66998
  TODOS_TASK_MANIFEST_BOUNDS,
65659
66999
  TODOS_STORAGE_TABLES,
65660
67000
  TODOS_STORAGE_FALLBACK_ENV,