@hasna/todos 0.15.29 → 0.15.32

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (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 +2835 -334
  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 +1447 -144
  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 +2031 -135
  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 +831 -58
  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 +2022 -126
  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 +232 -19
  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 +592 -61
  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
@@ -1015,6 +1015,46 @@ var init_stale_lock_handoff = __esm(() => {
1015
1015
  CANONICAL_LOCK_VERSION_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
1016
1016
  });
1017
1017
 
1018
+ // src/lib/task-parent-integrity.ts
1019
+ function parentCycleError(taskId, parentId) {
1020
+ return new ResourceConflictError("TASK_PARENT_CYCLE", `TASK_PARENT_CYCLE: assigning parent ${parentId} to task ${taskId} would create or retain a parent cycle`);
1021
+ }
1022
+ function assertTaskParentIntegrity(taskId, parentId, getTask) {
1023
+ if (parentId === undefined || parentId === null)
1024
+ return;
1025
+ const visited = new Set;
1026
+ let cursor = parentId;
1027
+ while (cursor) {
1028
+ if (cursor === taskId || visited.has(cursor)) {
1029
+ throw parentCycleError(taskId, parentId);
1030
+ }
1031
+ visited.add(cursor);
1032
+ const parent = getTask(cursor);
1033
+ if (!parent)
1034
+ throw new TaskNotFoundError(cursor);
1035
+ cursor = parent.parent_id;
1036
+ }
1037
+ }
1038
+ async function assertTaskParentIntegrityAsync(taskId, parentId, getTask) {
1039
+ if (parentId === undefined || parentId === null)
1040
+ return;
1041
+ const visited = new Set;
1042
+ let cursor = parentId;
1043
+ while (cursor) {
1044
+ if (cursor === taskId || visited.has(cursor)) {
1045
+ throw parentCycleError(taskId, parentId);
1046
+ }
1047
+ visited.add(cursor);
1048
+ const parent = await getTask(cursor);
1049
+ if (!parent)
1050
+ throw new TaskNotFoundError(cursor);
1051
+ cursor = parent.parent_id;
1052
+ }
1053
+ }
1054
+ var init_task_parent_integrity = __esm(() => {
1055
+ init_types();
1056
+ });
1057
+
1018
1058
  // src/project-registration/schema.ts
1019
1059
  function sqliteTodosProjectRegistrationSchemaSql() {
1020
1060
  return `
@@ -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
  logTaskChange2(id, "update", "priority", task.priority, input.priority, agentId, d);
9987
10033
  if (input.title !== undefined && input.title !== task.title)
9988
10034
  logTaskChange2(id, "update", "title", task.title, input.title, agentId, d);
10035
+ if (input.parent_id !== undefined && input.parent_id !== task.parent_id)
10036
+ logTaskChange2(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
  logTaskChange2(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 updateTask2(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
@@ -12669,11 +12719,11 @@ var init_tasks = __esm(() => {
12669
12719
  });
12670
12720
 
12671
12721
  // src/project-registration/authority.ts
12672
- import { createHash as createHash6 } from "crypto";
12722
+ import { createHash as createHash7 } from "crypto";
12673
12723
  // package.json
12674
12724
  var package_default = {
12675
12725
  name: "@hasna/todos",
12676
- version: "0.15.29",
12726
+ version: "0.15.32",
12677
12727
  description: "Universal task management for AI coding agents - CLI + MCP server + interactive TUI",
12678
12728
  type: "module",
12679
12729
  main: "dist/index.js",
@@ -13526,6 +13576,7 @@ function measureIntegrityRows(spec, sets) {
13526
13576
 
13527
13577
  // src/storage/postgres-adapter.ts
13528
13578
  init_redaction();
13579
+ init_task_parent_integrity();
13529
13580
 
13530
13581
  // src/storage/audit-history-import.ts
13531
13582
  var AUDIT_HISTORY_DIVERGENT_REPLAY = "AUDIT_HISTORY_DIVERGENT_REPLAY";
@@ -13597,8 +13648,8 @@ function createPostgresTodosStorageAdapter(options) {
13597
13648
  resolveRef: (ref) => store.resolveTaskRef(ref),
13598
13649
  list: (filter = {}) => store.listTasks(filter),
13599
13650
  count: (filter = {}) => store.countTasks(filter),
13600
- update: (id, input) => updateTask(id, input, store),
13601
- delete: (id, context) => store.delete("tasks", id, context),
13651
+ update: (id, input, context) => updateTask(id, input, store, context),
13652
+ delete: (id, context) => store.deleteTaskHierarchy(id, context),
13602
13653
  start: (id, agentId) => startTask(id, agentId, store),
13603
13654
  complete: (id, agentId, options2) => completeTask(id, agentId, options2, store),
13604
13655
  fail: (id, agentId, reason, options2) => failTask(id, agentId, reason, options2, store),
@@ -14131,22 +14182,70 @@ class PostgresJsonRecordStore {
14131
14182
  return "identical";
14132
14183
  throw new Error(divergentAuditHistoryReplayError(value.id));
14133
14184
  }
14134
- async upsertTaskWithPlanMembershipGuard(value, guardedPlanIds, explicitProject, context = {}) {
14185
+ async withTaskParentIntegrityTransaction(fn) {
14186
+ if (typeof this.options.client.transaction !== "function") {
14187
+ throw new Error("TASK_PARENT_ATOMICITY_UNAVAILABLE: PostgreSQL parent writes and task deletion require transaction(callback)");
14188
+ }
14189
+ return this.options.client.transaction(async (client) => {
14190
+ await client.query("/* todos:task-parent-integrity-lock */ SELECT pg_advisory_xact_lock(hashtextextended($1 || ':task-parent-integrity', 0))", [this.service]);
14191
+ return fn(client);
14192
+ });
14193
+ }
14194
+ async upsertTaskWithPlanMembershipGuard(value, guardedPlanIds, explicitProject, context = {}, parentGuard, queryClient) {
14135
14195
  const planIds = [...new Set(guardedPlanIds.filter(Boolean))].sort();
14136
- if (planIds.length === 0)
14196
+ if (planIds.length === 0 && !parentGuard)
14137
14197
  return this.upsert("tasks", value, context);
14138
14198
  await this.ensureSchema();
14199
+ if (parentGuard && !queryClient) {
14200
+ return this.withTaskParentIntegrityTransaction((client2) => this.upsertTaskWithPlanMembershipGuard(value, guardedPlanIds, explicitProject, context, parentGuard, client2));
14201
+ }
14202
+ const client = queryClient ?? this.options.client;
14139
14203
  const updatedAt = value.updated_at;
14140
14204
  const targetPlanId = value.plan_id;
14141
- const result = await this.options.client.query(`/* todos:task-plan-membership-guard */ WITH
14205
+ const result = await client.query(`/* todos:task-plan-membership-guard todos:task-parent-integrity-guard */ WITH RECURSIVE
14206
+ locked_task AS MATERIALIZED (
14207
+ SELECT payload FROM ${this.tableName}
14208
+ WHERE service = $1 AND object_type = 'tasks' AND object_id = $2 AND deleted_at IS NULL
14209
+ FOR UPDATE
14210
+ ),
14142
14211
  locked_plans AS MATERIALIZED (
14143
14212
  SELECT object_id, payload FROM ${this.tableName}
14144
14213
  WHERE service = $1 AND object_type = 'plans' AND deleted_at IS NULL
14145
14214
  AND object_id IN (SELECT value FROM jsonb_array_elements_text($7::jsonb))
14146
14215
  ORDER BY object_id
14147
14216
  FOR UPDATE
14217
+ ), parent_chain(object_id, payload, path, cycle) AS (
14218
+ SELECT parent.object_id, parent.payload, ARRAY[parent.object_id], false
14219
+ FROM ${this.tableName} AS parent
14220
+ WHERE $10::boolean
14221
+ AND $11::text IS NOT NULL
14222
+ AND parent.service = $1
14223
+ AND parent.object_type = 'tasks'
14224
+ AND parent.object_id = $11
14225
+ AND parent.deleted_at IS NULL
14226
+ UNION ALL
14227
+ SELECT ancestor.object_id,
14228
+ ancestor.payload,
14229
+ chain.path || ancestor.object_id,
14230
+ ancestor.object_id = ANY(chain.path)
14231
+ FROM parent_chain AS chain
14232
+ JOIN ${this.tableName} AS ancestor
14233
+ ON ancestor.service = $1
14234
+ AND ancestor.object_type = 'tasks'
14235
+ AND ancestor.object_id = chain.payload->>'parent_id'
14236
+ AND ancestor.deleted_at IS NULL
14237
+ WHERE NOT chain.cycle
14148
14238
  ), validation AS (
14149
14239
  SELECT
14240
+ (NOT $10::boolean OR NOT $13::boolean OR EXISTS (SELECT 1 FROM locked_task)) AS task_found,
14241
+ (NOT $10::boolean OR NOT $13::boolean
14242
+ OR (SELECT (payload->>'version')::integer FROM locked_task) = $12::integer) AS version_matches,
14243
+ (NOT $10::boolean OR $11::text IS NULL
14244
+ OR EXISTS (SELECT 1 FROM parent_chain WHERE object_id = $11)) AS parent_found,
14245
+ (NOT $10::boolean OR $11::text IS NULL
14246
+ OR ($11::text <> $2
14247
+ AND NOT EXISTS (SELECT 1 FROM parent_chain WHERE object_id = $2)
14248
+ AND NOT EXISTS (SELECT 1 FROM parent_chain WHERE cycle))) AS parent_acyclic,
14150
14249
  (SELECT count(*) FROM locked_plans) = jsonb_array_length($7::jsonb) AS all_plans_found,
14151
14250
  ($8::text IS NULL OR EXISTS (SELECT 1 FROM locked_plans WHERE object_id = $8)) AS target_plan_found,
14152
14251
  (SELECT payload->>'project_id' FROM locked_plans WHERE object_id = $8) AS target_project_id
@@ -14167,7 +14266,13 @@ class PostgresJsonRecordStore {
14167
14266
  )
14168
14267
  SELECT $1, 'tasks', $2, guarded.payload, $4::timestamptz, NULL, $5, $6
14169
14268
  FROM guarded
14170
- WHERE guarded.all_plans_found AND guarded.target_plan_found AND NOT guarded.project_conflict
14269
+ WHERE guarded.task_found
14270
+ AND guarded.version_matches
14271
+ AND guarded.parent_found
14272
+ AND guarded.parent_acyclic
14273
+ AND guarded.all_plans_found
14274
+ AND guarded.target_plan_found
14275
+ AND NOT guarded.project_conflict
14171
14276
  ON CONFLICT (service, object_type, object_id) DO UPDATE SET
14172
14277
  payload = EXCLUDED.payload,
14173
14278
  updated_at = EXCLUDED.updated_at,
@@ -14180,8 +14285,10 @@ class PostgresJsonRecordStore {
14180
14285
  AND COALESCE(${this.tableName}.version, 0) <= COALESCE(EXCLUDED.version, 0))
14181
14286
  RETURNING payload
14182
14287
  )
14183
- SELECT guarded.all_plans_found, guarded.target_plan_found, guarded.project_conflict,
14184
- (SELECT payload FROM stored) AS payload
14288
+ SELECT guarded.task_found, guarded.version_matches, guarded.parent_found, guarded.parent_acyclic,
14289
+ guarded.all_plans_found, guarded.target_plan_found, guarded.project_conflict,
14290
+ (SELECT payload FROM stored) AS payload,
14291
+ (SELECT payload FROM locked_task) AS current_payload
14185
14292
  FROM guarded`, [
14186
14293
  this.service,
14187
14294
  value.id,
@@ -14191,9 +14298,26 @@ class PostgresJsonRecordStore {
14191
14298
  numberValue2(value.version),
14192
14299
  jsonbParam(planIds),
14193
14300
  targetPlanId,
14194
- explicitProject
14301
+ explicitProject,
14302
+ Boolean(parentGuard),
14303
+ parentGuard?.parentId ?? null,
14304
+ parentGuard?.expectedVersion ?? null,
14305
+ parentGuard?.operation === "update"
14195
14306
  ]);
14196
14307
  const row = result.rows[0];
14308
+ if (parentGuard && !row?.task_found) {
14309
+ throw new TaskNotFoundError(value.id);
14310
+ }
14311
+ if (parentGuard && !row?.version_matches) {
14312
+ const current = row?.current_payload ? payloadRecord2(row.current_payload) : await this.get("tasks", value.id);
14313
+ throw new VersionConflictError(value.id, parentGuard.expectedVersion, current?.version ?? -1);
14314
+ }
14315
+ if (parentGuard && !row?.parent_found && parentGuard.parentId) {
14316
+ throw new TaskNotFoundError(parentGuard.parentId);
14317
+ }
14318
+ if (parentGuard && !row?.parent_acyclic && parentGuard.parentId) {
14319
+ throw new ResourceConflictError("TASK_PARENT_CYCLE", `TASK_PARENT_CYCLE: assigning parent ${parentGuard.parentId} to task ${value.id} would create or retain a parent cycle`);
14320
+ }
14197
14321
  if (!row?.all_plans_found || !row.target_plan_found) {
14198
14322
  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 });
14199
14323
  }
@@ -14201,6 +14325,8 @@ class PostgresJsonRecordStore {
14201
14325
  throw new ResourceConflictError("PLAN_PROJECT_LINK_CONFLICT", `Task project conflicts with linked plan ${targetPlanId}`);
14202
14326
  }
14203
14327
  if (!row.payload) {
14328
+ if (row.current_payload)
14329
+ return payloadRecord2(row.current_payload);
14204
14330
  return await requireRecord("tasks", value.id, this);
14205
14331
  }
14206
14332
  return payloadRecord2(row.payload);
@@ -14526,6 +14652,80 @@ class PostgresJsonRecordStore {
14526
14652
  version: numberValue2(existing["version"])
14527
14653
  }, context);
