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