ace-assign 0.64.0 → 0.66.1

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 (129) hide show
  1. checksums.yaml +4 -4
  2. data/.ace-defaults/assign/catalog/steps/auto-merge.step.yml +11 -0
  3. data/CHANGELOG.md +25 -0
  4. data/docs/protected-authority.md +23 -7
  5. data/docs/usage.md +117 -4
  6. data/exe/ace-scope-ready +55 -0
  7. data/handbook/workflow-instructions/assign/drive.wf.md +26 -1
  8. data/lib/ace/assign/atoms/attempt_state_machine.rb +8 -0
  9. data/lib/ace/assign/atoms/delivery_parameters.rb +4 -39
  10. data/lib/ace/assign/authority/assignment_inventory.rb +290 -0
  11. data/lib/ace/assign/authority/campaign_consumer_policy.rb +100 -0
  12. data/lib/ace/assign/authority/campaign_execution.rb +119 -0
  13. data/lib/ace/assign/authority/campaign_round_transfer.rb +82 -0
  14. data/lib/ace/assign/authority/candidate_transfer.rb +169 -2
  15. data/lib/ace/assign/authority/cleanup_result_protection.rb +53 -0
  16. data/lib/ace/assign/authority/client.rb +156 -6
  17. data/lib/ace/assign/authority/deployment.rb +201 -39
  18. data/lib/ace/assign/authority/deployment_history.rb +146 -0
  19. data/lib/ace/assign/authority/endcap.rb +131 -18
  20. data/lib/ace/assign/authority/endcap_attempts.rb +274 -0
  21. data/lib/ace/assign/authority/endcap_campaign_results.rb +232 -0
  22. data/lib/ace/assign/authority/endcap_campaign_rounds.rb +266 -0
  23. data/lib/ace/assign/authority/endcap_contexts.rb +23 -0
  24. data/lib/ace/assign/authority/endcap_inboxes.rb +3 -280
  25. data/lib/ace/assign/authority/endcap_prepared_work.rb +80 -0
  26. data/lib/ace/assign/authority/endcap_publication.rb +207 -0
  27. data/lib/ace/assign/authority/endcap_results.rb +36 -18
  28. data/lib/ace/assign/authority/endcap_review_requests.rb +183 -0
  29. data/lib/ace/assign/authority/endcap_reviews.rb +130 -0
  30. data/lib/ace/assign/authority/endcap_service_settlement.rb +152 -0
  31. data/lib/ace/assign/authority/endcap_services.rb +221 -18
  32. data/lib/ace/assign/authority/execution_scope_observation.rb +468 -41
  33. data/lib/ace/assign/authority/inbox_context_original.rb +113 -0
  34. data/lib/ace/assign/authority/launch_attempt_consumers.rb +51 -0
  35. data/lib/ace/assign/authority/launch_campaign_registration.rb +214 -0
  36. data/lib/ace/assign/authority/launch_control_channel.rb +313 -0
  37. data/lib/ace/assign/authority/launch_control_exclusions.rb +141 -0
  38. data/lib/ace/assign/authority/launch_driver.rb +298 -19
  39. data/lib/ace/assign/authority/launch_input_inhibition.rb +132 -0
  40. data/lib/ace/assign/authority/launch_lifecycle.rb +275 -100
  41. data/lib/ace/assign/authority/launch_prepared_work.rb +122 -0
  42. data/lib/ace/assign/authority/launch_review.rb +91 -0
  43. data/lib/ace/assign/authority/launch_scope_abort.rb +0 -7
  44. data/lib/ace/assign/authority/launch_scope_admission.rb +136 -5
  45. data/lib/ace/assign/authority/launch_scope_close.rb +68 -37
  46. data/lib/ace/assign/authority/launch_scope_release.rb +696 -17
  47. data/lib/ace/assign/authority/launch_steering.rb +353 -0
  48. data/lib/ace/assign/authority/launch_stop.rb +254 -0
  49. data/lib/ace/assign/authority/maintenance_workspace_retirement.rb +229 -0
  50. data/lib/ace/assign/authority/original_inbox_selection.rb +41 -0
  51. data/lib/ace/assign/authority/posix_acl.rb +5 -2
  52. data/lib/ace/assign/authority/prepared_input.rb +130 -0
  53. data/lib/ace/assign/authority/prepared_queue.rb +185 -0
  54. data/lib/ace/assign/authority/prepared_task_context.rb +116 -0
  55. data/lib/ace/assign/authority/prepared_work.rb +304 -0
  56. data/lib/ace/assign/authority/prepared_worker.rb +69 -0
  57. data/lib/ace/assign/authority/protected_assignment_context.rb +226 -0
  58. data/lib/ace/assign/authority/readiness_hook.rb +120 -0
  59. data/lib/ace/assign/authority/router.rb +24 -2
  60. data/lib/ace/assign/authority/server.rb +103 -33
  61. data/lib/ace/assign/authority/service_cleanup_dispatch.rb +88 -0
  62. data/lib/ace/assign/authority/service_cleanup_evidence.rb +180 -0
  63. data/lib/ace/assign/authority/service_delivery_evidence.rb +101 -0
  64. data/lib/ace/assign/authority/service_evidence.rb +243 -12
  65. data/lib/ace/assign/authority/service_publication_evidence.rb +110 -0
  66. data/lib/ace/assign/authority/task_context_entry.rb +32 -0
  67. data/lib/ace/assign/authority/transfer_codec.rb +44 -12
  68. data/lib/ace/assign/cli/commands/assignment_target.rb +15 -2
  69. data/lib/ace/assign/cli/commands/attempt/base.rb +49 -0
  70. data/lib/ace/assign/cli/commands/attempt/finish.rb +20 -4
  71. data/lib/ace/assign/cli/commands/attempt/reconcile.rb +11 -0
  72. data/lib/ace/assign/cli/commands/authority/inbox_context_principal.rb +25 -0
  73. data/lib/ace/assign/cli/commands/authority/inbox_context_selection.rb +28 -0
  74. data/lib/ace/assign/cli/commands/authority/launch.rb +58 -6
  75. data/lib/ace/assign/cli/commands/authority/serve.rb +2 -1
  76. data/lib/ace/assign/cli/commands/authority/status.rb +1 -1
  77. data/lib/ace/assign/cli/commands/authority/task_context.rb +31 -0
  78. data/lib/ace/assign/cli/commands/authority/task_context_principal.rb +25 -0
  79. data/lib/ace/assign/cli/commands/authority/task_context_selection.rb +30 -0
  80. data/lib/ace/assign/cli/commands/authority/worker.rb +23 -0
  81. data/lib/ace/assign/cli/commands/campaign_export_result.rb +55 -0
  82. data/lib/ace/assign/cli/commands/campaign_record_round.rb +57 -0
  83. data/lib/ace/assign/cli/commands/create.rb +2 -0
  84. data/lib/ace/assign/cli/commands/delivery.rb +54 -4
  85. data/lib/ace/assign/cli/commands/fail.rb +6 -0
  86. data/lib/ace/assign/cli/commands/finish.rb +23 -2
  87. data/lib/ace/assign/cli/commands/fork_session.rb +6 -2
  88. data/lib/ace/assign/cli/commands/inbox_bind.rb +34 -0
  89. data/lib/ace/assign/cli/commands/protected_submission.rb +61 -0
  90. data/lib/ace/assign/cli/commands/resume.rb +12 -0
  91. data/lib/ace/assign/cli/commands/select.rb +2 -0
  92. data/lib/ace/assign/cli/commands/start.rb +2 -0
  93. data/lib/ace/assign/cli/commands/status.rb +4 -2
  94. data/lib/ace/assign/cli/commands/step.rb +3 -1
  95. data/lib/ace/assign/cli/commands/submit_candidate.rb +34 -0
  96. data/lib/ace/assign/cli/commands/submit_result.rb +61 -0
  97. data/lib/ace/assign/cli.rb +36 -5
  98. data/lib/ace/assign/errors.rb +139 -0
  99. data/lib/ace/assign/models/assignment.rb +26 -4
  100. data/lib/ace/assign/models/evidence_event.rb +3 -3
  101. data/lib/ace/assign/molecules/assignment_manager.rb +12 -2
  102. data/lib/ace/assign/molecules/attempt_reconciler.rb +20 -0
  103. data/lib/ace/assign/molecules/canonical_attempt_state.rb +151 -0
  104. data/lib/ace/assign/molecules/canonical_evidence.rb +5 -2
  105. data/lib/ace/assign/molecules/canonical_read_snapshot.rb +374 -0
  106. data/lib/ace/assign/molecules/evidence_journal.rb +720 -44
  107. data/lib/ace/assign/molecules/execution_scope_lineage.rb +27 -9
  108. data/lib/ace/assign/molecules/fork_session_launcher.rb +16 -6
  109. data/lib/ace/assign/molecules/journal_input_inhibition.rb +78 -0
  110. data/lib/ace/assign/molecules/journal_mutation.rb +82 -13
  111. data/lib/ace/assign/molecules/journal_prompt_mutation.rb +241 -0
  112. data/lib/ace/assign/molecules/lifecycle_exclusion.rb +178 -26
  113. data/lib/ace/assign/molecules/original_launch_binding.rb +37 -0
  114. data/lib/ace/assign/molecules/protected_control_exclusion.rb +303 -0
  115. data/lib/ace/assign/molecules/protected_workspace_exclusion.rb +330 -0
  116. data/lib/ace/assign/molecules/protected_workspace_provisioner.rb +164 -0
  117. data/lib/ace/assign/molecules/queue_scanner.rb +26 -11
  118. data/lib/ace/assign/molecules/receipt_verifier.rb +21 -15
  119. data/lib/ace/assign/molecules/step_writer.rb +27 -5
  120. data/lib/ace/assign/molecules/terminal_scope_receipt.rb +88 -0
  121. data/lib/ace/assign/organisms/assignment_executor.rb +4 -4
  122. data/lib/ace/assign/organisms/attempt_coordinator.rb +66 -76
  123. data/lib/ace/assign/organisms/prepared_work_builder.rb +194 -0
  124. data/lib/ace/assign/organisms/protected_delivery_coordinator.rb +100 -0
  125. data/lib/ace/assign/organisms/task_assignment_creator.rb +4 -2
  126. data/lib/ace/assign/version.rb +1 -1
  127. data/lib/ace/assign.rb +1 -112
  128. metadata +90 -11
  129. data/lib/ace/assign/cli/commands/inbox_reconcile.rb +0 -36