14528
14654
  }
14655
+ async deleteTaskHierarchy(id, context = {}) {
14656
+ await this.ensureSchema();
14657
+ return this.withTaskParentIntegrityTransaction(async (client) => {
14658
+ const timestamp = new Date().toISOString();
14659
+ const result = await client.query(`/* todos:task-parent-integrity-delete */ WITH RECURSIVE
14660
+ task_tree(object_id, path, cycle) AS (
14661
+ SELECT task.object_id, ARRAY[task.object_id], false
14662
+ FROM ${this.tableName} AS task
14663
+ WHERE task.service = $1
14664
+ AND task.object_type = 'tasks'
14665
+ AND task.object_id = $2
14666
+ AND task.deleted_at IS NULL
14667
+ UNION ALL
14668
+ SELECT child.object_id,
14669
+ tree.path || child.object_id,
14670
+ child.object_id = ANY(tree.path)
14671
+ FROM task_tree AS tree
14672
+ JOIN ${this.tableName} AS child
14673
+ ON child.service = $1
14674
+ AND child.object_type = 'tasks'
14675
+ AND child.payload->>'parent_id' = tree.object_id
14676
+ AND child.deleted_at IS NULL
14677
+ WHERE NOT tree.cycle
14678
+ ), tombstoned AS (
14679
+ UPDATE ${this.tableName} AS task
14680
+ SET deleted_at = $3::timestamptz,
14681
+ updated_at = $3::timestamptz,
14682
+ source_machine_id = $4
14683
+ WHERE task.service = $1
14684
+ AND task.object_type = 'tasks'
14685
+ AND task.deleted_at IS NULL
14686
+ AND task.object_id IN (
14687
+ SELECT object_id FROM task_tree WHERE NOT cycle
14688
+ )
14689
+ RETURNING task.object_id
14690
+ ), tombstoned_related AS (
14691
+ UPDATE ${this.tableName} AS related
14692
+ SET deleted_at = $3::timestamptz,
14693
+ updated_at = $3::timestamptz,
14694
+ source_machine_id = $4
14695
+ WHERE related.service = $1
14696
+ AND related.deleted_at IS NULL
14697
+ AND (
14698
+ (
14699
+ related.object_type = 'dependencies'
14700
+ AND (
14701
+ related.payload->>'task_id' IN (
14702
+ SELECT object_id FROM task_tree WHERE NOT cycle
14703
+ )
14704
+ OR related.payload->>'depends_on' IN (
14705
+ SELECT object_id FROM task_tree WHERE NOT cycle
14706
+ )
14707
+ )
14708
+ )
14709
+ OR (
14710
+ related.object_type IN ('comments', 'verifications', 'commits', 'refs')
14711
+ AND related.payload->>'task_id' IN (
14712
+ SELECT object_id FROM task_tree WHERE NOT cycle
14713
+ )
14714
+ )
14715
+ )
14716
+ RETURNING related.object_id
14717
+ )
14718
+ SELECT EXISTS (SELECT 1 FROM task_tree WHERE object_id = $2) AS found,
14719
+ (SELECT count(*) FROM tombstoned) AS deleted_count,
14720
+ (SELECT count(*) FROM tombstoned_related) AS related_deleted_count`, [
14721
+ this.service,
14722
+ id,
14723
+ timestamp,
14724
+ context.requestId ?? this.sourceMachineId ?? null
14725
+ ]);
14726
+ return Boolean(result.rows[0]?.found);
14727
+ });
14728
+ }
14529
14729
  async getPlanProjectLinkReceipt(receiptId) {
14530
14730
  const value = await this.get("plan_project_link_receipts", receiptId);
14531
14731
  return value ? assertPlanProjectLinkReceipt(value) : null;
@@ -14946,9 +15146,8 @@ class PostgresJsonRecordStore {
14946
15146
  }
14947
15147
  async function createTask(input, store, context) {
14948
15148
  const timestamp = new Date().toISOString();
14949
- if (input.parent_id && !await store.get("tasks", input.parent_id)) {
14950
- throw new TaskNotFoundError(input.parent_id);
14951
- }
15149
+ const taskId = randomUUID();
15150
+ await assertTaskParentIntegrityAsync(taskId, input.parent_id, (id) => store.get("tasks", id));
14952
15151
  const linkedPlan = input.plan_id ? await store.get("plans", input.plan_id) : null;
14953
15152
  const requestedProjectId = input.project_id ?? context?.projectId ?? null;
14954
15153
  if (linkedPlan?.project_id && requestedProjectId && requestedProjectId !== linkedPlan.project_id) {
@@ -14957,7 +15156,7 @@ async function createTask(input, store, context) {
14957
15156
  const effectiveProjectId = linkedPlan?.project_id ?? requestedProjectId;
14958
15157
  const shortId = effectiveProjectId ? await nextTaskShortId(effectiveProjectId, store, context) : null;
14959
15158
  const task = {
14960
- id: randomUUID(),
15159
+ id: taskId,
14961
15160
  short_id: shortId,
14962
15161
  project_id: effectiveProjectId,
14963
15162
  parent_id: input.parent_id ?? null,
@@ -15013,15 +15212,16 @@ async function createTask(input, store, context) {
15013
15212
  synced_at: null,
15014
15213
  archived_at: null
15015
15214
  };
15016
- const storedTask = await store.upsertTaskWithPlanMembershipGuard(task, task.plan_id ? [task.plan_id] : [], input.project_id !== undefined || context?.projectId !== undefined, context);
15215
+ const storedTask = await store.upsertTaskWithPlanMembershipGuard(task, task.plan_id ? [task.plan_id] : [], input.project_id !== undefined || context?.projectId !== undefined, context, input.parent_id ? { operation: "create", expectedVersion: 0, parentId: input.parent_id } : undefined);
15017
15216
  await logTaskChange(storedTask.id, "created", "status", null, storedTask.status, storedTask.assigned_by ?? storedTask.agent_id, store, context);
15018
15217
  return storedTask;
15019
15218
  }
15020
- async function updateTask(id, input, store) {
15219
+ async function updateTask(id, input, store, context) {
15021
15220
  const existing = await requireRecord("tasks", id, store);
15022
15221
  if (existing.version !== input.version) {
15023
- throw new Error(`Task ${id} version conflict: expected ${existing.version}, got ${input.version}`);
15222
+ throw new VersionConflictError(id, input.version, existing.version);
15024
15223
  }
15224
+ await assertTaskParentIntegrityAsync(id, input.parent_id, (candidateId) => store.get("tasks", candidateId));
15025
15225
  const effectivePlanId = input.plan_id !== undefined ? input.plan_id : existing.plan_id;
15026
15226
  const linkedPlan = effectivePlanId ? await store.get("plans", effectivePlanId) : null;
15027
15227
  if (linkedPlan?.project_id) {
@@ -15046,10 +15246,19 @@ async function updateTask(id, input, store) {
15046
15246
  metadata: input.metadata ?? existing.metadata,
15047
15247
  requires_approval: input.requires_approval ?? existing.requires_approval,
15048
15248
  task_list_id: input.task_list_id !== undefined ? input.task_list_id : existing.task_list_id,
15249
+ parent_id: input.parent_id !== undefined ? input.parent_id : existing.parent_id,
15049
15250
  created_by: existing.created_by,
15050
15251
  completed_at: reopened ? null : input.completed_at !== undefined ? input.completed_at : existing.completed_at
15051
15252
  };
15052
- return store.upsertTaskWithPlanMembershipGuard(task, [existing.plan_id, effectivePlanId].filter((planId) => Boolean(planId)), input.project_id !== undefined);
15253
+ const storedTask = await store.upsertTaskWithPlanMembershipGuard(task, [existing.plan_id, effectivePlanId].filter((planId) => Boolean(planId)), input.project_id !== undefined, context, {
15254
+ operation: "update",
15255
+ expectedVersion: input.version,
15256
+ parentId: input.parent_id !== undefined ? input.parent_id : existing.parent_id
15257
+ });
15258
+ if (input.parent_id !== undefined && input.parent_id !== existing.parent_id) {
15259
+ await logTaskChange(id, "update", "parent_id", existing.parent_id, input.parent_id, existing.assigned_to ?? existing.agent_id, store, context);
15260
+ }
15261
+ return storedTask;
15053
15262
  }
15054
15263
  async function startTask(id, agentId, store) {
15055
15264
  const task = await requireRecord("tasks", id, store);
@@ -15113,7 +15322,11 @@ async function patchTask(task, patch, store) {
15113
15322
  version: task.version + 1,
15114
15323
  updated_at: new Date().toISOString()
15115
15324
  };
15116
- return store.upsertTaskWithPlanMembershipGuard(updated, [task.plan_id, updated.plan_id].filter((planId) => Boolean(planId)), Object.prototype.hasOwnProperty.call(patch, "project_id"));
15325
+ return store.upsertTaskWithPlanMembershipGuard(updated, [task.plan_id, updated.plan_id].filter((planId) => Boolean(planId)), Object.prototype.hasOwnProperty.call(patch, "project_id"), {}, {
15326
+ operation: "update",
15327
+ expectedVersion: task.version,
15328
+ parentId: updated.parent_id
15329
+ });
15117
15330
  }
15118
15331
  var CLOUD_LOCK_EXPIRY_MINUTES = 30;
15119
15332
  function sameCloudLockHolder(stored, incoming) {
@@ -16103,6 +16316,7 @@ class PostgresTodosProjectRegistrationTransaction {
16103
16316
  AND (payload->>'path' = $2 OR payload->>'task_list_id' = $3)
16104
16317
  ORDER BY payload->>'created_at' ASC, object_id ASC
16105
16318
  LIMIT 1
16319
+ FOR UPDATE
16106
16320
  `, [this.service, path, taskListSlug]);
16107
16321
  return result.rows[0] ? parsePayload(result.rows[0].payload) : null;
16108
16322
  }
@@ -16113,6 +16327,7 @@ class PostgresTodosProjectRegistrationTransaction {
16113
16327
  AND payload->>'project_id' = $2 AND payload->>'slug' = $3
16114
16328
  ORDER BY payload->>'created_at' ASC, object_id ASC
16115
16329
  LIMIT 1
16330
+ FOR UPDATE
16116
16331
  `, [this.service, projectId, slug]);
16117
16332
  return result.rows[0] ? parsePayload(result.rows[0].payload) : null;
16118
16333
  }
@@ -16123,10 +16338,24 @@ class PostgresTodosProjectRegistrationTransaction {
16123
16338
  return await this.storage.taskLists.create(input);
16124
16339
  }
16125
16340
  async getProject(id) {
16126
- return await this.storage.projects.get(id);
16341
+ const result = await this.client.query(`
16342
+ SELECT payload FROM ${this.tableName}
16343
+ WHERE service = $1 AND object_type = 'projects' AND object_id = $2
16344
+ AND deleted_at IS NULL
16345
+ LIMIT 1
16346
+ FOR SHARE
16347
+ `, [this.service, id]);
16348
+ return result.rows[0] ? parsePayload(result.rows[0].payload) : null;
16127
16349
  }
16128
16350
  async getTaskList(id) {
16129
- return await this.storage.taskLists.get(id);
16351
+ const result = await this.client.query(`
16352
+ SELECT payload FROM ${this.tableName}
16353
+ WHERE service = $1 AND object_type = 'task_lists' AND object_id = $2
16354
+ AND deleted_at IS NULL
16355
+ LIMIT 1
16356
+ FOR SHARE
16357
+ `, [this.service, id]);
16358
+ return result.rows[0] ? parsePayload(result.rows[0].payload) : null;
16130
16359
  }
16131
16360
  async lockCompensationWrites() {
16132
16361
  await this.client.query(`LOCK TABLE ${this.tableName} IN SHARE ROW EXCLUSIVE MODE`);
@@ -16206,11 +16435,102 @@ class PostgresTodosProjectRegistrationBackend {
16206
16435
  async getTaskList(id) {
16207
16436
  return (await this.direct()).getTaskList(id);
16208
16437
  }
16438
+ async getProjectResourceCollectionRevision(input) {
16439
+ await this.ensureSchema();
16440
+ const result = await this.client.query(`
16441
+ WITH resources(kind_rank, target_id, revision) AS (
16442
+ SELECT 0, object_id, COALESCE(payload->>'updated_at', updated_at::text)
16443
+ FROM ${this.tableName}
16444
+ WHERE service = $1 AND object_type = 'projects'
16445
+ AND deleted_at IS NULL AND object_id = $2
16446
+ UNION ALL
16447
+ SELECT 1, object_id, COALESCE(payload->>'updated_at', updated_at::text)
16448
+ FROM ${this.tableName}
16449
+ WHERE service = $1 AND object_type = 'task_lists'
16450
+ AND deleted_at IS NULL AND object_id = $3
16451
+ AND payload->>'project_id' = $2
16452
+ UNION ALL
16453
+ SELECT 2, object_id, COALESCE(payload->>'updated_at', updated_at::text)
16454
+ FROM ${this.tableName}
16455
+ WHERE $4::boolean AND service = $1 AND object_type = 'plans'
16456
+ AND deleted_at IS NULL AND payload->>'project_id' = $2
16457
+ UNION ALL
16458
+ SELECT 3, object_id, COALESCE(payload->>'updated_at', updated_at::text)
16459
+ FROM ${this.tableName}
16460
+ WHERE $4::boolean AND service = $1 AND object_type = 'tasks'
16461
+ AND deleted_at IS NULL AND payload->>'project_id' = $2
16462
+ )
16463
+ SELECT 'md5:' || md5(COALESCE(string_agg(
16464
+ kind_rank::text || chr(31) || target_id || chr(31) || revision,
16465
+ chr(30) ORDER BY kind_rank ASC, target_id ASC
16466
+ ), '')) AS revision
16467
+ FROM resources
16468
+ `, [
16469
+ this.service,
16470
+ input.todos_project_id,
16471
+ input.task_list_id,
16472
+ input.include_anchors
16473
+ ]);
16474
+ const revision = result.rows[0]?.revision;
16475
+ if (!revision) {
16476
+ throw new TodosProjectRegistrationError("TODOS_PROJECT_REGISTRATION_RECORD_NOT_FOUND", "could not derive the hosted project-resource collection revision");
16477
+ }
16478
+ return revision;
16479
+ }
16480
+ async listProjectResourceCandidates(input) {
16481
+ await this.ensureSchema();
16482
+ const afterRank = input.after?.kind_rank ?? -1;
16483
+ const afterId = input.after?.target_id ?? "";
16484
+ const result = await this.client.query(`
16485
+ WITH resources(kind, kind_rank, target_id, parent_id, revision) AS (
16486
+ SELECT 'project'::text, 0, object_id, NULL::text,
16487
+ COALESCE(payload->>'updated_at', updated_at::text)
16488
+ FROM ${this.tableName}
16489
+ WHERE service = $1 AND object_type = 'projects'
16490
+ AND deleted_at IS NULL AND object_id = $2
16491
+ UNION ALL
16492
+ SELECT 'task_list'::text, 1, object_id, payload->>'project_id',
16493
+ COALESCE(payload->>'updated_at', updated_at::text)
16494
+ FROM ${this.tableName}
16495
+ WHERE service = $1 AND object_type = 'task_lists'
16496
+ AND deleted_at IS NULL AND object_id = $3
16497
+ AND payload->>'project_id' = $2
16498
+ UNION ALL
16499
+ SELECT 'plan'::text, 2, object_id, payload->>'project_id',
16500
+ COALESCE(payload->>'updated_at', updated_at::text)
16501
+ FROM ${this.tableName}
16502
+ WHERE $4::boolean AND service = $1 AND object_type = 'plans'
16503
+ AND deleted_at IS NULL AND payload->>'project_id' = $2
16504
+ UNION ALL
16505
+ SELECT 'task'::text, 3, object_id,
16506
+ COALESCE(payload->>'plan_id', payload->>'project_id'),
16507
+ COALESCE(payload->>'updated_at', updated_at::text)
16508
+ FROM ${this.tableName}
16509
+ WHERE $4::boolean AND service = $1 AND object_type = 'tasks'
16510
+ AND deleted_at IS NULL AND payload->>'project_id' = $2
16511
+ )
16512
+ SELECT kind, kind_rank, target_id, parent_id, revision
16513
+ FROM resources
16514
+ WHERE kind_rank > $5 OR (kind_rank = $5 AND target_id > $6)
16515
+ ORDER BY kind_rank ASC, target_id ASC
16516
+ LIMIT $7
16517
+ `, [
16518
+ this.service,
16519
+ input.todos_project_id,
16520
+ input.task_list_id,
16521
+ input.include_anchors,
16522
+ afterRank,
16523
+ afterId,
16524
+ input.limit
16525
+ ]);
16526
+ return result.rows;
16527
+ }
16209
16528
  }
16210
16529
 
16211
16530
  // src/project-registration/sqlite.ts
16212
16531
  init_database();
16213
16532
  init_storage_tombstones();
16533
+ import { createHash as createHash6 } from "crypto";
16214
16534
 
16215
16535
  // src/storage/local-sqlite.ts
16216
16536
  init_types();
@@ -18518,6 +18838,64 @@ class SqliteTodosProjectRegistrationBackend {
18518
18838
  getTaskList(id) {
18519
18839
  return this.direct.getTaskList(id);
18520
18840
  }
18841
+ async getProjectResourceCollectionRevision(input) {
18842
+ const digest = createHash6("sha256");
18843
+ const rows = this.db.query(`
18844
+ WITH resources(kind_rank, target_id, revision) AS (
18845
+ SELECT 0, id, updated_at
18846
+ FROM projects
18847
+ WHERE id = ?
18848
+ UNION ALL
18849
+ SELECT 1, id, updated_at
18850
+ FROM task_lists
18851
+ WHERE id = ? AND project_id = ?
18852
+ UNION ALL
18853
+ SELECT 2, id, updated_at
18854
+ FROM plans
18855
+ WHERE ? = 1 AND project_id = ?
18856
+ UNION ALL
18857
+ SELECT 3, id, updated_at
18858
+ FROM tasks
18859
+ WHERE ? = 1 AND project_id = ?
18860
+ )
18861
+ SELECT kind_rank, target_id, revision
18862
+ FROM resources
18863
+ ORDER BY kind_rank ASC, target_id ASC
18864
+ `).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);
18865
+ for (const row of rows) {
18866
+ digest.update(`${row.kind_rank}\x00${row.target_id}\x00${row.revision}
18867
+ `);
18868
+ }
18869
+ return `sha256:${digest.digest("hex")}`;
18870
+ }
18871
+ async listProjectResourceCandidates(input) {
18872
+ const afterRank = input.after?.kind_rank ?? -1;
18873
+ const afterId = input.after?.target_id ?? "";
18874
+ return this.db.query(`
18875
+ WITH resources(kind, kind_rank, target_id, parent_id, revision) AS (
18876
+ SELECT 'project', 0, id, NULL, updated_at
18877
+ FROM projects
18878
+ WHERE id = ?
18879
+ UNION ALL
18880
+ SELECT 'task_list', 1, id, project_id, updated_at
18881
+ FROM task_lists
18882
+ WHERE id = ? AND project_id = ?
18883
+ UNION ALL
18884
+ SELECT 'plan', 2, id, project_id, updated_at
18885
+ FROM plans
18886
+ WHERE ? = 1 AND project_id = ?
18887
+ UNION ALL
18888
+ SELECT 'task', 3, id, COALESCE(plan_id, project_id), updated_at
18889
+ FROM tasks
18890
+ WHERE ? = 1 AND project_id = ?
18891
+ )
18892
+ SELECT kind, kind_rank, target_id, parent_id, revision
18893
+ FROM resources
18894
+ WHERE kind_rank > ? OR (kind_rank = ? AND target_id > ?)
18895
+ ORDER BY kind_rank ASC, target_id ASC
18896
+ LIMIT ?
18897
+ `).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);
18898
+ }
18521
18899
  }
18522
18900
 
18523
18901
  // src/project-registration/authority.ts
@@ -18529,6 +18907,8 @@ var AUTHORITY_ROUTE_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{2,127}$/;
18529
18907
  var PACKAGE_VERSION_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._+-]{0,127}$/;
18530
18908
  var SHA256_PATTERN = /^[0-9a-f]{64}$/;
18531
18909
  var IDEMPOTENCY_PATTERN = /^prk_[0-9a-f]{48}$/;
18910
+ var PROJECT_RESOURCE_PAGE_LIMIT = 500;
18911
+ var PROJECT_RESOURCE_CURSOR_VERSION = 1;
18532
18912
 
18533
18913
  class WriteBoundaryError extends Error {
18534
18914
  point;
@@ -18556,7 +18936,7 @@ function canonicalize3(value) {
18556
18936
  return out;
18557
18937
  }
18558
18938
  function digestProjectRegistrationValue(value) {
18559
- return createHash6("sha256").update(canonicalProjectRegistrationJson(value)).digest("hex");
18939
+ return createHash7("sha256").update(canonicalProjectRegistrationJson(value)).digest("hex");
18560
18940
  }
18561
18941
  function deriveTodosProjectRegistrationIdempotencyKey(input) {
18562
18942
  return `prk_${digestProjectRegistrationValue({
@@ -18668,35 +19048,68 @@ function projectRecord(project) {
18668
19048
  return {
18669
19049
  target_id: project.id,
18670
19050
  revision: project.updated_at,
18671
- digest: digestProjectRegistrationValue({
18672
- id: project.id,
18673
- name: project.name,
18674
- path: project.path,
18675
- description: project.description,
18676
- task_list_id: project.task_list_id,
18677
- task_prefix: project.task_prefix,
18678
- task_counter: project.task_counter,
18679
- created_at: project.created_at,
18680
- updated_at: project.updated_at
18681
- })
19051
+ digest: projectRegistrationDigest(project)
18682
19052
  };
18683
19053
  }
18684
19054
  function taskListRecord(taskList) {
18685
19055
  return {
18686
19056
  target_id: taskList.id,
18687
19057
  revision: taskList.updated_at,
18688
- digest: digestProjectRegistrationValue({
18689
- id: taskList.id,
18690
- project_id: taskList.project_id,
18691
- slug: taskList.slug,
18692
- name: taskList.name,
18693
- description: taskList.description,
18694
- metadata: taskList.metadata,
18695
- created_at: taskList.created_at,
18696
- updated_at: taskList.updated_at
19058
+ digest: taskListRegistrationDigest(taskList)
19059
+ };
19060
+ }
19061
+ function boundExistingProjectRecord(project) {
19062
+ return {
19063
+ target_id: project.id,
19064
+ revision: project.created_at,
19065
+ digest: projectRegistrationDigest({
19066
+ ...project,
19067
+ updated_at: project.created_at
19068
+ })
19069
+ };
19070
+ }
19071
+ function boundExistingTaskListRecord(taskList) {
19072
+ return {
19073
+ target_id: taskList.id,
19074
+ revision: taskList.created_at,
19075
+ digest: taskListRegistrationDigest({
19076
+ ...taskList,
19077
+ updated_at: taskList.created_at
18697
19078
  })
18698
19079
  };
18699
19080
  }
19081
+ function projectRegistrationDigest(project) {
19082
+ return digestProjectRegistrationValue({
19083
+ id: project.id,
19084
+ name: project.name,
19085
+ path: project.path,
19086
+ description: project.description,
19087
+ task_list_id: project.task_list_id,
19088
+ task_prefix: project.task_prefix,
19089
+ task_counter: project.task_counter,
19090
+ created_at: project.created_at,
19091
+ updated_at: project.updated_at
19092
+ });
19093
+ }
19094
+ function taskListRegistrationDigest(taskList) {
19095
+ return digestProjectRegistrationValue({
19096
+ id: taskList.id,
19097
+ project_id: taskList.project_id,
19098
+ slug: taskList.slug,
19099
+ name: taskList.name,
19100
+ description: taskList.description,
19101
+ metadata: taskList.metadata,
19102
+ created_at: taskList.created_at,
19103
+ updated_at: taskList.updated_at
19104
+ });
19105
+ }
19106
+ function canonicalValuesEqual(left, right) {
19107
+ try {
19108
+ return canonicalProjectRegistrationJson(left) === canonicalProjectRegistrationJson(right);
19109
+ } catch {
19110
+ return false;
19111
+ }
19112
+ }
18700
19113
  function receiptId(input) {
18701
19114
  return `tpr_${digestProjectRegistrationValue(input).slice(0, 40)}`;
18702
19115
  }
@@ -18734,9 +19147,37 @@ function normalizedCallDigest(request) {
18734
19147
  project_slug: request.project_slug,
18735
19148
  project_name: request.project_name,
18736
19149
  desired: request.desired,
19150
+ bind_existing: request.bind_existing === true,
18737
19151
  accepted_receipt_id: request.accepted_receipt?.receipt_id ?? null
18738
19152
  });
18739
19153
  }
19154
+ function legacyNormalizedCallDigestBeforeBindExisting(request) {
19155
+ return digestProjectRegistrationValue({
19156
+ authority_route: request.authority_route,
19157
+ package_version: request.package_version,
19158
+ authority_id: request.authority_id,
19159
+ tenant_id: request.tenant_id,
19160
+ corpus_id: request.corpus_id,
19161
+ operation_id: request.operation_id,
19162
+ step_id: request.step_id,
19163
+ resource_kind: request.resource_kind,
19164
+ direction: request.direction,
19165
+ target_selector: request.target_selector,
19166
+ idempotency_key: request.idempotency_key,
19167
+ request_digest: request.request_digest,
19168
+ precondition_digest: request.precondition_digest,
19169
+ project_id: request.project_id,
19170
+ project_slug: request.project_slug,
19171
+ project_name: request.project_name,
19172
+ desired: request.desired,
19173
+ accepted_receipt_id: request.accepted_receipt?.receipt_id ?? null
19174
+ });
19175
+ }
19176
+ function acceptedCallMatches(request, accepted, callDigest = normalizedCallDigest(request)) {
19177
+ if (accepted.normalized_call_digest === callDigest)
19178
+ return true;
19179
+ return request.bind_existing !== true && accepted.normalized_call_digest === legacyNormalizedCallDigestBeforeBindExisting(request);
19180
+ }
18740
19181
  function assertCommonRequest(request, capability) {
18741
19182
  assertBounds(request);
18742
19183
  assertResourceKind(request.resource_kind);
@@ -18778,6 +19219,9 @@ function assertCommonRequest(request, capability) {
18778
19219
  if (!request.desired || typeof request.desired !== "object" || Array.isArray(request.desired)) {
18779
19220
  throw new TodosProjectRegistrationError("TODOS_PROJECT_REGISTRATION_INVALID_INPUT", "desired must be a JSON object");
18780
19221
  }
19222
+ if (request.bind_existing !== undefined && typeof request.bind_existing !== "boolean") {
19223
+ throw new TodosProjectRegistrationError("TODOS_PROJECT_REGISTRATION_INVALID_INPUT", "bind_existing must be boolean when supplied");
19224
+ }
18781
19225
  const expectedKey = deriveTodosProjectRegistrationIdempotencyKey({
18782
19226
  operation_id: request.operation_id,
18783
19227
  step_id: request.step_id,
@@ -18799,7 +19243,7 @@ function assertForwardRequest(request, capability) {
18799
19243
  const expectedRequestDigest = digestProjectRegistrationValue(request.desired);
18800
19244
  const expectedPreconditionDigest = digestProjectRegistrationValue({
18801
19245
  target_selector: request.target_selector,
18802
- expected: "absent"
19246
+ expected: request.bind_existing === true ? "absent_or_matching_existing" : "absent"
18803
19247
  });
18804
19248
  if (request.request_digest !== expectedRequestDigest || request.precondition_digest !== expectedPreconditionDigest) {
18805
19249
  throw new TodosProjectRegistrationError("TODOS_PROJECT_REGISTRATION_DIGEST_MISMATCH", "request_digest or precondition_digest does not match normalized forward semantics", {
@@ -18878,7 +19322,7 @@ function receiptBase(request, callDigest, capability) {
18878
19322
  normalized_call_digest: callDigest
18879
19323
  };
18880
19324
  }
18881
- function makeAcceptedReceipt(request, callDigest, capability, record, createdAt) {
19325
+ function makeAcceptedReceipt(request, callDigest, capability, record, createdAt, createdByOperation = true) {
18882
19326
  return makeReceipt({
18883
19327
  ...receiptBase(request, callDigest, capability),
18884
19328
  outcome: "accepted",
@@ -18888,7 +19332,7 @@ function makeAcceptedReceipt(request, callDigest, capability, record, createdAt)
18888
19332
  result_digest: record.digest,
18889
19333
  duplicate_of_receipt_id: null,
18890
19334
  accepted_receipt_id: request.direction === "inverse" ? request.accepted_receipt.receipt_id : null,
18891
- created_by_operation: true
19335
+ created_by_operation: createdByOperation
18892
19336
  }, createdAt);
18893
19337
  }
18894
19338
  function makeDuplicateReceipt(request, callDigest, capability, accepted, createdAt) {
@@ -18950,6 +19394,53 @@ function bindingFor(request, callDigest, timestamp2, capability) {
18950
19394
  updated_at: timestamp2
18951
19395
  };
18952
19396
  }
19397
+ function encodeProjectResourceCursor(input) {
19398
+ return Buffer.from(JSON.stringify({
19399
+ version: PROJECT_RESOURCE_CURSOR_VERSION,
19400
+ ...input
19401
+ }), "utf8").toString("base64url");
19402
+ }
19403
+ function decodeProjectResourceCursor(cursor, expected) {
19404
+ if (!cursor)
19405
+ return null;
19406
+ let parsed;
19407
+ try {
19408
+ parsed = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8"));
19409
+ } catch {
19410
+ throw new TodosProjectRegistrationError("TODOS_PROJECT_REGISTRATION_INVALID_INPUT", "cursor is not a valid project-resource cursor");
19411
+ }
19412
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
19413
+ throw new TodosProjectRegistrationError("TODOS_PROJECT_REGISTRATION_INVALID_INPUT", "cursor is not a valid project-resource cursor");
19414
+ }
19415
+ const value = parsed;
19416
+ 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) {
19417
+ throw new TodosProjectRegistrationError("TODOS_PROJECT_REGISTRATION_INVALID_INPUT", "cursor does not match this project-resource query");
19418
+ }
19419
+ return {
19420
+ kind_rank: Number(value["kind_rank"]),
19421
+ target_id: value["target_id"],
19422
+ collection_revision: value["collection_revision"]
19423
+ };
19424
+ }
19425
+ function projectResourceFromCandidate(sourceProjectId, candidate) {
19426
+ const scope = candidate.kind === "project" || candidate.kind === "task_list" ? "collection" : "resource";
19427
+ return {
19428
+ source_project_id: sourceProjectId,
19429
+ kind: candidate.kind,
19430
+ scope,
19431
+ target_id: candidate.target_id,
19432
+ parent_id: candidate.parent_id,
19433
+ revision: candidate.revision,
19434
+ digest: digestProjectRegistrationValue({
19435
+ source_project_id: sourceProjectId,
19436
+ kind: candidate.kind,
19437
+ scope,
19438
+ target_id: candidate.target_id,
19439
+ parent_id: candidate.parent_id,
19440
+ revision: candidate.revision
19441
+ })
19442
+ };
19443
+ }
18953
19444
 
18954
19445
  class PackageOwnedTodosProjectRegistrationAuthority {
18955
19446
  backend;
@@ -18971,6 +19462,10 @@ class PackageOwnedTodosProjectRegistrationAuthority {
18971
19462
  immutable_receipts: true,
18972
19463
  exact_terminal_lookup: true,
18973
19464
  exact_readback: true,
19465
+ bind_existing_adoption: true,
19466
+ prior_registration_adoption_validation: true,
19467
+ project_resource_enumeration: true,
19468
+ project_resource_page_limit: PROJECT_RESOURCE_PAGE_LIMIT,
18974
19469
  conditional_inverse: true,
18975
19470
  ambiguous_outcome_reconciliation: true
18976
19471
  };
@@ -19031,7 +19526,7 @@ class PackageOwnedTodosProjectRegistrationAuthority {
19031
19526
  if (!accepted2) {
19032
19527
  throw new TodosProjectRegistrationError("TODOS_PROJECT_REGISTRATION_CONFLICT", "duplicate receipt points to a missing accepted receipt");
19033
19528
  }
19034
- if (accepted2.normalized_call_digest !== callDigest) {
19529
+ if (!acceptedCallMatches(request, accepted2, callDigest)) {
19035
19530
  return this.terminalFor(transaction, request, callDigest, "operation_step_semantics_changed", { targetId: accepted2.target_id });
19036
19531
  }
19037
19532
  return this.duplicateFor(transaction, request, callDigest, accepted2);
@@ -19045,7 +19540,7 @@ class PackageOwnedTodosProjectRegistrationAuthority {
19045
19540
  });
19046
19541
  if (!accepted)
19047
19542
  return null;
19048
- if (accepted.normalized_call_digest === callDigest) {
19543
+ if (acceptedCallMatches(request, accepted, callDigest)) {
19049
19544
  return this.duplicateFor(transaction, request, callDigest, accepted);
19050
19545
  }
19051
19546
  return this.terminalFor(transaction, request, callDigest, "operation_step_semantics_changed", { targetId: accepted.target_id });
@@ -19056,6 +19551,12 @@ class PackageOwnedTodosProjectRegistrationAuthority {
19056
19551
  const slug2 = taskListSlug(request.project_slug);
19057
19552
  const conflict2 = await transaction.findProjectConflict(path, slug2);
19058
19553
  if (conflict2) {
19554
+ if (request.bind_existing === true && conflict2.path === path && conflict2.task_list_id === slug2) {
19555
+ return {
19556
+ record: boundExistingProjectRecord(conflict2),
19557
+ created_by_operation: false
19558
+ };
19559
+ }
19059
19560
  return this.terminalFor(transaction, request, normalizedCallDigest(request), "target_already_exists", { targetId: conflict2.id });
19060
19561
  }
19061
19562
  await this.fault("before_object_write", request);
@@ -19067,7 +19568,10 @@ class PackageOwnedTodosProjectRegistrationAuthority {
19067
19568
  task_prefix: deterministicTaskPrefix(request.project_slug)
19068
19569
  });
19069
19570
  await this.fault("after_object_write", request);
19070
- return projectRecord(project);
19571
+ return {
19572
+ record: projectRecord(project),
19573
+ created_by_operation: true
19574
+ };
19071
19575
  }
19072
19576
  const todosProjectId = String(request.desired["todos_project_id"]);
19073
19577
  const sourceBinding = await transaction.getBinding(authorityScope(this.capabilityValue), "project", request.project_id);
@@ -19081,6 +19585,12 @@ class PackageOwnedTodosProjectRegistrationAuthority {
19081
19585
  const slug = taskListSlug(request.project_slug);
19082
19586
  const conflict = await transaction.findTaskListConflict(todosProjectId, slug);
19083
19587
  if (conflict) {
19588
+ if (request.bind_existing === true && conflict.project_id === todosProjectId && conflict.slug === slug) {
19589
+ return {
19590
+ record: boundExistingTaskListRecord(conflict),
19591
+ created_by_operation: false
19592
+ };
19593
+ }
19084
19594
  return this.terminalFor(transaction, request, normalizedCallDigest(request), "target_already_exists", { targetId: conflict.id });
19085
19595
  }
19086
19596
  await this.fault("before_object_write", request);
@@ -19097,7 +19607,10 @@ class PackageOwnedTodosProjectRegistrationAuthority {
19097
19607
  if (taskList.project_id !== todosProjectId) {
19098
19608
  throw new TodosProjectRegistrationError("TODOS_PROJECT_REGISTRATION_CONFLICT", "task-list create did not preserve the exact full Todos project id");
19099
19609
  }
19100
- return taskListRecord(taskList);
19610
+ return {
19611
+ record: taskListRecord(taskList),
19612
+ created_by_operation: true
19613
+ };
19101
19614
  }
19102
19615
  async create(request) {
19103
19616
  const startedAt = Date.now();
@@ -19119,9 +19632,9 @@ class PackageOwnedTodosProjectRegistrationAuthority {
19119
19632
  const claimed = await transaction.claimBinding(bindingFor(request, callDigest, timestamp2, this.capabilityValue));
19120
19633
  if (!claimed) {
19121
19634
  const binding = await transaction.getBinding(authorityScope(this.capabilityValue), request.resource_kind, request.target_selector);
19122
- if (binding?.state === "accepted" && binding.normalized_call_digest === callDigest && binding.accepted_receipt_id) {
19635
+ if (binding?.state === "accepted" && binding.accepted_receipt_id) {
19123
19636
  const accepted2 = await transaction.getReceiptById(binding.accepted_receipt_id);
19124
- if (accepted2) {
19637
+ if (accepted2 && binding.normalized_call_digest === accepted2.normalized_call_digest && acceptedCallMatches(request, accepted2, callDigest)) {
19125
19638
  return this.duplicateFor(transaction, request, callDigest, accepted2);
19126
19639
  }
19127
19640
  }
@@ -19132,15 +19645,15 @@ class PackageOwnedTodosProjectRegistrationAuthority {
19132
19645
  await transaction.setBindingTerminal(authorityScope(this.capabilityValue), request.resource_kind, request.target_selector, this.now());
19133
19646
  return recordOrTerminal;
19134
19647
  }
19135
- const accepted = makeAcceptedReceipt(request, callDigest, this.capabilityValue, recordOrTerminal, this.now());
19648
+ const accepted = makeAcceptedReceipt(request, callDigest, this.capabilityValue, recordOrTerminal.record, this.now(), recordOrTerminal.created_by_operation);
19136
19649
  await this.fault("before_receipt_write", request);
19137
19650
  const stored = await insertDeterministicReceipt(transaction, accepted);
19138
19651
  await this.fault("after_receipt_write", request);
19139
19652
  await transaction.setBindingAccepted(authorityScope(this.capabilityValue), request.resource_kind, request.target_selector, {
19140
- target_id: recordOrTerminal.target_id,
19653
+ target_id: recordOrTerminal.record.target_id,
19141
19654
  accepted_receipt_id: stored.receipt_id,
19142
- result_revision: recordOrTerminal.revision,
19143
- result_digest: recordOrTerminal.digest,
19655
+ result_revision: recordOrTerminal.record.revision,
19656
+ result_digest: recordOrTerminal.record.digest,
19144
19657
  updated_at: this.now()
19145
19658
  });
19146
19659
  return stored;
@@ -19188,7 +19701,7 @@ class PackageOwnedTodosProjectRegistrationAuthority {
19188
19701
  direction: request.direction
19189
19702
  });
19190
19703
  if (accepted) {
19191
- 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 });
19704
+ return acceptedCallMatches(request, accepted, callDigest) ? this.duplicateFor(transaction, request, callDigest, accepted) : this.terminalFor(transaction, request, callDigest, "operation_step_semantics_changed", { targetId: accepted.target_id });
19192
19705
  }
19193
19706
  const timestamp2 = this.now();
19194
19707
  const claimed = await transaction.claimBinding(bindingFor(request, callDigest, timestamp2, this.capabilityValue));
@@ -19268,6 +19781,164 @@ class PackageOwnedTodosProjectRegistrationAuthority {
19268
19781
  }
19269
19782
  return withResponseControl({ receipt: publicReceipt(receipt) }, request, startedAt);
19270
19783
  }
19784
+ async listProjectResources(request) {
19785
+ const sourceProjectId = requireString(request.source_project_id, "source_project_id", { min: 16, max: 128, pattern: WORKSPACE_ID_PATTERN });
19786
+ if (!Number.isSafeInteger(request.limit) || request.limit <= 0 || request.limit > PROJECT_RESOURCE_PAGE_LIMIT) {
19787
+ throw new TodosProjectRegistrationError("TODOS_PROJECT_REGISTRATION_INVALID_BOUNDS", `limit must be an integer from 1 to ${PROJECT_RESOURCE_PAGE_LIMIT}`);
19788
+ }
19789
+ if (request.include_anchors !== undefined && typeof request.include_anchors !== "boolean") {
19790
+ throw new TodosProjectRegistrationError("TODOS_PROJECT_REGISTRATION_INVALID_INPUT", "include_anchors must be boolean when supplied");
19791
+ }
19792
+ const includeAnchors = request.include_anchors === true;
19793
+ const projectBinding = await this.backend.getBinding(authorityScope(this.capabilityValue), "project", sourceProjectId);
19794
+ if (!projectBinding || projectBinding.state !== "accepted" || !projectBinding.target_id) {
19795
+ 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 });
19796
+ }
19797
+ const taskListBinding = await this.backend.getBinding(authorityScope(this.capabilityValue), "task_list", `${projectBinding.target_id}:default`);
19798
+ if (!taskListBinding || taskListBinding.state !== "accepted" || !taskListBinding.target_id) {
19799
+ throw new TodosProjectRegistrationError("TODOS_PROJECT_REGISTRATION_RECORD_NOT_FOUND", "no accepted canonical task-list binding exists for this exact Todos project id", {
19800
+ source_project_id: sourceProjectId,
19801
+ todos_project_id: projectBinding.target_id
19802
+ });
19803
+ }
19804
+ const cursor = decodeProjectResourceCursor(request.cursor, {
19805
+ source_project_id: sourceProjectId,
19806
+ include_anchors: includeAnchors
19807
+ });
19808
+ const collectionInput = {
19809
+ todos_project_id: projectBinding.target_id,
19810
+ task_list_id: taskListBinding.target_id,
19811
+ include_anchors: includeAnchors
19812
+ };
19813
+ const collectionRevision = await this.backend.getProjectResourceCollectionRevision(collectionInput);
19814
+ if (cursor && cursor.collection_revision !== collectionRevision) {
19815
+ throw new TodosProjectRegistrationError("TODOS_PROJECT_REGISTRATION_COLLECTION_CHANGED", "project-resource collection changed during pagination; restart from the first page", {
19816
+ source_project_id: sourceProjectId,
19817
+ expected_collection_revision: cursor.collection_revision,
19818
+ current_collection_revision: collectionRevision
19819
+ });
19820
+ }
19821
+ const candidates = await this.backend.listProjectResourceCandidates({
19822
+ ...collectionInput,
19823
+ after: cursor ? { kind_rank: cursor.kind_rank, target_id: cursor.target_id } : null,
19824
+ limit: request.limit + 1
19825
+ });
19826
+ const verifiedCollectionRevision = await this.backend.getProjectResourceCollectionRevision(collectionInput);
19827
+ if (verifiedCollectionRevision !== collectionRevision) {
19828
+ throw new TodosProjectRegistrationError("TODOS_PROJECT_REGISTRATION_COLLECTION_CHANGED", "project-resource collection changed while producing a page; restart from the first page", {
19829
+ source_project_id: sourceProjectId,
19830
+ expected_collection_revision: collectionRevision,
19831
+ current_collection_revision: verifiedCollectionRevision
19832
+ });
19833
+ }
19834
+ const hasMore = candidates.length > request.limit;
19835
+ const pageCandidates = candidates.slice(0, request.limit);
19836
+ const resources = pageCandidates.map((candidate) => projectResourceFromCandidate(sourceProjectId, candidate));
19837
+ const last = pageCandidates.at(-1);
19838
+ return {
19839
+ authority: "todos",
19840
+ route: this.capabilityValue.route,
19841
+ package_version: this.capabilityValue.package_version,
19842
+ authority_id: this.capabilityValue.authority_id,
19843
+ tenant_id: this.capabilityValue.tenant_id,
19844
+ corpus_id: this.capabilityValue.corpus_id,
19845
+ source_project_id: sourceProjectId,
19846
+ todos_project_id: projectBinding.target_id,
19847
+ task_list_id: taskListBinding.target_id,
19848
+ include_anchors: includeAnchors,
19849
+ collection_revision: collectionRevision,
19850
+ limit: request.limit,
19851
+ count: resources.length,
19852
+ resources,
19853
+ has_more: hasMore,
19854
+ next_cursor: hasMore && last ? encodeProjectResourceCursor({
19855
+ source_project_id: sourceProjectId,
19856
+ include_anchors: includeAnchors,
19857
+ collection_revision: collectionRevision,
19858
+ kind_rank: last.kind_rank,
19859
+ target_id: last.target_id
19860
+ }) : null,
19861
+ complete: !hasMore,
19862
+ truncated: false
19863
+ };
19864
+ }
19865
+ async validatePriorRegistrationAdoption(sourceRequest, sourceReceipt, currentRecord) {
19866
+ const startedAt = Date.now();
19867
+ if (!sourceRequest || typeof sourceRequest !== "object" || Array.isArray(sourceRequest) || !sourceReceipt || typeof sourceReceipt !== "object" || Array.isArray(sourceReceipt) || !currentRecord || typeof currentRecord !== "object" || Array.isArray(currentRecord)) {
19868
+ throw new TodosProjectRegistrationError("TODOS_PROJECT_REGISTRATION_ADOPTION_REJECTED", "source request, source receipt, and current record must be present objects");
19869
+ }
19870
+ requireString(sourceRequest.package_version, "package_version", {
19871
+ max: 128,
19872
+ pattern: PACKAGE_VERSION_PATTERN
19873
+ });
19874
+ assertForwardRequest(sourceRequest, {
19875
+ ...this.capabilityValue,
19876
+ package_version: sourceRequest.package_version
19877
+ });
19878
+ const validation = await this.backend.transaction(async (transaction) => {
19879
+ const storedSource = await transaction.getReceiptById(sourceReceipt.receipt_id);
19880
+ if (!storedSource || !canonicalValuesEqual(publicReceipt(storedSource), sourceReceipt) || storedSource.outcome !== "accepted" && storedSource.outcome !== "duplicate_of_accepted") {
19881
+ throw new TodosProjectRegistrationError("TODOS_PROJECT_REGISTRATION_ADOPTION_REJECTED", "source receipt is not an exact immutable accepted or duplicate receipt", { receipt_id: sourceReceipt.receipt_id });
19882
+ }
19883
+ const accepted = storedSource.outcome === "accepted" ? storedSource : storedSource.duplicate_of_receipt_id ? await transaction.getReceiptById(storedSource.duplicate_of_receipt_id) : null;
19884
+ if (!accepted || accepted.outcome !== "accepted" || !accepted.target_id || !accepted.result_revision || !accepted.result_digest) {
19885
+ throw new TodosProjectRegistrationError("TODOS_PROJECT_REGISTRATION_ADOPTION_REJECTED", "source receipt does not resolve to one complete accepted receipt", { receipt_id: sourceReceipt.receipt_id });
19886
+ }
19887
+ 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);
19888
+ 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)) {
19889
+ throw new TodosProjectRegistrationError("TODOS_PROJECT_REGISTRATION_ADOPTION_REJECTED", "receipt authority, operation, request, precondition, or normalized-call lineage changed");
19890
+ }
19891
+ const binding = await transaction.getBinding(authorityScope(this.capabilityValue), sourceRequest.resource_kind, sourceRequest.target_selector);
19892
+ 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) {
19893
+ throw new TodosProjectRegistrationError("TODOS_PROJECT_REGISTRATION_ADOPTION_REJECTED", "accepted binding does not match the exact accepted receipt and source lineage");
19894
+ }
19895
+ const current = sourceRequest.resource_kind === "project" ? await transaction.getProject(accepted.target_id) : await transaction.getTaskList(accepted.target_id);
19896
+ if (!current || !canonicalValuesEqual(current, currentRecord) || current.id !== accepted.target_id || current.created_at !== accepted.result_revision) {
19897
+ throw new TodosProjectRegistrationError("TODOS_PROJECT_REGISTRATION_ADOPTION_REJECTED", "current record does not match the accepted target incarnation", { target_id: accepted.target_id });
19898
+ }
19899
+ let stableMatch = false;
19900
+ if (sourceRequest.resource_kind === "task_list") {
19901
+ stableMatch = taskListRegistrationDigest({
19902
+ ...current,
19903
+ updated_at: accepted.result_revision
19904
+ }) === accepted.result_digest;
19905
+ } else {
19906
+ const project = current;
19907
+ if (!Number.isSafeInteger(project.task_counter) || project.task_counter < 0) {
19908
+ throw new TodosProjectRegistrationError("TODOS_PROJECT_REGISTRATION_ADOPTION_REJECTED", "current project task counter is not a valid monotonic registration field");
19909
+ }
19910
+ for (let priorTaskCounter = 0;priorTaskCounter <= project.task_counter; priorTaskCounter += 1) {
19911
+ if (Date.now() - startedAt > sourceRequest.time_budget_ms) {
19912
+ throw new TodosProjectRegistrationError("TODOS_PROJECT_REGISTRATION_TIME_BUDGET_EXCEEDED", "prior registration adoption validation exceeded its time budget");
19913
+ }
19914
+ if (projectRegistrationDigest({
19915
+ ...project,
19916
+ task_counter: priorTaskCounter,
19917
+ updated_at: accepted.result_revision
19918
+ }) === accepted.result_digest) {
19919
+ stableMatch = true;
19920
+ break;
19921
+ }
19922
+ }
19923
+ }
19924
+ if (!stableMatch) {
19925
+ throw new TodosProjectRegistrationError("TODOS_PROJECT_REGISTRATION_ADOPTION_REJECTED", "stable project-registration fields changed after the accepted receipt", { target_id: accepted.target_id });
19926
+ }
19927
+ return {
19928
+ valid: true,
19929
+ resource_kind: sourceRequest.resource_kind,
19930
+ target_id: accepted.target_id,
19931
+ source_receipt_id: storedSource.receipt_id,
19932
+ accepted_receipt_id: accepted.receipt_id,
19933
+ source_outcome: storedSource.outcome,
19934
+ created_at: current.created_at,
19935
+ current_revision: current.updated_at,
19936
+ accepted_result_digest: accepted.result_digest
19937
+ };
19938
+ });
19939
+ assertWithinBounds(validation, sourceRequest, startedAt);
19940
+ return validation;
19941
+ }
19271
19942
  async storedAcceptedReceipt(request, supplied) {
19272
19943
  const stored = await this.backend.getReceiptById(supplied.receipt_id);
19273
19944
  if (!stored || stored.outcome !== "accepted" || !stored.created_by_operation || canonicalProjectRegistrationJson(publicReceipt(stored)) !== canonicalProjectRegistrationJson(supplied)) {
@@ -19439,6 +20110,65 @@ function createPostgresTodosProjectRegistrationAuthority(client, options = {}) {
19439
20110
  cursorTableName
19440
20111
  }), authorityOptions);
19441
20112
  }
20113
+ // src/project-registration/adoption-validation.ts
20114
+ var VALIDATION_KEYS = [
20115
+ "valid",
20116
+ "resource_kind",
20117
+ "target_id",
20118
+ "source_receipt_id",
20119
+ "accepted_receipt_id",
20120
+ "source_outcome",
20121
+ "created_at",
20122
+ "current_revision",
20123
+ "accepted_result_digest"
20124
+ ];
20125
+ function isRecord(value) {
20126
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
20127
+ }
20128
+ function isNonEmptyString(value) {
20129
+ return typeof value === "string" && value.length > 0;
20130
+ }
20131
+ function hasExactKeys(value, expected) {
20132
+ const actual = Object.keys(value).sort();
20133
+ const wanted = [...expected].sort();
20134
+ return actual.length === wanted.length && actual.every((key, index) => key === wanted[index]);
20135
+ }
20136
+ function adoptionRejected(message) {
20137
+ throw new TodosProjectRegistrationError("TODOS_PROJECT_REGISTRATION_ADOPTION_REJECTED", `TODOS_PROJECT_REGISTRATION_ADOPTION_REJECTED: ${message}`);
20138
+ }
20139
+ function assertTodosPriorRegistrationAdoptionValidationEnvelope(value, input) {
20140
+ if (!isRecord(input) || !hasExactKeys(input, [
20141
+ "source_request",
20142
+ "source_receipt",
20143
+ "current_record"
20144
+ ])) {
20145
+ adoptionRejected("prior-adoption validation input is incomplete");
20146
+ }
20147
+ const request = input["source_request"];
20148
+ const receipt = input["source_receipt"];
20149
+ const current = input["current_record"];
20150
+ if (!isRecord(request) || !isRecord(receipt) || !isRecord(current)) {
20151
+ adoptionRejected("prior-adoption validation input records are incomplete");
20152
+ }
20153
+ const resourceKind = request["resource_kind"];
20154
+ const sourceOutcome = receipt["outcome"];
20155
+ const acceptedReceiptId = sourceOutcome === "accepted" ? receipt["receipt_id"] : sourceOutcome === "duplicate_of_accepted" ? receipt["duplicate_of_receipt_id"] : null;
20156
+ 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"]) {
20157
+ adoptionRejected("prior-adoption validation input does not carry one complete accepted receipt and current target incarnation");
20158
+ }
20159
+ if (sourceOutcome === "accepted" && receipt["duplicate_of_receipt_id"] !== null || sourceOutcome === "duplicate_of_accepted" && receipt["duplicate_of_receipt_id"] !== acceptedReceiptId) {
20160
+ adoptionRejected("prior-adoption validation source receipt lineage is incomplete");
20161
+ }
20162
+ if (!isRecord(value) || !hasExactKeys(value, ["validation"]) || !isRecord(value["validation"]) || !hasExactKeys(value["validation"], VALIDATION_KEYS)) {
20163
+ adoptionRejected("prior-adoption validation response envelope is incomplete");
20164
+ }
20165
+ const validation = value["validation"];
20166
+ 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"]) {
20167
+ adoptionRejected("prior-adoption validation response does not prove the exact accepted receipt and current target");
20168
+ }
20169
+ return validation;
20170
+ }
20171
+
19442
20172
  // src/project-registration/http.ts
19443
20173
  var JSON_HEADERS = { "Content-Type": "application/json" };
19444
20174
  function json(body, status = 200) {
@@ -19485,6 +20215,20 @@ async function handleTodosProjectRegistrationHttpRequest(req, url, authority, ba
19485
20215
  if ((action === "" || action === "capability") && method === "GET") {
19486
20216
  return json({ capability: await authority.capability() });
19487
20217
  }
20218
+ if (action === "resources" && method === "GET") {
20219
+ const sourceProjectId = url.searchParams.get("source_project_id");
20220
+ const limit = Number(url.searchParams.get("limit") ?? "100");
20221
+ const includeAnchorsRaw = url.searchParams.get("include_anchors");
20222
+ const includeAnchors = includeAnchorsRaw === null ? false : includeAnchorsRaw === "true" ? true : includeAnchorsRaw === "false" ? false : includeAnchorsRaw;
20223
+ return json({
20224
+ page: await authority.listProjectResources({
20225
+ source_project_id: sourceProjectId,
20226
+ limit,
20227
+ include_anchors: includeAnchors,
20228
+ cursor: url.searchParams.get("cursor") ?? undefined
20229
+ })
20230
+ });
20231
+ }
19488
20232
  if (method !== "POST")
19489
20233
  return json({ error: "method not allowed" }, 405);
19490
20234
  const body = await readJson(req);
@@ -19507,6 +20251,12 @@ async function handleTodosProjectRegistrationHttpRequest(req, url, authority, ba
19507
20251
  record: await authority.readExact(body)
19508
20252
  });
19509
20253
  }
20254
+ if (action === "validate-prior-adoption") {
20255
+ const input = body;
20256
+ return json({
20257
+ validation: await authority.validatePriorRegistrationAdoption(input.source_request, input.source_receipt, input.current_record)
20258
+ });
20259
+ }
19510
20260
  if (action === "compensate") {
19511
20261
  return json({
19512
20262
  receipt: await authority.compensate(body)
@@ -19581,6 +20331,28 @@ class TodosProjectRegistrationHttpClient {
19581
20331
  async lookupReceipt(request) {
19582
20332
  return this.request("/receipts/lookup", { method: "POST", body: JSON.stringify(request) });
19583
20333
  }
20334
+ async listProjectResources(request) {
20335
+ const query = new URLSearchParams({
20336
+ source_project_id: request.source_project_id,
20337
+ limit: String(request.limit),
20338
+ include_anchors: String(request.include_anchors === true),
20339
+ ...request.cursor ? { cursor: request.cursor } : {}
20340
+ });
20341
+ const body = await this.request(`/resources?${query.toString()}`);
20342
+ return body.page;
20343
+ }
20344
+ async validatePriorRegistrationAdoption(sourceRequest, sourceReceipt, currentRecord) {
20345
+ const input = {
20346
+ source_request: withoutTarget(sourceRequest),
20347
+ source_receipt: sourceReceipt,
20348
+ current_record: currentRecord
20349
+ };
20350
+ const body = await this.request("/validate-prior-adoption", {
20351
+ method: "POST",
20352
+ body: JSON.stringify(input)
20353
+ });
20354
+ return assertTodosPriorRegistrationAdoptionValidationEnvelope(body, input);
20355
+ }
19584
20356
  async compensate(request) {
19585
20357
  const body = await this.request("/compensate", { method: "POST", body: JSON.stringify(withoutTarget(request)) });
19586
20358
  return body.receipt;
@@ -19606,6 +20378,7 @@ export {
19606
20378
  createPostgresTodosProjectRegistrationAuthority,
19607
20379
  createLocalTodosProjectRegistrationAuthority,
19608
20380
  canonicalProjectRegistrationJson,
20381
+ assertTodosPriorRegistrationAdoptionValidationEnvelope,
19609
20382
  TodosProjectRegistrationHttpClient,
19610
20383
  TodosProjectRegistrationError,
19611
20384
  TODOS_PROJECT_REGISTRATION_SCHEMA_VERSION,