ace-assign 0.69.0 → 0.70.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 (38) hide show
  1. checksums.yaml +4 -4
  2. data/.ace-defaults/assign/presets/deliver-release.yml +11 -8
  3. data/.ace-defaults/assign/presets/integrate-batch.yml +10 -5
  4. data/.ace-defaults/assign/presets/supervise-delivery.yml +6 -4
  5. data/CHANGELOG.md +7 -0
  6. data/lib/ace/assign/atoms/assignment_scope.rb +5 -0
  7. data/lib/ace/assign/authority/assignment_definition.rb +35 -0
  8. data/lib/ace/assign/authority/candidate_transfer.rb +2 -2
  9. data/lib/ace/assign/authority/endcap_attempts.rb +1 -1
  10. data/lib/ace/assign/authority/launch_control_exclusions.rb +3 -17
  11. data/lib/ace/assign/authority/ordinary_attempt_start.rb +137 -0
  12. data/lib/ace/assign/authority/ordinary_client.rb +70 -0
  13. data/lib/ace/assign/authority/ordinary_completion.rb +94 -0
  14. data/lib/ace/assign/authority/ordinary_effect_settlement.rb +118 -0
  15. data/lib/ace/assign/authority/ordinary_lifecycle.rb +191 -0
  16. data/lib/ace/assign/authority/ordinary_peer.rb +54 -0
  17. data/lib/ace/assign/authority/ordinary_progress.rb +162 -0
  18. data/lib/ace/assign/authority/ordinary_queue.rb +115 -0
  19. data/lib/ace/assign/authority/ordinary_results.rb +196 -0
  20. data/lib/ace/assign/authority/ordinary_server.rb +75 -0
  21. data/lib/ace/assign/authority/prepared_work.rb +2 -2
  22. data/lib/ace/assign/authority/protected_assignment_context.rb +3 -3
  23. data/lib/ace/assign/authority/router.rb +11 -6
  24. data/lib/ace/assign/authority/server.rb +11 -180
  25. data/lib/ace/assign/authority/socket_server.rb +255 -0
  26. data/lib/ace/assign/authority/transfer_codec.rb +2 -2
  27. data/lib/ace/assign/cli/commands/attempt/evidence.rb +2 -2
  28. data/lib/ace/assign/molecules/canonical_read_commands.rb +84 -0
  29. data/lib/ace/assign/molecules/canonical_read_snapshot.rb +2 -63
  30. data/lib/ace/assign/molecules/evidence_journal.rb +141 -34
  31. data/lib/ace/assign/molecules/installed_journal_read.rb +143 -0
  32. data/lib/ace/assign/molecules/journal_mutation.rb +5 -3
  33. data/lib/ace/assign/molecules/lifecycle_exclusion.rb +13 -2
  34. data/lib/ace/assign/molecules/proposal_journal.rb +32 -4
  35. data/lib/ace/assign/organisms/attempt_coordinator.rb +51 -36
  36. data/lib/ace/assign/organisms/ordinary_merge_consumer.rb +5 -5
  37. data/lib/ace/assign/version.rb +1 -1
  38. metadata +23 -9
@@ -62,6 +62,7 @@ module Ace
62
62
  def initialize(repo_root:, ref: nil, checkout_root: nil, mode: :local,
63
63
  evidence_reader: nil, service_authorizer: nil, read_boundary: nil)
64
64
  unless %i[local protected].include?(mode) &&
65
+ (evidence_reader.nil? || evidence_reader.respond_to?(:call)) &&
65
66
  (mode != :protected || [evidence_reader, service_authorizer].all? { |owner| owner.respond_to?(:call) })
66
67
  raise ArgumentError, "protected journal requires source-owned evidence and service boundaries"
67
68
  end
@@ -128,7 +129,7 @@ module Ace
128
129
  paths.each do |path|
129
130
  parts = path.split("/", -1)
130
131
  event = parts.size == 4 && parts[0] == "execution" && parts[2] == "events" && JournalMutation::ID.match?(parts[1]) && parts[3].end_with?(".json")
131
- request = parts.size == 3 && parts[0, 2] == %w[execution requests] && parts[2].end_with?(".json") && JournalMutation::ID.match?(parts[2].delete_suffix(".json"))
132
+ request = parts.size == 3 && [%w[execution requests], %w[execution merge-requests]].include?(parts[0, 2]) && parts[2].end_with?(".json") && JournalMutation::ID.match?(parts[2].delete_suffix(".json"))
132
133
  # Definitions, prepared packets, candidate bundles and imports
133
134
  # retain the SAME mutation owner's closed immutable blob dialect.
134
135
  validate_blob_path!(path) unless event || request
@@ -138,6 +139,12 @@ module Ace
138
139
  unless records.map { |record| record.fetch("request_id") }.sort == expected_requests
139
140
  raise AttemptErrors::EvidenceUnavailable, "canonical initialization service inventory differs"
140
141
  end
142
+ fixed_records = fixed_merge_request_records(commit: value)
143
+ expected_fixed = paths.select { |path| path.start_with?("execution/merge-requests/") }.map { |path| File.basename(path, ".json") }.sort
144
+ unless fixed_records.map { |record| record.fetch("request_id") }.sort == expected_fixed
145
+ raise AttemptErrors::EvidenceUnavailable, "canonical initialization fixed merge inventory differs"
146
+ end
147
+ fixed_records.each { |record| verify_fixed_record!(record, commit: value) }
141
148
  current = File.stat(@repo_root)
