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
@@ -13,7 +13,7 @@ module Ace
13
13
  # Actor identity is derived from the execution boundary, never granted
14
14
  # by command flags or receipt claims. Two adapters ship:
15
15
  # - `local` — the interactive operator is the coordinator; identity
16
- # comes from the OS login boundary.
16
+ # comes from the kernel process account (equal real/effective UID).
17
17
  # - `service` — a trusted service executor supplies a structured
18
18
  # identity through a configured environment variable; the executor
19
19
  # owns OS/process enforcement.
@@ -25,14 +25,17 @@ module Ace
25
25
  TRUSTED_ROLES = %w[coordinator service].freeze
26
26
 
27
27
  # Resolved boundary identity.
28
- Identity = Struct.new(:actor, :role, :runtime, :adapter, keyword_init: true)
28
+ Identity = Struct.new(:actor, :role, :runtime, :adapter, :process_pid, :runtime_binding, keyword_init: true)
29
29
 
30
30
  # @param adapter [String, nil] "local" or "service" (default from config)
31
31
  # @param service_env [String, nil] Env var carrying the service identity (default from config)
32
- def initialize(adapter: nil, service_env: nil)
32
+ def initialize(adapter: nil, service_env: nil, runtime_resolver: Ace::Runtime, env: ENV, caller_pid: Process.ppid)
33
33
  section = Ace::Assign.config["attempt"] || {}
34
34
  @adapter = adapter || section["identity_adapter"] || "local"
35
35
  @service_env = service_env || section["service_identity_env"] || "ACE_ASSIGN_SERVICE_IDENTITY"
36
+ @runtime_resolver = runtime_resolver
37
+ @env = env
38
+ @caller_pid = caller_pid
36
39
  end
37
40
 
38
41
  # @return [Identity] Trusted boundary identity
@@ -55,19 +58,41 @@ module Ace
55
58
  private
56
59
 
57
60
  def from_local_boundary
58
- actor = Etc.getlogin || ENV["USER"] || ENV["LOGNAME"]
59
- if actor.nil? || actor.strip.empty?
60
- raise AttemptErrors::UnauthorizedIdentity, "No OS login identity available at the execution boundary"
61
+ uid, euid = Process.uid, Process.euid
62
+ unless uid == euid
63
+ raise AttemptErrors::UnauthorizedIdentity, "Local execution requires equal real and effective UIDs"
61
64
  end
62
65
 
66
+ account = local_account(euid)
67
+ verify_local_credentials!(uid, euid)
68
+ binding = native_process_binding
69
+ verify_local_credentials!(uid, euid)
63
70
  Identity.new(
64
- actor: actor.strip,
71
+ actor: account.name,
65
72
  role: "coordinator",
66
73
  runtime: "local:#{Socket.gethostname}",
67
- adapter: "local"
74
+ adapter: "local",
75
+ process_pid: binding&.dig("process_identity", "pid"),
76
+ runtime_binding: binding
68
77
  )
69
78
  end
70
79
 
80
+ def local_account(euid)
81
+ account = Etc.getpwuid(euid)
82
+ unless account && account.uid == euid && account.name.is_a?(String) && !account.name.strip.empty?
83
+ raise AttemptErrors::UnauthorizedIdentity, "No valid kernel process account for effective UID #{euid}"
84
+ end
85
+ account
86
+ rescue ArgumentError, SystemCallError
87
+ raise AttemptErrors::UnauthorizedIdentity, "Cannot resolve kernel process account for effective UID #{euid}"
88
+ end
89
+
90
+ def verify_local_credentials!(uid, euid)
91
+ unless Process.uid == uid && Process.euid == euid
92
+ raise AttemptErrors::UnauthorizedIdentity, "Local execution credentials changed during identity resolution"
93
+ end
94
+ end
95
+
71
96
  def from_service_env
72
97
  raw = ENV[@service_env].to_s.strip
73
98
  if raw.empty?
@@ -89,7 +114,22 @@ module Ace
89
114
  "Service identity must supply actor, runtime, and a role in #{ROLES.join('/')}"
90
115
  end
91
116
 
