ace-assign 0.63.1 → 0.66.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 (147) hide show
  1. checksums.yaml +4 -4
  2. data/.ace-defaults/assign/catalog/steps/auto-merge.step.yml +11 -0
  3. data/.ace-defaults/assign/catalog/steps/create-pr.step.yml +2 -2
  4. data/.ace-defaults/assign/catalog/steps/mark-pr-ready.step.yml +1 -1
  5. data/.ace-defaults/assign/catalog/steps/release-minor.step.yml +4 -2
  6. data/.ace-defaults/assign/catalog/steps/release.step.yml +4 -2
  7. data/.ace-defaults/assign/catalog/steps/update-pr-desc.step.yml +1 -1
  8. data/.ace-defaults/assign/presets/fix-bug.yml +15 -3
  9. data/.ace-defaults/assign/presets/work-on-task-ace-development.yml +16 -4
  10. data/.ace-defaults/assign/presets/work-on-task-auto-merge.yml +15 -3
  11. data/.ace-defaults/assign/presets/work-on-task.yml +16 -4
  12. data/CHANGELOG.md +62 -0
  13. data/docs/demo/canonical-skill-source.tape.yml +4 -4
  14. data/docs/protected-authority.md +282 -0
  15. data/docs/usage.md +205 -3
  16. data/exe/ace-scope-ready +55 -0
  17. data/handbook/workflow-instructions/assign/drive.wf.md +28 -3
  18. data/handbook/workflow-instructions/assign/prepare.wf.md +2 -2
  19. data/lib/ace/assign/atoms/attempt_state_machine.rb +8 -0
  20. data/lib/ace/assign/atoms/delivery_parameters.rb +19 -0
  21. data/lib/ace/assign/authority/assignment_inventory.rb +290 -0
  22. data/lib/ace/assign/authority/campaign_consumer_policy.rb +100 -0
  23. data/lib/ace/assign/authority/campaign_execution.rb +119 -0
  24. data/lib/ace/assign/authority/campaign_round_transfer.rb +82 -0
  25. data/lib/ace/assign/authority/candidate_transfer.rb +327 -0
  26. data/lib/ace/assign/authority/cleanup_result_protection.rb +53 -0
  27. data/lib/ace/assign/authority/client.rb +257 -0
  28. data/lib/ace/assign/authority/deployment.rb +627 -0
  29. data/lib/ace/assign/authority/deployment_history.rb +146 -0
  30. data/lib/ace/assign/authority/endcap.rb +455 -0
  31. data/lib/ace/assign/authority/endcap_attempts.rb +274 -0
  32. data/lib/ace/assign/authority/endcap_campaign_results.rb +232 -0
  33. data/lib/ace/assign/authority/endcap_campaign_rounds.rb +266 -0
  34. data/lib/ace/assign/authority/endcap_contexts.rb +23 -0
  35. data/lib/ace/assign/authority/endcap_inboxes.rb +37 -0
  36. data/lib/ace/assign/authority/endcap_prepared_work.rb +80 -0
  37. data/lib/ace/assign/authority/endcap_publication.rb +207 -0
  38. data/lib/ace/assign/authority/endcap_results.rb +398 -0
  39. data/lib/ace/assign/authority/endcap_review_requests.rb +183 -0
  40. data/lib/ace/assign/authority/endcap_reviews.rb +130 -0
  41. data/lib/ace/assign/authority/endcap_service_settlement.rb +152 -0
  42. data/lib/ace/assign/authority/endcap_services.rb +598 -0
  43. data/lib/ace/assign/authority/evidence_transfer.rb +67 -0
  44. data/lib/ace/assign/authority/execution_scope_observation.rb +903 -0
  45. data/lib/ace/assign/authority/inbox_context_original.rb +113 -0
  46. data/lib/ace/assign/authority/launch_attempt_consumers.rb +51 -0
  47. data/lib/ace/assign/authority/launch_campaign_registration.rb +214 -0
  48. data/lib/ace/assign/authority/launch_control_channel.rb +313 -0
  49. data/lib/ace/assign/authority/launch_control_exclusions.rb +141 -0
  50. data/lib/ace/assign/authority/launch_driver.rb +393 -0
  51. data/lib/ace/assign/authority/launch_input_inhibition.rb +132 -0
  52. data/lib/ace/assign/authority/launch_lifecycle.rb +997 -0
  53. data/lib/ace/assign/authority/launch_prepared_work.rb +122 -0
  54. data/lib/ace/assign/authority/launch_review.rb +91 -0
  55. data/lib/ace/assign/authority/launch_scope_abort.rb +81 -0
  56. data/lib/ace/assign/authority/launch_scope_admission.rb +215 -0
  57. data/lib/ace/assign/authority/launch_scope_close.rb +146 -0
  58. data/lib/ace/assign/authority/launch_scope_parent.rb +44 -0
  59. data/lib/ace/assign/authority/launch_scope_release.rb +859 -0
  60. data/lib/ace/assign/authority/launch_steering.rb +353 -0
  61. data/lib/ace/assign/authority/launch_stop.rb +254 -0
  62. data/lib/ace/assign/authority/maintenance_workspace_retirement.rb +229 -0
  63. data/lib/ace/assign/authority/original_inbox_selection.rb +41 -0
  64. data/lib/ace/assign/authority/posix_acl.rb +59 -0
  65. data/lib/ace/assign/authority/prepared_input.rb +130 -0
  66. data/lib/ace/assign/authority/prepared_queue.rb +185 -0
  67. data/lib/ace/assign/authority/prepared_task_context.rb +116 -0
  68. data/lib/ace/assign/authority/prepared_work.rb +304 -0
  69. data/lib/ace/assign/authority/prepared_worker.rb +69 -0
  70. data/lib/ace/assign/authority/private_directory.rb +33 -0
  71. data/lib/ace/assign/authority/protected_assignment_context.rb +226 -0
  72. data/lib/ace/assign/authority/readiness_hook.rb +120 -0
  73. data/lib/ace/assign/authority/receipt_transfer.rb +48 -0
  74. data/lib/ace/assign/authority/router.rb +76 -0
  75. data/lib/ace/assign/authority/server.rb +300 -0
  76. data/lib/ace/assign/authority/service_cleanup_dispatch.rb +88 -0
  77. data/lib/ace/assign/authority/service_cleanup_evidence.rb +180 -0
  78. data/lib/ace/assign/authority/service_delivery_evidence.rb +101 -0
  79. data/lib/ace/assign/authority/service_evidence.rb +286 -0
  80. data/lib/ace/assign/authority/service_publication_evidence.rb +110 -0
  81. data/lib/ace/assign/authority/task_context_entry.rb +32 -0
  82. data/lib/ace/assign/authority/transfer_codec.rb +187 -0
  83. data/lib/ace/assign/cli/commands/assignment_target.rb +15 -2
  84. data/lib/ace/assign/cli/commands/attempt/base.rb +49 -0
  85. data/lib/ace/assign/cli/commands/attempt/finish.rb +20 -4
  86. data/lib/ace/assign/cli/commands/attempt/reconcile.rb +11 -0
  87. data/lib/ace/assign/cli/commands/authority/inbox_context_principal.rb +25 -0
  88. data/lib/ace/assign/cli/commands/authority/inbox_context_selection.rb +28 -0
  89. data/lib/ace/assign/cli/commands/authority/launch.rb +97 -0
  90. data/lib/ace/assign/cli/commands/authority/serve.rb +31 -0
  91. data/lib/ace/assign/cli/commands/authority/status.rb +26 -0
  92. data/lib/ace/assign/cli/commands/authority/task_context.rb +31 -0
  93. data/lib/ace/assign/cli/commands/authority/task_context_principal.rb +25 -0
  94. data/lib/ace/assign/cli/commands/authority/task_context_selection.rb +30 -0
  95. data/lib/ace/assign/cli/commands/authority/terminate.rb +29 -0
  96. data/lib/ace/assign/cli/commands/authority/worker.rb +23 -0
  97. data/lib/ace/assign/cli/commands/campaign_export_result.rb +55 -0
  98. data/lib/ace/assign/cli/commands/campaign_record_round.rb +57 -0
  99. data/lib/ace/assign/cli/commands/create.rb +11 -1
  100. data/lib/ace/assign/cli/commands/delivery.rb +107 -0
  101. data/lib/ace/assign/cli/commands/fail.rb +6 -0
  102. data/lib/ace/assign/cli/commands/finish.rb +23 -2
  103. data/lib/ace/assign/cli/commands/fork_session.rb +6 -2
  104. data/lib/ace/assign/cli/commands/inbox_bind.rb +34 -0
  105. data/lib/ace/assign/cli/commands/protected_submission.rb +61 -0
  106. data/lib/ace/assign/cli/commands/resume.rb +38 -0
  107. data/lib/ace/assign/cli/commands/select.rb +2 -0
  108. data/lib/ace/assign/cli/commands/start.rb +2 -0
  109. data/lib/ace/assign/cli/commands/status.rb +4 -2
  110. data/lib/ace/assign/cli/commands/step.rb +3 -1
  111. data/lib/ace/assign/cli/commands/submit_candidate.rb +34 -0
  112. data/lib/ace/assign/cli/commands/submit_result.rb +61 -0
  113. data/lib/ace/assign/cli.rb +56 -4
  114. data/lib/ace/assign/errors.rb +139 -0
  115. data/lib/ace/assign/models/assignment.rb +26 -4
  116. data/lib/ace/assign/models/evidence_event.rb +4 -2
  117. data/lib/ace/assign/molecules/assignment_manager.rb +12 -2
  118. data/lib/ace/assign/molecules/attempt_reconciler.rb +51 -12
  119. data/lib/ace/assign/molecules/canonical_attempt_state.rb +151 -0
  120. data/lib/ace/assign/molecules/canonical_evidence.rb +153 -0
  121. data/lib/ace/assign/molecules/canonical_read_snapshot.rb +374 -0
  122. data/lib/ace/assign/molecules/evidence_journal.rb +922 -91
  123. data/lib/ace/assign/molecules/execution_identity_resolver.rb +49 -9
  124. data/lib/ace/assign/molecules/execution_scope_lineage.rb +345 -0
  125. data/lib/ace/assign/molecules/fork_session_launcher.rb +16 -6
  126. data/lib/ace/assign/molecules/journal_input_inhibition.rb +78 -0
  127. data/lib/ace/assign/molecules/journal_mutation.rb +271 -0
  128. data/lib/ace/assign/molecules/journal_prompt_mutation.rb +241 -0
  129. data/lib/ace/assign/molecules/lifecycle_exclusion.rb +182 -26
  130. data/lib/ace/assign/molecules/original_launch_binding.rb +37 -0
  131. data/lib/ace/assign/molecules/proposal_journal.rb +151 -0
  132. data/lib/ace/assign/molecules/protected_control_exclusion.rb +303 -0
  133. data/lib/ace/assign/molecules/protected_workspace_exclusion.rb +330 -0
  134. data/lib/ace/assign/molecules/protected_workspace_provisioner.rb +164 -0
  135. data/lib/ace/assign/molecules/queue_scanner.rb +26 -11
  136. data/lib/ace/assign/molecules/receipt_verifier.rb +68 -32
  137. data/lib/ace/assign/molecules/step_writer.rb +27 -5
  138. data/lib/ace/assign/molecules/terminal_scope_receipt.rb +88 -0
  139. data/lib/ace/assign/organisms/assignment_executor.rb +10 -4
  140. data/lib/ace/assign/organisms/attempt_coordinator.rb +302 -13
  141. data/lib/ace/assign/organisms/delivery_coordinator.rb +342 -0
  142. data/lib/ace/assign/organisms/prepared_work_builder.rb +194 -0
  143. data/lib/ace/assign/organisms/protected_delivery_coordinator.rb +100 -0
  144. data/lib/ace/assign/organisms/task_assignment_creator.rb +11 -4
  145. data/lib/ace/assign/version.rb +1 -1
  146. data/lib/ace/assign.rb +4 -102
  147. metadata +186 -8