142
149
  unless !File.symlink?(@repo_root) && [current.dev, current.ino, current.uid, current.gid, current.mode] == identity && ref_value == value
143
150
  raise AttemptErrors::EvidenceUnavailable, "canonical initialization selection changed"
@@ -201,6 +208,21 @@ module Ace
201
208
  event_commits_for_assignments!(selectors: {assignment_id => event_digests}, commit: commit).fetch(assignment_id)
202
209
  end
203
210
 
211
+ # Cross-attempt projections use canonical publication order, never the
212
+ # wall-clock timestamps of independently chained attempts. Exactly one
213
+ # selected event must have been introduced by the latest relevant CAS.
214
+ def latest_event!(assignment_id:, event_digests:, commit:)
215
+ introductions = event_commits!(assignment_id: assignment_id, event_digests: event_digests, commit: commit)
216
+ introduced_commits = introductions.values.to_h { |value| [value, true] }
217
+ node = canonical_history_nodes!(commit).find { |entry| introduced_commits.key?(entry.first) }
218
+ selected = node && introductions.select { |_digest, introduced| introduced == node.first }.keys
219
+ unless selected&.one?
220
+ raise AttemptErrors::EvidenceUnavailable, "latest canonical event is missing or ambiguous"
221
+ end
222
+ read_events(assignment_id, commit: commit).find { |event| event.fetch("digest") == selected.first } ||
223
+ raise(AttemptErrors::EvidenceUnavailable, "latest canonical event is unavailable")
224
+ end
225
+
204
226
  # One immutable walk for an operation selecting several assignments.
205
227
  # Every selected assignment retains its complete chain and exact raw
206
228
  # blob proof; the result is returned, never cached on this owner.
@@ -760,7 +782,9 @@ module Ace
760
782
  %w[succeeded failed failed-settled].include?(state)
761
783
  end
762
784
 
763
- def validate_terminal_receipt!(current, state, receipt, pending: nil)
785
+ # Shared schema/binding checks; storage interpretation belongs to the
786
+ # caller's source-selected evidence owner, not this pure validator.
787
+ def validate_terminal_receipt_structure!(current, state, receipt)
764
788
  unless receipt.is_a?(Hash) && receipt.keys.sort == TERMINAL_RECEIPT_FIELDS.sort
765
789
  raise AttemptErrors::ReceiptRejected,
766
790
  "Service terminal receipt has invalid fields"
@@ -787,7 +811,14 @@ module Ace
787
811
  raise AttemptErrors::ReceiptRejected,
788
812
  "Service settlement requires a no-effect attestation artifact"
789
813
  end
790
- if @mode == :protected
814
+ evidence_items
815
+ end
816
+
817
+ def validate_terminal_receipt!(current, state, receipt, pending: nil)
818
+ evidence_items = validate_terminal_receipt_structure!(current, state, receipt)
819
+ # Canonical byte ownership is also usable by the ordinary installed
820
+ # service. It does not opt that service into protected authority.
821
+ if @evidence_reader
791
822
  if (current["operation"] == "prune-preserved-workspace" && %w[succeeded failed-settled].include?(state)) ||
792
823
  (current["operation"] == "publish" && %w[succeeded failed].include?(state))
793
824
  # The same installed evidence reader owns the entire imported
@@ -922,6 +953,9 @@ module Ace
922
953
  def authorization_conflict(binding)
923
954
  authorization = binding["authorization"]
924
955
  return nil unless authorization.is_a?(String) && !authorization.empty?
956
+ if authorization.start_with?("proposal-")
957
+ return proposal_claim_conflict(authorization, request_id: binding.fetch("request_id"), fixed: false)
958
+ end
925
959
  service_request_records.find do |other|
926
960
  next false if other["request_id"] == binding.fetch("request_id")
927
961
  next false if other["state"] == "rejected" && other["consumed"] == false
@@ -994,8 +1028,8 @@ module Ace
994
1028
  #
995
1029
  # @param assignment_id [String] Assignment ID
996
1030
  # @return [Array<Models::Attempt>] Derived attempts
997
- def derived_attempts(assignment_id)
998
- events = read_events(assignment_id)
1031
+ def derived_attempts(assignment_id, commit: ref_value)
1032
+ events = read_events(assignment_id, commit: commit)
999
1033
  by_attempt = events.group_by { |event| event["attempt_id"] }
1000
1034
  by_attempt.delete(nil)
1001
1035
 
@@ -1006,7 +1040,7 @@ module Ace
1006
1040
  state = derive_state(attempt_events)
1007
1041
  next if state.nil?
1008
1042
 
1009
- build_attempt(assignment_id, attempt_id, intent["payload"], attempt_events, state)
1043
+ build_attempt(assignment_id, attempt_id, intent["payload"], attempt_events, state, commit: commit)
1010
1044
  end
1011
1045
  end
1012
1046
 
@@ -1271,7 +1305,7 @@ module Ace
1271
1305
  # opaque reference is receiver-issued, never caller-supplied, and
