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,456 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "digest"
4
- require "json"
5
- require "open3"
6
- require_relative "protected_service_request"
7
- require_relative "../molecules/ordinary_socket"
8
- require "ace/assign/atoms/fixed_merge_schemas"
9
- require "ace/assign/molecules/installed_journal_read"
10
-
11
- module Ace
12
- module Lab
13
- module Organisms
14
- # The ordinary fixed merge admission producer (spec 8x3.t.xz9.4). One
15
- # mode of the maintained `service request/status` vocabulary: it
16
- # canonicalizes the immutable request binding, authenticates the
17
- # verified project overseer against the installed ordinary mapping,
18
- # resolves the named queue entry and attempt, transports the request
19
- # to the configured ordinary receiver, and emits the adopted ace-lab
20
- # envelopes — never a second protocol. Refusals are pre-effect and
21
- # carry no durable reference; unavailability is a caller-side blocker;
22
- # the canonical Assign attempt/evidence state stays the sole
23
- # queue/effect record, so this class owns no ledger.
24
- class OrdinaryMergeRequest
25
- MODE = "ordinary-merge"
26
- OPERATION = "merge"
27
-
28
- # The closed pre-effect refusal codes (exhaustive pre-effect mapping).
29
- CODES = %w[ordinary_merge_invalid_request ordinary_merge_unauthenticated
30
- ordinary_merge_forbidden ordinary_merge_selection_mismatch
31
- ordinary_merge_authorization_invalid ordinary_merge_provenance_mismatch
32
- ordinary_merge_target_changed ordinary_merge_evidence_invalid
33
- ordinary_merge_request_conflict ordinary_merge_unavailable].freeze
34
-
35
- # Selector keys this producer accepts; anything else — including the
36
- # protected mapping/scope/service/generation selectors and any
37
- # executable/argv/UID/socket/evidence-path/credential/token selector
38
- # — refuses before effect.
39
- REQUEST_KEYS = %i[project assignment attempt queue_entry operation input authorization
40
- request_id mode].freeze
41
- STATUS_KEYS = %i[project assignment attempt queue_entry reference request input_digest
42
- mode format].freeze
43
-
44
- ID = Molecules::ServicePolicy::ID
45
-
46
- def initialize(topology: nil, policy: nil, coordinator: nil, repo_root: nil, ingress: nil)
47
- @topology = topology || TopologyService.from_config
48
- @repo_root = repo_root || git_toplevel
49
- @coordinator = coordinator
50
- @policy = policy || Molecules::ServicePolicy.new(
51
- Molecules::GrantResolver.trusted_document(Ace::Lab.authorization_path)
52
- )
53
- @ingress = ingress
54
- end
55
-
56
- def request(project:, assignment:, attempt:, queue_entry:, operation:, input:,
57
- authorization:, request_id:, **options)
58
- digest = nil
59
- unless (options.keys - REQUEST_KEYS).empty?
60
- invalid!("unknown request selector: #{unknown(options.keys, REQUEST_KEYS)}", request_id)
61
- end
62
- invalid!("operation must be merge", request_id) unless operation == OPERATION
63
- identifiers!(project: project, assignment: assignment, attempt: attempt,
64
- queue_entry: queue_entry, request_id: request_id)
65
- unless authorization.is_a?(String) && authorization.match?(ID)
66
- invalid!("invalid authorization reference", request_id)
67
- end
68
- document = load_request_document(input, request_id)
69
- validate_document!(document, request_id)
70
- digest = input_digest(project: project, assignment: assignment, attempt: attempt,
71
- queue_entry: queue_entry, authorization: authorization, request_id: request_id,
72
- document: document)
73
- principal = authenticate!(project)
74
- unless @coordinator
75
- return with_installed_journal(project) { |producer| producer.request(project: project,
76
- assignment: assignment, attempt: attempt, queue_entry: queue_entry, operation: operation,
77
- input: input, authorization: authorization, request_id: request_id, **options) }
78
- end
79
- existing = @coordinator.fixed_merge_record(request_id)
80
- if existing
81
- return ack_envelope(existing) if exact_replay?(existing, project: project,
82
- assignment: assignment, attempt: attempt, queue_entry: queue_entry,
83
- authorization: authorization, digest: digest)
84
- conflict!("request ID already exists with a different binding", request_id, digest)
85
- end
86
- receiver = receiver_mapping!(project)
87
- resolve_selection!(project: project, assignment: assignment, attempt: attempt,
88
- queue_entry: queue_entry)
89
- authorize!(authorization, project: project, assignment: assignment, attempt: attempt,
90
- queue_entry: queue_entry, document: document, digest: digest)
91
- verify_candidate_head!(document)
92
- submission = {"project_id" => project, "assignment_id" => assignment,
93
- "attempt_id" => attempt, "queue_entry" => queue_entry, "operation" => OPERATION,
94
- "authorization" => authorization, "request_id" => request_id,
95
- "input_digest" => digest, "request" => document, "principal" => principal,
96
- "receiver_id" => receiver.fetch("receiver_id"),
97
- "configuration_sha256" => receiver.fetch("configuration_sha256")}
98
- response = deliver(receiver, submission)
99
- normalize_response(response, request_id: request_id, input_digest: digest)
100
- rescue OrdinaryMergeRefusal => e
101
- self.class.refusal_envelope(e.code, e.detail, request_id: e.request_id || request_id,
102
- input_digest: e.input_digest || digest)
103
- rescue Ace::Assign::AttemptErrors::AuthorizationConflict => e
104
- self.class.refusal_envelope("ordinary_merge_authorization_invalid", e.message,
105
- request_id: request_id, input_digest: digest)
106
- rescue Ace::Assign::AttemptErrors::Conflict => e
107
- self.class.refusal_envelope("ordinary_merge_request_conflict", e.message,
108
- request_id: request_id, input_digest: digest)
109
- rescue Molecules::OrdinarySocket::OrdinaryUnavailable => e
110
- self.class.refusal_envelope("ordinary_merge_unavailable", e.message,
111
- request_id: request_id, input_digest: digest)
112
- rescue Ace::Assign::AttemptErrors::EvidenceUnavailable, Ace::Lab::InvalidConfigurationError => e
113
- # Canonical evidence or installed configuration is unreadable: a
114
- # caller-side blocker (endpoint/configuration failed), never a
115
- # refusal and never a durable reference.
116
- self.class.refusal_envelope("ordinary_merge_unavailable", e.message,
117
- request_id: request_id, input_digest: digest)
118
- end
119
-
120
- # Read-only status: resolve the opaque reference against the canonical
121
- # record with every selector; performs no dispatch, retry,
122
- # authorization refresh or selector repair. Read-only status survives
123
- # rollback and receiver downtime because the canonical Assign state —
124
- # never a receiver — owns the record.
125
- def status(project:, assignment:, attempt:, queue_entry:, reference:, request:,
126
- input_digest:, **options)
127
- unless (options.keys - STATUS_KEYS).empty?
128
- return status_error(self.class.refusal_envelope("ordinary_merge_invalid_request",
129
- "unknown status selector: #{unknown(options.keys, STATUS_KEYS)}",
130
- request_id: request, input_digest: nil,
131
- schema: Ace::Assign::Atoms::FixedMergeSchemas::STATUS_ERROR_SCHEMA_ID))
132
- end
133
- identifiers!(project: project, assignment: assignment, attempt: attempt,
134
- queue_entry: queue_entry, request_id: request)
135
- unless options[:format] == "json"
136
- invalid!("status requires --format json", request)
137
- end
138
- unless reference.is_a?(String) && Ace::Assign::Molecules::FixedEffectReference.valid?(reference)
139
- invalid!("invalid reference", request)
140
- end
141
- unless input_digest.is_a?(String) && input_digest.match?(/\A[0-9a-f]{64}\z/)
142
- invalid!("invalid input digest", request)
143
- end
144
- authenticate!(project)
145
- unless @coordinator
146
- return with_installed_journal(project) { |producer| producer.status(project: project,
147
- assignment: assignment, attempt: attempt, queue_entry: queue_entry, reference: reference,
148
- request: request, input_digest: input_digest, **options) }
149
- end
150
- record = @coordinator.fixed_merge_status(reference: reference, request_id: request,
151
- project_id: project, assignment_id: assignment, attempt_id: attempt,
152
- queue_entry: queue_entry, input_digest: input_digest)
153
- # Unknown or mismatched references answer identically, without
154
- # revealing whether another project holds the reference.
155
- return status_error(selection_error) unless record
156
- {"status" => "ok", "data" => {
157
- "schema" => Ace::Assign::Atoms::FixedMergeSchemas::STATUS_SCHEMA_ID,
158
- "state" => record.fetch("state"),
159
- "reference" => record.fetch("reference"),
160
- "request_id" => record.fetch("request_id"),
161
- "input_digest" => record.fetch("input_digest"),
162
- "receipt" => %w[pending uncertain].include?(record.fetch("state")) ? nil : record["receipt"]
163
- }}
164
- rescue OrdinaryMergeRefusal => e
165
- status_error(self.class.refusal_envelope(e.code, e.detail, request_id: request, input_digest: nil,
166
- schema: Ace::Assign::Atoms::FixedMergeSchemas::STATUS_ERROR_SCHEMA_ID))
167
- rescue Ace::Assign::AttemptErrors::EvidenceUnavailable, Ace::Lab::InvalidConfigurationError => e
168
- # Canonical state or installed configuration unreadable: the status
169
- # command degrades to the caller-side blocker envelope, never to a
170
- # fabricated state or a raw crash.
171
- status_error(self.class.refusal_envelope("ordinary_merge_unavailable", e.message,
172
- request_id: request, input_digest: nil,
173
- schema: Ace::Assign::Atoms::FixedMergeSchemas::STATUS_ERROR_SCHEMA_ID))
174
- end
175
-
176
- # ---- envelope composition (the adopted ace-lab vocabulary) ---------
177
-
178
- def ack_envelope(record)
179
- {"status" => "ok", "data" => {
180
- "schema" => Ace::Assign::Atoms::FixedMergeSchemas::ACK_SCHEMA_ID,
181
- "type" => "accepted",
182
- "request_id" => record.fetch("request_id"),
183
- "input_digest" => record.fetch("input_digest"),
184
- "reference" => record.fetch("reference")
185
- }}
186
- end
187
-
188
- def self.sanitize(message)
189
- cleaned = message.to_s.scrub.gsub(/\s+/, " ").strip[0, 512]
190
- cleaned.empty? ? "ordinary merge request refused" : cleaned
191
- end
192
-
193
- # Envelope composition is instance-independent; the CLI reuses it for
194
- # mode-selection refusals before any producer exists.
195
- def self.refusal_envelope(code, message, request_id:, input_digest:,
196
- schema: Ace::Assign::Atoms::FixedMergeSchemas::REFUSAL_SCHEMA_ID)
197
- raise ArgumentError, "unknown ordinary merge refusal code" unless CODES.include?(code)
198
- echo = request_id.to_s[0, 256]
199
- echo = "unknown" if echo.empty?
200
- {"status" => "error", "error" => {
201
- "schema" => schema,
202
- "code" => code,
203
- "message" => sanitize(message),
204
- "request_id" => echo,
205
- "input_digest" => input_digest
206
- }}
207
- end
208
-
209
- private
210
-
211
- def with_installed_journal(project)
212
- receiver = @policy.ordinary_receiver!(project)
213
- unless receiver && %w[journal_repository journal_ref].all? { |key| receiver.key?(key) }
214
- raise Ace::Lab::InvalidConfigurationError, "installed ordinary journal selection is missing"
215
- end
216
- repository, ref = receiver.values_at("journal_repository", "journal_ref")
217
- boundary = Ace::Assign::Molecules::InstalledJournalRead.new(repo_root: repository,
218
- ref: ref, owner_uid: receiver.fetch("receiver_uid"))
219
- boundary.with(deadline: Process.clock_gettime(Process::CLOCK_MONOTONIC) + 30) do |view|
220
- journal = Ace::Assign::Molecules::EvidenceJournal.new(repo_root: repository,
221
- ref: ref, checkout_root: File.join(repository, ".unused-reader"), read_boundary: view)
222
- coordinator = Ace::Assign::Organisms::AttemptCoordinator.new(repo_root: @repo_root, journal: journal)
223
- yield self.class.new(topology: @topology, policy: @policy, repo_root: @repo_root,
224
- coordinator: coordinator, ingress: @ingress)
225
- end
226
- end
227
-
228
- # The immutable binding digest — spec ruling r4: lowercase hex SHA-256
229
- # over the canonical JSON (recursively key-sorted, compact, UTF-8) of
230
- # the full eight-field binding. Computed once here at admission; the
231
- # same shared implementation re-derives it for replay and receipt
232
- # verification, so the pair can never diverge.
233
- def input_digest(project:, assignment:, attempt:, queue_entry:, authorization:,
234
- request_id:, document:)
235
- Ace::Assign::Atoms::FixedMergeSchemas.canonical_request_digest(
236
- project: project, assignment: assignment, attempt: attempt,
237
- queue_entry: queue_entry, authorization: authorization,
238
- request_id: request_id, input: document
239
- )
240
- end
241
-
242
- def validate_document!(document, request_id)
243
- Ace::Assign::Atoms::FixedMergeSchemas.validate_request!(document)
244
- Ace::Assign::Atoms::FixedMergeSchemas.require_request_joins!(document)
245
- rescue Ace::Assign::Atoms::FixedMergeSchemas::SchemaError => e
246
- invalid!(e.message, request_id)
247
- end
248
-
249
- # The verified local process owner is the only caller identity; it
250
- # must be the configured ordinary project overseer principal.
251
- def authenticate!(project)
252
- identities = Molecules::CallerAuthorizer.local_identity
253
- refused("ordinary_merge_unauthenticated", "verified local identity is unavailable") if identities.empty?
254
- receiver = @policy.ordinary_receiver!(project)
255
- unless receiver.is_a?(Hash) && identities.include?(receiver.fetch("principal"))
256
- refused("ordinary_merge_forbidden",
257
- "authenticated principal is not the configured ordinary project overseer")
258
- end
259
- receiver.fetch("principal")
260
- end
261
-
262
- # The installed ordinary section supplies identity and placement only.
263
- # A disabled or absent admission mapping is rollback: a caller-side
264
- # blocker that never redirects ordinary callers and never deletes the
265
- # canonical records — read-only status stays fully available.
266
- def receiver_mapping!(project)
267
- receiver = @policy.ordinary_receiver!(project)
268
- if receiver.nil? || receiver.fetch("admission", "enabled") != "enabled"
269
- raise Molecules::OrdinarySocket::OrdinaryUnavailable,
270
- "ordinary merge admission is not available in the installed configuration"
271
- end
272
- receiver.merge("configuration_sha256" => Molecules::ServicePolicy.configuration_digest(@policy.document))
273
- end
274
-
275
- def resolve_selection!(project:, assignment:, attempt:, queue_entry:)
276
- @coordinator.service_attempt("assignment_id" => assignment, "attempt_id" => attempt,
277
- "project_id" => project)
278
- rescue Ace::Assign::AttemptErrors::NotFound, Ace::Assign::AttemptErrors::ReceiptRejected,
279
- Ace::Assign::AttemptErrors::InvalidState => e
280
- refused("ordinary_merge_selection_mismatch", e.message)
281
- end
282
-
283
- def authorize!(authorization, project:, assignment:, attempt:, queue_entry:, document:, digest:)
284
- binding = {"operation" => OPERATION, "project_id" => project,
285
- "assignment_id" => assignment, "attempt_id" => attempt, "queue_entry" => queue_entry,
286
- "input_digest" => digest, "forge" => document.fetch("forge"),
287
- "pr_url" => document.fetch("pr_url"), "head_repository" => document.fetch("head_repository"),
288
- "head_ref" => document.fetch("head_ref"), "candidate_head" => document.fetch("candidate_head"),
289
- "base_repository" => document.fetch("base_repository"), "base_ref" => document.fetch("base_ref"),
290
- "expected_base_head" => document.fetch("expected_base_head")}
291
- if authorization.start_with?("proposal-")
292
- @coordinator.proposal_authorization(authorization, binding.slice(
293
- "operation", "project_id", "assignment_id", "attempt_id", "input_digest", "candidate_head").merge(
294
- "target" => {"resource" => document.fetch("pr_url"), "artifact_digest" => nil},
295
- "caller_uid" => Process.uid))
296
- else
297
- @policy.authorize_ordinary!(authorization, binding)
298
- end
299
- rescue SecurityError, Ace::Assign::AttemptErrors::ReceiptRejected => e
300
- # Spec table (ruling r4): the authorization decision or reference is
301
- # invalid for this request — missing, expired, revoked, out of scope,
302
- # bound to another operation/project/PR/head, or not an ordinary
303
- # merge decision at all. Authorization-decision mismatches NEVER use
304
- # provenance_mismatch; that code is reserved for registered
305
- # provenance drift verified by the receiver (and relayed verbatim).
306
- refused("ordinary_merge_authorization_invalid", e.message)
307
- end
308
-
309
- def verify_candidate_head!(document)
310
- unless current_head == document.fetch("candidate_head")
311
- refused("ordinary_merge_target_changed", "candidate head moved from the immutable request")
312
- end
313
- end
314
-
315
- def deliver(receiver, submission)
316
- ingress = @ingress || default_ingress(receiver)
317
- ingress.call(submission: submission)
318
- end
319
-
320
- def default_ingress(receiver)
321
- socket = Molecules::OrdinarySocket.new(socket_path: receiver.fetch("socket_path"),
322
- receiver_uid: receiver.fetch("receiver_uid"))
323
- ->(submission:) { socket.submit(submission) }
324
- end
325
-
326
- # A valid receiver acknowledgment is relayed exactly; a valid receiver
327
- # refusal is relayed with its closed code; anything else is a lost or
328
- # unauthenticated response — a caller-side blocker, never a proven
329
- # refusal and never a resubmission authorization.
330
- def normalize_response(response, request_id:, input_digest:)
331
- unless response.is_a?(Hash) && (response.keys - %w[status data error]).empty? &&
332
- %w[ok error].include?(response["status"])
333
- raise Molecules::OrdinarySocket::OrdinaryUnavailable, "receiver response envelope is invalid"
334
- end
335
- return normalize_ack(response, request_id: request_id, input_digest: input_digest) if
336
- response.fetch("status") == "ok"
337
- normalize_refusal(response, request_id: request_id, input_digest: input_digest)
338
- end
339
-
340
- def normalize_ack(response, request_id:, input_digest:)
341
- data = response.fetch("data") { {} }
342
- expected = %w[schema type request_id input_digest reference]
343
- digest = data.is_a?(Hash) && data["input_digest"]
344
- unless data.is_a?(Hash) && (data.keys - expected).empty? &&
345
- expected.all? { |key| data[key].is_a?(String) && !data[key].empty? } &&
346
- data.fetch("schema") == Ace::Assign::Atoms::FixedMergeSchemas::ACK_SCHEMA_ID &&
347
- data.fetch("type") == "accepted" && data.fetch("request_id") == request_id &&
348
- digest == input_digest && digest.match?(/\A[0-9a-f]{64}\z/) &&
349
- Ace::Assign::Molecules::FixedEffectReference.valid?(data.fetch("reference"))
350
- raise Molecules::OrdinarySocket::OrdinaryUnavailable,
351
- "receiver acknowledgment is invalid or lost before durability could be confirmed"
352
- end
353
- {"status" => "ok", "data" => data.slice(*expected)}
354
- end
355
-
356
- def normalize_refusal(response, request_id:, input_digest:)
357
- error = response.fetch("error") { {} }
358
- expected = %w[schema code message request_id input_digest]
359
- valid = error.is_a?(Hash) && error.keys.sort == expected.sort &&
360
- error.fetch("schema") == Ace::Assign::Atoms::FixedMergeSchemas::REFUSAL_SCHEMA_ID &&
361
- CODES.include?(error.fetch("code")) &&
362
- error.fetch("message").is_a?(String) && !error.fetch("message").empty? &&
363
- error.fetch("message").length <= 512 &&
364
- error.fetch("request_id") == request_id &&
365
- (error.fetch("input_digest").nil? || error.fetch("input_digest") == input_digest)
366
- unless valid
367
- raise Molecules::OrdinarySocket::OrdinaryUnavailable, "receiver refusal envelope is invalid"
368
- end
369
- {"status" => "error", "error" => error.slice(*expected)}
370
- end
371
-
372
- def selection_error
373
- self.class.refusal_envelope("ordinary_merge_selection_mismatch",
374
- "reference and selectors do not match a canonical ordinary merge record",
375
- request_id: nil, input_digest: nil,
376
- schema: Ace::Assign::Atoms::FixedMergeSchemas::STATUS_ERROR_SCHEMA_ID)
377
- end
378
-
379
- def status_error(envelope)
380
- {"status" => "error", "error" => envelope.fetch("error")}
381
- end
382
-
383
- def identifiers!(project:, assignment:, attempt:, queue_entry:, request_id:)
384
- {"project" => project, "assignment" => assignment, "attempt" => attempt,
385
- "queue_entry" => queue_entry, "request_id" => request_id}.each do |name, value|
386
- invalid!("invalid #{name} ID", request_id) unless value.is_a?(String) && value.match?(ID)
387
- end
388
- end
389
-
390
- def load_request_document(input, request_id)
391
- Atoms::ServiceInput.load(input)
392
- rescue ArgumentError => e
393
- invalid!(e.message, request_id)
394
- end
395
-
396
- def unknown(keys, allowed)
397
- (keys - allowed).sort.join(", ")
398
- end
399
-
400
- # A pre-effect refusal carries its closed code, sanitized message and
401
- # the request binding for the wire envelope — never a reference.
402
- OrdinaryMergeRefusal = Class.new(::StandardError) do
403
- attr_reader :code, :detail, :request_id, :input_digest
404
-
405
- def initialize(code, detail, request_id: nil, input_digest: nil)
406
- @code = code
407
- @detail = detail
408
- @request_id = request_id
409
- @input_digest = input_digest
410
- super(detail)
411
- end
412
- end
413
-
414
- def invalid!(message, request_id)
415
- raise OrdinaryMergeRefusal.new("ordinary_merge_invalid_request", message, request_id: request_id)
416
- end
417
-
418
- def conflict!(message, request_id, digest)
419
- raise OrdinaryMergeRefusal.new("ordinary_merge_request_conflict", message,
420
- request_id: request_id, input_digest: digest)
421
- end
422
-
423
- # A replay must match every caller-supplied immutable field: command
424
- # selectors, authorization, input byte meaning and canonical digest.
425
- def exact_replay?(existing, project:, assignment:, attempt:, queue_entry:,
426
- authorization:, digest:)
427
- existing["state"] &&
428
- existing.slice("project_id", "assignment_id", "attempt_id", "queue_entry",
429
- "authorization", "input_digest") == {
430
- "project_id" => project, "assignment_id" => assignment, "attempt_id" => attempt,
431
- "queue_entry" => queue_entry, "authorization" => authorization, "input_digest" => digest
432
- }
433
- end
434
-
435
- def refused(code, message)
436
- raise OrdinaryMergeRefusal.new(code, message)
437
- end
438
-
439
- # The git toplevel (not a package directory that happens to hold a
440
- # Rakefile) bounds candidate-head verification for nested invocations.
441
- def git_toplevel
442
- out, status = Open3.capture2("git", "rev-parse", "--show-toplevel")
443
- (status&.success? ? out.strip : Dir.pwd)
444
- rescue Errno::ENOENT
445
- Dir.pwd
446
- end
447
-
448
- def current_head
449
- out, _stderr, status = Open3.capture3("git", "rev-parse", "HEAD", chdir: @repo_root)
450
- refused("ordinary_merge_target_changed", "candidate head is unavailable") unless status.success?
451
- out.strip
452
- end
453
- end
454
- end
455
- end
456
- end