@@ -0,0 +1,164 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "protected_workspace_exclusion"
4
+
5
+ module Ace
6
+ module Assign
7
+ module Molecules
8
+ class LifecycleExclusion
9
+ # Original full-privilege operator phase, before publication/start.
10
+ # It only provisions derived exclusion artifacts, never a workspace.
11
+ class WorkspaceProvisioner
12
+ class Protection < WorkspaceHostReader::Protection
13
+ private
14
+ def root_path(_root) = nil
15
+ end
16
+
17
+ def initialize(mapping_id:, project_id:, authority:, cwd_resource:, files: File, directories: Dir, protection: nil)
18
+ @selection = LifecycleExclusion.workspace_selection(mapping_id: mapping_id, project_id: project_id,
19
+ authority: authority, cwd_resource: cwd_resource)
20
+ @authority = authority.slice("state_root", "uid", "gid").freeze
21
+ @files, @directories = files, directories
22
+ @protection = protection || Protection.new(projection: {"authority_uid" => authority.fetch("uid"),
23
+ "authority_gid" => authority.fetch("gid"), "root_resource" => {}})
24
+ @handles = {}
25
+ end
26
+
27
+ def provision!
28
+ root = @authority.fetch("state_root")
29
+ parts = root.split("/").reject(&:empty?)
30
+ (["/"] + parts.each_index.map { |index| "/" + parts.take(index + 1).join("/") }).each do |path|
31
+ hold_directory!(path)
32
+ end
33
+ selected_directory!(@handles.fetch(root), mode: 0o700)
34
+ relative = @selection.fetch("host_path").delete_prefix(root + "/").split("/")
35
+ parent = root
36
+ relative.each_with_index do |part, index|
37
+ path = File.join(parent, part)
38
+ mode = index == relative.length - 1 ? 0o755 : 0o700
39
+ provision_directory!(path, parent, mode)
40
+ verify_unchanged!
41
+ parent = path
42
+ end
43
+ project = File.dirname(File.dirname(File.dirname(@selection.fetch("host_path"))))
44
+ provision_directory!(File.join(project, "control"), project, 0o700)
45
+ root_stat = @handles.fetch(@selection.fetch("host_path")).stat
46
+ host_identity = {"device" => root_stat.dev, "inode" => root_stat.ino,
47
+ "uid" => root_stat.uid, "gid" => root_stat.gid}.freeze
48
+ # Resource mount identity is observed by the existing boundary owner;
49
+ # provision returns only association/host inode, not a namespace pin.
50
+ provision_file!("lock", "")
51
+ marker = provision_file!("state.json", LifecycleExclusion.workspace_initial_marker(key: @selection.fetch("key")))
52
+ bytes = marker.read(8193)
53
+ state = LifecycleExclusion.workspace_marker!(bytes: bytes, key: @selection.fetch("key"))
54
+ raise AttemptErrors::Conflict, "Original lifecycle workspace is already fenced" if state.fetch("removed")
55
+ unless [marker.stat.uid, marker.stat.gid] == @authority.values_at("uid", "gid")
56
+ raise AttemptErrors::EvidenceUnavailable, "Initial lifecycle marker owner differs"
57
+ end
58
+ verify_unchanged!
59
+ result = @selection.merge("host_identity" => host_identity)
60
+ result.fetch("host_identity").freeze
61
+ result.each_value(&:freeze)
62
+ result.freeze
63
+ rescue Ace::Runtime::RuntimeUnavailableError, SystemCallError, IOError
64
+ raise AttemptErrors::EvidenceUnavailable, "Original lifecycle provisioning is unavailable"
65
+ ensure
66
+ close!
67
+ end
68
+
69
+ private
70
+
71
+ def provision_directory!(path, parent, mode)
72
+ created = begin
73
+ @directories.mkdir(path, 0o700)
74
+ true
75
+ rescue Errno::EEXIST
76
+ false
77
+ end
78
+ handle = hold_directory!(path)
79
+ if created
80
+ handle.chown(@authority.fetch("uid"), @authority.fetch("gid"))
81
+ handle.chmod(mode)
82
+ handle.fsync
83
+ @handles.fetch(parent).fsync
84
+ end
85
+ selected_directory!(handle, mode: mode)
86
+ end
87
+
88
+ def hold_directory!(path)
89
+ handle = @files.open(path, File::RDONLY | File::NOFOLLOW | File::NONBLOCK)
90
+ unless handle.stat.directory? && identity(handle.stat) == identity(@files.lstat(path))
91
+ raise AttemptErrors::EvidenceUnavailable, "Lifecycle provisioning directory differs"
92
+ end
93
+ @protection.verify!(path, handle, directory: true)
94
+ @handles[path] = handle
95
+ ensure
96
+ handle&.close unless @handles[path] == handle
97
+ end
98
+
99
+ def selected_directory!(handle, mode:)
100
+ stat = handle.stat
101
+ unless [stat.uid, stat.gid, stat.mode & 0o7777] == @authority.values_at("uid", "gid") + [mode]
102
+ raise AttemptErrors::EvidenceUnavailable, "Lifecycle provisioning selected directory differs"
103
+ end
104
+ end
105
+
106
+ def provision_file!(suffix, bytes)
107
+ root = @selection.fetch("host_path")
108
+ path = File.join(root, "#{Digest::SHA256.hexdigest(@selection.fetch('key'))}.#{suffix}")
109
+ created = true
110
+ handle = begin
111
+ @files.open(path, File::RDWR | File::CREAT | File::EXCL | File::NOFOLLOW | File::NONBLOCK, 0o600)
112
+ rescue Errno::EEXIST
113
+ created = false
114
+ @files.open(path, File::RDONLY | File::NOFOLLOW | File::NONBLOCK)
115
+ end
116
+ if created
117
+ handle.write(bytes)
118
+ handle.flush
119
+ handle.chown(@authority.fetch("uid"), @authority.fetch("gid"))
120
+ handle.chmod(0o644)
121
+ handle.fsync
122
+ @handles.fetch(root).fsync
123
+ end
124
+ stat = handle.stat
125
+ unless stat.file? && stat.nlink == 1 && (stat.mode & 0o7777) == 0o644 &&
126
+ identity(stat) == identity(@files.lstat(path)) && stat.size <= 8192 &&
127
+ (suffix != "lock" || stat.size.zero? && [stat.uid, stat.gid] == @authority.values_at("uid", "gid"))
128
+ raise AttemptErrors::EvidenceUnavailable, "Lifecycle provisioning file differs"
129
+ end
130
+ @protection.verify!(path, handle, directory: false)
131
+ handle.rewind
132
+ @handles[path] = handle
133
+ ensure
134
+ handle&.close unless @handles[path] == handle
135
+ end
136
+
137
+ def verify_unchanged!
138
+ @handles.each do |path, handle|
139
+ unless identity(handle.stat) == identity(@files.lstat(path))
140
+ raise AttemptErrors::EvidenceUnavailable, "Lifecycle provisioned object was replaced"
141
+ end
142
+ @protection.verify!(path, handle, directory: handle.stat.directory?)
143
+ end
144
+ end
145
+
146
+ def identity(stat) = [stat.dev, stat.ino, stat.uid, stat.gid, stat.mode]
147
+
148
+ def close!
149
+ error = nil
150
+ @handles.values.reverse_each do |handle|
151
+ begin
152
+ handle.close unless handle.closed?
153
+ rescue SystemCallError, IOError => caught
154
+ error ||= caught
155
+ end
156
+ end
157
+ @handles.clear
158
+ raise error if error
159
+ end
160
+ end
161
+ end
162
+ end
163
+ end
164
+ end
@@ -53,19 +53,42 @@ module Ace
53
53
  end.compact