92
- Identity.new(actor: actor, role: role, runtime: runtime, adapter: "service")
117
+ pid = data["process_pid"]
118
+ if pid && (!pid.is_a?(Integer) || !pid.positive?)
119
+ raise AttemptErrors::UnauthorizedIdentity, "Service process_pid must be a positive integer"
120
+ end
121
+ Identity.new(actor: actor, role: role, runtime: runtime, adapter: "service", process_pid: pid)
122
+ end
123
+
124
+ def native_process_binding
125
+ adapter = Ace::Runtime::Molecules::RuntimeSelector.new(
126
+ config: Ace::Runtime.config, registry: @runtime_resolver, env: @env).resolve
127
+ context = adapter.context
128
+ return nil unless context[:in_runtime] && context[:pane]
129
+
130
+ adapter.process_binding(pane: context[:pane], caller_pid: @caller_pid)
131
+ rescue Ace::Runtime::Error
132
+ nil
93
133
  end
94
134
  end
95
135
  end
@@ -0,0 +1,345 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../models/evidence_event"
4
+ require "json"
5
+
6
+ module Ace
7
+ module Assign
8
+ module Molecules
9
+ # Canonical lineage only. Live owner/boundary observation and lifecycle
10
+ # exclusion are mandatory before recording or consuming a fresh proof.
11
+ # This reader never promotes cached population into a proof event.
12
+ class ExecutionScopeLineage
13
+ BINDING_FIELDS = %w[project_id assignment_id attempt_id mapping_id slot_id reservation_generation
14
+ scope_generation deployment_digest boot_id slice_invocation_id cgroup_identity resource_mount_namespace_identity resource_identities network_installation_selection network_namespace_identity boot_baseline_selection].freeze
15
+ PROOF_FIELDS = %w[scope_generation scope_binding_event_id seal_event_id boot_id slice_invocation_id
16
+ cgroup_identity populated].freeze
17
+ PROCESS_FIELDS = %w[pid uid gid groups started_at host parent_pid].freeze
18
+ RESOURCE_FIELDS = %w[host_path view_path mount_id filesystem_type device inode uid gid].freeze
19
+ CGROUP_FIELDS = %w[path mount_id filesystem_type device inode].freeze
20
+ ID = /\A[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}\z/
21
+ DIGEST = /\A[0-9a-f]{64}\z/
22
+ INVOCATION = /\A[0-9a-f]{32}\z/
23
+ BOOT = /\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/
24
+
25
+ def self.validate_process_identity!(value, boot_id:)
26
+ unless boot_id.is_a?(String) && BOOT.match?(boot_id) && value.is_a?(Hash) && value.keys.sort == PROCESS_FIELDS.sort
27
+ raise AttemptErrors::EvidenceUnavailable, "scope process identity fields differ"
28
+ end
29
+ groups = value["groups"]
30
+ unless %w[pid uid gid parent_pid].all? { |key| value[key].is_a?(Integer) && value[key].positive? } &&
31
+ groups.is_a?(Array) && groups.all? { |id| id.is_a?(Integer) && id.positive? } && groups == groups.sort.uniq &&
32
+ value["started_at"].is_a?(String) && value["started_at"].match?(/\Alinux:#{Regexp.escape(boot_id)}:[0-9]+\z/) &&
33
+ value["host"].is_a?(String) && !value["host"].empty?
34
+ raise AttemptErrors::EvidenceUnavailable, "scope process birth/credentials are malformed"
35
+ end
36
+ value
37
+ end
38
+
39
+ def self.validate_network_selection!(selection)
40
+ unless selection.is_a?(Hash) && selection.keys.sort == %w[installer_artifact policy_export profile report]
41
+ raise AttemptErrors::EvidenceUnavailable, "network installation selection is not closed"
42
+ end
43
+ selection.each_value do |ref|
44
+ unless ref.is_a?(Hash) && ref.keys.sort == %w[bytes path sha256] && ref["path"].is_a?(String) &&
45
+ ref["path"].bytesize.between?(1, 4096) && ref["path"].start_with?("/") && !ref["path"].include?("\0") &&
46
+ File.expand_path(ref["path"]) == ref["path"] && ref["sha256"].is_a?(String) && DIGEST.match?(ref["sha256"]) &&
47
+ ref["bytes"].is_a?(Integer) && ref["bytes"].between?(1, 1_048_576)
48
+ raise AttemptErrors::EvidenceUnavailable, "network installation artifact reference differs"
49
+ end
50
+ end
51
+ selection
52
+ end
53
+
54
+ def self.validate_boot_baseline_selection!(ref)
55
+ unless ref.is_a?(Hash) && ref.keys.sort == %w[bytes path sha256] && ref["path"].is_a?(String) &&
56
+ ref["path"].bytesize.between?(1, 4096) && ref["path"].start_with?("/") && !ref["path"].include?("\0") &&
57
+ File.expand_path(ref["path"]) == ref["path"] && ref["sha256"].is_a?(String) && DIGEST.match?(ref["sha256"]) &&
58
+ ref["bytes"].is_a?(Integer) && ref["bytes"].between?(1, 16_384)
59
+ raise AttemptErrors::EvidenceUnavailable, "boot baseline artifact reference differs"
60
+ end
61
+ ref
62
+ end
63
+
64
+ attr_reader :binding_event, :native_event, :child_event, :seal_event, :proof_event, :admission_event
65
+
66
+ def initialize(events:, project_id:, assignment_id:, attempt_id:, mapping_id:)
67
+ events = freeze_tree(JSON.parse(JSON.generate(events)))
68
+ unless events.is_a?(Array) && Models::EvidenceEvent.chain_valid?(events) &&
69
+ events.all? { |event| event["attempt_id"] == attempt_id }
70
+ unavailable!("scope requires its intact exact attempt chain")
71
+ end
72
+ @expected = {"project_id" => project_id, "assignment_id" => assignment_id,
73
+ "attempt_id" => attempt_id, "mapping_id" => mapping_id}
74
+ generation = 0
75
+ events.each do |event|
76
+ generation += 1 if event["type"] == "authority_mutation"
77
+ accept_reservation!(event, generation) if event["type"] == "authority_mutation" &&
78
+ event.dig("payload", "operation") == "reserve_attempt"
79
+ accept_admission!(event, generation) if event["type"] == "authority_mutation" &&
80
+ event.dig("payload", "operation") == "scope_service_admission"
81
+ case event["type"]
82
+ when "scope_bound" then accept_binding!(event, generation + 1)
83
+ when "scope_native_bound" then accept_native!(event)
84
+ when "scope_child_bound" then accept_child!(event)
85
+ when "scope_sealed" then accept_seal!(event)
86
+ when "scope_closed_no_writers" then accept_proof!(event)
87
+ end
88
+ end
89
+ rescue KeyError, NoMethodError, TypeError
90
+ unavailable!("canonical scope lineage is incomplete")
91
+ end
92
+
93
+ def binding
94
+ binding_event&.fetch("payload")
95
+ end
96
+
97
+ def sealed?
98
+ !seal_event.nil?
99
+ end
100
+
101
+ def proof_id
102
+ proof_event&.fetch("digest")
103
+ end
104
+
105
+ def require_open!
106
+ unavailable!("canonical scope binding is missing") unless binding
107
+ raise AttemptErrors::Conflict, "execution generation is sealed" if sealed?
108
+ true
109
+ end
110
+
111
+ def require_launch_bound!
112
+ require_open!
113
+ unavailable!("original native and child stages are missing") unless native_event && child_event
114
+ child_event.fetch("payload").fetch("original_process_binding")
115
+ end
116
+
117
+ def require_positive!(scope_generation:, scope_binding_event_id:, seal_event_id:, proof_id:)
118
+ unless proof_event && binding.fetch("scope_generation") == scope_generation &&
119
+ binding_event.fetch("digest") == scope_binding_event_id && seal_event.fetch("digest") == seal_event_id &&
120
+ proof_event.fetch("digest") == proof_id
121
+ unavailable!("exact canonical scope proof is unavailable")
122
+ end
123
+ proof_event.fetch("payload")
124
+ end
125
+
126
+ private
127
+
128
+ def accept_reservation!(event, generation)
129
+ unavailable!("canonical reservation is repeated") if @reservation
130
+ value = event.dig("payload", "data")
131
+ unless value.is_a?(Hash) && value.slice(*@expected.keys) == @expected &&
132
+ value["reservation_generation"] == generation && value["generation"] == generation &&
133
+ value["launch_ticket"].is_a?(String) && ID.match?(value["launch_ticket"])
134
+ unavailable!("canonical reservation binding differs")
135
+ end
136
+ @reservation = value
137
+ end
138
+
139
+ def freeze_tree(value)
140
+ case value
141
+ when Hash then value.each { |key, item| key.freeze; freeze_tree(item) }
142
+ when Array then value.each { |item| freeze_tree(item) }
143
+ end
144
+ value.freeze
145
+ end
146
+
147
+ def accept_binding!(event, generation)
148
+ unavailable!("scope binding cannot be replaced") if binding_event
149
+ value = event.fetch("payload")
150
+ exact_fields!(value, BINDING_FIELDS)
151
+ unless value.slice(*@expected.keys) == @expected &&
152
+ value["slot_id"].is_a?(String) && ID.match?(value["slot_id"]) &&
153
+ value["scope_generation"] == generation && @reservation &&
154
+ value["reservation_generation"] == @reservation["reservation_generation"] &&
155
+ digest?(value["deployment_digest"]) && boot?(value["boot_id"]) &&
156
+ invocation?(value["slice_invocation_id"])
157
+ unavailable!("scope generation binding differs")
158
+ end
159
+ cgroup!(value.fetch("cgroup_identity"))
160
+ namespace!(value.fetch("resource_mount_namespace_identity"))
161
+ namespace!(value.fetch("network_namespace_identity"))
162
+ self.class.validate_network_selection!(value.fetch("network_installation_selection"))
163
+ self.class.validate_boot_baseline_selection!(value.fetch("boot_baseline_selection"))
164
+ resources = value.fetch("resource_identities")
165
+ resources!(resources)
166
+ @binding_event = event
167
+ end
168
+
169
+ def stage_reference!(value)
170
+ unavailable!("scope stage requires its original open parent") unless binding && !sealed?
171
+ unless value["scope_generation"] == binding.fetch("scope_generation") &&
172
+ value["scope_binding_event_id"] == binding_event.fetch("digest")
173
+ unavailable!("scope stage references another parent")
174
+ end
175
+ end
176
+
177
+ def accept_admission!(event, generation)
178
+ unavailable!("native admission cannot replace an existing stage") if admission_event || native_event
179
+ value = event.fetch("payload").fetch("data")
180
+ stage_reference!(value)
181
+ unless value.slice(*@expected.keys) == @expected && value["generation"] == generation &&
182
+ value["native_admission"] == "issued_uncertain"
183
+ unavailable!("native admission does not name the exact current reserved owner")
184
+ end
185
+ installation = value.fetch("network_installation")
186
+ exact_fields!(installation, %w[report_id boot_id slot_id namespace_path namespace_identity profile_sha256 policy_export_sha256 report_sha256 installer_artifact_sha256])
187
+ namespace!(installation.fetch("namespace_identity"))
188
+ selection = binding.fetch("network_installation_selection")
189
+ unless boot?(installation["report_id"]) && installation["boot_id"] == binding["boot_id"] &&
190
+ installation["slot_id"] == binding["slot_id"] && installation["namespace_identity"] == binding["network_namespace_identity"] &&
191
+ installation["namespace_path"].is_a?(String) && installation["namespace_path"].start_with?("/") &&
192
+ !installation["namespace_path"].include?("\0") && File.expand_path(installation["namespace_path"]) == installation["namespace_path"] &&
193
+ %w[profile policy_export report installer_artifact].all? { |key| installation["#{key}_sha256"] == selection.fetch(key).fetch("sha256") }
194
+ unavailable!("native admission installation differs from original parent selection")
195
+ end
196
+ @admission_event = event
197
+ end
198
+
199
+ def accept_native!(event)
200
+ unavailable!("native binding cannot be replaced") if native_event
201
+ value = event.fetch("payload")
202
+ exact_fields!(value, %w[scope_generation scope_binding_event_id service_invocation_id server_identity socket_identity workspace_id
203
+ mount_namespace_identity resource_observer_identity resource_identities network_namespace_identity network_admission_event_id])
204
+ stage_reference!(value)
205
+ namespace!(value.fetch("network_namespace_identity"))
206
+ unless admission_event && value["network_admission_event_id"] == admission_event.fetch("digest") &&
207
+ value["network_namespace_identity"] == binding.fetch("network_namespace_identity")
208
+ unavailable!("native server does not join the original admitted network installation")
209
+ end
210
+ process!(value.fetch("server_identity"), binding.fetch("boot_id"))
211
+ socket = value.fetch("socket_identity")
212
+ unless invocation?(value["service_invocation_id"]) && value["workspace_id"].is_a?(String) &&
213
+ value["workspace_id"].match?(/\Aw[1-9][0-9]{0,8}\z/) && socket.is_a?(Array) && socket.size == 3 &&
214
+ socket.all? { |part| part.is_a?(Integer) && part >= 0 } && socket.last == value.dig("server_identity", "uid")
215
+ unavailable!("scope native incarnation differs")
216
+ end
217
+ namespace!(value.fetch("mount_namespace_identity"))
218
+ observer = value.fetch("resource_observer_identity")
219
+ process!(observer, binding.fetch("boot_id"))
220
+ server = value.fetch("server_identity")
221
+ unless observer.values_at("uid", "gid", "groups", "host") == server.values_at("uid", "gid", "groups", "host") &&
222
+ observer["pid"] != server["pid"]
223
+ unavailable!("native resource observer is not the distinct fixed same-user hook")
224
+ end
225
+ resources!(value.fetch("resource_identities"))
226
+ parent_resources = binding.fetch("resource_identities").to_h { |resource| [resource.values_at("host_path", "view_path"), resource] }
227
+ value.fetch("resource_identities").each do |resource|
228
+ original = parent_resources[resource.values_at("host_path", "view_path")]
229
+ if original && original.slice("device", "inode", "filesystem_type", "uid", "gid") != resource.slice("device", "inode", "filesystem_type", "uid", "gid")
230
+ unavailable!("native view substitutes a parent-pinned backing object")
231
+ end
232
+ end
233
+ @native_event = event
234
+ end
235
+
236
+ def accept_child!(event)
237
+ unavailable!("child binding requires its single native predecessor") unless native_event && !child_event
238
+ value = event.fetch("payload")
239
+ exact_fields!(value, %w[scope_generation scope_binding_event_id native_binding_event_id original_process_binding])
240
+ stage_reference!(value)
241
+ unless value["native_binding_event_id"] == native_event.fetch("digest")
242
+ unavailable!("child references another native incarnation")
243
+ end
244
+ native = native_event.fetch("payload")
245
+ original = value.fetch("original_process_binding")
246
+ exact_fields!(original, %w[runtime session pane terminal_id process_identity shell_identity native_origin])
247
+ process!(original.fetch("process_identity"), binding.fetch("boot_id"))
248
+ socket = native.fetch("socket_identity")
249
+ server = native.fetch("server_identity")
250
+ child = original.fetch("process_identity")
251
+ origin = original.fetch("native_origin")
252
+ exact_fields!(origin, %w[workspace tab pane server_identity socket_identity command cwd])
253
+ command = origin.fetch("command")
254
+ unless child["parent_pid"] == server["pid"] &&
255
+ child.values_at("uid", "gid", "groups") == server.values_at("uid", "gid", "groups") &&
256
+ original["runtime"] == "herdr" && original["shell_identity"] == child &&
257
+ original["session"] == native["workspace_id"] && origin["workspace"] == native["workspace_id"] &&
258
+ origin["server_identity"] == server && origin["socket_identity"] == socket &&
259
+ origin["pane"] == original["pane"] && %w[pane terminal_id].all? { |key| original[key].is_a?(String) && !original[key].empty? } &&
260
+ origin["tab"].is_a?(String) && !origin["tab"].empty? && path?(origin["cwd"]) &&
261
+ command.is_a?(Array) && command.size == 3 && path?(command.first) && command[1] == binding["mapping_id"] &&
262
+ command.last == @reservation.fetch("launch_ticket")
263
+ unavailable!("scope original-child lineage differs")
264
+ end
265
+ @child_event = event
266
+ end
267
+
268
+ def accept_seal!(event)
269
+ unavailable!("scope seal has no immutable binding or is repeated") unless binding && !seal_event
270
+ value = event.fetch("payload")
271
+ exact_fields!(value, %w[scope_generation scope_binding_event_id])
272
+ unless value == {"scope_generation" => binding.fetch("scope_generation"),
273
+ "scope_binding_event_id" => binding_event.fetch("digest")}
274
+ unavailable!("scope seal references another incarnation")
275
+ end
276
+ @seal_event = event
277
+ end
278
+
279
+ def accept_proof!(event)
280
+ unavailable!("scope proof requires its original seal and single observation") unless sealed? && !proof_event
281
+ value = event.fetch("payload")
282
+ exact_fields!(value, PROOF_FIELDS)
283
+ expected = binding.slice("scope_generation", "boot_id", "slice_invocation_id", "cgroup_identity")
284
+ expected.merge!("scope_binding_event_id" => binding_event.fetch("digest"),
285
+ "seal_event_id" => seal_event.fetch("digest"), "populated" => 0)
286
+ unavailable!("scope proof is not exact sealed empty population") unless value == expected
287
+ @proof_event = event
288
+ end
289
+
290
+ def namespace!(value)
291
+ exact_fields!(value, %w[device inode])
292
+ unless value["device"].is_a?(Integer) && value["device"] >= 0 && positive_integer?(value["inode"])
293
+ unavailable!("scope mount namespace identity is malformed")
294
+ end
295
+ end
296
+
297
+ def resources!(resources)
298
+ unless resources.is_a?(Array) && resources.size <= 64 &&
299
+ resources.all? { |resource| resource.is_a?(Hash) } &&
300
+ resources.map { |resource| resource.values_at("host_path", "view_path") }.uniq.size == resources.size &&
301
+ resources.map { |resource| resource["view_path"] }.uniq.size == resources.size
302
+ unavailable!("scope resource observations are duplicate or oversized")
303
+ end
304
+ resources.each do |resource|
305
+ exact_fields!(resource, RESOURCE_FIELDS)
306
+ unless path?(resource["host_path"]) && path?(resource["view_path"]) && positive_integer?(resource["mount_id"]) &&
307
+ %w[device inode uid gid].all? { |key| resource[key].is_a?(Integer) && resource[key] >= 0 } &&
308
+ resource["filesystem_type"].is_a?(String) && resource["filesystem_type"].bytesize.between?(1, 64) &&
309
+ resource["filesystem_type"].ascii_only? && !resource["filesystem_type"].match?(/[\s\0]/)
310
+ unavailable!("scope resource identity is malformed")
311
+ end
312
+ end
313
+ end
314
+
315
+ def cgroup!(value)
316
+ exact_fields!(value, CGROUP_FIELDS)
317
+ unless path?(value["path"]) && value["path"].start_with?("/sys/fs/cgroup/") &&
318
+ value["filesystem_type"] == "cgroup2" &&
319
+ %w[mount_id device inode].all? { |key| value[key].is_a?(Integer) && value[key] >= 0 }
320
+ unavailable!("scope parent object identity is malformed")
321
+ end
322
+ end
323
+
324
+ def process!(value, boot_id)
325
+ self.class.validate_process_identity!(value, boot_id: boot_id)
326
+ end
327
+
328
+ def exact_fields!(value, keys)
329
+ unavailable!("scope event schema differs") unless value.is_a?(Hash) && value.keys.sort == keys.sort
330
+ end
331
+
332
+ def path?(value)
333
+ value.is_a?(String) && value.bytesize.between?(2, 4096) && !value.include?("\0") &&
334
+ value.start_with?("/") && File.expand_path(value) == value
335
+ end
336
+
337
+ def positive_integer?(value) = value.is_a?(Integer) && value.positive?
338
+ def digest?(value) = value.is_a?(String) && DIGEST.match?(value)
339
+ def boot?(value) = value.is_a?(String) && BOOT.match?(value)
340
+ def invocation?(value) = value.is_a?(String) && INVOCATION.match?(value)
341
+ def unavailable!(message) = raise(AttemptErrors::EvidenceUnavailable, message)
342
+ end
343
+ end
344
+ end
345
+ end
@@ -85,14 +85,22 @@ module Ace
85
85
  end
86
86
 
87
87
  def launch_provider_session(assignment_id:, fork_root:, provider:, cli_args: nil, timeout: nil, cache_dir: nil,
88
- last_message_file: nil, session_meta_file: nil)
88
+ last_message_file: nil, session_meta_file: nil, prepared_input: nil)
89
89
  ensure_not_same_scoped_refork!(assignment_id: assignment_id, fork_root: fork_root)