1272
1306
  # replays return the originally issued one.
1273
1307
  FIXED_MERGE_BINDING_FIELDS = %w[request_id project_id assignment_id attempt_id
1274
- queue_entry operation authorization input_digest request principal receiver_id
1308
+ queue_entry operation authorization input_digest request principal caller_uid receiver_id
1275
1309
  configuration_sha256 execution_identity].freeze
1276
1310
  FIXED_MERGE_MUTABLE_FIELDS = %w[state receipt reference recorded_at claimed_at finished_at].freeze
1277
1311
 
@@ -1324,25 +1358,27 @@ module Ace
1324
1358
 
1325
1359
  # Claim the start of the fixed effect: pending -> uncertain in one
1326
1360
  # CAS transition, so a receiver restart can never dispatch a second
1327
- # merge for the same admitted record — begin replays as already
1328
- # started, and only the owning effect may later settle it.
1361
+ # merge for the same admitted record. An already-started claim refuses;
1362
+ # only the winning effect may later settle it.
1329
1363
  def begin_fixed_merge_request(request_id)
1330
1364
  current = fixed_merge_request(request_id)
1331
1365
  raise AttemptErrors::NotFound, "Fixed merge request #{request_id} not found" unless current
1332
- return current if current["state"] == "uncertain"
1366
+ if current["state"] == "uncertain"
1367
+ raise AttemptErrors::InvalidState, "Fixed merge request #{request_id} was already started"
1368
+ end
1333
1369
  unless current["state"] == "pending"
1334
1370
  raise AttemptErrors::InvalidState, "Fixed merge request #{request_id} is terminal"
1335
1371
  end
1336
1372
  update_fixed_merge_request(request_id, expected: current,
1337
1373
  replacement: current.merge("state" => "uncertain", "claimed_at" => Time.now.utc.iso8601(9)),
1338
- event_type: "merge_transition")
1374
+ event_type: "merge_transition", guard: -> { verify_fixed_proposal!(current) })
1339
1375
  end
1340
1376
 
1341
1377
  # Terminal settlement: succeeded/failed with the exact receipt
1342
1378
  # validated HERE at the journal boundary — structure, the closed
1343
1379
  # terminal invariants, and the binding against the current record.
1344
1380
  # Failure is terminal only with immutable no-effect evidence.
1345
- def settle_fixed_merge_request(request_id, terminal_status:, receipt:)
1381
+ def settle_fixed_merge_request(request_id, terminal_status:, receipt:, attestation: nil)
1346
1382
  unless FIXED_MERGE_STATES.include?(terminal_status) && %w[succeeded failed].include?(terminal_status)
1347
1383
  raise ArgumentError, "invalid fixed merge terminal status"
1348
1384
  end
@@ -1351,17 +1387,40 @@ module Ace
1351
1387
  unless %w[pending uncertain].include?(current["state"])
1352
1388
  raise AttemptErrors::InvalidState, "Fixed merge request #{request_id} is terminal"
1353
1389
  end
1390
+ evidence_files = {}
1391
+ if terminal_status == "failed"
1392
+ unless attestation.is_a?(Hash) &&
1393
+ attestation.values_at("request_id", "input_digest", "reference", "outcome") ==
1394
+ current.values_at("request_id", "input_digest", "reference") + ["no_effect"] &&
1395
+ attestation["candidate_head"] == current.dig("request", "candidate_head") &&
1396
+ attestation["expected_base_head"] == current.dig("request", "expected_base_head")
1397
+ raise AttemptErrors::ReceiptRejected, "Fixed merge no_effect_evidence requires a bound canonical attestation"
1398
+ end
1399
+ content = JSON.generate(attestation)
1400
+ raise AttemptErrors::ReceiptRejected, "Fixed merge attestation exceeds bound" if content.bytesize > 16_384
1401
+ path = fixed_no_effect_path(request_id)
1402
+ evidence_files[path] = content
1403
+ receipt = receipt.merge("effect" => receipt.fetch("effect").merge(
1404
+ "no_effect_evidence" => {"reference" => path, "sha256" => Digest::SHA256.hexdigest(content)}))
1405
+ elsif attestation
1406
+ raise AttemptErrors::ReceiptRejected, "Successful merge cannot carry no-effect attestation"
1407
+ end
1354
1408
  validate_fixed_receipt!(current, terminal_status, receipt)
1355
1409
  update_fixed_merge_request(request_id, expected: current,
1356
1410
  replacement: current.merge("state" => terminal_status, "receipt" => receipt,
1357
1411
  "finished_at" => Time.now.utc.iso8601(9)),
1358
- event_type: "merge_transition")
1412
+ event_type: "merge_transition", evidence_files: evidence_files)
1359
1413
  end
1360
1414
 
1361
1415
  def fixed_merge_request_path(request_id)
1362
1416
  "execution/merge-requests/#{request_id}.json"
1363
1417
  end
1364
1418
 
1419
+ def fixed_no_effect_path(request_id)
1420
+ validate_request_id!(request_id)
1421
+ "execution/merge-evidence/#{request_id}.json"
1422
+ end
1423
+
1365
1424
  def validate_fixed_receipt!(current, terminal_status, receipt)
1366
1425
  Atoms::FixedMergeSchemas.validate_receipt!(receipt)
