ace-assign 0.63.1 → 0.64.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) 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 +43 -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/organisms/assignment_executor.rb +6 -0
  61. data/lib/ace/assign/organisms/attempt_coordinator.rb +312 -13
  62. data/lib/ace/assign/organisms/delivery_coordinator.rb +342 -0
  63. data/lib/ace/assign/organisms/task_assignment_creator.rb +8 -3
  64. data/lib/ace/assign/version.rb +1 -1
  65. data/lib/ace/assign.rb +13 -0
  66. metadata +107 -8
@@ -0,0 +1,151 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module Ace
6
+ module Assign
7
+ module Molecules
8
+ # A decision projection of the existing qjl event chain. No effect
9
+ # executor, extra ref, file registry, or claim ledger lives here.
10
+ module ProposalJournal
11
+ PROPOSAL_REFERENCE = /\Aproposal-[0-9a-f]{24}-r[1-9][0-9]*\z/
12
+ PROPOSAL_BINDING = %w[operation project_id assignment_id attempt_id input_digest target candidate_head caller_uid].freeze
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
15
+
16
+ def proposal_history(id)
17
+ assignment_ids.flat_map do |assignment|
18
+ events = read_events(assignment)
19
+ events.group_by { |event| event["attempt_id"] }.each_value do |chain|
20
+ unless Models::EvidenceEvent.chain_valid?(chain)
21
+ raise AttemptErrors::EvidenceUnavailable, "Proposal evidence chain is corrupt"
22
+ end
23
+ end
24
+ events.select { |event| event["type"] == "proposal_state" && event.dig("payload", "record", "proposal_id") == id }
25
+ .map { |event| event.fetch("payload").fetch("record") }
26
+ end
27
+ end
28
+
29
+ def proposal_record(id)
30
+ proposal_history(id).last
31
+ end
32
+
33
+ def proposals
34
+ assignment_ids.flat_map do |assignment|
35
+ read_events(assignment).select { |event| event["type"] == "proposal_state" }
36
+ .map { |event| event.dig("payload", "record", "proposal_id") }
37
+ end.uniq.map { |id| proposal_record(id) }
38
+ end
39
+
40
+ def proposal_operation_record(operation_id)
41
+ proposals.each do |record|
42
+ found = proposal_history(record["proposal_id"]).reverse.find do |entry|
43
+ entry["revision_operation_id"] == operation_id
44
+ end
45
+ return found if found
46
+ end
47
+ nil
48
+ end
49
+
50
+ # Admitted HITL policy runs this block while the same canonical
51
+ # mutation lock/CAS boundary excludes service claims and other replies.
52
+ def change_proposal(id, assignment_id:, attempt_id:, &policy)
53
+ 3.times do
54
+ events = read_events(assignment_id).select { |event| event["attempt_id"] == attempt_id }
55
+ generation = events.count { |event| event["type"] == "authority_mutation" }
56
+ begin
57
+ return mutate(assignment_id: assignment_id, attempt_id: attempt_id,
58
+ mutation_id: "proposal-#{SecureRandom.hex(16)}", operation: "proposal-decision",
59
+ parameters_digest: Digest::SHA256.hexdigest(id), expected_generation: generation) do |current, _commit, _generation|
60
+ record = current.reverse.find do |event|
61
+ event["type"] == "proposal_state" && event.dig("payload", "record", "proposal_id") == id
62
+ end&.dig("payload", "record")
63
+ global = proposal_record(id)
64
+ if global && (global["assignment_id"] != assignment_id || global["attempt_id"] != attempt_id)
65
+ raise AttemptErrors::ReceiptRejected, "Proposal identity belongs to another assignment or attempt"
66
+ end
67
+ updated = policy.call(record)
68
+ return record if updated == record
69
+ unless updated.is_a?(Hash) && updated["proposal_id"] == id &&
70
+ updated["assignment_id"] == assignment_id && updated["attempt_id"] == attempt_id
71
+ raise AttemptErrors::ReceiptRejected, "Proposal policy changed immutable ownership"
72
+ end
73
+ validate_proposal_revision!(record, updated)
74
+ entries = []
75
+ if record && record["revision_id"] != updated["revision_id"]
76
+ entries << {type: "proposal_state", payload: {"record" => record.merge("state" => "superseded", "resolution" => "revised")}}
77
+ end
78
+ if record.nil? || record["revision_id"] != updated["revision_id"]
79
+ entries << {type: "proposal_state", payload: {"record" => updated.merge("state" => "proposed")}}
80
+ end
81
+ entries << {type: "proposal_state", payload: {"record" => updated}}
82
+ {events: entries, data: {"record" => updated}}
83
+ end.fetch("record")
84
+ rescue AttemptErrors::Conflict => e
85
+ raise unless e.message == "Authority registration generation changed"
86
+ end
87
+ end
88
+ raise AttemptErrors::EvidenceUnavailable, "Proposal generation stayed conflicting"
89
+ end
90
+
91
+ def validate_proposal_revision!(previous, updated)
92
+ if previous && previous["revision_id"] == updated["revision_id"]
93
+ unless PROPOSAL_IMMUTABLE.all? { |field| previous[field] == updated[field] }
94
+ raise AttemptErrors::ReceiptRejected, "Proposal revision content is immutable"
95
+ end
96
+ return
97
+ end
98
+ expected = previous ? previous.fetch("revision") + 1 : 1
99
+ reference = "#{updated.fetch("proposal_id")}-r#{expected}"
100
+ valid = updated["revision"] == expected && updated["revision_id"] == reference &&
101
+ updated["request_id"] == reference && updated["authorization"] == reference &&
102
+ updated["state"] == "awaiting-delivery" && PROPOSAL_REFERENCE.match?(reference)
103
+ if previous
104
+ valid &&= updated["expected_source_revision"] == previous["revision"] &&
105
+ updated["revision_operation_id"].is_a?(String) &&
106
+ updated["revision_operation_id"].match?(/\Arevision-[0-9a-f]{24}\z/) &&
107
+ proposal_operation_record(updated["revision_operation_id"]).nil? &&
108
+ %w[assignment_id attempt_id project_id requester caller_uid].all? { |field| previous[field] == updated[field] }
109
+ else
110
+ valid &&= updated["revision_operation_id"].nil? && updated["expected_source_revision"].nil?
111
+ end
112
+ unresolved = previous && proposal_claims(previous["revision_id"]).any? do |claim|
113
+ claim["state"] != "succeeded" &&
114
+ !(claim["state"] == "failed-settled" && settlement_evidence_intact?(claim))
115
+ end
116
+ raise AttemptErrors::ReceiptRejected, "Claimed proposal cannot be revised; reconcile the existing effect" if unresolved
117
+ raise AttemptErrors::ReceiptRejected, "Proposal revision must begin with exact immutable delivery scope" unless valid
118
+ end
119
+ private :validate_proposal_revision!
120
+
121
+ def proposal_claims(reference)
122
+ service_request_records.select do |request|
123
+ request["authorization"] == reference &&
124
+ !(request["state"] == "rejected" && request["consumed"] == false)
125
+ end
126
+ end
127
+
128
+ def proposal_authorize!(reference, binding)
129
+ unless reference.is_a?(String) && PROPOSAL_REFERENCE.match?(reference)
130
+ raise AttemptErrors::ReceiptRejected, "Invalid proposal authorization reference"
131
+ end
132
+ id = reference.sub(/-r[1-9][0-9]*\z/, "")
133
+ record = proposal_record(id)
134
+ unless record && record["revision_id"] == reference &&
135
+ record["stop_requested"] != true &&
136
+ %w[approved-explicitly approved-by-silence].include?(record["state"]) &&
137
+ PROPOSAL_BINDING.all? { |field| record[field] == binding[field] }
138
+ raise AttemptErrors::ReceiptRejected, "Proposal is not authorized for this exact effect"
139
+ end
140
+ attempt = derived_attempts(binding.fetch("assignment_id")).find do |candidate|
141
+ candidate.attempt_id == binding.fetch("attempt_id")
142
+ end
143
+ unless attempt && attempt.state == "running"
144
+ raise AttemptErrors::ReceiptRejected, "Proposal effect needs an authoritative bound running attempt"
145
+ end
146
+ record
147
+ end
148
+ end
149
+ end
150
+ end
151
+ end
@@ -22,8 +22,14 @@ module Ace
22
22
  APPROVED_VERDICT = "approved"