90
90
  resolved_provider = provider || config.dig("execution", "provider") || DEFAULT_PROVIDER
91
91
  resolved_timeout = timeout || config.dig("execution", "timeout") || DEFAULT_TIMEOUT
92
92
  scoped_assignment = "#{assignment_id}@#{fork_root}"
93
- prompt = "/as-assign-drive #{scoped_assignment}"
93
+ if prepared_input && prepared_input.descriptor.values_at("assignment_id", "scope") != [assignment_id, fork_root]
94
+ raise Error, "Prepared provider scope differs"
95
+ end
96
+ prompt = prepared_input ? prepared_input.drive_prompt : "/as-assign-drive #{scoped_assignment}"
94
97
  last_msg_file = last_message_file || build_last_message_file(cache_dir, fork_root)
95
98
  scope_env = self.class.fork_scope_env(assignment_id: assignment_id, fork_root: fork_root)
99
+ if prepared_input
100
+ scope_env = scope_env.merge("ACE_ASSIGN_LAUNCH_MAPPING" => prepared_input.descriptor.fetch("mapping_id"),
101
+ "ACE_ASSIGN_ASSIGNMENT_ID" => assignment_id, "ACE_ASSIGN_ATTEMPT_ID" => prepared_input.descriptor.fetch("attempt_id"),
102
+ "ACE_ASSIGN_TASK_CONTEXT_ENTRY" => JSON.generate(prepared_input.descriptor.fetch("task_context_entry").fetch("manifest")))
103
+ end
96
104
 
