@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
@@ -4873,7 +4873,9 @@ init_types();
4873
4873
 
4874
4874
  // src/task-manifest/types.ts
4875
4875
  var TODOS_TASK_MANIFEST_ROUTE = "todos.task-manifest.v1";
4876
+ var TODOS_TASK_MANIFEST_CALLER_ROUTE = "accounts.task-manifest.v1";
4876
4877
  var TODOS_TASK_MANIFEST_SCHEMA_VERSION = 1;
4878
+ var TODOS_TASK_MANIFEST_PLAN_SLUG_PROVENANCE = "deterministic-v1";
4877
4879
  function supportsIdempotentOutboxDelivery(capability) {
4878
4880
  return capability !== null && typeof capability === "object" && capability["idempotent_outbox_delivery"] === true;
4879
4881
  }
@@ -4903,6 +4905,8 @@ var TODOS_TASK_MANIFEST_BOUNDS = {
4903
4905
  };
4904
4906
  var key = exports_external.string().min(1).max(96).regex(/^[a-z][a-z0-9_-]*$/);
4905
4907
  var identifier = exports_external.string().min(1).max(200).regex(/^[A-Za-z0-9][A-Za-z0-9._:-]*$/);
4908
+ var digest = exports_external.string().length(64).regex(/^[0-9a-f]{64}$/);
4909
+ var idempotencyKey = exports_external.string().length(52).regex(/^tmk_[0-9a-f]{48}$/);
4906
4910
  var uuid = exports_external.string().uuid();
4907
4911
  var scalar = exports_external.union([exports_external.string().max(4096), exports_external.number().finite(), exports_external.boolean(), exports_external.null()]);