@@ -15,10 +15,10 @@ module Ace
15
15
  # metadata only.
16
16
  module EvidenceEvent
17
17
  TYPES = %w[
18
- intent process_start receipt_accepted transition
18
+ intent process_start receipt_accepted transition attempt_stopped
19
19
  candidate_invalidated reconciliation
20
- service_claim service_transition recovery_observation inbox_binding inbox_reconciliation delivery
21
- authority_mutation evidence_import proposal_state result_submitted
20
+ service_claim service_transition service_challenge service_no_effect_challenge service_publication_challenge service_publication_issuing recovery_observation inbox_binding delivery
21
+ authority_mutation evidence_import proposal_state result_submitted prompt_issued prompt_outcome prompt_completion_observed input_inhibited
22
22
  scope_provisioning scope_bound scope_native_bound scope_child_bound scope_sealed scope_closed_no_writers
23
23
  ].freeze
24
24
 
@@ -13,7 +13,8 @@ module Ace
13
13
  # Uses ace-b36ts for assignment ID generation.
14
14
  class AssignmentManager
15
15
  # @param cache_base [String] Base cache directory
16
- def initialize(cache_base: nil)
16
+ def initialize(cache_base: nil, selected_assignment: nil)
17
+ @selected_assignment = selected_assignment
17
18
  @cache_base = cache_base || Ace::Assign.cache_dir