54
54
  end
55
55
 
56
+ # Source-authenticated callers retain exact step/report bytes through
57
+ # validation and parsing. No pathname reopen chooses executable work.
58
+ def scan_captured(steps:, assignment:, reports:)
59
+ parsed = Atoms::StepSorter.sort(steps.keys).map do |filename|
60
+ path = File.join(assignment.steps_dir, filename)
61
+ info = Atoms::StepFileParser.parse_filename(filename)
62
+ report_name = Atoms::StepFileParser.generate_report_filename(info.fetch(:number), info.fetch(:name))
63
+ report = reports[report_name]
64
+ report = Atoms::StepFileParser.parse(report).fetch(:body).strip if report
65
+ parse_step_content(path, steps.fetch(filename), report)
66
+ end
67
+ Models::QueueState.new(steps: parsed, assignment: assignment)
68
+ end
69
+
56
70
  private
57
71
 
58
72
  def parse_step_file(file_path)
59
73
  content = File.read(file_path)
74
+ filename_info = Atoms::StepFileParser.parse_filename(File.basename(file_path))
75
+ report = load_report(file_path, filename_info[:number], filename_info[:name])
76
+ parse_step_content(file_path, content, report)
77
+ rescue ArgumentError => e
78
+ warn "Invalid step file #{file_path}: #{e.message}"
79
+ nil
80
+ rescue => e
81
+ warn "Failed to parse step file #{file_path}: #{e.message}" if Ace::Assign.debug?
82
+ nil
83
+ end
84
+
85
+ def parse_step_content(file_path, content, report)
60
86
  parsed = Atoms::StepFileParser.parse(content)