1367
1426
  Atoms::FixedMergeSchemas.validate_receipt_against!(receipt, current)
@@ -1388,25 +1447,39 @@ module Ace
1388
1447
  end
1389
1448
  end
1390
1449
 
1391
- def fixed_no_effect_intact?(record)
1450
+ def fixed_no_effect_intact?(record, commit: ref_value)
1392
1451
  receipt = record["receipt"]
1393
1452
  return false unless receipt.is_a?(Hash)
1394
1453
  evidence = receipt.dig("effect", "no_effect_evidence")
1395
1454
  return false unless evidence.is_a?(Hash)
1396
- repo_root = File.realpath(@repo_root)
1397
- path = File.expand_path(evidence["reference"].to_s, repo_root)
1398
- real = begin
1399
- File.realpath(path)
1400
- rescue Errno::ENOENT, Errno::EACCES, Errno::ELOOP
1401
- return false
1402
- end
1403
- real.start_with?(repo_root + File::SEPARATOR) &&
1404
- Digest::SHA256.file(real).hexdigest == evidence.fetch("sha256")
1405
- rescue Errno::ENOENT, AttemptErrors::EvidenceUnavailable
1455
+ path = fixed_no_effect_path(record.fetch("request_id"))
1456
+ return false unless evidence["reference"] == path
1457
+ content = bounded_blob(path, commit: commit, max_bytes: 16_384)
1458
+ proof = JSON.parse(content)
1459
+ Digest::SHA256.hexdigest(content) == evidence["sha256"] &&
1460
+ proof.values_at("request_id", "input_digest", "reference", "outcome") == record.values_at("request_id", "input_digest", "reference") + ["no_effect"] &&
1461
+ proof["candidate_head"] == record.dig("request", "candidate_head") && proof["expected_base_head"] == record.dig("request", "expected_base_head")
1462
+ rescue AttemptErrors::EvidenceUnavailable, JSON::ParserError, NoMethodError
1406
1463
  false
1407
1464
  end
1408
1465
 
1409
- def update_fixed_merge_request(request_id, expected:, replacement:, event_type:, guard: nil)
1466
+ # Immutable terminal consumption for ordinary completion. It uses the
1467
+ # same receipt and no-effect owners as admission/authorization release.
1468
+ def fixed_merge_settlement!(request_id, commit: ref_value)
1469
+ record = fixed_merge_request(request_id, commit: commit)
1470
+ raise AttemptErrors::EvidenceUnavailable, "canonical fixed merge is missing" unless record
1471
+ verify_fixed_record!(record, commit: commit)
1472
+ unless %w[succeeded failed].include?(record["state"])
1473
+ raise AttemptErrors::ServiceSettlementPending, "canonical fixed merge is unresolved"
1474
+ end
1475
+ validate_fixed_receipt!(record, record.fetch("state"), record.fetch("receipt"))
1476
+ if record["state"] == "failed" && !fixed_no_effect_intact?(record, commit: commit)
1477
+ raise AttemptErrors::EvidenceUnavailable, "canonical fixed merge no-effect proof differs"
1478
+ end
1479
+ record
1480
+ end
1481
+
1482
+ def update_fixed_merge_request(request_id, expected:, replacement:, event_type:, guard: nil, evidence_files: {})
1410
1483
  unless %w[merge_admission merge_transition].include?(event_type)
1411
1484
  raise ArgumentError, "invalid fixed merge event type"
1412
1485
  end
@@ -1419,10 +1492,8 @@ module Ace
1419
1492
  prepared = prepare_fixed_update(request_id: request_id, expected: expected,
1420
1493
  replacement: replacement, event_type: event_type, guard: guard)
1421
1494
  return prepared.fetch(:record) if prepared[:replayed]
1495
+ write_mutation_blobs(evidence_files, commit: old)
1422
1496
  path = fixed_merge_request_path(request_id)
1423
- full = File.join(checkout_dir, path)
1424
- FileUtils.mkdir_p(File.dirname(full))
1425
- File.write(full, JSON.pretty_generate(prepared.fetch(:record)))
1426
1497
  assignment_id = replacement.fetch("assignment_id")
1427
1498
  attempt_id = replacement.fetch("attempt_id")
1428
1499
  prior = read_events(assignment_id).reverse
@@ -1430,7 +1501,8 @@ module Ace
1430
1501
  event = Models::EvidenceEvent.build(type: event_type, attempt_id: attempt_id,
1431
1502
  payload: prepared.fetch(:event).fetch(:payload), previous_digest: prior)
1432
1503
  write_event_files(assignment_id, [event])
1433
- git!("-C", checkout_dir, "add", "--", path, "execution/#{assignment_id}/events")
1504
+ git!("-C", checkout_dir, "add", "--", "execution/#{assignment_id}/events")
1505
+ stage_mutation_blobs(evidence_files.merge(path => JSON.pretty_generate(prepared.fetch(:record))))
1434
1506
  state = replacement.fetch("state")
1435
1507
  git!("-C", checkout_dir, "-c", "user.name=ace-assign", "-c", "user.email=ace-assign@localhost",
1436
1508
  "commit", "-m", "evidence: fixed merge request #{request_id} #{state}")