18
19
  end
19
20
 
@@ -85,6 +86,7 @@ module Ace
85
86
  #
86
87
  # @return [Models::Assignment, nil] Most recent assignment or nil
87
88
  def find_active
89
+ return @selected_assignment if @selected_assignment
88
90
  return nil unless File.directory?(@cache_base)
89
91
 
90
92
  # Priority 1: use .current symlink if it exists (explicit selection)
@@ -185,6 +187,7 @@ module Ace
185
187
  )
186
188
 
187
189
  write_assignment_file(updated)
190
+ @selected_assignment = updated if @selected_assignment && @selected_assignment.id == updated.id
188
191
 
189
192
  # Update .latest symlink since this assignment was just updated
190
193
  update_latest_symlink(assignment.id)
@@ -259,7 +262,14 @@ module Ace
259
262
 
260
263
  def write_assignment_file(assignment)
261
264
  assignment_file = File.join(assignment.cache_dir, "assignment.yaml")
262
- File.write(assignment_file, assignment.to_h.to_yaml)
265
+ if @selected_assignment
266
+ File.open(assignment_file, File::WRONLY | File::TRUNC | File::NOFOLLOW | File::NONBLOCK) do |file|
267
+ raise ArgumentError, "selected assignment metadata is not regular" unless file.stat.file?
268
+ file.write(assignment.to_h.to_yaml); file.flush; file.fsync
269
+ end
270
+ else
271
+ File.write(assignment_file, assignment.to_h.to_yaml)
272
+ end
263
273
  end
264
274
 
265
275
  def load_from_file(assignment_file)
@@ -1,4 +1,5 @@
1
1
  # frozen_string_literal: true
2
+ require_relative "canonical_attempt_state"
2
3
 
3
4
  require "json"
4
5
 
@@ -19,6 +20,21 @@ module Ace
19
20
  # effects are never retried automatically, and an uncertain attempt
20
21
  # without a receipt stays uncertain.
21
22
  class AttemptReconciler
23
+ # The protected boundary supplies authenticated original scope facts
24
+ # and exact kernel identities; never a runtime discovered by pane name.
25
+ def self.protected_observation(kernel:, binding:, observer:, lineage:)
26
+ return {"liveness" => "unknown"} if lineage.sealed?
27
+ kernel.live!(binding.fetch("process_identity"))
28
+ kernel.live!(binding.fetch("native_origin").fetch("server_identity"))
29
+ observation = observer.observe(lineage)
30
+ unless observation.is_a?(Hash) && observation["populated"].is_a?(Integer) && observation["populated"].positive?
31
+ return {"liveness" => "unknown"}
32
+ end
33
+ {"liveness" => "live", "identity" => binding.fetch("process_identity"), "runtime_binding" => binding}
34
+ rescue AttemptErrors::EvidenceUnavailable, Ace::Runtime::RuntimeUnavailableError
35
+ {"liveness" => "unknown"}
36
+ end
37
+
22
38
  # @param journal [EvidenceJournal, nil] Managed evidence journal
23
39
  # @param verifier [ReceiptVerifier, nil] Receipt verifier (coordinator supplies on use)
24
40
  def initialize(journal: nil, verifier: nil, observer: nil, runtime_resolver: Ace::Runtime)
@@ -33,6 +49,10 @@ module Ace
33
49
  # @param attempt [Models::Attempt] Running attempt
34
50
  # @return [Symbol] :stopped, :live, or :uncertain
35
51
  def classify(attempt)
52
+ events = events_for(attempt)
53
+ if events.any? { |event| event["type"] == "attempt_stopped" } && CanonicalAttemptState.derive(events) == "stopped"
54
+ return :stopped
55
+ end
36
56
  return :live if process_live?(attempt)
37
57
  :uncertain
38
58
  end
