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