61
87
  fields = Atoms::StepFileParser.extract_fields(parsed)
62
88
 
63
89
  # Extract number and name from filename
64
90
  filename_info = Atoms::StepFileParser.parse_filename(File.basename(file_path))
65
91
 
66
- # Load report from separate file if it exists
67
- report = load_report(file_path, filename_info[:number], filename_info[:name])
68
-
69
92
  Models::Step.new(
70
93
  number: filename_info[:number],
71
94
  name: fields[:name] || filename_info[:name],
@@ -93,14 +116,6 @@ module Ace
93
116
  stall_reason: fields[:stall_reason],
94
117
  file_path: file_path
95
118
  )
96
- rescue ArgumentError => e
97
- # ArgumentError indicates invalid data (e.g., invalid context value)
98
- # Surface these errors visibly to help users fix configuration
99
- warn "Invalid step file #{file_path}: #{e.message}"
100
- nil
101
- rescue => e
102
- warn "Failed to parse step file #{file_path}: #{e.message}" if Ace::Assign.debug?
103
- nil
104
119
  end
105
120
 
106
121
  # Load report from the reports/ directory
@@ -22,8 +22,18 @@ 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, campaign_verifier: 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
33
+ unless campaign_verifier.nil? || campaign_verifier.respond_to?(:call)
34
+ raise ArgumentError, "campaign verifier must be a source-owned callable"
35
+ end
36
+ @campaign_verifier = campaign_verifier
27
37
  end