23
23
 
24
24
  # @param identity_resolver [ExecutionIdentityResolver] Trust boundary
25
- def initialize(identity_resolver: nil)
25
+ # @param artifact_reader [#call, nil] Source-owned canonical byte
26
+ # reader for protected composition; nil selects explicit local files
27
+ def initialize(identity_resolver: nil, artifact_reader: nil)
26
28
  @identity_resolver = identity_resolver || ExecutionIdentityResolver.new
29
+ unless artifact_reader.nil? || artifact_reader.respond_to?(:call)
30
+ raise ArgumentError, "artifact reader must be a source-owned callable"
31
+ end
32
+ @artifact_reader = artifact_reader
27
33
  end
28
34
 
29
35
  # Validate a submitted receipt against an attempt.
@@ -36,6 +42,18 @@ module Ace
36
42
  # @return [Models::ExecutionReceipt] Normalized receipt with digest
37
43
  # @raise [AttemptErrors::ReceiptRejected] on any validation failure
38
44
  def verify!(data, attempt:, identity:, live_head:, repo_root:)
45
+ verify_receipt!(data, attempt: attempt, live_head: live_head, repo_root: repo_root) do
46
+ verify_authority(data, identity)
47
+ end
48
+ end
49
+
50
+ # Submission validates evidence without granting terminal acceptance.
51
+ # The authenticated owner separately checks the exact worker attribution.
52
+ def verify_result!(data, attempt:, live_head:, repo_root:)
53
+ verify_receipt!(data, attempt: attempt, live_head: live_head, repo_root: repo_root)
54
+ end
55
+
56
+ def verify_receipt!(data, attempt:, live_head:, repo_root:)
39
57
  reject_unless(data.is_a?(Hash), "receipt must be a JSON object")
