ace-assign 0.63.0 → 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.
Files changed (67) hide show
  1. checksums.yaml +4 -4
  2. data/.ace-defaults/assign/catalog/steps/create-pr.step.yml +2 -2
  3. data/.ace-defaults/assign/catalog/steps/mark-pr-ready.step.yml +1 -1
  4. data/.ace-defaults/assign/catalog/steps/release-minor.step.yml +4 -2
  5. data/.ace-defaults/assign/catalog/steps/release.step.yml +4 -2
  6. data/.ace-defaults/assign/catalog/steps/update-pr-desc.step.yml +1 -1
  7. data/.ace-defaults/assign/presets/fix-bug.yml +15 -3
  8. data/.ace-defaults/assign/presets/work-on-task-ace-development.yml +16 -4
  9. data/.ace-defaults/assign/presets/work-on-task-auto-merge.yml +15 -3
  10. data/.ace-defaults/assign/presets/work-on-task.yml +16 -4
  11. data/CHANGELOG.md +51 -0
  12. data/docs/demo/canonical-skill-source.tape.yml +4 -4
  13. data/docs/protected-authority.md +266 -0
  14. data/docs/usage.md +91 -2
  15. data/handbook/workflow-instructions/assign/drive.wf.md +2 -2
  16. data/handbook/workflow-instructions/assign/prepare.wf.md +2 -2
  17. data/lib/ace/assign/atoms/delivery_parameters.rb +54 -0
  18. data/lib/ace/assign/authority/candidate_transfer.rb +160 -0
  19. data/lib/ace/assign/authority/client.rb +107 -0
  20. data/lib/ace/assign/authority/deployment.rb +465 -0
  21. data/lib/ace/assign/authority/endcap.rb +342 -0
  22. data/lib/ace/assign/authority/endcap_inboxes.rb +314 -0
  23. data/lib/ace/assign/authority/endcap_results.rb +380 -0
  24. data/lib/ace/assign/authority/endcap_services.rb +395 -0
  25. data/lib/ace/assign/authority/evidence_transfer.rb +67 -0
  26. data/lib/ace/assign/authority/execution_scope_observation.rb +476 -0
  27. data/lib/ace/assign/authority/launch_driver.rb +114 -0
  28. data/lib/ace/assign/authority/launch_lifecycle.rb +822 -0
  29. data/lib/ace/assign/authority/launch_scope_abort.rb +88 -0
  30. data/lib/ace/assign/authority/launch_scope_admission.rb +84 -0
  31. data/lib/ace/assign/authority/launch_scope_close.rb +115 -0
  32. data/lib/ace/assign/authority/launch_scope_parent.rb +44 -0
  33. data/lib/ace/assign/authority/launch_scope_release.rb +180 -0
  34. data/lib/ace/assign/authority/posix_acl.rb +56 -0
  35. data/lib/ace/assign/authority/private_directory.rb +33 -0
  36. data/lib/ace/assign/authority/receipt_transfer.rb +48 -0
  37. data/lib/ace/assign/authority/router.rb +54 -0
  38. data/lib/ace/assign/authority/server.rb +230 -0
  39. data/lib/ace/assign/authority/service_evidence.rb +55 -0
  40. data/lib/ace/assign/authority/transfer_codec.rb +155 -0
  41. data/lib/ace/assign/cli/commands/authority/launch.rb +45 -0
  42. data/lib/ace/assign/cli/commands/authority/serve.rb +30 -0
  43. data/lib/ace/assign/cli/commands/authority/status.rb +26 -0
  44. data/lib/ace/assign/cli/commands/authority/terminate.rb +29 -0
  45. data/lib/ace/assign/cli/commands/create.rb +9 -1
  46. data/lib/ace/assign/cli/commands/delivery.rb +57 -0
  47. data/lib/ace/assign/cli/commands/inbox_reconcile.rb +36 -0
  48. data/lib/ace/assign/cli/commands/resume.rb +26 -0
  49. data/lib/ace/assign/cli.rb +22 -1
  50. data/lib/ace/assign/models/evidence_event.rb +3 -1
  51. data/lib/ace/assign/molecules/attempt_reconciler.rb +31 -12
  52. data/lib/ace/assign/molecules/canonical_evidence.rb +150 -0
  53. data/lib/ace/assign/molecules/evidence_journal.rb +218 -63
  54. data/lib/ace/assign/molecules/execution_identity_resolver.rb +49 -9
  55. data/lib/ace/assign/molecules/execution_scope_lineage.rb +327 -0
  56. data/lib/ace/assign/molecules/journal_mutation.rb +202 -0
  57. data/lib/ace/assign/molecules/lifecycle_exclusion.rb +4 -0
  58. data/lib/ace/assign/molecules/proposal_journal.rb +151 -0
  59. data/lib/ace/assign/molecules/receipt_verifier.rb +49 -19
  60. data/lib/ace/assign/molecules/runtime_control_surface_runner.rb +4 -1
  61. data/lib/ace/assign/organisms/assignment_executor.rb +6 -0
  62. data/lib/ace/assign/organisms/attempt_coordinator.rb +312 -13
  63. data/lib/ace/assign/organisms/delivery_coordinator.rb +342 -0
  64. data/lib/ace/assign/organisms/task_assignment_creator.rb +8 -3
  65. data/lib/ace/assign/version.rb +1 -1
  66. data/lib/ace/assign.rb +13 -0
  67. metadata +119 -6
@@ -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 = ref_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
- attestation = /^ace-service-attestation request:#{Regexp.escape(current["request_id"])} \
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 = ref_value
296
+ value = commit
259
297
  return nil unless value
260
298
  out, stderr, status = git("show", "#{value}:#{service_request_path(request_id)}")
261
- return JSON.parse(out) if status.success?
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 = ref_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 = ref_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
- # Guards (for example authorization consumption) re-run inside
390
- # the lock against the ref being committed, so a racing claim
391
- # cannot slip through between the check and the CAS.
392
- if guard
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 = "running"
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 = events.select { |event| event["type"] == "receipt_accepted" }
473
- .map { |event| event.dig("payload", "receipt", "head") }
474
- .compact
475
- .first
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 OS login boundary.
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
- actor = Etc.getlogin || ENV["USER"] || ENV["LOGNAME"]
59
- if actor.nil? || actor.strip.empty?
60
- raise AttemptErrors::UnauthorizedIdentity, "No OS login identity available at the execution boundary"
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: actor.strip,
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
- Identity.new(actor: actor, role: role, runtime: runtime, adapter: "service")
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