28
38
 
29
39
  # Validate a submitted receipt against an attempt.
@@ -36,6 +46,18 @@ module Ace
36
46
  # @return [Models::ExecutionReceipt] Normalized receipt with digest
37
47
  # @raise [AttemptErrors::ReceiptRejected] on any validation failure
38
48
  def verify!(data, attempt:, identity:, live_head:, repo_root:)
49
+ verify_receipt!(data, attempt: attempt, live_head: live_head, repo_root: repo_root) do
50
+ verify_authority(data, identity)
51
+ end
52
+ end
53
+
54
+ # Submission validates evidence without granting terminal acceptance.
55
+ # The authenticated owner separately checks the exact worker attribution.
56
+ def verify_result!(data, attempt:, live_head:, repo_root:)
57
+ verify_receipt!(data, attempt: attempt, live_head: live_head, repo_root: repo_root)
58
+ end
59
+
60
+ def verify_receipt!(data, attempt:, live_head:, repo_root:)
39
61
  reject_unless(data.is_a?(Hash), "receipt must be a JSON object")
40
62
 
41
63
  if Models::ExecutionReceipt.forbidden_field?(data)
@@ -51,7 +73,7 @@ module Ace
51
73
 
52
74
  verify_binding(data, attempt)
53
75
  verify_producer(data)
54
- verify_authority(data, identity)
76
+ yield if block_given?
55
77
  verify_head(data, live_head)
56
78
  verify_artifacts(data, repo_root) if data["verdict"] == "succeeded"
57
79
  verify_checks(data)
@@ -63,18 +85,26 @@ module Ace
63
85
 
64
86
  receipt
65
87
  end
88
+ private :verify_receipt!
89
+
66
90
  # Recheck the source artifacts/checks of an already accepted receipt.
67
- # Historical reads (recorded at a past head) validate acceptance from
91
+ # Local historical reads (recorded at a past head) validate acceptance from
68
92
  # the append-only journal without re-hashing artifacts: the review
69
93
  # lifecycle legitimately archives and annotates finding files after
70
94
  # collection, and `.ace-local` working files are disposable, so their
71
95
  # current existence proves nothing about an accepted past receipt.
72
- # Current-head reads keep full artifact re-verification.
96
+ # Current-head reads keep full artifact re-verification. Protected
97
+ # readers always reverify imported bytes, including historical reads.
73
98
  def verify_accepted_evidence!(data, live_head:, repo_root:, historical: false)
74
99
  verify_head(data, live_head)
75
- verify_artifacts(data, repo_root) unless historical
100
+ artifacts = data["artifacts"]
101
+ reject_unless(artifacts.is_a?(Array), "accepted artifacts must be an array")
102
+ if (@artifact_reader || !historical) && (data["verdict"] == "succeeded" || !artifacts.empty?)
103
+ verify_artifacts(data, repo_root)
104
+ end
76
105
  verify_checks(data)
77
106
  verify_review(data)
