ace-lab 0.3.1 → 0.5.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 (31) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +25 -0
  3. data/docs/usage.md +10 -0
  4. data/lib/ace/lab/atoms/protected_publication_input.rb +50 -0
  5. data/lib/ace/lab/atoms/protected_workspace_prune_input.rb +114 -0
  6. data/lib/ace/lab/atoms/protected_workspace_prune_preview.rb +86 -0
  7. data/lib/ace/lab/atoms/service_input.rb +20 -8
  8. data/lib/ace/lab/authorization_path.rb +20 -0
  9. data/lib/ace/lab/cli/commands/service.rb +45 -0
  10. data/lib/ace/lab/errors.rb +11 -0
  11. data/lib/ace/lab/molecules/grant_resolver.rb +5 -1
  12. data/lib/ace/lab/molecules/protected_cleanup_owner_admission.rb +163 -0
  13. data/lib/ace/lab/molecules/protected_cleanup_owner_client.rb +165 -0
  14. data/lib/ace/lab/molecules/protected_cleanup_owner_identity.rb +239 -0
  15. data/lib/ace/lab/molecules/protected_publication_publisher.rb +110 -0
  16. data/lib/ace/lab/molecules/protected_service_handler.rb +43 -0
  17. data/lib/ace/lab/molecules/protected_service_policy.rb +156 -0
  18. data/lib/ace/lab/molecules/service_policy.rb +36 -1
  19. data/lib/ace/lab/organisms/authority_composition.rb +87 -0
  20. data/lib/ace/lab/organisms/protected_cleanup_owner.rb +361 -0
  21. data/lib/ace/lab/organisms/protected_service_client.rb +129 -0
  22. data/lib/ace/lab/organisms/protected_service_listener.rb +181 -0
  23. data/lib/ace/lab/organisms/protected_service_publication.rb +268 -0
  24. data/lib/ace/lab/organisms/protected_service_receiver.rb +267 -0
  25. data/lib/ace/lab/organisms/protected_service_recovery.rb +185 -0
  26. data/lib/ace/lab/organisms/protected_service_request.rb +128 -0
  27. data/lib/ace/lab/organisms/protected_service_worker.rb +89 -0
  28. data/lib/ace/lab/organisms/service_request_service.rb +4 -2
  29. data/lib/ace/lab/version.rb +1 -1
  30. data/lib/ace/lab.rb +2 -22
  31. metadata +52 -4
