ace-lab 0.3.0 → 0.4.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 521f7150bb3da5fcc12c475b5c2fce6c78808e78315e05731b43dc6632ffa599
4
- data.tar.gz: 32a8f1106ff9321c5e6971411be50fad60497c770d7fcb38d19623e270700258
3
+ metadata.gz: bbfb351af77987d179094c39d14b12ac6209ba6825bc680e932df4fcf484684d
4
+ data.tar.gz: 92d60f3d735582fc4371569d1f2d19691820d924855564ac95ac71eba9e06ecf
5
5
  SHA512:
6
- metadata.gz: e8e32415403e0235b9e6fd4f9862aed6b1577d98d651a5984ae10f3073be918248766542dca0dfe58555ecb0843d8d7df85e869b8751b9b28d5a28e447ca6cdd
7
- data.tar.gz: a12d71e70fc25b27fb2b85aeda273713dcbcebb477b29ada5a415b2f007ec3368fa6d8b5d2c8e9258bca8d4e63e7ff9965efa0d05be0f9534bdd739284eb38e1
6
+ metadata.gz: a088f9ce2372cf71bfabe2df3f58b578ced3b1dae4f28ac1d791c90af64b4ac2795020e3d7ecbd8158cab7c75e2be6413250b22a7413459e466dc5d155404fe9
7
+ data.tar.gz: 38031154d986e6d25fcfac08ee890cf619553ff26536327b128076f4fbec7c862a76bf6e04c438aea2bd87a6eb566aa82299959809c44fefd00c1edb0ab7433f
data/CHANGELOG.md CHANGED
@@ -7,12 +7,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.4.0] - 2026-10-05
11
+
12
+ ### Added
13
+
14
+ - Protected service policy, receiver and authority composition; incomplete full-service startup remains refused.
15
+ - Resolve immutable second-commander proposals through confirmed-delivery sixteen-hour policy and canonical Assign authorization.
16
+
17
+ ### Changed
18
+
19
+ - Declare the required direct dependencies and minimum producer versions for this coordinated release: `ace-assign ~> 0.64`, `ace-runtime ~> 0.2`.
20
+
21
+ ## [0.3.1] - 2026-10-04
22
+
23
+ ### Changed
24
+ - Track the runtime-neutral ace-assign 0.62 line (the published 0.3.0 still allows `~> 0.61`, which resolves 0.62 — this release ships the tightened constraint).
25
+
10
26
  ## [0.3.0] - 2026-10-04
11
27
 
12
28
  ### Added
13
29
  - **HITL delivery authorization policy (spec 8wq.t.34i)**: `Molecules::HitlAuthorizer` derives the lab-side transport facts (trusted transport uids, trusted HITL service identity, per-project Captain visibility) from the SAME trusted deployment document the grant resolver reads — uid-exact, project-scoped, never from claimed names or the cascade.
14
30
 
15
-
16
31
  ## [0.2.0] - 2026-10-02
17
32
 
18
33
  ### Added
@@ -33,7 +33,9 @@ module Ace
33
33
  raise Ace::Lab::InvalidConfigurationError,
34
34
  "invalid lab configuration: trusted authorization file must contain a mapping"
35
35
  end
36
- document
36
+ # Use the same principal schema and the same verified bytes for
37
+ # policy composition, rather than reading a second grants snapshot.
38
+ document.merge("principals" => parse_grants(content, path).fetch("principals"))
37
39
  rescue Psych::Exception
38
40
  raise Ace::Lab::InvalidConfigurationError,