107
+ verify_campaign(data, repo_root: repo_root, live_head: live_head) if @artifact_reader
78
108
  end
79
109
 
80
110
  # @param operation [String] Receipt operation
@@ -147,17 +177,7 @@ module Ace
147
177
  recorded = artifact["sha256"].to_s
148
178
  reject("artifact missing path or sha256") if path.empty? || recorded.empty?
149
179
 
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))
180
+ actual = Digest::SHA256.hexdigest(artifact_bytes(data, artifact, repo_root))
161
181
  reject("artifact digest mismatch for #{path}") unless actual == recorded
162
182
  end
163
183
  end
@@ -225,28 +245,44 @@ module Ace
225
245
  unless Array(data["artifacts"]).include?(reference)
226
246
  reject("campaign result must be included in verified receipt artifacts")
227
247
  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))
248
+ result = JSON.parse(artifact_bytes(data, reference, repo_root))
231
249
  reject_unless(result.is_a?(Hash), "campaign result must be an object")
232
- require "ace/review"
233
- current = Ace::Review::Organisms::CampaignManager.new(repo_root: repo_root).status(campaign["id"])
234
- unless result["campaign_id"] == campaign["id"] && result["accepted"] == true &&
235
- result["dry_run"] == false && current["accepted"] == true &&
236
- result["result_identity"] == current["result_identity"] &&
237
- result.dig("evidence", "current_head") == live_head && current.dig("evidence", "current_head") == live_head
238
- reject("campaign result is stale, incomplete, blocked or does not match live authority")
239
- end
240
- approval = current["rounds"].last["approval"]
241
- unless approval["producer"] == data.dig("producer", "actor") &&
242
- approval["reviewer"] == data.dig("review", "reviewer", "actor") &&
243
- approval["head"] == data["head"]
244
- reject("campaign producer/reviewer attribution does not match independently validated receipt")
250
+ if @campaign_verifier
251
+ @campaign_verifier.call(data, result, live_head)
252
+ return
245
253
  end
254
+ require "ace/review"
255
+ manager = Ace::Review::Organisms::CampaignManager.new(repo_root: repo_root)
256
+ current = manager.status(campaign["id"])
257
+ reject("campaign result ID differs from receipt") unless result["campaign_id"] == campaign["id"]
258
+ manager.with_verified_result!(result: result, subject: current.fetch("subject"),
259
+ contract_identity: current.fetch("contract_identity"), policy: current.fetch("effective_policy"),
260
+ head: live_head, base: current.fetch("evidence").fetch("current_base"),
261
+ producer: data.dig("producer", "actor"), reviewer: data.dig("review", "reviewer", "actor")) { true }
246
262
  rescue JSON::ParserError, ArgumentError, TypeError, KeyError => e
247
263
  reject("invalid campaign result: #{e.message}")
248
264
  end
249
265
 
266
+ # Protected composition injects the canonical imported-byte owner.
267
+ # Its failure never falls through to the disposable workspace copy.
268
+ def artifact_bytes(data, artifact, repo_root)
269
+ if @artifact_reader
270
+ bytes = @artifact_reader.call(data, artifact)
271
+ reject("canonical artifact reader did not return bytes") unless bytes.is_a?(String)
272
+ return bytes.b
273
+ end
274
+ path = artifact["path"].to_s
275
+ expanded = begin
276
+ File.expand_path(path, File.realpath(repo_root))
277
+ rescue Errno::ENOENT, Errno::EACCES
278
+ nil
279
+ end
280
+ reject("artifact file not found: #{path}") if expanded.nil? || !File.exist?(expanded)
281
+ resolved = safe_resolve(repo_root, path)
282
+ reject("artifact path escapes project root: #{path}") if resolved.nil?
283
+ File.read(resolved)
284
+ end
285
+
250
286
  # Resolve a project-relative artifact path to its real location,
251
287
  # rejecting traversal and symlink escapes (an in-repo symlink must
252
288
  # not smuggle in an external file); returns nil when the path leaves
@@ -11,6 +11,10 @@ module Ace
11
11
  # Handles creation of new step files and updating existing ones,
12
12
  # including appending reports and updating frontmatter.
13
13
  class StepWriter
14
+ PRIVATE_MAX_BYTES = 1024 * 1024
15
+ def initialize(private_files: false)
16
+ @private_files = private_files
17
+ end
14
18
  # Create a new step file
15
19
  #
16
20
  # @param steps_dir [String] Path to steps directory
@@ -101,6 +105,8 @@ module Ace
101
105
 
102
106
  # Extract number and name from filename for report file
103
107
  filename_info = Atoms::StepFileParser.parse_filename(File.basename(file_path))
108
+ encoded_report = report_bytes(filename_info[:number], filename_info[:name], report_content)
109
+ validate_private_bytes!(encoded_report)
104
110
 
105
111
  # Update frontmatter only (status + completed_at)