@@ -0,0 +1,185 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Lab
5
+ module Organisms
6
+ class ProtectedServiceReceiver
7
+ RECOVERY_BINDING = %w[assignment_id attempt_id candidate_generation head request_id].freeze
8
+ RECOVERY_EXECUTION = %w[mapping_id candidate_generation head claim_binding dispatch_phase].freeze
9
+ CLEANUP_RECOVERY_EXECUTION = (RECOVERY_EXECUTION + %w[operation_owner_binding executor_process_binding
10
+ request_event_digest dispatch_event_digest]).freeze
11
+ RECOVERY_CHALLENGE = %w[request_id input_digest claim_binding no_effect_challenge challenge_generation
12
+ failure_event_digest failure_generation challenge_event_digest].freeze
13
+
14
+ def recover_no_effect(binding:, input_bytes:, mutation_id:, expected_generation:)
15
+ request_id = nil
16
+ recovery_binding!(binding, input_bytes, mutation_id, expected_generation)
17
+ params = immutable(binding)
18
+ input_bytes = input_bytes.dup.freeze
19
+ mutation_id = mutation_id.dup.freeze
20
+ recovery_binding!(params, input_bytes, mutation_id, expected_generation)
21
+ request_id = params.fetch("request_id")
22
+ recovery_executor!
23
+ status, context, input = recovery_status!(params, input_bytes)
24
+ return projection(status) if %w[succeeded failed-settled].include?(status.fetch("state"))
25
+ unless context["challenge"]
26
+ claim_params = params.merge("expected_generation" => expected_generation)
27
+ begin
28
+ @client.call("claim_service_settlement", claim_params, mutation_id: mutation_id)
29
+ rescue Ace::Runtime::RuntimeUnavailableError
30
+ # Recover a possibly accepted challenge through current status,
31
+ # never refreshing the original generation or silently claiming.
32
+ end
33
+ status, context, input = recovery_status!(params, input_bytes)
34
+ return projection(status) if %w[succeeded failed-settled].include?(status.fetch("state"))
35
+ return uncertain(request_id) unless context["challenge"]
36
+ end
37
+ operation = recovery_inspector!(context)
38
+ root = Ace::Assign::Authority::PrivateDirectory.verify!(@receiver.fetch("staging_root"))
39
+ directory = Dir.mktmpdir("recovery-", root)
40
+ File.chmod(0o700, directory)
41
+ identity = File.lstat(directory)
42
+ envelope = immutable("version" => 1, "kind" => "service_no_effect_inspection",
43
+ "request" => context.fetch("request"), "input" => input.fetch(:input),
44
+ "execution" => context.fetch("execution"), "challenge" => context.fetch("challenge"))
45
+ # The selected inspector supplies retained success or positive absence;
46
+ # neither follows from a timeout or process/scope cleanup.
47
+ if context.dig("request", "operation") == "prune-preserved-workspace"
48
+ return uncertain(request_id) unless @cleanup_owner && context.dig("execution", "dispatch_phase") == "dispatch_started"
49
+ inspected_kind, artifacts = cleanup_inspection_evidence!(context, input.fetch(:input))
50
+ if inspected_kind == "succeeded"
51
+ evidence = artifacts.each_with_index.map do |artifact, index|
52
+ {"ref" => "cleanup-#{index}", "sha256" => Digest::SHA256.hexdigest(artifact)}
53
+ end
54
+ receipt = JSON.generate(context.fetch("request").merge("outcome" => "succeeded", "evidence" => evidence))
55
+ completion = params.merge("claim_binding" => context.dig("execution", "claim_binding"),
56
+ "receipt_sha256" => Digest::SHA256.hexdigest(receipt))
57
+ @client.call("complete_service", completion,
58
+ mutation_id: Digest::SHA256.hexdigest("cleanup-completed:#{mutation_id}"),
59
+ upload_parts: [receipt] + artifacts, purpose: :receipt_artifacts)
60
+ completed, = recovery_status!(params, input_bytes)
61
+ return uncertain(request_id) unless completed["state"] == "succeeded"
62
+ cleanup_staging(directory, identity, prefix: "recovery-")
63
+ return projection(completed)
64
+ end
65
+ response = {"outcome" => "failed", "request_id" => request_id,
66
+ "input_digest" => context.dig("request", "input_digest"), "evidence" => artifacts.each_with_index.map do |artifact, index|
67
+ {"ref" => index.zero? ? "cleanup-inspection-no-effect" : "cleanup-inspection-selection", "sha256" => Digest::SHA256.hexdigest(artifact)}
68
+ end}
69
+ else
70
+ response = @handler.execute(operation: immutable(operation), envelope: envelope, candidate_root: directory)
71
+ end
72
+ return uncertain(request_id) unless response && response["outcome"] == "failed" &&
73
+ response["request_id"] == request_id && response["input_digest"] == context.dig("request", "input_digest")
74
+ artifacts ||= Ace::Assign::Authority::EvidenceTransfer.read_staged(root: directory, evidence: response.fetch("evidence"))
75
+ receipt = context.fetch("request").merge("outcome" => "failed", "evidence" => response.fetch("evidence"))
76
+ bytes = JSON.generate(receipt)
77
+ completion_params = params.merge("claim_binding" => context.dig("execution", "claim_binding"),
78
+ "reconciliation_challenge" => context.fetch("challenge").slice("no_effect_challenge", "challenge_generation", "challenge_event_digest"),
79
+ "receipt_sha256" => Digest::SHA256.hexdigest(bytes))
80
+ @client.call("complete_no_effect", completion_params,
81
+ mutation_id: Digest::SHA256.hexdigest("no-effect:#{mutation_id}"),
82
+ upload_parts: [bytes] + artifacts, purpose: :receipt_artifacts)
83
+ # Protected status invokes the shared canonical receipt/import reader.
84
+ # Never accept a terminal state string without that authentication.
85
+ settled, = recovery_status!(params, input_bytes)
86
+ return uncertain(request_id) unless settled["state"] == "failed-settled"
87
+ cleanup_staging(directory, identity, prefix: "recovery-")
88
+ projection(settled)
89
+ rescue Ace::Assign::Error, Ace::Runtime::RuntimeUnavailableError, Ace::Lab::InvalidConfigurationError,
90
+ SecurityError, ArgumentError, KeyError, TypeError, SystemCallError
91
+ uncertain(request_id)
92
+ end
93
+
94
+ private
95
+
96
+ def recovery_binding!(binding, bytes, mutation_id, generation)
97
+ unless binding.is_a?(Hash) && binding.keys.sort == RECOVERY_BINDING.sort &&
98
+ %w[assignment_id attempt_id request_id].all? { |key| binding[key].is_a?(String) && binding[key].match?(Ace::Assign::Molecules::JournalMutation::ID) } &&
99
+ binding["head"].is_a?(String) && binding["head"].match?(Ace::Assign::Authority::CandidateTransfer::SHA) &&
100
+ binding["candidate_generation"].is_a?(Integer) && binding["candidate_generation"] >= 0 &&
101
+ generation.is_a?(Integer) && generation.positive? && bytes.is_a?(String) &&
102
+ mutation_id.is_a?(String) && mutation_id.match?(Ace::Assign::Molecules::JournalMutation::ID)
103
+ raise ArgumentError, "original recovery binding is invalid"
104
+ end
105
+ end
106
+
107
+ def recovery_executor!
108
+ me = @kernel.capture(Process.pid)
109
+ credentials = @project.fetch("peer_credentials").fetch(@receiver.fetch("executor_uid").to_s)
110
+ unless me["uid"] == @receiver.fetch("executor_uid") && me["gid"] == credentials.fetch("gid") &&
111
+ me["groups"] == credentials.fetch("groups")
112
+ raise SecurityError, "recovery executor differs"
113
+ end
114
+ @kernel.live!(me)
115
+ end
116
+
117
+ def recovery_status!(params, bytes)
118
+ reply = @client.call("service_status", params)
119
+ data = reply.data
120
+ context = data.fetch("settlement_context")
121
+ unless context.is_a?(Hash) && context.keys.sort == %w[version request execution challenge].sort &&
122
+ context["version"].is_a?(Integer) && context["version"] == 1
123
+ raise SecurityError, "canonical recovery context is malformed"
124
+ end
125
+ request, execution, challenge = context.values_at("request", "execution", "challenge")
126
+ execution_fields = request.is_a?(Hash) && request["operation"] == "prune-preserved-workspace" &&
127
+ execution.is_a?(Hash) && execution["dispatch_phase"] == "dispatch_started" ? CLEANUP_RECOVERY_EXECUTION : RECOVERY_EXECUTION
128
+ unless request.is_a?(Hash) && request.keys.sort == Ace::Assign::Molecules::EvidenceJournal::TERMINAL_BINDING_FIELDS.sort &&
129
+ execution.is_a?(Hash) && execution.keys.sort == execution_fields.sort &&
130
+ %w[assignment_id attempt_id request_id].all? { |key| request[key] == params.fetch(key) } &&
131
+ request["project_id"] == @map.fetch("project_id") && request["executor_uid"] == @receiver.fetch("executor_uid") &&
132
+ request["transport"] == "unix" && request["candidate_head"] == params.fetch("head") &&
133
+ execution["mapping_id"] == @mapping_id && execution["candidate_generation"] == params.fetch("candidate_generation") &&
134
+ execution["head"] == params.fetch("head") && %w[issued dispatch_started].include?(execution["dispatch_phase"]) &&
135
+ %w[uncertain failed succeeded failed-settled].include?(data["state"]) &&
136
+ data["request_id"] == params.fetch("request_id") && data["service_id"] == @service_id
137
+ raise SecurityError, "canonical recovery original binding differs"
138
+ end
139
+ if !challenge.nil? && (!challenge.is_a?(Hash) || challenge.keys.sort != RECOVERY_CHALLENGE.sort ||
140
+ !%w[challenge_generation failure_generation].all? { |key| challenge[key].is_a?(Integer) && challenge[key].positive? } ||
141
+ challenge["request_id"] != request["request_id"] || challenge["input_digest"] != request["input_digest"] ||
142
+ challenge["claim_binding"] != execution["claim_binding"])
143
+ raise SecurityError, "canonical recovery challenge differs"
144
+ end
145
+ input = @inputs.input_binding(bytes, expected_digest: request.fetch("input_digest"),
146
+ expected_target: request.fetch("target"), operation: request.fetch("operation"))
147
+ [data, immutable(context), input]
148
+ end
149
+
150
+ def cleanup_inspection_evidence!(context, input)
151
+ request, execution = context.values_at("request", "execution")
152
+ result = @cleanup_owner.inspect!(request: request.slice("request_id", "input_digest").merge(
153
+ "claim_binding" => execution.fetch("claim_binding"),
154
+ "request_event_digest" => execution.fetch("request_event_digest"),
155
+ "dispatch_event_digest" => execution.fetch("dispatch_event_digest"), "input" => input,
156
+ "challenge_ref" => context.fetch("challenge").slice("challenge_event_digest")),
157
+ operation_owner_binding: execution.fetch("operation_owner_binding"))
158
+ unless result.is_a?(Hash) && result.keys.sort == %i[bytes kind receipt_ref] &&
159
+ %w[succeeded failed-no-effect].include?(result[:kind])
160
+ raise SecurityError, "original cleanup inspection kind differs"
161
+ end
162
+ success = result.fetch(:kind) == "succeeded"
163
+ ref, bytes = result.values_at(:receipt_ref, :bytes)
164
+ unless ref.is_a?(Hash) && ref.keys.sort == %w[bytes path sha256] && bytes.is_a?(String) &&
165
+ bytes.bytesize.between?(1, 65_536) && ref["bytes"].is_a?(Integer) && ref["bytes"] == bytes.bytesize &&
166
+ ref["sha256"] == Digest::SHA256.hexdigest(bytes)
167
+ raise SecurityError, "original cleanup inspection result differs"
168
+ end
169
+ selection = {"schema" => success ? Ace::Assign::Authority::ServiceCleanupEvidence::ROOT_SELECTION_SCHEMA : Ace::Assign::Authority::ServiceCleanupEvidence::ROOT_INSPECTION_SELECTION_SCHEMA,
170
+ "request_id" => request.fetch("request_id"), "input_digest" => request.fetch("input_digest"),
171
+ "operation_owner_binding_digest" => Ace::Assign::Atoms::EvidenceDigest.digest(execution.fetch("operation_owner_binding")),
172
+ (success ? "receipt_ref" : "inspection_ref") => ref}
173
+ [result.fetch(:kind), [bytes, JSON.generate(selection)].freeze].freeze
174
+ end
175
+
176
+ def recovery_inspector!(context)
177
+ request = context.fetch("request")
178
+ policy = Molecules::ServicePolicy.new(Molecules::GrantResolver.trusted_document(Ace::Lab.authorization_path))
179
+ policy.inspection!(request.fetch("operation"), project: @map.fetch("project_id"),
180
+ service_id: @service_id, executor_uid: @receiver.fetch("executor_uid"))
181
+ end
182
+ end
183
+ end
184
+ end
185
+ end
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+ require "ace/assign/authority/protected_assignment_context"
3
+ require "ace/assign/organisms/protected_delivery_coordinator"
4
+ require_relative "protected_service_client"
5
+ require "ace/git/atoms/service_pr_input"
6
+
7
+ module Ace
8
+ module Lab
9
+ module Organisms
10
+ # Public selection adapter. The original authority and fixed receiver
11
+ # own every grant/effect; this adapter has no local journal or executor.
12
+ class ProtectedServiceRequest
13
+ FLAGS = %i[mapping scope service candidate_head candidate_generation expected_generation input_digest target artifact_digest].freeze
14
+ LIMIT = 16_384
15
+
16
+ def initialize(context: Ace::Assign::Authority::ProtectedAssignmentContext.load, ingress_factory: nil)
17
+ @context = context
18
+ @ingress = ingress_factory || ->(**selection) { ProtectedServiceClient.new(**selection) }
19
+ end
20
+
21
+ def selected?(options)
22
+ @context.protected_participant? || @context.mapping_hint? || FLAGS.any? { |key| !options[key].nil? }
23
+ end
24
+
25
+ def request(project:, assignment:, attempt:, operation:, input_path:, authorization:, request_id:, **options)
26
+ refuse_nonworker!
27
+ unless (options.keys - %i[mapping scope service candidate_head candidate_generation expected_generation dry_run]).empty?
28
+ raise ArgumentError, "protected request rejects incompatible options"
29
+ end
30
+ raise ArgumentError, "protected service request requires a fixed PR operation and no dry-run" unless Ace::Git::Atoms::ServicePrInput::OPERATIONS.include?(operation) && !options[:dry_run]
31
+ validate!(project, assignment, attempt, request_id, options)
32
+ unless authorization.is_a?(String) && authorization.match?(Molecules::ServicePolicy::ID) &&
33
+ options[:service].is_a?(String) && options[:service].match?(Molecules::ServicePolicy::ID) &&
34
+ options[:expected_generation].is_a?(Integer) && options[:expected_generation].positive?
35
+ raise ArgumentError, "protected service request requires receiver, authorization and original generation"
36
+ end
37
+ input = Atoms::ServiceInput.load(input_path)
38
+ Ace::Git::Atoms::ServicePrInput.validate(input, operation: operation)
39
+ target = Atoms::ServiceInput.target(input)
40
+ digest = Atoms::ServiceInput.digest(input)
41
+ prepared = resolve!(project, assignment, attempt, options)
42
+ selection = {"project_id" => project, "mapping_id" => options.fetch(:mapping), "assignment_id" => assignment,
43
+ "attempt_id" => attempt, "scope" => options.fetch(:scope), "service_id" => options.fetch(:service),
44
+ "candidate_head" => options.fetch(:candidate_head), "candidate_generation" => options.fetch(:candidate_generation),
45
+ "expected_generation" => options.fetch(:expected_generation), "request_id" => request_id,
46
+ "input_digest" => digest, "target" => target}
47
+ submission = {"assignment_id" => assignment, "attempt_id" => attempt, "expected_generation" => options.fetch(:expected_generation),
48
+ "candidate_generation" => options.fetch(:candidate_generation), "head" => options.fetch(:candidate_head),
49
+ "request_id" => request_id, "operation" => operation, "input_digest" => digest, "target" => target,
50
+ "authorization" => authorization}
51
+ mutation = Digest::SHA256.hexdigest("qkb.#{operation}.claim:v1:#{assignment}:#{attempt}:#{request_id}")
52
+ claim = @context.with_installed_selection(options: options, input: prepared) do |deployment, kernel, mapping|
53
+ @ingress.call(mapping_id: mapping, service_id: options.fetch(:service), deployment: deployment, kernel: kernel)
54
+ .submit(submission: submission, input_bytes: JSON.generate(input), mutation_id: mutation)
55
+ end
56
+ envelope = if claim.fetch("type") == "service_claim_accepted"
57
+ {"status" => "ok", "data" => {"selection" => selection, "claim" => claim}}
58
+ else
59
+ code = claim.fetch("type")
60
+ {"status" => "error", "error" => {"code" => code,
61
+ "message" => "inspect original canonical service status; no automatic resubmission",
62
+ "selection" => selection, "claim" => claim}}
63
+ end
64
+ bounded!(envelope, selection: selection)
65
+ end
66
+
67
+ def status(request_id:, **options)
68
+ refuse_nonworker!
69
+ unless (options.keys - %i[project assignment attempt mapping scope candidate_head candidate_generation input_digest target artifact_digest format]).empty?
70
+ raise ArgumentError, "protected status rejects incompatible options"
71
+ end
72
+ project, assignment, attempt = options.values_at(:project, :assignment, :attempt)
73
+ validate!(project, assignment, attempt, request_id, options)
74
+ unless options[:input_digest].is_a?(String) && options[:input_digest].match?(Atoms::ServiceInput::SHA256)
75
+ raise ArgumentError, "protected status requires original input digest"
76
+ end
77
+ target = Atoms::ServiceInput.target({"target" => {"resource" => options[:target], "artifact_digest" => options[:artifact_digest]}})
78
+ resolve!(project, assignment, attempt, options)
79
+ data = Ace::Assign::Organisms::ProtectedDeliveryCoordinator.new(client: @context.client(options: options), project_id: project)
80
+ .perform(assignment_id: assignment, attempt_id: attempt, operation: "status", service_request_id: request_id,
81
+ candidate_head: options.fetch(:candidate_head), candidate_generation: options.fetch(:candidate_generation),
82
+ input_digest: options.fetch(:input_digest), target: target)
83
+ bounded!({"status" => "ok", "data" => data})
84
+ end
85
+
86
+ private
87
+
88
+ def refuse_nonworker!
89
+ if @context.protected_participant? && !@context.protected_worker?
90
+ raise Ace::Assign::AttemptErrors::EvidenceUnavailable, "protected service request/status is worker-only"
91
+ end
92
+ end
93
+
94
+ def validate!(project, assignment, attempt, request_id, options)
95
+ unless [project, assignment, attempt, request_id, options[:mapping]].all? { |value| value.is_a?(String) && value.match?(Molecules::ServicePolicy::ID) } &&
96
+ options[:scope].is_a?(String) && options[:scope].match?(Ace::Assign::Authority::PreparedWork::SCOPE) &&
97
+ options[:candidate_head].is_a?(String) && options[:candidate_head].match?(/\A[0-9a-f]{40}\z/) &&
98
+ options[:candidate_generation].is_a?(Integer) && options[:candidate_generation].positive?
99
+ raise ArgumentError, "protected service requires exact original scoped selectors"
100
+ end
101
+ end
102
+
103
+ def resolve!(project, assignment, attempt, options)
104
+ input = @context.resolve(options: options.merge(attempt: attempt), assignment_id: assignment, scope: options.fetch(:scope))
105
+ unless input && input.descriptor.values_at("project_id", "assignment_id", "attempt_id", "scope", "mapping_id") ==
106
+ [project, assignment, attempt, options.fetch(:scope), options.fetch(:mapping)]
107
+ raise Ace::Assign::AttemptErrors::EvidenceUnavailable, "original prepared service selection differs"
108
+ end
109
+ input
110
+ end
111
+
112
+ def bounded!(envelope, selection: nil)
113
+ return envelope if JSON.generate(envelope).bytesize <= LIMIT
114
+ if selection
115
+ # Submission may already be durable. Never classify lost output as
116
+ # a pre-effect refusal or retry; retain the original lookup tuple.
117
+ envelope = {"status" => "error", "error" => {"code" => "service_claim_unconfirmed",
118
+ "message" => "claim output unavailable; inspect original canonical service status without resubmission",
119
+ "selection" => selection, "claim" => {"version" => 1, "type" => "service_claim_unconfirmed",
120
+ "required_action" => "inspect_canonical_service_status"}}}
121
+ return envelope if JSON.generate(envelope).bytesize <= LIMIT
122
+ end
123
+ raise Ace::Assign::AttemptErrors::EvidenceUnavailable, "protected status output exceeds bound"
124
+ end
125
+ end
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "protected_service_receiver"
4
+
5
+ module Ace
6
+ module Lab
7
+ module Organisms
8
+ # One admitted worker for one installed receiver. Capacity is acquired
9
+ # before the canonical claim, not after an accepted request. This owns
10
+ # execution lifetime only; it never substitutes for canonical status.
11
+ class ProtectedServiceWorker
12
+ def initialize(receiver:)
13
+ @receiver = receiver
14
+ @mutex = Mutex.new
15
+ @worker = nil
16
+ @closing = false
17
+ end
18
+
19
+ def start(submission:, peer:, input_bytes:, mutation_id:, on_claim:, deadline: nil)
20
+ raise ArgumentError, "receiver acceptance callback unavailable" unless on_claim.respond_to?(:call)
21
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
22
+ deadline ||= now + 5
23
+ unless deadline.is_a?(Numeric) && deadline.finite? && deadline > now && deadline <= now + 5
24
+ raise ArgumentError, "receiver acceptance deadline is invalid"
25
+ end
26
+ # Hold independent copies before returning to the socket handler.
27
+ submission = copy(submission)
28
+ peer = copy(peer)
29
+ input_bytes = input_bytes.dup.freeze
30
+ mutation_id = mutation_id.dup.freeze
31
+ @mutex.synchronize do
32
+ raise Ace::Assign::AttemptErrors::Conflict, "receiver capacity unavailable" if @closing || @worker&.alive?
33
+
34
+ @worker = Thread.new do
35
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
36
+ next({"state" => "uncertain"}) unless remaining.positive?
37
+ result = @receiver.execute(submission: submission, peer: peer, input_bytes: input_bytes,
38
+ mutation_id: mutation_id, claim_timeout: [remaining, 5].min,
39
+ on_claim: lambda do |identity|
40
+ begin
41
+ on_claim.call(identity)
42
+ rescue IOError, SystemCallError
43
+ # A lost acceptance reply cannot cancel already admitted
44
+ # work or cause a second execution. Status owns recovery.
45
+ nil
46
+ end
47
+ end)
48
+ if submission.fetch("operation") == "publish"
49
+ while @receiver.publication_active? && !@mutex.synchronize { @closing }
50
+ result = @receiver.tick_publication!(request_id: submission.fetch("request_id"),
51
+ deadline: Process.clock_gettime(Process::CLOCK_MONOTONIC) + 30)
52
+ sleep 0.25 if @receiver.publication_active?
53
+ end
54
+ @receiver.release_publication_lifetime!
55
+ end
56
+ result
57
+ end
58
+ end
59
+ end
60
+
61
+ # Stop admission before joining. A timed-out join preserves both the
62
+ # worker and closing state; the listener must retain its lifetime lock.
63
+ def close(timeout:)
64
+ unless timeout.is_a?(Numeric) && timeout.finite? && timeout >= 0
65
+ raise ArgumentError, "receiver close deadline is invalid"
66
+ end
67
+ worker = @mutex.synchronize do
68
+ @closing = true
69
+ @worker
70
+ end
71
+ !worker || !worker.join(timeout).nil?
72
+ end
73
+
74
+ private
75
+
76
+ def copy(value)
77
+ duplicate = JSON.parse(JSON.generate(value))
78
+ freeze_value(duplicate)
79
+ end
80
+
81
+ def freeze_value(value)
82
+ value.each_value { |child| freeze_value(child) } if value.is_a?(Hash)
83
+ value.each { |child| freeze_value(child) } if value.is_a?(Array)
84
+ value.freeze
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
@@ -69,7 +69,8 @@ module Ace
69
69
  return route.envelope unless route.ok?
