ace-assign 0.63.1 → 0.64.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.
- checksums.yaml +4 -4
- data/.ace-defaults/assign/catalog/steps/create-pr.step.yml +2 -2
- data/.ace-defaults/assign/catalog/steps/mark-pr-ready.step.yml +1 -1
- data/.ace-defaults/assign/catalog/steps/release-minor.step.yml +4 -2
- data/.ace-defaults/assign/catalog/steps/release.step.yml +4 -2
- data/.ace-defaults/assign/catalog/steps/update-pr-desc.step.yml +1 -1
- data/.ace-defaults/assign/presets/fix-bug.yml +15 -3
- data/.ace-defaults/assign/presets/work-on-task-ace-development.yml +16 -4
- data/.ace-defaults/assign/presets/work-on-task-auto-merge.yml +15 -3
- data/.ace-defaults/assign/presets/work-on-task.yml +16 -4
- data/CHANGELOG.md +43 -0
- data/docs/demo/canonical-skill-source.tape.yml +4 -4
- data/docs/protected-authority.md +266 -0
- data/docs/usage.md +91 -2
- data/handbook/workflow-instructions/assign/drive.wf.md +2 -2
- data/handbook/workflow-instructions/assign/prepare.wf.md +2 -2
- data/lib/ace/assign/atoms/delivery_parameters.rb +54 -0
- data/lib/ace/assign/authority/candidate_transfer.rb +160 -0
- data/lib/ace/assign/authority/client.rb +107 -0
- data/lib/ace/assign/authority/deployment.rb +465 -0
- data/lib/ace/assign/authority/endcap.rb +342 -0
- data/lib/ace/assign/authority/endcap_inboxes.rb +314 -0
- data/lib/ace/assign/authority/endcap_results.rb +380 -0
- data/lib/ace/assign/authority/endcap_services.rb +395 -0
- data/lib/ace/assign/authority/evidence_transfer.rb +67 -0
- data/lib/ace/assign/authority/execution_scope_observation.rb +476 -0
- data/lib/ace/assign/authority/launch_driver.rb +114 -0
- data/lib/ace/assign/authority/launch_lifecycle.rb +822 -0
- data/lib/ace/assign/authority/launch_scope_abort.rb +88 -0
- data/lib/ace/assign/authority/launch_scope_admission.rb +84 -0
- data/lib/ace/assign/authority/launch_scope_close.rb +115 -0
- data/lib/ace/assign/authority/launch_scope_parent.rb +44 -0
- data/lib/ace/assign/authority/launch_scope_release.rb +180 -0
- data/lib/ace/assign/authority/posix_acl.rb +56 -0
- data/lib/ace/assign/authority/private_directory.rb +33 -0
- data/lib/ace/assign/authority/receipt_transfer.rb +48 -0
- data/lib/ace/assign/authority/router.rb +54 -0
- data/lib/ace/assign/authority/server.rb +230 -0
- data/lib/ace/assign/authority/service_evidence.rb +55 -0
- data/lib/ace/assign/authority/transfer_codec.rb +155 -0
- data/lib/ace/assign/cli/commands/authority/launch.rb +45 -0
- data/lib/ace/assign/cli/commands/authority/serve.rb +30 -0
- data/lib/ace/assign/cli/commands/authority/status.rb +26 -0
- data/lib/ace/assign/cli/commands/authority/terminate.rb +29 -0
- data/lib/ace/assign/cli/commands/create.rb +9 -1
- data/lib/ace/assign/cli/commands/delivery.rb +57 -0
- data/lib/ace/assign/cli/commands/inbox_reconcile.rb +36 -0
- data/lib/ace/assign/cli/commands/resume.rb +26 -0
- data/lib/ace/assign/cli.rb +22 -1
- data/lib/ace/assign/models/evidence_event.rb +3 -1
- data/lib/ace/assign/molecules/attempt_reconciler.rb +31 -12
- data/lib/ace/assign/molecules/canonical_evidence.rb +150 -0
- data/lib/ace/assign/molecules/evidence_journal.rb +218 -63
- data/lib/ace/assign/molecules/execution_identity_resolver.rb +49 -9
- data/lib/ace/assign/molecules/execution_scope_lineage.rb +327 -0
- data/lib/ace/assign/molecules/journal_mutation.rb +202 -0
- data/lib/ace/assign/molecules/lifecycle_exclusion.rb +4 -0
- data/lib/ace/assign/molecules/proposal_journal.rb +151 -0
- data/lib/ace/assign/molecules/receipt_verifier.rb +49 -19
- data/lib/ace/assign/organisms/assignment_executor.rb +6 -0
- data/lib/ace/assign/organisms/attempt_coordinator.rb +312 -13
- data/lib/ace/assign/organisms/delivery_coordinator.rb +342 -0
- data/lib/ace/assign/organisms/task_assignment_creator.rb +8 -3
- data/lib/ace/assign/version.rb +1 -1
- data/lib/ace/assign.rb +13 -0
- metadata +107 -8
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "digest"
|
|
4
3
|
require "digest"
|
|
5
4
|
require "fileutils"
|
|
6
5
|
require "json"
|
|
7
6
|
require "open3"
|
|
7
|
+
require_relative "proposal_journal"
|
|
8
|
+
require_relative "journal_mutation"
|
|
8
9
|
|
|
9
10
|
module Ace
|
|
10
11
|
module Assign
|
|
@@ -25,12 +26,25 @@ module Ace
|
|
|
25
26
|
# reloads the authoritative ref and replays only still-valid,
|
|
26
27
|
# non-duplicate events.
|
|
27
28
|
class EvidenceJournal
|
|
29
|
+
include JournalMutation
|
|
30
|
+
include ProposalJournal
|
|
28
31
|
CAS_ATTEMPTS = 3
|
|
29
32
|
|
|
33
|
+
# Selected by source composition, never by receipt or wire parameters.
|
|
34
|
+
def evidence_mode
|
|
35
|
+
@mode
|
|
36
|
+
end
|
|
37
|
+
|
|
30
38
|
# @param repo_root [String] Git repository root holding the evidence ref
|
|
31
39
|
# @param ref [String, nil] Evidence ref (default from config)
|
|
32
40
|
# @param checkout_root [String, nil] Isolated checkout root (default from config)
|
|
33
|
-
def initialize(repo_root:, ref: nil, checkout_root: nil
|
|
41
|
+
def initialize(repo_root:, ref: nil, checkout_root: nil, mode: :local,
|
|
42
|
+
evidence_reader: nil, service_authorizer: nil)
|
|
43
|
+
unless %i[local protected].include?(mode) &&
|
|
44
|
+
(mode != :protected || [evidence_reader, service_authorizer].all? { |owner| owner.respond_to?(:call) })
|
|
45
|
+
raise ArgumentError, "protected journal requires source-owned evidence and service boundaries"
|
|
46
|
+
end
|
|
47
|
+
@mode, @evidence_reader, @service_authorizer = mode, evidence_reader, service_authorizer
|
|
34
48
|
@repo_root = repo_root
|
|
35
49
|
@ref = ref || default_config("evidence_git_ref") || "refs/ace/execution"
|
|
36
50
|
root = checkout_root || default_config("evidence_checkout_root") || ".ace-local/assign/evidence-checkout"
|
|
@@ -89,14 +103,36 @@ module Ace
|
|
|
89
103
|
end
|
|
90
104
|
end
|
|
91
105
|
|
|
106
|
+
# Build an event against the latest chain while holding the ref lock.
|
|
107
|
+
# Re-run the eligibility guard on each CAS retry, never append a
|
|
108
|
+
# stale chain continuation after another accepted writer.
|
|
109
|
+
def record(assignment_id:, attempt_id:, type:, payload:, guard: nil)
|
|
110
|
+
with_lock do
|
|
111
|
+
CAS_ATTEMPTS.times do
|
|
112
|
+
old = ref_value
|
|
113
|
+
ensure_checkout!
|
|
114
|
+
old = ref_value if old.nil?
|
|
115
|
+
sync_checkout(old)
|
|
116
|
+
guard&.call
|
|
117
|
+
previous = read_events(assignment_id).reverse.find { |event| event["attempt_id"] == attempt_id }&.fetch("digest")
|
|
118
|
+
event = Models::EvidenceEvent.build(type: type, attempt_id: attempt_id,
|
|
119
|
+
payload: payload, previous_digest: previous)
|
|
120
|
+
write_event_files(assignment_id, [event])
|
|
121
|
+
commit = commit_events(assignment_id, attempt_id, [event])
|
|
122
|
+
return commit if update_ref_cas(commit, old)
|
|
123
|
+
end
|
|
124
|
+
raise AttemptErrors::EvidenceUnavailable, "Evidence ref stayed conflicting during event recording"
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
92
128
|
# Read all journal events recorded for an assignment, ordered by the
|
|
93
129
|
# digest chain (never by filename: same-second events sort
|
|
94
130
|
# alphabetically, which can invert lifecycle order).
|
|
95
131
|
#
|
|
96
132
|
# @param assignment_id [String] Assignment ID
|
|
97
133
|
# @return [Array<Hash>] Parsed events in journal order
|
|
98
|
-
def read_events(assignment_id)
|
|
99
|
-
value =
|
|
134
|
+
def read_events(assignment_id, commit: ref_value)
|
|
135
|
+
value = commit
|
|
100
136
|
return [] if value.nil?
|
|
101
137
|
|
|
102
138
|
paths, stderr, status = git("ls-tree", "-r", "--name-only", value, "--",
|
|
@@ -197,7 +233,7 @@ module Ace
|
|
|
197
233
|
%w[succeeded failed failed-settled].include?(state)
|
|
198
234
|
end
|
|
199
235
|
|
|
200
|
-
def validate_terminal_receipt!(current, state, receipt)
|
|
236
|
+
def validate_terminal_receipt!(current, state, receipt, pending: nil)
|
|
201
237
|
unless receipt.is_a?(Hash) && receipt.keys.sort == TERMINAL_RECEIPT_FIELDS.sort
|
|
202
238
|
raise AttemptErrors::ReceiptRejected,
|
|
203
239
|
"Service terminal receipt has invalid fields"
|
|
@@ -224,6 +260,16 @@ module Ace
|
|
|
224
260
|
raise AttemptErrors::ReceiptRejected,
|
|
225
261
|
"Service settlement requires a no-effect attestation artifact"
|
|
226
262
|
end
|
|
263
|
+
if @mode == :protected
|
|
264
|
+
evidence_items.each do |item|
|
|
265
|
+
content = @evidence_reader.call(item, current, state, pending)
|
|
266
|
+
unless content.is_a?(String) && Digest::SHA256.hexdigest(content.b) == item["sha256"]
|
|
267
|
+
raise AttemptErrors::ReceiptRejected, "Canonical service evidence is unverifiable"
|
|
268
|
+
end
|
|
269
|
+
verify_service_attestation!(content, current, state)
|
|
270
|
+
end
|
|
271
|
+
return true
|
|
272
|
+
end
|
|
227
273
|
repo_root = File.realpath(@repo_root)
|
|
228
274
|
receipt["evidence"].each do |item|
|
|
229
275
|
path = File.expand_path(item["ref"], repo_root)
|
|
@@ -241,24 +287,21 @@ module Ace
|
|
|
241
287
|
end
|
|
242
288
|
raise AttemptErrors::ReceiptRejected,
|
|
243
289
|
"Service terminal receipt evidence is unverifiable: #{item["ref"]}" unless intact
|
|
244
|
-
|
|
245
|
-
input:#{Regexp.escape(current["input_digest"])} outcome:(\S+)( no-effect:(\S+))?$/
|
|
246
|
-
attested = File.read(real).scan(attestation).first
|
|
247
|
-
attested_outcome = state == "failed-settled" ? "failed" : state
|
|
248
|
-
unless attested && attested.first == attested_outcome &&
|
|
249
|
-
(state == "failed-settled" ? attested[2] == "true" : true)
|
|
250
|
-
raise AttemptErrors::ReceiptRejected,
|
|
251
|
-
"Service terminal receipt evidence does not attest #{attested_outcome}: #{item["ref"]}"
|
|
252
|
-
end
|
|
290
|
+
verify_service_attestation!(File.read(real), current, state)
|
|
253
291
|
end
|
|
254
292
|
end
|
|
255
293
|
|
|
256
|
-
def service_request(request_id)
|
|
294
|
+
def service_request(request_id, commit: ref_value)
|
|
257
295
|
validate_request_id!(request_id)
|
|
258
|
-
value =
|
|
296
|
+
value = commit
|
|
259
297
|
return nil unless value
|
|
260
298
|
out, stderr, status = git("show", "#{value}:#{service_request_path(request_id)}")
|
|
261
|
-
|
|
299
|
+
if status.success?
|
|
300
|
+
record = JSON.parse(out)
|
|
301
|
+
verify_service_record!(record, commit: value) if @mode == :protected
|
|
302
|
+
validate_terminal_receipt!(record, record["state"], record["receipt"], pending: {commit: value}) if @mode == :protected && terminal_state?(record["state"])
|
|
303
|
+
return record
|
|
304
|
+
end
|
|
262
305
|
return nil if stderr.include?("does not exist") || stderr.include?("exists on disk")
|
|
263
306
|
raise AttemptErrors::EvidenceUnavailable, "Cannot read service request: #{stderr}"
|
|
264
307
|
rescue JSON::ParserError
|
|
@@ -292,6 +335,12 @@ input:#{Regexp.escape(current["input_digest"])} outcome:(\S+)( no-effect:(\S+))?
|
|
|
292
335
|
def settlement_evidence_intact?(record)
|
|
293
336
|
receipt = record["receipt"]
|
|
294
337
|
return false unless receipt.is_a?(Hash)
|
|
338
|
+
if @mode == :protected
|
|
339
|
+
commit = ref_value
|
|
340
|
+
verify_service_record!(record, commit: commit)
|
|
341
|
+
validate_terminal_receipt!(record, "failed-settled", receipt, pending: {commit: commit})
|
|
342
|
+
return true
|
|
343
|
+
end
|
|
295
344
|
repo_root = File.realpath(@repo_root)
|
|
296
345
|
Array(receipt["evidence"]).all? do |item|
|
|
297
346
|
path = File.expand_path(item["ref"].to_s, repo_root)
|
|
@@ -307,10 +356,12 @@ input:#{Regexp.escape(current["input_digest"])} outcome:(\S+)( no-effect:(\S+))?
|
|
|
307
356
|
false
|
|
308
357
|
end
|
|
309
358
|
end
|
|
359
|
+
rescue AttemptErrors::EvidenceUnavailable, AttemptErrors::ReceiptRejected
|
|
360
|
+
false
|
|
310
361
|
end
|
|
311
362
|
|
|
312
|
-
def service_request_records
|
|
313
|
-
value =
|
|
363
|
+
def service_request_records(commit: ref_value)
|
|
364
|
+
value = commit
|
|
314
365
|
return [] unless value
|
|
315
366
|
paths, stderr, status = git("ls-tree", "-r", "--name-only", value, "--", "execution/requests/")
|
|
316
367
|
raise AttemptErrors::EvidenceUnavailable, "Cannot read service request index: #{stderr}" unless status.success?
|
|
@@ -319,7 +370,9 @@ input:#{Regexp.escape(current["input_digest"])} outcome:(\S+)( no-effect:(\S+))?
|
|
|
319
370
|
unless read_status.success?
|
|
320
371
|
raise AttemptErrors::EvidenceUnavailable, "Cannot read service request #{path}: #{error}"
|
|
321
372
|
end
|
|
322
|
-
JSON.parse(content)
|
|
373
|
+
record = JSON.parse(content)
|
|
374
|
+
verify_service_record!(record, commit: value) if @mode == :protected
|
|
375
|
+
record
|
|
323
376
|
end
|
|
324
377
|
rescue JSON::ParserError
|
|
325
378
|
raise AttemptErrors::EvidenceUnavailable, "Corrupt service request index"
|
|
@@ -366,8 +419,8 @@ input:#{Regexp.escape(current["input_digest"])} outcome:(\S+)( no-effect:(\S+))?
|
|
|
366
419
|
# ref itself (works even when the local assignment cache is gone).
|
|
367
420
|
#
|
|
368
421
|
# @return [Array<String>] Assignment IDs with journal evidence
|
|
369
|
-
def assignment_ids
|
|
370
|
-
value =
|
|
422
|
+
def assignment_ids(commit: ref_value)
|
|
423
|
+
value = commit
|
|
371
424
|
return [] if value.nil?
|
|
372
425
|
|
|
373
426
|
paths, stderr, status = git("ls-tree", "-d", "--name-only", value, "--", "execution/")
|
|
@@ -386,47 +439,20 @@ input:#{Regexp.escape(current["input_digest"])} outcome:(\S+)( no-effect:(\S+))?
|
|
|
386
439
|
ensure_checkout!
|
|
387
440
|
old = ref_value if old.nil?
|
|
388
441
|
sync_checkout(old)
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
conflict = guard.call
|
|
394
|
-
if conflict
|
|
395
|
-
raise AttemptErrors::Conflict,
|
|
396
|
-
"Authorization reference already consumed by request #{conflict.fetch("request_id")}"
|
|
397
|
-
end
|
|
398
|
-
end
|
|
399
|
-
path = File.join(checkout_dir, service_request_path(request_id))
|
|
400
|
-
existing = File.exist?(path) ? JSON.parse(File.read(path)) : nil
|
|
401
|
-
if expected.nil? && existing
|
|
402
|
-
return existing if existing.except("state", "receipt", "reason", "claimed_at") ==
|
|
403
|
-
replacement.except("state", "receipt", "reason", "claimed_at")
|
|
404
|
-
raise AttemptErrors::Conflict, "Service request #{request_id} has different input"
|
|
405
|
-
end
|
|
406
|
-
if expected && existing != expected
|
|
407
|
-
raise AttemptErrors::Conflict, "Service request #{request_id} changed during transition"
|
|
408
|
-
end
|
|
409
|
-
terminal = existing && %w[succeeded failed rejected].include?(existing["state"])
|
|
410
|
-
settlement = existing && existing["state"] == "failed" && replacement["state"] == "failed-settled"
|
|
411
|
-
if expected && existing && terminal && !settlement
|
|
412
|
-
raise AttemptErrors::InvalidState, "Service request #{request_id} is terminal"
|
|
413
|
-
end
|
|
414
|
-
|
|
415
|
-
FileUtils.mkdir_p(File.dirname(path))
|
|
416
|
-
File.write(path, JSON.pretty_generate(replacement))
|
|
442
|
+
prepared = prepare_service_update(request_id: request_id, expected: expected,
|
|
443
|
+
replacement: replacement, event_type: event_type, guard: guard)
|
|
444
|
+
return prepared.fetch(:record) if prepared[:replayed]
|
|
445
|
+
write_service_records([prepared])
|
|
417
446
|
assignment_id = replacement.fetch("assignment_id")
|
|
418
447
|
attempt_id = replacement.fetch("attempt_id")
|
|
419
448
|
prior = read_events(assignment_id).reverse
|
|
420
449
|
.find { |entry| entry["attempt_id"] == attempt_id }&.fetch("digest")
|
|
421
|
-
payload = {"request_id" => request_id, "state" => replacement.fetch("state"),
|
|
422
|
-
"input_digest" => replacement.fetch("input_digest"),
|
|
423
|
-
"receipt_digest" => replacement["receipt"] &&
|
|
424
|
-
Atoms::EvidenceDigest.digest(replacement["receipt"])}
|
|
425
450
|
event = Models::EvidenceEvent.build(type: event_type, attempt_id: attempt_id,
|
|
426
|
-
payload: payload, previous_digest: prior)
|
|
451
|
+
payload: prepared.fetch(:event).fetch(:payload), previous_digest: prior)
|
|
427
452
|
write_event_files(assignment_id, [event])
|
|
428
453
|
git!("-C", checkout_dir, "add", "--", service_request_path(request_id),
|
|
429
454
|
"execution/#{assignment_id}/events")
|
|
455
|
+
stage_service_records([prepared])
|
|
430
456
|
state = replacement.fetch("state")
|
|
431
457
|
git!("-C", checkout_dir, "-c", "user.name=ace-assign", "-c", "user.email=ace-assign@localhost",
|
|
432
458
|
"commit", "-m", "evidence: service request #{request_id} #{state}")
|
|
@@ -437,6 +463,120 @@ input:#{Regexp.escape(current["input_digest"])} outcome:(\S+)( no-effect:(\S+))?
|
|
|
437
463
|
end
|
|
438
464
|
end
|
|
439
465
|
|
|
466
|
+
# Shared owner for ordinary service writes and atomic authority
|
|
467
|
+
# imports. Call only after checkout sync, under this journal's CAS lock.
|
|
468
|
+
def prepare_service_update(request_id:, expected:, replacement:, event_type:, guard: nil, pending: nil)
|
|
469
|
+
validate_request_id!(request_id)
|
|
470
|
+
unless replacement.is_a?(Hash) && replacement["request_id"] == request_id &&
|
|
471
|
+
%w[service_claim service_transition].include?(event_type) &&
|
|
472
|
+
%w[accepted uncertain rejected succeeded failed failed-settled].include?(replacement["state"])
|
|
473
|
+
raise ArgumentError, "invalid service update plan"
|
|
474
|
+
end
|
|
475
|
+
existing = service_request(request_id)
|
|
476
|
+
if expected.nil? && existing
|
|
477
|
+
return {record: existing, replayed: true} if existing.except("state", "receipt", "reason", "claimed_at") ==
|
|
478
|
+
replacement.except("state", "receipt", "reason", "claimed_at")
|
|
479
|
+
raise AttemptErrors::Conflict, "Service request #{request_id} has different input"
|
|
480
|
+
end
|
|
481
|
+
if expected && existing != expected
|
|
482
|
+
raise AttemptErrors::Conflict, "Service request #{request_id} changed during transition"
|
|
483
|
+
end
|
|
484
|
+
if expected.nil?
|
|
485
|
+
unless event_type == "service_claim" && %w[accepted uncertain rejected].include?(replacement["state"])
|
|
486
|
+
raise AttemptErrors::InvalidState, "Service request needs an initial claim"
|
|
487
|
+
end
|
|
488
|
+
if %w[accepted uncertain].include?(replacement["state"])
|
|
489
|
+
if replacement["authorization"].to_s.start_with?("proposal-")
|
|
490
|
+
unless respond_to?(:proposal_authorize!, true)
|
|
491
|
+
raise AttemptErrors::UnauthorizedIdentity, "Canonical proposal producer is unavailable"
|
|
492
|
+
end
|
|
493
|
+
proposal_authorize!(replacement["authorization"], replacement)
|
|
494
|
+
end
|
|
495
|
+
conflict = authorization_conflict(replacement)
|
|
496
|
+
raise_authorization_conflict!(conflict) if conflict
|
|
497
|
+
end
|
|
498
|
+
else
|
|
499
|
+
unless event_type == "service_transition"
|
|
500
|
+
raise AttemptErrors::InvalidState, "Existing service request needs a transition"
|
|
501
|
+
end
|
|
502
|
+
terminal = %w[succeeded failed rejected failed-settled].include?(existing["state"])
|
|
503
|
+
settlement = replacement["state"] == "failed-settled" &&
|
|
504
|
+
(existing["state"] == "failed" || (existing["state"] == "rejected" && existing["consumed"] != false))
|
|
505
|
+
if terminal && !settlement
|
|
506
|
+
raise AttemptErrors::InvalidState, "Service request #{request_id} is terminal"
|
|
507
|
+
end
|
|
508
|
+
mutable = %w[state receipt reason claimed_at failed_at dispatch_phase no_effect_challenge
|
|
509
|
+
challenge_generation challenge_event_digest completion_digest]
|
|
510
|
+
unless existing.except(*mutable) == replacement.except(*mutable)
|
|
511
|
+
raise AttemptErrors::Conflict, "Service request #{request_id} changed immutable binding"
|
|
512
|
+
end
|
|
513
|
+
end
|
|
514
|
+
conflict = guard&.call
|
|
515
|
+
raise_authorization_conflict!(conflict) if conflict
|
|
516
|
+
@service_authorizer.call(existing, replacement, pending) if @mode == :protected
|
|
517
|
+
validate_terminal_receipt!(replacement, replacement["state"], replacement["receipt"], pending: pending) if terminal_state?(replacement["state"])
|
|
518
|
+
payload = {"request_id" => request_id, "state" => replacement.fetch("state"),
|
|
519
|
+
"input_digest" => replacement.fetch("input_digest"),
|
|
520
|
+
"record_digest" => Atoms::EvidenceDigest.digest(replacement),
|
|
521
|
+
"receipt_digest" => replacement["receipt"] && Atoms::EvidenceDigest.digest(replacement["receipt"])}
|
|
522
|
+
{record: replacement, path: service_request_path(request_id),
|
|
523
|
+
event: {type: event_type, payload: payload}, replayed: false}
|
|
524
|
+
end
|
|
525
|
+
|
|
526
|
+
def prepare_service_updates(updates, assignment_id:, attempt_id:, pending:)
|
|
527
|
+
unless updates.is_a?(Array) && updates.length <= 1
|
|
528
|
+
raise ArgumentError, "one service update is allowed per authority mutation"
|
|
529
|
+
end
|
|
530
|
+
updates.map do |update|
|
|
531
|
+
unless update.is_a?(Hash) && update.keys.sort == %i[event_type expected replacement request_id].sort &&
|
|
532
|
+
update.dig(:replacement, "assignment_id") == assignment_id && update.dig(:replacement, "attempt_id") == attempt_id
|
|
533
|
+
raise ArgumentError, "service update does not match mutation attempt"
|
|
534
|
+
end
|
|
535
|
+
prepare_service_update(**update, pending: pending)
|
|
536
|
+
end.reject { |prepared| prepared[:replayed] }
|
|
537
|
+
end
|
|
538
|
+
|
|
539
|
+
def write_service_records(records)
|
|
540
|
+
records.each do |prepared|
|
|
541
|
+
path = File.join(checkout_dir, prepared.fetch(:path))
|
|
542
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
543
|
+
File.write(path, JSON.pretty_generate(prepared.fetch(:record)))
|
|
544
|
+
end
|
|
545
|
+
end
|
|
546
|
+
|
|
547
|
+
def stage_service_records(records)
|
|
548
|
+
stage_mutation_blobs(records.to_h { |prepared| [prepared.fetch(:path), JSON.pretty_generate(prepared.fetch(:record))] })
|
|
549
|
+
end
|
|
550
|
+
|
|
551
|
+
def verify_service_record!(record, commit: ref_value)
|
|
552
|
+
events = read_events(record.fetch("assignment_id"), commit: commit).select { |event| event["attempt_id"] == record.fetch("attempt_id") }
|
|
553
|
+
unless Models::EvidenceEvent.chain_valid?(events)
|
|
554
|
+
raise AttemptErrors::EvidenceUnavailable, "Canonical service event chain is unverifiable"
|
|
555
|
+
end
|
|
556
|
+
event = events.reverse.find do |entry|
|
|
557
|
+
%w[service_claim service_transition].include?(entry["type"]) && entry.dig("payload", "request_id") == record["request_id"]
|
|
558
|
+
end
|
|
559
|
+
unless event && event.dig("payload", "record_digest") == Atoms::EvidenceDigest.digest(record)
|
|
560
|
+
raise AttemptErrors::EvidenceUnavailable, "Canonical service record does not match accepted event"
|
|
561
|
+
end
|
|
562
|
+
rescue KeyError, TypeError
|
|
563
|
+
raise AttemptErrors::EvidenceUnavailable, "Canonical service record has invalid binding"
|
|
564
|
+
end
|
|
565
|
+
|
|
566
|
+
def raise_authorization_conflict!(conflict)
|
|
567
|
+
raise AttemptErrors::Conflict,
|
|
568
|
+
"Authorization reference already consumed by request #{conflict.fetch('request_id')}"
|
|
569
|
+
end
|
|
570
|
+
|
|
571
|
+
def verify_service_attestation!(content, request, state)
|
|
572
|
+
expression = /^ace-service-attestation request:#{Regexp.escape(request["request_id"])} input:#{Regexp.escape(request["input_digest"])} outcome:(\S+)( no-effect:(\S+))?$/
|
|
573
|
+
attested = content.scan(expression).first
|
|
574
|
+
outcome = state == "failed-settled" ? "failed" : state
|
|
575
|
+
unless attested && attested.first == outcome && (state != "failed-settled" || attested[2] == "true")
|
|
576
|
+
raise AttemptErrors::ReceiptRejected, "Service terminal evidence does not attest #{outcome}"
|
|
577
|
+
end
|
|
578
|
+
end
|
|
579
|
+
|
|
440
580
|
def service_request_path(request_id)
|
|
441
581
|
"execution/requests/#{request_id}.json"
|
|
442
582
|
end
|
|
@@ -451,9 +591,11 @@ input:#{Regexp.escape(current["input_digest"])} outcome:(\S+)( no-effect:(\S+))?
|
|
|
451
591
|
def derive_state(events)
|
|
452
592
|
return nil unless events.any? { |event| event["type"] == "intent" }
|
|
453
593
|
|
|
454
|
-
state = "
|
|
594
|
+
state = "reserved"
|
|
455
595
|
events.each do |event|
|
|
456
596
|
case event["type"]
|
|
597
|
+
when "process_start"
|
|
598
|
+
state = "running"
|
|
457
599
|
when "receipt_accepted"
|
|
458
600
|
state = event.dig("payload", "receipt", "verdict") || state
|
|
459
601
|
when "transition"
|
|
@@ -469,19 +611,24 @@ input:#{Regexp.escape(current["input_digest"])} outcome:(\S+)( no-effect:(\S+))?
|
|
|
469
611
|
process_start = events.reverse.find do |event|
|
|
470
612
|
event["type"] == "process_start" && event["attempt_id"] == attempt_id
|
|
471
613
|
end
|
|
472
|
-
candidate_head =
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
614
|
+
candidate_head = nil
|
|
615
|
+
events.each do |event|
|
|
616
|
+
case event["type"]
|
|
617
|
+
when "candidate_invalidated" then candidate_head = nil
|
|
618
|
+
when "receipt_accepted" then candidate_head ||= event.dig("payload", "receipt", "head")
|
|
619
|
+
when "delivery"
|
|
620
|
+
candidate_head = event.dig("payload", "head") if %w[intent result].include?(event.dig("payload", "stage"))
|
|
621
|
+
end
|
|
622
|
+
end
|
|
476
623
|
binding = Models::AttemptBinding.new(
|
|
477
624
|
attempt_id: attempt_id,
|
|
478
625
|
assignment_id: assignment_id,
|
|
479
626
|
scope: intent_payload["scope"],
|
|
480
627
|
project_id: intent_payload["project_id"],
|
|
481
628
|
task_id: intent_payload["task_id"],
|
|
482
|
-
actor: process_start&.dig("payload", "actor") || "recovered",
|
|
483
|
-
role: process_start&.dig("payload", "role") || "coordinator",
|
|
484
|
-
runtime: process_start&.dig("payload", "runtime") || "recovered",
|
|
629
|
+
actor: process_start&.dig("payload", "actor") || intent_payload["actor"] || "recovered",
|
|
630
|
+
role: process_start&.dig("payload", "role") || intent_payload["role"] || "coordinator",
|
|
631
|
+
runtime: process_start&.dig("payload", "runtime") || intent_payload["runtime"] || "recovered",
|
|
485
632
|
base_head: intent_payload["base_head"],
|
|
486
633
|
evidence_git_ref: ref,
|
|
487
634
|
created_at: parse_event_time(intent_time(events, attempt_id))
|
|
@@ -602,6 +749,11 @@ input:#{Regexp.escape(current["input_digest"])} outcome:(\S+)( no-effect:(\S+))?
|
|
|
602
749
|
lock.flock(File::LOCK_EX)
|
|
603
750
|
begin
|
|
604
751
|
yield
|
|
752
|
+
rescue StandardError
|
|
753
|
+
# No rejected writer may leave staged or untracked transaction
|
|
754
|
+
# data for the next writer to admit. This checkout is disposable.
|
|
755
|
+
sync_checkout(ref_value) if File.exist?(File.join(checkout_dir, ".git"))
|
|
756
|
+
raise
|
|
605
757
|
ensure
|
|
606
758
|
lock.flock(File::LOCK_UN)
|
|
607
759
|
end
|
|
@@ -648,6 +800,9 @@ input:#{Regexp.escape(current["input_digest"])} outcome:(\S+)( no-effect:(\S+))?
|
|
|
648
800
|
return if target.nil?
|
|
649
801
|
|
|
650
802
|
git!("-C", checkout_dir, "reset", "--hard", target)
|
|
803
|
+
# All writers use this boundary, including CAS retries. Only the
|
|
804
|
+
# journal's disposable namespaces are cleaned, never the candidate.
|
|
805
|
+
git!("-C", checkout_dir, "clean", "-fd", "--", "execution", "evidence")
|
|
651
806
|
rescue AttemptErrors::EvidenceUnavailable
|
|
652
807
|
# Stale, broken, or unregistered worktree: rebuild from scratch.
|
|
653
808
|
FileUtils.rm_rf(checkout_dir)
|
|
@@ -13,7 +13,7 @@ module Ace
|
|
|
13
13
|
# Actor identity is derived from the execution boundary, never granted
|
|
14
14
|
# by command flags or receipt claims. Two adapters ship:
|
|
15
15
|
# - `local` — the interactive operator is the coordinator; identity
|
|
16
|
-
# comes from the
|
|
16
|
+
# comes from the kernel process account (equal real/effective UID).
|
|
17
17
|
# - `service` — a trusted service executor supplies a structured
|
|
18
18
|
# identity through a configured environment variable; the executor
|
|
19
19
|
# owns OS/process enforcement.
|
|
@@ -25,14 +25,17 @@ module Ace
|
|
|
25
25
|
TRUSTED_ROLES = %w[coordinator service].freeze
|
|
26
26
|
|
|
27
27
|
# Resolved boundary identity.
|
|
28
|
-
Identity = Struct.new(:actor, :role, :runtime, :adapter, keyword_init: true)
|
|
28
|
+
Identity = Struct.new(:actor, :role, :runtime, :adapter, :process_pid, :runtime_binding, keyword_init: true)
|
|
29
29
|
|
|
30
30
|
# @param adapter [String, nil] "local" or "service" (default from config)
|
|
31
31
|
# @param service_env [String, nil] Env var carrying the service identity (default from config)
|
|
32
|
-
def initialize(adapter: nil, service_env: nil)
|
|
32
|
+
def initialize(adapter: nil, service_env: nil, runtime_resolver: Ace::Runtime, env: ENV, caller_pid: Process.ppid)
|
|
33
33
|
section = Ace::Assign.config["attempt"] || {}
|
|
34
34
|
@adapter = adapter || section["identity_adapter"] || "local"
|
|
35
35
|
@service_env = service_env || section["service_identity_env"] || "ACE_ASSIGN_SERVICE_IDENTITY"
|
|
36
|
+
@runtime_resolver = runtime_resolver
|
|
37
|
+
@env = env
|
|
38
|
+
@caller_pid = caller_pid
|
|
36
39
|
end
|
|
37
40
|
|
|
38
41
|
# @return [Identity] Trusted boundary identity
|
|
@@ -55,19 +58,41 @@ module Ace
|
|
|
55
58
|
private
|
|
56
59
|
|
|
57
60
|
def from_local_boundary
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
raise AttemptErrors::UnauthorizedIdentity, "
|
|
61
|
+
uid, euid = Process.uid, Process.euid
|
|
62
|
+
unless uid == euid
|
|
63
|
+
raise AttemptErrors::UnauthorizedIdentity, "Local execution requires equal real and effective UIDs"
|
|
61
64
|
end
|
|
62
65
|
|
|
66
|
+
account = local_account(euid)
|
|
67
|
+
verify_local_credentials!(uid, euid)
|
|
68
|
+
binding = native_process_binding
|
|
69
|
+
verify_local_credentials!(uid, euid)
|
|
63
70
|
Identity.new(
|
|
64
|
-
actor:
|
|
71
|
+
actor: account.name,
|
|
65
72
|
role: "coordinator",
|
|
66
73
|
runtime: "local:#{Socket.gethostname}",
|
|
67
|
-
adapter: "local"
|
|
74
|
+
adapter: "local",
|
|
75
|
+
process_pid: binding&.dig("process_identity", "pid"),
|
|
76
|
+
runtime_binding: binding
|
|
68
77
|
)
|
|
69
78
|
end
|
|
70
79
|
|
|
80
|
+
def local_account(euid)
|
|
81
|
+
account = Etc.getpwuid(euid)
|
|
82
|
+
unless account && account.uid == euid && account.name.is_a?(String) && !account.name.strip.empty?
|
|
83
|
+
raise AttemptErrors::UnauthorizedIdentity, "No valid kernel process account for effective UID #{euid}"
|
|
84
|
+
end
|
|
85
|
+
account
|
|
86
|
+
rescue ArgumentError, SystemCallError
|
|
87
|
+
raise AttemptErrors::UnauthorizedIdentity, "Cannot resolve kernel process account for effective UID #{euid}"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def verify_local_credentials!(uid, euid)
|
|
91
|
+
unless Process.uid == uid && Process.euid == euid
|
|
92
|
+
raise AttemptErrors::UnauthorizedIdentity, "Local execution credentials changed during identity resolution"
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
71
96
|
def from_service_env
|
|
72
97
|
raw = ENV[@service_env].to_s.strip
|
|
73
98
|
if raw.empty?
|
|
@@ -89,7 +114,22 @@ module Ace
|
|
|
89
114
|
"Service identity must supply actor, runtime, and a role in #{ROLES.join('/')}"
|
|
90
115
|
end
|
|
91
116
|
|
|
92
|
-
|
|
117
|
+
pid = data["process_pid"]
|
|
118
|
+
if pid && (!pid.is_a?(Integer) || !pid.positive?)
|
|
119
|
+
raise AttemptErrors::UnauthorizedIdentity, "Service process_pid must be a positive integer"
|
|
120
|
+
end
|
|
121
|
+
Identity.new(actor: actor, role: role, runtime: runtime, adapter: "service", process_pid: pid)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def native_process_binding
|
|
125
|
+
adapter = Ace::Runtime::Molecules::RuntimeSelector.new(
|
|
126
|
+
config: Ace::Runtime.config, registry: @runtime_resolver, env: @env).resolve
|
|
127
|
+
context = adapter.context
|
|
128
|
+
return nil unless context[:in_runtime] && context[:pane]
|
|
129
|
+
|
|
130
|
+
adapter.process_binding(pane: context[:pane], caller_pid: @caller_pid)
|
|
131
|
+
rescue Ace::Runtime::Error
|
|
132
|
+
nil
|
|
93
133
|
end
|
|
94
134
|
end
|
|
95
135
|
end
|