40
58
 
41
59
  if Models::ExecutionReceipt.forbidden_field?(data)
@@ -51,7 +69,7 @@ module Ace
51
69
 
52
70
  verify_binding(data, attempt)
53
71
  verify_producer(data)
54
- verify_authority(data, identity)
72
+ yield if block_given?
55
73
  verify_head(data, live_head)
56
74
  verify_artifacts(data, repo_root) if data["verdict"] == "succeeded"
57
75
  verify_checks(data)
@@ -63,18 +81,22 @@ module Ace
63
81
 
64
82
  receipt
65
83
  end
84
+ private :verify_receipt!
85
+
66
86
  # Recheck the source artifacts/checks of an already accepted receipt.
67
- # Historical reads (recorded at a past head) validate acceptance from
87
+ # Local historical reads (recorded at a past head) validate acceptance from
68
88
  # the append-only journal without re-hashing artifacts: the review
69
89
  # lifecycle legitimately archives and annotates finding files after
70
90
  # collection, and `.ace-local` working files are disposable, so their
71
91
  # current existence proves nothing about an accepted past receipt.
72
- # Current-head reads keep full artifact re-verification.
92
+ # Current-head reads keep full artifact re-verification. Protected
93
+ # readers always reverify imported bytes, including historical reads.
73
94
  def verify_accepted_evidence!(data, live_head:, repo_root:, historical: false)
74
95
  verify_head(data, live_head)