70
70
  service_id = route.data.fetch("entry").fetch("id")
71
71
  trusted = Molecules::GrantResolver.trusted_document(Ace::Lab.authorization_path) unless @policy
72
- policy = @policy || Molecules::ServicePolicy.new(trusted)
72
+ policy = @policy || Molecules::ServicePolicy.new(trusted,
73
+ ->(reference, exact) { @coordinator.proposal_authorization(reference, exact) })
73
74
  head = current_head
74
75
  binding = {"request_id" => request_id, "assignment_id" => assignment, "attempt_id" => attempt,
75
76
  "project_id" => project, "operation" => operation,
@@ -212,7 +213,8 @@ module Ace
212
213
  if @policy
213
214
  -> { @policy }
214
215
  else
215
- -> { Molecules::ServicePolicy.new(Molecules::GrantResolver.trusted_document(Ace::Lab.authorization_path)) }
216
+ -> { Molecules::ServicePolicy.new(Molecules::GrantResolver.trusted_document(Ace::Lab.authorization_path),
217
+ ->(reference, exact) { @coordinator.proposal_authorization(reference, exact) }) }
216
218
  end
217
219
  end
218
220
 
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ace
4
4
  module Lab
5
- VERSION = "0.3.1"
5
+ VERSION = "0.5.0"
6
6
  end
7
7
  end
data/lib/ace/lab.rb CHANGED
@@ -3,14 +3,8 @@
3
3
  require_relative "lab/version"
4
4
  require "ace/support/config"
5
5
 
6
- module Ace
7
- module Lab
8
- class Error < StandardError; end
9
-
10
- # Raised when topology configuration violates the schema contract
11
- class InvalidConfigurationError < Error; end
12
- end
13
- end
6
+ require_relative "lab/errors"
7
+ require_relative "lab/authorization_path"
14
8
 
15
9
  # Load all ace-lab components
16
10
  require_relative "lab/atoms/topology_schema"
@@ -63,20 +57,6 @@ module Ace
63
57
  # Same discovery the cascade resolver performs for this namespace
64
58
  LAB_FILE_PATTERNS = ["lab/config.yml", "lab/config.yaml"].freeze
65
59
 
66
- # Authorization grants live in a single deployment-controlled file at a
67
- # FIXED path — never in the caller-writable configuration cascade and
68
- # never at a caller-selected location (review rounds 4-5, F3/F1). The
69
- # file must be root-owned and not group/world-writable, including every
70
- # directory on its real path; GrantResolver verifies and fails closed.
71
- AUTHORIZATION_PATH = "/etc/lab/ace-lab/authorization.yml"
72
-
73
- # Path of the trusted authorization grants document. A method (not a
74
- # bare constant reference) so tests can stub the seam without any
75
- # caller-controllable production override.
76
- # @return [String]
77
- def self.authorization_path
78
- AUTHORIZATION_PATH
79
- end
80
60
 
81
61
  # Load configuration using Ace::Support::Config cascade.
82
62
  # Load failures raise InvalidConfigurationError (classified by the query
metadata CHANGED
@@ -1,14 +1,28 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ace-lab
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.1
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Michal Czyz
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-10-04 00:00:00.000000000 Z
10
+ date: 2026-10-08 00:00:00.000000000 Z
11
11
  dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: ace-runtime
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '0.2'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '0.2'
12
26
  - !ruby/object:Gem::Dependency
13
27
  name: ace-support-cli
14
28
  requirement: !ruby/object:Gem::Requirement
@@ -57,14 +71,28 @@ dependencies:
57
71
  requirements:
58
72
  - - "~>"
59
73
  - !ruby/object:Gem::Version
60
- version: '0.62'
74
+ version: '0.66'
75
+ type: :runtime
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - "~>"
80
+ - !ruby/object:Gem::Version
81
+ version: '0.66'
82
+ - !ruby/object:Gem::Dependency
83
+ name: ace-hitl
84
+ requirement: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - "~>"
87
+ - !ruby/object:Gem::Version
88
+ version: '0.13'
61
89
  type: :runtime
62
90
  prerelease: false
63
91
  version_requirements: !ruby/object:Gem::Requirement
64
92
  requirements:
65
93
  - - "~>"
66
94
  - !ruby/object:Gem::Version
67
- version: '0.62'
95
+ version: '0.13'
68
96
  - !ruby/object:Gem::Dependency
69
97
  name: ace-support-test-helpers
70
98
  requirement: !ruby/object:Gem::Requirement
@@ -168,9 +196,13 @@ files:
168
196
  - exe/ace-lab
169
197
  - lib/ace/lab.rb
170
198
  - lib/ace/lab/atoms/binding_freshness.rb
199
+ - lib/ace/lab/atoms/protected_publication_input.rb
200
+ - lib/ace/lab/atoms/protected_workspace_prune_input.rb
201
+ - lib/ace/lab/atoms/protected_workspace_prune_preview.rb
171
202
  - lib/ace/lab/atoms/public_projection.rb
172
203
  - lib/ace/lab/atoms/service_input.rb
173
204
  - lib/ace/lab/atoms/topology_schema.rb
205
+ - lib/ace/lab/authorization_path.rb
174
206
  - lib/ace/lab/cli.rb
175
207
  - lib/ace/lab/cli/commands/agents.rb
176
208
  - lib/ace/lab/cli/commands/projects.rb
@@ -179,6 +211,7 @@ files:
179
211
  - lib/ace/lab/cli/commands/service.rb
180
212
  - lib/ace/lab/cli/commands/services.rb
181
213
  - lib/ace/lab/cli/commands/support.rb
214
+ - lib/ace/lab/errors.rb
182
215
  - lib/ace/lab/models/query_result.rb
183
216
  - lib/ace/lab/models/runtime_binding.rb
184
217
  - lib/ace/lab/models/service_receipt.rb
@@ -189,9 +222,24 @@ files:
189
222
  - lib/ace/lab/molecules/grant_resolver.rb
190
223
  - lib/ace/lab/molecules/hitl_authorizer.rb
191
224
  - lib/ace/lab/molecules/inventory_query.rb
225
+ - lib/ace/lab/molecules/protected_cleanup_owner_admission.rb
226
+ - lib/ace/lab/molecules/protected_cleanup_owner_client.rb
227
+ - lib/ace/lab/molecules/protected_cleanup_owner_identity.rb
228
+ - lib/ace/lab/molecules/protected_publication_publisher.rb
229
+ - lib/ace/lab/molecules/protected_service_handler.rb
230
+ - lib/ace/lab/molecules/protected_service_policy.rb
192
231
  - lib/ace/lab/molecules/service_executor.rb
193
232
  - lib/ace/lab/molecules/service_policy.rb
194
233
  - lib/ace/lab/molecules/topology_loader.rb
234
+ - lib/ace/lab/organisms/authority_composition.rb
235
+ - lib/ace/lab/organisms/protected_cleanup_owner.rb
236
+ - lib/ace/lab/organisms/protected_service_client.rb
237
+ - lib/ace/lab/organisms/protected_service_listener.rb
238
+ - lib/ace/lab/organisms/protected_service_publication.rb
239
+ - lib/ace/lab/organisms/protected_service_receiver.rb
240
+ - lib/ace/lab/organisms/protected_service_recovery.rb
241
+ - lib/ace/lab/organisms/protected_service_request.rb
242
+ - lib/ace/lab/organisms/protected_service_worker.rb
195
243
  - lib/ace/lab/organisms/service_request_service.rb
196
244
  - lib/ace/lab/organisms/topology_service.rb
197
245
  - lib/ace/lab/version.rb