97
105
  result = query_interface.query(
98
106
  resolved_provider,
@@ -102,7 +110,8 @@ module Ace
102
110
  timeout: resolved_timeout,
103
111
  fallback: false,
104
112
  last_message_file: last_msg_file,
105
- subprocess_env: scope_env
113
+ subprocess_env: scope_env,
114
+ working_dir: prepared_input&.working_directory
106
115
  )
107
116
 
108
117
  if last_msg_file && result[:text] && !result[:text].strip.empty?
@@ -110,7 +119,8 @@ module Ace
110
119
  File.write(last_msg_file, result[:text]) if existing.empty?
111
120
  end
112
121
 
113
- write_session_metadata(last_msg_file, result, prompt: prompt, session_meta_file: session_meta_file)
122
+ write_session_metadata(last_msg_file, result, prompt: prompt, session_meta_file: session_meta_file,
123
+ discover_session: prepared_input.nil?)
114
124
 
115
125
  result
116
126
  rescue Ace::LLM::Error => e
@@ -220,12 +230,12 @@ module Ace
220
230
  File.write(session_meta_file, meta.to_yaml) unless meta.empty?
221
231
  end
222
232
 
223
- def write_session_metadata(last_msg_file, result, prompt:, session_meta_file: nil)
233
+ def write_session_metadata(last_msg_file, result, prompt:, session_meta_file: nil, discover_session: true)
224
234
  return unless last_msg_file
