@hasna/todos 0.15.29 → 0.15.33

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 (71) hide show
  1. package/dist/cli/cloud-router.d.ts +13 -1
  2. package/dist/cli/cloud-router.d.ts.map +1 -1
  3. package/dist/cli/commands/project-registration-commands.d.ts +2 -0
  4. package/dist/cli/commands/project-registration-commands.d.ts.map +1 -1
  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 +2861 -342
  8. package/dist/contracts.js +52 -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 +1466 -147
  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 +2050 -138
  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 +24 -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/sqlite.d.ts +13 -1
  33. package/dist/project-registration/sqlite.d.ts.map +1 -1
  34. package/dist/project-registration/types.d.ts +68 -1
  35. package/dist/project-registration/types.d.ts.map +1 -1
  36. package/dist/project-registration.js +849 -60
  37. package/dist/registry.js +52 -2
  38. package/dist/release-provenance.json +5 -5
  39. package/dist/sdk/index.d.ts +1 -1
  40. package/dist/sdk/index.d.ts.map +1 -1
  41. package/dist/sdk/index.js +151 -0
  42. package/dist/sdk/v1.generated.d.ts +282 -0
  43. package/dist/sdk/v1.generated.d.ts.map +1 -1
  44. package/dist/server/index.js +2041 -129
  45. package/dist/server/openapi.d.ts +2856 -1357
  46. package/dist/server/openapi.d.ts.map +1 -1
  47. package/dist/server/v1.d.ts.map +1 -1
  48. package/dist/storage/postgres-adapter.d.ts.map +1 -1
  49. package/dist/storage/postgres-sync.d.ts +8 -0
  50. package/dist/storage/postgres-sync.d.ts.map +1 -1
  51. package/dist/storage.js +233 -20
  52. package/dist/task-manifest/authority.d.ts +13 -1
  53. package/dist/task-manifest/authority.d.ts.map +1 -1
  54. package/dist/task-manifest/backend.d.ts +5 -0
  55. package/dist/task-manifest/backend.d.ts.map +1 -1
  56. package/dist/task-manifest/index.d.ts +2 -2
  57. package/dist/task-manifest/index.d.ts.map +1 -1
  58. package/dist/task-manifest/plan-slug.d.ts +20 -0
  59. package/dist/task-manifest/plan-slug.d.ts.map +1 -1
  60. package/dist/task-manifest/postgres.d.ts +1 -0
  61. package/dist/task-manifest/postgres.d.ts.map +1 -1
  62. package/dist/task-manifest/schema-sql.d.ts.map +1 -1
  63. package/dist/task-manifest/schema.d.ts.map +1 -1
  64. package/dist/task-manifest/sqlite.d.ts +1 -0
  65. package/dist/task-manifest/sqlite.d.ts.map +1 -1
  66. package/dist/task-manifest/types.d.ts +21 -1
  67. package/dist/task-manifest/types.d.ts.map +1 -1
  68. package/dist/task-manifest.js +593 -62
  69. package/dist/types/index.d.ts +4 -2
  70. package/dist/types/index.d.ts.map +1 -1
  71. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -7917,6 +7917,46 @@ function guardPlanRowsSqlite(planIds, db) {
7917
7917
  }
7918
7918
  }
7919
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
+
7920
7960
  // src/lib/creator-identity.ts
