ace-assign 0.57.1 → 0.58.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 (34) hide show
  1. checksums.yaml +4 -4
  2. data/.ace-defaults/assign/config.yml +13 -0
  3. data/CHANGELOG.md +13 -0
  4. data/docs/exit-codes.md +10 -0
  5. data/docs/usage.md +32 -0
  6. data/handbook/workflow-instructions/assign/drive.wf.md +64 -8
  7. data/lib/ace/assign/atoms/assignment_scope.rb +53 -0
  8. data/lib/ace/assign/atoms/attempt_state_machine.rb +76 -0
  9. data/lib/ace/assign/atoms/evidence_digest.rb +78 -0
  10. data/lib/ace/assign/cli/commands/attempt/base.rb +35 -0
  11. data/lib/ace/assign/cli/commands/attempt/finish.rb +35 -0
  12. data/lib/ace/assign/cli/commands/attempt/reconcile.rb +38 -0
  13. data/lib/ace/assign/cli/commands/attempt/start.rb +45 -0
  14. data/lib/ace/assign/cli/commands/attempt/status.rb +54 -0
  15. data/lib/ace/assign/cli/commands/status.rb +24 -1
  16. data/lib/ace/assign/cli.rb +30 -2
  17. data/lib/ace/assign/models/assignment.rb +21 -4
  18. data/lib/ace/assign/models/attempt.rb +178 -0
  19. data/lib/ace/assign/models/attempt_binding.rb +116 -0
  20. data/lib/ace/assign/models/evidence_event.rb +82 -0
  21. data/lib/ace/assign/models/execution_receipt.rb +135 -0
  22. data/lib/ace/assign/molecules/assignment_manager.rb +35 -3
  23. data/lib/ace/assign/molecules/attempt_reconciler.rb +88 -0
  24. data/lib/ace/assign/molecules/attempt_store.rb +201 -0
  25. data/lib/ace/assign/molecules/evidence_calculator.rb +144 -91
  26. data/lib/ace/assign/molecules/evidence_journal.rb +424 -0
  27. data/lib/ace/assign/molecules/execution_identity_resolver.rb +97 -0
  28. data/lib/ace/assign/molecules/receipt_verifier.rb +227 -0
  29. data/lib/ace/assign/organisms/assignment_executor.rb +9 -3
  30. data/lib/ace/assign/organisms/attempt_coordinator.rb +544 -0
  31. data/lib/ace/assign/organisms/task_assignment_creator.rb +1 -1
  32. data/lib/ace/assign/version.rb +1 -1
  33. data/lib/ace/assign.rb +56 -0
  34. metadata +20 -2
