ace-assign 0.67.0 → 0.68.0
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +7 -0
- data/handbook/workflow-instructions/assign/drive.wf.md +39 -0
- data/lib/ace/assign/atoms/fixed_merge_schemas.rb +398 -0
- data/lib/ace/assign/errors.rb +5 -1
- data/lib/ace/assign/models/evidence_event.rb +1 -0
- data/lib/ace/assign/molecules/evidence_journal.rb +281 -10
- data/lib/ace/assign/molecules/fixed_effect_reference.rb +31 -0
- data/lib/ace/assign/molecules/ordinary_merge_cli.rb +68 -0
- data/lib/ace/assign/molecules/ordinary_merge_contract.rb +410 -0
- data/lib/ace/assign/organisms/attempt_coordinator.rb +90 -7
- data/lib/ace/assign/organisms/ordinary_merge_consumer.rb +365 -0
- data/lib/ace/assign/version.rb +1 -1
- data/lib/ace/assign.rb +3 -0
- metadata +7 -2
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
require "uri"
|
|
5
|
+
require "ace/git/atoms/delivery_parameters"
|
|
6
|
+
require_relative "../atoms/evidence_digest"
|
|
7
|
+
require_relative "../atoms/fixed_merge_schemas"
|
|
8
|
+
|
|
9
|
+
module Ace
|
|
10
|
+
module Assign
|
|
11
|
+
module Molecules
|
|
12
|
+
# Closed wire contract for the ordinary fixed merge producer
|
|
13
|
+
# (`8x3.t.xz9.4`) consumed by the qkb.1 ordinary merge consumer through
|
|
14
|
+
# `ace-lab service request/status --mode ordinary-merge`.
|
|
15
|
+
#
|
|
16
|
+
# The consumer invokes and validates the maintained envelopes without
|
|
17
|
+
# translation or flattening. Every validator rejects unknown fields at
|
|
18
|
+
# every nesting level and enforces the published patterns, formats and
|
|
19
|
+
# semantic joins. The terminal receipt stays embedded: request
|
|
20
|
+
# selectors live only under the single `request` object, and succeeded
|
|
21
|
+
# (merged/already_merged) versus proven-no-effect failed are mutually
|
|
22
|
+
# exclusive.
|
|
23
|
+
module OrdinaryMergeContract
|
|
24
|
+
REQUEST_SCHEMA = "ace.lab.service.ordinary-merge-request/v1"
|
|
25
|
+
ACK_SCHEMA = "ace.lab.service.ordinary-merge-ack/v1"
|
|
26
|
+
REFUSAL_SCHEMA = "ace.lab.service.ordinary-merge-refusal/v1"
|
|
27
|
+
STATUS_SCHEMA = "ace.lab.service.ordinary-merge-status/v1"
|
|
28
|
+
STATUS_ERROR_SCHEMA = "ace.lab.service.ordinary-merge-status-error/v1"
|
|
29
|
+
RECEIPT_SCHEMA = "ace.assign.fixed-effect-receipt/v1"
|
|
30
|
+
REVIEW_SCHEMA = "ace.review.acceptance-reference/v1"
|
|
31
|
+
TEST_SCHEMA = "ace.test.execution-reference/v1"
|
|
32
|
+
|
|
33
|
+
REFUSAL_CODES = %w[
|
|
34
|
+
ordinary_merge_invalid_request
|
|
35
|
+
ordinary_merge_unauthenticated
|
|
36
|
+
ordinary_merge_forbidden
|
|
37
|
+
ordinary_merge_selection_mismatch
|
|
38
|
+
ordinary_merge_authorization_invalid
|
|
39
|
+
ordinary_merge_provenance_mismatch
|
|
40
|
+
ordinary_merge_target_changed
|
|
41
|
+
ordinary_merge_evidence_invalid
|
|
42
|
+
ordinary_merge_request_conflict
|
|
43
|
+
ordinary_merge_unavailable
|
|
44
|
+
].freeze
|
|
45
|
+
|
|
46
|
+
# A caller-side blocker: never a proven refusal, never authorization
|
|
47
|
+
# for resubmission. Every other code is an authenticated receiver
|
|
48
|
+
# refusal that proves no effect began.
|
|
49
|
+
UNAVAILABLE_CODE = "ordinary_merge_unavailable"
|
|
50
|
+
PROVEN_NO_EFFECT_CODES = (REFUSAL_CODES - [UNAVAILABLE_CODE]).freeze
|
|
51
|
+
|
|
52
|
+
STATES = %w[pending uncertain succeeded failed].freeze
|
|
53
|
+
TERMINAL_STATES = %w[succeeded failed].freeze
|
|
54
|
+
OUTCOMES = %w[merged already_merged failed_no_effect].freeze
|
|
55
|
+
|
|
56
|
+
HEX64 = /\A[0-9a-f]{64}\z/
|
|
57
|
+
SHA40 = /\A[0-9a-f]{40}\z/
|
|
58
|
+
FORGE_NAME = /\A[A-Za-z0-9][A-Za-z0-9._:-]{0,127}\z/
|
|
59
|
+
DATETIME = /\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})\z/
|
|
60
|
+
|
|
61
|
+
REQUEST_KEYS = %w[schema forge pr_url head_repository head_ref candidate_head
|
|
62
|
+
base_repository base_ref expected_base_head method review_evidence test_evidence].freeze
|
|
63
|
+
REVIEW_KEYS = %w[schema reference reviewer verdict reviewed_head recorded_at record_sha256].freeze
|
|
64
|
+
TEST_KEYS = %w[schema reference candidate_head command result recorded_at receipt_sha256].freeze
|
|
65
|
+
ACK_KEYS = %w[schema type request_id input_digest reference].freeze
|
|
66
|
+
REFUSAL_KEYS = %w[schema code message request_id input_digest].freeze
|
|
67
|
+
STATUS_KEYS = %w[schema state reference request_id input_digest receipt].freeze
|
|
68
|
+
RECEIPT_KEYS = %w[schema reference request_id input_digest terminal_status request
|
|
69
|
+
producer effect remote_observation].freeze
|
|
70
|
+
RECEIPT_REQUEST_KEYS = %w[project assignment attempt queue_entry operation authorization input].freeze
|
|
71
|
+
PRODUCER_KEYS = %w[receiver_id principal configuration_sha256].freeze
|
|
72
|
+
EFFECT_KEYS = %w[started_at finished_at outcome failure_code no_effect_evidence].freeze
|
|
73
|
+
OBSERVATION_KEYS = %w[forge_instance pr_identity_url target_repository_identity target_ref_observed
|
|
74
|
+
merged_candidate_head admission_target_head resulting_target_head merge_commit
|
|
75
|
+
checkout_id worktree_id branch].freeze
|
|
76
|
+
|
|
77
|
+
class << self
|
|
78
|
+
# Validates and returns the closed ordinary-merge request input.
|
|
79
|
+
# Both packages enforce `format` and reject unknown fields at every
|
|
80
|
+
# nesting level; evidence heads must equal the candidate head.
|
|
81
|
+
def validate_request_input!(document)
|
|
82
|
+
exact_object!(document, REQUEST_KEYS, "request input")
|
|
83
|
+
const!(document.fetch("schema"), REQUEST_SCHEMA, "request input schema")
|
|
84
|
+
pattern!(document.fetch("forge"), FORGE_NAME, "forge")
|
|
85
|
+
url!(document.fetch("pr_url"))
|
|
86
|
+
bounded!(document.fetch("head_repository"), 1024, "head_repository")
|
|
87
|
+
bounded!(document.fetch("head_ref"), 255, "head_ref")
|
|
88
|
+
pattern!(document.fetch("candidate_head"), SHA40, "candidate_head")
|
|
89
|
+
bounded!(document.fetch("base_repository"), 1024, "base_repository")
|
|
90
|
+
bounded!(document.fetch("base_ref"), 255, "base_ref")
|
|
91
|
+
pattern!(document.fetch("expected_base_head"), SHA40, "expected_base_head")
|
|
92
|
+
const!(document.fetch("method"), "merge", "method")
|
|
93
|
+
review = document.fetch("review_evidence")
|
|
94
|
+
exact_object!(review, REVIEW_KEYS, "review_evidence")
|
|
95
|
+
const!(review.fetch("schema"), REVIEW_SCHEMA, "review_evidence schema")
|
|
96
|
+
bounded!(review.fetch("reference"), 256, "review_evidence.reference")
|
|
97
|
+
bounded!(review.fetch("reviewer"), 128, "review_evidence.reviewer")
|
|
98
|
+
const!(review.fetch("verdict"), "approved", "review_evidence.verdict")
|
|
99
|
+
pattern!(review.fetch("reviewed_head"), SHA40, "review_evidence.reviewed_head")
|
|
100
|
+
datetime!(review.fetch("recorded_at"), "review_evidence.recorded_at")
|
|
101
|
+
pattern!(review.fetch("record_sha256"), HEX64, "review_evidence.record_sha256")
|
|
102
|
+
tests = document.fetch("test_evidence")
|
|
103
|
+
raise ArgumentError, "request input test_evidence must be a non-empty array" unless tests.is_a?(Array) && !tests.empty?
|
|
104
|
+
seen = []
|
|
105
|
+
tests.each_with_index do |item, index|
|
|
106
|
+
exact_object!(item, TEST_KEYS, "test_evidence[#{index}]")
|
|
107
|
+
const!(item.fetch("schema"), TEST_SCHEMA, "test_evidence[#{index}] schema")
|
|
108
|
+
bounded!(item.fetch("reference"), 256, "test_evidence[#{index}].reference")
|
|
109
|
+
pattern!(item.fetch("candidate_head"), SHA40, "test_evidence[#{index}].candidate_head")
|
|
110
|
+
bounded!(item.fetch("command"), 2048, "test_evidence[#{index}].command")
|
|
111
|
+
const!(item.fetch("result"), "passed", "test_evidence[#{index}].result")
|
|
112
|
+
datetime!(item.fetch("recorded_at"), "test_evidence[#{index}].recorded_at")
|
|
113
|
+
pattern!(item.fetch("receipt_sha256"), HEX64, "test_evidence[#{index}].receipt_sha256")
|
|
114
|
+
entry = Atoms::EvidenceDigest.digest(item)
|
|
115
|
+
raise ArgumentError, "request input test_evidence entries must be unique" if seen.include?(entry)
|
|
116
|
+
seen << entry
|
|
117
|
+
end
|
|
118
|
+
unless review.fetch("reviewed_head") == document.fetch("candidate_head") &&
|
|
119
|
+
tests.all? { |item| item.fetch("candidate_head") == document.fetch("candidate_head") }
|
|
120
|
+
raise ArgumentError, "request input evidence heads must equal the candidate head"
|
|
121
|
+
end
|
|
122
|
+
document
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Canonical lowercase SHA-256 input digest of a validated document.
|
|
126
|
+
# Canonical request digest — spec ruling r4: the shared producer/
|
|
127
|
+
# consumer implementation lives in Atoms::FixedMergeSchemas
|
|
128
|
+
# (canonical_request_digest); this contract deliberately keeps no
|
|
129
|
+
# local digest computation so both packages can never diverge.
|
|
130
|
+
|
|
131
|
+
# Admission acknowledgment: exact keys, exact selector echo, opaque
|
|
132
|
+
# reference with no parseable structure implied.
|
|
133
|
+
def validate_ack!(data, request_id:, input_digest:)
|
|
134
|
+
exact_object!(data, ACK_KEYS, "acknowledgment")
|
|
135
|
+
const!(data.fetch("schema"), ACK_SCHEMA, "acknowledgment schema")
|
|
136
|
+
const!(data.fetch("type"), "accepted", "acknowledgment type")
|
|
137
|
+
selector!(data.fetch("request_id"), "acknowledgment request_id")
|
|
138
|
+
pattern!(data.fetch("input_digest"), HEX64, "acknowledgment input_digest")
|
|
139
|
+
selector!(data.fetch("reference"), "acknowledgment reference")
|
|
140
|
+
unless data.fetch("request_id") == request_id && data.fetch("input_digest") == input_digest
|
|
141
|
+
raise ArgumentError, "acknowledgment selectors differ from the recorded binding"
|
|
142
|
+
end
|
|
143
|
+
data
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Closed outer response envelope: status "ok" carries exactly the
|
|
147
|
+
# data object; status "error" carries exactly the error object. The
|
|
148
|
+
# outer objects reject unknown fields; the returned branch payload
|
|
149
|
+
# is validated by the inner contract validators. Any other outer
|
|
150
|
+
# status or shape is malformed. Returns {status:, payload:}.
|
|
151
|
+
def validate_envelope_branch!(envelope)
|
|
152
|
+
unless envelope.is_a?(Hash) && %w[ok error].include?(envelope["status"])
|
|
153
|
+
raise ArgumentError, "response envelope status must be ok or error"
|
|
154
|
+
end
|
|
155
|
+
if envelope["status"] == "ok"
|
|
156
|
+
exact_object!(envelope, %w[status data], "response envelope")
|
|
157
|
+
payload = envelope.fetch("data")
|
|
158
|
+
else
|
|
159
|
+
exact_object!(envelope, %w[status error], "response envelope")
|
|
160
|
+
payload = envelope.fetch("error")
|
|
161
|
+
end
|
|
162
|
+
raise ArgumentError, "response envelope branch must be a JSON object" unless payload.is_a?(Hash)
|
|
163
|
+
{status: envelope["status"], payload: payload}
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
# Pre-admission refusal envelope. Refusal is request-time only; it
|
|
167
|
+
# never carries a reference and is never a read-only status value.
|
|
168
|
+
def validate_refusal!(error, schema: REFUSAL_SCHEMA)
|
|
169
|
+
exact_object!(error, REFUSAL_KEYS, "refusal")
|
|
170
|
+
const!(error.fetch("schema"), schema, "refusal schema")
|
|
171
|
+
unless REFUSAL_CODES.include?(error.fetch("code"))
|
|
172
|
+
raise ArgumentError, "refusal code is outside the closed set"
|
|
173
|
+
end
|
|
174
|
+
message = error.fetch("message")
|
|
175
|
+
unless message.is_a?(String) && !message.empty? && message.length <= 512
|
|
176
|
+
raise ArgumentError, "refusal message must be a sanitized non-empty string of at most 512 characters"
|
|
177
|
+
end
|
|
178
|
+
selector!(error.fetch("request_id"), "refusal request_id")
|
|
179
|
+
digest = error.fetch("input_digest")
|
|
180
|
+
valid_digest = digest.nil? || (digest.is_a?(String) && digest.match?(HEX64))
|
|
181
|
+
unless valid_digest
|
|
182
|
+
raise ArgumentError, "refusal input_digest must be a lowercase SHA-256 string or null"
|
|
183
|
+
end
|
|
184
|
+
error
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Read-only status success envelope. Reports only pending,
|
|
188
|
+
# uncertain, succeeded or failed; succeeded/failed carry the one
|
|
189
|
+
# embedded terminal receipt, pending/uncertain a null receipt.
|
|
190
|
+
def validate_status!(envelope, reference:, request_id:, input_digest:)
|
|
191
|
+
exact_object!(envelope, %w[status data], "status envelope")
|
|
192
|
+
const!(envelope.fetch("status"), "ok", "status envelope")
|
|
193
|
+
data = envelope.fetch("data")
|
|
194
|
+
exact_object!(data, STATUS_KEYS, "status data")
|
|
195
|
+
const!(data.fetch("schema"), STATUS_SCHEMA, "status schema")
|
|
196
|
+
unless STATES.include?(data.fetch("state"))
|
|
197
|
+
raise ArgumentError, "status state is outside the closed model"
|
|
198
|
+
end
|
|
199
|
+
selector!(data.fetch("reference"), "status reference")
|
|
200
|
+
selector!(data.fetch("request_id"), "status request_id")
|
|
201
|
+
pattern!(data.fetch("input_digest"), HEX64, "status input_digest")
|
|
202
|
+
unless data.fetch("reference") == reference && data.fetch("request_id") == request_id &&
|
|
203
|
+
data.fetch("input_digest") == input_digest
|
|
204
|
+
raise ArgumentError, "status identifiers differ from the recorded binding"
|
|
205
|
+
end
|
|
206
|
+
receipt = data.fetch("receipt")
|
|
207
|
+
if TERMINAL_STATES.include?(data.fetch("state"))
|
|
208
|
+
raise ArgumentError, "terminal status requires the embedded receipt" unless receipt.is_a?(Hash)
|
|
209
|
+
validated = validate_receipt!(receipt)
|
|
210
|
+
raise ArgumentError, "receipt terminal_status differs from the status state" unless validated.fetch("terminal_status") == data.fetch("state")
|
|
211
|
+
else
|
|
212
|
+
raise ArgumentError, "pending/uncertain status must carry a null receipt" unless receipt.nil?
|
|
213
|
+
validated = nil
|
|
214
|
+
end
|
|
215
|
+
{state: data.fetch("state"), receipt: validated}
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# Terminal receipt: embedded request, never flattened. When
|
|
219
|
+
# `binding` is given, every command selector and the complete input
|
|
220
|
+
# document must equal the recorded binding; success additionally
|
|
221
|
+
# joins the remote observation back to the embedded request.
|
|
222
|
+
def validate_receipt!(receipt, binding: nil)
|
|
223
|
+
exact_object!(receipt, RECEIPT_KEYS, "terminal receipt")
|
|
224
|
+
const!(receipt.fetch("schema"), RECEIPT_SCHEMA, "receipt schema")
|
|
225
|
+
selector!(receipt.fetch("reference"), "receipt reference")
|
|
226
|
+
selector!(receipt.fetch("request_id"), "receipt request_id")
|
|
227
|
+
pattern!(receipt.fetch("input_digest"), HEX64, "receipt input_digest")
|
|
228
|
+
unless %w[succeeded failed].include?(receipt.fetch("terminal_status"))
|
|
229
|
+
raise ArgumentError, "receipt terminal_status is outside the closed set"
|
|
230
|
+
end
|
|
231
|
+
request = receipt.fetch("request")
|
|
232
|
+
exact_object!(request, RECEIPT_REQUEST_KEYS, "receipt request")
|
|
233
|
+
bounded!(request.fetch("project"), 128, "receipt request.project")
|
|
234
|
+
bounded!(request.fetch("assignment"), 128, "receipt request.assignment")
|
|
235
|
+
bounded!(request.fetch("attempt"), 128, "receipt request.attempt")
|
|
236
|
+
bounded!(request.fetch("queue_entry"), 128, "receipt request.queue_entry")
|
|
237
|
+
const!(request.fetch("operation"), "merge", "receipt request.operation")
|
|
238
|
+
selector!(request.fetch("authorization"), "receipt request.authorization")
|
|
239
|
+
input = validate_request_input!(request.fetch("input"))
|
|
240
|
+
producer = receipt.fetch("producer")
|
|
241
|
+
exact_object!(producer, PRODUCER_KEYS, "receipt producer")
|
|
242
|
+
bounded!(producer.fetch("receiver_id"), 128, "receipt producer.receiver_id")
|
|
243
|
+
bounded!(producer.fetch("principal"), 256, "receipt producer.principal")
|
|
244
|
+
pattern!(producer.fetch("configuration_sha256"), HEX64, "receipt producer.configuration_sha256")
|
|
245
|
+
unless Atoms::FixedMergeSchemas.canonical_request_digest(
|
|
246
|
+
project: request.fetch("project"), assignment: request.fetch("assignment"),
|
|
247
|
+
attempt: request.fetch("attempt"), queue_entry: request.fetch("queue_entry"),
|
|
248
|
+
authorization: request.fetch("authorization"), request_id: receipt.fetch("request_id"),
|
|
249
|
+
input: input
|
|
250
|
+
) == receipt.fetch("input_digest")
|
|
251
|
+
raise ArgumentError, "receipt input_digest is not the canonical digest of the embedded binding"
|
|
252
|
+
end
|
|
253
|
+
validate_effect!(receipt.fetch("effect"))
|
|
254
|
+
validate_observation!(receipt.fetch("remote_observation"), input)
|
|
255
|
+
enforce_exclusivity!(receipt)
|
|
256
|
+
join_observation!(receipt, input)
|
|
257
|
+
join_binding!(receipt, binding, input) if binding
|
|
258
|
+
receipt
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
private
|
|
262
|
+
|
|
263
|
+
def validate_effect!(effect)
|
|
264
|
+
exact_object!(effect, EFFECT_KEYS, "receipt effect")
|
|
265
|
+
started_at = effect.fetch("started_at")
|
|
266
|
+
datetime!(started_at, "receipt effect.started_at") unless started_at.nil?
|
|
267
|
+
datetime!(effect.fetch("finished_at"), "receipt effect.finished_at")
|
|
268
|
+
unless OUTCOMES.include?(effect.fetch("outcome"))
|
|
269
|
+
raise ArgumentError, "receipt effect.outcome is outside the closed set"
|
|
270
|
+
end
|
|
271
|
+
failure_code = effect.fetch("failure_code")
|
|
272
|
+
unless failure_code.nil? || failure_code == "merge_failed_no_effect"
|
|
273
|
+
raise ArgumentError, "receipt effect.failure_code must be null or merge_failed_no_effect"
|
|
274
|
+
end
|
|
275
|
+
evidence = effect.fetch("no_effect_evidence")
|
|
276
|
+
valid_evidence = evidence.nil? || (evidence.is_a?(Hash) &&
|
|
277
|
+
evidence.keys.sort == %w[reference sha256].sort &&
|
|
278
|
+
bounded?(evidence["reference"], 256) &&
|
|
279
|
+
evidence["sha256"].is_a?(String) && evidence["sha256"].match?(HEX64))
|
|
280
|
+
unless valid_evidence
|
|
281
|
+
raise ArgumentError, "receipt effect.no_effect_evidence must be null or a bounded reference/sha256 object"
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def validate_observation!(observation, input)
|
|
286
|
+
return if observation.nil?
|
|
287
|
+
exact_object!(observation, OBSERVATION_KEYS, "receipt remote_observation")
|
|
288
|
+
pattern!(observation.fetch("forge_instance"), FORGE_NAME, "remote_observation.forge_instance")
|
|
289
|
+
url!(observation.fetch("pr_identity_url"), "remote_observation.pr_identity_url")
|
|
290
|
+
bounded!(observation.fetch("target_repository_identity"), 1024, "remote_observation.target_repository_identity")
|
|
291
|
+
bounded!(observation.fetch("target_ref_observed"), 255, "remote_observation.target_ref_observed")
|
|
292
|
+
%w[merged_candidate_head admission_target_head resulting_target_head merge_commit].each do |key|
|
|
293
|
+
pattern!(observation.fetch(key), SHA40, "remote_observation.#{key}")
|
|
294
|
+
end
|
|
295
|
+
bounded!(observation.fetch("checkout_id"), 256, "remote_observation.checkout_id")
|
|
296
|
+
bounded!(observation.fetch("worktree_id"), 256, "remote_observation.worktree_id")
|
|
297
|
+
bounded!(observation.fetch("branch"), 255, "remote_observation.branch")
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
# Succeeded (merged/already_merged) versus proven-no-effect failed
|
|
301
|
+
# are mutually exclusive; failure is terminal only with immutable
|
|
302
|
+
# no-effect evidence.
|
|
303
|
+
def enforce_exclusivity!(receipt)
|
|
304
|
+
effect = receipt.fetch("effect")
|
|
305
|
+
case receipt.fetch("terminal_status")
|
|
306
|
+
when "succeeded"
|
|
307
|
+
unless %w[merged already_merged].include?(effect.fetch("outcome")) &&
|
|
308
|
+
effect.fetch("failure_code").nil? && effect.fetch("no_effect_evidence").nil? &&
|
|
309
|
+
effect.fetch("started_at").is_a?(String) && receipt.fetch("remote_observation").is_a?(Hash)
|
|
310
|
+
raise ArgumentError, "succeeded receipt must describe a verified remote outcome without failure evidence"
|
|
311
|
+
end
|
|
312
|
+
when "failed"
|
|
313
|
+
unless effect.fetch("outcome") == "failed_no_effect" &&
|
|
314
|
+
effect.fetch("failure_code") == "merge_failed_no_effect" &&
|
|
315
|
+
effect.fetch("no_effect_evidence").is_a?(Hash) && receipt.fetch("remote_observation").nil?
|
|
316
|
+
raise ArgumentError, "failed receipt must prove no effect and carry no remote observation"
|
|
317
|
+
end
|
|
318
|
+
end
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
# `remote_observation` uses distinct observation keys whose values
|
|
322
|
+
# are joined back to the embedded request; the fixed merge method
|
|
323
|
+
# additionally requires merge_commit == resulting_target_head.
|
|
324
|
+
def join_observation!(receipt, input)
|
|
325
|
+
observation = receipt.fetch("remote_observation")
|
|
326
|
+
return if observation.nil?
|
|
327
|
+
unless observation.fetch("forge_instance") == input.fetch("forge") &&
|
|
328
|
+
observation.fetch("pr_identity_url") == input.fetch("pr_url") &&
|
|
329
|
+
observation.fetch("target_repository_identity") == input.fetch("base_repository") &&
|
|
330
|
+
observation.fetch("target_ref_observed") == input.fetch("base_ref") &&
|
|
331
|
+
observation.fetch("merged_candidate_head") == input.fetch("candidate_head") &&
|
|
332
|
+
observation.fetch("admission_target_head") == input.fetch("expected_base_head") &&
|
|
333
|
+
observation.fetch("merge_commit") == observation.fetch("resulting_target_head")
|
|
334
|
+
raise ArgumentError, "remote observation does not join back to the embedded request"
|
|
335
|
+
end
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
def join_binding!(receipt, binding, input)
|
|
339
|
+
request = receipt.fetch("request")
|
|
340
|
+
expected = binding.fetch(:request)
|
|
341
|
+
unless receipt.fetch("reference") == binding.fetch(:reference) &&
|
|
342
|
+
receipt.fetch("request_id") == binding.fetch(:request_id) &&
|
|
343
|
+
receipt.fetch("input_digest") == binding.fetch(:input_digest) &&
|
|
344
|
+
request.fetch("project") == expected.fetch(:project) &&
|
|
345
|
+
request.fetch("assignment") == expected.fetch(:assignment) &&
|
|
346
|
+
request.fetch("attempt") == expected.fetch(:attempt) &&
|
|
347
|
+
request.fetch("queue_entry") == expected.fetch(:queue_entry) &&
|
|
348
|
+
request.fetch("authorization") == expected.fetch(:authorization) &&
|
|
349
|
+
input == expected.fetch(:input)
|
|
350
|
+
raise ArgumentError, "embedded request selectors differ from the recorded binding"
|
|
351
|
+
end
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
def exact_object!(value, keys, label)
|
|
355
|
+
raise ArgumentError, "#{label} must be a JSON object" unless value.is_a?(Hash)
|
|
356
|
+
unless value.keys.sort == keys.sort
|
|
357
|
+
raise ArgumentError, "#{label} must have exactly the closed field set"
|
|
358
|
+
end
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
def const!(value, expected, label)
|
|
362
|
+
return value if value == expected
|
|
363
|
+
raise ArgumentError, "#{label} must be #{expected.inspect}"
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
def pattern!(value, regex, label)
|
|
367
|
+
return value if value.is_a?(String) && value.match?(regex)
|
|
368
|
+
raise ArgumentError, "#{label} does not match the closed pattern"
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
def bounded?(value, max)
|
|
372
|
+
value.is_a?(String) && !value.empty? && value.length <= max
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
def bounded!(value, max, label)
|
|
376
|
+
return value if bounded?(value, max)
|
|
377
|
+
raise ArgumentError, "#{label} must be a non-empty string of at most #{max} characters"
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
def selector!(value, label)
|
|
381
|
+
bounded!(value, 256, label)
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
def datetime!(value, label)
|
|
385
|
+
unless value.is_a?(String) && value.match?(DATETIME) &&
|
|
386
|
+
Time.iso8601(value)
|
|
387
|
+
raise ArgumentError, "#{label} must be an RFC 3339 date-time"
|
|
388
|
+
end
|
|
389
|
+
rescue ArgumentError
|
|
390
|
+
raise ArgumentError, "#{label} must be an RFC 3339 date-time"
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
# `format: uri` plus the maintained neutral PR URL validation: the
|
|
394
|
+
# normalized URL produced by the selected forge adapter.
|
|
395
|
+
def url!(value, label = "pr_url")
|
|
396
|
+
unless value.is_a?(String) && !value.empty? && value.length <= 2048 &&
|
|
397
|
+
!value.match?(/\s/) && URI.parse(value).absolute? &&
|
|
398
|
+
%w[http https].include?(URI.parse(value).scheme)
|
|
399
|
+
raise ArgumentError, "#{label} must be an absolute http(s) URI"
|
|
400
|
+
end
|
|
401
|
+
Ace::Git::Atoms::DeliveryParameters.validate_url!(value)
|
|
402
|
+
value
|
|
403
|
+
rescue URI::InvalidURIError, Ace::Git::Error
|
|
404
|
+
raise ArgumentError, "#{label} must be a normalized forge PR URL"
|
|
405
|
+
end
|
|
406
|
+
end
|
|
407
|
+
end
|
|
408
|
+
end
|
|
409
|
+
end
|
|
410
|
+
end
|
|
@@ -93,7 +93,7 @@ module Ace
|
|
|
93
93
|
raise AttemptErrors::Conflict, "Terminal recovery is read-only"
|
|
94
94
|
end
|
|
95
95
|
planned = [{type: "recovery_observation", payload: {"decision" => decision,
|
|
96
|
-
|
|
96
|
+
"recovery_reason" => reason, "observation" => observation}}]
|
|
97
97
|
if state == "running" && decision != "adopt"
|
|
98
98
|
Atoms::AttemptStateMachine.transition!(state, "uncertain")
|
|
99
99
|
planned << {type: "transition", payload: {"from" => state, "to" => "uncertain", "reason" => reason}}
|
|
@@ -285,6 +285,90 @@ module Ace
|
|
|
285
285
|
receipt: receipt, validated: true)
|
|
286
286
|
end
|
|
287
287
|
|
|
288
|
+
# ---- ordinary fixed merge primitive (spec 8x3.t.xz9.4) -------------
|
|
289
|
+
#
|
|
290
|
+
# The shared creation/resolution/read-only-status primitive the
|
|
291
|
+
# ordinary receiver composes. Every durable write and read for
|
|
292
|
+
# ordinary fixed merge requests lives in the canonical evidence
|
|
293
|
+
# journal — this API never creates a receiver-local record.
|
|
294
|
+
|
|
295
|
+
# Admit one ordinary fixed merge request. The opaque reference is
|
|
296
|
+
# created here and returned only through the durable record; callers
|
|
297
|
+
# receive the canonical record with its journal commit, so an
|
|
298
|
+
# acknowledgment is never issued before durability. An identical
|
|
299
|
+
# resubmission replays the original record; any different field
|
|
300
|
+
# under the same request ID, or a reused authorization, is a
|
|
301
|
+
# conflict/refusal — never a second effect.
|
|
302
|
+
def admit_fixed_merge_request(submission)
|
|
303
|
+
record = stringified(submission).merge("state" => "pending",
|
|
304
|
+
"reference" => Molecules::FixedEffectReference.create)
|
|
305
|
+
# The guard raises the distinct error itself, so journal conflicts
|
|
306
|
+
# never need message-string discrimination: replay/binding conflicts
|
|
307
|
+
# stay Conflict, a consumed authorization is AuthorizationConflict.
|
|
308
|
+
guard = lambda do
|
|
309
|
+
conflict = @journal.fixed_authorization_conflict(record)
|
|
310
|
+
if conflict
|
|
311
|
+
raise AttemptErrors::AuthorizationConflict,
|
|
312
|
+
"Authorization reference is not currently valid"
|
|
313
|
+
end
|
|
314
|
+
nil
|
|
315
|
+
end
|
|
316
|
+
@journal.admit_fixed_merge_request(record, guard: guard)
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
# Begin the fixed effect (pending -> uncertain). The single CAS
|
|
320
|
+
# transition is the at-most-one-merge proof: a restarted receiver
|
|
321
|
+
# replays "already started" and must not dispatch again.
|
|
322
|
+
def begin_fixed_merge_effect(request_id)
|
|
323
|
+
@journal.begin_fixed_merge_request(request_id)
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
# Settle the fixed effect with the exact terminal receipt. The
|
|
327
|
+
# journal validates the closed receipt schema and its binding to the
|
|
328
|
+
# admitted record before the transition lands.
|
|
329
|
+
def settle_fixed_merge_effect(request_id, terminal_status:, receipt:)
|
|
330
|
+
@journal.settle_fixed_merge_request(request_id, terminal_status: terminal_status, receipt: receipt)
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
# Read-only canonical record lookup by request ID: lets the producer
|
|
334
|
+
# recognize an exact replay (and return the original acknowledgment)
|
|
335
|
+
# or a conflicting reuse without any receiver-local state.
|
|
336
|
+
def fixed_merge_record(request_id)
|
|
337
|
+
journal_for.fixed_merge_request(request_id)
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
# Read-only status: resolve the opaque reference against the
|
|
341
|
+
# canonical record and verify every selector. Performs no dispatch,
|
|
342
|
+
# retry, authorization refresh or selector repair. Returns the
|
|
343
|
+
# frozen canonical record, or nil when the reference or any selector
|
|
344
|
+
# does not match — the caller must answer a mismatch identically
|
|
345
|
+
# without revealing whether another project holds the reference.
|
|
346
|
+
def fixed_merge_status(reference:, request_id:, project_id:, assignment_id:, attempt_id:,
|
|
347
|
+
queue_entry:, input_digest:)
|
|
348
|
+
record = @journal.fixed_merge_request(request_id)
|
|
349
|
+
return nil unless record
|
|
350
|
+
matches = reference == record["reference"] && input_digest == record["input_digest"] &&
|
|
351
|
+
[project_id, assignment_id, attempt_id, queue_entry].zip(
|
|
352
|
+
%w[project_id assignment_id attempt_id queue_entry].map { |key| record[key] }
|
|
353
|
+
).all? { |supplied, canonical| supplied == canonical }
|
|
354
|
+
matches ? freeze_canonical(record) : nil
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
def freeze_canonical(record)
|
|
358
|
+
JSON.parse(JSON.generate(record)).freeze
|
|
359
|
+
end
|
|
360
|
+
private :freeze_canonical
|
|
361
|
+
|
|
362
|
+
def stringified(submission)
|
|
363
|
+
unless submission.is_a?(Hash)
|
|
364
|
+
raise ArgumentError, "fixed merge submission must be a mapping"
|
|
365
|
+
end
|
|
366
|
+
submission.each_with_object({}) do |(key, value), result|
|
|
367
|
+
result[key.to_s] = value
|
|
368
|
+
end
|
|
369
|
+
end
|
|
370
|
+
private :stringified
|
|
371
|
+
|
|
288
372
|
def validate_service_receipt!(request, state, receipt, require_no_effect: false)
|
|
289
373
|
unless receipt.is_a?(Hash) && receipt.keys.sort == SERVICE_RECEIPT_FIELDS.sort
|
|
290
374
|
raise AttemptErrors::ReceiptRejected, "Service receipt has invalid fields"
|
|
@@ -637,7 +721,7 @@ input:#{Regexp.escape(request.fetch("input_digest"))} outcome:(\S+)( no-effect:(
|
|
|
637
721
|
elsif unstarted
|
|
638
722
|
["reconcile-required", "launch binding unavailable; positive no-execution/no-surviving-writer proof required before abort"]
|
|
639
723
|
elsif !assignment || observation["liveness"] != "live"
|
|
640
|
-
["reconcile-required", !assignment ? "assignment checkpoint unavailable" : observation["reason"]]
|
|
724
|
+
["reconcile-required", (!assignment) ? "assignment checkpoint unavailable" : observation["reason"]]
|
|
641
725
|
else
|
|
642
726
|
["adopt", "verified owner survives; preserve current attempt"]
|
|
643
727
|
end
|
|
@@ -649,7 +733,7 @@ input:#{Regexp.escape(request.fetch("input_digest"))} outcome:(\S+)( no-effect:(
|
|
|
649
733
|
"accepted_effects" => accepted_effects,
|
|
650
734
|
"accepted_receipt_digests" => receipt_history.map { |receipt| receipt["digest"] },
|
|
651
735
|
"liveness" => observation["liveness"], "last_verified_observation" =>
|
|
652
|
-
(observation["liveness"] == "live" ? observation : last_verified_observation(attempt)),
|
|
736
|
+
((observation["liveness"] == "live") ? observation : last_verified_observation(attempt)),
|
|
653
737
|
"observation" => observation)
|
|
654
738
|
end
|
|
655
739
|
decision = if recovered.empty? || recovered.any? { |item| item["decision"] == "reconcile-required" }
|
|
@@ -661,14 +745,14 @@ input:#{Regexp.escape(request.fetch("input_digest"))} outcome:(\S+)( no-effect:(
|
|
|
661
745
|
end
|
|
662
746
|
{"assignment_id" => assignment_id, "task_id" => assignment&.task_id,
|
|
663
747
|
"latest_attempt_id" => recovered.last&.dig("attempt_id"),
|
|
664
|
-
"decision" => decision, "liveness" => decision == "adopt" ? "live" : "unknown",
|
|
748
|
+
"decision" => decision, "liveness" => (decision == "adopt") ? "live" : "unknown",
|
|
665
749
|
"last_verified_observation" => recovered.filter_map { |item| item["last_verified_observation"] }
|
|
666
750
|
.max_by { |item| item["observed_at"].to_s },
|
|
667
751
|
"recovery_reason" => recovered.empty? ? "accepted attempt evidence unavailable" :
|
|
668
752
|
recovered.find { |item| item["decision"] == decision }&.dig("recovery_reason"),
|
|
669
753
|
"goal" => assignment&.description || assignment&.name,
|
|
670
754
|
"checkpoint" => queue && {"state" => queue.assignment_state.to_s,
|
|
671
|
-
|
|
755
|
+
"active_steps" => queue.active_steps.map(&:number), "next_step" => queue.next_workable&.number},
|
|
672
756
|
"attempts" => recovered, "inbox_events" => inbox_records,
|
|
673
757
|
"pending_hitl" => queue ? queue.steps.filter_map do |step|
|
|
674
758
|
id = step.stall_reason.to_s[/\AHITL:\s*([a-zA-Z0-9_.-]+)/, 1]
|
|
@@ -694,7 +778,7 @@ input:#{Regexp.escape(request.fetch("input_digest"))} outcome:(\S+)( no-effect:(
|
|
|
694
778
|
previous_digest: last_event_digest(attempt))
|
|
695
779
|
attempt = append_events(attempt, [event])
|
|
696
780
|
if attempt.state == "running" && projection["decision"] != "adopt"
|
|
697
|
-
target = projection["decision"] == "restart-required" ? "stopped" : "uncertain"
|
|
781
|
+
target = (projection["decision"] == "restart-required") ? "stopped" : "uncertain"
|
|
698
782
|
attempt = append_events(attempt, [transition_event(attempt, target, projection["recovery_reason"])])
|
|
699
783
|
attempt = attempt.transition(target)
|
|
700
784
|
@store.release(assignment_id, attempt.binding.scope, attempt.attempt_id) if attempt.terminal?
|
|
@@ -710,7 +794,6 @@ input:#{Regexp.escape(request.fetch("input_digest"))} outcome:(\S+)( no-effect:(
|
|
|
710
794
|
# Herdr owns signature, pinned key, generation and native identity
|
|
711
795
|
# verification; this consumer records only its verified references.
|
|
712
796
|
|
|
713
|
-
|
|
714
797
|
# Register a transport reference in the existing attempt journal so
|
|
715
798
|
# a missing delivery record cannot be mistaken for no pending effect.
|
|
716
799
|
def bind_inbox(attempt_id:, event_id:, inbox:, identity: nil)
|