@@ -0,0 +1,151 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "execution_scope_lineage"
4
+ require_relative "../atoms/attempt_state_machine"
5
+
6
+ module Ace
7
+ module Assign
8
+ module Molecules
9
+ # Canonical state projection, not live closure or settlement authority.
10
+ # The stopped producer and terminal consumer must additionally obtain the
11
+ # existing owners' authenticated original-prefix evidence and fresh proof.
12
+ module CanonicalAttemptState
13
+ STOPPED_FIELDS = %w[project_id mapping_id assignment_id attempt_id descriptor_sha256
14
+ scope_binding_event_id seal_event_id closed_proof_event_id service_settlement_event_digests].freeze
15
+ SELECTION_FIELDS = (STOPPED_FIELDS - %w[service_settlement_event_digests]).freeze
16
+
17
+ def self.derive(events)
18
+ return nil unless events.any? { |event| event["type"] == "intent" }
19
+ state = "reserved"
20
+ stopped = false
21
+ events.each do |event|
22
+ if stopped && %w[process_start receipt_accepted transition reconciliation attempt_stopped].include?(event["type"])
23
+ unavailable!("Accepted stopped attempt cannot be resurrected or replaced")
24
+ end
25
+ case event["type"]
26
+ when "process_start" then state = "running"
27
+ when "receipt_accepted" then state = event.dig("payload", "receipt", "verdict") || state
28
+ when "transition" then state = event.dig("payload", "to") || state
29
+ when "reconciliation" then state = event.dig("payload", "resolution") || state
30
+ when "attempt_stopped"
31
+ verify_stopped_acceptance!(events, event, prior_state: state)
32
+ state = "stopped"
33
+ stopped = true
34
+ end
35
+ end
36
+ state
37
+ end
38
+
39
+ def self.stopped_payload!(events:, selection:, service_evidence:, commit:)
40
+ unless selection.is_a?(Hash) && selection.keys.sort == SELECTION_FIELDS.sort &&
41
+ service_evidence.is_a?(Hash) && service_evidence.keys.sort == %w[commit services] &&
42
+ commit.is_a?(String) && commit.match?(/\A(?:[0-9a-f]{40}|[0-9a-f]{64})\z/) &&
43
+ service_evidence["commit"] == commit
44
+ unavailable!("Stopped plan evidence prefix differs")
45
+ end
46
+ services = service_evidence.fetch("services")
47
+ unless services.is_a?(Array) &&
48
+ services.all? { |item| item.is_a?(Hash) && item.keys.sort == %w[event_digest evidence_refs receipt_digest record_digest request_id state] }
49
+ unavailable!("Stopped settlement projections are malformed")
50
+ end
51
+ service_ids = services.map { |item| item.fetch("request_id") }
52
+ unless service_ids == service_ids.sort && service_ids.uniq == service_ids
53
+ unavailable!("Stopped settlement projection is not ordered unique owner output")
54
+ end
55
+ services.each do |item|
56
+ event = events.find { |entry| entry["digest"] == item.fetch("event_digest") && entry["type"] == "service_transition" }
57
+ unless event && event.dig("payload", "request_id") == item.fetch("request_id") &&
58
+ event.dig("payload", "state") == item.fetch("state") && %w[succeeded failed-settled].include?(item.fetch("state")) &&
59
+ event.dig("payload", "record_digest") == item.fetch("record_digest") &&
60
+ event.dig("payload", "receipt_digest") == item.fetch("receipt_digest")
61
+ unavailable!("Stopped service projection differs from canonical terminal event")
62
+ end
63
+ end
64
+ payload = selection.merge("service_settlement_event_digests" => services.map { |item| item.fetch("event_digest") }.sort)
65
+ validate_stopped_payload!(events, payload)
66
+ payload
67
+ rescue KeyError, TypeError, ArgumentError, NoMethodError
68
+ unavailable!("Stopped plan evidence is malformed")
69
+ end
70
+
71
+ def self.verify_stopped_acceptance!(events, event, prior_state:)
72
+ Atoms::AttemptStateMachine.proof_stopped_transition!(prior_state)
73
+ unless events.count { |entry| entry["type"] == "attempt_stopped" } == 1 && Models::EvidenceEvent.chain_valid?(events)
74
+ unavailable!("Stopped canonical chain is ambiguous or corrupt")
75
+ end
76
+ payload = event.fetch("payload")
77
+ prefix = events.take_while { |entry| entry["digest"] != event.fetch("digest") }
78
+ validate_stopped_payload!(prefix, payload)
79
+ acceptance = events.find { |entry| entry["type"] == "authority_mutation" && entry["previous_digest"] == event.fetch("digest") }
80
+ data = acceptance&.dig("payload", "data")
81
+ generation = acceptance && events.take_while { |entry| entry["digest"] != acceptance["digest"] }.count { |entry| entry["type"] == "authority_mutation" } + 1
82
+ unless acceptance && data.is_a?(Hash) && data.keys.sort == %w[attempt_id generation proof_id required_action state] &&
83
+ data["generation"].is_a?(Integer) && data["generation"] == generation &&
84
+ acceptance.dig("payload", "operation") == "stop_attempt" &&
85
+ acceptance.dig("payload", "assignment_id") == payload.fetch("assignment_id") &&
86
+ acceptance.dig("payload", "attempt_id") == payload.fetch("attempt_id") &&
87
+ acceptance.dig("payload", "data", "attempt_id") == payload.fetch("attempt_id") &&
88
+ acceptance.dig("payload", "data", "state") == "stopped" &&
89
+ acceptance.dig("payload", "data", "proof_id") == payload.fetch("closed_proof_event_id") &&
90
+ acceptance.dig("payload", "data", "required_action").nil?
91
+ unavailable!("Stopped transition has no adjacent accepted owner mutation")
92
+ end
93
+ seal = prefix.find { |entry| entry["digest"] == payload.fetch("seal_event_id") }
94
+ accepted_seal = seal && prefix.find { |entry| entry["type"] == "authority_mutation" && entry["previous_digest"] == seal["digest"] &&
95
+ %w[close_execution_scope stop_attempt].include?(entry.dig("payload", "operation")) }
96
+ proof = prefix.find { |entry| entry["digest"] == payload.fetch("closed_proof_event_id") }
97
+ accepted_proof = proof && prefix.find { |entry| entry["type"] == "authority_mutation" && entry["previous_digest"] == proof["digest"] &&
98
+ %w[close_execution_scope stop_attempt].include?(entry.dig("payload", "operation")) &&
99
+ entry.dig("payload", "data", "proof_id") == proof["digest"] &&
100
+ (entry.dig("payload", "operation") == "close_execution_scope" ?
101
+ entry.dig("payload", "data", "state") == "closed_no_writers" :
102
+ entry.dig("payload", "data").is_a?(Hash) && entry.dig("payload", "data").keys.sort == %w[attempt_id generation proof_id required_action state] &&
103
+ entry.dig("payload", "data", "attempt_id") == payload.fetch("attempt_id") &&
104
+ entry.dig("payload", "data", "generation").is_a?(Integer) &&
105
+ entry.dig("payload", "data", "generation") == prefix.take_while { |item| item["digest"] != entry["digest"] }.count { |item| item["type"] == "authority_mutation" } + 1 &&
106
+ entry.dig("payload", "data", "state") == "uncertain" &&
107
+ entry.dig("payload", "data", "required_action") == "reconcile_scope") }
108
+ unless accepted_seal && (accepted_proof || event["previous_digest"] == proof&.fetch("digest"))
109
+ unavailable!("Stopped canonical seal or proof has no accepted owner")
110
+ end
111
+ event
112
+ rescue KeyError, TypeError, ArgumentError, NoMethodError, AttemptErrors::InvalidTransition
113
+ unavailable!("Stopped canonical acceptance is malformed")
114
+ end
115
+
116
+ def self.validate_stopped_payload!(events, payload)
117
+ unless payload.is_a?(Hash) && payload.keys.sort == STOPPED_FIELDS.sort &&
118
+ %w[project_id mapping_id assignment_id attempt_id].all? { |key| payload[key].is_a?(String) && ExecutionScopeLineage::ID.match?(payload[key]) } &&
119
+ %w[descriptor_sha256 scope_binding_event_id seal_event_id closed_proof_event_id].all? { |key| digest?(payload[key]) } &&
120
+ %w[service_settlement_event_digests].all? { |key|
121
+ values = payload[key]
122
+ values.is_a?(Array) && values.all? { |value| digest?(value) } && values == values.sort && values.uniq == values }
123
+ unavailable!("Stopped canonical payload is not closed")
124
+ end
125
+ lineage = ExecutionScopeLineage.new(events: events, **payload.slice("project_id", "mapping_id", "assignment_id", "attempt_id").transform_keys(&:to_sym))
126
+ provisioning = events.select { |event| event["type"] == "scope_provisioning" }
127
+ unless provisioning.one? && provisioning.first.dig("payload", "descriptor_sha256") == payload.fetch("descriptor_sha256") &&
128
+ lineage.native_event && lineage.child_event &&
129
+ lineage.binding_event&.fetch("digest") == payload.fetch("scope_binding_event_id") &&
130
+ lineage.seal_event&.fetch("digest") == payload.fetch("seal_event_id") &&
131
+ lineage.proof_event&.fetch("digest") == payload.fetch("closed_proof_event_id")
132
+ unavailable!("Stopped canonical original scope selectors differ")
133
+ end
134
+ payload.fetch("service_settlement_event_digests").each do |digest|
135
+ unless events.any? { |event| event["digest"] == digest && event["type"] == "service_transition" &&
136
+ %w[succeeded failed-settled].include?(event.dig("payload", "state")) }
137
+ unavailable!("Stopped canonical service selector is not terminal evidence")
138
+ end
139
+ end
140
+ payload
141
+ rescue KeyError, TypeError, ArgumentError, NoMethodError
142
+ unavailable!("Stopped canonical scope is malformed")
143
+ end
144
+
145
+ def self.digest?(value) = value.is_a?(String) && ExecutionScopeLineage::DIGEST.match?(value)
146
+ def self.unavailable!(message) = raise AttemptErrors::EvidenceUnavailable, message
147
+ private_class_method :digest?, :unavailable!
148
+ end
149
+ end
150
+ end
151
+ end
@@ -14,8 +14,8 @@ module Ace
14
14
  MAX_TOTAL_BYTES = 256 * 1024