@@ -1477,6 +1549,13 @@ module Ace
1477
1549
  end
1478
1550
  conflict = guard&.call
1479
1551
  raise_authorization_conflict!(conflict) if conflict
1552
+ if expected.nil? || replacement["state"] == "uncertain"
1553
+ attempt = derived_attempts(replacement.fetch("assignment_id")).find { |item| item.attempt_id == replacement.fetch("attempt_id") }
1554
+ unless attempt&.managed? && attempt.active? && attempt.binding.project_id == replacement.fetch("project_id")
1555
+ raise AttemptErrors::ReceiptRejected, "Fixed merge admission requires its current active canonical attempt"
1556
+ end
1557
+ end
1558
+ verify_fixed_proposal!(replacement) if event_type == "merge_admission"
1480
1559
  payload = {"request_id" => request_id, "state" => replacement.fetch("state"),
1481
1560
  "input_digest" => replacement.fetch("input_digest"),
1482
1561
  "record_digest" => Atoms::EvidenceDigest.digest(replacement),
@@ -1485,6 +1564,21 @@ module Ace
1485
1564
  end
1486
1565
  private :prepare_fixed_update
1487
1566
 
1567
+ def verify_fixed_proposal!(record)
1568
+ return unless record.fetch("authorization").start_with?("proposal-")
1569
+ proposal_authorize!(record.fetch("authorization"), fixed_proposal_binding(record))
1570
+ conflict = proposal_claim_conflict(record.fetch("authorization"),
1571
+ request_id: record.fetch("request_id"), fixed: true)
1572
+ raise AttemptErrors::AuthorizationConflict, "Proposal already holds an effect claim" if conflict
1573
+ nil
1574
+ end
1575
+
1576
+ def fixed_proposal_binding(record)
1577
+ record.slice("operation", "project_id", "assignment_id", "attempt_id", "input_digest", "caller_uid").merge(
1578
+ "target" => {"resource" => record.fetch("request").fetch("pr_url"), "artifact_digest" => nil},
1579
+ "candidate_head" => record.fetch("request").fetch("candidate_head"))
1580
+ end
1581
+
1488
1582
  def fixed_replay?(existing, replacement)
1489
1583
  FIXED_MERGE_BINDING_FIELDS.all? do |field|
1490
1584
  existing[field] == replacement.fetch(field)
@@ -1514,7 +1608,8 @@ module Ace
1514
1608
  # The module marks a private region above; the fixed merge primitive
1515
1609
  # is a public coordinator-facing surface, so it is re-published here.
1516
1610
  public :fixed_merge_request, :fixed_merge_request_records, :admit_fixed_merge_request,
1517
- :begin_fixed_merge_request, :settle_fixed_merge_request, :fixed_authorization_conflict
1611
+ :begin_fixed_merge_request, :settle_fixed_merge_request, :fixed_authorization_conflict, :fixed_merge_settlement!,
1612
+ :verify_service_attestation!
1518
1613
 
1519
1614
  # Latest lifecycle state implied by the events, or nil when the
1520
1615
  # events do not describe a full attempt (intent missing).
@@ -1522,7 +1617,7 @@ module Ace
1522
1617
  CanonicalAttemptState.derive(events)
1523
1618
  end
1524
1619
 
1525
- def build_attempt(assignment_id, attempt_id, intent_payload, events, state)
1620
+ def build_attempt(assignment_id, attempt_id, intent_payload, events, state, commit: ref_value)
1526
1621
  process_start = events.reverse.find do |event|
1527
1622
  event["type"] == "process_start" && event["attempt_id"] == attempt_id
1528
1623
  end
@@ -1552,7 +1647,7 @@ module Ace
1552
1647
  binding: binding,
1553
1648
  state: state,
1554
1649
  candidate_head: candidate_head,
1555
- journal_commit: ref_value
1650
+ journal_commit: commit
1556
1651
  )
1557
1652
  end
1558
1653
 
@@ -1658,7 +1753,7 @@ module Ace
1658
1753
  File.join(@checkout_root, ".evidence.lock")
1659
1754
  end
1660
1755
 
1661
- def with_lock(cleanup_on_failure: true, owner_uid: nil, owner_gid: nil)
1756
+ def with_lock(cleanup_on_failure: true, owner_uid: nil, owner_gid: nil, deadline: nil)
1662
1757
  raise AttemptErrors::EvidenceUnavailable, "read-only journal cannot mutate canonical evidence" if @read_boundary
1663
1758
  FileUtils.mkdir_p(@checkout_root)
1664
1759
  lock = if owner_uid
@@ -1667,7 +1762,19 @@ module Ace
1667
1762
  File.open(lock_path, File::RDWR | File::CREAT, 0o600)
1668
1763
  end
1669
1764
  begin
1670
- lock.flock(File::LOCK_EX)
1765
+ if deadline
1766
+ unless deadline.is_a?(Numeric) && deadline.finite?
1767
+ raise ArgumentError, "canonical lock deadline must be finite"
1768
+ end
1769
+ loop do
1770
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
1771
+ raise AttemptErrors::Conflict, "canonical journal lock deadline elapsed" unless remaining.positive?
1772
+ break if lock.flock(File::LOCK_EX | File::LOCK_NB)
1773
+ sleep [remaining, 0.01].min
1774
+ end
1775
+ else
1776
+ lock.flock(File::LOCK_EX)
1777
+ end
1671
1778
  begin