7921
7961
  function canonicalAgentRef(value) {
7922
7962
  return value.trim().toLowerCase();
@@ -9440,6 +9480,7 @@ function createTaskStored(input, d) {
9440
9480
  let id = uuid();
9441
9481
  for (let attempt = 0;attempt < 3; attempt++) {
9442
9482
  try {
9483
+ assertTaskParentIntegrity(id, input.parent_id, (candidateId) => getTask(candidateId, d));
9443
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)
9444
9485
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
9445
9486
  id,
@@ -9818,6 +9859,7 @@ function updateTaskStored(id, input, db) {
9818
9859
  throw new VersionConflictError(id, input.version, task.version);
9819
9860
  }
9820
9861
  input = sanitizeUpdateTaskInput(input);
9862
+ assertTaskParentIntegrity(id, input.parent_id, (candidateId) => getTask(candidateId, d));
9821
9863
  const effectivePlanId = input.plan_id !== undefined ? input.plan_id : task.plan_id;
9822
9864
  const linkedProjectId = linkedPlanProjectId(effectivePlanId, d);
9823
9865
  if (linkedProjectId) {
@@ -9871,6 +9913,10 @@ function updateTaskStored(id, input, db) {
9871
9913
  sets.push("project_id = ?");
9872
9914
  params.push(input.project_id);
9873
9915
  }
9916
+ if (input.parent_id !== undefined) {
9917
+ sets.push("parent_id = ?");
9918
+ params.push(input.parent_id);
9919
+ }
9874
9920
  if (input.assigned_to !== undefined) {
9875
9921
  sets.push("assigned_to = ?");
9876
9922
  params.push(input.assigned_to);
@@ -9986,6 +10032,8 @@ function updateTaskStored(id, input, db) {
9986
10032
  logTaskChange(id, "update", "priority", task.priority, input.priority, agentId, d);
9987
10033
  if (input.title !== undefined && input.title !== task.title)
9988
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);
9989
10037
  if (input.assigned_to !== undefined && input.assigned_to !== task.assigned_to)
9990
10038
  logTaskChange(id, "update", "assigned_to", task.assigned_to, input.assigned_to, agentId, d);
9991
10039
  if (input.working_dir !== undefined && input.working_dir !== task.working_dir)
@@ -10043,7 +10091,8 @@ function updateTask(id, input, db) {
10043
10091
  if (!before)
10044
10092
  throw new TaskNotFoundError(id);
10045
10093
  const guardedPlanIds = [before.plan_id, input.plan_id];
10046
- if (!guardedPlanIds.some(Boolean))
10094
+ const needsSerializedWrite = input.parent_id !== undefined || guardedPlanIds.some(Boolean);
10095
+ if (!needsSerializedWrite)
10047
10096
  return updateTaskStored(id, input, d);
10048
10097
  return d.transaction(() => {
10049
10098
  guardPlanRowsSqlite(guardedPlanIds, d);
@@ -10079,6 +10128,7 @@ var init_task_crud = __esm(() => {
10079
10128
  init_checklists();
10080
10129
  init_storage_tombstones();
10081
10130
  init_prewrite_secrets();
10131
+ init_task_parent_integrity();
10082
10132
  });
10083
10133
 
10084
10134
  // src/db/task-status.ts
@@ -12783,7 +12833,7 @@ var init_dispatches = __esm(() => {
12783
12833
  // package.json
12784
12834
  var package_default = {
12785
12835
  name: "@hasna/todos",
12786
- version: "0.15.29",
12836
+ version: "0.15.33",
12787
12837
  description: "Universal task management for AI coding agents - CLI + MCP server + interactive TUI",
12788
12838
  type: "module",
12789
12839
  main: "dist/index.js",
@@ -27129,7 +27179,7 @@ function postgresTodosSyncSchemaSql(tableName = DEFAULT_TODOS_POSTGRES_SYNC_TABL
27129
27179
  AS $$ SELECT unaccent('unaccent', $1) $$`,
27130
27180
  `CREATE OR REPLACE FUNCTION todos_try_timestamptz(text)
27131
27181
  RETURNS timestamptz
27132
- LANGUAGE plpgsql IMMUTABLE PARALLEL SAFE
27182
+ LANGUAGE plpgsql IMMUTABLE PARALLEL UNSAFE
27133
27183
  SET DateStyle TO 'ISO, YMD'
27134
27184
  AS $$
27135
27185
  BEGIN
@@ -27505,6 +27555,7 @@ function assertSafeIdentifier(value) {
27505
27555
 
27506
27556
  // src/storage/postgres-adapter.ts
27507
27557
  init_redaction();
27558
+ init_task_parent_integrity();
27508
27559
 
27509
27560
  // src/task-manifest/canonical.ts
27510
27561
  import { createHash as createHash11 } from "crypto";
@@ -27548,8 +27599,8 @@ function createPostgresTodosStorageAdapter(options) {
27548
27599
  resolveRef: (ref) => store.resolveTaskRef(ref),
27549
27600
  list: (filter = {}) => store.listTasks(filter),
27550
27601
  count: (filter = {}) => store.countTasks(filter),
27551
- update: (id, input) => updateTask2(id, input, store),
27552
- 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),
27553
27604
  start: (id, agentId) => startTask2(id, agentId, store),
27554
27605
  complete: (id, agentId, options2) => completeTask2(id, agentId, options2, store),
27555
27606
  fail: (id, agentId, reason, options2) => failTask2(id, agentId, reason, options2, store),
@@ -28082,22 +28133,70 @@ class PostgresJsonRecordStore {
28082
28133
  return "identical";
28083
28134
  throw new Error(divergentAuditHistoryReplayError(value.id));
28084
28135
  }
28085
- 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) {
28086
28146
  const planIds = [...new Set(guardedPlanIds.filter(Boolean))].sort();
28087
- if (planIds.length === 0)
28147
+ if (planIds.length === 0 && !parentGuard)
28088
28148
  return this.upsert("tasks", value, context);
28089
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;
28090
28154
  const updatedAt = value.updated_at;
28091
28155
  const targetPlanId = value.plan_id;
28092
- 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
+ ),
28093
28162
  locked_plans AS MATERIALIZED (
28094
28163
  SELECT object_id, payload FROM ${this.tableName}
28095
28164
  WHERE service = $1 AND object_type = 'plans' AND deleted_at IS NULL
28096
28165
  AND object_id IN (SELECT value FROM jsonb_array_elements_text($7::jsonb))
28097
28166
  ORDER BY object_id
28098
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
28099
28189
  ), validation AS (
28100
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,
28101
28200
  (SELECT count(*) FROM locked_plans) = jsonb_array_length($7::jsonb) AS all_plans_found,
28102
28201
  ($8::text IS NULL OR EXISTS (SELECT 1 FROM locked_plans WHERE object_id = $8)) AS target_plan_found,
28103
28202
  (SELECT payload->>'project_id' FROM locked_plans WHERE object_id = $8) AS target_project_id
@@ -28118,7 +28217,13 @@ class PostgresJsonRecordStore {
28118
28217
  )
28119
28218
  SELECT $1, 'tasks', $2, guarded.payload, $4::timestamptz, NULL, $5, $6
28120
28219
  FROM guarded
28121
- 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
28122
28227
  ON CONFLICT (service, object_type, object_id) DO UPDATE SET
28123
28228
  payload = EXCLUDED.payload,
28124
28229
  updated_at = EXCLUDED.updated_at,
@@ -28131,8 +28236,10 @@ class PostgresJsonRecordStore {
28131
28236
  AND COALESCE(${this.tableName}.version, 0) <= COALESCE(EXCLUDED.version, 0))
28132
28237
  RETURNING payload
28133
28238
  )
28134
- SELECT guarded.all_plans_found, guarded.target_plan_found, guarded.project_conflict,
28135
- (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
28136
28243
  FROM guarded`, [
28137
28244
  this.service,
28138
28245
  value.id,
@@ -28142,9 +28249,26 @@ class PostgresJsonRecordStore {
28142
28249
  numberValue3(value.version),
28143
28250
  jsonbParam(planIds),
28144
28251
  targetPlanId,
28145
- explicitProject
28252
+ explicitProject,
28253
+ Boolean(parentGuard),
28254
+ parentGuard?.parentId ?? null,
28255
+ parentGuard?.expectedVersion ?? null,
28256
+ parentGuard?.operation === "update"
28146
28257
  ]);
28147
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
+ }
28148
28272
  if (!row?.all_plans_found || !row.target_plan_found) {
28149
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 });
28150
28274
  }
@@ -28152,6 +28276,8 @@ class PostgresJsonRecordStore {
28152
28276
  throw new ResourceConflictError("PLAN_PROJECT_LINK_CONFLICT", `Task project conflicts with linked plan ${targetPlanId}`);
28153
28277
  }
28154
28278
  if (!row.payload) {
28279
+ if (row.current_payload)
28280
+ return payloadRecord2(row.current_payload);
28155
28281
  return await requireRecord("tasks", value.id, this);
28156
28282
  }
28157
28283
  return payloadRecord2(row.payload);
@@ -28477,6 +28603,80 @@ class PostgresJsonRecordStore {
28477
28603
  version: numberValue3(existing["version"])
28478
28604
  }, context);
28479
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
+ }
28480
28680
  async getPlanProjectLinkReceipt(receiptId) {
28481
28681
  const value = await this.get("plan_project_link_receipts", receiptId);
28482
28682
  return value ? assertPlanProjectLinkReceipt(value) : null;
@@ -28897,9 +29097,8 @@ class PostgresJsonRecordStore {
28897
29097
  }
28898
29098
  async function createTask2(input, store, context) {
28899
29099
  const timestamp3 = new Date().toISOString();
28900
- if (input.parent_id && !await store.get("tasks", input.parent_id)) {
28901
- throw new TaskNotFoundError(input.parent_id);
28902
- }
29100
+ const taskId = randomUUID3();
29101
+ await assertTaskParentIntegrityAsync(taskId, input.parent_id, (id) => store.get("tasks", id));
28903
29102
  const linkedPlan = input.plan_id ? await store.get("plans", input.plan_id) : null;
28904
29103
  const requestedProjectId = input.project_id ?? context?.projectId ?? null;
28905
29104
  if (linkedPlan?.project_id && requestedProjectId && requestedProjectId !== linkedPlan.project_id) {
@@ -28908,7 +29107,7 @@ async function createTask2(input, store, context) {
28908
29107
  const effectiveProjectId = linkedPlan?.project_id ?? requestedProjectId;
28909
29108
  const shortId = effectiveProjectId ? await nextTaskShortId2(effectiveProjectId, store, context) : null;
28910
29109
  const task2 = {
28911
- id: randomUUID3(),
29110
+ id: taskId,
28912
29111
  short_id: shortId,
28913
29112
  project_id: effectiveProjectId,
28914
29113
  parent_id: input.parent_id ?? null,
@@ -28964,15 +29163,16 @@ async function createTask2(input, store, context) {
28964
29163
  synced_at: null,
28965
29164
  archived_at: null
28966
29165
  };
28967
- 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);
28968
29167
  await logTaskChange2(storedTask.id, "created", "status", null, storedTask.status, storedTask.assigned_by ?? storedTask.agent_id, store, context);
28969
29168
  return storedTask;
28970
29169
  }
28971
- async function updateTask2(id, input, store) {
29170
+ async function updateTask2(id, input, store, context) {
28972
29171
  const existing = await requireRecord("tasks", id, store);
28973
29172
  if (existing.version !== input.version) {
28974
- throw new Error(`Task ${id} version conflict: expected ${existing.version}, got ${input.version}`);
29173
+ throw new VersionConflictError(id, input.version, existing.version);
28975
29174
  }
29175
+ await assertTaskParentIntegrityAsync(id, input.parent_id, (candidateId) => store.get("tasks", candidateId));
28976
29176
  const effectivePlanId = input.plan_id !== undefined ? input.plan_id : existing.plan_id;
28977
29177
  const linkedPlan = effectivePlanId ? await store.get("plans", effectivePlanId) : null;
28978
29178
  if (linkedPlan?.project_id) {
@@ -28997,10 +29197,19 @@ async function updateTask2(id, input, store) {
28997
29197
  metadata: input.metadata ?? existing.metadata,
28998
29198
  requires_approval: input.requires_approval ?? existing.requires_approval,
28999
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,
29000
29201
  created_by: existing.created_by,
29001
29202
  completed_at: reopened ? null : input.completed_at !== undefined ? input.completed_at : existing.completed_at
29002
29203
  };
29003
- 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;
29004
29213
  }
29005
29214
  async function startTask2(id, agentId, store) {
29006
29215
  const task2 = await requireRecord("tasks", id, store);
@@ -29064,7 +29273,11 @@ async function patchTask(task2, patch, store) {
29064
29273
  version: task2.version + 1,
29065
29274
  updated_at: new Date().toISOString()
29066
29275
  };
29067
- 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
+ });
29068
29281
  }
29069
29282
  var CLOUD_LOCK_EXPIRY_MINUTES = 30;
29070
29283
  function sameCloudLockHolder(stored, incoming) {
@@ -34184,7 +34397,7 @@ function createLocalPrGroupLedger(db = getDatabase()) {
34184
34397
  return new PrGroupLedger(new SqlitePrGroupLedgerPersistence(db));
34185
34398
  }
34186
34399
  // src/project-registration/authority.ts
34187
- import { createHash as createHash14 } from "crypto";
34400
+ import { createHash as createHash15 } from "crypto";
34188
34401
  // src/project-registration/types.ts
34189
34402
  var TODOS_PROJECT_REGISTRATION_ROUTE = "todos.project-registration.v1";
34190
34403
  var TODOS_PROJECT_REGISTRATION_CALLER_ROUTE = "projects.full-registration.v1";
@@ -34478,6 +34691,7 @@ class PostgresTodosProjectRegistrationTransaction {
34478
34691
  AND (payload->>'path' = $2 OR payload->>'task_list_id' = $3)
34479
34692
  ORDER BY payload->>'created_at' ASC, object_id ASC
34480
34693
  LIMIT 1
34694
+ FOR UPDATE
34481
34695
  `, [this.service, path, taskListSlug]);
34482
34696
  return result.rows[0] ? parsePayload3(result.rows[0].payload) : null;
34483
34697
  }
@@ -34488,6 +34702,7 @@ class PostgresTodosProjectRegistrationTransaction {
34488
34702
  AND payload->>'project_id' = $2 AND payload->>'slug' = $3
34489
34703
  ORDER BY payload->>'created_at' ASC, object_id ASC
34490
34704
  LIMIT 1
34705
+ FOR UPDATE
34491
34706
  `, [this.service, projectId, slug]);
34492
34707
  return result.rows[0] ? parsePayload3(result.rows[0].payload) : null;
34493
34708
  }
@@ -34498,10 +34713,24 @@ class PostgresTodosProjectRegistrationTransaction {
34498
34713
  return await this.storage.taskLists.create(input);
34499
34714
  }
34500
34715
  async getProject(id) {
34501
- 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;
34502
34724
  }
34503
34725
  async getTaskList(id) {
34504
- 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;
34505
34734
  }
34506
34735
  async lockCompensationWrites() {
34507
34736
  await this.client.query(`LOCK TABLE ${this.tableName} IN SHARE ROW EXCLUSIVE MODE`);
@@ -34581,11 +34810,102 @@ class PostgresTodosProjectRegistrationBackend {
34581
34810
  async getTaskList(id) {
34582
34811
  return (await this.direct()).getTaskList(id);
34583
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
+ }
34584
34903
  }
34585
34904
 
34586
34905
  // src/project-registration/sqlite.ts
34587
34906
  init_database();
34588
34907
  init_storage_tombstones();
34908
+ import { createHash as createHash14 } from "crypto";
34589
34909
  var sqliteTransactionTails2 = new WeakMap;
34590
34910
  var PROJECT_REFERENCE_COLUMNS = new Set([
34591
34911
  "project_id",
@@ -35239,6 +35559,64 @@ class SqliteTodosProjectRegistrationBackend {
35239
35559
  getTaskList(id) {
35240
35560
  return this.direct.getTaskList(id);
35241
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
+ }
35242
35620
  }
35243
35621
 
35244
35622
  // src/project-registration/authority.ts
@@ -35250,6 +35628,8 @@ var AUTHORITY_ROUTE_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{2,127}$/;
35250
35628
  var PACKAGE_VERSION_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._+-]{0,127}$/;
35251
35629
  var SHA256_PATTERN = /^[0-9a-f]{64}$/;
35252
35630
  var IDEMPOTENCY_PATTERN = /^prk_[0-9a-f]{48}$/;
35631
+ var PROJECT_RESOURCE_PAGE_LIMIT = 500;
35632
+ var PROJECT_RESOURCE_CURSOR_VERSION = 1;
35253
35633
 
35254
35634
  class WriteBoundaryError extends Error {
35255
35635
  point;
@@ -35277,7 +35657,7 @@ function canonicalize4(value) {
35277
35657
  return out;
35278
35658
  }
35279
35659
  function digestProjectRegistrationValue(value) {
35280
- return createHash14("sha256").update(canonicalProjectRegistrationJson(value)).digest("hex");
35660
+ return createHash15("sha256").update(canonicalProjectRegistrationJson(value)).digest("hex");
35281
35661
  }
35282
35662
  function deriveTodosProjectRegistrationIdempotencyKey(input) {
35283
35663
  return `prk_${digestProjectRegistrationValue({
@@ -35389,35 +35769,68 @@ function projectRecord(project) {
35389
35769
  return {
35390
35770
  target_id: project.id,
35391
35771
  revision: project.updated_at,
35392
- digest: digestProjectRegistrationValue({
35393
- id: project.id,
35394
- name: project.name,
35395
- path: project.path,
35396
- description: project.description,
35397
- task_list_id: project.task_list_id,
35398
- task_prefix: project.task_prefix,
35399
- task_counter: project.task_counter,
35400
- created_at: project.created_at,
35401
- updated_at: project.updated_at
35402
- })
35772
+ digest: projectRegistrationDigest(project)
35403
35773
  };
35404
35774
  }
35405
35775
  function taskListRecord(taskList) {
35406
35776
  return {
35407
35777
  target_id: taskList.id,
35408
35778
  revision: taskList.updated_at,
35409
- digest: digestProjectRegistrationValue({
35410
- id: taskList.id,
35411
- project_id: taskList.project_id,
35412
- slug: taskList.slug,
35413
- name: taskList.name,
35414
- description: taskList.description,
35415
- metadata: taskList.metadata,
35416
- created_at: taskList.created_at,
35417
- 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
35418
35789
  })
35419
35790
  };
35420
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
35799
+ })
35800
+ };
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
+ }
35421
35834
  function receiptId(input) {
35422
35835
  return `tpr_${digestProjectRegistrationValue(input).slice(0, 40)}`;
35423
35836
  }
@@ -35437,6 +35850,29 @@ function assertCapabilityRequest(request, capability2) {
35437
35850
  }
35438
35851
  }
35439
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) {
35440
35876
  return digestProjectRegistrationValue({
35441
35877
  authority_route: request.authority_route,
35442
35878
  package_version: request.package_version,
@@ -35458,6 +35894,11 @@ function normalizedCallDigest(request) {
35458
35894
  accepted_receipt_id: request.accepted_receipt?.receipt_id ?? null
35459
35895
  });
35460
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
+ }
35461
35902
  function assertCommonRequest(request, capability2) {
35462
35903
  assertBounds(request);
35463
35904
  assertResourceKind(request.resource_kind);
@@ -35499,6 +35940,9 @@ function assertCommonRequest(request, capability2) {
35499
35940
  if (!request.desired || typeof request.desired !== "object" || Array.isArray(request.desired)) {
35500
35941
  throw new TodosProjectRegistrationError("TODOS_PROJECT_REGISTRATION_INVALID_INPUT", "desired must be a JSON object");
35501
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
+ }
35502
35946
  const expectedKey = deriveTodosProjectRegistrationIdempotencyKey({
35503
35947
  operation_id: request.operation_id,
35504
35948
  step_id: request.step_id,
@@ -35520,7 +35964,7 @@ function assertForwardRequest(request, capability2) {
35520
35964
  const expectedRequestDigest = digestProjectRegistrationValue(request.desired);
35521
35965
  const expectedPreconditionDigest = digestProjectRegistrationValue({
35522
35966
  target_selector: request.target_selector,
35523
- expected: "absent"
35967
+ expected: request.bind_existing === true ? "absent_or_matching_existing" : "absent"
35524
35968
  });
35525
35969
  if (request.request_digest !== expectedRequestDigest || request.precondition_digest !== expectedPreconditionDigest) {
35526
35970
  throw new TodosProjectRegistrationError("TODOS_PROJECT_REGISTRATION_DIGEST_MISMATCH", "request_digest or precondition_digest does not match normalized forward semantics", {
@@ -35599,7 +36043,7 @@ function receiptBase(request, callDigest, capability2) {
35599
36043
  normalized_call_digest: callDigest
35600
36044
  };
35601
36045
  }
35602
- function makeAcceptedReceipt(request, callDigest, capability2, record, createdAt2) {
36046
+ function makeAcceptedReceipt(request, callDigest, capability2, record, createdAt2, createdByOperation = true) {
35603
36047
  return makeReceipt({
35604
36048
  ...receiptBase(request, callDigest, capability2),
35605
36049
  outcome: "accepted",
@@ -35609,7 +36053,7 @@ function makeAcceptedReceipt(request, callDigest, capability2, record, createdAt
35609
36053
  result_digest: record.digest,
35610
36054
  duplicate_of_receipt_id: null,
35611
36055
  accepted_receipt_id: request.direction === "inverse" ? request.accepted_receipt.receipt_id : null,
35612
- created_by_operation: true
36056
+ created_by_operation: createdByOperation
35613
36057
  }, createdAt2);
35614
36058
  }
35615
36059
  function makeDuplicateReceipt(request, callDigest, capability2, accepted, createdAt2) {
@@ -35671,6 +36115,53 @@ function bindingFor(request, callDigest, timestamp3, capability2) {
35671
36115
  updated_at: timestamp3
35672
36116
  };
35673
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
+ }
35674
36165
 
35675
36166
  class PackageOwnedTodosProjectRegistrationAuthority {
35676
36167
  backend;
@@ -35692,6 +36183,10 @@ class PackageOwnedTodosProjectRegistrationAuthority {
35692
36183
  immutable_receipts: true,
35693
36184
  exact_terminal_lookup: true,
35694
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,
35695
36190
  conditional_inverse: true,
35696
36191
  ambiguous_outcome_reconciliation: true
35697
36192
  };
@@ -35752,7 +36247,7 @@ class PackageOwnedTodosProjectRegistrationAuthority {
35752
36247
  if (!accepted2) {
35753
36248
  throw new TodosProjectRegistrationError("TODOS_PROJECT_REGISTRATION_CONFLICT", "duplicate receipt points to a missing accepted receipt");
35754
36249
  }
35755
- if (accepted2.normalized_call_digest !== callDigest) {
36250
+ if (!acceptedCallMatches(request, accepted2, callDigest)) {
35756
36251
  return this.terminalFor(transaction, request, callDigest, "operation_step_semantics_changed", { targetId: accepted2.target_id });
35757
36252
  }
35758
36253
  return this.duplicateFor(transaction, request, callDigest, accepted2);
@@ -35766,7 +36261,7 @@ class PackageOwnedTodosProjectRegistrationAuthority {
35766
36261
  });
35767
36262
  if (!accepted)
35768
36263
  return null;
35769
- if (accepted.normalized_call_digest === callDigest) {
36264
+ if (acceptedCallMatches(request, accepted, callDigest)) {
35770
36265
  return this.duplicateFor(transaction, request, callDigest, accepted);
35771
36266
  }
35772
36267
  return this.terminalFor(transaction, request, callDigest, "operation_step_semantics_changed", { targetId: accepted.target_id });
@@ -35777,6 +36272,12 @@ class PackageOwnedTodosProjectRegistrationAuthority {
35777
36272
  const slug2 = taskListSlug(request.project_slug);
35778
36273
  const conflict2 = await transaction.findProjectConflict(path, slug2);
35779
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
+ }
35780
36281
  return this.terminalFor(transaction, request, normalizedCallDigest(request), "target_already_exists", { targetId: conflict2.id });
35781
36282
  }
35782
36283
  await this.fault("before_object_write", request);
@@ -35788,7 +36289,10 @@ class PackageOwnedTodosProjectRegistrationAuthority {
35788
36289
  task_prefix: deterministicTaskPrefix(request.project_slug)
35789
36290
  });
35790
36291
  await this.fault("after_object_write", request);
35791
- return projectRecord(project);
36292
+ return {
36293
+ record: projectRecord(project),
36294
+ created_by_operation: true
36295
+ };
35792
36296
  }
35793
36297
  const todosProjectId = String(request.desired["todos_project_id"]);
35794
36298
  const sourceBinding = await transaction.getBinding(authorityScope(this.capabilityValue), "project", request.project_id);
@@ -35802,6 +36306,12 @@ class PackageOwnedTodosProjectRegistrationAuthority {
35802
36306
  const slug = taskListSlug(request.project_slug);
35803
36307
  const conflict = await transaction.findTaskListConflict(todosProjectId, slug);
35804
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
+ }
35805
36315
  return this.terminalFor(transaction, request, normalizedCallDigest(request), "target_already_exists", { targetId: conflict.id });
35806
36316
  }
35807
36317
  await this.fault("before_object_write", request);
@@ -35818,7 +36328,10 @@ class PackageOwnedTodosProjectRegistrationAuthority {
35818
36328
  if (taskList.project_id !== todosProjectId) {
35819
36329
  throw new TodosProjectRegistrationError("TODOS_PROJECT_REGISTRATION_CONFLICT", "task-list create did not preserve the exact full Todos project id");
35820
36330
  }
35821
- return taskListRecord(taskList);
36331
+ return {
36332
+ record: taskListRecord(taskList),
36333
+ created_by_operation: true
36334
+ };
35822
36335
  }
35823
36336
  async create(request) {
35824
36337
  const startedAt = Date.now();
@@ -35840,9 +36353,9 @@ class PackageOwnedTodosProjectRegistrationAuthority {
35840
36353
  const claimed = await transaction.claimBinding(bindingFor(request, callDigest, timestamp3, this.capabilityValue));
35841
36354
  if (!claimed) {
35842
36355
  const binding = await transaction.getBinding(authorityScope(this.capabilityValue), request.resource_kind, request.target_selector);
35843
- if (binding?.state === "accepted" && binding.normalized_call_digest === callDigest && binding.accepted_receipt_id) {
36356
+ if (binding?.state === "accepted" && binding.accepted_receipt_id) {
35844
36357
  const accepted2 = await transaction.getReceiptById(binding.accepted_receipt_id);
35845
- if (accepted2) {
36358
+ if (accepted2 && binding.normalized_call_digest === accepted2.normalized_call_digest && acceptedCallMatches(request, accepted2, callDigest)) {
35846
36359
  return this.duplicateFor(transaction, request, callDigest, accepted2);
35847
36360
  }
35848
36361
  }
@@ -35853,15 +36366,15 @@ class PackageOwnedTodosProjectRegistrationAuthority {
35853
36366
  await transaction.setBindingTerminal(authorityScope(this.capabilityValue), request.resource_kind, request.target_selector, this.now());
35854
36367
  return recordOrTerminal;
35855
36368
  }
35856
- 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);
35857
36370
  await this.fault("before_receipt_write", request);
35858
36371
  const stored = await insertDeterministicReceipt(transaction, accepted);
35859
36372
  await this.fault("after_receipt_write", request);
35860
36373
  await transaction.setBindingAccepted(authorityScope(this.capabilityValue), request.resource_kind, request.target_selector, {
35861
- target_id: recordOrTerminal.target_id,
36374
+ target_id: recordOrTerminal.record.target_id,
35862
36375
  accepted_receipt_id: stored.receipt_id,
35863
- result_revision: recordOrTerminal.revision,
35864
- result_digest: recordOrTerminal.digest,
36376
+ result_revision: recordOrTerminal.record.revision,
36377
+ result_digest: recordOrTerminal.record.digest,
35865
36378
  updated_at: this.now()
35866
36379
  });
35867
36380
  return stored;
@@ -35909,7 +36422,7 @@ class PackageOwnedTodosProjectRegistrationAuthority {
35909
36422
  direction: request.direction
35910
36423
  });
35911
36424
  if (accepted) {
35912
- 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 });
35913
36426
  }
35914
36427
  const timestamp3 = this.now();
35915
36428
  const claimed = await transaction.claimBinding(bindingFor(request, callDigest, timestamp3, this.capabilityValue));
@@ -35942,9 +36455,14 @@ class PackageOwnedTodosProjectRegistrationAuthority {
35942
36455
  if (request.max_items !== 1) {
35943
36456
  throw new TodosProjectRegistrationError("TODOS_PROJECT_REGISTRATION_INVALID_BOUNDS", "max_items must be exactly 1 for terminal receipt lookup");
35944
36457
  }
35945
- 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) {
36458
+ if (request.authority !== "todos" || request.authority_id !== this.capabilityValue.authority_id || request.tenant_id !== this.capabilityValue.tenant_id) {
35946
36459
  throw new TodosProjectRegistrationError("TODOS_PROJECT_REGISTRATION_CAPABILITY_MISMATCH", "receipt lookup does not match this authority capability identity");
35947
36460
  }
36461
+ requireString(request.corpus_id, "corpus_id", {
36462
+ min: 3,
36463
+ max: 128,
36464
+ pattern: AUTHORITY_ROUTE_PATTERN
36465
+ });
35948
36466
  requireString(request.authority_route, "authority_route", {
35949
36467
  min: 3,
35950
36468
  max: 128,
@@ -35975,6 +36493,7 @@ class PackageOwnedTodosProjectRegistrationAuthority {
35975
36493
  }
35976
36494
  const receipt = await this.backend.getReceiptForLookup({
35977
36495
  ...authorityScope(this.capabilityValue),
36496
+ corpus_id: request.corpus_id,
35978
36497
  route: request.authority_route,
35979
36498
  package_version: request.package_version,
35980
36499
  operation_id: request.operation_id,
@@ -35989,6 +36508,174 @@ class PackageOwnedTodosProjectRegistrationAuthority {
35989
36508
  }
35990
36509
  return withResponseControl({ receipt: publicReceipt(receipt) }, request, startedAt);
35991
36510
  }
36511
+ async listProjectResources(request) {
36512
+ const sourceProjectId = requireString(request.source_project_id, "source_project_id", { min: 16, max: 128, pattern: WORKSPACE_ID_PATTERN });
36513
+ if (!Number.isSafeInteger(request.limit) || request.limit <= 0 || request.limit > PROJECT_RESOURCE_PAGE_LIMIT) {
36514
+ throw new TodosProjectRegistrationError("TODOS_PROJECT_REGISTRATION_INVALID_BOUNDS", `limit must be an integer from 1 to ${PROJECT_RESOURCE_PAGE_LIMIT}`);
36515
+ }
36516
+ if (request.include_anchors !== undefined && typeof request.include_anchors !== "boolean") {
36517
+ throw new TodosProjectRegistrationError("TODOS_PROJECT_REGISTRATION_INVALID_INPUT", "include_anchors must be boolean when supplied");
36518
+ }
36519
+ const includeAnchors = request.include_anchors === true;
36520
+ const projectBinding = await this.backend.getBinding(authorityScope(this.capabilityValue), "project", sourceProjectId);
36521
+ if (!projectBinding || projectBinding.state !== "accepted" || !projectBinding.target_id) {
36522
+ 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 });
36523
+ }
36524
+ const taskListBinding = await this.backend.getBinding(authorityScope(this.capabilityValue), "task_list", `${projectBinding.target_id}:default`);
36525
+ if (!taskListBinding || taskListBinding.state !== "accepted" || !taskListBinding.target_id) {
36526
+ throw new TodosProjectRegistrationError("TODOS_PROJECT_REGISTRATION_RECORD_NOT_FOUND", "no accepted canonical task-list binding exists for this exact Todos project id", {
36527
+ source_project_id: sourceProjectId,
36528
+ todos_project_id: projectBinding.target_id
36529
+ });
36530
+ }
36531
+ const cursor = decodeProjectResourceCursor(request.cursor, {
36532
+ source_project_id: sourceProjectId,
36533
+ include_anchors: includeAnchors
36534
+ });
36535
+ const collectionInput = {
36536
+ todos_project_id: projectBinding.target_id,
36537
+ task_list_id: taskListBinding.target_id,
36538
+ include_anchors: includeAnchors
36539
+ };
36540
+ const collectionRevision = await this.backend.getProjectResourceCollectionRevision(collectionInput);
36541
+ if (cursor && cursor.collection_revision !== collectionRevision) {
36542
+ throw new TodosProjectRegistrationError("TODOS_PROJECT_REGISTRATION_COLLECTION_CHANGED", "project-resource collection changed during pagination; restart from the first page", {
36543
+ source_project_id: sourceProjectId,
36544
+ expected_collection_revision: cursor.collection_revision,
36545
+ current_collection_revision: collectionRevision
36546
+ });
36547
+ }
36548
+ const candidates = await this.backend.listProjectResourceCandidates({
36549
+ ...collectionInput,
36550
+ after: cursor ? { kind_rank: cursor.kind_rank, target_id: cursor.target_id } : null,
36551
+ limit: request.limit + 1
36552
+ });
36553
+ const verifiedCollectionRevision = await this.backend.getProjectResourceCollectionRevision(collectionInput);
36554
+ if (verifiedCollectionRevision !== collectionRevision) {
36555
+ throw new TodosProjectRegistrationError("TODOS_PROJECT_REGISTRATION_COLLECTION_CHANGED", "project-resource collection changed while producing a page; restart from the first page", {
36556
+ source_project_id: sourceProjectId,
36557
+ expected_collection_revision: collectionRevision,
36558
+ current_collection_revision: verifiedCollectionRevision
36559
+ });
36560
+ }
36561
+ const hasMore = candidates.length > request.limit;
36562
+ const pageCandidates = candidates.slice(0, request.limit);
36563
+ const resources = pageCandidates.map((candidate) => projectResourceFromCandidate(sourceProjectId, candidate));
36564
+ const last = pageCandidates.at(-1);
36565
+ return {
36566
+ authority: "todos",
36567
+ route: this.capabilityValue.route,
36568
+ package_version: this.capabilityValue.package_version,
36569
+ authority_id: this.capabilityValue.authority_id,
36570
+ tenant_id: this.capabilityValue.tenant_id,
36571
+ corpus_id: this.capabilityValue.corpus_id,
36572
+ source_project_id: sourceProjectId,
36573
+ todos_project_id: projectBinding.target_id,
36574
+ task_list_id: taskListBinding.target_id,
36575
+ include_anchors: includeAnchors,
36576
+ collection_revision: collectionRevision,
36577
+ limit: request.limit,
36578
+ count: resources.length,
36579
+ resources,
36580
+ has_more: hasMore,
36581
+ next_cursor: hasMore && last ? encodeProjectResourceCursor({
36582
+ source_project_id: sourceProjectId,
36583
+ include_anchors: includeAnchors,
36584
+ collection_revision: collectionRevision,
36585
+ kind_rank: last.kind_rank,
36586
+ target_id: last.target_id
36587
+ }) : null,
36588
+ complete: !hasMore,
36589
+ truncated: false
36590
+ };
36591
+ }
36592
+ async validatePriorRegistrationAdoption(sourceRequest, sourceReceipt, currentRecord) {
36593
+ const startedAt = Date.now();
36594
+ if (!sourceRequest || typeof sourceRequest !== "object" || Array.isArray(sourceRequest) || !sourceReceipt || typeof sourceReceipt !== "object" || Array.isArray(sourceReceipt) || !currentRecord || typeof currentRecord !== "object" || Array.isArray(currentRecord)) {
36595
+ throw new TodosProjectRegistrationError("TODOS_PROJECT_REGISTRATION_ADOPTION_REJECTED", "source request, source receipt, and current record must be present objects");
36596
+ }
36597
+ requireString(sourceRequest.package_version, "package_version", {
36598
+ max: 128,
36599
+ pattern: PACKAGE_VERSION_PATTERN
36600
+ });
36601
+ requireString(sourceRequest.corpus_id, "corpus_id", {
36602
+ min: 3,
36603
+ max: 128,
36604
+ pattern: AUTHORITY_ROUTE_PATTERN
36605
+ });
36606
+ assertForwardRequest(sourceRequest, {
36607
+ ...this.capabilityValue,
36608
+ package_version: sourceRequest.package_version,
36609
+ corpus_id: sourceRequest.corpus_id
36610
+ });
36611
+ const validation = await this.backend.transaction(async (transaction) => {
36612
+ const storedSource = await transaction.getReceiptById(sourceReceipt.receipt_id);
36613
+ if (!storedSource || !canonicalValuesEqual(publicReceipt(storedSource), sourceReceipt) || storedSource.outcome !== "accepted" && storedSource.outcome !== "duplicate_of_accepted") {
36614
+ throw new TodosProjectRegistrationError("TODOS_PROJECT_REGISTRATION_ADOPTION_REJECTED", "source receipt is not an exact immutable accepted or duplicate receipt", { receipt_id: sourceReceipt.receipt_id });
36615
+ }
36616
+ const accepted = storedSource.outcome === "accepted" ? storedSource : storedSource.duplicate_of_receipt_id ? await transaction.getReceiptById(storedSource.duplicate_of_receipt_id) : null;
36617
+ if (!accepted || accepted.outcome !== "accepted" || !accepted.target_id || !accepted.result_revision || !accepted.result_digest) {
36618
+ throw new TodosProjectRegistrationError("TODOS_PROJECT_REGISTRATION_ADOPTION_REJECTED", "source receipt does not resolve to one complete accepted receipt", { receipt_id: sourceReceipt.receipt_id });
36619
+ }
36620
+ 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);
36621
+ 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)) {
36622
+ throw new TodosProjectRegistrationError("TODOS_PROJECT_REGISTRATION_ADOPTION_REJECTED", "receipt authority, operation, request, precondition, or normalized-call lineage changed");
36623
+ }
36624
+ const binding = await transaction.getBinding({
36625
+ authority_id: sourceRequest.authority_id,
36626
+ tenant_id: sourceRequest.tenant_id,
36627
+ corpus_id: sourceRequest.corpus_id
36628
+ }, sourceRequest.resource_kind, sourceRequest.target_selector);
36629
+ 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) {
36630
+ throw new TodosProjectRegistrationError("TODOS_PROJECT_REGISTRATION_ADOPTION_REJECTED", "accepted binding does not match the exact accepted receipt and source lineage");
36631
+ }
36632
+ const current = sourceRequest.resource_kind === "project" ? await transaction.getProject(accepted.target_id) : await transaction.getTaskList(accepted.target_id);
36633
+ if (!current || !canonicalValuesEqual(current, currentRecord) || current.id !== accepted.target_id || current.created_at !== accepted.result_revision) {
36634
+ throw new TodosProjectRegistrationError("TODOS_PROJECT_REGISTRATION_ADOPTION_REJECTED", "current record does not match the accepted target incarnation", { target_id: accepted.target_id });
36635
+ }
36636
+ let stableMatch = false;
36637
+ if (sourceRequest.resource_kind === "task_list") {
36638
+ stableMatch = taskListRegistrationDigest({
36639
+ ...current,
36640
+ updated_at: accepted.result_revision
36641
+ }) === accepted.result_digest;
36642
+ } else {
36643
+ const project = current;
36644
+ if (!Number.isSafeInteger(project.task_counter) || project.task_counter < 0) {
36645
+ throw new TodosProjectRegistrationError("TODOS_PROJECT_REGISTRATION_ADOPTION_REJECTED", "current project task counter is not a valid monotonic registration field");
36646
+ }
36647
+ for (let priorTaskCounter = 0;priorTaskCounter <= project.task_counter; priorTaskCounter += 1) {
36648
+ if (Date.now() - startedAt > sourceRequest.time_budget_ms) {
36649
+ throw new TodosProjectRegistrationError("TODOS_PROJECT_REGISTRATION_TIME_BUDGET_EXCEEDED", "prior registration adoption validation exceeded its time budget");
36650
+ }
36651
+ if (projectRegistrationDigest({
36652
+ ...project,
36653
+ task_counter: priorTaskCounter,
36654
+ updated_at: accepted.result_revision
36655
+ }) === accepted.result_digest) {
36656
+ stableMatch = true;
36657
+ break;
36658
+ }
36659
+ }
36660
+ }
36661
+ if (!stableMatch) {
36662
+ throw new TodosProjectRegistrationError("TODOS_PROJECT_REGISTRATION_ADOPTION_REJECTED", "stable project-registration fields changed after the accepted receipt", { target_id: accepted.target_id });
36663
+ }
36664
+ return {
36665
+ valid: true,
36666
+ resource_kind: sourceRequest.resource_kind,
36667
+ target_id: accepted.target_id,
36668
+ source_receipt_id: storedSource.receipt_id,
36669
+ accepted_receipt_id: accepted.receipt_id,
36670
+ source_outcome: storedSource.outcome,
36671
+ created_at: current.created_at,
36672
+ current_revision: current.updated_at,
36673
+ accepted_result_digest: accepted.result_digest
36674
+ };
36675
+ });
36676
+ assertWithinBounds(validation, sourceRequest, startedAt);
36677
+ return validation;
36678
+ }
35992
36679
  async storedAcceptedReceipt(request, supplied) {
35993
36680
  const stored = await this.backend.getReceiptById(supplied.receipt_id);
35994
36681
  if (!stored || stored.outcome !== "accepted" || !stored.created_by_operation || canonicalProjectRegistrationJson(publicReceipt(stored)) !== canonicalProjectRegistrationJson(supplied)) {
@@ -36160,6 +36847,65 @@ function createPostgresTodosProjectRegistrationAuthority(client, options = {}) {
36160
36847
  cursorTableName
36161
36848
  }), authorityOptions);
36162
36849
  }
36850
+ // src/project-registration/adoption-validation.ts
36851
+ var VALIDATION_KEYS = [
36852
+ "valid",
36853
+ "resource_kind",
36854
+ "target_id",
36855
+ "source_receipt_id",
36856
+ "accepted_receipt_id",
36857
+ "source_outcome",
36858
+ "created_at",
36859
+ "current_revision",
36860
+ "accepted_result_digest"
36861
+ ];
36862
+ function isRecord2(value) {
36863
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
36864
+ }
36865
+ function isNonEmptyString(value) {
36866
+ return typeof value === "string" && value.length > 0;
36867
+ }
36868
+ function hasExactKeys(value, expected) {
36869
+ const actual = Object.keys(value).sort();
36870
+ const wanted = [...expected].sort();
36871
+ return actual.length === wanted.length && actual.every((key, index) => key === wanted[index]);
36872
+ }
36873
+ function adoptionRejected(message) {
36874
+ throw new TodosProjectRegistrationError("TODOS_PROJECT_REGISTRATION_ADOPTION_REJECTED", `TODOS_PROJECT_REGISTRATION_ADOPTION_REJECTED: ${message}`);
36875
+ }
36876
+ function assertTodosPriorRegistrationAdoptionValidationEnvelope(value, input) {
36877
+ if (!isRecord2(input) || !hasExactKeys(input, [
36878
+ "source_request",
36879
+ "source_receipt",
36880
+ "current_record"
36881
+ ])) {
36882
+ adoptionRejected("prior-adoption validation input is incomplete");
36883
+ }
36884
+ const request = input["source_request"];
36885
+ const receipt = input["source_receipt"];
36886
+ const current = input["current_record"];
36887
+ if (!isRecord2(request) || !isRecord2(receipt) || !isRecord2(current)) {
36888
+ adoptionRejected("prior-adoption validation input records are incomplete");
36889
+ }
36890
+ const resourceKind = request["resource_kind"];
36891
+ const sourceOutcome = receipt["outcome"];
36892
+ const acceptedReceiptId = sourceOutcome === "accepted" ? receipt["receipt_id"] : sourceOutcome === "duplicate_of_accepted" ? receipt["duplicate_of_receipt_id"] : null;
36893
+ 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"]) {
36894
+ adoptionRejected("prior-adoption validation input does not carry one complete accepted receipt and current target incarnation");
36895
+ }
36896
+ if (sourceOutcome === "accepted" && receipt["duplicate_of_receipt_id"] !== null || sourceOutcome === "duplicate_of_accepted" && receipt["duplicate_of_receipt_id"] !== acceptedReceiptId) {
36897
+ adoptionRejected("prior-adoption validation source receipt lineage is incomplete");
36898
+ }
36899
+ if (!isRecord2(value) || !hasExactKeys(value, ["validation"]) || !isRecord2(value["validation"]) || !hasExactKeys(value["validation"], VALIDATION_KEYS)) {
36900
+ adoptionRejected("prior-adoption validation response envelope is incomplete");
36901
+ }
36902
+ const validation = value["validation"];
36903
+ 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"]) {
36904
+ adoptionRejected("prior-adoption validation response does not prove the exact accepted receipt and current target");
36905
+ }
36906
+ return validation;
36907
+ }
36908
+
36163
36909
  // src/project-registration/http.ts
36164
36910
  var JSON_HEADERS = { "Content-Type": "application/json" };
36165
36911
  function json(body, status = 200) {
@@ -36206,6 +36952,20 @@ async function handleTodosProjectRegistrationHttpRequest(req, url, authority, ba
36206
36952
  if ((action === "" || action === "capability") && method === "GET") {
36207
36953
  return json({ capability: await authority.capability() });
36208
36954
  }
36955
+ if (action === "resources" && method === "GET") {
36956
+ const sourceProjectId = url.searchParams.get("source_project_id");
36957
+ const limit = Number(url.searchParams.get("limit") ?? "100");
36958
+ const includeAnchorsRaw = url.searchParams.get("include_anchors");
36959
+ const includeAnchors = includeAnchorsRaw === null ? false : includeAnchorsRaw === "true" ? true : includeAnchorsRaw === "false" ? false : includeAnchorsRaw;
36960
+ return json({
36961
+ page: await authority.listProjectResources({
36962
+ source_project_id: sourceProjectId,
36963
+ limit,
36964
+ include_anchors: includeAnchors,
36965
+ cursor: url.searchParams.get("cursor") ?? undefined
36966
+ })
36967
+ });
36968
+ }
36209
36969
  if (method !== "POST")
36210
36970
  return json({ error: "method not allowed" }, 405);
36211
36971
  const body = await readJson(req);
@@ -36228,6 +36988,12 @@ async function handleTodosProjectRegistrationHttpRequest(req, url, authority, ba
36228
36988
  record: await authority.readExact(body)
36229
36989
  });
36230
36990
  }
36991
+ if (action === "validate-prior-adoption") {
36992
+ const input = body;
36993
+ return json({
36994
+ validation: await authority.validatePriorRegistrationAdoption(input.source_request, input.source_receipt, input.current_record)
36995
+ });
36996
+ }
36231
36997
  if (action === "compensate") {
36232
36998
  return json({
36233
36999
  receipt: await authority.compensate(body)
@@ -36302,6 +37068,28 @@ class TodosProjectRegistrationHttpClient {
36302
37068
  async lookupReceipt(request) {
36303
37069
  return this.request("/receipts/lookup", { method: "POST", body: JSON.stringify(request) });
36304
37070
  }
37071
+ async listProjectResources(request) {
37072
+ const query = new URLSearchParams({
37073
+ source_project_id: request.source_project_id,
37074
+ limit: String(request.limit),
37075
+ include_anchors: String(request.include_anchors === true),
37076
+ ...request.cursor ? { cursor: request.cursor } : {}
37077
+ });
37078
+ const body = await this.request(`/resources?${query.toString()}`);
37079
+ return body.page;
37080
+ }
37081
+ async validatePriorRegistrationAdoption(sourceRequest, sourceReceipt, currentRecord) {
37082
+ const input = {
37083
+ source_request: withoutTarget(sourceRequest),
37084
+ source_receipt: sourceReceipt,
37085
+ current_record: currentRecord
37086
+ };
37087
+ const body = await this.request("/validate-prior-adoption", {
37088
+ method: "POST",
37089
+ body: JSON.stringify(input)
37090
+ });
37091
+ return assertTodosPriorRegistrationAdoptionValidationEnvelope(body, input);
37092
+ }
36305
37093
  async compensate(request) {
36306
37094
  const body = await this.request("/compensate", { method: "POST", body: JSON.stringify(withoutTarget(request)) });
36307
37095
  return body.receipt;
@@ -40295,7 +41083,9 @@ init_types();
40295
41083
 
40296
41084
  // src/task-manifest/types.ts
40297
41085
  var TODOS_TASK_MANIFEST_ROUTE = "todos.task-manifest.v1";
41086
+ var TODOS_TASK_MANIFEST_CALLER_ROUTE = "accounts.task-manifest.v1";
40298
41087
  var TODOS_TASK_MANIFEST_SCHEMA_VERSION = 1;
41088
+ var TODOS_TASK_MANIFEST_PLAN_SLUG_PROVENANCE = "deterministic-v1";
40299
41089
  function supportsIdempotentOutboxDelivery(capability2) {
40300
41090
  return capability2 !== null && typeof capability2 === "object" && capability2["idempotent_outbox_delivery"] === true;
40301
41091
  }
@@ -40325,6 +41115,8 @@ var TODOS_TASK_MANIFEST_BOUNDS = {
40325
41115
  };
40326
41116
  var key = exports_external.string().min(1).max(96).regex(/^[a-z][a-z0-9_-]*$/);
40327
41117
  var identifier = exports_external.string().min(1).max(200).regex(/^[A-Za-z0-9][A-Za-z0-9._:-]*$/);
41118
+ var digest = exports_external.string().length(64).regex(/^[0-9a-f]{64}$/);
41119
+ var idempotencyKey = exports_external.string().length(52).regex(/^tmk_[0-9a-f]{48}$/);
40328
41120
  var uuid2 = exports_external.string().uuid();
40329
41121
  var scalar = exports_external.union([exports_external.string().max(4096), exports_external.number().finite(), exports_external.boolean(), exports_external.null()]);
40330
41122
  var boundedScalarRecord = (limit, field2) => exports_external.record(exports_external.string().max(200), scalar).superRefine((value, context) => {
@@ -40367,7 +41159,9 @@ var effect = exports_external.object({
40367
41159
  var schema = exports_external.object({
40368
41160
  version: exports_external.literal(1),
40369
41161
  operation_id: identifier,
40370
- idempotency_key: identifier,
41162
+ step_id: identifier,
41163
+ idempotency_key: idempotencyKey,
41164
+ precondition_digest: digest,
40371
41165
  project_id: uuid2,
40372
41166
  task_list_id: uuid2.optional(),
40373
41167
  if_binding_version: exports_external.number().int().min(0).optional(),
@@ -40383,7 +41177,10 @@ var schema = exports_external.object({
40383
41177
  }).strict();
40384
41178
  var compensationSchema = exports_external.object({
40385
41179
  receipt_id: uuid2,
40386
- idempotency_key: identifier,
41180
+ operation_id: identifier,
41181
+ step_id: identifier,
41182
+ idempotency_key: idempotencyKey,
41183
+ precondition_digest: digest,
40387
41184
  if_binding_version: exports_external.number().int().min(1).max(Number.MAX_SAFE_INTEGER)
40388
41185
  }).strict();
40389
41186
  var bindingLookupSchema = exports_external.object({
@@ -40468,10 +41265,31 @@ function parseTodosTaskManifestBindingLookup(input) {
40468
41265
  }
40469
41266
 
40470
41267
  // src/task-manifest/plan-slug.ts
41268
+ var TASK_MANIFEST_DETERMINISTIC_SLUG_PROVENANCE = "deterministic-v1";
40471
41269
  function taskManifestPlanSlug(manifest, planId) {
40472
41270
  const base = normalizeSlug(manifest.plan.key) || normalizeSlug(manifest.plan.name) || "plan";
40473
41271
  return `${base}-${planId}`;
40474
41272
  }
41273
+ function sqliteLegacyTaskManifestPlanSlug(rows, planId, targetBase) {
41274
+ const target = rows.find((row) => row.id === planId);
41275
+ if (!target)
41276
+ return null;
41277
+ const used = new Set;
41278
+ 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));
41279
+ for (const row of ordered) {
41280
+ const base = row.id === planId ? normalizeSlug(targetBase ?? row.name) || "plan" : normalizeSlug(row.slug || row.name) || "plan";
41281
+ let candidate = base;
41282
+ let suffix = 2;
41283
+ while (used.has(candidate)) {
41284
+ candidate = `${base}-${suffix}`;
41285
+ suffix += 1;
41286
+ }
41287
+ if (row.id === planId)
41288
+ return candidate;
41289
+ used.add(candidate);
41290
+ }
41291
+ return null;
41292
+ }
40475
41293
 
40476
41294
  // src/task-manifest/backend.ts
40477
41295
  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;
@@ -40485,11 +41303,13 @@ function validateTaskManifestBindingLookupRows(rows, tenantId, planId) {
40485
41303
  const row = rows[0];
40486
41304
  const bindingVersion = Number(row.binding_version);
40487
41305
  const state = row.state;
40488
- 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") {
41306
+ 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") {
40489
41307
  throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_LOOKUP_CONFLICT", "Task-manifest binding and immutable apply receipt disagree", { plan_id: planId });
40490
41308
  }
40491
41309
  return {
40492
41310
  plan_id: planId,
41311
+ operation_id: row.binding_operation_id,
41312
+ step_id: row.binding_step_id,
40493
41313
  apply_receipt_id: row.apply_receipt_id,
40494
41314
  binding_version: bindingVersion,
40495
41315
  state
@@ -40597,9 +41417,15 @@ function sqliteTodosTaskManifestSchemaSql() {
40597
41417
  schema_version INTEGER NOT NULL CHECK(schema_version = 1),
40598
41418
  kind TEXT NOT NULL CHECK(kind IN ('apply', 'compensate')),
40599
41419
  operation_id TEXT NOT NULL,
41420
+ step_id TEXT NOT NULL,
40600
41421
  idempotency_key TEXT NOT NULL,
40601
41422
  request_digest TEXT NOT NULL,
41423
+ precondition_digest TEXT NOT NULL,
40602
41424
  result_digest TEXT NOT NULL,
41425
+ slug_provenance TEXT,
41426
+ outcome TEXT NOT NULL CHECK(outcome IN ('accepted', 'terminal_nonacceptance')),
41427
+ reason TEXT,
41428
+ duplicate_of_receipt_id TEXT,
40603
41429
  binding_version INTEGER NOT NULL,
40604
41430
  apply_receipt_id TEXT,
40605
41431
  manifest_json TEXT,
@@ -40610,9 +41436,13 @@ function sqliteTodosTaskManifestSchemaSql() {
40610
41436
  CREATE TABLE IF NOT EXISTS todos_task_manifest_bindings (
40611
41437
  operation_id TEXT PRIMARY KEY,
40612
41438
  tenant_id TEXT NOT NULL,
41439
+ step_id TEXT NOT NULL,
40613
41440
  idempotency_key TEXT NOT NULL UNIQUE,
40614
41441
  request_digest TEXT NOT NULL,
41442
+ precondition_digest TEXT NOT NULL,
40615
41443
  result_digest TEXT NOT NULL,
41444
+ slug_provenance TEXT,
41445
+ outcome TEXT NOT NULL CHECK(outcome IN ('accepted', 'terminal_nonacceptance')),
40616
41446
  apply_receipt_id TEXT NOT NULL UNIQUE REFERENCES todos_task_manifest_receipts(receipt_id),
40617
41447
  manifest_json TEXT NOT NULL,
40618
41448
  result_json TEXT NOT NULL,
@@ -40633,6 +41463,27 @@ function sqliteTodosTaskManifestSchemaSql() {
40633
41463
  created_at TEXT NOT NULL,
40634
41464
  delivered_at TEXT
40635
41465
  );
41466
+ CREATE TABLE IF NOT EXISTS todos_task_manifest_terminal_receipts (
41467
+ receipt_id TEXT PRIMARY KEY,
41468
+ tenant_id TEXT NOT NULL,
41469
+ authority TEXT NOT NULL CHECK(authority = 'todos'),
41470
+ route TEXT NOT NULL,
41471
+ schema_version INTEGER NOT NULL CHECK(schema_version = 1),
41472
+ kind TEXT NOT NULL CHECK(kind IN ('apply', 'compensate')),
41473
+ operation_id TEXT NOT NULL,
41474
+ step_id TEXT NOT NULL,
41475
+ idempotency_key TEXT NOT NULL,
41476
+ request_digest TEXT NOT NULL,
41477
+ precondition_digest TEXT NOT NULL,
41478
+ result_digest TEXT NOT NULL,
41479
+ outcome TEXT NOT NULL CHECK(outcome = 'terminal_nonacceptance'),
41480
+ reason TEXT NOT NULL,
41481
+ binding_version INTEGER NOT NULL,
41482
+ apply_receipt_id TEXT,
41483
+ manifest_json TEXT,
41484
+ result_json TEXT NOT NULL,
41485
+ created_at TEXT NOT NULL
41486
+ );
40636
41487
  CREATE INDEX IF NOT EXISTS idx_todos_task_manifest_outbox_receipt
40637
41488
  ON todos_task_manifest_outbox(apply_receipt_id, status);
40638
41489
  CREATE TRIGGER IF NOT EXISTS todos_task_manifest_receipts_immutable_update
@@ -40643,6 +41494,14 @@ function sqliteTodosTaskManifestSchemaSql() {
40643
41494
  BEFORE DELETE ON todos_task_manifest_receipts BEGIN
40644
41495
  SELECT RAISE(ABORT, 'todos task manifest receipts are immutable');
40645
41496
  END;
41497
+ CREATE TRIGGER IF NOT EXISTS todos_task_manifest_terminal_receipts_immutable_update
41498
+ BEFORE UPDATE ON todos_task_manifest_terminal_receipts BEGIN
41499
+ SELECT RAISE(ABORT, 'todos task manifest terminal receipts are immutable');
41500
+ END;
41501
+ CREATE TRIGGER IF NOT EXISTS todos_task_manifest_terminal_receipts_immutable_delete
41502
+ BEFORE DELETE ON todos_task_manifest_terminal_receipts BEGIN
41503
+ SELECT RAISE(ABORT, 'todos task manifest terminal receipts are immutable');
41504
+ END;
40646
41505
  `;
40647
41506
  }
40648
41507
  function sqliteTableHasColumn(db, tableName, columnName) {
@@ -40659,6 +41518,24 @@ function ensureSqliteTodosTaskManifestSchema(db, tenantId) {
40659
41518
  if (!sqliteTableHasColumn(db, tableName, "tenant_id")) {
40660
41519
  db.exec(`ALTER TABLE "${tableName}" ADD COLUMN tenant_id TEXT NOT NULL DEFAULT ${tenantDefault}`);
40661
41520
  }
41521
+ if (!sqliteTableHasColumn(db, tableName, "slug_provenance")) {
41522
+ db.exec(`ALTER TABLE "${tableName}" ADD COLUMN slug_provenance TEXT`);
41523
+ }
41524
+ }
41525
+ const defaults = [
41526
+ ["todos_task_manifest_receipts", "step_id", "TEXT NOT NULL DEFAULT 'legacy-apply'"],
41527
+ ["todos_task_manifest_receipts", "precondition_digest", `TEXT NOT NULL DEFAULT '${"0".repeat(64)}'`],
41528
+ ["todos_task_manifest_receipts", "outcome", "TEXT NOT NULL DEFAULT 'accepted'"],
41529
+ ["todos_task_manifest_receipts", "reason", "TEXT"],
41530
+ ["todos_task_manifest_receipts", "duplicate_of_receipt_id", "TEXT"],
41531
+ ["todos_task_manifest_bindings", "step_id", "TEXT NOT NULL DEFAULT 'legacy-apply'"],
41532
+ ["todos_task_manifest_bindings", "precondition_digest", `TEXT NOT NULL DEFAULT '${"0".repeat(64)}'`],
41533
+ ["todos_task_manifest_bindings", "outcome", "TEXT NOT NULL DEFAULT 'accepted'"]
41534
+ ];
41535
+ for (const [tableName, columnName, definition] of defaults) {
41536
+ if (!sqliteTableHasColumn(db, tableName, columnName)) {
41537
+ db.exec(`ALTER TABLE "${tableName}" ADD COLUMN "${columnName}" ${definition}`);
41538
+ }
40662
41539
  }
40663
41540
  db.exec(`
40664
41541
  CREATE INDEX IF NOT EXISTS idx_todos_task_manifest_receipts_tenant
@@ -40668,6 +41545,12 @@ function ensureSqliteTodosTaskManifestSchema(db, tenantId) {
40668
41545
  tenant_id,
40669
41546
  json_extract(result_json, '$.graph.plan_id')
40670
41547
  );
41548
+ DROP INDEX IF EXISTS idx_todos_task_manifest_terminal_receipts_lookup;
41549
+ DROP INDEX IF EXISTS idx_todos_task_manifest_terminal_receipts_identity;
41550
+ CREATE INDEX IF NOT EXISTS idx_todos_task_manifest_terminal_receipts_lookup
41551
+ ON todos_task_manifest_terminal_receipts(tenant_id, kind, operation_id, step_id);
41552
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_todos_task_manifest_terminal_receipts_identity
41553
+ ON todos_task_manifest_terminal_receipts(tenant_id, kind, operation_id, step_id);
40671
41554
  `);
40672
41555
  }
40673
41556
  function postgresTodosTaskManifestSchemaSql(tenantId = "default") {
@@ -40681,9 +41564,15 @@ function postgresTodosTaskManifestSchemaSql(tenantId = "default") {
40681
41564
  schema_version integer NOT NULL CHECK(schema_version = 1),
40682
41565
  kind text NOT NULL CHECK(kind IN ('apply', 'compensate')),
40683
41566
  operation_id text NOT NULL,
41567
+ step_id text NOT NULL,
40684
41568
  idempotency_key text NOT NULL,
40685
41569
  request_digest text NOT NULL,
41570
+ precondition_digest text NOT NULL,
40686
41571
  result_digest text NOT NULL,
41572
+ slug_provenance text,
41573
+ outcome text NOT NULL CHECK(outcome IN ('accepted', 'terminal_nonacceptance')),
41574
+ reason text,
41575
+ duplicate_of_receipt_id text,
40687
41576
  binding_version integer NOT NULL,
40688
41577
  apply_receipt_id text,
40689
41578
  manifest_json jsonb,
@@ -40695,12 +41584,28 @@ function postgresTodosTaskManifestSchemaSql(tenantId = "default") {
40695
41584
  ADD COLUMN IF NOT EXISTS tenant_id text NOT NULL DEFAULT ${tenantDefault}`,
40696
41585
  `ALTER TABLE todos_task_manifest_receipts
40697
41586
  ALTER COLUMN tenant_id DROP DEFAULT`,
41587
+ `ALTER TABLE todos_task_manifest_receipts
41588
+ ADD COLUMN IF NOT EXISTS slug_provenance text`,
41589
+ `ALTER TABLE todos_task_manifest_receipts
41590
+ ADD COLUMN IF NOT EXISTS step_id text NOT NULL DEFAULT 'legacy-apply'`,
41591
+ `ALTER TABLE todos_task_manifest_receipts
41592
+ ADD COLUMN IF NOT EXISTS precondition_digest text NOT NULL DEFAULT '${"0".repeat(64)}'`,
41593
+ `ALTER TABLE todos_task_manifest_receipts
41594
+ ADD COLUMN IF NOT EXISTS outcome text NOT NULL DEFAULT 'accepted'`,
41595
+ `ALTER TABLE todos_task_manifest_receipts
41596
+ ADD COLUMN IF NOT EXISTS reason text`,
41597
+ `ALTER TABLE todos_task_manifest_receipts
41598
+ ADD COLUMN IF NOT EXISTS duplicate_of_receipt_id text`,
40698
41599
  `CREATE TABLE IF NOT EXISTS todos_task_manifest_bindings (
40699
41600
  operation_id text PRIMARY KEY,
40700
41601
  tenant_id text NOT NULL,
41602
+ step_id text NOT NULL,
40701
41603
  idempotency_key text NOT NULL UNIQUE,
40702
41604
  request_digest text NOT NULL,
41605
+ precondition_digest text NOT NULL,
40703
41606
  result_digest text NOT NULL,
41607
+ slug_provenance text,
41608
+ outcome text NOT NULL CHECK(outcome IN ('accepted', 'terminal_nonacceptance')),
40704
41609
  apply_receipt_id text NOT NULL UNIQUE REFERENCES todos_task_manifest_receipts(receipt_id),
40705
41610
  manifest_json jsonb NOT NULL,
40706
41611
  result_json jsonb NOT NULL,
@@ -40714,6 +41619,14 @@ function postgresTodosTaskManifestSchemaSql(tenantId = "default") {
40714
41619
  ADD COLUMN IF NOT EXISTS tenant_id text NOT NULL DEFAULT ${tenantDefault}`,
40715
41620
  `ALTER TABLE todos_task_manifest_bindings
40716
41621
  ALTER COLUMN tenant_id DROP DEFAULT`,
41622
+ `ALTER TABLE todos_task_manifest_bindings
41623
+ ADD COLUMN IF NOT EXISTS slug_provenance text`,
41624
+ `ALTER TABLE todos_task_manifest_bindings
41625
+ ADD COLUMN IF NOT EXISTS step_id text NOT NULL DEFAULT 'legacy-apply'`,
41626
+ `ALTER TABLE todos_task_manifest_bindings
41627
+ ADD COLUMN IF NOT EXISTS precondition_digest text NOT NULL DEFAULT '${"0".repeat(64)}'`,
41628
+ `ALTER TABLE todos_task_manifest_bindings
41629
+ ADD COLUMN IF NOT EXISTS outcome text NOT NULL DEFAULT 'accepted'`,
40717
41630
  `CREATE TABLE IF NOT EXISTS todos_task_manifest_outbox (
40718
41631
  id text PRIMARY KEY,
40719
41632
  apply_receipt_id text NOT NULL REFERENCES todos_task_manifest_receipts(receipt_id),
@@ -40725,10 +41638,37 @@ function postgresTodosTaskManifestSchemaSql(tenantId = "default") {
40725
41638
  created_at timestamptz NOT NULL,
40726
41639
  delivered_at timestamptz
40727
41640
  )`,
41641
+ `CREATE TABLE IF NOT EXISTS todos_task_manifest_terminal_receipts (
41642
+ receipt_id text PRIMARY KEY,
41643
+ tenant_id text NOT NULL,
41644
+ authority text NOT NULL CHECK(authority = 'todos'),
41645
+ route text NOT NULL,
41646
+ schema_version integer NOT NULL CHECK(schema_version = 1),
41647
+ kind text NOT NULL CHECK(kind IN ('apply', 'compensate')),
41648
+ operation_id text NOT NULL,
41649
+ step_id text NOT NULL,
41650
+ idempotency_key text NOT NULL,
41651
+ request_digest text NOT NULL,
41652
+ precondition_digest text NOT NULL,
41653
+ result_digest text NOT NULL,
41654
+ outcome text NOT NULL CHECK(outcome = 'terminal_nonacceptance'),
41655
+ reason text NOT NULL,
41656
+ binding_version integer NOT NULL,
41657
+ apply_receipt_id text,
41658
+ manifest_json jsonb,
41659
+ result_json jsonb NOT NULL,
41660
+ created_at timestamptz NOT NULL
41661
+ )`,
40728
41662
  `CREATE INDEX IF NOT EXISTS todos_task_manifest_outbox_receipt_idx
40729
41663
  ON todos_task_manifest_outbox(apply_receipt_id, status)`,
40730
41664
  `CREATE INDEX IF NOT EXISTS todos_task_manifest_receipts_tenant_idx
40731
41665
  ON todos_task_manifest_receipts(tenant_id, receipt_id, kind)`,
41666
+ `DROP INDEX IF EXISTS todos_task_manifest_terminal_receipts_lookup_idx`,
41667
+ `DROP INDEX IF EXISTS todos_task_manifest_terminal_receipts_identity_idx`,
41668
+ `CREATE INDEX IF NOT EXISTS todos_task_manifest_terminal_receipts_lookup_idx
41669
+ ON todos_task_manifest_terminal_receipts(tenant_id, kind, operation_id, step_id)`,
41670
+ `CREATE UNIQUE INDEX IF NOT EXISTS todos_task_manifest_terminal_receipts_identity_idx
41671
+ ON todos_task_manifest_terminal_receipts(tenant_id, kind, operation_id, step_id)`,
40732
41672
  `CREATE INDEX IF NOT EXISTS todos_task_manifest_bindings_tenant_plan_idx
40733
41673
  ON todos_task_manifest_bindings(
40734
41674
  tenant_id,
@@ -40741,6 +41681,10 @@ function postgresTodosTaskManifestSchemaSql(tenantId = "default") {
40741
41681
  `DROP TRIGGER IF EXISTS todos_task_manifest_receipts_immutable ON todos_task_manifest_receipts`,
40742
41682
  `CREATE TRIGGER todos_task_manifest_receipts_immutable
40743
41683
  BEFORE UPDATE OR DELETE ON todos_task_manifest_receipts
41684
+ FOR EACH ROW EXECUTE FUNCTION todos_task_manifest_receipts_immutable()`,
41685
+ `DROP TRIGGER IF EXISTS todos_task_manifest_terminal_receipts_immutable ON todos_task_manifest_terminal_receipts`,
41686
+ `CREATE TRIGGER todos_task_manifest_terminal_receipts_immutable
41687
+ BEFORE UPDATE OR DELETE ON todos_task_manifest_terminal_receipts
40744
41688
  FOR EACH ROW EXECUTE FUNCTION todos_task_manifest_receipts_immutable()`
40745
41689
  ];
40746
41690
  }
@@ -40752,7 +41696,76 @@ function fault(faults, point) {
40752
41696
  throw new Error(`Injected task-manifest fault at ${point}`);
40753
41697
  }
40754
41698
  function parseApplyResult(value, duplicate) {
40755
- return { ...JSON.parse(value), duplicate };
41699
+ const parsed = JSON.parse(value);
41700
+ return {
41701
+ ...parsed,
41702
+ duplicate,
41703
+ receipt: {
41704
+ ...parsed.receipt,
41705
+ step_id: parsed.receipt.step_id ?? "legacy-apply",
41706
+ precondition_digest: parsed.receipt.precondition_digest ?? "0".repeat(64),
41707
+ outcome: parsed.receipt.outcome ?? "accepted",
41708
+ reason: parsed.receipt.reason ?? null,
41709
+ duplicate_of_receipt_id: parsed.receipt.duplicate_of_receipt_id ?? null
41710
+ }
41711
+ };
41712
+ }
41713
+ function terminalApplyResult(input, reason) {
41714
+ const receipt = {
41715
+ receipt_id: input.terminal_receipt_id,
41716
+ authority: "todos",
41717
+ route: "todos.task-manifest.v1",
41718
+ schema_version: 1,
41719
+ kind: "apply",
41720
+ operation_id: input.manifest.operation_id,
41721
+ step_id: input.manifest.step_id,
41722
+ idempotency_key: input.manifest.idempotency_key,
41723
+ request_digest: input.request_digest,
41724
+ precondition_digest: input.manifest.precondition_digest,
41725
+ result_digest: canonicalDigest({
41726
+ outcome: "terminal_nonacceptance",
41727
+ reason,
41728
+ operation_id: input.manifest.operation_id,
41729
+ step_id: input.manifest.step_id,
41730
+ request_digest: input.request_digest
41731
+ }),
41732
+ outcome: "terminal_nonacceptance",
41733
+ reason,
41734
+ duplicate_of_receipt_id: null,
41735
+ binding_version: 0,
41736
+ apply_receipt_id: null,
41737
+ created_at: input.now
41738
+ };
41739
+ return {
41740
+ duplicate: false,
41741
+ receipt,
41742
+ graph: input.graph,
41743
+ readback: { plans: 0, tasks: 0, dependencies: 0, comments: 0, verifications: 0, complete: true },
41744
+ outbox_ids: [],
41745
+ result_digest: receipt.result_digest
41746
+ };
41747
+ }
41748
+ function validateCompensationPlanSlug(db, manifest, planId, slugProvenance) {
41749
+ const plan = db.query("SELECT id, project_id, name, slug, created_at FROM plans WHERE id = ? LIMIT 1").get(planId);
41750
+ if (!plan) {
41751
+ throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_COMPENSATION_REFUSED", "Compensation refused: plan changed since apply");
41752
+ }
41753
+ if (slugProvenance === TASK_MANIFEST_DETERMINISTIC_SLUG_PROVENANCE) {
41754
+ if (plan.slug !== taskManifestPlanSlug(manifest, planId)) {
41755
+ throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_COMPENSATION_REFUSED", "Compensation refused: plan changed since apply");
41756
+ }
41757
+ return;
41758
+ }
41759
+ if (slugProvenance !== null && slugProvenance !== undefined) {
41760
+ throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_COMPENSATION_REFUSED", "Compensation refused: unknown plan slug provenance");
41761
+ }
41762
+ if (plan.slug === null)
41763
+ return;
41764
+ 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);
41765
+ const expected = sqliteLegacyTaskManifestPlanSlug(rows, planId, manifest.plan.key || manifest.plan.name);
41766
+ if (expected === null || plan.slug !== expected) {
41767
+ throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_COMPENSATION_REFUSED", "Compensation refused: legacy plan slug was not produced by SQLite allocation");
41768
+ }
40756
41769
  }
40757
41770
 
40758
41771
  class SqliteTodosTaskManifestBackend {
@@ -40792,33 +41805,49 @@ class SqliteTodosTaskManifestBackend {
40792
41805
  async apply(input, faults) {
40793
41806
  return this.serialized(() => {
40794
41807
  const { manifest } = input;
41808
+ const terminal = this.db.query(`SELECT result_json
41809
+ FROM todos_task_manifest_terminal_receipts
41810
+ WHERE tenant_id = ?
41811
+ AND kind = 'apply'
41812
+ AND (receipt_id = ? OR (operation_id = ? AND step_id = ?))
41813
+ ORDER BY created_at ASC, receipt_id ASC
41814
+ LIMIT 1`).get(this.tenantId, input.terminal_receipt_id, manifest.operation_id, manifest.step_id);
41815
+ if (terminal)
41816
+ return parseApplyResult(terminal.result_json, true);
40795
41817
  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);
40796
41818
  if (binding) {
40797
- if (binding["idempotency_key"] !== manifest.idempotency_key || binding["request_digest"] !== input.request_digest) {
40798
- throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_IDEMPOTENCY_CONFLICT", "Operation is already bound to a different request");
41819
+ 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) {
41820
+ return this.persistTerminal(input, "TODOS_TASK_MANIFEST_IDEMPOTENCY_CONFLICT");
41821
+ }
41822
+ if (binding["outcome"] === "terminal_nonacceptance") {
41823
+ const terminalResult = this.persistTerminal(input, "TODOS_TASK_MANIFEST_GRAPH_CONFLICT");
41824
+ return { ...terminalResult, duplicate: true };
40799
41825
  }
40800
41826
  if (binding["state"] !== "applied") {
40801
- throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_GRAPH_CONFLICT", "Operation was already compensated");
41827
+ return this.persistTerminal(input, "TODOS_TASK_MANIFEST_GRAPH_CONFLICT");
40802
41828
  }
40803
41829
  return parseApplyResult(String(binding["result_json"]), true);
40804
41830
  }
40805
41831
  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);
40806
41832
  if (idempotency)
40807
- throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_IDEMPOTENCY_CONFLICT", "Idempotency key is already used by another operation");
41833
+ return this.persistTerminal(input, "TODOS_TASK_MANIFEST_IDEMPOTENCY_CONFLICT");
41834
+ if (manifest.idempotency_key !== input.expected_idempotency_key) {
41835
+ return this.persistTerminal(input, "TODOS_TASK_MANIFEST_IDEMPOTENCY_MISMATCH");
41836
+ }
40808
41837
  if (manifest.if_binding_version !== undefined && manifest.if_binding_version !== 0) {
40809
- throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_CAS_CONFLICT", "New manifest binding version must be 0");
41838
+ return this.persistTerminal(input, "TODOS_TASK_MANIFEST_CAS_CONFLICT");
40810
41839
  }
40811
41840
  if (!this.db.query("SELECT 1 AS found FROM projects WHERE id = ? LIMIT 1").get(manifest.project_id)) {
40812
- throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_FOREIGN_REFERENCE", "Project does not exist");
41841
+ return this.persistTerminal(input, "TODOS_TASK_MANIFEST_FOREIGN_REFERENCE");
40813
41842
  }
40814
41843
  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)) {
40815
- throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_FOREIGN_REFERENCE", "Task list does not belong to the project");
41844
+ return this.persistTerminal(input, "TODOS_TASK_MANIFEST_FOREIGN_REFERENCE");
40816
41845
  }
40817
41846
  const allIds = [input.graph.plan_id, ...Object.values(input.graph.task_ids), ...input.graph.comment_ids, ...input.graph.verification_ids];
40818
41847
  for (const id of allIds) {
40819
41848
  for (const table of ["plans", "tasks", "task_comments", "task_verifications"]) {
40820
41849
  if (this.db.query(`SELECT 1 AS found FROM ${table} WHERE id = ? LIMIT 1`).get(id)) {
40821
- throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_GRAPH_CONFLICT", `Deterministic id already exists: ${id}`);
41850
+ return this.persistTerminal(input, "TODOS_TASK_MANIFEST_GRAPH_CONFLICT");
40822
41851
  }
40823
41852
  }
40824
41853
  }
@@ -40874,9 +41903,14 @@ class SqliteTodosTaskManifestBackend {
40874
41903
  schema_version: 1,
40875
41904
  kind: "apply",
40876
41905
  operation_id: manifest.operation_id,
41906
+ step_id: manifest.step_id,
40877
41907
  idempotency_key: manifest.idempotency_key,
40878
41908
  request_digest: input.request_digest,
41909
+ precondition_digest: manifest.precondition_digest,
40879
41910
  result_digest: input.result_digest,
41911
+ outcome: "accepted",
41912
+ reason: null,
41913
+ duplicate_of_receipt_id: null,
40880
41914
  binding_version: 1,
40881
41915
  apply_receipt_id: null,
40882
41916
  created_at: input.now
@@ -40892,9 +41926,10 @@ class SqliteTodosTaskManifestBackend {
40892
41926
  const resultJson = canonicalJson(result);
40893
41927
  const manifestJson = canonicalJson(manifest);
40894
41928
  this.db.query(`INSERT INTO todos_task_manifest_receipts (
40895
- receipt_id, tenant_id, authority, route, schema_version, kind, operation_id, idempotency_key,
40896
- request_digest, result_digest, binding_version, apply_receipt_id, manifest_json, result_json, created_at
40897
- ) 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);
41929
+ receipt_id, tenant_id, authority, route, schema_version, kind, operation_id, step_id, idempotency_key,
41930
+ request_digest, precondition_digest, result_digest, slug_provenance, outcome, reason,
41931
+ duplicate_of_receipt_id, binding_version, apply_receipt_id, manifest_json, result_json, created_at
41932
+ ) 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);
40898
41933
  for (const entry2 of input.outbox) {
40899
41934
  this.db.query(`INSERT INTO todos_task_manifest_outbox (
40900
41935
  id, apply_receipt_id, topic, payload, payload_digest, status, created_at
@@ -40902,18 +41937,37 @@ class SqliteTodosTaskManifestBackend {
40902
41937
  }
40903
41938
  fault(faults, "after_outbox_write");
40904
41939
  this.db.query(`INSERT INTO todos_task_manifest_bindings (
40905
- operation_id, tenant_id, idempotency_key, request_digest, result_digest, apply_receipt_id,
40906
- manifest_json, result_json, state, version, created_at, updated_at
40907
- ) 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);
41940
+ operation_id, tenant_id, step_id, idempotency_key, request_digest, precondition_digest, result_digest,
41941
+ slug_provenance, outcome, apply_receipt_id, manifest_json, result_json, state, version, created_at, updated_at
41942
+ ) 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);
40908
41943
  fault(faults, "after_receipt_write");
40909
41944
  return result;
40910
41945
  });
40911
41946
  }
41947
+ persistTerminal(input, reason) {
41948
+ const result = terminalApplyResult(input, reason);
41949
+ const resultJson = canonicalJson(result);
41950
+ this.db.query(`INSERT OR IGNORE INTO todos_task_manifest_terminal_receipts (
41951
+ receipt_id, tenant_id, authority, route, schema_version, kind, operation_id, step_id,
41952
+ idempotency_key, request_digest, precondition_digest, result_digest, outcome, reason,
41953
+ binding_version, apply_receipt_id, manifest_json, result_json, created_at
41954
+ ) 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);
41955
+ const stored = this.db.query(`SELECT receipt_id, result_json
41956
+ FROM todos_task_manifest_terminal_receipts
41957
+ WHERE tenant_id = ? AND kind = 'apply'
41958
+ AND (receipt_id = ? OR (operation_id = ? AND step_id = ?))
41959
+ ORDER BY created_at ASC, receipt_id ASC
41960
+ LIMIT 1`).get(this.tenantId, result.receipt.receipt_id, input.manifest.operation_id, input.manifest.step_id);
41961
+ return stored ? parseApplyResult(stored.result_json, stored.receipt_id !== result.receipt.receipt_id) : result;
41962
+ }
40912
41963
  async readExact(receiptId2) {
40913
41964
  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);
40914
- if (!row)
41965
+ if (row)
41966
+ return parseApplyResult(row.result_json, false);
41967
+ 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);
41968
+ if (!terminal)
40915
41969
  throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_RECEIPT_NOT_FOUND", `Apply receipt not found: ${receiptId2}`);
40916
- return parseApplyResult(row.result_json, false);
41970
+ return parseApplyResult(terminal.result_json, false);
40917
41971
  }
40918
41972
  async lookupBindingByPlanId(planId) {
40919
41973
  const rows = this.db.query(`
@@ -40923,6 +41977,7 @@ class SqliteTodosTaskManifestBackend {
40923
41977
  b.version AS binding_version,
40924
41978
  b.tenant_id AS binding_tenant_id,
40925
41979
  b.operation_id AS binding_operation_id,
41980
+ b.step_id AS binding_step_id,
40926
41981
  json_extract(b.result_json, '$.graph.plan_id') AS binding_plan_id,
40927
41982
  r.tenant_id AS receipt_tenant_id,
40928
41983
  r.authority AS receipt_authority,
@@ -40930,6 +41985,7 @@ class SqliteTodosTaskManifestBackend {
40930
41985
  r.schema_version AS receipt_schema_version,
40931
41986
  r.kind AS receipt_kind,
40932
41987
  r.operation_id AS receipt_operation_id,
41988
+ r.step_id AS receipt_step_id,
40933
41989
  json_extract(r.result_json, '$.graph.plan_id') AS receipt_plan_id
40934
41990
  FROM todos_task_manifest_bindings b
40935
41991
  LEFT JOIN todos_task_manifest_receipts r
@@ -40992,6 +42048,12 @@ class SqliteTodosTaskManifestBackend {
40992
42048
  if (!binding || Number(binding["version"]) !== input.if_binding_version) {
40993
42049
  throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_CAS_CONFLICT", "Binding version changed before compensation");
40994
42050
  }
42051
+ const storedStepId = String(row["step_id"] ?? "legacy-apply");
42052
+ const storedRequestDigest = String(row["request_digest"]);
42053
+ const storedPreconditionDigest = String(row["precondition_digest"] ?? "0".repeat(64));
42054
+ 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"]) {
42055
+ throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_COMPENSATION_REFUSED", "Compensation refused: receipt and binding identity disagree");
42056
+ }
40995
42057
  if (binding["state"] !== "applied")
40996
42058
  throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_COMPENSATION_REFUSED", "Graph is not in applied state");
40997
42059
  const delivered = this.db.query(`SELECT o.id FROM todos_task_manifest_outbox o
@@ -41002,10 +42064,16 @@ class SqliteTodosTaskManifestBackend {
41002
42064
  throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_COMPENSATION_REFUSED", "Compensation refused: delivered outbox row exists");
41003
42065
  const applyResult = parseApplyResult(String(row["result_json"]), false);
41004
42066
  const manifest = JSON.parse(String(row["manifest_json"]));
42067
+ const manifestRecord = manifest;
42068
+ const applyStepId = typeof manifestRecord["step_id"] === "string" ? String(manifestRecord["step_id"]) : null;
41005
42069
  const expectedEffects = [
41006
42070
  {
41007
42071
  topic: "todos.task-manifest.applied",
41008
- payload: { operation_id: manifest.operation_id, project_id: manifest.project_id }
42072
+ payload: {
42073
+ operation_id: manifest.operation_id,
42074
+ ...applyStepId ? { step_id: applyStepId } : {},
42075
+ project_id: manifest.project_id
42076
+ }
41009
42077
  },
41010
42078
  ...(manifest.effects ?? []).map((effect2) => ({ topic: effect2.topic, payload: effect2.payload }))
41011
42079
  ];
@@ -41045,8 +42113,10 @@ class SqliteTodosTaskManifestBackend {
41045
42113
  if (canonicalJson(actualReadback) !== canonicalJson(applyResult.readback)) {
41046
42114
  throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_COMPENSATION_REFUSED", "Compensation refused: graph changed since apply", { actualReadback });
41047
42115
  }
41048
- 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);
41049
- 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)) {
42116
+ const slugProvenance = row["slug_provenance"];
42117
+ validateCompensationPlanSlug(this.db, manifest, applyResult.graph.plan_id, slugProvenance);
42118
+ 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);
42119
+ 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)) {
41050
42120
  throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_COMPENSATION_REFUSED", "Compensation refused: plan changed since apply");
41051
42121
  }
41052
42122
  for (const task3 of manifest.tasks) {
@@ -41123,9 +42193,10 @@ class SqliteTodosTaskManifestBackend {
41123
42193
  const result = { duplicate: false, receipt, absent: true, readback };
41124
42194
  const resultJson = canonicalJson(result);
41125
42195
  this.db.query(`INSERT INTO todos_task_manifest_receipts (
41126
- receipt_id, tenant_id, authority, route, schema_version, kind, operation_id, idempotency_key,
41127
- request_digest, result_digest, binding_version, apply_receipt_id, manifest_json, result_json, created_at
41128
- ) 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);
42196
+ receipt_id, tenant_id, authority, route, schema_version, kind, operation_id, step_id, idempotency_key,
42197
+ request_digest, precondition_digest, result_digest, slug_provenance, outcome, reason,
42198
+ duplicate_of_receipt_id, binding_version, apply_receipt_id, manifest_json, result_json, created_at
42199
+ ) 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);
41129
42200
  const updated = this.db.query(`UPDATE todos_task_manifest_bindings SET state = 'compensated', version = ?, compensation_receipt_id = ?, updated_at = ?
41130
42201
  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);
41131
42202
  if (updated.changes !== 1) {
@@ -41159,6 +42230,39 @@ function safeIdentifier2(value, field2) {
41159
42230
  function parseJson2(value) {
41160
42231
  return typeof value === "string" ? JSON.parse(value) : value;
41161
42232
  }
42233
+ function parseApplyResult2(value, duplicate) {
42234
+ const parsed = parseJson2(value);
42235
+ return {
42236
+ ...parsed,
42237
+ duplicate,
42238
+ receipt: {
42239
+ ...parsed.receipt,
42240
+ step_id: parsed.receipt.step_id ?? "legacy-apply",
42241
+ precondition_digest: parsed.receipt.precondition_digest ?? "0".repeat(64),
42242
+ outcome: parsed.receipt.outcome ?? "accepted",
42243
+ reason: parsed.receipt.reason ?? null,
42244
+ duplicate_of_receipt_id: parsed.receipt.duplicate_of_receipt_id ?? null
42245
+ }
42246
+ };
42247
+ }
42248
+ function validatePostgresPlanSlug(manifest, planId, slug, provenance) {
42249
+ if (provenance === TASK_MANIFEST_DETERMINISTIC_SLUG_PROVENANCE) {
42250
+ const expected = taskManifestPlanSlug(manifest, planId);
42251
+ if (slug !== expected) {
42252
+ throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_COMPENSATION_REFUSED", "Compensation refused: plan slug changed since apply");
42253
+ }
42254
+ return expected;
42255
+ }
42256
+ if (provenance !== null && provenance !== undefined) {
42257
+ throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_COMPENSATION_REFUSED", "Compensation refused: unknown plan slug provenance");
42258
+ }
42259
+ if (slug === null || slug === undefined)
42260
+ return null;
42261
+ if (slug !== null && slug !== undefined) {
42262
+ throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_COMPENSATION_REFUSED", "Compensation refused: legacy PostgreSQL plan slug must be NULL");
42263
+ }
42264
+ return null;
42265
+ }
41162
42266
  function timestamp3(value) {
41163
42267
  return value instanceof Date ? value.toISOString() : new Date(String(value)).toISOString();
41164
42268
  }
@@ -41166,6 +42270,41 @@ function fault2(faults, point) {
41166
42270
  if (faults.points.has(point))
41167
42271
  throw new Error(`Injected task-manifest fault at ${point}`);
41168
42272
  }
42273
+ function terminalApplyResult2(input, reason) {
42274
+ const receipt = {
42275
+ receipt_id: input.terminal_receipt_id,
42276
+ authority: "todos",
42277
+ route: "todos.task-manifest.v1",
42278
+ schema_version: 1,
42279
+ kind: "apply",
42280
+ operation_id: input.manifest.operation_id,
42281
+ step_id: input.manifest.step_id,
42282
+ idempotency_key: input.manifest.idempotency_key,
42283
+ request_digest: input.request_digest,
42284
+ precondition_digest: input.manifest.precondition_digest,
42285
+ result_digest: canonicalDigest({
42286
+ outcome: "terminal_nonacceptance",
42287
+ reason,
42288
+ operation_id: input.manifest.operation_id,
42289
+ step_id: input.manifest.step_id,
42290
+ request_digest: input.request_digest
42291
+ }),
42292
+ outcome: "terminal_nonacceptance",
42293
+ reason,
42294
+ duplicate_of_receipt_id: null,
42295
+ binding_version: 0,
42296
+ apply_receipt_id: null,
42297
+ created_at: input.now
42298
+ };
42299
+ return {
42300
+ duplicate: false,
42301
+ receipt,
42302
+ graph: input.graph,
42303
+ readback: { plans: 0, tasks: 0, dependencies: 0, comments: 0, verifications: 0, complete: true },
42304
+ outbox_ids: [],
42305
+ result_digest: receipt.result_digest
42306
+ };
42307
+ }
41169
42308
  function receiptFromRow3(row) {
41170
42309
  return {
41171
42310
  receipt_id: String(row["receipt_id"]),
@@ -41174,9 +42313,14 @@ function receiptFromRow3(row) {
41174
42313
  schema_version: 1,
41175
42314
  kind: row["kind"],
41176
42315
  operation_id: String(row["operation_id"]),
42316
+ step_id: String(row["step_id"] ?? "legacy-apply"),
41177
42317
  idempotency_key: String(row["idempotency_key"]),
41178
42318
  request_digest: String(row["request_digest"]),
42319
+ precondition_digest: String(row["precondition_digest"] ?? "0".repeat(64)),
41179
42320
  result_digest: String(row["result_digest"]),
42321
+ outcome: row["outcome"] ?? "accepted",
42322
+ reason: row["reason"] == null ? null : row["reason"],
42323
+ duplicate_of_receipt_id: row["duplicate_of_receipt_id"] == null ? null : String(row["duplicate_of_receipt_id"]),
41180
42324
  binding_version: Number(row["binding_version"]),
41181
42325
  apply_receipt_id: row["apply_receipt_id"] == null ? null : String(row["apply_receipt_id"]),
41182
42326
  created_at: timestamp3(row["created_at"])
@@ -41294,46 +42438,89 @@ class PostgresTodosTaskManifestBackend {
41294
42438
  now4
41295
42439
  ]);
41296
42440
  }
42441
+ async persistTerminal(tx, input, reason) {
42442
+ const result = terminalApplyResult2(input, reason);
42443
+ const resultJson = canonicalJson(result);
42444
+ await tx.query(`INSERT INTO todos_task_manifest_terminal_receipts (
42445
+ receipt_id, tenant_id, authority, route, schema_version, kind, operation_id, step_id,
42446
+ idempotency_key, request_digest, precondition_digest, result_digest, outcome, reason,
42447
+ binding_version, apply_receipt_id, manifest_json, result_json, created_at
42448
+ ) VALUES ($1, $2, 'todos', 'todos.task-manifest.v1', 1, 'apply', $3, $4, $5, $6, $7, $8,
42449
+ 'terminal_nonacceptance', $9, 0, NULL, $10::jsonb, $11::jsonb, $12)
42450
+ ON CONFLICT (tenant_id, kind, operation_id, step_id) DO NOTHING`, [
42451
+ result.receipt.receipt_id,
42452
+ this.tenantId,
42453
+ input.manifest.operation_id,
42454
+ input.manifest.step_id,
42455
+ input.manifest.idempotency_key,
42456
+ input.request_digest,
42457
+ input.manifest.precondition_digest,
42458
+ result.receipt.result_digest,
42459
+ reason,
42460
+ canonicalJson(input.manifest),
42461
+ resultJson,
42462
+ input.now
42463
+ ]);
42464
+ const stored = await tx.query(`SELECT receipt_id, result_json
42465
+ FROM todos_task_manifest_terminal_receipts
42466
+ WHERE tenant_id = $1 AND kind = 'apply'
42467
+ AND (receipt_id = $2 OR (operation_id = $3 AND step_id = $4))
42468
+ ORDER BY created_at ASC, receipt_id ASC
42469
+ LIMIT 1`, [this.tenantId, result.receipt.receipt_id, input.manifest.operation_id, input.manifest.step_id]);
42470
+ return stored.rows[0] ? parseApplyResult2(stored.rows[0]["result_json"], stored.rows[0]["receipt_id"] !== result.receipt.receipt_id) : result;
42471
+ }
41297
42472
  async apply(input, faults) {
41298
42473
  await this.ensureSchema();
41299
42474
  return this.client.transaction(async (tx) => {
41300
42475
  const { manifest } = input;
41301
42476
  await tx.query("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))", [`${this.service}\x1F${manifest.operation_id}`]);
41302
42477
  await tx.query("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))", [`${this.service}\x1Fidempotency\x1F${manifest.idempotency_key}`]);
42478
+ const terminal = await tx.query(`SELECT result_json FROM todos_task_manifest_terminal_receipts
42479
+ WHERE tenant_id = $1
42480
+ AND kind = 'apply'
42481
+ AND (receipt_id = $2 OR (operation_id = $3 AND step_id = $4))
42482
+ ORDER BY created_at ASC, receipt_id ASC
42483
+ LIMIT 1`, [this.tenantId, input.terminal_receipt_id, manifest.operation_id, manifest.step_id]);
42484
+ if (terminal.rows[0]) {
42485
+ return parseApplyResult2(terminal.rows[0]["result_json"], true);
42486
+ }
41303
42487
  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]);
41304
42488
  if (existing.rows[0]) {
41305
42489
  const binding = existing.rows[0];
41306
- if (binding["idempotency_key"] !== manifest.idempotency_key || binding["request_digest"] !== input.request_digest) {
41307
- throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_IDEMPOTENCY_CONFLICT", "Operation is already bound to a different request");
42490
+ 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) {
42491
+ return this.persistTerminal(tx, input, "TODOS_TASK_MANIFEST_IDEMPOTENCY_CONFLICT");
41308
42492
  }
41309
42493
  if (binding["state"] !== "applied") {
41310
- throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_GRAPH_CONFLICT", "Operation was already compensated");
42494
+ return this.persistTerminal(tx, input, "TODOS_TASK_MANIFEST_GRAPH_CONFLICT");
41311
42495
  }
41312
- return { ...parseJson2(binding["result_json"]), duplicate: true };
42496
+ return parseApplyResult2(binding["result_json"], true);
41313
42497
  }
41314
42498
  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]);
41315
42499
  if (reused.rows[0])
41316
- throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_IDEMPOTENCY_CONFLICT", "Idempotency key is already used");
42500
+ return this.persistTerminal(tx, input, "TODOS_TASK_MANIFEST_IDEMPOTENCY_CONFLICT");
42501
+ if (manifest.idempotency_key !== input.expected_idempotency_key) {
42502
+ return this.persistTerminal(tx, input, "TODOS_TASK_MANIFEST_IDEMPOTENCY_MISMATCH");
42503
+ }
41317
42504
  if (manifest.if_binding_version !== undefined && manifest.if_binding_version !== 0) {
41318
- throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_CAS_CONFLICT", "New manifest binding version must be 0");
42505
+ return this.persistTerminal(tx, input, "TODOS_TASK_MANIFEST_CAS_CONFLICT");
41319
42506
  }
41320
42507
  const project = await tx.query(`SELECT 1 AS found FROM ${this.tableName}
41321
42508
  WHERE service = $1 AND object_type = 'projects' AND object_id = $2 AND deleted_at IS NULL LIMIT 1`, [this.service, manifest.project_id]);
41322
42509
  if (!project.rows[0])
41323
- throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_FOREIGN_REFERENCE", "Project does not exist");
42510
+ return this.persistTerminal(tx, input, "TODOS_TASK_MANIFEST_FOREIGN_REFERENCE");
41324
42511
  if (manifest.task_list_id) {
41325
42512
  const taskList = await tx.query(`SELECT payload FROM ${this.tableName}
41326
42513
  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]);
41327
42514
  const payload = taskList.rows[0] ? parseJson2(taskList.rows[0]["payload"]) : null;
41328
42515
  if (!payload || payload["project_id"] !== manifest.project_id) {
41329
- throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_FOREIGN_REFERENCE", "Task list does not belong to the project");
42516
+ return this.persistTerminal(tx, input, "TODOS_TASK_MANIFEST_FOREIGN_REFERENCE");
41330
42517
  }
41331
42518
  }
41332
42519
  const objectIds = [input.graph.plan_id, ...Object.values(input.graph.task_ids), ...input.graph.comment_ids, ...input.graph.verification_ids, ...input.graph.dependency_ids];
41333
42520
  const conflict = await tx.query(`SELECT object_id FROM ${this.tableName}
41334
42521
  WHERE service = $1 AND object_id IN (${placeholders2(2, objectIds.length)}) LIMIT 1`, [this.service, ...objectIds]);
41335
42522
  if (conflict.rows[0])
41336
- throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_GRAPH_CONFLICT", "A deterministic graph id already exists");
42523
+ return this.persistTerminal(tx, input, "TODOS_TASK_MANIFEST_GRAPH_CONFLICT");
41337
42524
  await this.insertSync(tx, "plans", input.graph.plan_id, planPayload(input), input.now);
41338
42525
  fault2(faults, "after_plan_write");
41339
42526
  for (const task3 of manifest.tasks) {
@@ -41403,9 +42590,14 @@ class PostgresTodosTaskManifestBackend {
41403
42590
  schema_version: 1,
41404
42591
  kind: "apply",
41405
42592
  operation_id: manifest.operation_id,
42593
+ step_id: manifest.step_id,
41406
42594
  idempotency_key: manifest.idempotency_key,
41407
42595
  request_digest: input.request_digest,
42596
+ precondition_digest: manifest.precondition_digest,
41408
42597
  result_digest: input.result_digest,
42598
+ outcome: "accepted",
42599
+ reason: null,
42600
+ duplicate_of_receipt_id: null,
41409
42601
  binding_version: 1,
41410
42602
  apply_receipt_id: null,
41411
42603
  created_at: input.now
@@ -41422,14 +42614,18 @@ class PostgresTodosTaskManifestBackend {
41422
42614
  const resultJson = canonicalJson(result);
41423
42615
  await tx.query(`INSERT INTO todos_task_manifest_receipts (
41424
42616
  receipt_id, tenant_id, authority, route, schema_version, kind, operation_id, idempotency_key,
41425
- request_digest, result_digest, binding_version, apply_receipt_id, manifest_json, result_json, created_at
41426
- ) VALUES ($1, $2, 'todos', 'todos.task-manifest.v1', 1, 'apply', $3, $4, $5, $6, 1, NULL, $7::jsonb, $8::jsonb, $9)`, [
42617
+ step_id, request_digest, precondition_digest, result_digest, slug_provenance, outcome,
42618
+ reason, duplicate_of_receipt_id, binding_version, apply_receipt_id, manifest_json, result_json, created_at
42619
+ ) 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)`, [
41427
42620
  input.receipt_id,
41428
42621
  this.tenantId,
41429
42622
  manifest.operation_id,
41430
42623
  manifest.idempotency_key,
42624
+ manifest.step_id,
41431
42625
  input.request_digest,
42626
+ manifest.precondition_digest,
41432
42627
  input.result_digest,
42628
+ TASK_MANIFEST_DETERMINISTIC_SLUG_PROVENANCE,
41433
42629
  manifestJson,
41434
42630
  resultJson,
41435
42631
  input.now
@@ -41448,14 +42644,18 @@ class PostgresTodosTaskManifestBackend {
41448
42644
  }
41449
42645
  fault2(faults, "after_outbox_write");
41450
42646
  await tx.query(`INSERT INTO todos_task_manifest_bindings (
41451
- operation_id, tenant_id, idempotency_key, request_digest, result_digest, apply_receipt_id,
41452
- manifest_json, result_json, state, version, created_at, updated_at
41453
- ) VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8::jsonb, 'applied', 1, $9, $9)`, [
42647
+ operation_id, tenant_id, step_id, idempotency_key, request_digest, precondition_digest,
42648
+ result_digest, slug_provenance, outcome, apply_receipt_id, manifest_json, result_json,
42649
+ state, version, created_at, updated_at
42650
+ ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'accepted', $9, $10::jsonb, $11::jsonb, 'applied', 1, $12, $12)`, [
41454
42651
  manifest.operation_id,
41455
42652
  this.tenantId,
42653
+ manifest.step_id,
41456
42654
  manifest.idempotency_key,
41457
42655
  input.request_digest,
42656
+ manifest.precondition_digest,
41458
42657
  input.result_digest,
42658
+ TASK_MANIFEST_DETERMINISTIC_SLUG_PROVENANCE,
41459
42659
  input.receipt_id,
41460
42660
  manifestJson,
41461
42661
  resultJson,
@@ -41468,9 +42668,12 @@ class PostgresTodosTaskManifestBackend {
41468
42668
  async readExact(receiptId2) {
41469
42669
  await this.ensureSchema();
41470
42670
  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]);
41471
- if (!result.rows[0])
42671
+ if (result.rows[0])
42672
+ return parseApplyResult2(result.rows[0]["result_json"], false);
42673
+ 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]);
42674
+ if (!terminal.rows[0])
41472
42675
  throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_RECEIPT_NOT_FOUND", `Apply receipt not found: ${receiptId2}`);
41473
- return { ...parseJson2(result.rows[0]["result_json"]), duplicate: false };
42676
+ return parseApplyResult2(terminal.rows[0]["result_json"], false);
41474
42677
  }
41475
42678
  async lookupBindingByPlanId(planId) {
41476
42679
  await this.ensureSchema();
@@ -41481,6 +42684,7 @@ class PostgresTodosTaskManifestBackend {
41481
42684
  b.version AS binding_version,
41482
42685
  b.tenant_id AS binding_tenant_id,
41483
42686
  b.operation_id AS binding_operation_id,
42687
+ b.step_id AS binding_step_id,
41484
42688
  b.result_json #>> '{graph,plan_id}' AS binding_plan_id,
41485
42689
  r.tenant_id AS receipt_tenant_id,
41486
42690
  r.authority AS receipt_authority,
@@ -41488,6 +42692,7 @@ class PostgresTodosTaskManifestBackend {
41488
42692
  r.schema_version AS receipt_schema_version,
41489
42693
  r.kind AS receipt_kind,
41490
42694
  r.operation_id AS receipt_operation_id,
42695
+ r.step_id AS receipt_step_id,
41491
42696
  r.result_json #>> '{graph,plan_id}' AS receipt_plan_id
41492
42697
  FROM todos_task_manifest_bindings b
41493
42698
  LEFT JOIN todos_task_manifest_receipts r
@@ -41574,6 +42779,10 @@ class PostgresTodosTaskManifestBackend {
41574
42779
  if (!binding || Number(binding["version"]) !== input.if_binding_version) {
41575
42780
  throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_CAS_CONFLICT", "Binding version changed before compensation");
41576
42781
  }
42782
+ const appliedReceipt = receiptFromRow3(applyRow);
42783
+ 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"]) {
42784
+ throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_COMPENSATION_REFUSED", "Compensation refused: receipt and binding identity disagree");
42785
+ }
41577
42786
  if (binding["state"] !== "applied")
41578
42787
  throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_COMPENSATION_REFUSED", "Graph is not applied");
41579
42788
  const delivered = await tx.query(`SELECT o.id FROM todos_task_manifest_outbox o
@@ -41588,12 +42797,18 @@ class PostgresTodosTaskManifestBackend {
41588
42797
  LIMIT 1`, [this.tenantId, input.receipt_id]);
41589
42798
  if (delivered.rows[0])
41590
42799
  throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_COMPENSATION_REFUSED", "Compensation refused: delivered outbox row exists");
41591
- const applyResult = parseJson2(applyRow["result_json"]);
42800
+ const applyResult = parseApplyResult2(applyRow["result_json"], false);
41592
42801
  const manifest = parseJson2(applyRow["manifest_json"]);
42802
+ const manifestRecord = manifest;
42803
+ const applyStepId = typeof manifestRecord["step_id"] === "string" ? String(manifestRecord["step_id"]) : null;
41593
42804
  const expectedEffects = [
41594
42805
  {
41595
42806
  topic: "todos.task-manifest.applied",
41596
- payload: { operation_id: manifest.operation_id, project_id: manifest.project_id }
42807
+ payload: {
42808
+ operation_id: manifest.operation_id,
42809
+ ...applyStepId ? { step_id: applyStepId } : {},
42810
+ project_id: manifest.project_id
42811
+ }
41597
42812
  },
41598
42813
  ...(manifest.effects ?? []).map((effect2) => ({ topic: effect2.topic, payload: effect2.payload }))
41599
42814
  ];
@@ -41641,9 +42856,15 @@ class PostgresTodosTaskManifestBackend {
41641
42856
  }
41642
42857
  const appliedAt = receiptFromRow3(applyRow).created_at;
41643
42858
  const expectedPayloads = new Map;
42859
+ const planRow = await tx.query(`SELECT payload FROM ${this.tableName}
42860
+ WHERE service = $1 AND object_type = 'plans' AND object_id = $2
42861
+ LIMIT 1`, [this.service, applyResult.graph.plan_id]);
42862
+ const actualPlan = planRow.rows[0] ? parseJson2(planRow.rows[0]["payload"]) : null;
42863
+ const planExpected = planPayload({ manifest, graph: applyResult.graph, now: appliedAt });
42864
+ planExpected.slug = validatePostgresPlanSlug(manifest, applyResult.graph.plan_id, actualPlan?.["slug"], applyRow["slug_provenance"]);
41644
42865
  expectedPayloads.set(applyResult.graph.plan_id, {
41645
42866
  type: "plans",
41646
- payload: canonicalJson(planPayload({ manifest, graph: applyResult.graph, now: appliedAt }))
42867
+ payload: canonicalJson(planExpected)
41647
42868
  });
41648
42869
  for (const task3 of manifest.tasks)
41649
42870
  expectedPayloads.set(applyResult.graph.task_ids[task3.key], {
@@ -41743,14 +42964,17 @@ class PostgresTodosTaskManifestBackend {
41743
42964
  const readback = await this.readback(tx, applyResult.graph);
41744
42965
  const result = { duplicate: false, receipt, absent: true, readback };
41745
42966
  await tx.query(`INSERT INTO todos_task_manifest_receipts (
41746
- receipt_id, tenant_id, authority, route, schema_version, kind, operation_id, idempotency_key,
41747
- request_digest, result_digest, binding_version, apply_receipt_id, manifest_json, result_json, created_at
41748
- ) VALUES ($1, $2, 'todos', 'todos.task-manifest.v1', 1, 'compensate', $3, $4, $5, $6, $7, $8, NULL, $9::jsonb, $10)`, [
42967
+ receipt_id, tenant_id, authority, route, schema_version, kind, operation_id, step_id, idempotency_key,
42968
+ request_digest, precondition_digest, result_digest, slug_provenance, outcome, reason,
42969
+ duplicate_of_receipt_id, binding_version, apply_receipt_id, manifest_json, result_json, created_at
42970
+ ) 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)`, [
41749
42971
  compensationReceiptId,
41750
42972
  this.tenantId,
41751
42973
  receipt.operation_id,
42974
+ receipt.step_id,
41752
42975
  input.idempotency_key,
41753
42976
  requestDigest,
42977
+ input.precondition_digest,
41754
42978
  receipt.result_digest,
41755
42979
  receipt.binding_version,
41756
42980
  input.receipt_id,
@@ -41810,36 +43034,83 @@ function resolveTenantId(value) {
41810
43034
  }
41811
43035
  return tenantId;
41812
43036
  }
43037
+ function taskManifestRequestDigest(manifest) {
43038
+ const { idempotency_key: _idempotencyKey, ...request } = manifest;
43039
+ return canonicalDigest(request);
43040
+ }
43041
+ function taskManifestCompensationRequestDigest(request) {
43042
+ return canonicalDigest(request);
43043
+ }
43044
+ function deriveTodosTaskManifestApplyPreconditionDigest(input) {
43045
+ return canonicalDigest({
43046
+ route: TODOS_TASK_MANIFEST_CALLER_ROUTE,
43047
+ direction: "apply",
43048
+ operation_id: input.operation_id,
43049
+ step_id: input.step_id,
43050
+ project_id: input.project_id,
43051
+ task_list_id: input.task_list_id ?? null,
43052
+ expected_binding_version: input.if_binding_version ?? 0
43053
+ });
43054
+ }
43055
+ function deriveTodosTaskManifestCompensationPreconditionDigest(input) {
43056
+ return canonicalDigest({
43057
+ route: TODOS_TASK_MANIFEST_CALLER_ROUTE,
43058
+ direction: "compensate",
43059
+ operation_id: input.operation_id,
43060
+ step_id: input.step_id,
43061
+ apply_receipt_id: input.receipt_id,
43062
+ expected_binding_version: input.if_binding_version
43063
+ });
43064
+ }
43065
+ function deriveTodosTaskManifestIdempotencyKey(input) {
43066
+ return `tmk_${canonicalDigest({
43067
+ route: TODOS_TASK_MANIFEST_CALLER_ROUTE,
43068
+ ...input
43069
+ }).slice(0, 48)}`;
43070
+ }
41813
43071
  function normalize(input, now4) {
41814
43072
  const parsed = parseTodosTaskManifest(input);
41815
43073
  const requestBytes = Buffer.byteLength(canonicalJson(parsed), "utf8");
41816
43074
  if (requestBytes > TODOS_TASK_MANIFEST_BOUNDS.request_bytes) {
41817
43075
  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 });
41818
43076
  }
43077
+ const { idempotency_key: _idempotencyKey, ...request } = parsed;
43078
+ const request_digest = taskManifestRequestDigest(request);
41819
43079
  const manifest = sanitizeManifest(parsed);
43080
+ const expectedPreconditionDigest = deriveTodosTaskManifestApplyPreconditionDigest(manifest);
43081
+ if (manifest.precondition_digest !== expectedPreconditionDigest) {
43082
+ throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_DIGEST_MISMATCH", "precondition_digest does not match the exact apply target and binding version", { expected_precondition_digest: expectedPreconditionDigest });
43083
+ }
43084
+ const expectedIdempotencyKey = deriveTodosTaskManifestIdempotencyKey({
43085
+ operation_id: manifest.operation_id,
43086
+ step_id: manifest.step_id,
43087
+ direction: "apply",
43088
+ target_selector: manifest.project_id,
43089
+ request_digest,
43090
+ precondition_digest: manifest.precondition_digest
43091
+ });
41820
43092
  const task_ids = Object.fromEntries(manifest.tasks.map((task3) => [
41821
43093
  task3.key,
41822
- deterministicUuid(TODOS_TASK_MANIFEST_ROUTE, manifest.operation_id, "task", task3.key)
43094
+ deterministicUuid(TODOS_TASK_MANIFEST_ROUTE, manifest.operation_id, manifest.step_id, "task", task3.key)
41823
43095
  ]));
41824
43096
  const graph = {
41825
- plan_id: deterministicUuid(TODOS_TASK_MANIFEST_ROUTE, manifest.operation_id, "plan", manifest.plan.key),
43097
+ plan_id: deterministicUuid(TODOS_TASK_MANIFEST_ROUTE, manifest.operation_id, manifest.step_id, "plan", manifest.plan.key),
41826
43098
  task_ids,
41827
- comment_ids: manifest.tasks.flatMap((task3) => (task3.comments ?? []).map((_, index) => deterministicUuid(TODOS_TASK_MANIFEST_ROUTE, manifest.operation_id, "comment", task3.key, String(index)))),
41828
- verification_ids: manifest.tasks.flatMap((task3) => (task3.verifications ?? []).map((_, index) => deterministicUuid(TODOS_TASK_MANIFEST_ROUTE, manifest.operation_id, "verification", task3.key, String(index)))),
43099
+ 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)))),
43100
+ 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)))),
41829
43101
  dependency_ids: (manifest.dependencies ?? []).map((edge) => `${task_ids[edge.task]}::${task_ids[edge.depends_on]}`)
41830
43102
  };
41831
- const request_digest = canonicalDigest(parsed);
41832
43103
  const effectInputs = [
41833
43104
  {
41834
43105
  topic: "todos.task-manifest.applied",
41835
- payload: { operation_id: manifest.operation_id, project_id: manifest.project_id }
43106
+ payload: { operation_id: manifest.operation_id, step_id: manifest.step_id, project_id: manifest.project_id }
41836
43107
  },
41837
43108
  ...manifest.effects ?? []
41838
43109
  ];
41839
43110
  const outbox = effectInputs.map((effect2, index) => {
41840
43111
  const payload = { ...effect2.payload };
41841
43112
  return {
41842
- id: deterministicUuid(TODOS_TASK_MANIFEST_ROUTE, manifest.operation_id, "outbox", String(index)),
43113
+ id: deterministicUuid(TODOS_TASK_MANIFEST_ROUTE, manifest.operation_id, manifest.step_id, "outbox", String(index)),
41843
43114
  topic: effect2.topic,
41844
43115
  payload,
41845
43116
  digest: canonicalDigest({ topic: effect2.topic, payload })
@@ -41849,11 +43120,14 @@ function normalize(input, now4) {
41849
43120
  return {
41850
43121
  manifest,
41851
43122
  request_digest,
43123
+ expected_idempotency_key: expectedIdempotencyKey,
41852
43124
  result_digest,
41853
- receipt_id: deterministicUuid(TODOS_TASK_MANIFEST_ROUTE, "apply", manifest.operation_id, manifest.idempotency_key, request_digest),
43125
+ receipt_id: deterministicUuid(TODOS_TASK_MANIFEST_ROUTE, "apply", manifest.operation_id, manifest.step_id, manifest.idempotency_key, request_digest),
43126
+ terminal_receipt_id: deterministicUuid(TODOS_TASK_MANIFEST_ROUTE, "terminal", "apply", manifest.operation_id, manifest.step_id, manifest.idempotency_key, request_digest),
41854
43127
  graph,
41855
43128
  outbox,
41856
- now: now4
43129
+ now: now4,
43130
+ plan_slug_provenance: TODOS_TASK_MANIFEST_PLAN_SLUG_PROVENANCE
41857
43131
  };
41858
43132
  }
41859
43133
  function sanitizeManifest(manifest) {
@@ -41911,6 +43185,10 @@ class PackageOwnedTodosTaskManifestAuthority {
41911
43185
  tenant_id: this.tenantId,
41912
43186
  backend: this.backend.kind,
41913
43187
  deterministic_ids: true,
43188
+ operation_step_identity: true,
43189
+ deterministic_idempotency_keys: true,
43190
+ terminal_nonacceptance_receipts: true,
43191
+ plan_slug_provenance: TODOS_TASK_MANIFEST_PLAN_SLUG_PROVENANCE,
41914
43192
  immutable_receipts: true,
41915
43193
  transactional_outbox: true,
41916
43194
  idempotent_outbox_delivery: true,
@@ -41940,7 +43218,11 @@ class PackageOwnedTodosTaskManifestAuthority {
41940
43218
  async apply(input) {
41941
43219
  const normalized = normalize(input, this.now());
41942
43220
  const faults = await this.prepareFaults();
41943
- return this.bounded(await this.backend.apply(normalized, faults));
43221
+ const result = this.bounded(await this.backend.apply(normalized, faults));
43222
+ if (result.receipt.outcome === "terminal_nonacceptance") {
43223
+ throw new TodosTaskManifestError(result.receipt.reason ?? "TODOS_TASK_MANIFEST_GRAPH_CONFLICT", "Task-manifest apply reached an immutable terminal nonacceptance", { receipt: result.receipt });
43224
+ }
43225
+ return result;
41944
43226
  }
41945
43227
  readExact(receiptId2) {
41946
43228
  if (!receiptId2 || receiptId2.length > 200) {
@@ -41973,18 +43255,48 @@ class PackageOwnedTodosTaskManifestAuthority {
41973
43255
  async compensate(input) {
41974
43256
  const request = parseTodosTaskManifestCompensation(input);
41975
43257
  const applied = await this.backend.readExact(request.receipt_id);
41976
- const requestDigest = canonicalDigest(request);
41977
- const compensationReceiptId = deterministicUuid(TODOS_TASK_MANIFEST_ROUTE, "compensate", applied.receipt.operation_id, request.idempotency_key, requestDigest);
43258
+ if (applied.receipt.outcome !== "accepted") {
43259
+ throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_COMPENSATION_REFUSED", "Compensation refused: apply receipt is terminal nonacceptance");
43260
+ }
43261
+ if (request.operation_id !== applied.receipt.operation_id) {
43262
+ throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_IDEMPOTENCY_CONFLICT", "Compensation operation_id must match the accepted apply operation");
43263
+ }
43264
+ if (request.step_id === applied.receipt.step_id) {
43265
+ throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_INVALID_INPUT", "Compensation must use a distinct step_id from apply");
43266
+ }
43267
+ const expectedPreconditionDigest = deriveTodosTaskManifestCompensationPreconditionDigest(request);
43268
+ if (request.precondition_digest !== expectedPreconditionDigest) {
43269
+ throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_DIGEST_MISMATCH", "precondition_digest does not match the exact compensation receipt and binding version", { expected_precondition_digest: expectedPreconditionDigest });
43270
+ }
43271
+ const { idempotency_key: _requestIdempotencyKey, ...compensationRequestWithoutKey } = request;
43272
+ const requestDigest = taskManifestCompensationRequestDigest(compensationRequestWithoutKey);
43273
+ const expectedIdempotencyKey = deriveTodosTaskManifestIdempotencyKey({
43274
+ operation_id: request.operation_id,
43275
+ step_id: request.step_id,
43276
+ direction: "compensate",
43277
+ target_selector: request.receipt_id,
43278
+ request_digest: requestDigest,
43279
+ precondition_digest: request.precondition_digest
43280
+ });
43281
+ if (request.idempotency_key !== expectedIdempotencyKey) {
43282
+ throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_IDEMPOTENCY_MISMATCH", "idempotency_key does not match the deterministic operation/step/compensation semantics", { expected_idempotency_key: expectedIdempotencyKey });
43283
+ }
43284
+ const compensationReceiptId = deterministicUuid(TODOS_TASK_MANIFEST_ROUTE, "compensate", request.operation_id, request.step_id, request.idempotency_key, requestDigest);
41978
43285
  const receipt = {
41979
43286
  receipt_id: compensationReceiptId,
41980
43287
  authority: "todos",
41981
43288
  route: TODOS_TASK_MANIFEST_ROUTE,
41982
43289
  schema_version: 1,
41983
43290
  kind: "compensate",
41984
- operation_id: applied.receipt.operation_id,
43291
+ operation_id: request.operation_id,
43292
+ step_id: request.step_id,
41985
43293
  idempotency_key: request.idempotency_key,
41986
43294
  request_digest: requestDigest,
43295
+ precondition_digest: request.precondition_digest,
41987
43296
  result_digest: canonicalDigest({ absent: true, apply_receipt_id: applied.receipt.receipt_id }),
43297
+ outcome: "accepted",
43298
+ reason: null,
43299
+ duplicate_of_receipt_id: null,
41988
43300
  binding_version: request.if_binding_version + 1,
41989
43301
  apply_receipt_id: applied.receipt.receipt_id,
41990
43302
  created_at: this.now()
@@ -42166,7 +43478,7 @@ function createTodosTaskManifestHttpClient(options) {
42166
43478
  return new TodosTaskManifestHttpClient(options);
42167
43479
  }
42168
43480
  // src/ai-tools.ts
42169
- import { createHash as createHash15 } from "crypto";
43481
+ import { createHash as createHash16 } from "crypto";
42170
43482
  // src/cli/cloud-router.ts
42171
43483
  import { resolveStorageClient } from "@hasna/contracts/client/storage";
42172
43484
  import { normalizeStorageMode } from "@hasna/contracts/mode";
@@ -42404,7 +43716,7 @@ async function requiredRemoteRoute(client, route, request, recognized404Codes =
42404
43716
  return await request();
42405
43717
  } catch (error) {
42406
43718
  const status2 = error && typeof error === "object" ? error.status : undefined;
42407
- if (status2 === 404) {
43719
+ if (status2 === 404 || status2 === 405) {
42408
43720
  const body2 = error && typeof error === "object" ? error.body : undefined;
42409
43721
  const code = body2 && typeof body2 === "object" && !Array.isArray(body2) ? body2.code : undefined;
42410
43722
  if (typeof code === "string" && recognized404Codes.includes(code))
@@ -43474,7 +44786,7 @@ function deriveTodosAiUpdateTaskApprovalIdentity(input) {
43474
44786
  expected_version: input.expected_version,
43475
44787
  patch
43476
44788
  });
43477
- const payloadDigest = createHash15("sha256").update(canonical).digest("hex");
44789
+ const payloadDigest = createHash16("sha256").update(canonical).digest("hex");
43478
44790
  return {
43479
44791
  ref: `todos-ai:update_task:${payloadDigest}`,
43480
44792
  payload_digest: payloadDigest
@@ -43629,9 +44941,9 @@ function normalizeUpdateTaskInput(input) {
43629
44941
  if (!Object.hasOwn(record, "expected_version")) {
43630
44942
  throw new Error("expected_version is required");
43631
44943
  }
43632
- const idempotencyKey = boundedRequiredString(record, "idempotency_key", TODOS_AI_UPDATE_TASK_LIMITS.max_idempotency_key_bytes);
43633
- const idempotencyBytes = ENCODER.encode(idempotencyKey).byteLength;
43634
- if (idempotencyBytes < TODOS_AI_UPDATE_TASK_LIMITS.min_idempotency_key_bytes || !/^[A-Za-z0-9._:-]+$/.test(idempotencyKey)) {
44944
+ const idempotencyKey2 = boundedRequiredString(record, "idempotency_key", TODOS_AI_UPDATE_TASK_LIMITS.max_idempotency_key_bytes);
44945
+ const idempotencyBytes = ENCODER.encode(idempotencyKey2).byteLength;
44946
+ if (idempotencyBytes < TODOS_AI_UPDATE_TASK_LIMITS.min_idempotency_key_bytes || !/^[A-Za-z0-9._:-]+$/.test(idempotencyKey2)) {
43635
44947
  throw new Error("idempotency_key must be a bounded stable identifier");
43636
44948
  }
43637
44949
  const patchValue = record["patch"];
@@ -43650,7 +44962,7 @@ function normalizeUpdateTaskInput(input) {
43650
44962
  expected_version: expectedVersion,
43651
44963
  patch,
43652
44964
  changed_fields: changedFields,
43653
- idempotency_key: idempotencyKey,
44965
+ idempotency_key: idempotencyKey2,
43654
44966
  payload_digest: identity.payload_digest,
43655
44967
  approval_ref: identity.ref
43656
44968
  };
@@ -44331,7 +45643,7 @@ init_task_lifecycle();
44331
45643
  init_task_crud();
44332
45644
  init_redaction();
44333
45645
  import { Database as Database3 } from "bun:sqlite";
44334
- import { createHash as createHash16 } from "crypto";
45646
+ import { createHash as createHash17 } from "crypto";
44335
45647
  import { existsSync as existsSync9, readdirSync as readdirSync2, statSync as statSync4 } from "fs";
44336
45648
  import { basename as basename2, dirname as dirname6, join as join9, resolve as resolve10 } from "path";
44337
45649
 
@@ -44614,8 +45926,8 @@ function normalizePath3(input) {
44614
45926
  return resolve10(input);
44615
45927
  }
44616
45928
  function sourceStoreId(sourceDbPath) {
44617
- const digest = createHash16("sha256").update(sourceDbPath).digest("hex").slice(0, 16);
44618
- return `sqlite:${digest}`;
45929
+ const digest2 = createHash17("sha256").update(sourceDbPath).digest("hex").slice(0, 16);
45930
+ return `sqlite:${digest2}`;
44619
45931
  }
44620
45932
  function inferSourceRepoPath(sourceDbPath) {
44621
45933
  const normalized = normalizePath3(sourceDbPath);
@@ -46576,7 +47888,7 @@ init_comments();
46576
47888
 
46577
47889
  // src/db/api-keys.ts
46578
47890
  init_database();
46579
- import { createHash as createHash17, randomBytes as randomBytes2, timingSafeEqual as timingSafeEqual3 } from "crypto";
47891
+ import { createHash as createHash18, randomBytes as randomBytes2, timingSafeEqual as timingSafeEqual3 } from "crypto";
46580
47892
  function rowToRecord(row) {
46581
47893
  return {
46582
47894
  id: row.id,
@@ -46590,7 +47902,7 @@ function rowToRecord(row) {
46590
47902
  };
46591
47903
  }
46592
47904
  function hashApiKey(key2) {
46593
- return createHash17("sha256").update(key2).digest("hex");
47905
+ return createHash18("sha256").update(key2).digest("hex");
46594
47906
  }
46595
47907
  function safeEqualHex(a, b) {
46596
47908
  if (a.length !== b.length)
@@ -50491,7 +51803,7 @@ init_database();
50491
51803
  init_tasks();
50492
51804
  import { existsSync as existsSync17, readFileSync as readFileSync14 } from "fs";
50493
51805
  import { basename as basename5 } from "path";
50494
- import { createHash as createHash18 } from "crypto";
51806
+ import { createHash as createHash19 } from "crypto";
50495
51807
  init_secret_redaction();
50496
51808
  var INBOX_INTAKE_SCHEMA = "todos.inbox_intake.v1";
50497
51809
  var INTAKE_SOURCE_TYPES = [
@@ -50504,7 +51816,7 @@ var INTAKE_SOURCE_TYPES = [
50504
51816
  ];
50505
51817
  var INTAKE_TRIAGE_STATUSES = ["preview", "triaged", "duplicate", "created"];
50506
51818
  function fingerprint2(text) {
50507
- return createHash18("sha256").update(text).digest("hex").slice(0, 16);
51819
+ return createHash19("sha256").update(text).digest("hex").slice(0, 16);
50508
51820
  }
50509
51821
  function loadRawContent(input) {
50510
51822
  if (input.github_url) {
@@ -55964,7 +57276,7 @@ init_database();
55964
57276
  init_tasks();
55965
57277
  init_redaction();
55966
57278
  init_sync_utils();
55967
- import { createHash as createHash19 } from "crypto";
57279
+ import { createHash as createHash20 } from "crypto";
55968
57280
  import { existsSync as existsSync22, readFileSync as readFileSync20, statSync as statSync9 } from "fs";
55969
57281
  import { hostname as hostname3, platform, arch } from "os";
55970
57282
  import { dirname as dirname15, join as join18, resolve as resolve17 } from "path";
@@ -55986,7 +57298,7 @@ var CONFIG_FILES = [
55986
57298
  "dashboard/vite.config.ts"
55987
57299
  ];
55988
57300
  function sha2566(value) {
55989
- return createHash19("sha256").update(value).digest("hex");
57301
+ return createHash20("sha256").update(value).digest("hex");
55990
57302
  }
55991
57303
  function fileRecord(root, relativePath) {
55992
57304
  const path = join18(root, relativePath);
@@ -56101,8 +57413,8 @@ function defaultSnapshotDir() {
56101
57413
  return join18(dirname15(resolve17(dbPath)), "environment-snapshots");
56102
57414
  }
56103
57415
  function snapshotWithId(snapshot) {
56104
- const digest = sha2566(JSON.stringify(snapshot)).slice(0, 24);
56105
- return { id: `env_${digest}`, ...snapshot };
57416
+ const digest2 = sha2566(JSON.stringify(snapshot)).slice(0, 24);
57417
+ return { id: `env_${digest2}`, ...snapshot };
56106
57418
  }
56107
57419
  function captureEnvironmentSnapshot(input = {}) {
56108
57420
  const root = resolve17(input.root || process.cwd());
@@ -56238,7 +57550,7 @@ function compareEnvironmentSnapshotFiles(leftPath, rightPath) {
56238
57550
  init_database();
56239
57551
  init_projects();
56240
57552
  init_plans();
56241
- import { createHash as createHash20 } from "crypto";
57553
+ import { createHash as createHash21 } from "crypto";
56242
57554
  import { mkdirSync as mkdirSync18, writeFileSync as writeFileSync16 } from "fs";
56243
57555
  import { dirname as dirname16, join as join19 } from "path";
56244
57556
  var DECISION_RECORD_SCHEMA = "todos.decision_record.v1";
@@ -56294,7 +57606,7 @@ function rowToDecisionRecord(row) {
56294
57606
  }
56295
57607
  function stableSnapshotHash(payload) {
56296
57608
  const { captured_at: _capturedAt, ...rest } = payload;
56297
- return createHash20("sha256").update(JSON.stringify(rest)).digest("hex");
57609
+ return createHash21("sha256").update(JSON.stringify(rest)).digest("hex");
56298
57610
  }
56299
57611
  function createDecisionRecord(input, db) {
56300
57612
  const d = db || getDatabase();
@@ -60787,7 +62099,7 @@ function syncWithAgents(agents, taskListIdByAgent, projectId, direction = "both"
60787
62099
  init_tasks();
60788
62100
  init_task_files();
60789
62101
  import { existsSync as existsSync27, readFileSync as readFileSync24, statSync as statSync10 } from "fs";
60790
- import { createHash as createHash21 } from "crypto";
62102
+ import { createHash as createHash22 } from "crypto";
60791
62103
  import { relative as relative6, resolve as resolve18, join as join25 } from "path";
60792
62104
  var EXTRACT_TAGS = ["TODO", "FIXME", "HACK", "XXX", "BUG", "NOTE"];
60793
62105
  var DEFAULT_EXTENSIONS = new Set([
@@ -60852,7 +62164,7 @@ var SKIP_DIRS2 = new Set([
60852
62164
  ".parcel-cache"
60853
62165
  ]);
60854
62166
  function stableHash(value) {
60855
- return createHash21("sha256").update(value).digest("hex");
62167
+ return createHash22("sha256").update(value).digest("hex");
60856
62168
  }
60857
62169
  function normalizePathForMatch(value) {
60858
62170
  return value.replace(/\\/g, "/").replace(/^\.\//, "");
@@ -61750,7 +63062,7 @@ function renderWorkflowStatesMarkdown(states = listWorkflowStates()) {
61750
63062
  }
61751
63063
  // src/lib/agent-replay-simulator.ts
61752
63064
  init_redaction();
61753
- import { createHash as createHash22 } from "crypto";
63065
+ import { createHash as createHash23 } from "crypto";
61754
63066
  import { readFileSync as readFileSync25 } from "fs";
61755
63067
  function isObject(value) {
61756
63068
  return Boolean(value && typeof value === "object" && !Array.isArray(value));
@@ -61772,7 +63084,7 @@ function stable2(value) {
61772
63084
  return Object.fromEntries(Object.keys(value).sort().map((key2) => [key2, stable2(value[key2])]));
61773
63085
  }
61774
63086
  function fingerprint3(value) {
61775
- return createHash22("sha256").update(JSON.stringify(stable2(value))).digest("hex");
63087
+ return createHash23("sha256").update(JSON.stringify(stable2(value))).digest("hex");
61776
63088
  }
61777
63089
  function unpackFixture(input) {
61778
63090
  if (!isObject(input))
@@ -62011,7 +63323,7 @@ function renderAgentReplaySimulationMarkdown(simulation) {
62011
63323
  }
62012
63324
  // src/lib/local-extensions.ts
62013
63325
  init_config2();
62014
- import { createHash as createHash23, createVerify } from "crypto";
63326
+ import { createHash as createHash24, createVerify } from "crypto";
62015
63327
  import { existsSync as existsSync28, readdirSync as readdirSync6, readFileSync as readFileSync26, statSync as statSync11 } from "fs";
62016
63328
  import { basename as basename6, join as join26, resolve as resolve19 } from "path";
62017
63329
  init_redaction();
@@ -62099,7 +63411,7 @@ function parseJson3(path) {
62099
63411
  return JSON.parse(readFileSync26(path, "utf8"));
62100
63412
  }
62101
63413
  function sha2567(bytes) {
62102
- return `sha256:${createHash23("sha256").update(bytes).digest("hex")}`;
63414
+ return `sha256:${createHash24("sha256").update(bytes).digest("hex")}`;
62103
63415
  }
62104
63416
  function compareVersions(a, b) {
62105
63417
  const left = a.split(".").map((part) => Number.parseInt(part, 10) || 0);
@@ -64714,6 +66026,8 @@ export {
64714
66026
  testTerminalNotificationRule,
64715
66027
  testLocalEventHook,
64716
66028
  tasksFromTemplate,
66029
+ taskManifestRequestDigest,
66030
+ taskManifestCompensationRequestDigest,
64717
66031
  taskFromTemplate,
64718
66032
  tagToPriority,
64719
66033
  syncWithAgents,
@@ -65448,6 +66762,9 @@ export {
65448
66762
  detectInboxSourceType,
65449
66763
  detectCyclesFromEdges,
65450
66764
  describeTerminalNotificationRule,
66765
+ deriveTodosTaskManifestIdempotencyKey,
66766
+ deriveTodosTaskManifestCompensationPreconditionDigest,
66767
+ deriveTodosTaskManifestApplyPreconditionDigest,
65451
66768
  deriveTodosProjectRegistrationIdempotencyKey,
65452
66769
  deriveTodosAiUpdateTaskApprovalIdentity,
65453
66770
  deriveInboxTitle,
@@ -65692,6 +67009,8 @@ export {
65692
67009
  TODOS_TASK_ROUTE_STATE_SCHEMA_VERSION,
65693
67010
  TODOS_TASK_MANIFEST_SCHEMA_VERSION,
65694
67011
  TODOS_TASK_MANIFEST_ROUTE,
67012
+ TODOS_TASK_MANIFEST_PLAN_SLUG_PROVENANCE,
67013
+ TODOS_TASK_MANIFEST_CALLER_ROUTE,
65695
67014
  TODOS_TASK_MANIFEST_BOUNDS,
65696
67015
  TODOS_STORAGE_TABLES,
65697
67016
  TODOS_STORAGE_FALLBACK_ENV,