@@ -0,0 +1,227 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module Ace
6
+ module Assign
7
+ module Molecules
8
+ # Canonical receipt validation for accepted execution evidence.
9
+ #
10
+ # Verifies schema and field hygiene, attempt binding, producer
11
+ # attribution, transition authority, current-head candidate binding,
12
+ # artifact digests, executed checks, and independent review verdicts.
13
+ # Every failure is a rejection with an actionable reason; a receipt is
14
+ # never partially accepted.
15
+ class ReceiptVerifier
16
+ # Operations that act outside the local repository once executed.
17
+ EXTERNAL_EFFECT_OPERATIONS = %w[merge publish deploy release].freeze
18
+
19
+ REVIEW_OPERATION = "review"
20
+ HEAD_PATTERN = /\A[0-9a-f]{7,64}\z/.freeze
21
+ APPROVED_VERDICT = "approved"
22
+
23
+ # @param identity_resolver [ExecutionIdentityResolver] Trust boundary
24
+ def initialize(identity_resolver: nil)
25
+ @identity_resolver = identity_resolver || ExecutionIdentityResolver.new
26
+ end
27
+
28
+ # Validate a submitted receipt against an attempt.
29
+ #
30
+ # @param data [Hash] Parsed receipt payload
31
+ # @param attempt [Models::Attempt] Target attempt (loaded by coordinator)
32
+ # @param identity [ExecutionIdentityResolver::Identity] Finishing boundary identity
33
+ # @param live_head [String] Current candidate revision (git rev-parse HEAD)
34
+ # @param repo_root [String] Project root for artifact resolution
35
+ # @return [Models::ExecutionReceipt] Normalized receipt with digest
36
+ # @raise [AttemptErrors::ReceiptRejected] on any validation failure
37
+ def verify!(data, attempt:, identity:, live_head:, repo_root:)
38
+ reject_unless(data.is_a?(Hash), "receipt must be a JSON object")
39
+
40
+ if Models::ExecutionReceipt.forbidden_field?(data)
41
+ reject("receipt carries forbidden fields (credentials, environment, or terminal output)")
42
+ end
43
+
44
+ required = %w[attempt_id assignment_id project_id scope operation producer head verdict]
45
+ missing = required.select { |key| data[key].nil? || data[key].to_s.strip.empty? }
46
+ reject("missing required fields: #{missing.join(', ')}") unless missing.empty?
47
+ unless Models::ExecutionReceipt::VERDICTS.include?(data["verdict"].to_s)
48
+ reject("unsupported verdict '#{data['verdict']}': must be one of #{Models::ExecutionReceipt::VERDICTS.join(', ')}")
49
+ end
50
+
51
+ verify_binding(data, attempt)
52
+ verify_producer(data)
53
+ verify_authority(data, identity)
54
+ verify_head(data, live_head)
55
+ verify_artifacts(data, repo_root) if data["verdict"] == "succeeded"
56
+ verify_checks(data)
57
+ verify_review(data)
58
+
59
+ receipt = Models::ExecutionReceipt.from_h(data.merge("recorded_at" => Time.now.utc))
60
+ verify_digest(receipt)
61
+
62
+ receipt
63
+ end
64
+ # @param operation [String] Receipt operation
65
+ # @return [Boolean] True when the operation acts outside the repository
66
+ def external_effect?(operation)
67
+ EXTERNAL_EFFECT_OPERATIONS.include?(operation.to_s)
68
+ end
69
+
70
+ private
71
+
72
+ def verify_binding(data, attempt)
73
+ binding = attempt.binding
74
+ unless data["attempt_id"] == attempt.attempt_id
75
+ reject("receipt attempt_id #{data['attempt_id']} does not match attempt #{attempt.attempt_id}")
76
+ end
77
+ unless data["assignment_id"] == binding.assignment_id
78
+ reject("receipt assignment #{data['assignment_id']} does not match attempt assignment #{binding.assignment_id}")
79
+ end
80
+ unless data["project_id"] == binding.project_id
81
+ reject("receipt project #{data['project_id']} does not match attempt project #{binding.project_id}")
82
+ end
83
+ unless Atoms::AssignmentScope.equal?(data["scope"], binding.scope)
84
+ reject("receipt scope #{data['scope']} does not match attempt scope #{binding.scope}")
85
+ end
86
+ end
87
+
88
+ def verify_producer(data)
89
+ producer = data["producer"]
90
+ reject_unless(producer.is_a?(Hash), "producer must be an object with actor, role, runtime")
91
+
92
+ actor = producer["actor"].to_s.strip
93
+ role = producer["role"].to_s.strip
94
+ runtime = producer["runtime"].to_s.strip
95
+ if actor.empty? || runtime.empty? || !ExecutionIdentityResolver::ROLES.include?(role)
96
+ reject("producer attribution incomplete: actor, runtime, and a known role are required")
97
+ end
98
+ end
99
+
100
+ # Workers submit attributable results but cannot self-approve
101
+ # succeeded outcomes; only coordinator/service authority accepts them.
102
+ def verify_authority(data, identity)
103
+ return if data["verdict"] != "succeeded"
104
+ return if @identity_resolver.trusted?(identity)
105
+
106
+ reject("identity #{identity.actor}/#{identity.role} may not accept a succeeded verdict")
107
+ end
108
+
109
+ # The tested/reviewed head must be the live candidate revision; stale
110
+ # heads are rejected (candidate invalidation is recorded by the
111
+ # coordinator).
112
+ def verify_head(data, live_head)
113
+ reject("live candidate head unavailable") if live_head.nil? || live_head.to_s.strip.empty?
114
+ reject("receipt head '#{data['head']}' is not a git SHA") unless data["head"].match?(HEAD_PATTERN)
115
+
116
+ unless data["head"] == live_head
117
+ reject("receipt head #{data['head']} is stale; current candidate is #{live_head}")
118
+ end
119
+ end
120
+
121
+ # Succeeded outcomes need at least one artifact whose recorded digest
122
+ # matches the file on disk. Paths stay below the project root; symlink
123
+ # or traversal escapes are rejected.
124
+ def verify_artifacts(data, repo_root)
125
+ artifacts = data["artifacts"]
126
+ reject_unless(artifacts.is_a?(Array) && !artifacts.empty?, "succeeded verdict requires at least one artifact")
127
+
128
+ artifacts.each do |artifact|
129
+ reject_unless(artifact.is_a?(Hash), "artifact entries must be objects")
130
+ path = artifact["path"].to_s
131
+ recorded = artifact["sha256"].to_s
132
+ reject("artifact missing path or sha256") if path.empty? || recorded.empty?
133
+
134
+ expanded = begin
135
+ File.expand_path(path, File.realpath(repo_root))
136
+ rescue Errno::ENOENT, Errno::EACCES
137
+ nil
138
+ end
139
+ reject("artifact file not found: #{path}") if expanded.nil? || !File.exist?(expanded)
140
+
141
+ resolved = safe_resolve(repo_root, path)
142
+ reject("artifact path escapes project root: #{path}") if resolved.nil?
143
+
144
+ actual = Digest::SHA256.hexdigest(File.read(resolved))
145
+ reject("artifact digest mismatch for #{path}") unless actual == recorded
146
+ end
147
+ end
148
+
149
+ def verify_checks(data)
150
+ checks = data["checks"] || []
151
+ reject_unless(checks.is_a?(Array), "checks must be an array")
152
+ if data["verdict"] == "succeeded" && checks.empty?
153
+ reject("succeeded verdict requires at least one executed check")
154
+ end
155
+
156
+ checks.each do |check|
157
+ reject_unless(check.is_a?(Hash), "check entries must be objects")
158
+ reject("check missing name") if check["name"].to_s.strip.empty?
159
+ reject("check #{check['name']} did not pass") if check["verdict"].to_s != "passed" && data["verdict"] == "succeeded"
160
+ end
161
+ end
162
+
163
+ # A caller-supplied digest must equal the canonical digest of the
164
+ # receipt payload; arbitrary or all-zero digests are rejected.
165
+ def verify_digest(receipt)
166
+ return if receipt.digest == Atoms::EvidenceDigest.digest(receipt.digest_payload)
167
+
168
+ reject("receipt digest mismatch: supplied digest does not match the canonical payload")
169
+ end
170
+
171
+ # Review receipts need an executed, independent reviewer verdict for
172
+ # exactly the tested head — a report existing is not approval, and a
173
+ # review receipt without a reviewer verdict is not evidence.
174
+ def verify_review(data)
175
+ review = data["review"]
176
+ if review.nil?
177
+ if data["operation"] == REVIEW_OPERATION && data["verdict"] == "succeeded"
178
+ reject("review receipt requires an executed independent reviewer verdict")
179
+ end
180
+
181
+ return
182
+ end
183
+
184
+ reject_unless(review.is_a?(Hash), "review must be an object")
185
+ reviewer = review["reviewer"]
186
+ reject_unless(reviewer.is_a?(Hash) && !reviewer["actor"].to_s.strip.empty?, "review must name an executed reviewer")
187
+
188
+ producer_actor = data.dig("producer", "actor").to_s.strip
189
+ if reviewer["actor"].to_s.strip == producer_actor
190
+ reject("reviewer and producer must differ (self-approval rejected)")
191
+ end
192
+
193
+ reject("review verdict must be '#{APPROVED_VERDICT}' for a succeeded receipt") if review["verdict"] != APPROVED_VERDICT && data["verdict"] == "succeeded"
194
+ unless review["head"] == data["head"]
195
+ reject("review head #{review['head']} does not match receipt head #{data['head']}")
196
+ end
197
+ end
198
+
199
+ # Resolve a project-relative artifact path to its real location,
200
+ # rejecting traversal and symlink escapes (an in-repo symlink must
201
+ # not smuggle in an external file); returns nil when the path leaves
202
+ # the root.
203
+ def safe_resolve(repo_root, path)
204
+ root = File.realpath(repo_root)
205
+ candidate = File.expand_path(path, root)
206
+ return nil unless candidate == root || candidate.start_with?(root + File::SEPARATOR)
207
+ return nil unless File.exist?(candidate)
208
+
209
+ resolved = File.realpath(candidate)
210
+ return nil unless resolved == root || resolved.start_with?(root + File::SEPARATOR)
211
+
212
+ resolved
213
+ rescue Errno::ENOENT, Errno::EACCES
214
+ nil
215
+ end
216
+
217
+ def reject(message)
218
+ raise AttemptErrors::ReceiptRejected, message
219
+ end
220
+
221
+ def reject_unless(condition, message)
222
+ reject(message) unless condition
223
+ end
224
+ end
225
+ end
226
+ end
227
+ end
@@ -56,8 +56,12 @@ module Ace
56
56
  #
