ace-assign 0.64.0 → 0.66.1

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 (129) hide show
  1. checksums.yaml +4 -4
  2. data/.ace-defaults/assign/catalog/steps/auto-merge.step.yml +11 -0
  3. data/CHANGELOG.md +25 -0
  4. data/docs/protected-authority.md +23 -7
  5. data/docs/usage.md +117 -4
  6. data/exe/ace-scope-ready +55 -0
  7. data/handbook/workflow-instructions/assign/drive.wf.md +26 -1
  8. data/lib/ace/assign/atoms/attempt_state_machine.rb +8 -0
  9. data/lib/ace/assign/atoms/delivery_parameters.rb +4 -39
  10. data/lib/ace/assign/authority/assignment_inventory.rb +290 -0
  11. data/lib/ace/assign/authority/campaign_consumer_policy.rb +100 -0
  12. data/lib/ace/assign/authority/campaign_execution.rb +119 -0
  13. data/lib/ace/assign/authority/campaign_round_transfer.rb +82 -0
  14. data/lib/ace/assign/authority/candidate_transfer.rb +169 -2
  15. data/lib/ace/assign/authority/cleanup_result_protection.rb +53 -0
  16. data/lib/ace/assign/authority/client.rb +156 -6
  17. data/lib/ace/assign/authority/deployment.rb +201 -39
  18. data/lib/ace/assign/authority/deployment_history.rb +146 -0
  19. data/lib/ace/assign/authority/endcap.rb +131 -18
  20. data/lib/ace/assign/authority/endcap_attempts.rb +274 -0
  21. data/lib/ace/assign/authority/endcap_campaign_results.rb +232 -0
  22. data/lib/ace/assign/authority/endcap_campaign_rounds.rb +266 -0
  23. data/lib/ace/assign/authority/endcap_contexts.rb +23 -0
  24. data/lib/ace/assign/authority/endcap_inboxes.rb +3 -280
  25. data/lib/ace/assign/authority/endcap_prepared_work.rb +80 -0
  26. data/lib/ace/assign/authority/endcap_publication.rb +207 -0
  27. data/lib/ace/assign/authority/endcap_results.rb +36 -18
  28. data/lib/ace/assign/authority/endcap_review_requests.rb +183 -0
  29. data/lib/ace/assign/authority/endcap_reviews.rb +130 -0
  30. data/lib/ace/assign/authority/endcap_service_settlement.rb +152 -0
  31. data/lib/ace/assign/authority/endcap_services.rb +221 -18
  32. data/lib/ace/assign/authority/execution_scope_observation.rb +468 -41
  33. data/lib/ace/assign/authority/inbox_context_original.rb +113 -0
  34. data/lib/ace/assign/authority/launch_attempt_consumers.rb +51 -0
  35. data/lib/ace/assign/authority/launch_campaign_registration.rb +214 -0
  36. data/lib/ace/assign/authority/launch_control_channel.rb +313 -0
  37. data/lib/ace/assign/authority/launch_control_exclusions.rb +141 -0
  38. data/lib/ace/assign/authority/launch_driver.rb +298 -19
  39. data/lib/ace/assign/authority/launch_input_inhibition.rb +132 -0
  40. data/lib/ace/assign/authority/launch_lifecycle.rb +275 -100
  41. data/lib/ace/assign/authority/launch_prepared_work.rb +122 -0
  42. data/lib/ace/assign/authority/launch_review.rb +91 -0
  43. data/lib/ace/assign/authority/launch_scope_abort.rb +0 -7
  44. data/lib/ace/assign/authority/launch_scope_admission.rb +136 -5
  45. data/lib/ace/assign/authority/launch_scope_close.rb +68 -37
  46. data/lib/ace/assign/authority/launch_scope_release.rb +696 -17
  47. data/lib/ace/assign/authority/launch_steering.rb +353 -0
  48. data/lib/ace/assign/authority/launch_stop.rb +254 -0
  49. data/lib/ace/assign/authority/maintenance_workspace_retirement.rb +229 -0
  50. data/lib/ace/assign/authority/original_inbox_selection.rb +41 -0
  51. data/lib/ace/assign/authority/posix_acl.rb +5 -2
  52. data/lib/ace/assign/authority/prepared_input.rb +130 -0
  53. data/lib/ace/assign/authority/prepared_queue.rb +185 -0
  54. data/lib/ace/assign/authority/prepared_task_context.rb +116 -0
  55. data/lib/ace/assign/authority/prepared_work.rb +304 -0
  56. data/lib/ace/assign/authority/prepared_worker.rb +69 -0
  57. data/lib/ace/assign/authority/protected_assignment_context.rb +226 -0
  58. data/lib/ace/assign/authority/readiness_hook.rb +120 -0
  59. data/lib/ace/assign/authority/router.rb +24 -2
  60. data/lib/ace/assign/authority/server.rb +103 -33
  61. data/lib/ace/assign/authority/service_cleanup_dispatch.rb +88 -0
  62. data/lib/ace/assign/authority/service_cleanup_evidence.rb +180 -0
  63. data/lib/ace/assign/authority/service_delivery_evidence.rb +101 -0
  64. data/lib/ace/assign/authority/service_evidence.rb +243 -12
  65. data/lib/ace/assign/authority/service_publication_evidence.rb +110 -0
  66. data/lib/ace/assign/authority/task_context_entry.rb +32 -0
  67. data/lib/ace/assign/authority/transfer_codec.rb +44 -12
  68. data/lib/ace/assign/cli/commands/assignment_target.rb +15 -2
  69. data/lib/ace/assign/cli/commands/attempt/base.rb +49 -0
  70. data/lib/ace/assign/cli/commands/attempt/finish.rb +20 -4
  71. data/lib/ace/assign/cli/commands/attempt/reconcile.rb +11 -0
  72. data/lib/ace/assign/cli/commands/authority/inbox_context_principal.rb +25 -0
  73. data/lib/ace/assign/cli/commands/authority/inbox_context_selection.rb +28 -0
  74. data/lib/ace/assign/cli/commands/authority/launch.rb +58 -6
  75. data/lib/ace/assign/cli/commands/authority/serve.rb +2 -1
  76. data/lib/ace/assign/cli/commands/authority/status.rb +1 -1
  77. data/lib/ace/assign/cli/commands/authority/task_context.rb +31 -0
  78. data/lib/ace/assign/cli/commands/authority/task_context_principal.rb +25 -0
  79. data/lib/ace/assign/cli/commands/authority/task_context_selection.rb +30 -0
  80. data/lib/ace/assign/cli/commands/authority/worker.rb +23 -0
  81. data/lib/ace/assign/cli/commands/campaign_export_result.rb +55 -0
  82. data/lib/ace/assign/cli/commands/campaign_record_round.rb +57 -0
  83. data/lib/ace/assign/cli/commands/create.rb +2 -0
  84. data/lib/ace/assign/cli/commands/delivery.rb +54 -4
  85. data/lib/ace/assign/cli/commands/fail.rb +6 -0
  86. data/lib/ace/assign/cli/commands/finish.rb +23 -2
  87. data/lib/ace/assign/cli/commands/fork_session.rb +6 -2
  88. data/lib/ace/assign/cli/commands/inbox_bind.rb +34 -0
  89. data/lib/ace/assign/cli/commands/protected_submission.rb +61 -0
  90. data/lib/ace/assign/cli/commands/resume.rb +12 -0
  91. data/lib/ace/assign/cli/commands/select.rb +2 -0
  92. data/lib/ace/assign/cli/commands/start.rb +2 -0
  93. data/lib/ace/assign/cli/commands/status.rb +4 -2
  94. data/lib/ace/assign/cli/commands/step.rb +3 -1
  95. data/lib/ace/assign/cli/commands/submit_candidate.rb +34 -0
  96. data/lib/ace/assign/cli/commands/submit_result.rb +61 -0
  97. data/lib/ace/assign/cli.rb +36 -5
  98. data/lib/ace/assign/errors.rb +139 -0
  99. data/lib/ace/assign/models/assignment.rb +26 -4
  100. data/lib/ace/assign/models/evidence_event.rb +3 -3
  101. data/lib/ace/assign/molecules/assignment_manager.rb +12 -2
  102. data/lib/ace/assign/molecules/attempt_reconciler.rb +20 -0
  103. data/lib/ace/assign/molecules/canonical_attempt_state.rb +151 -0
  104. data/lib/ace/assign/molecules/canonical_evidence.rb +5 -2
  105. data/lib/ace/assign/molecules/canonical_read_snapshot.rb +374 -0
  106. data/lib/ace/assign/molecules/evidence_journal.rb +720 -44
  107. data/lib/ace/assign/molecules/execution_scope_lineage.rb +27 -9
  108. data/lib/ace/assign/molecules/fork_session_launcher.rb +16 -6
  109. data/lib/ace/assign/molecules/journal_input_inhibition.rb +78 -0
  110. data/lib/ace/assign/molecules/journal_mutation.rb +82 -13
  111. data/lib/ace/assign/molecules/journal_prompt_mutation.rb +241 -0
  112. data/lib/ace/assign/molecules/lifecycle_exclusion.rb +178 -26
  113. data/lib/ace/assign/molecules/original_launch_binding.rb +37 -0
  114. data/lib/ace/assign/molecules/protected_control_exclusion.rb +303 -0
  115. data/lib/ace/assign/molecules/protected_workspace_exclusion.rb +330 -0
  116. data/lib/ace/assign/molecules/protected_workspace_provisioner.rb +164 -0
  117. data/lib/ace/assign/molecules/queue_scanner.rb +26 -11
  118. data/lib/ace/assign/molecules/receipt_verifier.rb +21 -15
  119. data/lib/ace/assign/molecules/step_writer.rb +27 -5
  120. data/lib/ace/assign/molecules/terminal_scope_receipt.rb +88 -0
  121. data/lib/ace/assign/organisms/assignment_executor.rb +4 -4
  122. data/lib/ace/assign/organisms/attempt_coordinator.rb +66 -76
  123. data/lib/ace/assign/organisms/prepared_work_builder.rb +194 -0
  124. data/lib/ace/assign/organisms/protected_delivery_coordinator.rb +100 -0
  125. data/lib/ace/assign/organisms/task_assignment_creator.rb +4 -2
  126. data/lib/ace/assign/version.rb +1 -1
  127. data/lib/ace/assign.rb +1 -112
  128. metadata +90 -11
  129. data/lib/ace/assign/cli/commands/inbox_reconcile.rb +0 -36
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Assign
5
+ module Authority
6
+ # Canonical-only original dispatch proof shared by status, root admission
7
+ # and initial/retained inspection imports. Installed peer policy stays in
8
+ # the fixed root transport owner.
9
+ module ServiceCleanupDispatch
10
+ CLEANUP_MUTABLE_SETTLEMENT_FIELDS = %w[state receipt reason claimed_at failed_at no_effect_challenge
11
+ challenge_generation challenge_event_digest completion_digest no_effect_completion_digest].freeze
12
+
13
+ def cleanup_dispatch_context!(record, commit:, selectors: nil, pending: nil)
14
+ unless @journal.evidence_mode == :protected &&
15
+ record.values_at("operation", "dispatch_phase") == ["prune-preserved-workspace", "dispatch_started"]
16
+ raise AttemptErrors::EvidenceUnavailable, "cleanup original dispatch is unavailable"
17
+ end
18
+ pending ||= {commit: commit}
19
+ @journal.verify_canonical_prefix!(commit: commit) unless service_read_view(pending)
20
+ # The existing index authenticates accepted record provenance without
21
+ # recursively asking this terminal collection to validate itself.
22
+ selected = @journal.service_request_records(commit: commit)
23
+ .select { |entry| entry["request_id"] == record.fetch("request_id") }
24
+ # Initial import receives the lower writer's proposed terminal
25
+ # replacement. Its settlement fields are validated by that writer
26
+ # and collection; the original immutable dispatch must still match.
27
+ unless selected.one? && selected.first.except(*CLEANUP_MUTABLE_SETTLEMENT_FIELDS) ==
28
+ record.except(*CLEANUP_MUTABLE_SETTLEMENT_FIELDS)
29
+ raise AttemptErrors::EvidenceUnavailable, "cleanup selected record differs"
30
+ end
31
+ context(record, pending: {commit: commit})
32
+ events = service_read_events(record, pending)
33
+ names = %w[request_event_digest dispatch_event_digest]
34
+ operations = %w[request_service begin_dispatch]
35
+ if selectors.nil?
36
+ selectors = names.zip(operations).to_h do |name, operation|
37
+ matches = events.select do |event|
38
+ event["type"] == "authority_mutation" && event.dig("payload", "operation") == operation &&
39
+ event.dig("payload", "data", "request_id") == record.fetch("request_id") &&
40
+ event.dig("payload", "data", "claim_binding") == record.fetch("claim_binding") &&
41
+ event.dig("payload", "data", operation == "request_service" ? "claim" : "invocation") ==
42
+ (operation == "request_service" ? "created" : "permitted")
43
+ end
44
+ raise AttemptErrors::EvidenceUnavailable, "cleanup original acceptance is ambiguous" unless matches.one?
45
+ [name, matches.first.fetch("digest")]
46
+ end
47
+ end
48
+ unless selectors.is_a?(Hash) && selectors.keys.sort == names.sort &&
49
+ selectors.values.all? { |value| value.is_a?(String) && value.match?(/\A[0-9a-f]{64}\z/) } && selectors.values.uniq.size == 2
50
+ raise AttemptErrors::EvidenceUnavailable, "cleanup original acceptance selectors differ"
51
+ end
52
+ introductions = service_read_introductions(record, selectors.values, pending)
53
+ dispatch_record = nil
54
+ names.zip(operations).each do |name, operation|
55
+ selector = selectors.fetch(name)
56
+ matches = events.select { |event| event.fetch("digest") == selector }
57
+ event = matches.one? && matches.first
58
+ payload = event && event.fetch("payload")
59
+ unless event && event.fetch("type") == "authority_mutation" && payload.fetch("operation") == operation &&
60
+ payload.slice("assignment_id", "attempt_id") == record.slice("assignment_id", "attempt_id") &&
61
+ payload.fetch("data").slice("request_id", "claim_binding") == record.slice("request_id", "claim_binding") &&
62
+ payload.fetch("data").fetch(operation == "request_service" ? "claim" : "invocation") ==
63
+ (operation == "request_service" ? "created" : "permitted")
64
+ raise AttemptErrors::EvidenceUnavailable, "cleanup original acceptance differs"
65
+ end
66
+ prefix = @journal.read_events(record.fetch("assignment_id"), commit: introductions.fetch(selector))
67
+ .select { |entry| entry.fetch("attempt_id") == record.fetch("attempt_id") }
68
+ original = @journal.service_request(record.fetch("request_id"), commit: introductions.fetch(selector))
69
+ expected = operation == "request_service" ? original&.merge("dispatch_phase" => "dispatch_started",
70
+ "operation_owner_binding" => record.fetch("operation_owner_binding"),
71
+ "executor_process_binding" => record.fetch("executor_process_binding")) : original
72
+ unless expected&.except(*CLEANUP_MUTABLE_SETTLEMENT_FIELDS) == record.except(*CLEANUP_MUTABLE_SETTLEMENT_FIELDS) &&
73
+ prefix.last == event && prefix[-2] &&
74
+ prefix[-2].fetch("type") == (operation == "request_service" ? "service_claim" : "service_transition") &&
75
+ prefix[-2].dig("payload", "record_digest") == Atoms::EvidenceDigest.digest(original) &&
76
+ event.fetch("previous_digest") == prefix[-2].fetch("digest")
77
+ raise AttemptErrors::EvidenceUnavailable, "cleanup acceptance is not its original canonical introduction"
78
+ end
79
+ dispatch_record = original if operation == "begin_dispatch"
80
+ end
81
+ freeze_projection(JSON.parse(JSON.generate(selectors.merge("commit" => commit, "dispatch_record" => dispatch_record))))
82
+ rescue KeyError, TypeError, ArgumentError
83
+ raise AttemptErrors::EvidenceUnavailable, "cleanup original dispatch proof is incomplete"
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,180 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ace/runtime/molecules/protected_artifact_set"
4
+ require_relative "cleanup_result_protection"
5
+
6
+ module Ace
7
+ module Assign
8
+ module Authority
9
+ # The existing service evidence owner authenticates this complete pair.
10
+ # Root-private archives remain outside the authority evidence graph.
11
+ module ServiceCleanupEvidence
12
+ ROOT_SELECTION_SCHEMA = "ace.protected-workspace-prune-root-selection/v1"
13
+ ROOT_RECEIPT_SCHEMA = "ace.protected-workspace-prune-receipt/v1"
14
+ ROOT_SELECTION_FIELDS = %w[schema request_id input_digest operation_owner_binding_digest receipt_ref].freeze
15
+ ROOT_INSPECTION_SELECTION_SCHEMA = "ace.protected-workspace-prune-inspection-selection/v1"
16
+ ROOT_INSPECTION_SELECTION_FIELDS = %w[schema request_id input_digest operation_owner_binding_digest inspection_ref].freeze
17
+ ROOT_RECEIPT_FIELDS = %w[schema request_id input_digest maintenance target publication canonical_snapshots preservation removal].freeze
18
+
19
+ # Pre-import decoding reuses the collection validator. Only the lower
20
+ # writer's pending collection can authenticate protected storage and
21
+ # accept a terminal update.
22
+ def cleanup_inspection_inputs!(contents, record, challenge)
23
+ unless record["operation"] == "prune-preserved-workspace" && contents.is_a?(Array) && contents.size == 2
24
+ raise AttemptErrors::ReceiptRejected, "cleanup inspection requires its complete pair"
25
+ end
26
+ cleanup_documents!(contents, record, no_effect: true, challenge: challenge)
27
+ true
28
+ rescue KeyError, TypeError, JSON::ParserError, JSON::GeneratorError
29
+ raise AttemptErrors::ReceiptRejected, "cleanup inspection pair is unavailable"
30
+ end
31
+
32
+ private
33
+
34
+ def cleanup_collection!(references, record, state, pending)
35
+ unless record["operation"] == "prune-preserved-workspace" && %w[succeeded failed-settled].include?(state) && references.size == 2
36
+ raise AttemptErrors::ReceiptRejected, "cleanup completion requires its complete original evidence pair"
37
+ end
38
+ no_effect = state == "failed-settled"
39
+ challenge = no_effect ? challenge!(record, pending: pending) : nil
40
+ expected = no_effect ? context_for_challenge(record, challenge) : context(record)
41
+ proof = cleanup_dispatch_context!(record, commit: pending && pending[:commit] || @journal.ref_value, pending: pending)
42
+ contents = references.map do |reference|
43
+ if pending && pending[:pending_events]
44
+ @canonical.read_pending(reference, **expected, **pending.slice(:current_events, :pending_events, :blobs, :commit))
45
+ else
46
+ @canonical.read(reference, **expected, commit: pending && pending[:commit] || @journal.ref_value)
47
+ end
48
+ end
49
+ reference, operation_bytes = cleanup_documents!(contents, record, no_effect: no_effect,
50
+ challenge: challenge)
51
+ path = reference.fetch("path")
52
+ # The lower writer supplies its pending import transaction. Historical
53
+ # consumers have only a pinned commit and never reopen mutable storage.
54
+ if pending && pending[:pending_events]
55
+ selected_artifacts = @cleanup_artifacts || Ace::Runtime::Molecules::ProtectedArtifactSet.new(
56
+ protection: CleanupResultProtection.new(result_path: path, authority_gid: Process.egid))
57
+ selected_artifacts.with do |artifacts|
58
+ unless artifacts.read!(reference) == operation_bytes
59
+ raise AttemptErrors::ReceiptRejected, "cleanup retained result bytes differ"
60
+ end
61
+ artifacts.verify_unchanged!
62
+ end
63
+ end
64
+ if (view = service_read_view(pending))
65
+ view.projection = settlement_projection(record, pending, challenge, proof)
66
+ end
67
+ contents.freeze
68
+ rescue KeyError, TypeError, JSON::ParserError, JSON::GeneratorError, Ace::Runtime::RuntimeUnavailableError
69
+ raise AttemptErrors::ReceiptRejected, "cleanup original evidence is unavailable"
70
+ end
71
+
72
+ def cleanup_documents!(contents, record, no_effect:, challenge:)
73
+ documents = contents.map { |bytes| cleanup_json!(bytes) }
74
+ selection_schema = no_effect ? ROOT_INSPECTION_SELECTION_SCHEMA : ROOT_SELECTION_SCHEMA
75
+ selection_fields = no_effect ? ROOT_INSPECTION_SELECTION_FIELDS : ROOT_SELECTION_FIELDS
76
+ selection_index = documents.each_index.select { |index| documents[index]["schema"] == selection_schema }
77
+ receipt_index = documents.each_index.select do |index|
78
+ no_effect ? documents[index].keys.sort == ServiceEvidence::INSPECTION_FIELDS.sort : documents[index]["schema"] == ROOT_RECEIPT_SCHEMA
79
+ end
80
+ unless selection_index.one? && receipt_index.one?
81
+ raise AttemptErrors::ReceiptRejected, "cleanup original evidence selection is ambiguous"
82
+ end
83
+ selection = documents.fetch(selection_index.first)
84
+ receipt = documents.fetch(receipt_index.first)
85
+ closed_cleanup!(selection, selection_fields)
86
+ closed_cleanup!(receipt, no_effect ? ServiceEvidence::INSPECTION_FIELDS : ROOT_RECEIPT_FIELDS)
87
+ unless [selection, receipt].all? { |value| value["request_id"] == record.fetch("request_id") && value["input_digest"] == record.fetch("input_digest") } &&
88
+ selection["operation_owner_binding_digest"] == Atoms::EvidenceDigest.digest(record.fetch("operation_owner_binding"))
89
+ raise AttemptErrors::ReceiptRejected, "cleanup original owner or request differs"
90
+ end
91
+ reference = selection.fetch(no_effect ? "inspection_ref" : "receipt_ref")
92
+ closed_cleanup!(reference, %w[path bytes sha256])
93
+ path = reference.fetch("path")
94
+ operation_bytes = contents.fetch(receipt_index.first)
95
+ unless path.is_a?(String) && path.bytesize <= 4096 && path.valid_encoding? && !path.include?("\0") &&
96
+ path.start_with?("/") && File.expand_path(path) == path &&
97
+ reference["bytes"].is_a?(Integer) && reference["bytes"].between?(1, 65_536) &&
98
+ reference["bytes"] == operation_bytes.bytesize && reference["sha256"] == Digest::SHA256.hexdigest(operation_bytes)
99
+ raise AttemptErrors::ReceiptRejected, "cleanup retained result reference differs"
100
+ end
101
+ if no_effect
102
+ inspection_projection!(receipt, record, challenge)
103
+ else
104
+ cleanup_receipt_projection!(receipt, record)
105
+ end
106
+ [reference, operation_bytes]
107
+ end
108
+
109
+ def cleanup_json!(bytes)
110
+ unless bytes.is_a?(String) && bytes.bytesize.between?(1, 65_536)
111
+ raise AttemptErrors::ReceiptRejected, "cleanup evidence exceeds bounds"
112
+ end
113
+ text = bytes.dup.force_encoding(Encoding::UTF_8)
114
+ raise AttemptErrors::ReceiptRejected, "cleanup evidence is invalid UTF8" unless text.valid_encoding? && !text.include?("\0")
115
+ value = JSON.parse(text, create_additions: false, max_nesting: 16, allow_duplicate_key: false, allow_comments: false)
116
+ raise AttemptErrors::ReceiptRejected, "cleanup evidence is not an object" unless value.is_a?(Hash)
117
+ value
118
+ end
119
+
120
+ def closed_cleanup!(value, fields)
121
+ unless value.is_a?(Hash) && value.keys.sort == fields.sort
122
+ raise AttemptErrors::ReceiptRejected, "cleanup evidence fields differ"
123
+ end
124
+ end
125
+
126
+ def cleanup_receipt_projection!(receipt, record)
127
+ preservation = receipt.fetch("preservation")
128
+ closed_cleanup!(preservation, %w[head branch destinations manifest_sha256 inventory_sha256 file_count total_bytes private_manifest_sha256 archives_sha256])
129
+ unless preservation["file_count"].is_a?(Integer) && preservation["file_count"].between?(0, 4096) &&
130
+ preservation["total_bytes"].is_a?(Integer) && preservation["total_bytes"].between?(0, 256 * 1024 * 1024) &&
131
+ %w[manifest_sha256 inventory_sha256 private_manifest_sha256 archives_sha256].all? { |key| cleanup_sha?(preservation[key]) } &&
132
+ preservation.values_at("manifest_sha256", "inventory_sha256", "private_manifest_sha256").uniq.one?
133
+ raise AttemptErrors::ReceiptRejected, "cleanup preservation observation differs"
134
+ end
135
+ input = receipt.slice("maintenance", "target", "publication").merge("schema" => "ace.protected-workspace-prune/v1",
136
+ "preservation" => preservation.slice("head", "branch", "destinations", "manifest_sha256"))
137
+ unless Atoms::EvidenceDigest.digest(input) == record.fetch("input_digest") &&
138
+ receipt.fetch("maintenance") == record.slice("project_id", "mapping_id", "assignment_id", "attempt_id")
139
+ raise AttemptErrors::ReceiptRejected, "cleanup original input differs"
140
+ end
141
+ snapshots = receipt.fetch("canonical_snapshots")
142
+ unless snapshots.is_a?(Array) && snapshots.size.between?(1, 256)
143
+ raise AttemptErrors::ReceiptRejected, "cleanup canonical snapshots exceed bounds"
144
+ end
145
+ snapshots.each do |snapshot|
146
+ closed_cleanup!(snapshot, %w[mapping_id journal_commit binding_event_digest release_event_digest proof_event_digest])
147
+ unless snapshot["mapping_id"].is_a?(String) && snapshot["mapping_id"].match?(Molecules::JournalMutation::ID) &&
148
+ snapshot["journal_commit"].is_a?(String) && snapshot["journal_commit"].match?(/\A(?:[0-9a-f]{40}|[0-9a-f]{64})\z/) &&
149
+ %w[binding_event_digest release_event_digest proof_event_digest].all? { |key| cleanup_sha?(snapshot[key]) }
150
+ raise AttemptErrors::ReceiptRejected, "cleanup canonical snapshot differs"
151
+ end
152
+ end
153
+ ids = snapshots.map { |snapshot| snapshot.fetch("mapping_id") }
154
+ raise AttemptErrors::ReceiptRejected, "cleanup canonical snapshots are not uniquely ordered" unless ids == ids.uniq.sort
155
+ removal = receipt.fetch("removal")
156
+ closed_cleanup!(removal, %w[original captured outcome fence_digest])
157
+ unless removal["outcome"] == "removed" && cleanup_sha?(removal["fence_digest"])
158
+ raise AttemptErrors::ReceiptRejected, "cleanup has no positive removal observation"
159
+ end
160
+ %w[original captured].each do |key|
161
+ object = removal.fetch(key)
162
+ closed_cleanup!(object, %w[device inode worktree_admin_id head branch])
163
+ unless %w[device inode].all? { |field| object[field].is_a?(Integer) && object[field].positive? } &&
164
+ object["worktree_admin_id"].is_a?(String) && object["worktree_admin_id"].bytesize.between?(1, 200) &&
165
+ object["worktree_admin_id"].match?(/\A[a-zA-Z0-9_.-]+\z/) &&
166
+ object["head"] == preservation["head"] && object["branch"] == preservation["branch"]
167
+ raise AttemptErrors::ReceiptRejected, "cleanup captured object differs"
168
+ end
169
+ end
170
+ unless removal.fetch("original") == removal.fetch("captured")
171
+ raise AttemptErrors::ReceiptRejected, "cleanup replaced object cannot attest removal"
172
+ end
173
+ true
174
+ end
175
+
176
+ def cleanup_sha?(value) = value.is_a?(String) && value.match?(/\A[0-9a-f]{64}\z/)
177
+ end
178
+ end
179
+ end
180
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+ require "ace/git/atoms/service_merge_evidence"
3
+ require "ace/git/atoms/service_pr_evidence"
4
+ require_relative "service_evidence"
5
+
6
+ module Ace
7
+ module Assign
8
+ module Authority
9
+ # The existing service completion/import transaction owns delivery writes.
10
+ class ServiceDeliveryEvidence
11
+ def initialize(journal:)
12
+ @journal = journal
13
+ end
14
+
15
+ def completion_event(record:, receipt:, completion_digest:, artifacts:)
16
+ unless artifacts.length == 1
17
+ raise AttemptErrors::ReceiptRejected, "merge completion requires its one fixed evidence artifact"
18
+ end
19
+ proof = validate_artifact!(record, artifacts.first)
20
+ {type: "delivery", payload: payload(record, receipt, completion_digest, proof)}
21
+ end
22
+
23
+ def verified(record:, events:, commit:)
24
+ unless %w[create update ready merge].include?(record["operation"]) && record["state"] == "succeeded" &&
25
+ record.fetch("receipt").fetch("evidence").length == 1
26
+ raise AttemptErrors::EvidenceUnavailable, "canonical merge completion is unavailable"
27
+ end
28
+ receipt = record.fetch("receipt")
29
+ reference = receipt.fetch("evidence").first
30
+ bytes = Molecules::CanonicalEvidence.new(journal: @journal).read(reference,
31
+ **ServiceEvidence.new(journal: @journal).context(record), commit: commit)
32
+ expected = payload(record, receipt, record.fetch("completion_digest"), validate_artifact!(record, bytes))
33
+ results = events.select { |event| event["type"] == "delivery" && event.dig("payload", "service_request_id") == record.fetch("request_id") }
34
+ unless results.one? && Atoms::EvidenceDigest.digest(results.first.fetch("payload")) == Atoms::EvidenceDigest.digest(expected)
35
+ raise AttemptErrors::EvidenceUnavailable, "canonical merge delivery result differs"
36
+ end
37
+ transitions = events.select { |event| event["type"] == "service_transition" &&
38
+ event.dig("payload", "request_id") == record.fetch("request_id") && event.dig("payload", "state") == "succeeded" &&
39
+ event.dig("payload", "record_digest") == Atoms::EvidenceDigest.digest(record) &&
40
+ event.dig("payload", "receipt_digest") == Atoms::EvidenceDigest.digest(receipt) }
41
+ imports = events.select { |event| event["type"] == "evidence_import" &&
42
+ "evidence/imports/#{event.dig('payload', 'artifact_id')}" == reference.fetch("ref") }
43
+ unless transitions.one? && imports.one?
44
+ raise AttemptErrors::EvidenceUnavailable, "canonical merge completion introduction differs"
45
+ end
46
+ # The first completion's authority mutation immediately follows its
47
+ # terminal transition. Later identical completion observations may
48
+ # have their own mutation IDs; they cannot replace this introduction.
49
+ completion = events[events.index(transitions.first) + 1]
50
+ unless completion && completion["type"] == "authority_mutation" &&
51
+ completion.dig("payload", "operation") == "complete_service" &&
52
+ completion.dig("payload", "data", "request_id") == record.fetch("request_id")
53
+ raise AttemptErrors::EvidenceUnavailable, "original merge completion mutation differs"
54
+ end
55
+ selected = [results.first, transitions.first, completion, imports.first]
56
+ introductions = @journal.event_commits!(assignment_id: record.fetch("assignment_id"),
57
+ event_digests: selected.map { |event| event.fetch("digest") }, commit: commit)
58
+ unless introductions.values.uniq.length == 1
59
+ raise AttemptErrors::EvidenceUnavailable, "merge delivery result was not atomically imported and completed"
60
+ end
61
+ results.first
62
+ rescue KeyError, TypeError, ArgumentError, AttemptErrors::ReceiptRejected
63
+ raise AttemptErrors::EvidenceUnavailable, "canonical merge delivery evidence is unverifiable"
64
+ end
65
+
66
+ private
67
+
68
+ def validate_artifact!(record, bytes)
69
+ options = {request_id: record.fetch("request_id"), input_digest: record.fetch("input_digest"),
70
+ target: record.fetch("target"), head: record.fetch("candidate_head")}
71
+ proof = if record.fetch("operation") == "merge"
72
+ Ace::Git::Atoms::ServiceMergeEvidence.validate(bytes, **options)
73
+ else
74
+ Ace::Git::Atoms::ServicePrEvidence.validate(bytes, operation: record.fetch("operation"), **options)
75
+ end
76
+ unless Atoms::EvidenceDigest.digest(proof.fetch(:input)) == record.fetch("input_digest")
77
+ raise AttemptErrors::ReceiptRejected, "merge evidence does not reconstruct the originally accepted input"
78
+ end
79
+ proof
80
+ rescue ArgumentError, KeyError
81
+ raise AttemptErrors::ReceiptRejected, "merge evidence original input or outcome differs"
82
+ end
83
+
84
+ def payload(record, receipt, completion_digest, proof)
85
+ self.class.payload(record, receipt, completion_digest, proof)
86
+ end
87
+
88
+ # Pure result shape shared with the read-only worker consumer. This
89
+ # function creates no evidence or authorization on its own.
90
+ def self.payload(record, receipt, completion_digest, proof)
91
+ {"stage" => "result", "operation" => record.fetch("operation"), "head" => record.fetch("candidate_head"), "outcome" => "succeeded",
92
+ "intent_digest" => nil, "intent_attempt_id" => nil,
93
+ "pr" => proof.fetch(:pr).slice("number", "url", "head_sha", "draft", "state", "merge_commit_sha"),
94
+ "service_request_id" => record.fetch("request_id"), "service_completion_digest" => completion_digest,
95
+ "service_receipt_digest" => Atoms::EvidenceDigest.digest(receipt), "service_evidence" => receipt.fetch("evidence"),
96
+ "producer" => {"actor" => "service:#{record.fetch('executor_uid')}", "role" => "service", "runtime" => "herdr"}}
97
+ end
98
+ end
99
+ end
100
+ end
101
+ end