106
112
  new_frontmatter = parsed[:frontmatter].merge({
@@ -119,7 +125,7 @@ module Ace
119
125
  )
120
126
  report_path = File.join(reports_dir, report_filename)
121
127
 
122
- write_report(report_path, filename_info[:number], filename_info[:name], report_content)
128
+ write_report(report_path, filename_info[:number], filename_info[:name], report_content, encoded: encoded_report)
123
129
 
124
130
  file_path
125
131
  end
@@ -204,8 +210,15 @@ module Ace
204
210
  # Write content atomically using temp file + rename pattern.
205
211
  # Prevents partial writes if process crashes mid-write.
206
212
  def atomic_write(path, content)
213
+ validate_private_bytes!(content)
207
214
  temp_path = "#{path}.tmp.#{Process.pid}"
208
- File.write(temp_path, content)
215
+ if @private_files
216
+ File.open(temp_path, File::WRONLY | File::CREAT | File::EXCL | File::NOFOLLOW, 0600) do |file|
217
+ file.write(content); file.flush; file.fsync
218
+ end
219
+ else
220
+ File.write(temp_path, content)
221
+ end
209
222
  File.rename(temp_path, path)
210
223
  end
211
224
 
@@ -230,15 +243,24 @@ module Ace
230
243
  # @param number [String] Step number
231
244
  # @param name [String] Step name
232
245
  # @param content [String] Report content
233
- def write_report(report_path, number, name, content)
246
+ def write_report(report_path, number, name, content, encoded: nil)
247
+ atomic_write(report_path, encoded || report_bytes(number, name, content))
248
+ end
249
+
250
+ def report_bytes(number, name, content)
234
251
  frontmatter = {
235
252
  "step" => number,
236
253
  "name" => name,
237
254
  "completed_at" => Time.now.utc.iso8601
238
255
  }
239
256
  yaml = frontmatter.to_yaml
240
- report_content = "#{yaml}---\n\n#{content}\n"
241
- atomic_write(report_path, report_content)
257
+ "#{yaml}---\n\n#{content}\n"
258
+ end
259
+
260
+ def validate_private_bytes!(content)
261
+ if @private_files && (!content.is_a?(String) || !content.bytesize.between?(1, PRIVATE_MAX_BYTES))
262
+ raise ArgumentError, "private queue output exceeds captured text bound"
263
+ end
242
264
  end
243
265
  end
244
266
  end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+ require_relative "receipt_verifier"
3
+ require_relative "canonical_evidence"
4
+
5
+ module Ace
6
+ module Assign
7
+ module Molecules
8
+ # Pure reader over already accepted terminal owner evidence. A submitted
9
+ # result alone carries no terminal authority; local filesystem artifacts
10
+ # are never substituted for its imported protected bytes.
11
+ class TerminalScopeReceipt
12
+ BINDING_FIELDS = %w[mapping_id project_id assignment_id attempt_id result_id worker_uid worker_actor
13
+ launch_ticket process_binding head candidate_generation uploaded_receipt_sha256 original_receipt_digest].freeze
14
+ PAYLOAD_FIELDS = %w[version result_id binding uploaded_receipt_sha256 original_receipt_digest receipt_digest receipt artifacts].freeze
15
+
16
+ def self.verify!(events:, lineage:, journal:, commit:, mapping:)
17
+ raise AttemptErrors::EvidenceUnavailable, "terminal receipt chain differs" unless Models::EvidenceEvent.chain_valid?(events)
18
+ terminal = events.select { |event| event["type"] == "receipt_accepted" &&
19
+ %w[succeeded failed].include?(event.dig("payload", "receipt", "verdict")) }
20
+ raise AttemptErrors::EvidenceUnavailable, "no unique accepted terminal receipt" unless terminal.size == 1
21
+ accepted = terminal.first
22
+ receipt = accepted.fetch("payload").fetch("receipt")
23
+ before = events.take_while { |event| event.fetch("digest") != accepted.fetch("digest") }
24
+ submitted = before.select { |event| event["type"] == "result_submitted" &&
25
+ event.dig("payload", "receipt_digest") == receipt.fetch("digest") }
26
+ raise AttemptErrors::EvidenceUnavailable, "accepted terminal result provenance differs" unless submitted.size == 1
27
+ payload = submitted.first.fetch("payload")
28
+ binding = payload.fetch("binding")
29
+ reservation = before.find { |event| event["type"] == "authority_mutation" && event.dig("payload", "operation") == "reserve_attempt" }
30
+ origin = reservation.fetch("payload").fetch("data")
31
+ expected = {"mapping_id" => origin.fetch("mapping_id"), "project_id" => mapping.fetch("project_id"),
32
+ "assignment_id" => origin.fetch("assignment_id"), "attempt_id" => accepted.fetch("attempt_id"),
33
+ "worker_uid" => mapping.fetch("worker_uid"), "worker_actor" => mapping.fetch("worker_actor"),
34
+ "launch_ticket" => origin.fetch("launch_ticket"), "process_binding" => lineage.child_event.fetch("payload").fetch("original_process_binding")}
35
+ candidates = before.select { |event| event["type"] == "authority_mutation" &&
36
+ event.dig("payload", "operation") == "submit_candidate" &&
37
+ event.dig("payload", "data", "candidate_generation") == binding.fetch("candidate_generation") }
38
+ candidate = candidates.one? && candidates.first.dig("payload", "data")
39
+ unless payload.keys.sort == PAYLOAD_FIELDS.sort && binding.keys.sort == BINDING_FIELDS.sort &&
40
+ payload["version"] == 1 && expected.all? { |key, value| binding[key] == value } &&
41
+ candidate && candidate["head"] == binding["head"] && receipt["head"] == binding["head"] &&
42
+ payload["result_id"] == binding["result_id"] && payload["uploaded_receipt_sha256"] == binding["uploaded_receipt_sha256"] &&
43
+ payload["original_receipt_digest"] == binding["original_receipt_digest"] &&
44
+ Models::ExecutionReceipt.from_h(payload.fetch("receipt")).digest_payload == Models::ExecutionReceipt.from_h(receipt).digest_payload &&
45
+ (receipt.keys - Models::ExecutionReceipt.from_h(receipt).to_h.keys).empty? &&
46
+ payload["artifacts"] == receipt["artifacts"] &&
47
+ receipt["producer"] == {"actor" => mapping.fetch("worker_actor"), "role" => "worker", "runtime" => "herdr"} &&
48
+ journal.canonical_attempt_state(events) == receipt.fetch("verdict")
49
+ raise AttemptErrors::EvidenceUnavailable, "accepted terminal receipt selectors differ"
50
+ end
51
+ model = Models::ExecutionReceipt.from_h(receipt)
52
+ unless model.digest == Atoms::EvidenceDigest.digest(model.digest_payload) && model.digest == payload.fetch("receipt_digest")
53
+ raise AttemptErrors::EvidenceUnavailable, "accepted terminal receipt digest differs"
54
+ end
55
+ finishes = events.select { |event| event["type"] == "authority_mutation" && event.dig("payload", "operation") == "finish" }
56
+ unless finishes.empty?
57
+ transition = events[events.index(accepted) + 1]
58
+ finish = events[events.index(accepted) + 2]
59
+ expected_reply = binding.slice("attempt_id", "result_id", "head", "candidate_generation").merge(
60
+ "receipt_digest" => payload.fetch("receipt_digest"), "state" => receipt.fetch("verdict"))
61
+ unless finishes.one? && finishes.first == finish && transition && transition["type"] == "transition" &&
62
+ transition.fetch("payload") == {"from" => "running", "to" => receipt.fetch("verdict"), "reason" => "protected_result_accepted"} &&
63
+ finish.dig("payload", "data").except("generation") == expected_reply &&
64
+ finish.dig("payload", "data", "generation") == events.take(events.index(finish) + 1).count { |event| event["type"] == "authority_mutation" }
65
+ raise AttemptErrors::EvidenceUnavailable, "accepted finish reply or terminal transition differs"
66
+ end
67
+ end
68
+ canonical = CanonicalEvidence.new(journal: journal)
69
+ context = {kind: "result", project_id: binding.fetch("project_id"), assignment_id: binding.fetch("assignment_id"),
70
+ attempt_id: binding.fetch("attempt_id"), peer_uid: binding.fetch("worker_uid"), binding: binding,
71
+ request_id_or_event_id: binding.fetch("result_id"), generation: binding.fetch("candidate_generation")}
72
+ reader = ->(_data, artifact) { canonical.read({"ref" => artifact.fetch("path"), "sha256" => artifact.fetch("sha256")},
73
+ **context, commit: commit) }
74
+ Array(receipt["artifacts"]).each { |artifact| reader.call(receipt, artifact) }
75
+ intent = before.find { |event| event["type"] == "intent" }
76
+ attempt = journal.send(:build_attempt, binding.fetch("assignment_id"), binding.fetch("attempt_id"),
77
+ intent.fetch("payload"), before, "running")
78
+ verifier = ReceiptVerifier.new(artifact_reader: reader)
79
+ verifier.verify_result!(receipt, attempt: attempt, live_head: binding.fetch("head"), repo_root: journal.repo_root)
80
+ verifier.verify_accepted_evidence!(receipt, live_head: binding.fetch("head"), repo_root: journal.repo_root, historical: true)
81
+ accepted
82
+ rescue KeyError, TypeError, ArgumentError, NoMethodError, AttemptErrors::ReceiptRejected
83
+ raise AttemptErrors::EvidenceUnavailable, "authenticated terminal scope receipt unavailable"
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end
@@ -37,10 +37,10 @@ module Ace
37
37
  end
38
38
  end
39
39
 
40
- def initialize(cache_base: nil, skill_source_resolver: nil, step_catalog: nil)
41
- @assignment_manager = Molecules::AssignmentManager.new(cache_base: cache_base)
42
- @queue_scanner = Molecules::QueueScanner.new
43
- @step_writer = Molecules::StepWriter.new
40
+ def initialize(cache_base: nil, skill_source_resolver: nil, step_catalog: nil, selected_assignment: nil, queue_scanner: nil)
41
+ @assignment_manager = Molecules::AssignmentManager.new(cache_base: cache_base, selected_assignment: selected_assignment)
42
+ @queue_scanner = queue_scanner || Molecules::QueueScanner.new
43
+ @step_writer = Molecules::StepWriter.new(private_files: !selected_assignment.nil?)
44
44
  @skill_source_resolver = skill_source_resolver || Molecules::SkillAssignSourceResolver.new
45
45
  @step_catalog = nil
46
46
  @step_catalog_from_fixture = step_catalog
@@ -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