57
57
  # @param config_path [String] Path to job.yaml config
58
58
  # @param parent_id [String, nil] Parent assignment ID for hierarchy linking
59
+ # @param task_id [String, nil] Source task ID attaching this assignment
60
+ # (also read from the job's assignment.task_id); nil = taskless
61
+ # @param project_id [String, nil] Project ID (also read from the job's
62
+ # assignment.project_id)
59
63
  # @return [Hash] Result with assignment and first step
60
- def start(config_path, parent_id: nil)
64
+ def start(config_path, parent_id: nil, task_id: nil, project_id: nil)
61
65
  raise ConfigErrors::NotFound, "Config file not found: #{config_path}" unless File.exist?(config_path)
62
66
 
63
67
  config = YAML.safe_load_file(config_path, permitted_classes: [Time, Date])
@@ -74,12 +78,14 @@ module Ace
74
78
  steps_config = expand_sub_steps(steps_config)
75
79
  steps_config = materialize_skill_backed_steps(steps_config)
76
80
 
77
- # Create assignment
81
+ # Create assignment; explicit args win over job config keys
78
82
  assignment = assignment_manager.create(
79
83
  name: assignment_config["name"] || File.basename(config_path, ".yaml"),
80
84
  description: assignment_config["description"],
81
85
  source_config: config_path,
82
- parent: parent_id
86
+ parent: parent_id,
87
+ task_id: task_id || assignment_config["task_id"],
88
+ project_id: project_id || assignment_config["project_id"]
83
89
  )
84
90
 
85
91
  # Create initial step files