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.
@@ -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
- "recovery_reason" => reason, "observation" => observation}}]
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
- "active_steps" => queue.active_steps.map(&:number), "next_step" => queue.next_workable&.number},
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)