225
235
 
226
236
  session_id = result.dig(:metadata, :session_id)
227
237
 
228
- if session_id.nil? || session_id.to_s.strip.empty?
238
+ if discover_session && (session_id.nil? || session_id.to_s.strip.empty?)
229
239
  detected = detect_provider_session(result[:provider], prompt)
230
240
  session_id = detected&.dig(:session_id)
231
241
  end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+ require_relative "execution_scope_lineage"
3
+
4
+ module Ace
5
+ module Assign
6
+ module Molecules
7
+ module JournalInputInhibition
8
+ INPUT_INHIBITION_FIELDS = %w[assignment_id attempt_id mapping_id original_binding_digest project_id seal_event_id].freeze
9
+
10
+ def observe_input_inhibition(selection:, evidence:, &authentication)
11
+ raise ArgumentError, "Original inhibition authentication is required" unless authentication
12
+ input = selection.merge("guarded_evidence" => evidence)
13
+ digest = Digest::SHA256.hexdigest(JSON.generate(canonical_prompt_value(input)))
14
+ mutate(assignment_id: selection.fetch("assignment_id"), attempt_id: selection.fetch("attempt_id"),
15
+ mutation_id: "input-inhibit.#{selection.fetch('seal_event_id')}", operation: "input_inhibition_observation",
16
+ parameters_digest: digest, expected_generation: nil, generation_mode: :input_inhibition,
17
+ input_inhibition: input, with_replay: true) do |events, commit, generation|
18
+ original = authentication.call(events, commit, generation)
19
+ unless original.is_a?(Hash) && original.keys.sort == %w[binding_digest origin] && original["binding_digest"] == selection.fetch("original_binding_digest")
20
+ raise AttemptErrors::EvidenceUnavailable, "Original input inhibition record differs"
21
+ end
22
+ verify_input_drained_evidence!(evidence, original.fetch("origin"))
23
+ {events: [{type: "input_inhibited", payload: selection.merge("guarded_evidence" => evidence)}], blobs: {},
24
+ data: selection.slice("original_binding_digest", "seal_event_id")}
25
+ end
26
+ end
27
+
28
+ def verify_input_drained_evidence!(evidence, original)
29
+ unless original.is_a?(Hash) && evidence.is_a?(Hash) && evidence.keys.sort == %w[input_state origin outcome pending_input] &&
30
+ evidence["outcome"] == "inhibited" && evidence["input_state"] == "inhibited" &&
31
+ evidence["pending_input"].is_a?(Integer) && evidence["pending_input"].zero?
32
+ raise AttemptErrors::EvidenceUnavailable, "Native input drainage is unconfirmed"
33
+ end
34
+ received = Ace::Herdr::Molecules::GuardedNativeOrigin.verify!(evidence.fetch("origin"),
35
+ terminal_id: original.fetch("terminal_id"), child: original.fetch("child"))
36
+ raise AttemptErrors::EvidenceUnavailable, "Native input drainage origin differs" unless received == original
37
+ true
38
+ rescue Ace::Runtime::RuntimeUnavailableError, KeyError, TypeError
39
+ raise AttemptErrors::EvidenceUnavailable, "Native input drainage is unverifiable"
40
+ end
41
+
42
+ private
43
+
44
+ def verify_input_inhibition!(input, mutation_id:, digest:, assignment_id:, attempt_id:, commit:)
45
+ unless input.is_a?(Hash) && input.keys.sort == (INPUT_INHIBITION_FIELDS + ["guarded_evidence"]).sort &&
46
+ INPUT_INHIBITION_FIELDS.all? { |key| input[key].is_a?(String) } &&
47
+ %w[original_binding_digest seal_event_id].all? { |key| input.fetch(key).match?(/\A[0-9a-f]{64}\z/) } &&
48
+ input["assignment_id"] == assignment_id && input["attempt_id"] == attempt_id &&
49
+ mutation_id == "input-inhibit.#{input.fetch('seal_event_id')}" &&
50
+ digest == Digest::SHA256.hexdigest(JSON.generate(canonical_prompt_value(input)))
51
+ raise ArgumentError, "Invalid fixed input inhibition selector"
52
+ end
53
+ events = read_events(assignment_id, commit: commit).select { |event| event["attempt_id"] == attempt_id }
54
+ authority_generation(events)
55
+ record = events.find { |event| event["digest"] == input.fetch("original_binding_digest") && event["type"] == "authority_mutation" &&
56
+ event.dig("payload", "operation") == "record_launch" }
57
+ data = record&.dig("payload", "data")
58
+ seal = events.find { |event| event["digest"] == input.fetch("seal_event_id") && event["type"] == "scope_sealed" }
59
+ accepted_seal = seal && events.find { |event| event["type"] == "authority_mutation" && event["previous_digest"] == seal.fetch("digest") &&
60
+ %w[close_execution_scope stop_attempt].include?(event.dig("payload", "operation")) }
61
+ lineage = ExecutionScopeLineage.new(events: events, project_id: input.fetch("project_id"), assignment_id: assignment_id,
62
+ attempt_id: attempt_id, mapping_id: input.fetch("mapping_id"))
63
+ unless data.is_a?(Hash) && %w[assignment_id attempt_id mapping_id].all? { |key| data[key] == input.fetch(key) } &&
64
+ seal && accepted_seal && lineage.seal_event == seal && events.index(record) < events.index(seal) &&
65
+ events.drop(events.index(seal) + 1).none? { |event| event["type"] == "prompt_issued" } &&
66
+ events.none? { |event| event["type"] == "authority_mutation" && event.dig("payload", "operation") == "scope_reservation_release" }
67
+ raise AttemptErrors::EvidenceUnavailable, "Input inhibition does not join accepted original seal"
68
+ end
69
+ origin = Ace::Herdr::Molecules::GuardedNativeOrigin.verify!(data.fetch("guarded_origin"),
70
+ terminal_id: data.fetch("process_binding").fetch("terminal_id"), child: data.fetch("process_binding").fetch("process_identity"))
71
+ verify_input_drained_evidence!(input.fetch("guarded_evidence"), origin)
72
+ rescue KeyError, TypeError, Ace::Runtime::RuntimeUnavailableError
73
+ raise AttemptErrors::EvidenceUnavailable, "Input inhibition canonical selection is malformed"
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end