From 03b6751d420e8e4e4cc9aeef4f76b13e28a7265f Mon Sep 17 00:00:00 2001 From: Cabbagec Date: Thu, 13 Aug 2026 06:45:54 +0000 Subject: [PATCH] feat: bind job events to command leases --- README.md | 16 ++++++------ examples/v1/assign-archive-job.json | 2 +- examples/v1/assign-eviction-job.json | 2 +- examples/v1/cancel-job-command.json | 2 +- examples/v1/client-state-snapshot.json | 2 +- examples/v1/ensure-route-command.json | 2 +- examples/v1/inventory-query.json | 2 +- examples/v1/job-committed-event.json | 3 ++- examples/v1/job-progress-event.json | 3 ++- examples/v1/reconcile-job-command.json | 34 ++++++++++++++++++++++++++ examples/v1/register-request.json | 4 +-- proto/archive_control/v1/control.proto | 16 ++++++++++++ 12 files changed, 71 insertions(+), 17 deletions(-) create mode 100644 examples/v1/reconcile-job-command.json diff --git a/README.md b/README.md index dbc82b9..d97e021 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Source-of-truth protobuf contract for Archive Control clients and the Titan control daemon. Implementations exchange canonical protobuf JSON as one text -message per WebSocket frame. +message per WebSocket frame. The current incompatible protocol major is `2`. Upstream: `cabbage/archive-control-proto` @@ -29,14 +29,16 @@ Upstream: `cabbage/archive-control-proto` 3. A server command is delivered at least once. Clients durably deduplicate `command_id` and return `DUPLICATE` for a replayed accepted command. 4. `CommandAck` means durable acceptance, not operation completion. -5. Job events have monotonically increasing global per-job sequences. A command - carries the accepted sequence base and grants one client the event-writer - lease; a gap requires `JobSnapshot` reconciliation before further - destructive transitions. +5. Job events have monotonically increasing global per-job sequences and name + the durable command that owns them. Control verifies the reporting client, + command cursor, and permitted state transition. A gap or conflict requires + authoritative `ReconcileJobCommand` processing before further destructive + transitions. 6. UUIDs use lowercase canonical text. Info hashes use validated lowercase hex. 7. `SelectionSet.ranges` are inclusive, sorted, non-overlapping, and coalesced. -8. Receivers ignore unknown JSON fields for compatible minor versions. They - reject an unknown required command/job operation. +8. Receivers reject a different protocol major. Within major `2`, they ignore + unknown JSON fields only for compatible minor versions and reject an unknown + required command/job operation. 9. Published field numbers and enum values are never reused. Removed values are reserved in the change that removes them. 10. Implementations cap an envelope at the negotiated maximum and keep streamed diff --git a/examples/v1/assign-archive-job.json b/examples/v1/assign-archive-job.json index 98011d7..041e4b4 100644 --- a/examples/v1/assign-archive-job.json +++ b/examples/v1/assign-archive-job.json @@ -1,6 +1,6 @@ { "protocolVersion": { - "major": 1, + "major": 2, "minor": 0 }, "messageId": "2fc6a524-84cd-47ab-bd73-30d463b7c43f", diff --git a/examples/v1/assign-eviction-job.json b/examples/v1/assign-eviction-job.json index f198f03..b7dd0c1 100644 --- a/examples/v1/assign-eviction-job.json +++ b/examples/v1/assign-eviction-job.json @@ -1,6 +1,6 @@ { "protocolVersion": { - "major": 1, + "major": 2, "minor": 0 }, "messageId": "9c62fcda-5f83-4286-80cf-a628001e44d3", diff --git a/examples/v1/cancel-job-command.json b/examples/v1/cancel-job-command.json index 09f6d23..002db03 100644 --- a/examples/v1/cancel-job-command.json +++ b/examples/v1/cancel-job-command.json @@ -1,6 +1,6 @@ { "protocolVersion": { - "major": 1, + "major": 2, "minor": 0 }, "messageId": "7c4a3534-c754-4c4d-a12e-1a1047e3f96e", diff --git a/examples/v1/client-state-snapshot.json b/examples/v1/client-state-snapshot.json index ee146d7..ed96280 100644 --- a/examples/v1/client-state-snapshot.json +++ b/examples/v1/client-state-snapshot.json @@ -1,6 +1,6 @@ { "protocolVersion": { - "major": 1, + "major": 2, "minor": 0 }, "messageId": "56aae470-d1c9-402a-bf14-d7a28ff8998f", diff --git a/examples/v1/ensure-route-command.json b/examples/v1/ensure-route-command.json index 245ce79..2337eb8 100644 --- a/examples/v1/ensure-route-command.json +++ b/examples/v1/ensure-route-command.json @@ -1,6 +1,6 @@ { "protocolVersion": { - "major": 1, + "major": 2, "minor": 0 }, "messageId": "830626f3-dc76-46f0-a9b1-a6fb61b3ba73", diff --git a/examples/v1/inventory-query.json b/examples/v1/inventory-query.json index 2ca9e85..d8cf00b 100644 --- a/examples/v1/inventory-query.json +++ b/examples/v1/inventory-query.json @@ -1,6 +1,6 @@ { "protocolVersion": { - "major": 1, + "major": 2, "minor": 0 }, "messageId": "d00def27-cde1-43b7-a6af-ee3582d0c7c1", diff --git a/examples/v1/job-committed-event.json b/examples/v1/job-committed-event.json index c4da360..b2a2090 100644 --- a/examples/v1/job-committed-event.json +++ b/examples/v1/job-committed-event.json @@ -1,6 +1,6 @@ { "protocolVersion": { - "major": 1, + "major": 2, "minor": 0 }, "messageId": "f337dc9c-84b8-42c6-a2ae-52f8f91258a8", @@ -8,6 +8,7 @@ "sentAt": "2026-07-22T12:08:00Z", "jobEvent": { "eventId": "ecfbd79e-e0fe-41da-ab77-012cde0ed53f", + "commandId": "6bfccbf4-866f-46fe-bf67-d8943c1ece91", "jobId": "fd6844d0-ed76-475e-8375-2f98d8eea470", "sequence": "23", "jobRevision": "9", diff --git a/examples/v1/job-progress-event.json b/examples/v1/job-progress-event.json index 8dad630..d07eedd 100644 --- a/examples/v1/job-progress-event.json +++ b/examples/v1/job-progress-event.json @@ -1,6 +1,6 @@ { "protocolVersion": { - "major": 1, + "major": 2, "minor": 0 }, "messageId": "44b64096-cd58-4f49-bae4-d829f50f787c", @@ -8,6 +8,7 @@ "sentAt": "2026-07-22T12:04:00Z", "jobEvent": { "eventId": "18cf6a34-b0a5-429e-b56a-9228b2dca640", + "commandId": "6bfccbf4-866f-46fe-bf67-d8943c1ece91", "jobId": "fd6844d0-ed76-475e-8375-2f98d8eea470", "sequence": "17", "jobRevision": "6", diff --git a/examples/v1/reconcile-job-command.json b/examples/v1/reconcile-job-command.json new file mode 100644 index 0000000..998f268 --- /dev/null +++ b/examples/v1/reconcile-job-command.json @@ -0,0 +1,34 @@ +{ + "protocolVersion": { + "major": 2 + }, + "messageId": "4be5df55-3d95-4f60-8f6b-1cfd0b64cfbb", + "sentAt": "2026-08-13T06:00:00Z", + "command": { + "commandId": "94b4f73f-31a8-426b-8511-12b91987f1b4", + "createdAt": "2026-08-13T06:00:00Z", + "reconcileJob": { + "authoritativeJob": { + "definition": { + "jobId": "3173ae0d-ecc4-4113-a659-7897aac6c66f", + "idempotencyKey": "55b6d968-e20d-444b-8fae-17914d15fdf5", + "operation": "JOB_OPERATION_ARCHIVE", + "resourceDisplayName": "example", + "createdAt": "2026-08-13T05:00:00Z", + "transfer": { + "sourceClientId": "cache-1", + "targetClientId": "archive-1", + "routeId": "route-example" + } + }, + "state": "JOB_STATE_RUNNING", + "revision": "12" + }, + "authoritativeLastEventSequence": "12", + "supersededCommandIds": [ + "6d4ab7f8-2ff0-47d8-8c7c-45a67dce8535" + ], + "reason": "control cursor is authoritative after stale event replay" + } + } +} diff --git a/examples/v1/register-request.json b/examples/v1/register-request.json index aad33ad..5754a49 100644 --- a/examples/v1/register-request.json +++ b/examples/v1/register-request.json @@ -1,13 +1,13 @@ { "protocolVersion": { - "major": 1, + "major": 2, "minor": 0 }, "messageId": "0d6d31f8-1242-4a40-a261-433897102fe4", "sentAt": "2026-07-22T12:00:00Z", "registerRequest": { "protocolVersion": { - "major": 1, + "major": 2, "minor": 0 }, "client": { diff --git a/proto/archive_control/v1/control.proto b/proto/archive_control/v1/control.proto index 3f7a429..926f3dc 100644 --- a/proto/archive_control/v1/control.proto +++ b/proto/archive_control/v1/control.proto @@ -38,6 +38,17 @@ message RequestJobSnapshotCommand { repeated string job_ids = 1; } +// ReconcileJobCommand makes control's durable cursor authoritative after a +// reconnect conflict, local database restore, or rejected stale event. The +// client must durably adopt this cursor before acknowledging the command and +// must never emit output for superseded_command_ids afterwards. +message ReconcileJobCommand { + JobRecord authoritative_job = 1; + uint64 authoritative_last_event_sequence = 2; + repeated string superseded_command_ids = 3; + string reason = 4; +} + message Command { string command_id = 1; google.protobuf.Timestamp created_at = 2; @@ -48,6 +59,7 @@ message Command { EnsureRouteCommand ensure_route = 13; InventoryQuery inventory_query = 14; RequestJobSnapshotCommand request_job_snapshot = 15; + ReconcileJobCommand reconcile_job = 16; } } @@ -97,6 +109,10 @@ message JobEvent { ResourceStateFingerprint observed_resource = 10; Placement observed_placement = 11; google.protobuf.Timestamp occurred_at = 12; + // The durable ExecuteStepCommand or CancelJobCommand that owns this event. + // Control verifies this ID, the reporting client, the command's expected + // cursor, and the permitted step/state transition before appending it. + string command_id = 13; } message JobSnapshot {