4908
4912
  var boundedScalarRecord = (limit, field) => exports_external.record(exports_external.string().max(200), scalar).superRefine((value, context) => {
@@ -4945,7 +4949,9 @@ var effect = exports_external.object({
4945
4949
  var schema = exports_external.object({
4946
4950
  version: exports_external.literal(1),
4947
4951
  operation_id: identifier,
4948
- idempotency_key: identifier,
4952
+ step_id: identifier,
4953
+ idempotency_key: idempotencyKey,
4954
+ precondition_digest: digest,
4949
4955
  project_id: uuid,
4950
4956
  task_list_id: uuid.optional(),
4951
4957
  if_binding_version: exports_external.number().int().min(0).optional(),
@@ -4961,7 +4967,10 @@ var schema = exports_external.object({
4961
4967
  }).strict();
4962
4968
  var compensationSchema = exports_external.object({
4963
4969
  receipt_id: uuid,
4964
- idempotency_key: identifier,
4970
+ operation_id: identifier,
4971
+ step_id: identifier,
4972
+ idempotency_key: idempotencyKey,
4973
+ precondition_digest: digest,
4965
4974
  if_binding_version: exports_external.number().int().min(1).max(Number.MAX_SAFE_INTEGER)
4966
4975
  }).strict();
4967
4976
  var bindingLookupSchema = exports_external.object({
@@ -5046,10 +5055,31 @@ function parseTodosTaskManifestBindingLookup(input) {
5046
5055
  }
5047
5056
 
5048
5057
  // src/task-manifest/plan-slug.ts
5058
+ var TASK_MANIFEST_DETERMINISTIC_SLUG_PROVENANCE = "deterministic-v1";
5049
5059
  function taskManifestPlanSlug(manifest, planId) {
5050
5060
  const base = normalizeSlug(manifest.plan.key) || normalizeSlug(manifest.plan.name) || "plan";
5051
5061
  return `${base}-${planId}`;
5052
5062
  }
5063
+ function sqliteLegacyTaskManifestPlanSlug(rows, planId, targetBase) {
5064
+ const target = rows.find((row) => row.id === planId);
5065
+ if (!target)
5066
+ return null;
5067
+ const used = new Set;
5068
+ 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));
5069
+ for (const row of ordered) {
5070
+ const base = row.id === planId ? normalizeSlug(targetBase ?? row.name) || "plan" : normalizeSlug(row.slug || row.name) || "plan";
5071
+ let candidate = base;
5072
+ let suffix = 2;
5073
+ while (used.has(candidate)) {
5074
+ candidate = `${base}-${suffix}`;
5075
+ suffix += 1;
5076
+ }
5077
+ if (row.id === planId)
5078
+ return candidate;
5079
+ used.add(candidate);
5080
+ }
5081
+ return null;
5082
+ }
5053
5083
 
5054
5084
  // src/task-manifest/backend.ts
5055
5085
  var UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
@@ -5063,11 +5093,13 @@ function validateTaskManifestBindingLookupRows(rows, tenantId, planId) {
5063
5093
  const row = rows[0];
5064
5094
  const bindingVersion = Number(row.binding_version);
5065
5095
  const state = row.state;
5066
- 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_PATTERN.test(row.apply_receipt_id) || !Number.isSafeInteger(bindingVersion) || bindingVersion < 1 || state !== "applied" && state !== "compensated") {
5096
+ 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_PATTERN.test(row.apply_receipt_id) || !Number.isSafeInteger(bindingVersion) || bindingVersion < 1 || state !== "applied" && state !== "compensated") {
5067
5097
  throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_LOOKUP_CONFLICT", "Task-manifest binding and immutable apply receipt disagree", { plan_id: planId });
5068
5098
  }
5069
5099
  return {
5070
5100
  plan_id: planId,
5101
+ operation_id: row.binding_operation_id,
5102
+ step_id: row.binding_step_id,
5071
5103
  apply_receipt_id: row.apply_receipt_id,
5072
5104
  binding_version: bindingVersion,
5073
5105
  state
@@ -5175,9 +5207,15 @@ function sqliteTodosTaskManifestSchemaSql() {
5175
5207
  schema_version INTEGER NOT NULL CHECK(schema_version = 1),
5176
5208
  kind TEXT NOT NULL CHECK(kind IN ('apply', 'compensate')),
5177
5209
  operation_id TEXT NOT NULL,
5210
+ step_id TEXT NOT NULL,
5178
5211
  idempotency_key TEXT NOT NULL,
5179
5212
  request_digest TEXT NOT NULL,
5213
+ precondition_digest TEXT NOT NULL,
5180
5214
  result_digest TEXT NOT NULL,
5215
+ slug_provenance TEXT,
5216
+ outcome TEXT NOT NULL CHECK(outcome IN ('accepted', 'terminal_nonacceptance')),
5217
+ reason TEXT,
5218
+ duplicate_of_receipt_id TEXT,
5181
5219
  binding_version INTEGER NOT NULL,
5182
5220
  apply_receipt_id TEXT,
5183
5221
  manifest_json TEXT,
@@ -5188,9 +5226,13 @@ function sqliteTodosTaskManifestSchemaSql() {
5188
5226
  CREATE TABLE IF NOT EXISTS todos_task_manifest_bindings (
5189
5227
  operation_id TEXT PRIMARY KEY,
5190
5228
  tenant_id TEXT NOT NULL,
5229
+ step_id TEXT NOT NULL,
5191
5230
  idempotency_key TEXT NOT NULL UNIQUE,
5192
5231
  request_digest TEXT NOT NULL,
5232
+ precondition_digest TEXT NOT NULL,
5193
5233
  result_digest TEXT NOT NULL,
5234
+ slug_provenance TEXT,
5235
+ outcome TEXT NOT NULL CHECK(outcome IN ('accepted', 'terminal_nonacceptance')),
5194
5236
  apply_receipt_id TEXT NOT NULL UNIQUE REFERENCES todos_task_manifest_receipts(receipt_id),
5195
5237
  manifest_json TEXT NOT NULL,
5196
5238
  result_json TEXT NOT NULL,
@@ -5211,6 +5253,27 @@ function sqliteTodosTaskManifestSchemaSql() {
5211
5253
  created_at TEXT NOT NULL,
5212
5254
  delivered_at TEXT
5213
5255
  );
5256
+ CREATE TABLE IF NOT EXISTS todos_task_manifest_terminal_receipts (
5257
+ receipt_id TEXT PRIMARY KEY,
5258
+ tenant_id TEXT NOT NULL,
5259
+ authority TEXT NOT NULL CHECK(authority = 'todos'),
5260
+ route TEXT NOT NULL,
5261
+ schema_version INTEGER NOT NULL CHECK(schema_version = 1),
5262
+ kind TEXT NOT NULL CHECK(kind IN ('apply', 'compensate')),
5263
+ operation_id TEXT NOT NULL,
5264
+ step_id TEXT NOT NULL,
5265
+ idempotency_key TEXT NOT NULL,
5266
+ request_digest TEXT NOT NULL,
5267
+ precondition_digest TEXT NOT NULL,
5268
+ result_digest TEXT NOT NULL,
5269
+ outcome TEXT NOT NULL CHECK(outcome = 'terminal_nonacceptance'),
5270
+ reason TEXT NOT NULL,
5271
+ binding_version INTEGER NOT NULL,
5272
+ apply_receipt_id TEXT,
5273
+ manifest_json TEXT,
5274
+ result_json TEXT NOT NULL,
5275
+ created_at TEXT NOT NULL
5276
+ );
5214
5277
  CREATE INDEX IF NOT EXISTS idx_todos_task_manifest_outbox_receipt
5215
5278
  ON todos_task_manifest_outbox(apply_receipt_id, status);
5216
5279
  CREATE TRIGGER IF NOT EXISTS todos_task_manifest_receipts_immutable_update
@@ -5221,6 +5284,14 @@ function sqliteTodosTaskManifestSchemaSql() {
5221
5284
  BEFORE DELETE ON todos_task_manifest_receipts BEGIN
5222
5285
  SELECT RAISE(ABORT, 'todos task manifest receipts are immutable');
5223
5286
  END;
5287
+ CREATE TRIGGER IF NOT EXISTS todos_task_manifest_terminal_receipts_immutable_update
5288
+ BEFORE UPDATE ON todos_task_manifest_terminal_receipts BEGIN
5289
+ SELECT RAISE(ABORT, 'todos task manifest terminal receipts are immutable');
5290
+ END;
5291
+ CREATE TRIGGER IF NOT EXISTS todos_task_manifest_terminal_receipts_immutable_delete
5292
+ BEFORE DELETE ON todos_task_manifest_terminal_receipts BEGIN
5293
+ SELECT RAISE(ABORT, 'todos task manifest terminal receipts are immutable');
5294
+ END;
5224
5295
  `;
5225
5296
  }
5226
5297
  function sqliteTableHasColumn(db, tableName, columnName) {
@@ -5237,6 +5308,24 @@ function ensureSqliteTodosTaskManifestSchema(db, tenantId) {
5237
5308
  if (!sqliteTableHasColumn(db, tableName, "tenant_id")) {
5238
5309
  db.exec(`ALTER TABLE "${tableName}" ADD COLUMN tenant_id TEXT NOT NULL DEFAULT ${tenantDefault}`);
5239
5310
  }
5311
+ if (!sqliteTableHasColumn(db, tableName, "slug_provenance")) {
5312
+ db.exec(`ALTER TABLE "${tableName}" ADD COLUMN slug_provenance TEXT`);
5313
+ }
5314
+ }
5315
+ const defaults = [
5316
+ ["todos_task_manifest_receipts", "step_id", "TEXT NOT NULL DEFAULT 'legacy-apply'"],
5317
+ ["todos_task_manifest_receipts", "precondition_digest", `TEXT NOT NULL DEFAULT '${"0".repeat(64)}'`],
5318
+ ["todos_task_manifest_receipts", "outcome", "TEXT NOT NULL DEFAULT 'accepted'"],
5319
+ ["todos_task_manifest_receipts", "reason", "TEXT"],
5320
+ ["todos_task_manifest_receipts", "duplicate_of_receipt_id", "TEXT"],
5321
+ ["todos_task_manifest_bindings", "step_id", "TEXT NOT NULL DEFAULT 'legacy-apply'"],
5322
+ ["todos_task_manifest_bindings", "precondition_digest", `TEXT NOT NULL DEFAULT '${"0".repeat(64)}'`],
5323
+ ["todos_task_manifest_bindings", "outcome", "TEXT NOT NULL DEFAULT 'accepted'"]
5324
+ ];
5325
+ for (const [tableName, columnName, definition] of defaults) {
5326
+ if (!sqliteTableHasColumn(db, tableName, columnName)) {
5327
+ db.exec(`ALTER TABLE "${tableName}" ADD COLUMN "${columnName}" ${definition}`);
5328
+ }
5240
5329
  }
5241
5330
  db.exec(`
5242
5331
  CREATE INDEX IF NOT EXISTS idx_todos_task_manifest_receipts_tenant
@@ -5246,6 +5335,12 @@ function ensureSqliteTodosTaskManifestSchema(db, tenantId) {
5246
5335
  tenant_id,
5247
5336
  json_extract(result_json, '$.graph.plan_id')
5248
5337
  );
5338
+ DROP INDEX IF EXISTS idx_todos_task_manifest_terminal_receipts_lookup;
5339
+ DROP INDEX IF EXISTS idx_todos_task_manifest_terminal_receipts_identity;
5340
+ CREATE INDEX IF NOT EXISTS idx_todos_task_manifest_terminal_receipts_lookup
5341
+ ON todos_task_manifest_terminal_receipts(tenant_id, kind, operation_id, step_id);
5342
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_todos_task_manifest_terminal_receipts_identity
5343
+ ON todos_task_manifest_terminal_receipts(tenant_id, kind, operation_id, step_id);
5249
5344
  `);
5250
5345
  }
5251
5346
  function postgresTodosTaskManifestSchemaSql(tenantId = "default") {
@@ -5259,9 +5354,15 @@ function postgresTodosTaskManifestSchemaSql(tenantId = "default") {
5259
5354
  schema_version integer NOT NULL CHECK(schema_version = 1),
5260
5355
  kind text NOT NULL CHECK(kind IN ('apply', 'compensate')),
5261
5356
  operation_id text NOT NULL,
5357
+ step_id text NOT NULL,
5262
5358
  idempotency_key text NOT NULL,
5263
5359
  request_digest text NOT NULL,
5360
+ precondition_digest text NOT NULL,
5264
5361
  result_digest text NOT NULL,
5362
+ slug_provenance text,
5363
+ outcome text NOT NULL CHECK(outcome IN ('accepted', 'terminal_nonacceptance')),
5364
+ reason text,
5365
+ duplicate_of_receipt_id text,
5265
5366
  binding_version integer NOT NULL,
5266
5367
  apply_receipt_id text,
5267
5368
  manifest_json jsonb,
@@ -5273,12 +5374,28 @@ function postgresTodosTaskManifestSchemaSql(tenantId = "default") {
5273
5374
  ADD COLUMN IF NOT EXISTS tenant_id text NOT NULL DEFAULT ${tenantDefault}`,
5274
5375
  `ALTER TABLE todos_task_manifest_receipts
5275
5376
  ALTER COLUMN tenant_id DROP DEFAULT`,
5377
+ `ALTER TABLE todos_task_manifest_receipts
5378
+ ADD COLUMN IF NOT EXISTS slug_provenance text`,
5379
+ `ALTER TABLE todos_task_manifest_receipts
5380
+ ADD COLUMN IF NOT EXISTS step_id text NOT NULL DEFAULT 'legacy-apply'`,
5381
+ `ALTER TABLE todos_task_manifest_receipts
5382
+ ADD COLUMN IF NOT EXISTS precondition_digest text NOT NULL DEFAULT '${"0".repeat(64)}'`,
5383
+ `ALTER TABLE todos_task_manifest_receipts
5384
+ ADD COLUMN IF NOT EXISTS outcome text NOT NULL DEFAULT 'accepted'`,
5385
+ `ALTER TABLE todos_task_manifest_receipts
5386
+ ADD COLUMN IF NOT EXISTS reason text`,
5387
+ `ALTER TABLE todos_task_manifest_receipts
5388
+ ADD COLUMN IF NOT EXISTS duplicate_of_receipt_id text`,
5276
5389
  `CREATE TABLE IF NOT EXISTS todos_task_manifest_bindings (
5277
5390
  operation_id text PRIMARY KEY,
5278
5391
  tenant_id text NOT NULL,
5392
+ step_id text NOT NULL,
5279
5393
  idempotency_key text NOT NULL UNIQUE,
5280
5394
  request_digest text NOT NULL,
5395
+ precondition_digest text NOT NULL,
5281
5396
  result_digest text NOT NULL,
5397
+ slug_provenance text,
5398
+ outcome text NOT NULL CHECK(outcome IN ('accepted', 'terminal_nonacceptance')),
5282
5399
  apply_receipt_id text NOT NULL UNIQUE REFERENCES todos_task_manifest_receipts(receipt_id),
5283
5400
  manifest_json jsonb NOT NULL,
5284
5401
  result_json jsonb NOT NULL,
@@ -5292,6 +5409,14 @@ function postgresTodosTaskManifestSchemaSql(tenantId = "default") {
5292
5409
  ADD COLUMN IF NOT EXISTS tenant_id text NOT NULL DEFAULT ${tenantDefault}`,
5293
5410
  `ALTER TABLE todos_task_manifest_bindings
5294
5411
  ALTER COLUMN tenant_id DROP DEFAULT`,
5412
+ `ALTER TABLE todos_task_manifest_bindings
5413
+ ADD COLUMN IF NOT EXISTS slug_provenance text`,
5414
+ `ALTER TABLE todos_task_manifest_bindings
5415
+ ADD COLUMN IF NOT EXISTS step_id text NOT NULL DEFAULT 'legacy-apply'`,
5416
+ `ALTER TABLE todos_task_manifest_bindings
5417
+ ADD COLUMN IF NOT EXISTS precondition_digest text NOT NULL DEFAULT '${"0".repeat(64)}'`,
5418
+ `ALTER TABLE todos_task_manifest_bindings
5419
+ ADD COLUMN IF NOT EXISTS outcome text NOT NULL DEFAULT 'accepted'`,
5295
5420
  `CREATE TABLE IF NOT EXISTS todos_task_manifest_outbox (
5296
5421
  id text PRIMARY KEY,
5297
5422
  apply_receipt_id text NOT NULL REFERENCES todos_task_manifest_receipts(receipt_id),
@@ -5303,10 +5428,37 @@ function postgresTodosTaskManifestSchemaSql(tenantId = "default") {
5303
5428
  created_at timestamptz NOT NULL,
5304
5429
  delivered_at timestamptz
5305
5430
  )`,
5431
+ `CREATE TABLE IF NOT EXISTS todos_task_manifest_terminal_receipts (
5432
+ receipt_id text PRIMARY KEY,
5433
+ tenant_id text NOT NULL,
5434
+ authority text NOT NULL CHECK(authority = 'todos'),
5435
+ route text NOT NULL,
5436
+ schema_version integer NOT NULL CHECK(schema_version = 1),
5437
+ kind text NOT NULL CHECK(kind IN ('apply', 'compensate')),
5438
+ operation_id text NOT NULL,
5439
+ step_id text NOT NULL,
5440
+ idempotency_key text NOT NULL,
5441
+ request_digest text NOT NULL,
5442
+ precondition_digest text NOT NULL,
5443
+ result_digest text NOT NULL,
5444
+ outcome text NOT NULL CHECK(outcome = 'terminal_nonacceptance'),
5445
+ reason text NOT NULL,
5446
+ binding_version integer NOT NULL,
5447
+ apply_receipt_id text,
5448
+ manifest_json jsonb,
5449
+ result_json jsonb NOT NULL,
5450
+ created_at timestamptz NOT NULL
5451
+ )`,
5306
5452
  `CREATE INDEX IF NOT EXISTS todos_task_manifest_outbox_receipt_idx
5307
5453
  ON todos_task_manifest_outbox(apply_receipt_id, status)`,
5308
5454
  `CREATE INDEX IF NOT EXISTS todos_task_manifest_receipts_tenant_idx
5309
5455
  ON todos_task_manifest_receipts(tenant_id, receipt_id, kind)`,
5456
+ `DROP INDEX IF EXISTS todos_task_manifest_terminal_receipts_lookup_idx`,
5457
+ `DROP INDEX IF EXISTS todos_task_manifest_terminal_receipts_identity_idx`,
5458
+ `CREATE INDEX IF NOT EXISTS todos_task_manifest_terminal_receipts_lookup_idx
5459
+ ON todos_task_manifest_terminal_receipts(tenant_id, kind, operation_id, step_id)`,
5460
+ `CREATE UNIQUE INDEX IF NOT EXISTS todos_task_manifest_terminal_receipts_identity_idx
5461
+ ON todos_task_manifest_terminal_receipts(tenant_id, kind, operation_id, step_id)`,
5310
5462
  `CREATE INDEX IF NOT EXISTS todos_task_manifest_bindings_tenant_plan_idx
5311
5463
  ON todos_task_manifest_bindings(
5312
5464
  tenant_id,
@@ -5319,6 +5471,10 @@ function postgresTodosTaskManifestSchemaSql(tenantId = "default") {
5319
5471
  `DROP TRIGGER IF EXISTS todos_task_manifest_receipts_immutable ON todos_task_manifest_receipts`,
5320
5472
  `CREATE TRIGGER todos_task_manifest_receipts_immutable
5321
5473
  BEFORE UPDATE OR DELETE ON todos_task_manifest_receipts
5474
+ FOR EACH ROW EXECUTE FUNCTION todos_task_manifest_receipts_immutable()`,
5475
+ `DROP TRIGGER IF EXISTS todos_task_manifest_terminal_receipts_immutable ON todos_task_manifest_terminal_receipts`,
5476
+ `CREATE TRIGGER todos_task_manifest_terminal_receipts_immutable
5477
+ BEFORE UPDATE OR DELETE ON todos_task_manifest_terminal_receipts
5322
5478
  FOR EACH ROW EXECUTE FUNCTION todos_task_manifest_receipts_immutable()`
5323
5479
  ];
5324
5480
  }
@@ -5330,7 +5486,76 @@ function fault(faults, point) {
5330
5486
  throw new Error(`Injected task-manifest fault at ${point}`);
5331
5487
  }
5332
5488
  function parseApplyResult(value, duplicate) {
5333
- return { ...JSON.parse(value), duplicate };
5489
+ const parsed = JSON.parse(value);
5490
+ return {
5491
+ ...parsed,
5492
+ duplicate,
5493
+ receipt: {
5494
+ ...parsed.receipt,
5495
+ step_id: parsed.receipt.step_id ?? "legacy-apply",
5496
+ precondition_digest: parsed.receipt.precondition_digest ?? "0".repeat(64),
5497
+ outcome: parsed.receipt.outcome ?? "accepted",
5498
+ reason: parsed.receipt.reason ?? null,
5499
+ duplicate_of_receipt_id: parsed.receipt.duplicate_of_receipt_id ?? null
5500
+ }
5501
+ };
5502
+ }
5503
+ function terminalApplyResult(input, reason) {
5504
+ const receipt = {
5505
+ receipt_id: input.terminal_receipt_id,
5506
+ authority: "todos",
5507
+ route: "todos.task-manifest.v1",
5508
+ schema_version: 1,
5509
+ kind: "apply",
5510
+ operation_id: input.manifest.operation_id,
5511
+ step_id: input.manifest.step_id,
5512
+ idempotency_key: input.manifest.idempotency_key,
5513
+ request_digest: input.request_digest,
5514
+ precondition_digest: input.manifest.precondition_digest,
5515
+ result_digest: canonicalDigest({
5516
+ outcome: "terminal_nonacceptance",
5517
+ reason,
5518
+ operation_id: input.manifest.operation_id,
5519
+ step_id: input.manifest.step_id,
5520
+ request_digest: input.request_digest
5521
+ }),
5522
+ outcome: "terminal_nonacceptance",
5523
+ reason,
5524
+ duplicate_of_receipt_id: null,
5525
+ binding_version: 0,
5526
+ apply_receipt_id: null,
5527
+ created_at: input.now
5528
+ };
5529
+ return {
5530
+ duplicate: false,
5531
+ receipt,
5532
+ graph: input.graph,
5533
+ readback: { plans: 0, tasks: 0, dependencies: 0, comments: 0, verifications: 0, complete: true },
5534
+ outbox_ids: [],
5535
+ result_digest: receipt.result_digest
5536
+ };
5537
+ }
5538
+ function validateCompensationPlanSlug(db, manifest, planId, slugProvenance) {
5539
+ const plan = db.query("SELECT id, project_id, name, slug, created_at FROM plans WHERE id = ? LIMIT 1").get(planId);
5540
+ if (!plan) {
5541
+ throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_COMPENSATION_REFUSED", "Compensation refused: plan changed since apply");
5542
+ }
5543
+ if (slugProvenance === TASK_MANIFEST_DETERMINISTIC_SLUG_PROVENANCE) {
5544
+ if (plan.slug !== taskManifestPlanSlug(manifest, planId)) {
5545
+ throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_COMPENSATION_REFUSED", "Compensation refused: plan changed since apply");
5546
+ }
5547
+ return;
5548
+ }
5549
+ if (slugProvenance !== null && slugProvenance !== undefined) {
5550
+ throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_COMPENSATION_REFUSED", "Compensation refused: unknown plan slug provenance");
5551
+ }
5552
+ if (plan.slug === null)
5553
+ return;
5554
+ 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);
5555
+ const expected = sqliteLegacyTaskManifestPlanSlug(rows, planId, manifest.plan.key || manifest.plan.name);
5556
+ if (expected === null || plan.slug !== expected) {
5557
+ throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_COMPENSATION_REFUSED", "Compensation refused: legacy plan slug was not produced by SQLite allocation");
5558
+ }
5334
5559
  }
5335
5560
 
5336
5561
  class SqliteTodosTaskManifestBackend {
@@ -5370,33 +5595,49 @@ class SqliteTodosTaskManifestBackend {
5370
5595
  async apply(input, faults) {
5371
5596
  return this.serialized(() => {
5372
5597
  const { manifest } = input;
5598
+ const terminal = this.db.query(`SELECT result_json
5599
+ FROM todos_task_manifest_terminal_receipts
5600
+ WHERE tenant_id = ?
5601
+ AND kind = 'apply'
5602
+ AND (receipt_id = ? OR (operation_id = ? AND step_id = ?))
5603
+ ORDER BY created_at ASC, receipt_id ASC
5604
+ LIMIT 1`).get(this.tenantId, input.terminal_receipt_id, manifest.operation_id, manifest.step_id);
5605
+ if (terminal)
5606
+ return parseApplyResult(terminal.result_json, true);
5373
5607
  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);
5374
5608
  if (binding) {
5375
- if (binding["idempotency_key"] !== manifest.idempotency_key || binding["request_digest"] !== input.request_digest) {
5376
- throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_IDEMPOTENCY_CONFLICT", "Operation is already bound to a different request");
5609
+ 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) {
5610
+ return this.persistTerminal(input, "TODOS_TASK_MANIFEST_IDEMPOTENCY_CONFLICT");
5611
+ }
5612
+ if (binding["outcome"] === "terminal_nonacceptance") {
5613
+ const terminalResult = this.persistTerminal(input, "TODOS_TASK_MANIFEST_GRAPH_CONFLICT");
5614
+ return { ...terminalResult, duplicate: true };
5377
5615
  }
5378
5616
  if (binding["state"] !== "applied") {
5379
- throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_GRAPH_CONFLICT", "Operation was already compensated");
5617
+ return this.persistTerminal(input, "TODOS_TASK_MANIFEST_GRAPH_CONFLICT");
5380
5618
  }
5381
5619
  return parseApplyResult(String(binding["result_json"]), true);
5382
5620
  }
5383
5621
  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);
5384
5622
  if (idempotency)
5385
- throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_IDEMPOTENCY_CONFLICT", "Idempotency key is already used by another operation");
5623
+ return this.persistTerminal(input, "TODOS_TASK_MANIFEST_IDEMPOTENCY_CONFLICT");
5624
+ if (manifest.idempotency_key !== input.expected_idempotency_key) {
5625
+ return this.persistTerminal(input, "TODOS_TASK_MANIFEST_IDEMPOTENCY_MISMATCH");
5626
+ }
5386
5627
  if (manifest.if_binding_version !== undefined && manifest.if_binding_version !== 0) {
5387
- throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_CAS_CONFLICT", "New manifest binding version must be 0");
5628
+ return this.persistTerminal(input, "TODOS_TASK_MANIFEST_CAS_CONFLICT");
5388
5629
  }
5389
5630
  if (!this.db.query("SELECT 1 AS found FROM projects WHERE id = ? LIMIT 1").get(manifest.project_id)) {
5390
- throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_FOREIGN_REFERENCE", "Project does not exist");
5631
+ return this.persistTerminal(input, "TODOS_TASK_MANIFEST_FOREIGN_REFERENCE");
5391
5632
  }
5392
5633
  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)) {
5393
- throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_FOREIGN_REFERENCE", "Task list does not belong to the project");
5634
+ return this.persistTerminal(input, "TODOS_TASK_MANIFEST_FOREIGN_REFERENCE");
5394
5635
  }
5395
5636
  const allIds = [input.graph.plan_id, ...Object.values(input.graph.task_ids), ...input.graph.comment_ids, ...input.graph.verification_ids];
5396
5637
  for (const id of allIds) {
5397
5638
  for (const table of ["plans", "tasks", "task_comments", "task_verifications"]) {
5398
5639
  if (this.db.query(`SELECT 1 AS found FROM ${table} WHERE id = ? LIMIT 1`).get(id)) {
5399
- throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_GRAPH_CONFLICT", `Deterministic id already exists: ${id}`);
5640
+ return this.persistTerminal(input, "TODOS_TASK_MANIFEST_GRAPH_CONFLICT");
5400
5641
  }
5401
5642
  }
5402
5643
  }
@@ -5452,9 +5693,14 @@ class SqliteTodosTaskManifestBackend {
5452
5693
  schema_version: 1,
5453
5694
  kind: "apply",
5454
5695
  operation_id: manifest.operation_id,
5696
+ step_id: manifest.step_id,
5455
5697
  idempotency_key: manifest.idempotency_key,
5456
5698
  request_digest: input.request_digest,
5699
+ precondition_digest: manifest.precondition_digest,
5457
5700
  result_digest: input.result_digest,
5701
+ outcome: "accepted",
5702
+ reason: null,
5703
+ duplicate_of_receipt_id: null,
5458
5704
  binding_version: 1,
5459
5705
  apply_receipt_id: null,
5460
5706
  created_at: input.now
@@ -5470,9 +5716,10 @@ class SqliteTodosTaskManifestBackend {
5470
5716
  const resultJson = canonicalJson(result);
5471
5717
  const manifestJson = canonicalJson(manifest);
5472
5718
  this.db.query(`INSERT INTO todos_task_manifest_receipts (
5473
- receipt_id, tenant_id, authority, route, schema_version, kind, operation_id, idempotency_key,
5474
- request_digest, result_digest, binding_version, apply_receipt_id, manifest_json, result_json, created_at
5475
- ) 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);
5719
+ receipt_id, tenant_id, authority, route, schema_version, kind, operation_id, step_id, idempotency_key,
5720
+ request_digest, precondition_digest, result_digest, slug_provenance, outcome, reason,
5721
+ duplicate_of_receipt_id, binding_version, apply_receipt_id, manifest_json, result_json, created_at
5722
+ ) 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);
5476
5723
  for (const entry of input.outbox) {
5477
5724
  this.db.query(`INSERT INTO todos_task_manifest_outbox (
5478
5725
  id, apply_receipt_id, topic, payload, payload_digest, status, created_at
@@ -5480,18 +5727,37 @@ class SqliteTodosTaskManifestBackend {
5480
5727
  }
5481
5728
  fault(faults, "after_outbox_write");
5482
5729
  this.db.query(`INSERT INTO todos_task_manifest_bindings (
5483
- operation_id, tenant_id, idempotency_key, request_digest, result_digest, apply_receipt_id,
5484
- manifest_json, result_json, state, version, created_at, updated_at
5485
- ) 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);
5730
+ operation_id, tenant_id, step_id, idempotency_key, request_digest, precondition_digest, result_digest,
5731
+ slug_provenance, outcome, apply_receipt_id, manifest_json, result_json, state, version, created_at, updated_at
5732
+ ) 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);
5486
5733
  fault(faults, "after_receipt_write");
5487
5734
  return result;
5488
5735
  });
5489
5736
  }
5737
+ persistTerminal(input, reason) {
5738
+ const result = terminalApplyResult(input, reason);
5739
+ const resultJson = canonicalJson(result);
5740
+ this.db.query(`INSERT OR IGNORE INTO todos_task_manifest_terminal_receipts (
5741
+ receipt_id, tenant_id, authority, route, schema_version, kind, operation_id, step_id,
5742
+ idempotency_key, request_digest, precondition_digest, result_digest, outcome, reason,
5743
+ binding_version, apply_receipt_id, manifest_json, result_json, created_at
5744
+ ) 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);
5745
+ const stored = this.db.query(`SELECT receipt_id, result_json
5746
+ FROM todos_task_manifest_terminal_receipts
5747
+ WHERE tenant_id = ? AND kind = 'apply'
5748
+ AND (receipt_id = ? OR (operation_id = ? AND step_id = ?))
5749
+ ORDER BY created_at ASC, receipt_id ASC
5750
+ LIMIT 1`).get(this.tenantId, result.receipt.receipt_id, input.manifest.operation_id, input.manifest.step_id);
5751
+ return stored ? parseApplyResult(stored.result_json, stored.receipt_id !== result.receipt.receipt_id) : result;
5752
+ }
5490
5753
  async readExact(receiptId) {
5491
5754
  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, receiptId);
5492
- if (!row)
5755
+ if (row)
5756
+ return parseApplyResult(row.result_json, false);
5757
+ 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, receiptId);
5758
+ if (!terminal)
5493
5759
  throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_RECEIPT_NOT_FOUND", `Apply receipt not found: ${receiptId}`);
5494
- return parseApplyResult(row.result_json, false);
5760
+ return parseApplyResult(terminal.result_json, false);
5495
5761
  }
5496
5762
  async lookupBindingByPlanId(planId) {
5497
5763
  const rows = this.db.query(`
@@ -5501,6 +5767,7 @@ class SqliteTodosTaskManifestBackend {
5501
5767
  b.version AS binding_version,
5502
5768
  b.tenant_id AS binding_tenant_id,
5503
5769
  b.operation_id AS binding_operation_id,
5770
+ b.step_id AS binding_step_id,
5504
5771
  json_extract(b.result_json, '$.graph.plan_id') AS binding_plan_id,
5505
5772
  r.tenant_id AS receipt_tenant_id,
5506
5773
  r.authority AS receipt_authority,
@@ -5508,6 +5775,7 @@ class SqliteTodosTaskManifestBackend {
5508
5775
  r.schema_version AS receipt_schema_version,
5509
5776
  r.kind AS receipt_kind,
5510
5777
  r.operation_id AS receipt_operation_id,
5778
+ r.step_id AS receipt_step_id,
5511
5779
  json_extract(r.result_json, '$.graph.plan_id') AS receipt_plan_id
5512
5780
  FROM todos_task_manifest_bindings b
5513
5781
  LEFT JOIN todos_task_manifest_receipts r
@@ -5570,6 +5838,12 @@ class SqliteTodosTaskManifestBackend {
5570
5838
  if (!binding || Number(binding["version"]) !== input.if_binding_version) {
5571
5839
  throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_CAS_CONFLICT", "Binding version changed before compensation");
5572
5840
  }
5841
+ const storedStepId = String(row["step_id"] ?? "legacy-apply");
5842
+ const storedRequestDigest = String(row["request_digest"]);
5843
+ const storedPreconditionDigest = String(row["precondition_digest"] ?? "0".repeat(64));
5844
+ 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"]) {
5845
+ throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_COMPENSATION_REFUSED", "Compensation refused: receipt and binding identity disagree");
5846
+ }
5573
5847
  if (binding["state"] !== "applied")
5574
5848
  throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_COMPENSATION_REFUSED", "Graph is not in applied state");
5575
5849
  const delivered = this.db.query(`SELECT o.id FROM todos_task_manifest_outbox o
@@ -5580,10 +5854,16 @@ class SqliteTodosTaskManifestBackend {
5580
5854
  throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_COMPENSATION_REFUSED", "Compensation refused: delivered outbox row exists");
5581
5855
  const applyResult = parseApplyResult(String(row["result_json"]), false);
5582
5856
  const manifest = JSON.parse(String(row["manifest_json"]));
5857
+ const manifestRecord = manifest;
5858
+ const applyStepId = typeof manifestRecord["step_id"] === "string" ? String(manifestRecord["step_id"]) : null;
5583
5859
  const expectedEffects = [
5584
5860
  {
5585
5861
  topic: "todos.task-manifest.applied",
5586
- payload: { operation_id: manifest.operation_id, project_id: manifest.project_id }
5862
+ payload: {
5863
+ operation_id: manifest.operation_id,
5864
+ ...applyStepId ? { step_id: applyStepId } : {},
5865
+ project_id: manifest.project_id
5866
+ }
5587
5867
  },
5588
5868
  ...(manifest.effects ?? []).map((effect2) => ({ topic: effect2.topic, payload: effect2.payload }))
5589
5869
  ];
@@ -5623,8 +5903,10 @@ class SqliteTodosTaskManifestBackend {
5623
5903
  if (canonicalJson(actualReadback) !== canonicalJson(applyResult.readback)) {
5624
5904
  throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_COMPENSATION_REFUSED", "Compensation refused: graph changed since apply", { actualReadback });
5625
5905
  }
5626
- 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);
5627
- 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)) {
5906
+ const slugProvenance = row["slug_provenance"];
5907
+ validateCompensationPlanSlug(this.db, manifest, applyResult.graph.plan_id, slugProvenance);
5908
+ 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);
5909
+ 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)) {
5628
5910
  throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_COMPENSATION_REFUSED", "Compensation refused: plan changed since apply");
5629
5911
  }
5630
5912
  for (const task2 of manifest.tasks) {
@@ -5701,9 +5983,10 @@ class SqliteTodosTaskManifestBackend {
5701
5983
  const result = { duplicate: false, receipt, absent: true, readback };
5702
5984
  const resultJson = canonicalJson(result);
5703
5985
  this.db.query(`INSERT INTO todos_task_manifest_receipts (
5704
- receipt_id, tenant_id, authority, route, schema_version, kind, operation_id, idempotency_key,
5705
- request_digest, result_digest, binding_version, apply_receipt_id, manifest_json, result_json, created_at
5706
- ) 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, now);
5986
+ receipt_id, tenant_id, authority, route, schema_version, kind, operation_id, step_id, idempotency_key,
5987
+ request_digest, precondition_digest, result_digest, slug_provenance, outcome, reason,
5988
+ duplicate_of_receipt_id, binding_version, apply_receipt_id, manifest_json, result_json, created_at
5989
+ ) 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, now);
5707
5990
  const updated = this.db.query(`UPDATE todos_task_manifest_bindings SET state = 'compensated', version = ?, compensation_receipt_id = ?, updated_at = ?
5708
5991
  WHERE tenant_id = ? AND operation_id = ? AND state = 'applied' AND version = ?`).run(receipt.binding_version, compensationReceiptId, now, this.tenantId, receipt.operation_id, input.if_binding_version);
5709
5992
  if (updated.changes !== 1) {
@@ -6141,6 +6424,39 @@ function safeIdentifier(value, field) {
6141
6424
  function parseJson(value) {
6142
6425
  return typeof value === "string" ? JSON.parse(value) : value;
6143
6426
  }
6427
+ function parseApplyResult2(value, duplicate) {
6428
+ const parsed = parseJson(value);
6429
+ return {
6430
+ ...parsed,
6431
+ duplicate,
6432
+ receipt: {
6433
+ ...parsed.receipt,
6434
+ step_id: parsed.receipt.step_id ?? "legacy-apply",
6435
+ precondition_digest: parsed.receipt.precondition_digest ?? "0".repeat(64),
6436
+ outcome: parsed.receipt.outcome ?? "accepted",
6437
+ reason: parsed.receipt.reason ?? null,
6438
+ duplicate_of_receipt_id: parsed.receipt.duplicate_of_receipt_id ?? null
6439
+ }
6440
+ };
6441
+ }
6442
+ function validatePostgresPlanSlug(manifest, planId, slug, provenance) {
6443
+ if (provenance === TASK_MANIFEST_DETERMINISTIC_SLUG_PROVENANCE) {
6444
+ const expected = taskManifestPlanSlug(manifest, planId);
6445
+ if (slug !== expected) {
6446
+ throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_COMPENSATION_REFUSED", "Compensation refused: plan slug changed since apply");
6447
+ }
6448
+ return expected;
6449
+ }
6450
+ if (provenance !== null && provenance !== undefined) {
6451
+ throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_COMPENSATION_REFUSED", "Compensation refused: unknown plan slug provenance");
6452
+ }
6453
+ if (slug === null || slug === undefined)
6454
+ return null;
6455
+ if (slug !== null && slug !== undefined) {
6456
+ throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_COMPENSATION_REFUSED", "Compensation refused: legacy PostgreSQL plan slug must be NULL");
6457
+ }
6458
+ return null;
6459
+ }
6144
6460
  function timestamp(value) {
6145
6461
  return value instanceof Date ? value.toISOString() : new Date(String(value)).toISOString();
6146
6462
  }
@@ -6148,6 +6464,41 @@ function fault2(faults, point) {
6148
6464
  if (faults.points.has(point))
6149
6465
  throw new Error(`Injected task-manifest fault at ${point}`);
6150
6466
  }
6467
+ function terminalApplyResult2(input, reason) {
6468
+ const receipt = {
6469
+ receipt_id: input.terminal_receipt_id,
6470
+ authority: "todos",
6471
+ route: "todos.task-manifest.v1",
6472
+ schema_version: 1,
6473
+ kind: "apply",
6474
+ operation_id: input.manifest.operation_id,
6475
+ step_id: input.manifest.step_id,
6476
+ idempotency_key: input.manifest.idempotency_key,
6477
+ request_digest: input.request_digest,
6478
+ precondition_digest: input.manifest.precondition_digest,
6479
+ result_digest: canonicalDigest({
6480
+ outcome: "terminal_nonacceptance",
6481
+ reason,
6482
+ operation_id: input.manifest.operation_id,
6483
+ step_id: input.manifest.step_id,
6484
+ request_digest: input.request_digest
6485
+ }),
6486
+ outcome: "terminal_nonacceptance",
6487
+ reason,
6488
+ duplicate_of_receipt_id: null,
6489
+ binding_version: 0,
6490
+ apply_receipt_id: null,
6491
+ created_at: input.now
6492
+ };
6493
+ return {
6494
+ duplicate: false,
6495
+ receipt,
6496
+ graph: input.graph,
6497
+ readback: { plans: 0, tasks: 0, dependencies: 0, comments: 0, verifications: 0, complete: true },
6498
+ outbox_ids: [],
6499
+ result_digest: receipt.result_digest
6500
+ };
6501
+ }
6151
6502
  function receiptFromRow(row) {
6152
6503
  return {
6153
6504
  receipt_id: String(row["receipt_id"]),
@@ -6156,9 +6507,14 @@ function receiptFromRow(row) {
6156
6507
  schema_version: 1,
6157
6508
  kind: row["kind"],
6158
6509
  operation_id: String(row["operation_id"]),
6510
+ step_id: String(row["step_id"] ?? "legacy-apply"),
6159
6511
  idempotency_key: String(row["idempotency_key"]),
6160
6512
  request_digest: String(row["request_digest"]),
6513
+ precondition_digest: String(row["precondition_digest"] ?? "0".repeat(64)),
6161
6514
  result_digest: String(row["result_digest"]),
6515
+ outcome: row["outcome"] ?? "accepted",
6516
+ reason: row["reason"] == null ? null : row["reason"],
6517
+ duplicate_of_receipt_id: row["duplicate_of_receipt_id"] == null ? null : String(row["duplicate_of_receipt_id"]),
6162
6518
  binding_version: Number(row["binding_version"]),
6163
6519
  apply_receipt_id: row["apply_receipt_id"] == null ? null : String(row["apply_receipt_id"]),
6164
6520
  created_at: timestamp(row["created_at"])
@@ -6276,46 +6632,89 @@ class PostgresTodosTaskManifestBackend {
6276
6632
  now
6277
6633
  ]);
6278
6634
  }
6635
+ async persistTerminal(tx, input, reason) {
6636
+ const result = terminalApplyResult2(input, reason);
6637
+ const resultJson = canonicalJson(result);
6638
+ await tx.query(`INSERT INTO todos_task_manifest_terminal_receipts (
6639
+ receipt_id, tenant_id, authority, route, schema_version, kind, operation_id, step_id,
6640
+ idempotency_key, request_digest, precondition_digest, result_digest, outcome, reason,
6641
+ binding_version, apply_receipt_id, manifest_json, result_json, created_at
6642
+ ) VALUES ($1, $2, 'todos', 'todos.task-manifest.v1', 1, 'apply', $3, $4, $5, $6, $7, $8,
6643
+ 'terminal_nonacceptance', $9, 0, NULL, $10::jsonb, $11::jsonb, $12)
6644
+ ON CONFLICT (tenant_id, kind, operation_id, step_id) DO NOTHING`, [
6645
+ result.receipt.receipt_id,
6646
+ this.tenantId,
6647
+ input.manifest.operation_id,
6648
+ input.manifest.step_id,
6649
+ input.manifest.idempotency_key,
6650
+ input.request_digest,
6651
+ input.manifest.precondition_digest,
6652
+ result.receipt.result_digest,
6653
+ reason,
6654
+ canonicalJson(input.manifest),
6655
+ resultJson,
6656
+ input.now
6657
+ ]);
6658
+ const stored = await tx.query(`SELECT receipt_id, result_json
6659
+ FROM todos_task_manifest_terminal_receipts
6660
+ WHERE tenant_id = $1 AND kind = 'apply'
6661
+ AND (receipt_id = $2 OR (operation_id = $3 AND step_id = $4))
6662
+ ORDER BY created_at ASC, receipt_id ASC
6663
+ LIMIT 1`, [this.tenantId, result.receipt.receipt_id, input.manifest.operation_id, input.manifest.step_id]);
6664
+ return stored.rows[0] ? parseApplyResult2(stored.rows[0]["result_json"], stored.rows[0]["receipt_id"] !== result.receipt.receipt_id) : result;
6665
+ }
6279
6666
  async apply(input, faults) {
6280
6667
  await this.ensureSchema();
6281
6668
  return this.client.transaction(async (tx) => {
6282
6669
  const { manifest } = input;
6283
6670
  await tx.query("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))", [`${this.service}\x1F${manifest.operation_id}`]);
6284
6671
  await tx.query("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))", [`${this.service}\x1Fidempotency\x1F${manifest.idempotency_key}`]);
6672
+ const terminal = await tx.query(`SELECT result_json FROM todos_task_manifest_terminal_receipts
6673
+ WHERE tenant_id = $1
6674
+ AND kind = 'apply'
6675
+ AND (receipt_id = $2 OR (operation_id = $3 AND step_id = $4))
6676
+ ORDER BY created_at ASC, receipt_id ASC
6677
+ LIMIT 1`, [this.tenantId, input.terminal_receipt_id, manifest.operation_id, manifest.step_id]);
6678
+ if (terminal.rows[0]) {
6679
+ return parseApplyResult2(terminal.rows[0]["result_json"], true);
6680
+ }
6285
6681
  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]);
6286
6682
  if (existing.rows[0]) {
6287
6683
  const binding = existing.rows[0];
6288
- if (binding["idempotency_key"] !== manifest.idempotency_key || binding["request_digest"] !== input.request_digest) {
6289
- throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_IDEMPOTENCY_CONFLICT", "Operation is already bound to a different request");
6684
+ 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) {
6685
+ return this.persistTerminal(tx, input, "TODOS_TASK_MANIFEST_IDEMPOTENCY_CONFLICT");
6290
6686
  }
6291
6687
  if (binding["state"] !== "applied") {
6292
- throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_GRAPH_CONFLICT", "Operation was already compensated");
6688
+ return this.persistTerminal(tx, input, "TODOS_TASK_MANIFEST_GRAPH_CONFLICT");
6293
6689
  }
6294
- return { ...parseJson(binding["result_json"]), duplicate: true };
6690
+ return parseApplyResult2(binding["result_json"], true);
6295
6691
  }
6296
6692
  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]);
6297
6693
  if (reused.rows[0])
6298
- throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_IDEMPOTENCY_CONFLICT", "Idempotency key is already used");
6694
+ return this.persistTerminal(tx, input, "TODOS_TASK_MANIFEST_IDEMPOTENCY_CONFLICT");
6695
+ if (manifest.idempotency_key !== input.expected_idempotency_key) {
6696
+ return this.persistTerminal(tx, input, "TODOS_TASK_MANIFEST_IDEMPOTENCY_MISMATCH");
6697
+ }
6299
6698
  if (manifest.if_binding_version !== undefined && manifest.if_binding_version !== 0) {
6300
- throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_CAS_CONFLICT", "New manifest binding version must be 0");
6699
+ return this.persistTerminal(tx, input, "TODOS_TASK_MANIFEST_CAS_CONFLICT");
6301
6700
  }
6302
6701
  const project = await tx.query(`SELECT 1 AS found FROM ${this.tableName}
6303
6702
  WHERE service = $1 AND object_type = 'projects' AND object_id = $2 AND deleted_at IS NULL LIMIT 1`, [this.service, manifest.project_id]);
6304
6703
  if (!project.rows[0])
6305
- throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_FOREIGN_REFERENCE", "Project does not exist");
6704
+ return this.persistTerminal(tx, input, "TODOS_TASK_MANIFEST_FOREIGN_REFERENCE");
6306
6705
  if (manifest.task_list_id) {
6307
6706
  const taskList = await tx.query(`SELECT payload FROM ${this.tableName}
6308
6707
  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]);
6309
6708
  const payload = taskList.rows[0] ? parseJson(taskList.rows[0]["payload"]) : null;
6310
6709
  if (!payload || payload["project_id"] !== manifest.project_id) {
6311
- throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_FOREIGN_REFERENCE", "Task list does not belong to the project");
6710
+ return this.persistTerminal(tx, input, "TODOS_TASK_MANIFEST_FOREIGN_REFERENCE");
6312
6711
  }
6313
6712
  }
6314
6713
  const objectIds = [input.graph.plan_id, ...Object.values(input.graph.task_ids), ...input.graph.comment_ids, ...input.graph.verification_ids, ...input.graph.dependency_ids];
6315
6714
  const conflict = await tx.query(`SELECT object_id FROM ${this.tableName}
6316
6715
  WHERE service = $1 AND object_id IN (${placeholders2(2, objectIds.length)}) LIMIT 1`, [this.service, ...objectIds]);
6317
6716
  if (conflict.rows[0])
6318
- throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_GRAPH_CONFLICT", "A deterministic graph id already exists");
6717
+ return this.persistTerminal(tx, input, "TODOS_TASK_MANIFEST_GRAPH_CONFLICT");
6319
6718
  await this.insertSync(tx, "plans", input.graph.plan_id, planPayload(input), input.now);
6320
6719
  fault2(faults, "after_plan_write");
6321
6720
  for (const task2 of manifest.tasks) {
@@ -6385,9 +6784,14 @@ class PostgresTodosTaskManifestBackend {
6385
6784
  schema_version: 1,
6386
6785
  kind: "apply",
6387
6786
  operation_id: manifest.operation_id,
6787
+ step_id: manifest.step_id,
6388
6788
  idempotency_key: manifest.idempotency_key,
6389
6789
  request_digest: input.request_digest,
6790
+ precondition_digest: manifest.precondition_digest,
6390
6791
  result_digest: input.result_digest,
6792
+ outcome: "accepted",
6793
+ reason: null,
6794
+ duplicate_of_receipt_id: null,
6391
6795
  binding_version: 1,
6392
6796
  apply_receipt_id: null,
6393
6797
  created_at: input.now
@@ -6404,14 +6808,18 @@ class PostgresTodosTaskManifestBackend {
6404
6808
  const resultJson = canonicalJson(result);
6405
6809
  await tx.query(`INSERT INTO todos_task_manifest_receipts (
6406
6810
  receipt_id, tenant_id, authority, route, schema_version, kind, operation_id, idempotency_key,
6407
- request_digest, result_digest, binding_version, apply_receipt_id, manifest_json, result_json, created_at
6408
- ) VALUES ($1, $2, 'todos', 'todos.task-manifest.v1', 1, 'apply', $3, $4, $5, $6, 1, NULL, $7::jsonb, $8::jsonb, $9)`, [
6811
+ step_id, request_digest, precondition_digest, result_digest, slug_provenance, outcome,
6812
+ reason, duplicate_of_receipt_id, binding_version, apply_receipt_id, manifest_json, result_json, created_at
6813
+ ) 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)`, [
6409
6814
  input.receipt_id,
6410
6815
  this.tenantId,
6411
6816
  manifest.operation_id,
6412
6817
  manifest.idempotency_key,
6818
+ manifest.step_id,
6413
6819
  input.request_digest,
6820
+ manifest.precondition_digest,
6414
6821
  input.result_digest,
6822
+ TASK_MANIFEST_DETERMINISTIC_SLUG_PROVENANCE,
6415
6823
  manifestJson,
6416
6824
  resultJson,
6417
6825
  input.now
@@ -6430,14 +6838,18 @@ class PostgresTodosTaskManifestBackend {
6430
6838
  }
6431
6839
  fault2(faults, "after_outbox_write");
6432
6840
  await tx.query(`INSERT INTO todos_task_manifest_bindings (
6433
- operation_id, tenant_id, idempotency_key, request_digest, result_digest, apply_receipt_id,
6434
- manifest_json, result_json, state, version, created_at, updated_at
6435
- ) VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8::jsonb, 'applied', 1, $9, $9)`, [
6841
+ operation_id, tenant_id, step_id, idempotency_key, request_digest, precondition_digest,
6842
+ result_digest, slug_provenance, outcome, apply_receipt_id, manifest_json, result_json,
6843
+ state, version, created_at, updated_at
6844
+ ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'accepted', $9, $10::jsonb, $11::jsonb, 'applied', 1, $12, $12)`, [
6436
6845
  manifest.operation_id,
6437
6846
  this.tenantId,
6847
+ manifest.step_id,
6438
6848
  manifest.idempotency_key,
6439
6849
  input.request_digest,
6850
+ manifest.precondition_digest,
6440
6851
  input.result_digest,
6852
+ TASK_MANIFEST_DETERMINISTIC_SLUG_PROVENANCE,
6441
6853
  input.receipt_id,
6442
6854
  manifestJson,
6443
6855
  resultJson,
@@ -6450,9 +6862,12 @@ class PostgresTodosTaskManifestBackend {
6450
6862
  async readExact(receiptId) {
6451
6863
  await this.ensureSchema();
6452
6864
  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, receiptId]);
6453
- if (!result.rows[0])
6865
+ if (result.rows[0])
6866
+ return parseApplyResult2(result.rows[0]["result_json"], false);
6867
+ 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, receiptId]);
6868
+ if (!terminal.rows[0])
6454
6869
  throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_RECEIPT_NOT_FOUND", `Apply receipt not found: ${receiptId}`);
6455
- return { ...parseJson(result.rows[0]["result_json"]), duplicate: false };
6870
+ return parseApplyResult2(terminal.rows[0]["result_json"], false);
6456
6871
  }
6457
6872
  async lookupBindingByPlanId(planId) {
6458
6873
  await this.ensureSchema();
@@ -6463,6 +6878,7 @@ class PostgresTodosTaskManifestBackend {
6463
6878
  b.version AS binding_version,
6464
6879
  b.tenant_id AS binding_tenant_id,
6465
6880
  b.operation_id AS binding_operation_id,
6881
+ b.step_id AS binding_step_id,
6466
6882
  b.result_json #>> '{graph,plan_id}' AS binding_plan_id,
6467
6883
  r.tenant_id AS receipt_tenant_id,
6468
6884
  r.authority AS receipt_authority,
@@ -6470,6 +6886,7 @@ class PostgresTodosTaskManifestBackend {
6470
6886
  r.schema_version AS receipt_schema_version,
6471
6887
  r.kind AS receipt_kind,
6472
6888
  r.operation_id AS receipt_operation_id,
6889
+ r.step_id AS receipt_step_id,
6473
6890
  r.result_json #>> '{graph,plan_id}' AS receipt_plan_id
6474
6891
  FROM todos_task_manifest_bindings b
6475
6892
  LEFT JOIN todos_task_manifest_receipts r
@@ -6556,6 +6973,10 @@ class PostgresTodosTaskManifestBackend {
6556
6973
  if (!binding || Number(binding["version"]) !== input.if_binding_version) {
6557
6974
  throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_CAS_CONFLICT", "Binding version changed before compensation");
6558
6975
  }
6976
+ const appliedReceipt = receiptFromRow(applyRow);
6977
+ 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"]) {
6978
+ throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_COMPENSATION_REFUSED", "Compensation refused: receipt and binding identity disagree");
6979
+ }
6559
6980
  if (binding["state"] !== "applied")
6560
6981
  throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_COMPENSATION_REFUSED", "Graph is not applied");
6561
6982
  const delivered = await tx.query(`SELECT o.id FROM todos_task_manifest_outbox o
@@ -6570,12 +6991,18 @@ class PostgresTodosTaskManifestBackend {
6570
6991
  LIMIT 1`, [this.tenantId, input.receipt_id]);
6571
6992
  if (delivered.rows[0])
6572
6993
  throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_COMPENSATION_REFUSED", "Compensation refused: delivered outbox row exists");
6573
- const applyResult = parseJson(applyRow["result_json"]);
6994
+ const applyResult = parseApplyResult2(applyRow["result_json"], false);
6574
6995
  const manifest = parseJson(applyRow["manifest_json"]);
6996
+ const manifestRecord = manifest;
6997
+ const applyStepId = typeof manifestRecord["step_id"] === "string" ? String(manifestRecord["step_id"]) : null;
6575
6998
  const expectedEffects = [
6576
6999
  {
6577
7000
  topic: "todos.task-manifest.applied",
6578
- payload: { operation_id: manifest.operation_id, project_id: manifest.project_id }
7001
+ payload: {
7002
+ operation_id: manifest.operation_id,
7003
+ ...applyStepId ? { step_id: applyStepId } : {},
7004
+ project_id: manifest.project_id
7005
+ }
6579
7006
  },
6580
7007
  ...(manifest.effects ?? []).map((effect2) => ({ topic: effect2.topic, payload: effect2.payload }))
6581
7008
  ];
@@ -6623,9 +7050,15 @@ class PostgresTodosTaskManifestBackend {
6623
7050
  }
6624
7051
  const appliedAt = receiptFromRow(applyRow).created_at;
6625
7052
  const expectedPayloads = new Map;
7053
+ const planRow = await tx.query(`SELECT payload FROM ${this.tableName}
7054
+ WHERE service = $1 AND object_type = 'plans' AND object_id = $2
7055
+ LIMIT 1`, [this.service, applyResult.graph.plan_id]);
7056
+ const actualPlan = planRow.rows[0] ? parseJson(planRow.rows[0]["payload"]) : null;
7057
+ const planExpected = planPayload({ manifest, graph: applyResult.graph, now: appliedAt });
7058
+ planExpected.slug = validatePostgresPlanSlug(manifest, applyResult.graph.plan_id, actualPlan?.["slug"], applyRow["slug_provenance"]);
6626
7059
  expectedPayloads.set(applyResult.graph.plan_id, {
6627
7060
  type: "plans",
6628
- payload: canonicalJson(planPayload({ manifest, graph: applyResult.graph, now: appliedAt }))
7061
+ payload: canonicalJson(planExpected)
6629
7062
  });
6630
7063
  for (const task2 of manifest.tasks)
6631
7064
  expectedPayloads.set(applyResult.graph.task_ids[task2.key], {
@@ -6725,14 +7158,17 @@ class PostgresTodosTaskManifestBackend {
6725
7158
  const readback = await this.readback(tx, applyResult.graph);
6726
7159
  const result = { duplicate: false, receipt, absent: true, readback };
6727
7160
  await tx.query(`INSERT INTO todos_task_manifest_receipts (
6728
- receipt_id, tenant_id, authority, route, schema_version, kind, operation_id, idempotency_key,
6729
- request_digest, result_digest, binding_version, apply_receipt_id, manifest_json, result_json, created_at
6730
- ) VALUES ($1, $2, 'todos', 'todos.task-manifest.v1', 1, 'compensate', $3, $4, $5, $6, $7, $8, NULL, $9::jsonb, $10)`, [
7161
+ receipt_id, tenant_id, authority, route, schema_version, kind, operation_id, step_id, idempotency_key,
7162
+ request_digest, precondition_digest, result_digest, slug_provenance, outcome, reason,
7163
+ duplicate_of_receipt_id, binding_version, apply_receipt_id, manifest_json, result_json, created_at
7164
+ ) 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)`, [
6731
7165
  compensationReceiptId,
6732
7166
  this.tenantId,
6733
7167
  receipt.operation_id,
7168
+ receipt.step_id,
6734
7169
  input.idempotency_key,
6735
7170
  requestDigest,
7171
+ input.precondition_digest,
6736
7172
  receipt.result_digest,
6737
7173
  receipt.binding_version,
6738
7174
  input.receipt_id,
@@ -6792,36 +7228,83 @@ function resolveTenantId(value) {
6792
7228
  }
6793
7229
  return tenantId;
6794
7230
  }
7231
+ function taskManifestRequestDigest(manifest) {
7232
+ const { idempotency_key: _idempotencyKey, ...request } = manifest;
7233
+ return canonicalDigest(request);
7234
+ }
7235
+ function taskManifestCompensationRequestDigest(request) {
7236
+ return canonicalDigest(request);
7237
+ }
7238
+ function deriveTodosTaskManifestApplyPreconditionDigest(input) {
7239
+ return canonicalDigest({
7240
+ route: TODOS_TASK_MANIFEST_CALLER_ROUTE,
7241
+ direction: "apply",
7242
+ operation_id: input.operation_id,
7243
+ step_id: input.step_id,
7244
+ project_id: input.project_id,
7245
+ task_list_id: input.task_list_id ?? null,
7246
+ expected_binding_version: input.if_binding_version ?? 0
7247
+ });
7248
+ }
7249
+ function deriveTodosTaskManifestCompensationPreconditionDigest(input) {
7250
+ return canonicalDigest({
7251
+ route: TODOS_TASK_MANIFEST_CALLER_ROUTE,
7252
+ direction: "compensate",
7253
+ operation_id: input.operation_id,
7254
+ step_id: input.step_id,
7255
+ apply_receipt_id: input.receipt_id,
7256
+ expected_binding_version: input.if_binding_version
7257
+ });
7258
+ }
7259
+ function deriveTodosTaskManifestIdempotencyKey(input) {
7260
+ return `tmk_${canonicalDigest({
7261
+ route: TODOS_TASK_MANIFEST_CALLER_ROUTE,
7262
+ ...input
7263
+ }).slice(0, 48)}`;
7264
+ }
6795
7265
  function normalize(input, now) {
6796
7266
  const parsed = parseTodosTaskManifest(input);
6797
7267
  const requestBytes = Buffer.byteLength(canonicalJson(parsed), "utf8");
6798
7268
  if (requestBytes > TODOS_TASK_MANIFEST_BOUNDS.request_bytes) {
6799
7269
  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 });
6800
7270
  }
7271
+ const { idempotency_key: _idempotencyKey, ...request } = parsed;
7272
+ const request_digest = taskManifestRequestDigest(request);
6801
7273
  const manifest = sanitizeManifest(parsed);
7274
+ const expectedPreconditionDigest = deriveTodosTaskManifestApplyPreconditionDigest(manifest);
7275
+ if (manifest.precondition_digest !== expectedPreconditionDigest) {
7276
+ throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_DIGEST_MISMATCH", "precondition_digest does not match the exact apply target and binding version", { expected_precondition_digest: expectedPreconditionDigest });
7277
+ }
7278
+ const expectedIdempotencyKey = deriveTodosTaskManifestIdempotencyKey({
7279
+ operation_id: manifest.operation_id,
7280
+ step_id: manifest.step_id,
7281
+ direction: "apply",
7282
+ target_selector: manifest.project_id,
7283
+ request_digest,
7284
+ precondition_digest: manifest.precondition_digest
7285
+ });
6802
7286
  const task_ids = Object.fromEntries(manifest.tasks.map((task2) => [
6803
7287
  task2.key,
6804
- deterministicUuid(TODOS_TASK_MANIFEST_ROUTE, manifest.operation_id, "task", task2.key)
7288
+ deterministicUuid(TODOS_TASK_MANIFEST_ROUTE, manifest.operation_id, manifest.step_id, "task", task2.key)
6805
7289
  ]));
6806
7290
  const graph = {
6807
- plan_id: deterministicUuid(TODOS_TASK_MANIFEST_ROUTE, manifest.operation_id, "plan", manifest.plan.key),
7291
+ plan_id: deterministicUuid(TODOS_TASK_MANIFEST_ROUTE, manifest.operation_id, manifest.step_id, "plan", manifest.plan.key),
6808
7292
  task_ids,
6809
- comment_ids: manifest.tasks.flatMap((task2) => (task2.comments ?? []).map((_, index) => deterministicUuid(TODOS_TASK_MANIFEST_ROUTE, manifest.operation_id, "comment", task2.key, String(index)))),
6810
- verification_ids: manifest.tasks.flatMap((task2) => (task2.verifications ?? []).map((_, index) => deterministicUuid(TODOS_TASK_MANIFEST_ROUTE, manifest.operation_id, "verification", task2.key, String(index)))),
7293
+ comment_ids: manifest.tasks.flatMap((task2) => (task2.comments ?? []).map((_, index) => deterministicUuid(TODOS_TASK_MANIFEST_ROUTE, manifest.operation_id, manifest.step_id, "comment", task2.key, String(index)))),
7294
+ verification_ids: manifest.tasks.flatMap((task2) => (task2.verifications ?? []).map((_, index) => deterministicUuid(TODOS_TASK_MANIFEST_ROUTE, manifest.operation_id, manifest.step_id, "verification", task2.key, String(index)))),
6811
7295
  dependency_ids: (manifest.dependencies ?? []).map((edge) => `${task_ids[edge.task]}::${task_ids[edge.depends_on]}`)
6812
7296
  };
6813
- const request_digest = canonicalDigest(parsed);
6814
7297
  const effectInputs = [
6815
7298
  {
6816
7299
  topic: "todos.task-manifest.applied",
6817
- payload: { operation_id: manifest.operation_id, project_id: manifest.project_id }
7300
+ payload: { operation_id: manifest.operation_id, step_id: manifest.step_id, project_id: manifest.project_id }
6818
7301
  },
6819
7302
  ...manifest.effects ?? []
6820
7303
  ];
6821
7304
  const outbox = effectInputs.map((effect2, index) => {
6822
7305
  const payload = { ...effect2.payload };
6823
7306
  return {
6824
- id: deterministicUuid(TODOS_TASK_MANIFEST_ROUTE, manifest.operation_id, "outbox", String(index)),
7307
+ id: deterministicUuid(TODOS_TASK_MANIFEST_ROUTE, manifest.operation_id, manifest.step_id, "outbox", String(index)),
6825
7308
  topic: effect2.topic,
6826
7309
  payload,
6827
7310
  digest: canonicalDigest({ topic: effect2.topic, payload })
@@ -6831,11 +7314,14 @@ function normalize(input, now) {
6831
7314
  return {
6832
7315
  manifest,
6833
7316
  request_digest,
7317
+ expected_idempotency_key: expectedIdempotencyKey,
6834
7318
  result_digest,
6835
- receipt_id: deterministicUuid(TODOS_TASK_MANIFEST_ROUTE, "apply", manifest.operation_id, manifest.idempotency_key, request_digest),
7319
+ receipt_id: deterministicUuid(TODOS_TASK_MANIFEST_ROUTE, "apply", manifest.operation_id, manifest.step_id, manifest.idempotency_key, request_digest),
7320
+ terminal_receipt_id: deterministicUuid(TODOS_TASK_MANIFEST_ROUTE, "terminal", "apply", manifest.operation_id, manifest.step_id, manifest.idempotency_key, request_digest),
6836
7321
  graph,
6837
7322
  outbox,
6838
- now
7323
+ now,
7324
+ plan_slug_provenance: TODOS_TASK_MANIFEST_PLAN_SLUG_PROVENANCE
6839
7325
  };
6840
7326
  }
6841
7327
  function sanitizeManifest(manifest) {
@@ -6893,6 +7379,10 @@ class PackageOwnedTodosTaskManifestAuthority {
6893
7379
  tenant_id: this.tenantId,
6894
7380
  backend: this.backend.kind,
6895
7381
  deterministic_ids: true,
7382
+ operation_step_identity: true,
7383
+ deterministic_idempotency_keys: true,
7384
+ terminal_nonacceptance_receipts: true,
7385
+ plan_slug_provenance: TODOS_TASK_MANIFEST_PLAN_SLUG_PROVENANCE,
6896
7386
  immutable_receipts: true,
6897
7387
  transactional_outbox: true,
6898
7388
  idempotent_outbox_delivery: true,
@@ -6922,7 +7412,11 @@ class PackageOwnedTodosTaskManifestAuthority {
6922
7412
  async apply(input) {
6923
7413
  const normalized = normalize(input, this.now());
6924
7414
  const faults = await this.prepareFaults();
6925
- return this.bounded(await this.backend.apply(normalized, faults));
7415
+ const result = this.bounded(await this.backend.apply(normalized, faults));
7416
+ if (result.receipt.outcome === "terminal_nonacceptance") {
7417
+ throw new TodosTaskManifestError(result.receipt.reason ?? "TODOS_TASK_MANIFEST_GRAPH_CONFLICT", "Task-manifest apply reached an immutable terminal nonacceptance", { receipt: result.receipt });
7418
+ }
7419
+ return result;
6926
7420
  }
6927
7421
  readExact(receiptId) {
6928
7422
  if (!receiptId || receiptId.length > 200) {
@@ -6955,18 +7449,48 @@ class PackageOwnedTodosTaskManifestAuthority {
6955
7449
  async compensate(input) {
6956
7450
  const request = parseTodosTaskManifestCompensation(input);
6957
7451
  const applied = await this.backend.readExact(request.receipt_id);
6958
- const requestDigest = canonicalDigest(request);
6959
- const compensationReceiptId = deterministicUuid(TODOS_TASK_MANIFEST_ROUTE, "compensate", applied.receipt.operation_id, request.idempotency_key, requestDigest);
7452
+ if (applied.receipt.outcome !== "accepted") {
7453
+ throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_COMPENSATION_REFUSED", "Compensation refused: apply receipt is terminal nonacceptance");
7454
+ }
7455
+ if (request.operation_id !== applied.receipt.operation_id) {
7456
+ throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_IDEMPOTENCY_CONFLICT", "Compensation operation_id must match the accepted apply operation");
7457
+ }
7458
+ if (request.step_id === applied.receipt.step_id) {
7459
+ throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_INVALID_INPUT", "Compensation must use a distinct step_id from apply");
7460
+ }
7461
+ const expectedPreconditionDigest = deriveTodosTaskManifestCompensationPreconditionDigest(request);
7462
+ if (request.precondition_digest !== expectedPreconditionDigest) {
7463
+ throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_DIGEST_MISMATCH", "precondition_digest does not match the exact compensation receipt and binding version", { expected_precondition_digest: expectedPreconditionDigest });
7464
+ }
7465
+ const { idempotency_key: _requestIdempotencyKey, ...compensationRequestWithoutKey } = request;
7466
+ const requestDigest = taskManifestCompensationRequestDigest(compensationRequestWithoutKey);
7467
+ const expectedIdempotencyKey = deriveTodosTaskManifestIdempotencyKey({
7468
+ operation_id: request.operation_id,
7469
+ step_id: request.step_id,
7470
+ direction: "compensate",
7471
+ target_selector: request.receipt_id,
7472
+ request_digest: requestDigest,
7473
+ precondition_digest: request.precondition_digest
7474
+ });
7475
+ if (request.idempotency_key !== expectedIdempotencyKey) {
7476
+ throw new TodosTaskManifestError("TODOS_TASK_MANIFEST_IDEMPOTENCY_MISMATCH", "idempotency_key does not match the deterministic operation/step/compensation semantics", { expected_idempotency_key: expectedIdempotencyKey });
7477
+ }
7478
+ const compensationReceiptId = deterministicUuid(TODOS_TASK_MANIFEST_ROUTE, "compensate", request.operation_id, request.step_id, request.idempotency_key, requestDigest);
6960
7479
  const receipt = {
6961
7480
  receipt_id: compensationReceiptId,
6962
7481
  authority: "todos",
6963
7482
  route: TODOS_TASK_MANIFEST_ROUTE,
6964
7483
  schema_version: 1,
6965
7484
  kind: "compensate",
6966
- operation_id: applied.receipt.operation_id,
7485
+ operation_id: request.operation_id,
7486
+ step_id: request.step_id,
6967
7487
  idempotency_key: request.idempotency_key,
6968
7488
  request_digest: requestDigest,
7489
+ precondition_digest: request.precondition_digest,
6969
7490
  result_digest: canonicalDigest({ absent: true, apply_receipt_id: applied.receipt.receipt_id }),
7491
+ outcome: "accepted",
7492
+ reason: null,
7493
+ duplicate_of_receipt_id: null,
6970
7494
  binding_version: request.if_binding_version + 1,
6971
7495
  apply_receipt_id: applied.receipt.receipt_id,
6972
7496
  created_at: this.now()
@@ -7148,6 +7672,8 @@ function createTodosTaskManifestHttpClient(options) {
7148
7672
  return new TodosTaskManifestHttpClient(options);
7149
7673
  }
7150
7674
  export {
7675
+ taskManifestRequestDigest,
7676
+ taskManifestCompensationRequestDigest,
7151
7677
  supportsIdempotentOutboxDelivery,
7152
7678
  sqliteTodosTaskManifestSchemaSql,
7153
7679
  postgresTodosTaskManifestSchemaSql,
@@ -7157,6 +7683,9 @@ export {
7157
7683
  handleTodosTaskManifestHttpRequest,
7158
7684
  canonicalDigest as digestTodosTaskManifest,
7159
7685
  deterministicUuid as deterministicTodosTaskManifestId,
7686
+ deriveTodosTaskManifestIdempotencyKey,
7687
+ deriveTodosTaskManifestCompensationPreconditionDigest,
7688
+ deriveTodosTaskManifestApplyPreconditionDigest,
7160
7689
  createTodosTaskManifestHttpClient,
7161
7690
  createSqliteTodosTaskManifestAuthority,
7162
7691
  createPostgresTodosTaskManifestAuthority,
@@ -7165,6 +7694,8 @@ export {
7165
7694
  TodosTaskManifestError,
7166
7695
  TODOS_TASK_MANIFEST_SCHEMA_VERSION,
7167
7696
  TODOS_TASK_MANIFEST_ROUTE,
7697
+ TODOS_TASK_MANIFEST_PLAN_SLUG_PROVENANCE,
7698
+ TODOS_TASK_MANIFEST_CALLER_ROUTE,
7168
7699
  TODOS_TASK_MANIFEST_BOUNDS,
7169
7700
  SqliteTodosTaskManifestBackend,
7170
7701
  PostgresTodosTaskManifestBackend,