39
41
  "invalid lab configuration: trusted authorization file could not be parsed"
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "open3"
5
+ require "timeout"
6
+ require "ace/assign/authority/private_directory"
7
+
8
+ module Ace
9
+ module Lab
10
+ module Molecules
11
+ # Source-owned receiver handler. Permission admission is a separate
12
+ # authority operation; this runner never grants or reconstructs it.
13
+ class ProtectedServiceHandler
14
+ MAX_STDOUT = 16 * 1024
15
+ MAX_STDERR = 8 * 1024
16
+
17
+ def execute(operation:, envelope:, candidate_root:)
18
+ unless Process.uid == operation.fetch("executor_uid") && Process.euid == operation.fetch("executor_uid")
19
+ raise SecurityError, "handler executor differs"
20
+ end
21
+ root = Ace::Assign::Authority::PrivateDirectory.verify!(candidate_root)
22
+ out = nil
23
+ Open3.popen3({"PATH" => "/usr/bin:/bin", "LANG" => "C.UTF-8", "HOME" => root},
24
+ *operation.fetch("argv"), chdir: root, pgroup: true, unsetenv_others: true) do |stdin, stdout, stderr, waiter|
25
+ readers = []
26
+ begin
27
+ Timeout.timeout(30) do
28
+ readers = [Thread.new { bounded(stdout, MAX_STDOUT) }, Thread.new { bounded(stderr, MAX_STDERR) }]
29
+ stdin.write(JSON.generate(envelope))
30
+ stdin.close
31
+ output, errors = readers.map(&:value)
32
+ unless output && errors && waiter.value.success?
33
+ return nil
34
+ end
35
+ out = output
36
+ end
37
+ rescue Timeout::Error, IOError, SystemCallError
38
+ return nil
39
+ ensure
40
+ stdin.close unless stdin.closed?
41
+ # This group is created and owned by this receiver. Termination
42
+ # is cleanup, never proof that an arbitrary handler left no effect.
43
+ Process.kill("KILL", -waiter.pid) rescue nil
44
+ waiter.value
45
+ [stdout, stderr].each { |io| io.close unless io.closed? }
46
+ readers.each(&:join)
47
+ end
48
+ end
49
+ response = JSON.parse(out)
50
+ request = envelope.fetch("request")
51
+ unless response.is_a?(Hash) && response.keys.sort == %w[evidence input_digest outcome request_id] &&
52
+ response["request_id"] == request.fetch("request_id") && response["input_digest"] == request.fetch("input_digest") &&
53
+ %w[succeeded failed].include?(response["outcome"]) && response["evidence"].is_a?(Array)
54
+ return nil
55
+ end
56
+ response
57
+ rescue JSON::ParserError
58
+ nil
59
+ end
60
+
61
+ private
62
+
63
+ def bounded(io, limit)
64
+ bytes = io.read(limit + 1).to_s
65
+ bytes.bytesize <= limit ? bytes : nil
66
+ rescue IOError, SystemCallError
67
+ nil
68
+ ensure
69
+ io.close unless io.closed?
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "etc"
5
+ require "ace/assign"
6
+
7
+ module Ace
8
+ module Lab
9
+ module Molecules
10
+ # The receiver and authority share the existing input/policy owners.
11
+ # Only composition supplies collaborators; no wire-selected policy,
12
+ # executable, input path or caller identity is accepted here.
13
+ class ProtectedServicePolicy
14
+ def initialize(proposal_resolver:, document_loader: nil)
15
+ @proposal_resolver = proposal_resolver
16
+ @document_loader = document_loader || -> { GrantResolver.trusted_document(Ace::Lab.authorization_path) }
17
+ end
18
+
19
+ def input_binding(bytes, expected_digest:, expected_target:)
20
+ unless bytes.is_a?(String) && bytes.bytesize.between?(1, Atoms::ServiceInput::MAX_BYTES)
21
+ raise ArgumentError, "structured service input exceeds its fixed limit"
22
+ end
23
+ input = JSON.parse(bytes)
24
+ raise ArgumentError, "structured service input must be an object" unless input.is_a?(Hash)
25
+ Atoms::ServiceInput.validate!(input)
26
+ digest = Atoms::ServiceInput.digest(input)
27
+ target = Atoms::ServiceInput.target(input)
28
+ unless digest == expected_digest && target == expected_target
29
+ raise SecurityError, "structured service input differs from the bound digest or target"
30
+ end
31
+ deep_freeze(input: input, input_digest: digest, target: target)
32
+ rescue JSON::ParserError
33
+ raise ArgumentError, "structured service input must be valid JSON"
34
+ end
35
+
36
+ def prepare!(binding, input_bytes:)
37
+ input = input_binding(input_bytes, expected_digest: binding.fetch("input_digest"), expected_target: binding.fetch("target"))
38
+ authorized = authorize!(binding)
39
+ canonical = snapshot(binding).merge("executor_uid" => authorized.fetch(:operation).fetch("executor_uid"), "transport" => "unix")
40
+ deep_freeze(authorized.merge(binding: canonical, input: input.fetch(:input)))
41
+ end
42
+
43
+ # Called again inside each claim/begin CAS retry using the immutable
44
+ # binding and ephemeral parsed input, never a caller filesystem path.
45
+ def authorize!(binding)
46
+ uid = binding.fetch("caller_uid")
47
+ document = visible_document!(project: binding.fetch("project_id"), uid: uid)
48
+ resolver = ->(reference, exact) { @proposal_resolver.call(binding.fetch("project_id"), reference, exact) }
49
+ policy = ServicePolicy.new(document, resolver)
50
+ operation = policy.operation!(binding.fetch("operation"), project: binding.fetch("project_id"), service_id: binding.fetch("service_id"))
51
+ unless operation.fetch("transport", "local") == "local"
52
+ raise Ace::Lab::InvalidConfigurationError, "protected receiver requires a fixed local handler operation"
53
+ end
54
+ unless operation.fetch("executor_uid").positive? && operation.fetch("executor_uid") != uid
55
+ raise SecurityError, "protected executor must be a separate nonroot account"
56
+ end
57
+ if binding.key?("executor_uid") && binding["executor_uid"] != operation.fetch("executor_uid")
58
+ raise SecurityError, "configured executor differs from the recorded claim"
59
+ end
60
+ decision = policy.authorize!(binding.fetch("authorization"), binding)
61
+ digest = Ace::Assign::Atoms::EvidenceDigest.digest("operation" => operation, "authorization" => decision,
62
+ "caller_uid" => uid, "project_id" => binding.fetch("project_id"))
63
+ if binding.key?("policy_digest") && binding["policy_digest"] != digest
64
+ raise SecurityError, "service policy changed after claim"
65
+ end
66
+ deep_freeze(operation: snapshot(operation), policy_digest: digest,
67
+ operation_digest: Ace::Assign::Atoms::EvidenceDigest.digest(operation))
68
+ end
69
+
70
+ def visible!(project:, uid:)
71
+ visible_document!(project: project, uid: uid)
72
+ true
73
+ end
74
+
75
+ def authorize_update!(existing, replacement, _pending)
76
+ initial_effect = existing.nil? && %w[accepted uncertain].include?(replacement["state"])
77
+ fresh_dispatch = existing && existing["dispatch_phase"] == "issued" && replacement["dispatch_phase"] == "dispatch_started"
78
+ authorize!(replacement) if initial_effect || fresh_dispatch
79
+ true
80
+ end
81
+
82
+ private
83
+
84
+ def visible_document!(project:, uid:)
85
+ raise SecurityError, "protected caller UID is invalid" unless uid.is_a?(Integer) && uid.positive?
86
+ username = begin
87
+ Etc.getpwuid(uid).name
88
+ rescue ArgumentError
89
+ nil
90
+ end
91
+ document = @document_loader.call
92
+ identities = CallerAuthorizer.matchable_identities(username, uid)
93
+ visibility = CallerAuthorizer.new(principals: document.fetch("principals", {}), identity: identities)
94
+ raise SecurityError, "caller project grant has been revoked" unless visibility.authorized?(project)
95
+ document
96
+ end
97
+
98
+ def snapshot(value)
99
+ JSON.parse(JSON.generate(value))
100
+ end
101
+
102
+ def deep_freeze(value)
103
+ case value
104
+ when Hash then value.each { |key, item| deep_freeze(key); deep_freeze(item) }
105
+ when Array then value.each { |item| deep_freeze(item) }
106
+ end
107
+ value.freeze
108
+ end
109
+ end
110
+ end
111
+ end
112
+ end
@@ -12,9 +12,10 @@ module Ace
12
12
  NAME = /\A[a-z][a-z0-9-]{0,63}\z/