1672
1779
  yield
1673
1780
  rescue
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ace/herdr/molecules/bounded_process"
4
+ require_relative "evidence_journal"
5
+ require_relative "canonical_read_commands"
6
+
7
+ module Ace
8
+ module Assign
9
+ module Molecules
10
+ # Read the installed owner's immutable Git objects without opening its
11
+ # private writer lock/cache, copying the repository, or contacting it.
12
+ # Source selection is supplied only by verified installation policy.
13
+ class InstalledJournalRead
14
+ include CanonicalReadCommands
15
+ ENVIRONMENT = {"PATH" => "/usr/bin:/bin", "LC_ALL" => "C",
16
+ "GIT_CONFIG_NOSYSTEM" => "1", "GIT_CONFIG_SYSTEM" => "/dev/null",
17
+ "GIT_CONFIG_GLOBAL" => "/dev/null", "GIT_TERMINAL_PROMPT" => "0",
18
+ "GIT_ALLOW_PROTOCOL" => "", "GIT_NO_REPLACE_OBJECTS" => "1",
19
+ "GIT_NO_LAZY_FETCH" => "1", "GIT_OPTIONAL_LOCKS" => "0"}.freeze
20
+ ENTRY_LIMIT = 100_000
21
+
22
+ def initialize(repo_root:, ref:, owner_uid:)
23
+ unless repo_root.is_a?(String) && repo_root.start_with?("/") &&
24
+ !repo_root.include?("\0") && File.expand_path(repo_root) == repo_root &&
25
+ ref.is_a?(String) && ref.match?(%r{\Arefs/[a-zA-Z0-9_-]+(?:/[a-zA-Z0-9_-]+)+\z}) &&
26
+ owner_uid.is_a?(Integer) && owner_uid.positive?
27
+ raise ArgumentError, "installed journal selection is malformed"
28
+ end
29
+ @repo_root, @ref, @owner_uid = repo_root.dup.freeze, ref.dup.freeze, owner_uid
30
+ @mutex = Mutex.new
31
+ end
32
+
33
+ def with(deadline:)
34
+ refuse!("read_busy") unless @mutex.try_lock
35
+ begin
36
+ @deadline = deadline
37
+ remaining
38
+ verify_source!
39
+ @view = File.join(@repo_root, ".git")
40
+ @commit = run!(["rev-parse", "--verify", @ref], limit: 128).stdout.strip
41
+ refuse!("ref_unavailable") unless @commit.match?(OID)
42
+ result = yield self
43
+ verify_source!
44
+ result
45
+ ensure
46
+ @view = @commit = @deadline = nil
47
+ @mutex.unlock
48
+ end
49
+ rescue SystemCallError, IOError, Timeout::Error, Herdr::Molecules::BoundedProcess::PostLaunchError
50
+ refuse!("source_unavailable")
51
+ end
52
+
53
+ private
54
+
55
+ def refuse!(reason)
56
+ raise AttemptErrors::EvidenceUnavailable, "installed journal read unavailable: #{reason}"
57
+ end
58
+
59
+ def remaining
60
+ refuse!("deadline") unless @deadline.is_a?(Numeric) && @deadline.finite?
61
+ value = @deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
62
+ refuse!("deadline") unless value.positive?
63
+ value
64
+ end
65
+
66
+ def verify_source!
67
+ # Caller-writable ancestors can replace even a correctly owned leaf.
68
+ current = File.join(@repo_root, ".git")
69
+ loop do
70
+ stat = File.lstat(current)
71
+ unless stat.directory? && !stat.symlink? && [0, @owner_uid].include?(stat.uid) &&
72
+ (stat.mode & 0o022).zero?
73
+ refuse!("storage_owner")
74
+ end
75
+ if [@repo_root, File.join(@repo_root, ".git")].include?(current) && stat.uid != @owner_uid
76
+ refuse!("repository_owner")
77
+ end
78
+ break if current == "/"
79
+ current = File.dirname(current)
80
+ end
81
+ %w[commondir shallow reftable info/grafts objects/info/alternates objects/info/http-alternates].each do |name|
82
+ path = File.join(@repo_root, ".git", name)
83
+ refuse!("redirected_storage") if File.exist?(path) || File.symlink?(path)
84
+ end
85
+ @entries = 0
86
+ %w[objects refs config HEAD packed-refs].each do |name|
87
+ path = File.join(@repo_root, ".git", name)
88
+ next if %w[HEAD packed-refs].include?(name) && !File.exist?(path) && !File.symlink?(path)
89
+ verify_tree!(path)
90
+ end
91
+ verify_config!
92
+ end
93
+
94
+ def verify_tree!(path)
95
+ remaining
96
+ @entries += 1
97
+ refuse!("storage_inventory_limit") if @entries > ENTRY_LIMIT
98
+ stat = File.lstat(path)
99
+ unless !stat.symlink? && (stat.file? || stat.directory?) &&
100
+ [0, @owner_uid].include?(stat.uid) && (stat.mode & 0o022).zero?
101
+ refuse!("storage_owner")
102
+ end
103
+ Dir.each_child(path) { |name| verify_tree!(File.join(path, name)) } if stat.directory?
104
+ end
105
+
106
+ def verify_config!
107
+ bytes = File.open(File.join(@repo_root, ".git/config"), File::RDONLY | File::NOFOLLOW) { |io| io.read(65_537) }
108
+ refuse!("config_limit") if bytes.bytesize > 65_536
109
+ result = Herdr::Molecules::BoundedProcess.call(
110
+ ["/usr/bin/git", "config", "--no-includes", "--null", "--list", "--file", "/dev/stdin"],
111
+ environment: ENVIRONMENT, chdir: "/", stdin_data: bytes,
112
+ timeout_s: remaining, output_limit: 65_536)
113
+ refuse!("config_unavailable") unless result.status.success? && !result.oversized
114
+ result.stdout.split("\0").each do |entry|
115
+ key, value = entry.split("\n", 2)
116
+ if key.start_with?("include.", "includeif.", "extensions.") || key == "core.worktree" ||
117
+ key == "core.repositoryformatversion" && value != "0"
118
+ refuse!("redirected_configuration")
119
+ end
120
+ end
121
+ end
122
+
123
+ def read_result(argv, **options)
124
+ run!(argv, **options, allow_failure: true)
125
+ end
126
+
127
+ def run!(argv, stdin_data: "", limit:, allow_failure: false)
128
+ # All ref-based reads in one observation use the same immutable tip.
129
+ selected = argv.map { |arg| arg == @ref && @commit ? @commit : arg }
130
+ result = Herdr::Molecules::BoundedProcess.call([
131
+ "/usr/bin/git", "--git-dir=#{@view}", "--no-pager",
132
+ "-c", "safe.directory=#{@repo_root}", "-c", "core.hooksPath=/dev/null",
133
+ "-c", "core.fsmonitor=false", "-c", "protocol.allow=never", *selected],
134
+ environment: ENVIRONMENT, chdir: @repo_root, stdin_data: stdin_data,
135
+ timeout_s: remaining, output_limit: [limit, BYTE_LIMIT].min, stderr_limit: 65_536)
136
+ refuse!("resource_limit") if result.oversized
137
+ refuse!("git_unavailable") unless allow_failure || result.status.success?
138
+ result
139
+ end
140
+ end
141
+ end
142
+ end
143
+ end
@@ -34,7 +34,7 @@ module Ace
34
34
  # returns events [{type:, payload:}], immutable blobs, and public data.