75
- verify_artifacts(data, repo_root) unless historical
96
+ verify_artifacts(data, repo_root) if @artifact_reader || !historical
76
97
  verify_checks(data)
77
98
  verify_review(data)
99
+ verify_campaign(data, repo_root: repo_root, live_head: live_head) if @artifact_reader
78
100
  end
79
101
 
80
102
  # @param operation [String] Receipt operation
@@ -147,17 +169,7 @@ module Ace
147
169
  recorded = artifact["sha256"].to_s
148
170
  reject("artifact missing path or sha256") if path.empty? || recorded.empty?
149
171
 
150
- expanded = begin
151
- File.expand_path(path, File.realpath(repo_root))
152
- rescue Errno::ENOENT, Errno::EACCES
153
- nil
154
- end
155
- reject("artifact file not found: #{path}") if expanded.nil? || !File.exist?(expanded)
156
-
157
- resolved = safe_resolve(repo_root, path)
158
- reject("artifact path escapes project root: #{path}") if resolved.nil?
159
-
160
- actual = Digest::SHA256.hexdigest(File.read(resolved))
172
+ actual = Digest::SHA256.hexdigest(artifact_bytes(data, artifact, repo_root))
161
173
  reject("artifact digest mismatch for #{path}") unless actual == recorded
162
174
  end
163
175
  end
@@ -225,9 +237,7 @@ module Ace
225
237
  unless Array(data["artifacts"]).include?(reference)
226
238
  reject("campaign result must be included in verified receipt artifacts")
227
239
  end
228
- resolved = safe_resolve(repo_root, reference["path"].to_s)
229
- reject("campaign result path escapes repository or is missing") unless resolved
230
- result = JSON.parse(File.read(resolved))
240
+ result = JSON.parse(artifact_bytes(data, reference, repo_root))
231
241
  reject_unless(result.is_a?(Hash), "campaign result must be an object")
232
242
  require "ace/review"
233
243
  current = Ace::Review::Organisms::CampaignManager.new(repo_root: repo_root).status(campaign["id"])
@@ -247,6 +257,26 @@ module Ace
247
257
  reject("invalid campaign result: #{e.message}")
248
258
  end
249
259
 
260
+ # Protected composition injects the canonical imported-byte owner.
261
+ # Its failure never falls through to the disposable workspace copy.
262
+ def artifact_bytes(data, artifact, repo_root)
263
+ if @artifact_reader
264
+ bytes = @artifact_reader.call(data, artifact)
265
+ reject("canonical artifact reader did not return bytes") unless bytes.is_a?(String)
266
+ return bytes.b
267
+ end
268
+ path = artifact["path"].to_s
269
+ expanded = begin
270
+ File.expand_path(path, File.realpath(repo_root))
271
+ rescue Errno::ENOENT, Errno::EACCES
272
+ nil
273
+ end
274
+ reject("artifact file not found: #{path}") if expanded.nil? || !File.exist?(expanded)
275
+ resolved = safe_resolve(repo_root, path)
276
+ reject("artifact path escapes project root: #{path}") if resolved.nil?
277
+ File.read(resolved)
278
+ end
279
+
250
280
  # Resolve a project-relative artifact path to its real location,
251
281
  # rejecting traversal and symlink escapes (an in-repo symlink must
252
282
  # not smuggle in an external file); returns nil when the path leaves
@@ -68,6 +68,12 @@ module Ace
68
68
 
69
69
  assignment_config = config["assignment"] || {}
70
70
  steps_config = config["steps"] || []
71
+ if config.key?("delivery")
72
+ Atoms::DeliveryParameters.validate(config["delivery"])
73
+ unless Array(steps_config).any? { |step| %w[create-pr update-pr-desc mark-pr-ready].include?(step["name"]) }
74
+ raise ArgumentError, "Explicit delivery inputs require remote steps"
75
+ end
76
+ end
71
77
 
72
78
  raise Error, "No steps defined in config" if steps_config.empty?
73
79