15
15
  MAX_ARTIFACTS = 16
16
16
  ID = /\A[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}\z/
17
- ROLES = {"service" => "executor", "review" => "reviewer", "inbox" => "signer",
18
- "observation" => "observer", "result" => "worker"}.freeze
17
+ ROLES = {"service" => "executor", "review" => "reviewer",
18
+ "result" => "worker"}.freeze
19
19
  DESCRIPTOR_FIELDS = %w[version artifact_id kind project_id assignment_id attempt_id peer_uid role
20
20
  binding_digest sha256 bytes admitted_at admitted_after_event_digest request_id_or_event_id
21
21
  candidate_generation_or_claim_generation].freeze
@@ -132,6 +132,9 @@ module Ace
132
132
  expected.all? { |key, value| descriptor[key] == value }
133
133
  before = events.take_while { |entry| entry["digest"] != event["digest"] }
134
134
  unavailable! unless before.any? { |entry| entry["digest"] == descriptor["admitted_after_event_digest"] }
135
+ if kind == "service" && binding["no_effect_challenge"]
136
+ unavailable! unless descriptor["admitted_after_event_digest"] == binding.fetch("challenge_event_digest")
137
+ end
135
138
  Time.iso8601(descriptor.fetch("admitted_at"))
136
139
  bytes = yield(reference.fetch("ref"))
137
140
  unavailable! unless bytes.is_a?(String) && bytes.bytesize <= MAX_ARTIFACT_BYTES && bytes.bytesize == descriptor["bytes"] &&