35
35
  # Only the authority calls this internal journal API; wire requests
36
36
  # cannot select blob paths, event types, or accepted response data.
37
- def mutate(assignment_id:, attempt_id:, mutation_id:, operation:, parameters_digest:, expected_generation:, with_replay: false, generation_mode: :expected, prompt_binding: nil, prompt_completion: nil, prompt_observation: nil, input_inhibition: nil)
37
+ def mutate(assignment_id:, attempt_id:, mutation_id:, operation:, parameters_digest:, expected_generation:, with_replay: false, generation_mode: :expected, prompt_binding: nil, prompt_completion: nil, prompt_observation: nil, input_inhibition: nil, deadline: nil, replay_authorizer: nil)
38
38
  unless (generation_mode == :expected && expected_generation.is_a?(Integer) && expected_generation >= 0) ||
39
39
  (generation_mode == :recorded_completion && %w[complete_service complete_no_effect].include?(operation) && expected_generation.nil?) ||
40
40
  (generation_mode == :prompt_completion && operation == "prompt_attempt" && expected_generation.nil? && prompt_completion.is_a?(Hash)) ||
@@ -71,7 +71,8 @@ module Ace
71
71
  unless parameters_digest.is_a?(String) && parameters_digest.match?(/\A[0-9a-f]{64}\z/)
72
72
  raise ArgumentError, "invalid mutation parameter digest"
73
73
  end
74
- with_lock do
74
+ raise ArgumentError, "replay authorizer must be source owned" if replay_authorizer && !replay_authorizer.respond_to?(:call)
75
+ with_lock(deadline: deadline) do
75
76
  self.class::CAS_ATTEMPTS.times do
76
77
  old = ref_value
77
78
  ensure_checkout!
@@ -96,6 +97,7 @@ module Ace
96
97
  raise AttemptErrors::Conflict, "Mutation ID is already bound to different input"
97
98
  end
98
99
  result = replay.fetch("data").merge("journal_commit" => replay.fetch("journal_commit"))
100
+ replay_authorizer&.call
99
101
  return with_replay ? {data: result, replayed: true} : result
100
102
  end
101
103
  current = read_events(assignment_id, commit: old).select { |event| event["attempt_id"] == attempt_id }
@@ -215,7 +217,7 @@ module Ace
215
217
  end
216
218
 
217
219
  def validate_blob_path!(path)
218
- unless path.is_a?(String) && path.match?(%r{\A(?:evidence/imports|execution/definitions|execution/prepared|candidates/bundles)/[a-zA-Z0-9_.-]+\z}) &&
220
+ unless path.is_a?(String) && path.match?(%r{\A(?:evidence/imports|execution/definitions|execution/prepared|execution/merge-evidence|candidates/bundles)/[a-zA-Z0-9_.-]+\z}) &&
219
221
  !%w[. ..].include?(File.basename(path))