13
13
  ID = /\A[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}\z/
14
14
 
15
- def initialize(document)
15
+ def initialize(document, proposal_resolver = nil)
16
16
  @operations = document.fetch("operations", {})
17
17
  @authorizations = document.fetch("authorizations", {})
18
+ @proposal_resolver = proposal_resolver
18
19
  unless @operations.is_a?(Hash) && @authorizations.is_a?(Hash)
19
20
  raise Ace::Lab::InvalidConfigurationError, "trusted service policy must contain mappings"
20
21
  end
@@ -80,6 +81,14 @@ module Ace
80
81
 
81
82
  def authorize!(reference, binding)
82
83
  raise ArgumentError, "invalid authorization reference" unless reference.to_s.match?(ID)
84
+ if reference.start_with?("proposal-")
85
+ raise SecurityError, "canonical proposal authority is unavailable" unless @proposal_resolver
86
+ begin
87
+ return @proposal_resolver.call(reference, binding)
88
+ rescue Ace::Assign::Error
89
+ raise SecurityError, "canonical proposal does not authorize this exact effect"
90
+ end
91
+ end
83
92
  decision = @authorizations[reference]
84
93
  raise SecurityError, "authorization reference is unresolved" unless decision.is_a?(Hash)
85
94
  required = %w[operation project_id assignment_id attempt_id input_digest target candidate_head caller_uid]
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ace/assign/authority/server"
4
+ require "ace/assign/authority/router"
5
+ require "ace/assign/authority/launch_lifecycle"
6
+ require "ace/assign/authority/endcap"
7
+ require_relative "../molecules/protected_service_policy"
8
+
9
+ module Ace
10
+ module Lab
11
+ module Organisms
12
+ # The only full-service composition. The deployed map chooses identity
13
+ # and placement; source selects all handlers, policy and evidence readers.
14
+ class AuthorityComposition
15
+ REQUIRED_ENDCAP = %w[submit_candidate export_candidate assign_review accept_review request_service
16
+ begin_dispatch complete_service complete_no_effect submit_result finish recover bind_inbox reconcile_inbox
17
+ service_status evidence_fetch].freeze
18
+
19
+ def initialize(authority_id:, deployment: Ace::Assign::Authority::Deployment.load,
20
+ kernel: Ace::Runtime::Molecules::ProtectedLinux.new)
21
+ @authority_id, @deployment, @kernel = authority_id, deployment, kernel
22
+ end
23
+
24
+ def build
25
+ @deployment.verify_composition!(@authority_id, composition: "services")
26
+ unless (REQUIRED_ENDCAP - Ace::Assign::Authority::Endcap::OPERATIONS).empty?
27
+ raise InvalidConfigurationError, "full-service source composition is incomplete"
28
+ end
29
+ projects = @deployment.data.fetch("launch_mappings").values
30
+ .select { |map| map.fetch("authority_id") == @authority_id }.map { |map| map.fetch("project_id") }.uniq
31
+ raise InvalidConfigurationError, "full-service authority has no projects" if projects.empty?
32
+ projects.each do |id|
33
+ project = @deployment.project(id)
34
+ unless project["service_receivers"].is_a?(Hash) && !project["service_receivers"].empty?
35
+ raise InvalidConfigurationError, "full-service project has no fixed receiver"
36
+ end
37
+ end
38
+
39
+ journals = {}
40
+ endcap = nil
41
+ projects.each do |id|
42
+ project = @deployment.project(id)
43
+ reader = nil
44
+ journal = Ace::Assign::Molecules::EvidenceJournal.new(repo_root: project.fetch("journal_repository"),
45
+ checkout_root: project.fetch("evidence_checkout_root"), ref: project.fetch("evidence_git_ref"), mode: :protected,
46
+ evidence_reader: ->(reference, record, state, pending) { reader.call(reference, record, state, pending) },
47
+ service_authorizer: ->(existing, replacement, pending) {
48
+ endcap.authorize_service_update!(journal: journals.fetch(id), existing: existing,
49
+ replacement: replacement, pending: pending)
50
+ })
51
+ reader = Ace::Assign::Authority::ServiceEvidence.new(journal: journal)
52
+ journals[id] = journal
53
+ end
54
+ policy = Molecules::ProtectedServicePolicy.new(proposal_resolver: ->(project, reference, binding) {
55
+ journals.fetch(project).proposal_authorize!(reference, binding)
56
+ })
57
+ launch = Ace::Assign::Authority::LaunchLifecycle.new(deployment: @deployment, kernel: @kernel, journals: journals)
58
+ endcap = Ace::Assign::Authority::Endcap.new(deployment: @deployment, launch: launch, kernel: @kernel, service_policy: policy)
59
+ router = Ace::Assign::Authority::Router.new(launch: launch, handlers: [endcap])
60
+ Ace::Assign::Authority::Server.new(authority_id: @authority_id, deployment: @deployment, kernel: @kernel,
61
+ lifecycle: router, composition: "services")
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,177 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "digest"
5
+ require "fileutils"
6
+ require "ace/assign/authority/client"
7
+ require "ace/assign/authority/candidate_transfer"
8
+ require "ace/assign/authority/evidence_transfer"
9
+ require_relative "../molecules/protected_service_handler"
10
+ require_relative "../molecules/protected_service_policy"
11
+
12
+ module Ace
13
+ module Lab
14
+ module Organisms
15
+ # Fixed executor-side orchestration. All durable permission and outcome
16
+ # state remains in the existing authority journal. Transport supplies
17
+ # the kernel peer; submitted actor strings are never accepted.
18
+ class ProtectedServiceReceiver
19
+ SUBMISSION = %w[assignment_id attempt_id expected_generation candidate_generation head request_id
20
+ operation input_digest target authorization].freeze
21
+
22
+ def initialize(mapping_id:, service_id:, deployment: Ace::Assign::Authority::Deployment.load,
23
+ kernel: Ace::Runtime::Molecules::ProtectedLinux.new, client: nil,
24
+ handler: Molecules::ProtectedServiceHandler.new)
25
+ @mapping_id, @service_id, @deployment, @kernel, @handler = mapping_id, service_id, deployment, kernel, handler
26
+ @map = deployment.mapping(mapping_id)
27
+ deployment.verify_composition!(@map.fetch("authority_id"), composition: "services")
28
+ @project = deployment.project(@map.fetch("project_id"))
29
+ @receiver = @project.fetch("service_receivers").fetch(service_id)
30
+ @client = client || Ace::Assign::Authority::Client.new(mapping_id: mapping_id, deployment: deployment, kernel: kernel)
31
+ @inputs = Molecules::ProtectedServicePolicy.new(proposal_resolver: ->(*) { raise SecurityError, "proposal decisions belong to authority" })
32
+ end
33
+
34
+ def execute(submission:, peer:, input_bytes:, mutation_id:)
35
+ contacted = false
36
+ request_id = nil
37
+ unless submission.is_a?(Hash) && submission.keys.sort == SUBMISSION.sort &&
38
+ peer.fetch("uid") == @map.fetch("worker_uid") && peer["gid"] == @map.fetch("worker_gid") &&
39
+ peer["groups"] == @map.fetch("worker_groups")
40
+ raise SecurityError, "receiver submission or peer differs"
41
+ end
42
+ params = immutable(submission.merge("service_id" => @service_id, "worker_process_binding" => peer))
43
+ id = params.fetch("request_id")
44
+ unless id.is_a?(String) && id.match?(Ace::Assign::Molecules::JournalMutation::ID)
45
+ raise ArgumentError, "receiver request identity is invalid"
46
+ end
47
+ request_id = id
48
+ peer = params.fetch("worker_process_binding")
49
+ @kernel.live!(peer)
50
+ me = @kernel.capture(Process.pid)
51
+ credentials = @project.fetch("peer_credentials").fetch(@receiver.fetch("executor_uid").to_s)
52
+ unless me["uid"] == @receiver["executor_uid"] && me["gid"] == credentials["gid"] && me["groups"] == credentials["groups"]
53
+ raise SecurityError, "receiver principal differs"
54
+ end
55
+ unless input_bytes.is_a?(String) && mutation_id.is_a?(String) && mutation_id.match?(Ace::Assign::Molecules::JournalMutation::ID)
56
+ raise ArgumentError, "receiver input or mutation identity is invalid"
57
+ end
58
+ bytes = input_bytes.dup.freeze
59
+ input = @inputs.input_binding(bytes, expected_digest: params.fetch("input_digest"), expected_target: params.fetch("target"))
60
+ contacted = true
61
+ claim = @client.call("request_service", params, mutation_id: mutation_id, upload_parts: [bytes], purpose: :service_input)
62
+ unless !claim.replayed && claim.data["claim"] == "created"
63
+ return projection(claim.data)
64
+ end
65
+ binding = params.slice("assignment_id", "attempt_id", "candidate_generation", "head", "request_id")
66
+ exported = @client.call("export_candidate", binding.except("request_id").merge("purpose_id" => params.fetch("request_id")),
67
+ download: true, purpose: :candidate, timeout: 30)
68
+ unless exported.parts.is_a?(Array) && exported.parts.length == 1
69
+ raise SecurityError, "candidate export is incomplete"
70
+ end
71
+ materialized = Ace::Assign::Authority::CandidateTransfer.new(root: @receiver.fetch("staging_root")).materialize(
72
+ bytes: exported.parts.first, sha256: exported.data.fetch("sha256"), size: exported.data.fetch("bytes"),
73
+ head: binding.fetch("head"), tree: exported.data.fetch("tree"), root: @receiver.fetch("staging_root"))
74
+ staging_identity = File.lstat(materialized.fetch("directory"))
75
+ begin_params = binding.merge("claim_binding" => claim.data.fetch("claim_binding"), "expected_generation" => claim.data.fetch("generation"))
76
+ started = @client.call("begin_dispatch", begin_params, mutation_id: Digest::SHA256.hexdigest("begin:#{mutation_id}"), upload_parts: [bytes], purpose: :service_input)
77
+ return projection(started.data) unless !started.replayed && started.data["invocation"] == "permitted"
78
+
79
+ document = Molecules::GrantResolver.trusted_document(Ace::Lab.authorization_path)
80
+ operation = Molecules::ServicePolicy.new(document).operation!(params.fetch("operation"),
81
+ project: @map.fetch("project_id"), service_id: @service_id)
82
+ authorization = @client.call("service_authorization", binding.merge("claim_binding" => claim.data.fetch("claim_binding"),
83
+ "input_digest" => params.fetch("input_digest")), upload_parts: [bytes], purpose: :service_input)
84
+ expected = binding.slice("head", "candidate_generation", "request_id").merge("claim_binding" => claim.data.fetch("claim_binding"),
85
+ "policy_digest" => claim.data.fetch("policy_digest"), "operation_digest" => Ace::Assign::Atoms::EvidenceDigest.digest(operation))
86
+ unless !authorization.replayed && authorization.data == expected
87
+ raise SecurityError, "current authority authorization differs"
88
+ end
89
+ # This successful fresh authority read is the admission point. A
90
+ # later veto cannot retroactively undo it; no read creates permission.
91
+ reloaded = Molecules::ServicePolicy.new(Molecules::GrantResolver.trusted_document(Ace::Lab.authorization_path)).operation!(
92
+ params.fetch("operation"), project: @map.fetch("project_id"), service_id: @service_id)
93
+ unless Ace::Assign::Atoms::EvidenceDigest.digest(reloaded) == expected.fetch("operation_digest")
94
+ raise SecurityError, "local operation changed after admission"
95
+ end
96
+ canonical = params.slice(*Ace::Assign::Molecules::EvidenceJournal::TERMINAL_BINDING_FIELDS, "authorization", "service_id").merge(
97
+ "project_id" => @map.fetch("project_id"), "candidate_head" => binding.fetch("head"),
98
+ "caller_uid" => peer.fetch("uid"), "executor_uid" => @receiver.fetch("executor_uid"), "transport" => "unix")
99
+ envelope = immutable("version" => 1, "request" => canonical, "input" => input.fetch(:input), "execution" => {
100
+ "executor_uid" => @receiver.fetch("executor_uid"), "authority_id" => @map.fetch("authority_id"),
101
+ "claim_binding" => claim.data.fetch("claim_binding"), "candidate_generation" => binding.fetch("candidate_generation"),
102
+ "head" => binding.fetch("head"), "staging_id" => File.basename(materialized.fetch("directory"))})
103
+ response = @handler.execute(operation: immutable(operation), envelope: envelope, candidate_root: materialized.fetch("directory"))
104
+ return uncertain(params.fetch("request_id")) unless response
105
+ artifacts = Ace::Assign::Authority::EvidenceTransfer.read_staged(root: materialized.fetch("directory"), evidence: response.fetch("evidence"))
106
+ receipt = canonical.slice(*Ace::Assign::Molecules::EvidenceJournal::TERMINAL_BINDING_FIELDS).merge(
107
+ "outcome" => response.fetch("outcome"), "evidence" => response.fetch("evidence"))
108
+ receipt_bytes = JSON.generate(receipt)
109
+ completion = @client.call("complete_service", binding.merge("claim_binding" => claim.data.fetch("claim_binding"),
110
+ "receipt_sha256" => Digest::SHA256.hexdigest(receipt_bytes)),
111
+ mutation_id: Digest::SHA256.hexdigest("complete:#{mutation_id}"), upload_parts: [receipt_bytes] + artifacts, purpose: :receipt_artifacts)
112
+ unless completion.data["request_id"] == request_id && %w[succeeded failed].include?(completion.data["state"])
113
+ return uncertain(request_id)
114
+ end
115
+ cleanup_staging(materialized.fetch("directory"), staging_identity)
116
+ projection(completion.data)
117
+ rescue Ace::Assign::Error, Ace::Runtime::RuntimeUnavailableError, Ace::Lab::InvalidConfigurationError,
118
+ SecurityError, ArgumentError, KeyError, SystemCallError
119
+ contacted ? uncertain(request_id) : {"request_id" => request_id, "state" => "refused", "code" => "invalid_receiver_admission"}
120
+ end
121
+
122
+ private
123
+
124
+ def cleanup_staging(directory, identity)
125
+ quarantine = nil
126
+ root = File.expand_path(@receiver.fetch("staging_root"))
127
+ Ace::Assign::Authority::PrivateDirectory.verify!(root)
128
+ current = File.lstat(directory)
129
+ return unless File.dirname(directory) == root && File.basename(directory).start_with?("candidate-") &&
130
+ current.directory? && current.uid == Process.uid && current.dev == identity.dev && current.ino == identity.ino
131
+ # Capture the pathname atomically before checking the object we will
132
+ # delete. A concurrent replacement at the original name must survive.
133
+ quarantine = Dir.mktmpdir(".receiver-cleanup-", root)
134
+ File.chmod(0o700, quarantine)
135
+ captured = File.join(quarantine, "candidate")
136
+ File.rename(directory, captured)
137
+ moved = File.lstat(captured)
138
+ return unless moved.directory? && moved.uid == Process.uid &&
139
+ moved.dev == identity.dev && moved.ino == identity.ino
140
+ FileUtils.remove_entry_secure(captured)
141
+ rescue Ace::Assign::Error, SystemCallError
142
+ # A cleanup failure cannot change already-confirmed canonical truth.
143
+ # Never fall back to cleaning the staging root or another directory.
144
+ nil
145
+ ensure
146
+ if quarantine
147
+ begin
148
+ # Never recursively remove a mismatched captured object.
149
+ Dir.rmdir(quarantine)
150
+ rescue SystemCallError
151
+ nil
152
+ end
153
+ end
154
+ end
155
+
156
+ def projection(data)
157
+ data.slice("request_id", "state", "dispatch_phase", "claim", "generation", "journal_commit")
158
+ end
159
+
160
+ def uncertain(id)
161
+ {"request_id" => id, "state" => "uncertain", "required_action" => "inspect_canonical_service_status_and_record_exact_outcome"}
162
+ end
163
+
164
+ def immutable(value)
165
+ copy = JSON.parse(JSON.generate(value))
166
+ freeze_value(copy)
167
+ end
168
+
169
+ def freeze_value(value)
170
+ value.each_value { |child| freeze_value(child) } if value.is_a?(Hash)
171
+ value.each { |child| freeze_value(child) } if value.is_a?(Array)
172
+ value.freeze
173
+ end
174
+ end
175
+ end
176
+ end
177
+ 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.0"
5
+ VERSION = "0.4.0"
6
6
  end