@@ -0,0 +1,374 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "fileutils"
5
+ require "tmpdir"
6
+ require "ace/herdr/molecules/bounded_process"
7
+ require_relative "evidence_journal"
8
+ require_relative "../authority/private_directory"
9
+
10
+ module Ace
11
+ module Assign
12
+ module Molecules
13
+ # Isolates read-only Git from an installed authority's live configuration.
14
+ # The copied view never publishes evidence or mutates the source journal.
15
+ class CanonicalReadSnapshot
16
+ class Unavailable < AttemptErrors::EvidenceUnavailable; end
17
+ BYTE_LIMIT = 256 * 1_048_576
18
+ ENTRY_LIMIT = 4096
19
+ OID = /\A[0-9a-f]{40}\z/
20
+ ENVIRONMENT = {"PATH" => "/usr/bin:/bin", "LC_ALL" => "C",
21
+ "GIT_CONFIG_NOSYSTEM" => "1", "GIT_CONFIG_SYSTEM" => "/dev/null",
22
+ "GIT_CONFIG_GLOBAL" => "/dev/null", "GIT_TERMINAL_PROMPT" => "0",
23
+ "GIT_ALLOW_PROTOCOL" => "", "GIT_NO_REPLACE_OBJECTS" => "1",
24
+ "GIT_NO_LAZY_FETCH" => "1", "GIT_OPTIONAL_LOCKS" => "0"}.freeze
25
+
26
+ # These selections come from installed source composition, not frames.
27
+ def initialize(repo_root:, checkout_root:, ref:, private_root:, git_executable: "/usr/bin/git")
28
+ paths = [repo_root, checkout_root, private_root, git_executable]
29
+ unless paths.all? { |path| path.is_a?(String) && path.start_with?("/") &&
30
+ !path.include?("\0") && File.expand_path(path) == path } &&
31
+ ref.is_a?(String) && ref.match?(/\Arefs\/[a-zA-Z0-9_-]+(?:\/[a-zA-Z0-9_-]+)+\z/)
32
+ raise ArgumentError, "canonical snapshot source selection is malformed"
33
+ end
34
+ @repo_root, @checkout_root, @private_root, @git = paths.map { |path| path.dup.freeze }
35
+ @ref = ref.dup.freeze
36
+ @verification = Mutex.new
37
+ end
38
+
39
+ def with(deadline:)
40
+ refuse!("snapshot_busy") unless @verification.try_lock
41
+ begin
42
+ with_snapshot(deadline: deadline) { |snapshot| yield snapshot }
43
+ ensure
44
+ @verification.unlock
45
+ end
46
+ end
47
+
48
+ def with_snapshot(deadline:)
49
+ validate_deadline!(deadline)
50
+ verify_private_root!
51
+ private_stat = directory!(@private_root)
52
+ unless private_stat.uid == Process.uid && (private_stat.mode & 0o7777) == 0o700
53
+ refuse!("private_storage_unprotected")
54
+ end
55
+ Dir.mktmpdir("canonical-read-", @private_root) do |view|
56
+ verify_private_root!
57
+ refuse!("private_storage_unprotected") unless directory_identity(File.lstat(@private_root)) == directory_identity(private_stat)
58
+ @view, @deadline = view, deadline
59
+ @held, @directories, @inventory, @inventory_files, @metadata_files = [], [], [], [], []
60
+ @bytes, @entries = 0, 0
61
+ git_dir = File.join(@repo_root, ".git")
62
+ directory!(git_dir)
63
+ %w[commondir reftable shallow].each do |name|
64
+ refuse!("unsupported_source") if exists?(File.join(git_dir, name))
65
+ end
66
+ lock, original_lock = held_file!(File.join(@checkout_root, ".evidence.lock"), limit: 128)
67
+ begin
68
+ acquire!(lock)
69
+ begin
70
+ source_config!(git_dir)
71
+ @commit = select_ref!(git_dir)
72
+ objects = File.join(git_dir, "objects")
73
+ inventory = inventory!(objects)
74
+ inventory.each { |relative, handle| copy!(relative, handle) }
75
+ verify_held!
76
+ refuse!("source_changed") unless inventory_names!(objects) == @inventory &&
77
+ select_ref!(git_dir) == @commit && identity(File.lstat(lock.path)) == original_lock
78
+ verify_held!
79
+ ensure
80
+ lock.flock(File::LOCK_UN)
81
+ end
82
+ end
83
+ prepare_view!
84
+ run!(%w[fsck --full --strict --no-reflogs], limit: 65_536)
85
+ yield self
86
+ ensure
87
+ @held&.each { |handle| handle.close unless handle.closed? }
88
+ @held = @directories = @inventory = @inventory_files = @metadata_files = @view = @deadline = @commit = nil
89
+ end
90
+ rescue Timeout::Error
91
+ refuse!("resource_limit")
92
+ rescue IOError, SystemCallError, Herdr::Molecules::BoundedProcess::PostLaunchError
93
+ refuse!("source_unavailable")
94
+ end
95
+ private :with_snapshot
96
+
97
+ attr_reader :commit
98
+
99
+ # EvidenceJournal uses this only as its existing read command boundary.
100
+ def call(argv, stdin_data: "", output_limit: BYTE_LIMIT)
101
+ refuse!("read_outside_snapshot") unless @view && @commit
102
+ unless read_command?(argv, stdin_data)
103
+ refuse!("write_or_unsupported_command")
104
+ end
105
+ result = run!(argv, stdin_data: stdin_data, limit: [output_limit, BYTE_LIMIT].min)
106
+ [result.stdout.to_s.strip, result.stderr.to_s.strip, result.status]
107
+ end
108
+
109
+ def blob_batch(entries, output_limit:)
110
+ refuse!("read_outside_snapshot") unless @view && @commit
111
+ unless entries.is_a?(Array) && entries.all? { |oid| oid.is_a?(String) && oid.match?(OID) }
112
+ refuse!("unsupported_command")
113
+ end
114
+ result = run!(["cat-file", "--batch"], stdin_data: entries.join("\n") + "\n",
115
+ limit: [output_limit, BYTE_LIMIT].min)
116
+ result.stdout.to_s.b
117
+ end
118
+
119
+ def history_diff(commits, paths, output_limit:)
120
+ refuse!("read_outside_snapshot") unless @view && @commit
121
+ unless commits.is_a?(Array) && commits.size.between?(1, 64) && commits.uniq.size == commits.size &&
122
+ commits.all? { |commit| commit.is_a?(String) && commit.match?(OID) } &&
123
+ paths.is_a?(Array) && !paths.empty? && paths.uniq.size == paths.size &&
124
+ (paths == ["execution/"] || paths.all? { |path| path.is_a?(String) && path.match?(%r{\Aexecution/[a-zA-Z0-9_.:-]+/events/\z}) }) &&
125
+ output_limit.is_a?(Integer) && output_limit.positive?
126
+ refuse!("unsupported_command")
127
+ end
128
+ result = run!(%w[diff-tree --stdin --always --root -r --raw -z --no-renames --no-ext-diff
129
+ --no-textconv --no-abbrev --] + paths, stdin_data: commits.join("\n") + "\n",
130
+ limit: [output_limit, BYTE_LIMIT].min)
131
+ result.stdout.to_s.b
132
+ end
133
+
134
+ def blob_sizes(oids)
135
+ refuse!("read_outside_snapshot") unless @view && @commit
136
+ unless oids.is_a?(Array) && oids.size.between?(1, 64) && oids.all? { |oid| oid.is_a?(String) && oid.match?(OID) }
137
+ refuse!("unsupported_command")
138
+ end
139
+ run!(%w[cat-file --batch-check], stdin_data: oids.join("\n") + "\n", limit: oids.size * 128).stdout.to_s.b
140
+ end
141
+
142
+ def blob(path, commit:, max_bytes:)
143
+ refuse!("read_outside_snapshot") unless @view && @commit
144
+ unless commit.is_a?(String) && commit.match?(OID) && max_bytes.is_a?(Integer) && max_bytes.positive?
145
+ refuse!("unsupported_command")
146
+ end
147
+ result = run!(["cat-file", "blob", "#{commit}:#{path}"], limit: [max_bytes, BYTE_LIMIT].min)
148
+ result.stdout.to_s.b
149
+ end
150
+
151
+ private
152
+
153
+ def refuse!(reason)
154
+ raise Unavailable, "canonical read snapshot unavailable: #{reason}"
155
+ end
156
+
157
+ def validate_deadline!(deadline)
158
+ unless deadline.is_a?(Numeric) && deadline.finite? && deadline > Process.clock_gettime(Process::CLOCK_MONOTONIC)
159
+ refuse!("resource_limit")
160
+ end
161
+ end
162
+
163
+ def remaining
164
+ value = @deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
165
+ refuse!("resource_limit") unless value.positive?
166
+ value
167
+ end
168
+
169
+ def acquire!(lock)
170
+ until lock.flock(File::LOCK_SH | File::LOCK_NB)
171
+ sleep [remaining, 0.005].min
172
+ end
173
+ end
174
+
175
+ def identity(stat)
176
+ [stat.dev, stat.ino, stat.mode, stat.uid, stat.gid, stat.size, stat.mtime, stat.ctime]
177
+ end
178
+
179
+ def verify_private_root!
180
+ Authority::PrivateDirectory.verify!(@private_root)
181
+ rescue AttemptErrors::ReceiptRejected
182
+ refuse!("private_storage_unprotected")
183
+ end
184
+
185
+ def directory_identity(stat)
186
+ [stat.dev, stat.ino, stat.mode, stat.uid, stat.gid]
187
+ end
188
+
189
+ def directory!(path)
190
+ stat = File.lstat(path)
191
+ refuse!("unsupported_source") unless stat.directory? && !stat.symlink?
192
+ stat
193
+ end
194
+
195
+ def exists?(path)
196
+ File.lstat(path)
197
+ true
198
+ rescue Errno::ENOENT
199
+ false
200
+ end
201
+
202
+ def held_file!(path, limit:)
203
+ remaining
204
+ parent = File.dirname(path)
205
+ until parent == "/"
206
+ stat = directory!(parent)
207
+ @directories << [parent, directory_identity(stat)]
208
+ parent = File.dirname(parent)
209
+ end
210
+ handle = File.open(path, File::RDONLY | File::NOFOLLOW | File::NONBLOCK)
211
+ @held << handle
212
+ stat = handle.stat
213
+ refuse!("unsupported_source") unless stat.file?
214
+ refuse!("resource_limit") if stat.size > limit
215
+ [handle, identity(stat)]
216
+ end
217
+
218
+ def select_ref!(git_dir)
219
+ loose = File.join(git_dir, @ref)
220
+ if exists?(loose)
221
+ handle, before = held_file!(loose, limit: 128)
222
+ bytes = handle.read(129)
223
+ @metadata_files << [loose, handle, before]
224
+ refuse!("source_changed") unless identity(handle.stat) == before
225
+ refuse!("unsupported_ref") unless bytes.match?(/\A[0-9a-f]{40}\n?\z/)
226
+ return bytes.strip
227
+ end
228
+ packed = File.join(git_dir, "packed-refs")
229
+ handle, before = held_file!(packed, limit: 65_536)
230
+ bytes = handle.read(65_537)
231
+ @metadata_files << [packed, handle, before]
232
+ refuse!("source_changed") unless identity(handle.stat) == before
233
+ entries = {}
234
+ bytes.lines.each do |line|
235
+ next if line.start_with?("#") || line.match?(/\A\^[0-9a-f]{40}\n?\z/)
236
+ match = /\A([0-9a-f]{40}) (refs\/[^\s\0]+)\n?\z/.match(line)
237
+ refuse!("unsupported_ref") unless match && !entries.key?(match[2])
238
+ entries[match[2]] = match[1]
239
+ end
240
+ entries.fetch(@ref) { refuse!("unsupported_ref") }
241
+ end
242
+
243
+ def source_config!(git_dir)
244
+ path = File.join(git_dir, "config")
245
+ handle, before = held_file!(path, limit: 65_536)
246
+ bytes = handle.read(65_537)
247
+ @metadata_files << [path, handle, before]
248
+ refuse!("source_changed") unless identity(handle.stat) == before
249
+ # Standalone file parsing does not select the target repository,
250
+ # follow includes or execute aliases/hooks/helpers. No source config
251
+ # is installed into the private administrative view.
252
+ result = run!(["config", "--no-includes", "--null", "--list", "--file", "/dev/stdin"],
253
+ stdin_data: bytes, limit: 65_536)
254
+ versions = []
255
+ result.stdout.split("\0").each do |entry|
256
+ key, value = entry.split("\n", 2)
257
+ refuse!("unsupported_source") if key.start_with?("extensions.") || key == "core.worktree"
258
+ versions << value if key == "core.repositoryformatversion"
259
+ end
260
+ refuse!("unsupported_source") unless versions.empty? || versions == ["0"]
261
+ end
262
+
263
+ def inventory_names!(objects)
264
+ names = []
265
+ walk = lambda do |path, relative|
266
+ remaining
267
+ stat = File.lstat(path)
268
+ names << [relative, stat.directory? ? "directory" : "file"]
269
+ refuse!("resource_limit") if names.size > ENTRY_LIMIT
270
+ refuse!("unsupported_source") if stat.symlink? || !(stat.directory? || stat.file?)
271
+ if stat.directory?
272
+ unless relative.empty? || %w[pack info].include?(relative) || relative.match?(/\A[0-9a-f]{2}\z/)
273
+ refuse!("unsupported_source")
274
+ end
275
+ children = []
276
+ Dir.each_child(path) do |name|
277
+ remaining
278
+ children << name
279
+ refuse!("resource_limit") if children.size + names.size > ENTRY_LIMIT
280
+ end
281
+ children.sort.each { |name| walk.call(File.join(path, name), relative.empty? ? name : "#{relative}/#{name}") }
282
+ end
283
+ end
284
+ walk.call(objects, "")
285
+ names
286
+ end
287
+
288
+ def inventory!(objects)
289
+ @inventory = inventory_names!(objects)
290
+ files = @inventory.select { |_relative, kind| kind == "file" }.map(&:first)
291
+ packs = files.grep(/\Apack\/pack-[0-9a-f]{40}\.pack\z/)
292
+ unless packs.all? { |pack| files.include?(pack.sub(/\.pack\z/, ".idx")) } &&
293
+ files.grep(/\Apack\/pack-[0-9a-f]{40}\.(?:idx|rev)\z/).all? { |file| packs.include?(file.sub(/\.(?:idx|rev)\z/, ".pack")) }
294
+ refuse!("unsupported_source")
295
+ end
296
+ @inventory.filter_map do |relative, kind|
297
+ path = relative.empty? ? objects : File.join(objects, relative)
298
+ if kind == "directory"
299
+ unless relative.empty? || relative == "pack" || relative == "info" || relative.match?(/\A[0-9a-f]{2}\z/)
300
+ refuse!("unsupported_source")
301
+ end
302
+ @directories << [path, directory_identity(File.lstat(path))]
303
+ next
304
+ end
305
+ # These ordinary Git indexes/retention hints do not redirect
306
+ # storage. The clean view never reads them; only object bytes and
307
+ # pack indexes are copied. Redirect/lazy-fetch metadata refuses.
308
+ next if relative.match?(/\Ainfo\/(?:packs|commit-graph)\z/) ||
309
+ relative.match?(/\Apack\/pack-[0-9a-f]{40}\.(?:bitmap|keep)\z/)
310
+ unless relative.match?(/\A[0-9a-f]{2}\/[0-9a-f]{38}\z/) ||
311
+ relative.match?(/\Apack\/pack-[0-9a-f]{40}\.(?:pack|idx|rev)\z/)
312
+ refuse!("unsupported_source")
313
+ end
314
+ handle, before = held_file!(path, limit: BYTE_LIMIT)
315
+ @bytes += handle.stat.size
316
+ refuse!("resource_limit") if @bytes > BYTE_LIMIT
317
+ @inventory_files << [path, handle, before]
318
+ [relative, handle]
319
+ end
320
+ end
321
+
322
+ def copy!(relative, source)
323
+ destination = File.join(@view, "objects", relative)
324
+ FileUtils.mkdir_p(File.dirname(destination), mode: 0o700)
325
+ File.open(destination, File::WRONLY | File::CREAT | File::EXCL, 0o600) do |target|
326
+ copied = 0
327
+ expected = source.stat.size
328
+ while (chunk = source.read(65_536))
329
+ remaining
330
+ copied += chunk.bytesize
331
+ refuse!("source_changed") if copied > expected
332
+ target.write(chunk)
333
+ end
334
+ refuse!("source_changed") unless copied == expected
335
+ end
336
+ end
337
+
338
+ def verify_held!
339
+ @directories.each { |path, before| refuse!("source_changed") unless directory_identity(File.lstat(path)) == before }
340
+ (@inventory_files + @metadata_files).each do |path, handle, before|
341
+ refuse!("source_changed") unless identity(handle.stat) == before && identity(File.lstat(path)) == before
342
+ end
343
+ end
344
+
345
+ def prepare_view!
346
+ FileUtils.mkdir_p(File.join(@view, "objects"), mode: 0o700)
347
+ FileUtils.mkdir_p(File.join(@view, "refs"), mode: 0o700)
348
+ File.binwrite(File.join(@view, "HEAD"), "#{@commit}\n")
349
+ File.binwrite(File.join(@view, "config"), "[core]\nrepositoryformatversion = 0\nbare = true\n")
350
+ ref = File.join(@view, @ref)
351
+ FileUtils.mkdir_p(File.dirname(ref), mode: 0o700)
352
+ File.binwrite(ref, "#{@commit}\n")
353
+ end
354
+
355
+ def read_command?(argv, stdin)
356
+ return false unless argv.is_a?(Array) && argv.all? { |item| item.is_a?(String) && !item.include?("\0") } && stdin.empty?
357
+ allowed = {"rev-parse" => %w[--verify --quiet --git-dir], "cat-file" => %w[-t -s blob],
358
+ "rev-list" => %w[--first-parent --parents], "ls-tree" => %w[-r -l -d --name-only --],
359
+ "show" => [], "log" => %w[--diff-filter=A -1 --format=%H --]}
360
+ flags = allowed[argv.first]
361
+ flags && argv.drop(1).all? { |item| !item.start_with?("-") || flags.include?(item) || item == "--max-count=100001" }
362
+ end
363
+
364
+ def run!(argv, stdin_data: "", limit:)
365
+ result = Herdr::Molecules::BoundedProcess.call([@git, "--git-dir=#{@view}", "--no-pager", *argv],
366
+ environment: ENVIRONMENT, chdir: @view, stdin_data: stdin_data,
367
+ timeout_s: remaining, output_limit: limit, stderr_limit: 65_536)
368
+ refuse!(result.oversized ? "resource_limit" : "git_unavailable") unless result.status.success? && !result.oversized
369
+ result
370
+ end
371
+ end
372
+ end
373
+ end
374
+ end