220
222
  raise ArgumentError, "invalid canonical blob reference"
221
223
  end
@@ -214,14 +214,25 @@ module Ace
214
214
  # given order, released in reverse — callers must use one consistent
215
215
  # global order, task before assignment, to avoid deadlock). Fails
216
216
  # closed when any identity was pruned.
217
- def with_shared_multi(keys, reset_removed: false)
217
+ def with_shared_multi(keys, reset_removed: false, deadline: nil)
218
218
  ordered = Array(keys).compact.reject { |key| key.to_s.empty? }.uniq
219
219
  raise ArgumentError, "exclusion keys are required" if ordered.empty?
220
220
 
221
221
  locks = []
222
222
  begin
223
+ maintenance_deadline!(deadline) if deadline
223
224
  ordered.each { |key| locks << open_lock(key) }
224
- locks.each { |lock| lock.flock(File::LOCK_SH) }
225
+ locks.each do |lock|
226
+ if deadline
227
+ until lock.flock(File::LOCK_SH | File::LOCK_NB)
228
+ maintenance_deadline!(deadline)
229
+ sleep 0.01
230
+ end
231
+ maintenance_deadline!(deadline)
232
+ else
233
+ lock.flock(File::LOCK_SH)
234
+ end
235
+ end
225
236
  ordered.each do |key|
226
237
  if removed?(key)
227
238
  unless reset_removed
@@ -11,7 +11,7 @@ module Ace
11
11
  PROPOSAL_REFERENCE = /\Aproposal-[0-9a-f]{24}-r[1-9][0-9]*\z/
12
12
  PROPOSAL_BINDING = %w[operation project_id assignment_id attempt_id input_digest target candidate_head caller_uid].freeze
13
13
 
14
- PROPOSAL_IMMUTABLE = (PROPOSAL_BINDING + %w[schema proposal_id revision revision_id request_id requester content_digest created_at authorization context options recommendation rationale prerequisites lifecycle_request revision_operation_id expected_source_revision]).freeze
14
+ PROPOSAL_IMMUTABLE = (PROPOSAL_BINDING + %w[schema proposal_id revision revision_id request_id requester content_digest created_at authorization context options recommendation rationale prerequisites lifecycle_request revision_operation_id expected_source_revision silence_delegated]).freeze
15
15
 
16
16
  def proposal_history(id)
17
17
  assignment_ids.flat_map do |assignment|
@@ -110,8 +110,7 @@ module Ace
110
110
  valid &&= updated["revision_operation_id"].nil? && updated["expected_source_revision"].nil?
111
111
  end
112
112
  unresolved = previous && proposal_claims(previous["revision_id"]).any? do |claim|
113
- claim["state"] != "succeeded" &&
114
- !(claim["state"] == "failed-settled" && settlement_evidence_intact?(claim))
113
+ claim["state"] != "succeeded" && !proposal_no_effect?(claim)
115
114
  end
116
115
  raise AttemptErrors::ReceiptRejected, "Claimed proposal cannot be revised; reconcile the existing effect" if unresolved
117
116
  raise AttemptErrors::ReceiptRejected, "Proposal revision must begin with exact immutable delivery scope" unless valid
@@ -119,10 +118,39 @@ module Ace
119
118
  private :validate_proposal_revision!
120
119
 
121
120
  def proposal_claims(reference)
122
- service_request_records.select do |request|
121
+ claims = (service_request_records + fixed_merge_request_records).select do |request|
123
122
  request["authorization"] == reference &&
124
123
  !(request["state"] == "rejected" && request["consumed"] == false)
125
124
  end
125
+ return claims if claims.empty?
126
+ events = read_events(claims.first.fetch("assignment_id"))
127
+ claims.sort_by do |claim|
128
+ kind = claim.key?("queue_entry") ? "merge_admission" : "service_claim"
129
+ events.index do |event|
130
+ event["type"] == kind && event["attempt_id"] == claim.fetch("attempt_id") &&
131
+ event.dig("payload", "request_id") == claim.fetch("request_id")
132
+ end || raise(AttemptErrors::EvidenceUnavailable, "Proposal claim has no canonical admission event")
133
+ end
134
+ end
135
+
136
+ # Request IDs are scoped by record family. A same-text ID in the other
137
+ # family is another effect, never an idempotent replay.
138
+ def proposal_claim_conflict(reference, request_id:, fixed:)
139
+ [[service_request_records, false], [fixed_merge_request_records, true]].each do |records, family|
140
+ conflict = records.find do |claim|
141
+ claim["authorization"] == reference &&
142
+ !(family == fixed && claim["request_id"] == request_id) &&
143
+ !(claim["state"] == "rejected" && claim["consumed"] == false) &&
144
+ !proposal_no_effect?(claim)
145
+ end
146
+ return conflict if conflict
147
+ end
148
+ nil
149
+ end
150
+
151
+ def proposal_no_effect?(claim)
152
+ (claim["state"] == "failed-settled" && settlement_evidence_intact?(claim)) ||
153
+ (claim["state"] == "failed" && claim.key?("queue_entry") && fixed_no_effect_intact?(claim))
126
154
  end
127
155
 
128
156
  def proposal_authorize!(reference, binding)