7
7
  end
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.0
4
+ version: 0.4.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-05 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,14 @@ dependencies:
57
71
  requirements:
58
72
  - - "~>"
59
73
  - !ruby/object:Gem::Version
60
- version: '0.61'
74
+ version: '0.64'
61
75
  type: :runtime
62
76
  prerelease: false
63
77
  version_requirements: !ruby/object:Gem::Requirement
64
78
  requirements:
65
79
  - - "~>"
66
80
  - !ruby/object:Gem::Version
67
- version: '0.61'
81
+ version: '0.64'
68
82
  - !ruby/object:Gem::Dependency
69
83
  name: ace-support-test-helpers
70
84
  requirement: !ruby/object:Gem::Requirement
@@ -189,9 +203,13 @@ files:
189
203
  - lib/ace/lab/molecules/grant_resolver.rb
190
204
  - lib/ace/lab/molecules/hitl_authorizer.rb
191
205
  - lib/ace/lab/molecules/inventory_query.rb
206
+ - lib/ace/lab/molecules/protected_service_handler.rb
207
+ - lib/ace/lab/molecules/protected_service_policy.rb
192
208
  - lib/ace/lab/molecules/service_executor.rb
193
209
  - lib/ace/lab/molecules/service_policy.rb
194
210
  - lib/ace/lab/molecules/topology_loader.rb
211
+ - lib/ace/lab/organisms/authority_composition.rb
212
+ - lib/ace/lab/organisms/protected_service_receiver.rb
195
213
  - lib/ace/lab/organisms/service_request_service.rb
196
214
  - lib/ace/lab/organisms/topology_service.rb
197
215
  - lib/ace/lab/version.rb