ace-lab 0.6.0 → 0.8.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,688 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "fileutils"
5
+ require "json"
6
+ require "uri"
7
+ require_relative "ordinary_merge_request"
8
+ require_relative "../molecules/ordinary_receiver_config"
9
+ require_relative "../molecules/ordinary_admission_resolver"
10
+ require_relative "../molecules/ordinary_merge_read"
11
+ require_relative "../molecules/forge_merge_config"
12
+ require_relative "../molecules/forgejo_merge_adapter"
13
+ require "ace/assign/atoms/fixed_merge_schemas"
14
+
15
+ module Ace
16
+ module Lab
17
+ module Organisms
18
+ # The ordinary fixed merge RECEIVER/EXECUTOR (spec 8x3.t.xz9.4
19
+ # execution clauses) — the execution side of the ordinary merge chain.
20
+ # One mode of the maintained service vocabulary: `handle` performs the
21
+ # authenticated admission (mutual-identity binding, canonical digest
22
+ # re-derivation, scoped YAML authorization resolution, pre-effect
23
+ # target verification, durable Assign admission) and returns the exact
24
+ # wire envelope; `execute` then runs the authorized fixed operation as
25
+ # the authorized executor — at most one fast-forward merge per admitted
26
+ # request — and settles the exact terminal receipt into the canonical
27
+ # Assign attempt/evidence journal. The receiver owns no ledger: every
28
+ # durable write lives in Assign, and every refusal uses the closed
29
+ # producer envelope vocabulary.
30
+ #
31
+ # Merge-method contract: the closed schema's literal `method=merge`
32
+ # executes FAST-FORWARD-ONLY, on the forge wire as well (`Do:
33
+ # fast-forward-only`): a candidate that is not a descendant of the
34
+ # current base head refuses in the moved-target/needs-rebase family
35
+ # (never a silent substitution, never a merge-commit fallback); the
36
+ # terminal receipt proves the strategy by joining `merge_commit ==
37
+ # resulting_target_head == merged_candidate_head`. The merge POST
38
+ # additionally binds the approved candidate head SERVER-SIDE
39
+ # (`head_commit_id`): the forge refuses to merge any head other than
40
+ # the reviewed candidate, so a race between the state readback and
41
+ # the merge POST can never merge an unreviewed head.
42
+ #
43
+ # Forge effect contract (spec 8x3.t.xz9.5 integration handoff): the
44
+ # authorized effect is a REAL Forgejo pull request merge invoked under
45
+ # the scoped service identity from the host-installed forge merge
46
+ # configuration, and the terminal receipt records the REAL forge
47
+ # readback (`merged`, `merge_commit_sha`) — a local git effect is
48
+ # never presented as a forge result, and the receiver never updates
49
+ # the checked-out canonical main locally. The local repository
50
+ # (the producer's canonical checkout) serves read-only pre-effect
51
+ # verification only. Missing host configuration is an immediate
52
+ # actionable refusal naming the path and owner; ambiguous outcomes
53
+ # (timeouts, 5xx, unavailable readback) reconcile by bounded readback
54
+ # retry — never a blind re-dispatch — and leave the canonical record
55
+ # uncertain with the escalation owner named when unresolved.
56
+ class OrdinaryMergeReceiver
57
+ SUBMISSION_KEYS = %w[project_id assignment_id attempt_id queue_entry operation
58
+ authorization request_id input_digest request principal receiver_id
59
+ configuration_sha256].freeze
60
+ ID = Molecules::ServicePolicy::ID
61
+
62
+ def initialize(config:, coordinator:, policy_document:, merge_factory: nil,
63
+ forge_config: nil, forge_factory: nil, resolver: nil,
64
+ readback_attempts: 3, readback_backoff_seconds: 1.0, owner_guard: nil)
65
+ unless config.is_a?(Molecules::OrdinaryReceiverConfig)
66
+ raise ArgumentError, "receiver needs validated OrdinaryReceiverConfig"
67
+ end
68
+ @config = config
69
+ unless owner_guard.nil? || owner_guard.respond_to?(:call)
70
+ raise ArgumentError, "ordinary installed owner guard must be callable"
71
+ end
72
+ @owner_guard = owner_guard
73
+ @coordinator = coordinator
74
+ @policy_document = policy_document
75
+ @configuration_sha256 = Molecules::ServicePolicy.configuration_digest(policy_document)
76
+ @merge_factory = merge_factory || ->(document) {
77
+ Molecules::OrdinaryMergeRead.new(repo_path: config.repo_path,
78
+ base_ref: document.fetch("base_ref"))
79
+ }
80
+ # The scoped service identity is host-installed; an absent
81
+ # configuration refuses construction with the actionable path.
82
+ @forge_config = forge_config || Molecules::ForgeMergeConfig.load(
83
+ config.forge_config_path || Molecules::ForgeMergeConfig::DEFAULT_PATH
84
+ )
85
+ @forge_factory = forge_factory || ->(_document) {
86
+ Molecules::ForgejoMergeAdapter.new(base_url: @forge_config.base_url,
87
+ token: @forge_config.token, timeout_seconds: @forge_config.merge_timeout_seconds)
88
+ }
89
+ @resolver = resolver || Molecules::OrdinaryAdmissionResolver.new(
90
+ policy_document, path: config.policy_path,
91
+ proposal_resolver: coordinator.method(:proposal_authorization), caller_uid: config.principal_uid
92
+ )
93
+ @readback_attempts = readback_attempts
94
+ @readback_backoff_seconds = readback_backoff_seconds
95
+ end
96
+
97
+ attr_reader :config, :forge_config
98
+
99
+ # The listener handler entrypoint: admission only; the listener
100
+ # invokes #execute after the acknowledgment is on the wire.
101
+ def call(submission)
102
+ handle(submission)
103
+ end
104
+
105
+ # Authenticated admission of one wire submission. Returns the exact
106
+ # wire envelope: the durable acknowledgment after the canonical
107
+ # Assign record exists, or a closed pre-effect refusal envelope that
108
+ # proves no effect began. Never a receiver-local record.
109
+ def handle(submission)
110
+ @owner_guard&.call
111
+ invalid!("submission is not a mapping") unless submission.is_a?(Hash)
112
+ invalid!("submission has unknown selectors: #{unknown(submission)}") unless
113
+ (submission.keys - SUBMISSION_KEYS).empty?
114
+ invalid!("submission is missing selectors") unless (SUBMISSION_KEYS - submission.keys).empty?
115
+ request_id = submission.fetch("request_id")
116
+ unless submission.fetch("operation") == OrdinaryMergeRequest::OPERATION
117
+ invalid!("operation must be merge", request_id)
118
+ end
119
+ identifiers!(submission)
120
+ unless submission.fetch("receiver_id") == @config.receiver_id
121
+ return selection_mismatch("submission does not target this receiver", request_id)
122
+ end
123
+ unless submission.fetch("project_id") == @config.project
124
+ return selection_mismatch("submission does not target this receiver project", request_id)
125
+ end
126
+ unless submission.fetch("principal") == @config.principal
127
+ return selection_mismatch("submission principal does not match the installed mapping", request_id)
128
+ end
129
+ unless submission.fetch("configuration_sha256") == @configuration_sha256
130
+ return selection_mismatch("installed configuration differs from the submitting policy", request_id)
131
+ end
132
+ document = submission.fetch("request")
133
+ begin
134
+ Ace::Assign::Atoms::FixedMergeSchemas.validate_request!(document)
135
+ Ace::Assign::Atoms::FixedMergeSchemas.require_request_joins!(document)
136
+ rescue Ace::Assign::Atoms::FixedMergeSchemas::SchemaError => e
137
+ invalid!(e.message, request_id)
138
+ end
139
+ digest = rederive_digest(submission, document)
140
+ unless digest == submission.fetch("input_digest")
141
+ invalid!("canonical request digest does not re-derive from the submission", request_id)
142
+ end
143
+ # Selection and evidence integrity before authorization: the PR
144
+ # must target THIS receiver's configured forge repository, and the
145
+ # handed-over review must be independent of the executor.
146
+ forge_identity!(document, request_id)
147
+ reviewer_independence!(document, request_id)
148
+ decision = @resolver.decide(submission.fetch("authorization"), authorization_binding(submission))
149
+ unless decision.authorized?
150
+ return OrdinaryMergeRequest.refusal_envelope(decision.code, decision.message,
151
+ request_id: request_id, input_digest: digest)
152
+ end
153
+ verify_pre_effect_target!(document, request_id)
154
+ admitted = admit(submission)
155
+ ack_envelope(admitted)
156
+ rescue OrdinaryMergeRefusal => e
157
+ OrdinaryMergeRequest.refusal_envelope(e.code, e.detail,
158
+ request_id: e.request_id, input_digest: e.input_digest)
159
+ rescue Ace::Assign::AttemptErrors::AuthorizationConflict, Ace::Assign::AttemptErrors::ReceiptRejected => e
160
+ OrdinaryMergeRequest.refusal_envelope("ordinary_merge_authorization_invalid", e.message,
161
+ request_id: submission.is_a?(Hash) ? submission.fetch("request_id", "unknown") : "unknown",
162
+ input_digest: nil)
163
+ rescue Ace::Assign::AttemptErrors::Conflict => e
164
+ OrdinaryMergeRequest.refusal_envelope("ordinary_merge_request_conflict", e.message,
165
+ request_id: submission.is_a?(Hash) ? submission.fetch("request_id", "unknown") : "unknown",
166
+ input_digest: nil)
167
+ rescue Molecules::OrdinaryMergeRead::GitUnavailable, Ace::Assign::Error,
168
+ Ace::Lab::InvalidConfigurationError => e
169
+ OrdinaryMergeRequest.refusal_envelope("ordinary_merge_unavailable", e.message,
170
+ request_id: submission.is_a?(Hash) ? submission.fetch("request_id", "unknown") : "unknown",
171
+ input_digest: nil)
172
+ end
173
+
174
+ # The executor lifecycle for one admitted request: the single
175
+ # pending->uncertain CAS is the at-most-one-merge proof; a record
176
+ # already begun by a lost lifecycle stays uncertain and is NEVER
177
+ # re-executed or resubmitted. The effect is one REAL forge merge
178
+ # invocation under the scoped service identity; settlement is one
179
+ # journal CAS validated against the closed receipt schema from the
180
+ # real forge readback. Any crash between begin and settle leaves the
181
+ # canonical record uncertain with the original reference.
182
+ def execute(request_id)
183
+ @owner_guard&.call
184
+ record = @coordinator.fixed_merge_record(request_id)
185
+ return :unknown unless record
186
+ return :skipped_terminal unless record.fetch("state") == "pending"
187
+ begin
188
+ @coordinator.begin_fixed_merge_effect(request_id)
189
+ rescue Ace::Assign::AttemptErrors::ReceiptRejected, Ace::Assign::AttemptErrors::AuthorizationConflict
190
+ settle_no_effect(request_id, record, "canonical proposal is no longer authorized before effect")
191
+ return :refused
192
+ end
193
+ execute_effect(record)
194
+ :executed
195
+ rescue Ace::Assign::AttemptErrors::InvalidState, Ace::Assign::AttemptErrors::NotFound,
196
+ Ace::Assign::AttemptErrors::Conflict
197
+ # Another lifecycle won the CAS or the record is terminal: never a
198
+ # second effect, never a resubmission.
199
+ :skipped_terminal
200
+ end
201
+
202
+ # Receiver-restart reconciliation: adopt only records this exact
203
+ # installed configuration owns, still pending, and drive each one to
204
+ # a terminal state at most once. Already-uncertain records (a lost
205
+ # lifecycle) are never re-executed — they reconcile by reference.
206
+ def recover_pending!
207
+ @owner_guard&.call
208
+ records = @coordinator.fixed_merge_records
209
+ records.filter_map do |record|
210
+ next nil unless record.fetch("state") == "pending" &&
211
+ record.fetch("receiver_id") == @config.receiver_id &&
212
+ record.fetch("configuration_sha256") == @configuration_sha256 &&
213
+ record.fetch("project_id") == @config.project
214
+ [record.fetch("request_id"), execute(record.fetch("request_id"))]
215
+ end
216
+ end
217
+
218
+ private
219
+
220
+ # ---- admission -----------------------------------------------------
221
+
222
+ def identifiers!(submission)
223
+ %w[project_id assignment_id attempt_id queue_entry request_id].each do |key|
224
+ value = submission.fetch(key)
225
+ invalid!("invalid #{key}") unless value.is_a?(String) && value.match?(ID)
226
+ end
227
+ end
228
+
229
+ def rederive_digest(submission, document)
230
+ Ace::Assign::Atoms::FixedMergeSchemas.canonical_request_digest(
231
+ project: submission.fetch("project_id"), assignment: submission.fetch("assignment_id"),
232
+ attempt: submission.fetch("attempt_id"), queue_entry: submission.fetch("queue_entry"),
233
+ authorization: submission.fetch("authorization"),
234
+ request_id: submission.fetch("request_id"), input: document
235
+ )
236
+ end
237
+
238
+ def authorization_binding(submission)
239
+ document = submission.fetch("request")
240
+ {
241
+ "operation" => submission.fetch("operation"),
242
+ "project_id" => submission.fetch("project_id"),
243
+ "assignment_id" => submission.fetch("assignment_id"),
244
+ "attempt_id" => submission.fetch("attempt_id"),
245
+ "queue_entry" => submission.fetch("queue_entry"),
246
+ "input_digest" => submission.fetch("input_digest"),
247
+ "forge" => document.fetch("forge"),
248
+ "pr_url" => document.fetch("pr_url"),
249
+ "head_repository" => document.fetch("head_repository"),
250
+ "head_ref" => document.fetch("head_ref"),
251
+ "candidate_head" => document.fetch("candidate_head"),
252
+ "base_repository" => document.fetch("base_repository"),
253
+ "base_ref" => document.fetch("base_ref"),
254
+ "expected_base_head" => document.fetch("expected_base_head")
255
+ }
256
+ end
257
+
258
+ # Pre-effect target verification before any admission — read-only
259
+ # against the canonical repository (the producer's canonical
260
+ # checkout): the candidate object must exist and the base head must
261
+ # still equal the immutable request. Moved targets answer the
262
+ # moved-target/needs-rebase refusal family before any effect.
263
+ def verify_pre_effect_target!(document, request_id)
264
+ merge = @merge_factory.call(document)
265
+ candidate = document.fetch("candidate_head")
266
+ unless merge.candidate_commit?(candidate)
267
+ target_changed!("candidate head is absent from the canonical repository", request_id)
268
+ end
269
+ observed = merge.base_head
270
+ if observed != document.fetch("expected_base_head")
271
+ target_changed!("base head moved from the immutable request", request_id)
272
+ end
273
+ # Merge-method contract: `method=merge` is FAST-FORWARD-ONLY. A
274
+ # candidate that is not a descendant of the current base head is
275
+ # the moved-target/needs-rebase refusal family — never a silent
276
+ # method substitution, never a merge-commit fallback.
277
+ unless merge.fast_forwardable?(candidate)
278
+ target_changed!("candidate head is not fast-forwardable onto the base head", request_id)
279
+ end
280
+ end
281
+
282
+ def admit(submission)
283
+ record = submission.merge(
284
+ "principal" => @config.principal,
285
+ "caller_uid" => @config.principal_uid,
286
+ "execution_identity" => @config.execution_identity
287
+ )
288
+ @coordinator.admit_fixed_merge_request(record)
289
+ end
290
+
291
+ def ack_envelope(record)
292
+ {"status" => "ok", "data" => {
293
+ "schema" => Ace::Assign::Atoms::FixedMergeSchemas::ACK_SCHEMA_ID,
294
+ "type" => "accepted",
295
+ "request_id" => record.fetch("request_id"),
296
+ "input_digest" => record.fetch("input_digest"),
297
+ "reference" => record.fetch("reference")
298
+ }}
299
+ end
300
+
301
+ # The effect: re-verify the base head read-only, verify the forge PR
302
+ # state readback (head == candidate, base == expected), attach the
303
+ # independent final-head review when the host installed it, invoke
304
+ # the fast-forward-only merge, and settle from the real readback —
305
+ # reconciling ambiguity by bounded readback retry, never by
306
+ # repeating the merge.
307
+ def execute_effect(admitted_record)
308
+ request_id = admitted_record.fetch("request_id")
309
+ document = admitted_record.fetch("request")
310
+ candidate = document.fetch("candidate_head")
311
+ expected = document.fetch("expected_base_head")
312
+ merge = @merge_factory.call(document)
313
+ refusal = current_authorization_refusal(admitted_record)
314
+ return settle_no_effect(request_id, admitted_record, refusal) if refusal
315
+ observed = merge.base_head
316
+ if observed != expected
317
+ return settle_no_effect(request_id, admitted_record,
318
+ "base head moved before the effect: #{observed[0, 12]}")
319
+ end
320
+ identity = forge_identity!(document, request_id)
321
+ forge = @forge_factory.call(document)
322
+ pr = readback(forge, identity)
323
+ if pr.nil?
324
+ return settle_no_effect(request_id, admitted_record,
325
+ "pull request is absent from the forge")
326
+ end
327
+ if pr["merged"]
328
+ sha = pr["merge_commit_sha"]
329
+ if sha == candidate
330
+ return settle_merged(request_id, admitted_record, document, sha,
331
+ outcome: "already_merged")
332
+ end
333
+ return leave_uncertain(request_id,
334
+ "forge shows the pull request already merged at #{head_label(sha)}, " \
335
+ "not the approved candidate #{candidate[0, 12]}",
336
+ owner: @forge_config.escalation_owner)
337
+ end
338
+ unless pr.dig("head", "sha") == candidate
339
+ return settle_no_effect(request_id, admitted_record,
340
+ "forge pull request head moved from the approved candidate")
341
+ end
342
+ unless pr.dig("base", "sha") == expected
343
+ return settle_no_effect(request_id, admitted_record,
344
+ "forge base head moved from the immutable request")
345
+ end
346
+ refusal = current_authorization_refusal(admitted_record)
347
+ return settle_no_effect(request_id, admitted_record, refusal) if refusal
348
+ refusal = attach_independent_review!(forge, identity, request_id,
349
+ admitted_record, document)
350
+ return refusal if refusal
351
+ # Exact-head contract: the merge POST binds the approved candidate
352
+ # head server-side — the forge refuses to merge any head other
353
+ # than the reviewed candidate (race between readback and POST can
354
+ # never merge an unreviewed head).
355
+ refusal = current_authorization_refusal(admitted_record)
356
+ return settle_no_effect(request_id, admitted_record, refusal) if refusal
357
+ result = forge.merge_pull_request!(owner: identity.fetch("owner"),
358
+ repo: identity.fetch("repo"), index: identity.fetch("index"),
359
+ head_commit_id: candidate)
360
+ case result.outcome
361
+ when :invoked
362
+ reconcile_invoked(forge, identity, request_id, admitted_record,
363
+ document, candidate)
364
+ when :refused
365
+ settle_no_effect(request_id, admitted_record,
366
+ "forge refused the merge: #{result.reason}")
367
+ else
368
+ reconcile_ambiguous(forge, identity, request_id, admitted_record,
369
+ document, candidate, result.reason)
370
+ end
371
+ rescue Molecules::OrdinaryMergeRead::GitUnavailable, Molecules::ForgejoMergeAdapter::Unavailable => e
372
+ # The effect outcome is unprovable: the canonical record stays
373
+ # uncertain with the original reference — never a resubmission.
374
+ leave_uncertain(request_id, e.message)
375
+ end
376
+
377
+ # Recheck pending/recovered authority and again after slow forge calls,
378
+ # immediately before each privileged effect. No cached decision is a lease.
379
+ def current_authorization_refusal(record)
380
+ @owner_guard&.call
381
+ decision = @resolver.decide(record.fetch("authorization"), authorization_binding(record))
382
+ decision.message unless decision.authorized?
383
+ rescue Ace::Lab::InvalidConfigurationError, Ace::Assign::Error => e
384
+ e.message
385
+ end
386
+
387
+ # The merge invocation was accepted: prove the terminal state by
388
+ # readback before any settlement. A readback showing a merge at any
389
+ # head other than the approved candidate is never a success. A 404
390
+ # AFTER the invocation is never a no-effect proof either: the merge
391
+ # POST was actually invoked, so the pull request's absence
392
+ # (permission change, deletion, visibility loss) cannot prove
393
+ # absence of effect — the record stays uncertain with the original
394
+ # reference, the escalation owner named, no no-effect attestation
395
+ # and no retry, never a re-invocation.
396
+ def reconcile_invoked(forge, identity, request_id, admitted_record,
397
+ document, candidate)
398
+ pr = readback(forge, identity)
399
+ if pr.nil?
400
+ return leave_uncertain(request_id,
401
+ "pull request is absent from the forge after the merge invocation: absence " \
402
+ "cannot prove no effect (permission change, deletion, or visibility loss " \
403
+ "may hide an applied merge)",
404
+ owner: @forge_config.escalation_owner)
405
+ end
406
+ if pr["merged"]
407
+ sha = pr["merge_commit_sha"]
408
+ return settle_merged(request_id, admitted_record, document, sha) if sha == candidate
409
+ return leave_uncertain(request_id,
410
+ "forge merged the pull request at #{head_label(sha)}, " \
411
+ "not the approved candidate #{candidate[0, 12]}",
412
+ owner: @forge_config.escalation_owner)
413
+ end
414
+ leave_uncertain(request_id,
415
+ "forge accepted the merge invocation but the readback shows the pull request unmerged")
416
+ end
417
+
418
+ # The merge invocation outcome is ambiguous (timeout, reset, 5xx):
419
+ # reconciliation is readback-only. A readback proving the merge
420
+ # settles it; anything else stays uncertain — never a blind repeat,
421
+ # never a fabricated no-effect claim while the forge may still be
422
+ # applying the invocation. A 404 after the invocation stays in the
423
+ # same uncertain family: absence cannot prove no effect.
424
+ def reconcile_ambiguous(forge, identity, request_id, admitted_record,
425
+ document, candidate, reason)
426
+ pr = readback(forge, identity)
427
+ if pr.nil?
428
+ return leave_uncertain(request_id,
429
+ "merge invocation outcome ambiguous (#{reason}); the pull request is absent " \
430
+ "from the forge after the invocation and absence cannot prove no effect",
431
+ owner: @forge_config.escalation_owner)
432
+ end
433
+ if pr["merged"]
434
+ sha = pr["merge_commit_sha"]
435
+ return settle_merged(request_id, admitted_record, document, sha) if sha == candidate
436
+ return leave_uncertain(request_id,
437
+ "forge merged the pull request at #{head_label(sha)}, " \
438
+ "not the approved candidate #{candidate[0, 12]}",
439
+ owner: @forge_config.escalation_owner)
440
+ end
441
+ leave_uncertain(request_id,
442
+ "merge invocation outcome ambiguous (#{reason}); forge readback after " \
443
+ "reconciliation shows the pull request unmerged",
444
+ owner: @forge_config.escalation_owner)
445
+ end
446
+
447
+ # Attach the INDEPENDENT final-head review body to the PR before the
448
+ # merge when the host enabled review attachment. The body is composed
449
+ # only of the reviewer-produced evidence handed over in the admitted
450
+ # request — the receiver never composes an approval of its own. A
451
+ # refused or ambiguous attachment settles failed_no_effect: the merge
452
+ # was never invoked, so no effect provably occurred.
453
+ def attach_independent_review!(forge, identity, request_id, admitted_record,
454
+ document)
455
+ return nil unless @forge_config.review_attachment_enabled
456
+ evidence = document.fetch("review_evidence")
457
+ body = [
458
+ "Independent final-head review evidence (reviewer-produced, attached verbatim " \
459
+ "from the admitted merge request):",
460
+ "- reviewer: #{evidence.fetch("reviewer")}",
461
+ "- verdict: #{evidence.fetch("verdict")}",
462
+ "- reviewed_head: #{evidence.fetch("reviewed_head")}",
463
+ "- reference: #{evidence.fetch("reference")}",
464
+ "- recorded_at: #{evidence.fetch("recorded_at")}",
465
+ "- record_sha256: #{evidence.fetch("record_sha256")}"
466
+ ].join("\n")
467
+ result = forge.create_comment(owner: identity.fetch("owner"),
468
+ repo: identity.fetch("repo"), index: identity.fetch("index"), body: body)
469
+ case result.outcome
470
+ when :invoked
471
+ nil
472
+ when :refused
473
+ settle_no_effect(request_id, admitted_record,
474
+ "forge refused the independent review attachment: #{result.reason}")
475
+ else
476
+ settle_no_effect(request_id, admitted_record,
477
+ "independent review attachment outcome ambiguous; merge not invoked: #{result.reason}")
478
+ end
479
+ end
480
+
481
+ # One authoritative readback; transient forge unavailability is
482
+ # retried (reconciliation) a bounded number of times before
483
+ # surfacing — the merge is never re-dispatched from here.
484
+ def readback(forge, identity)
485
+ attempts = @readback_attempts
486
+ begin
487
+ attempts -= 1
488
+ forge.pull_request(owner: identity.fetch("owner"),
489
+ repo: identity.fetch("repo"), index: identity.fetch("index"))
490
+ rescue Molecules::ForgejoMergeAdapter::Unavailable
491
+ raise if attempts.zero?
492
+ sleep @readback_backoff_seconds
493
+ retry
494
+ end
495
+ end
496
+
497
+ def settle_merged(request_id, admitted_record, document, merge_commit_sha,
498
+ outcome: "merged")
499
+ receipt = {
500
+ "schema" => Ace::Assign::Atoms::FixedMergeSchemas::RECEIPT_SCHEMA_ID,
501
+ "reference" => admitted_record.fetch("reference"),
502
+ "request_id" => admitted_record.fetch("request_id"),
503
+ "input_digest" => admitted_record.fetch("input_digest"),
504
+ "terminal_status" => "succeeded",
505
+ "request" => {
506
+ "project" => admitted_record.fetch("project_id"),
507
+ "assignment" => admitted_record.fetch("assignment_id"),
508
+ "attempt" => admitted_record.fetch("attempt_id"),
509
+ "queue_entry" => admitted_record.fetch("queue_entry"),
510
+ "operation" => admitted_record.fetch("operation"),
511
+ "authorization" => admitted_record.fetch("authorization"),
512
+ "input" => document
513
+ },
514
+ "producer" => {
515
+ "receiver_id" => admitted_record.fetch("receiver_id"),
516
+ "principal" => admitted_record.fetch("principal"),
517
+ "configuration_sha256" => admitted_record.fetch("configuration_sha256")
518
+ },
519
+ "effect" => {
520
+ "started_at" => Time.now.utc.iso8601,
521
+ "finished_at" => Time.now.utc.iso8601,
522
+ "outcome" => outcome,
523
+ "failure_code" => nil,
524
+ "no_effect_evidence" => nil
525
+ },
526
+ # The REAL forge readback: the merge proof is the forge-reported
527
+ # merge commit (for the fast-forward-only strategy it equals the
528
+ # merged candidate head), never a local git observation.
529
+ "remote_observation" => {
530
+ "forge_instance" => document.fetch("forge"),
531
+ "pr_identity_url" => document.fetch("pr_url"),
532
+ "target_repository_identity" => document.fetch("base_repository"),
533
+ "target_ref_observed" => document.fetch("base_ref"),
534
+ "merged_candidate_head" => document.fetch("candidate_head"),
535
+ "admission_target_head" => document.fetch("expected_base_head"),
536
+ "resulting_target_head" => merge_commit_sha,
537
+ "merge_commit" => merge_commit_sha,
538
+ "checkout_id" => admitted_record.dig("execution_identity", "checkout_id"),
539
+ "worktree_id" => admitted_record.dig("execution_identity", "worktree_id"),
540
+ "branch" => admitted_record.dig("execution_identity", "branch")
541
+ }
542
+ }
543
+ settle(request_id, "succeeded", receipt)
544
+ end
545
+
546
+ # Terminal failure requires the immutable no-effect attestation: an
547
+ # blob committed atomically with the terminal receipt in Assign's
548
+ # canonical evidence ref. No caller checkout path is written.
549
+ def settle_no_effect(request_id, admitted_record, reason)
550
+ record = @coordinator.fixed_merge_record(request_id)
551
+ document = record.fetch("request")
552
+ # Provider I/O has its own lifetime. Settlement needs a fresh bounded
553
+ # local observation, never the expired pre-effect read deadline.
554
+ merge = @merge_factory.call(document)
555
+ attestation = {
556
+ "request_id" => request_id,
557
+ "input_digest" => record.fetch("input_digest"),
558
+ "reference" => record.fetch("reference"),
559
+ "candidate_head" => document.fetch("candidate_head"),
560
+ "expected_base_head" => document.fetch("expected_base_head"),
561
+ "observed_base_head" => merge.base_head,
562
+ "strategy" => Molecules::OrdinaryMergeRead::STRATEGY,
563
+ "outcome" => "no_effect",
564
+ "reason" => reason[0, 512],
565
+ "recorded_at" => Time.now.utc.iso8601
566
+ }
567
+ receipt = {
568
+ "schema" => Ace::Assign::Atoms::FixedMergeSchemas::RECEIPT_SCHEMA_ID,
569
+ "reference" => record.fetch("reference"),
570
+ "request_id" => record.fetch("request_id"),
571
+ "input_digest" => record.fetch("input_digest"),
572
+ "terminal_status" => "failed",
573
+ "request" => {
574
+ "project" => record.fetch("project_id"),
575
+ "assignment" => record.fetch("assignment_id"),
576
+ "attempt" => record.fetch("attempt_id"),
577
+ "queue_entry" => record.fetch("queue_entry"),
578
+ "operation" => record.fetch("operation"),
579
+ "authorization" => record.fetch("authorization"),
580
+ "input" => document
581
+ },
582
+ "producer" => {
583
+ "receiver_id" => record.fetch("receiver_id"),
584
+ "principal" => record.fetch("principal"),
585
+ "configuration_sha256" => record.fetch("configuration_sha256")
586
+ },
587
+ "effect" => {
588
+ "started_at" => Time.now.utc.iso8601,
589
+ "finished_at" => Time.now.utc.iso8601,
590
+ "outcome" => "failed_no_effect",
591
+ "failure_code" => "merge_failed_no_effect",
592
+ "no_effect_evidence" => nil
593
+ },
594
+ "remote_observation" => nil
595
+ }
596
+ settle(request_id, "failed", receipt, attestation: attestation)
597
+ end
598
+
599
+ def settle(request_id, terminal_status, receipt, attestation: nil)
600
+ @coordinator.settle_fixed_merge_effect(request_id, terminal_status: terminal_status,
601
+ receipt: receipt, attestation: attestation)
602
+ terminal_status.to_sym
603
+ rescue Ace::Assign::AttemptErrors::ReceiptRejected
604
+ # The journal refused the receipt: the record stays uncertain with
605
+ # its original reference. Never fabricate, never resubmit.
606
+ :uncertain
607
+ rescue Ace::Assign::AttemptErrors::InvalidState, Ace::Assign::AttemptErrors::NotFound
608
+ # Another lifecycle already settled this record: exactly one
609
+ # terminal settlement exists — never a second one.
610
+ :skipped_terminal
611
+ end
612
+
613
+ def leave_uncertain(request_id, reason, owner: @forge_config.escalation_owner)
614
+ # Intentionally no state change: uncertainty is the canonical
615
+ # unproved-outcome state and reconciliation happens by reference
616
+ # (never a re-dispatch). What is known is recorded on the service
617
+ # log with the actionable owner named.
618
+ warn(JSON.generate({"event" => "ordinary_merge_uncertain",
619
+ "request_id" => request_id, "reason" => reason.to_s[0, 512],
620
+ "owner" => owner}))
621
+ nil
622
+ end
623
+
624
+ def target_changed!(message, request_id)
625
+ raise OrdinaryMergeRefusal.new("ordinary_merge_target_changed", message,
626
+ request_id: request_id)
627
+ end
628
+
629
+ # The forge PR identity from the immutable request's pr_url, validated
630
+ # against the host-installed forge merge configuration: the PR must
631
+ # belong to THIS receiver's configured forge, owner and repository —
632
+ # anything else never targets this receiver (selection mismatch).
633
+ def forge_identity!(document, request_id)
634
+ forge_host = URI.parse(@forge_config.base_url).host.to_s.downcase
635
+ uri = URI.parse(document.fetch("pr_url"))
636
+ match = %r{\A/([^/]+)/([^/]+)/pulls/(\d+)\z}.match(uri.path.to_s)
637
+ unless uri.is_a?(URI::HTTPS) && uri.host.to_s.downcase == forge_host && match
638
+ selection_refusal!("pr_url does not target this receiver's configured forge repository",
639
+ request_id)
640
+ end
641
+ unless match[1] == @forge_config.owner && match[2] == @forge_config.repo
642
+ selection_refusal!("pr_url repository does not match the configured forge repository",
643
+ request_id)
644
+ end
645
+ {"owner" => match[1], "repo" => match[2], "index" => match[3].to_i}
646
+ end
647
+
648
+ # The independent final-head review is handed over in the request;
649
+ # the executor's own identity is never an independent reviewer.
650
+ # Evidence naming the receiver or its principal refuses pre-effect —
651
+ # it can never be attached as independent review and never grounds
652
+ # the merge.
653
+ def reviewer_independence!(document, request_id)
654
+ reviewer = document.dig("review_evidence", "reviewer")
655
+ return unless [config.receiver_id, config.principal].include?(reviewer)
656
+ raise OrdinaryMergeRefusal.new("ordinary_merge_evidence_invalid",
657
+ "review evidence names the receiver executor, not an independent reviewer",
658
+ request_id: request_id)
659
+ end
660
+
661
+ def selection_refusal!(message, request_id)
662
+ raise OrdinaryMergeRefusal.new("ordinary_merge_selection_mismatch", message,
663
+ request_id: request_id)
664
+ end
665
+
666
+ def head_label(sha)
667
+ (sha.is_a?(String) && sha.length >= 12) ? sha[0, 12] : sha.to_s
668
+ end
669
+
670
+ def selection_mismatch(message, request_id)
671
+ OrdinaryMergeRequest.refusal_envelope("ordinary_merge_selection_mismatch", message,
672
+ request_id: request_id, input_digest: nil)
673
+ end
674
+
675
+ def invalid!(message, request_id = "unknown")
676
+ raise OrdinaryMergeRefusal.new("ordinary_merge_invalid_request", message,
677
+ request_id: request_id)
678
+ end
679
+
680
+ def unknown(submission)
681
+ (submission.keys - SUBMISSION_KEYS).sort.join(", ")
682
+ end
683
+
684
+ OrdinaryMergeRefusal = OrdinaryMergeRequest::OrdinaryMergeRefusal
685
+ end
686
+ end
687
+ end
688
+ end