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,232 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Assign
5
+ module Authority
6
+ class Endcap
7
+ # Internal R1 dependency. The fixed authority consumer supplies the
8
+ # original expected linkage; no metadata document can attest execution.
9
+ # Raw imported artifacts are read at this same immutable prefix.
10
+ def with_campaign_execution_evidence!(journal:, commit:, params:, map:, receipt_digest:, execution_binding:)
11
+ raise ArgumentError, "campaign evidence requires a block" unless block_given?
12
+ begin
13
+ row = @launch.completion_terminal!(journal: journal, commit: commit, params: params, map: map)
14
+ unless row["canonical_state"] == "succeeded" && row["reservation_release_event_id"]
15
+ raise AttemptErrors::EvidenceUnavailable, "campaign execution is not succeeded and released"
16
+ end
17
+ definition = @launch.preview_attempt_definition!(journal: journal, commit: commit, params: params, map: map)
18
+ execution = definition.campaign_execution
19
+ raise AttemptErrors::EvidenceUnavailable, "campaign execution linkage is missing" unless execution
20
+ binding = execution.merge(params.slice("assignment_id", "attempt_id"),
21
+ "definition_digest" => row.fetch("definition_digest"), "binding_digest" => row.fetch("original_binding_digest"))
22
+ unless binding == execution_binding && binding["binding_digest"].is_a?(String)
23
+ raise AttemptErrors::EvidenceUnavailable, "campaign execution original linkage differs"
24
+ end
25
+ inventory = journal.canonical_event_inventory!(commit: commit)
26
+ accepted_commit = inventory.fetch("introductions").fetch(params.fetch("assignment_id"))
27
+ .fetch(row.fetch("terminal_event_id"))
28
+ events = @launch.preview_attempt_events!(journal: journal, commit: accepted_commit, params: params, map: map)
29
+ results = events.select { |event| event["type"] == "result_submitted" &&
30
+ event.dig("payload", "receipt_digest") == receipt_digest }
31
+ raise AttemptErrors::EvidenceUnavailable, "campaign execution result is ambiguous" unless results.one?
32
+ submitted = results.first.fetch("payload")
33
+ selected = params.merge(submitted.fetch("binding").slice("head", "candidate_generation"),
34
+ "result_id" => submitted.fetch("result_id"))
35
+ current = exact_candidate!(retained_candidate(events, selected.fetch("candidate_generation")), selected)
36
+ result = verified_result(journal, events, selected, map, retained_origin(events, selected), current, accepted_commit,
37
+ result_id: selected.fetch("result_id"))
38
+ unless result && result["receipt_digest"] == receipt_digest && result.dig("receipt", "verdict") == "succeeded"
39
+ raise AttemptErrors::EvidenceUnavailable, "campaign execution accepted receipt differs"
40
+ end
41
+ terminal = events.find { |event| event["digest"] == row.fetch("terminal_event_id") }
42
+ unless terminal && terminal["type"] == "receipt_accepted" && terminal.dig("payload", "receipt") == result.fetch("receipt")
43
+ raise AttemptErrors::EvidenceUnavailable, "campaign execution terminal receipt differs"
44
+ end
45
+ accepted = approved_review!(journal, events, selected, map, current, commit: accepted_commit)
46
+ campaign_finished_result!(journal: journal, commit: accepted_commit, events: events, params: selected, map: map,
47
+ result: result, accepted: accepted)
48
+ proof = result.fetch("receipt").merge("attempt_id" => params.fetch("attempt_id"),
49
+ "receipt_digest" => receipt_digest, "execution_binding" => binding, "journal_commit" => accepted_commit)
50
+ freeze_campaign_evidence!(proof)
51
+ rescue KeyError, TypeError, ArgumentError, AttemptErrors::ReceiptRejected
52
+ raise AttemptErrors::EvidenceUnavailable, "campaign execution evidence is unavailable"
53
+ end
54
+ active = true
55
+ thread = Thread.current
56
+ canonical = Molecules::CanonicalEvidence.new(journal: journal)
57
+ reader = ->(artifact) {
58
+ unless active && Thread.current.equal?(thread) && result.fetch("artifacts").include?(artifact)
59
+ raise AttemptErrors::EvidenceUnavailable, "campaign artifact read escaped its original evidence scope"
60
+ end
61
+ canonical.read({"ref" => artifact.fetch("path"), "sha256" => artifact.fetch("sha256")},
62
+ **result_context(result.fetch("binding")), commit: accepted_commit)
63
+ }
64
+ yield proof, reader
65
+ ensure
66
+ active = false
67
+ end
68
+
69
+ private
70
+
71
+ def campaign_execution_dependencies!(journal:, commit:, parent_params:, parent_map:)
72
+ # This closure belongs to one held original exclusion and immutable
73
+ # journal selection. Never retained across RPCs. Live/original source
74
+ # parent checks below still run on every callback; only fully verified raw
75
+ # canonical artifacts and their introduced-prefix proof are reused.
76
+ verified = {}
77
+ selected_rows = {}
78
+ inventory = nil
79
+ read = lambda do |reference, phase:, head:, artifacts: nil, name: nil, producer: nil, reviewer: nil, historical: false|
80
+ unless reference.is_a?(Hash) && reference.keys.sort == %w[attempt_id digest] &&
81
+ reference["digest"].is_a?(String) && reference["digest"].match?(DIGEST)
82
+ raise AttemptErrors::EvidenceUnavailable, "campaign child receipt reference differs"
83
+ end
84
+ selection_key = Atoms::EvidenceDigest.digest("commit" => commit, "reference" => reference,
85
+ "phase" => phase, "head" => head, "name" => name)
86
+ params, map, execution, row = selected_rows[selection_key] ||= begin
87
+ inventory ||= journal.canonical_event_inventory!(commit: commit)
88
+ matches = inventory.fetch("events").filter_map do |assignment, events|
89
+ next unless events.any? { |event| event["attempt_id"] == reference["attempt_id"] }
90
+ registration = events.reverse.find { |event| event.dig("payload", "operation") == "register_assignment" }&.dig("payload", "data")
91
+ next unless registration
92
+ descriptor = @launch.send(:control_original_descriptor!, registration.fetch("lifecycle_control"))
93
+ child_map = descriptor.mapping(registration.fetch("mapping_id"))
94
+ params = {"assignment_id" => assignment, "attempt_id" => reference.fetch("attempt_id"),
95
+ "mapping_id" => registration.fetch("mapping_id")}
96
+ definition = @launch.preview_attempt_definition!(journal: journal, commit: commit, params: params, map: child_map)
97
+ execution = definition.campaign_execution
98
+ next unless execution && execution.values_at("parent_assignment_id", "parent_attempt_id", "phase", "head") ==
99
+ parent_params.values_at("assignment_id", "attempt_id") + [phase, head]
100
+ next unless child_map["project_id"] == parent_map.fetch("project_id")
101
+ next if name && execution["check_name"] != name
102
+ [params, child_map, execution]
103
+ end
104
+ raise AttemptErrors::EvidenceUnavailable, "campaign child receipt linkage is ambiguous" unless matches.one?
105
+ params, map, execution = matches.first
106
+ row = @launch.completion_terminal!(journal: journal, commit: commit, params: params, map: map)
107
+ [params.freeze, map, freeze_campaign_evidence!(JSON.parse(JSON.generate(execution))),
108
+ freeze_campaign_evidence!(JSON.parse(JSON.generate(row)))]
109
+ end
110
+ binding = execution.merge(params.slice("assignment_id", "attempt_id"),
111
+ "definition_digest" => row.fetch("definition_digest"), "binding_digest" => row.fetch("original_binding_digest"))
112
+ unless historical
113
+ campaign_parent_candidate!(journal: journal, commit: commit, params: parent_params.merge(
114
+ "scope" => execution.fetch("parent_scope"), "head" => head, "base" => execution.fetch("base"),
115
+ "candidate_generation" => execution.fetch("parent_candidate_generation")), map: parent_map)
116
+ end
117
+ key = Atoms::EvidenceDigest.digest("journal" => journal.object_id, "commit" => commit,
118
+ "binding" => binding, "receipt" => reference, "phase" => phase, "head" => head,
119
+ "name" => name, "producer" => producer, "reviewer" => reviewer, "artifacts" => artifacts)
120
+ proof = verified[key] ||= with_campaign_execution_evidence!(journal: journal, commit: commit, params: params, map: map,
121
+ receipt_digest: reference.fetch("digest"), execution_binding: binding) do |canonical, reader|
122
+ canonical.fetch("artifacts").each do |artifact|
123
+ unless Digest::SHA256.hexdigest(reader.call(artifact)) == artifact.fetch("sha256")
124
+ raise AttemptErrors::EvidenceUnavailable, "canonical campaign artifact bytes differ"
125
+ end
126
+ end
127
+ freeze_campaign_evidence!(JSON.parse(JSON.generate(canonical)))
128
+ end
129
+ if producer && (proof.dig("producer", "actor") != producer || proof.dig("review", "reviewer", "actor") != reviewer)
130
+ raise AttemptErrors::EvidenceUnavailable, "campaign approval original actors differ"
131
+ end
132
+ if artifacts && artifacts.any? { |artifact| !proof.fetch("artifacts").any? { |ref| ref["sha256"] == artifact.fetch("sha256") } }
133
+ raise AttemptErrors::EvidenceUnavailable, "campaign artifact differs from canonical child bytes"
134
+ end
135
+ proof.merge("artifacts" => artifacts || proof.fetch("artifacts"), "historical" => historical)
136
+ end
137
+ {check_evidence: ->(reference, head:, name:, historical: false) {
138
+ read.call(reference, phase: "check", head: head, name: name, historical: historical)
139
+ }, review_evidence: ->(reference, head:, artifacts:, historical: false) {
140
+ read.call(reference, phase: "collection", head: head, artifacts: artifacts, historical: historical)
141
+ }, approval_evidence: ->(reference, head:, artifacts:, producer:, reviewer:, historical: false) {
142
+ read.call(reference, phase: "approval", head: head, artifacts: artifacts, producer: producer, reviewer: reviewer, historical: historical)
143
+ }}.freeze
144
+ end
145
+
146
+ def freeze_campaign_evidence!(value)
147
+ case value
148
+ when Hash then value.each { |key, item| freeze_campaign_evidence!(key); freeze_campaign_evidence!(item) }
149
+ when Array then value.each { |item| freeze_campaign_evidence!(item) }
150
+ end
151
+ value.freeze
152
+ end
153
+
154
+ # The ordinary result is still campaign-free. Its phase comes only
155
+ # from the authenticated original registered child definition.
156
+ def campaign_child_result!(journal:, commit:, events:, params:, map:, receipt:)
157
+ definition = @launch.preview_attempt_definition!(journal: journal, commit: commit,
158
+ params: params, map: map)
159
+ execution = definition.campaign_execution
160
+ return unless execution
161
+ CampaignExecution.validate!(execution, parent: definition.parent,
162
+ policy: campaign_original_parent_policy!(journal, commit, execution, map))
163
+ intent = events.find { |event| event["type"] == "intent" }&.fetch("payload")
164
+ CampaignExecution.validate_result!(execution, receipt: receipt, base: intent&.fetch("base_head"))
165
+ execution
166
+ rescue KeyError, TypeError, ArgumentError
167
+ raise AttemptErrors::ReceiptRejected, "campaign child result binding is incomplete"
168
+ end
169
+
170
+ def campaign_finished_result!(journal:, commit:, events:, params:, map:, result:, accepted:)
171
+ execution = campaign_child_result!(journal: journal, commit: commit, events: events, params: params, map: map,
172
+ receipt: result.fetch("receipt"))
173
+ return unless execution
174
+ CampaignExecution.validate_finished_review!(execution, receipt: result.fetch("receipt"),
175
+ accepted_review: accepted.fetch("review_receipt"))
176
+ rescue KeyError, ArgumentError, TypeError
177
+ raise AttemptErrors::ReceiptRejected, "campaign independent review differs from executed result"
178
+ end
179
+
180
+ def campaign_original_parent_policy!(journal, commit, execution, map)
181
+ campaign_original_parent_context!(journal, commit, execution, map).fetch(:selected).fetch("policy")
182
+ end
183
+
184
+ def campaign_original_parent_context!(journal, commit, execution, map)
185
+ parent = {"assignment_id" => execution.fetch("parent_assignment_id"),
186
+ "attempt_id" => execution.fetch("parent_attempt_id")}
187
+ inventory = journal.canonical_event_inventory!(commit: commit)
188
+ events = inventory.fetch("events").fetch(parent.fetch("assignment_id"))
189
+ reservations = events.select { |event| event["attempt_id"] == parent.fetch("attempt_id") &&
190
+ event.dig("payload", "operation") == "reserve_attempt" }
191
+ raise AttemptErrors::EvidenceUnavailable, "campaign parent original reservation differs" unless reservations.one?
192
+ parent["mapping_id"] = reservations.first.dig("payload", "data", "mapping_id")
193
+ prefix = inventory.fetch("introductions").fetch(parent.fetch("assignment_id"))
194
+ .fetch(reservations.first.fetch("digest"))
195
+ registration = @launch.send(:definition, journal, parent.fetch("assignment_id"), commit: prefix)
196
+ descriptor = @launch.send(:control_original_descriptor!, registration.fetch("lifecycle_control"))
197
+ original = @launch.send(:control_registration_context!, parent,
198
+ descriptor.mapping(parent.fetch("mapping_id")), journal, commit: commit)
199
+ unless original.fetch(:map).fetch("project_id") == map.fetch("project_id")
200
+ raise AttemptErrors::EvidenceUnavailable, "campaign child original project differs"
201
+ end
202
+ definition = @launch.preview_attempt_definition!(journal: journal, commit: commit,
203
+ params: parent, map: original.fetch(:map))
204
+ selected = CampaignExecution.validate_parent!(definition.review_campaign)
205
+ unless selected.values_at("campaign_id", "subject", "contract_identity") ==
206
+ execution.values_at("campaign_id", "subject", "contract_identity")
207
+ raise AttemptErrors::EvidenceUnavailable, "campaign child original parent differs"
208
+ end
209
+ {params: parent, map: original.fetch(:map), definition: definition, selected: selected}.freeze
210
+ end
211
+
212
+ def campaign_prepared_candidate!(journal:, commit:, execution:, map:)
213
+ original = campaign_original_parent_context!(journal, commit, execution, map)
214
+ CampaignExecution.validate!(execution, parent: original.fetch(:definition).id,
215
+ policy: original.fetch(:selected).fetch("policy"))
216
+ params = original.fetch(:params).merge("scope" => execution.fetch("parent_scope"),
217
+ "base" => execution.fetch("base"), "head" => execution.fetch("head"),
218
+ "candidate_generation" => execution.fetch("parent_candidate_generation"))
219
+ campaign_parent_candidate!(journal: journal, commit: commit, params: params, map: original.fetch(:map))
220
+ events = @launch.preview_attempt_events!(journal: journal, commit: commit, params: params, map: original.fetch(:map))
221
+ selected = exact_candidate!(candidate(events), params)
222
+ bytes = journal.bounded_blob(selected.fetch("bundle_ref"), commit: commit, max_bytes: CandidateTransfer::MAX_BYTES)
223
+ unless bytes.is_a?(String) && bytes.bytesize == selected.fetch("bytes") &&
224
+ Digest::SHA256.hexdigest(bytes) == selected.fetch("sha256")
225
+ raise AttemptErrors::EvidenceUnavailable, "campaign original candidate bundle differs"
226
+ end
227
+ [selected.slice("head", "tree", "bytes", "sha256", "candidate_generation").merge("campaign_execution" => execution), bytes]
228
+ end
229
+ end
230
+ end
231
+ end
232
+ end
@@ -0,0 +1,266 @@
1
+ # frozen_string_literal: true
2
+ require_relative "campaign_round_transfer"
3
+
4
+ module Ace
5
+ module Assign
6
+ module Authority
7
+ class Endcap
8
+ private
9
+
10
+ def campaign_round_request!(request)
11
+ params = request.fetch("params")
12
+ unless params.is_a?(Hash) && params.keys.sort == PARAMETERS.fetch("campaign_record_round").sort &&
13
+ params["head"].is_a?(String) && params["head"].match?(CandidateTransfer::SHA) &&
14
+ params["candidate_generation"].is_a?(Integer) && params["candidate_generation"].positive? &&
15
+ params["input_sha256"].is_a?(String) && params["input_sha256"].match?(DIGEST)
16
+ raise ArgumentError, "campaign round request differs"
17
+ end
18
+ %w[mapping_id assignment_id attempt_id].each { |key| result_id!(params.fetch(key)) }
19
+ result_id!(request.fetch("mutation_id"))
20
+ map = @deployment.mapping(params.fetch("mapping_id"))
21
+ raise AttemptErrors::UnauthorizedIdentity, "campaign project differs" unless map["project_id"] == request.fetch("project_id")
22
+ [params, map]
23
+ end
24
+
25
+ def authorize_campaign_round!(request:, peer:, role:)
26
+ params, map = campaign_round_request!(request)
27
+ @launch.with_assignment(params: params, map: map) do |journal, registration|
28
+ protected_journal!(journal)
29
+ descriptor = @launch.send(:control_original_descriptor!, registration.fetch("lifecycle_control"))
30
+ original_map = descriptor.mapping(params.fetch("mapping_id"))
31
+ events = @launch.preview_attempt_events!(journal: journal, commit: journal.ref_value, params: params, map: original_map)
32
+ finish_identity!(events, params, original_map, peer, role)
33
+ definition = @launch.preview_attempt_definition!(journal: journal, commit: journal.ref_value, params: params, map: original_map)
34
+ CampaignExecution.validate_parent!(definition.review_campaign)
35
+ end
36
+ true
37
+ end
38
+
39
+ def with_campaign_receipt!(journal:, commit:, events:, params:, map:, receipt:, result:, historical:)
40
+ unless result.is_a?(Hash) && result["schema"] == "ace.review.accepted-result/v1" &&
41
+ receipt["campaign"].is_a?(Hash) && receipt["campaign"].keys.sort == %w[id result] &&
42
+ receipt["operation"] == "review" && receipt["verdict"] == "succeeded"
43
+ raise AttemptErrors::ReceiptRejected, "parent campaign receipt differs"
44
+ end
45
+ context = @launch.send(:control_registration_context!, params, map, journal, commit: commit)
46
+ original_map = context.fetch(:map)
47
+ descriptor = context.fetch(:descriptor)
48
+ selected = CampaignExecution.validate_parent!(@launch.preview_attempt_definition!(journal: journal,
49
+ commit: commit, params: params, map: original_map).review_campaign)
50
+ unless receipt.dig("campaign", "id") == selected.fetch("campaign_id") && result["campaign_id"] == selected.fetch("campaign_id")
51
+ raise AttemptErrors::ReceiptRejected, "parent campaign identity differs"
52
+ end
53
+ origin = retained_origin(events, params)
54
+ parent = params.merge("scope" => origin.fetch("scope"), "base" => origin.fetch("base_head"), "head" => receipt.fetch("head"))
55
+ current = exact_candidate!(retained_candidate(events, params.fetch("candidate_generation")), parent)
56
+ project = descriptor.project(original_map.fetch("project_id"))
57
+ root = File.join(project.fetch("campaign_repository"), ".ace-local", "review", "candidate-views")
58
+ FileUtils.mkdir_p(root, mode: 0o700)
59
+ bundle = journal.bounded_blob(current.fetch("bundle_ref"), commit: commit, max_bytes: CandidateTransfer::MAX_BYTES)
60
+ CandidateTransfer.new(root: root).with_campaign_repository(bytes: bundle, sha256: current.fetch("sha256"),
61
+ size: current.fetch("bytes"), head: current.fetch("head"), tree: current.fetch("tree"), base: parent.fetch("base")) do |candidate_reader|
62
+ manager = @launch.send(:campaign_manager_for!, context,
63
+ **campaign_execution_dependencies!(journal: journal, commit: commit, parent_params: parent, parent_map: original_map),
64
+ candidate_reader: candidate_reader)
65
+ binding = selected.slice("subject", "contract_identity", "policy").transform_keys(&:to_sym).merge(
66
+ result: result, head: receipt.fetch("head"), base: parent.fetch("base"),
67
+ producer: receipt.fetch("producer").fetch("actor"), reviewer: receipt.fetch("review").fetch("reviewer").fetch("actor"))
68
+ if historical
69
+ manager.verify_retained_result!(**binding)
70
+ yield
71
+ else
72
+ deployment_sha = @launch.send(:protected_descriptor_sha256!)
73
+ CampaignConsumerPolicy.new.with(@deployment.project(original_map.fetch("project_id")).fetch("campaign_policy")) do |profiles, held|
74
+ manager.with_verified_result!(**binding, consumer_profiles: profiles) do |_verified, snapshot|
75
+ unless result == snapshot
76
+ raise AttemptErrors::ReceiptRejected, "exported campaign result bytes differ from verified owner"
77
+ end
78
+ held.verify_unchanged!
79
+ campaign_parent_candidate!(journal: journal, commit: commit, params: parent, map: original_map)
80
+ unless @launch.send(:protected_descriptor_sha256!) == deployment_sha
81
+ raise AttemptErrors::Conflict, "campaign consumer policy descriptor advanced"
82
+ end
83
+ value = yield
84
+ held.verify_unchanged!
85
+ unless @launch.send(:protected_descriptor_sha256!) == deployment_sha
86
+ raise AttemptErrors::Conflict, "campaign consumer policy descriptor advanced"
87
+ end
88
+ value
89
+ end
90
+ end
91
+ end
92
+ end
93
+ rescue Ace::Review::Atoms::CampaignContract::Invalid, KeyError, TypeError, ArgumentError
94
+ raise AttemptErrors::ReceiptRejected, "canonical parent campaign evidence is unavailable"
95
+ end
96
+
97
+ def with_campaign_submission!(journal:, commit:, events:, params:, map:, admitted:, replay:)
98
+ receipt = admitted.fetch(:receipt)
99
+ return yield unless receipt["campaign"]
100
+ definition = @launch.preview_attempt_definition!(journal: journal, commit: commit, params: params, map: map)
101
+ unless definition.review_campaign
102
+ raise AttemptErrors::EvidenceUnavailable, "protected campaigns require their canonical owner"
103
+ end
104
+ reference = receipt.fetch("campaign").fetch("result")
105
+ index = receipt.fetch("artifacts").index(reference)
106
+ raise AttemptErrors::ReceiptRejected, "campaign result is not an uploaded artifact" unless index
107
+ result = JSON.parse(admitted.fetch(:artifacts).fetch(index), create_additions: false,
108
+ max_nesting: 64, allow_duplicate_key: false, allow_comments: false)
109
+ with_campaign_receipt!(journal: journal, commit: commit, events: events, params: params, map: map,
110
+ receipt: receipt, result: result, historical: !replay.nil?) { yield }
111
+ rescue JSON::ParserError
112
+ raise AttemptErrors::ReceiptRejected, "campaign result JSON is malformed"
113
+ end
114
+
115
+ # Service callers already retain with_assignment's original control SH
116
+ # and authority mutex through CAS. Round writers take the same keys EX
117
+ # before R1, so this read cannot invert with an admitted round writer.
118
+ def campaign_service_checks!(journal:, commit:, events:, params:, map:, current:)
119
+ definition = @launch.preview_attempt_definition!(journal: journal, commit: commit, params: params, map: map)
120
+ return true unless definition.review_campaign
121
+ parent = params.merge("head" => current.fetch("head"), "candidate_generation" => current.fetch("candidate_generation"))
122
+ result = verified_result(journal, events, parent, map, retained_origin(events, parent), current, commit)
123
+ unless result&.dig("receipt", "campaign") && result.dig("receipt", "verdict") == "succeeded"
124
+ raise AttemptErrors::ReceiptRejected, "ready or merge requires its canonical parent campaign result and executed checks"
125
+ end
126
+ with_campaign_finish!(journal: journal, commit: commit, events: events,
127
+ params: parent.merge("result_id" => result.fetch("result_id")), map: map) { true }
128
+ end
129
+
130
+ def with_campaign_finish!(journal:, commit:, events:, params:, map:)
131
+ record = events.find { |event| event["type"] == "result_submitted" && event.dig("payload", "result_id") == params["result_id"] }
132
+ return yield unless record&.dig("payload", "receipt", "campaign")
133
+ current = exact_candidate!(candidate(events), params)
134
+ payload = verified_result(journal, events, params, map, retained_origin(events, params), current, commit,
135
+ result_id: params.fetch("result_id"))
136
+ reference = payload.fetch("receipt").fetch("campaign").fetch("result")
137
+ canonical = Molecules::CanonicalEvidence.new(journal: journal)
138
+ bytes = canonical.read({"ref" => reference.fetch("path"), "sha256" => reference.fetch("sha256")},
139
+ **result_context(payload.fetch("binding")), commit: commit)
140
+ result = JSON.parse(bytes, create_additions: false, max_nesting: 64, allow_duplicate_key: false, allow_comments: false)
141
+ with_campaign_receipt!(journal: journal, commit: commit, events: events, params: params, map: map,
142
+ receipt: payload.fetch("receipt"), result: result, historical: false) { yield }
143
+ rescue JSON::ParserError
144
+ raise AttemptErrors::ReceiptRejected, "canonical campaign result JSON is malformed"
145
+ end
146
+
147
+ def dispatch_campaign_export(request:, peer:, role:, body: true)
148
+ params = request.fetch("params")
149
+ unless params.is_a?(Hash) && params.keys.sort == PARAMETERS.fetch("campaign_export_result").sort &&
150
+ params["head"].is_a?(String) && params["head"].match?(CandidateTransfer::SHA) &&
151
+ params["candidate_generation"].is_a?(Integer) && params["candidate_generation"].positive? && request["mutation_id"].nil?
152
+ raise ArgumentError, "campaign result selectors differ"
153
+ end
154
+ %w[mapping_id assignment_id attempt_id].each { |key| result_id!(params.fetch(key)) }
155
+ map = @deployment.mapping(params.fetch("mapping_id"))
156
+ raise AttemptErrors::UnauthorizedIdentity, "campaign project differs" unless map["project_id"] == request.fetch("project_id")
157
+ @launch.with_assignment(params: params, map: map, exclusive: true) do |journal, registration|
158
+ protected_journal!(journal)
159
+ commit = journal.ref_value
160
+ descriptor = @launch.send(:control_original_descriptor!, registration.fetch("lifecycle_control"))
161
+ original_map = descriptor.mapping(params.fetch("mapping_id"))
162
+ context = @launch.send(:control_registration_context!, params, original_map, journal, commit: commit)
163
+ events = @launch.preview_attempt_events!(journal: journal, commit: commit, params: params, map: original_map)
164
+ origin = active_origin(events, params)
165
+ role == :worker ? worker_or_launcher!(peer, role, original_map, origin) : finish_identity!(events, params, original_map, peer, role)
166
+ selected = CampaignExecution.validate_parent!(@launch.preview_attempt_definition!(journal: journal,
167
+ commit: commit, params: params, map: original_map).review_campaign)
168
+ return true unless body
169
+ parent = params.merge("scope" => origin.fetch("scope"), "base" => origin.fetch("base_head"))
170
+ campaign_parent_candidate!(journal: journal, commit: commit, params: parent, map: original_map)
171
+ current = exact_candidate!(candidate(events), params)
172
+ bundle = journal.bounded_blob(current.fetch("bundle_ref"), commit: commit, max_bytes: CandidateTransfer::MAX_BYTES)
173
+ project = descriptor.project(original_map.fetch("project_id"))
174
+ root = File.join(project.fetch("campaign_repository"), ".ace-local", "review", "candidate-views")
175
+ FileUtils.mkdir_p(root, mode: 0o700)
176
+ CandidateTransfer.new(root: root).with_campaign_repository(bytes: bundle, sha256: current.fetch("sha256"),
177
+ size: current.fetch("bytes"), head: current.fetch("head"), tree: current.fetch("tree"), base: parent.fetch("base")) do |candidate_reader|
178
+ manager = @launch.send(:campaign_manager_for!, context,
179
+ **campaign_execution_dependencies!(journal: journal, commit: commit, parent_params: parent, parent_map: original_map),
180
+ candidate_reader: candidate_reader)
181
+ result = manager.accepted_result_snapshot(selected.fetch("campaign_id"))
182
+ deployment_sha = @launch.send(:protected_descriptor_sha256!)
183
+ CampaignConsumerPolicy.new.with(@deployment.project(original_map.fetch("project_id")).fetch("campaign_policy")) do |profiles, held|
184
+ manager.with_verified_result!(result: result, **selected.slice("subject", "contract_identity", "policy").transform_keys(&:to_sym),
185
+ head: params.fetch("head"), base: parent.fetch("base"), producer: result.fetch("producer"), reviewer: result.fetch("reviewer"),
186
+ consumer_profiles: profiles) do
187
+ held.verify_unchanged!
188
+ unless @launch.send(:protected_descriptor_sha256!) == deployment_sha
189
+ raise AttemptErrors::Conflict, "campaign export policy descriptor advanced"
190
+ end
191
+ campaign_parent_candidate!(journal: journal, commit: commit, params: parent, map: original_map)
192
+ bytes = JSON.generate(result).b
193
+ raise AttemptErrors::EvidenceUnavailable, "campaign result exceeds artifact bound" if bytes.bytesize > 64 * 1024
194
+ {data: {"campaign_id" => selected.fetch("campaign_id"), "result_identity" => result.fetch("result_identity"),
195
+ "sha256" => Digest::SHA256.hexdigest(bytes), "bytes" => bytes.bytesize, "head" => params.fetch("head"),
196
+ "candidate_generation" => params.fetch("candidate_generation"), "journal_commit" => commit},
197
+ replayed: false, transfer_parts: [bytes]}
198
+ end
199
+ end
200
+ end
201
+ end
202
+ rescue Ace::Review::Atoms::CampaignContract::Invalid, KeyError, TypeError
203
+ raise AttemptErrors::EvidenceUnavailable, "canonical campaign result is unavailable"
204
+ end
205
+
206
+ def dispatch_campaign_round(request:, peer:, role:, transfer:)
207
+ params, map = campaign_round_request!(request)
208
+ admitted = CampaignRoundTransfer.decode(input: transfer, sha256: params.fetch("input_sha256"))
209
+ input = admitted.fetch(:round)
210
+ unless input["attempt_id"] == request.fetch("mutation_id") && input["head"] == params.fetch("head")
211
+ raise AttemptErrors::Conflict, "campaign round stable request or head differs"
212
+ end
213
+ @launch.with_assignment(params: params, map: map, exclusive: true) do |journal, registration|
214
+ protected_journal!(journal)
215
+ commit = journal.ref_value
216
+ descriptor = @launch.send(:control_original_descriptor!, registration.fetch("lifecycle_control"))
217
+ original_map = descriptor.mapping(params.fetch("mapping_id"))
218
+ context = @launch.send(:control_registration_context!, params, original_map, journal, commit: commit)
219
+ events = @launch.preview_attempt_events!(journal: journal, commit: commit, params: params, map: original_map)
220
+ origin = finish_identity!(events, params, original_map, peer, role)
221
+ selected = CampaignExecution.validate_parent!(@launch.preview_attempt_definition!(journal: journal,
222
+ commit: commit, params: params, map: original_map).review_campaign)
223
+ parent_params = params.merge("scope" => origin.fetch("scope"), "base" => input.fetch("base"))
224
+ project = descriptor.project(original_map.fetch("project_id"))
225
+ paths = CampaignRoundTransfer.materialize(admitted: admitted, repository: project.fetch("campaign_repository"),
226
+ request_id: request.fetch("mutation_id"))
227
+ dependencies = campaign_execution_dependencies!(journal: journal, commit: commit,
228
+ parent_params: parent_params, parent_map: original_map)
229
+ current = exact_candidate!(retained_candidate(events, params.fetch("candidate_generation")), params)
230
+ bundle = journal.bounded_blob(current.fetch("bundle_ref"), commit: commit, max_bytes: CandidateTransfer::MAX_BYTES)
231
+ quarantine = File.join(project.fetch("campaign_repository"), ".ace-local", "review", "candidate-views")
232
+ FileUtils.mkdir_p(quarantine, mode: 0o700)
233
+ transfer_owner = CandidateTransfer.new(root: quarantine)
234
+ transfer_owner.with_campaign_repository(bytes: bundle, sha256: current.fetch("sha256"), size: current.fetch("bytes"),
235
+ head: current.fetch("head"), tree: current.fetch("tree"), base: input.fetch("base")) do |candidate_reader|
236
+ manager = @launch.send(:campaign_manager_for!, context, **dependencies, artifact_paths: paths, candidate_reader: candidate_reader)
237
+ deployment_sha = @launch.send(:protected_descriptor_sha256!)
238
+ CampaignConsumerPolicy.new.with(@deployment.project(original_map.fetch("project_id")).fetch("campaign_policy")) do |profiles, held|
239
+ # The existing R1 store checks exact replay before fresh policy,
240
+ # and binds the complete supplied frame in its input_digest.
241
+ data = manager.record_round(selected.fetch("campaign_id"), input, source_digest: admitted.fetch(:sha256),
242
+ expected_campaign: selected.reject { |key, _| key == "version" }, consumer_profiles: profiles,
243
+ before_record: -> {
244
+ held.verify_unchanged!
245
+ campaign_parent_candidate!(journal: journal, commit: commit, params: parent_params, map: original_map)
246
+ unless @launch.send(:protected_descriptor_sha256!) == deployment_sha
247
+ raise AttemptErrors::Conflict, "campaign policy descriptor advanced"
248
+ end
249
+ })
250
+ held.verify_unchanged!
251
+ unless journal.ref_value == commit && @launch.send(:protected_descriptor_sha256!) == deployment_sha
252
+ raise AttemptErrors::Conflict, "campaign round canonical selection advanced"
253
+ end
254
+ reply = data.slice("campaign_id", "recorded_complete", "completed_rounds", "accepted", "result_identity", "replayed")
255
+ .merge("request_id" => request.fetch("mutation_id"), "round_id" => input.fetch("round_id"))
256
+ {data: reply, replayed: data["replayed"] == true}
257
+ end
258
+ end
259
+ end
260
+ rescue Ace::Review::Atoms::CampaignContract::Invalid, KeyError, TypeError
261
+ raise AttemptErrors::EvidenceUnavailable, "canonical campaign round is unavailable"
262
+ end
263
+ end
264
+ end
265
+ end
266
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+ require "ace/herdr/molecules/inbox_context_client"
3
+
4
+ module Ace
5
+ module Assign
6
+ module Authority
7
+ class Endcap
8
+ private
9
+
10
+ def with_inbox_context(params, map, mutation_id:)
11
+ context = @deployment.inbox_context(params.fetch("mapping_id"), params.fetch("inbox_context_id"))
12
+ @deployment.verify_inbox_context!(params.fetch("mapping_id"), params.fetch("inbox_context_id"))
13
+ client = @inbox_context_clients&.fetch(params.fetch("inbox_context_id"))
14
+ client ||= Ace::Herdr::Molecules::InboxContextClient.selected(context_id: params.fetch("inbox_context_id"),
15
+ socket_path: context.fetch("control_socket_path"), owner_credentials: context.fetch("owner_credentials"), kernel: @kernel)
16
+ yield client
17
+ rescue Ace::Runtime::RuntimeUnavailableError, Ace::Herdr::Error
18
+ raise AttemptErrors::EvidenceUnavailable, "protected inbox context is unavailable"
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end