ace-assign 0.63.0 → 0.64.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. checksums.yaml +4 -4
  2. data/.ace-defaults/assign/catalog/steps/create-pr.step.yml +2 -2
  3. data/.ace-defaults/assign/catalog/steps/mark-pr-ready.step.yml +1 -1
  4. data/.ace-defaults/assign/catalog/steps/release-minor.step.yml +4 -2
  5. data/.ace-defaults/assign/catalog/steps/release.step.yml +4 -2
  6. data/.ace-defaults/assign/catalog/steps/update-pr-desc.step.yml +1 -1
  7. data/.ace-defaults/assign/presets/fix-bug.yml +15 -3
  8. data/.ace-defaults/assign/presets/work-on-task-ace-development.yml +16 -4
  9. data/.ace-defaults/assign/presets/work-on-task-auto-merge.yml +15 -3
  10. data/.ace-defaults/assign/presets/work-on-task.yml +16 -4
  11. data/CHANGELOG.md +51 -0
  12. data/docs/demo/canonical-skill-source.tape.yml +4 -4
  13. data/docs/protected-authority.md +266 -0
  14. data/docs/usage.md +91 -2
  15. data/handbook/workflow-instructions/assign/drive.wf.md +2 -2
  16. data/handbook/workflow-instructions/assign/prepare.wf.md +2 -2
  17. data/lib/ace/assign/atoms/delivery_parameters.rb +54 -0
  18. data/lib/ace/assign/authority/candidate_transfer.rb +160 -0
  19. data/lib/ace/assign/authority/client.rb +107 -0
  20. data/lib/ace/assign/authority/deployment.rb +465 -0
  21. data/lib/ace/assign/authority/endcap.rb +342 -0
  22. data/lib/ace/assign/authority/endcap_inboxes.rb +314 -0
  23. data/lib/ace/assign/authority/endcap_results.rb +380 -0
  24. data/lib/ace/assign/authority/endcap_services.rb +395 -0
  25. data/lib/ace/assign/authority/evidence_transfer.rb +67 -0
  26. data/lib/ace/assign/authority/execution_scope_observation.rb +476 -0
  27. data/lib/ace/assign/authority/launch_driver.rb +114 -0
  28. data/lib/ace/assign/authority/launch_lifecycle.rb +822 -0
  29. data/lib/ace/assign/authority/launch_scope_abort.rb +88 -0
  30. data/lib/ace/assign/authority/launch_scope_admission.rb +84 -0
  31. data/lib/ace/assign/authority/launch_scope_close.rb +115 -0
  32. data/lib/ace/assign/authority/launch_scope_parent.rb +44 -0
  33. data/lib/ace/assign/authority/launch_scope_release.rb +180 -0
  34. data/lib/ace/assign/authority/posix_acl.rb +56 -0
  35. data/lib/ace/assign/authority/private_directory.rb +33 -0
  36. data/lib/ace/assign/authority/receipt_transfer.rb +48 -0
  37. data/lib/ace/assign/authority/router.rb +54 -0
  38. data/lib/ace/assign/authority/server.rb +230 -0
  39. data/lib/ace/assign/authority/service_evidence.rb +55 -0
  40. data/lib/ace/assign/authority/transfer_codec.rb +155 -0
  41. data/lib/ace/assign/cli/commands/authority/launch.rb +45 -0
  42. data/lib/ace/assign/cli/commands/authority/serve.rb +30 -0
  43. data/lib/ace/assign/cli/commands/authority/status.rb +26 -0
  44. data/lib/ace/assign/cli/commands/authority/terminate.rb +29 -0
  45. data/lib/ace/assign/cli/commands/create.rb +9 -1
  46. data/lib/ace/assign/cli/commands/delivery.rb +57 -0
  47. data/lib/ace/assign/cli/commands/inbox_reconcile.rb +36 -0
  48. data/lib/ace/assign/cli/commands/resume.rb +26 -0
  49. data/lib/ace/assign/cli.rb +22 -1
  50. data/lib/ace/assign/models/evidence_event.rb +3 -1
  51. data/lib/ace/assign/molecules/attempt_reconciler.rb +31 -12
  52. data/lib/ace/assign/molecules/canonical_evidence.rb +150 -0
  53. data/lib/ace/assign/molecules/evidence_journal.rb +218 -63
  54. data/lib/ace/assign/molecules/execution_identity_resolver.rb +49 -9
  55. data/lib/ace/assign/molecules/execution_scope_lineage.rb +327 -0
  56. data/lib/ace/assign/molecules/journal_mutation.rb +202 -0
  57. data/lib/ace/assign/molecules/lifecycle_exclusion.rb +4 -0
  58. data/lib/ace/assign/molecules/proposal_journal.rb +151 -0
  59. data/lib/ace/assign/molecules/receipt_verifier.rb +49 -19
  60. data/lib/ace/assign/molecules/runtime_control_surface_runner.rb +4 -1
  61. data/lib/ace/assign/organisms/assignment_executor.rb +6 -0
  62. data/lib/ace/assign/organisms/attempt_coordinator.rb +312 -13
  63. data/lib/ace/assign/organisms/delivery_coordinator.rb +342 -0
  64. data/lib/ace/assign/organisms/task_assignment_creator.rb +8 -3
  65. data/lib/ace/assign/version.rb +1 -1
  66. data/lib/ace/assign.rb +13 -0
  67. metadata +119 -6
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Assign
5
+ module Authority
6
+ class LaunchLifecycle
7
+ SCOPE_ABORT_FIELDS = %w[kind scope_generation scope_binding_event_id seal_event_id proof_id].freeze
8
+
9
+ # Also used by the shared final release-before-reuse predicate. Calls
10
+ # existing owners at the same immutable CAS input, without impersonating
11
+ # a public evidence-fetch peer or inventing an alternate verifier.
12
+ def scope_settlement_complete!(journal:, events:, params:, map:, commit:)
13
+ if @result_owner
14
+ @result_owner.service_settlement_complete!(journal: journal, events: events, params: params, map: map, commit: commit)
15
+ else
16
+ service_events = events.any? { |event| %w[service_claim service_transition].include?(event["type"]) }
17
+ service_records = journal.service_request_records(commit: commit).any? do |record|
18
+ record["assignment_id"] == params.fetch("assignment_id") && record["attempt_id"] == params.fetch("attempt_id")
19
+ end
20
+ if service_events || service_records
21
+ raise AttemptErrors::EvidenceUnavailable, "canonical service settlement owner is unavailable"
22
+ end
23
+ end
24
+ contexts = @deployment.project(map.fetch("project_id")).fetch("inbox_contexts", {})
25
+ if contexts.any? || events.any? { |event| event["type"].start_with?("inbox_") }
26
+ unless @result_owner && @result_owner.respond_to?(:inbox_settlement_complete!)
27
+ raise AttemptErrors::EvidenceUnavailable, "canonical inbox settlement owner is unavailable"
28
+ end
29
+ @result_owner.inbox_settlement_complete!(journal: journal, events: events, params: params, map: map, commit: commit)
30
+ end
31
+ true
32
+ end
33
+
34
+ private
35
+
36
+ def scope_abort_plan(params, map, events, peer, journal:, commit:, supervisor:)
37
+ bytes = params.fetch("failure_evidence").dup.force_encoding(Encoding::UTF_8)
38
+ raise ArgumentError, "scope failure must be UTF-8 JSON" unless bytes.valid_encoding?
39
+ failure = JSON.parse(bytes, create_additions: false, max_nesting: 8, allow_duplicate_key: false, allow_comments: false)
40
+ strict!(failure, SCOPE_ABORT_FIELDS)
41
+ unless failure["kind"] == "protected_scope_before_release" && failure["scope_generation"].is_a?(Integer) && failure["scope_generation"].positive?
42
+ raise ArgumentError, "scope failure selectors differ"
43
+ end
44
+ %w[scope_binding_event_id seal_event_id proof_id].each do |key|
45
+ value = failure.fetch(key)
46
+ raise ArgumentError, "invalid scope failure selector" unless value.is_a?(String) && value.match?(/\A[0-9a-f]{64}\z/)
47
+ end
48
+ state = states(events)[params.fetch("attempt_id")]
49
+ unless state && state["mapping_id"] == params.fetch("mapping_id") && state["launch_ticket"] == params.fetch("launch_ticket") &&
50
+ (supervisor || @kernel.same?(state.fetch("launcher_identity"), peer))
51
+ raise AttemptErrors::UnauthorizedIdentity, "scope failure is not owned by this launcher"
52
+ end
53
+ chain = events.select { |event| event["attempt_id"] == params.fetch("attempt_id") }
54
+ lineage = Molecules::ExecutionScopeLineage.new(events: chain, project_id: map.fetch("project_id"),
55
+ assignment_id: params.fetch("assignment_id"), attempt_id: params.fetch("attempt_id"), mapping_id: params.fetch("mapping_id"))
56
+ lineage.require_positive!(**failure.reject { |key, _| key == "kind" }.transform_keys(&:to_sym))
57
+ scope_observer_for(params.fetch("mapping_id")).verify_closed!(lineage)
58
+ if chain.any? { |event| event["type"] == "authority_mutation" && event.dig("payload", "operation") == "release_launch" } ||
59
+ state["phase"] == "issued" || state["execution"] == "potentially_executed"
60
+ raise AttemptErrors::Conflict, "scope failure cannot undo issued launch permission"
61
+ end
62
+ scope_settlement_complete!(journal: journal, events: chain, params: params, map: map, commit: commit)
63
+ current = journal.canonical_attempt_state(chain)
64
+ unless %w[reserved running uncertain].include?(current)
65
+ raise AttemptErrors::Conflict, "scope failure cannot change a terminal attempt"
66
+ end
67
+ proof = failure.merge("release" => "not_issued")
68
+ reason = "protected_scope_closed_before_release"
69
+ event = if current == "uncertain"
70
+ {type: "reconciliation", payload: {"resolution" => "failed", "reason" => reason,
71
+ "failure_digest" => params.fetch("failure_digest"), "abort_observation" => proof}}
72
+ else
73
+ # This is the sole reserved -> failed admission. The generic state
74
+ # machine remains unchanged; no synthetic process_start is written.
75
+ {type: "transition", payload: {"from" => current, "to" => "failed", "reason" => reason,
76
+ "abort_observation" => proof}}
77
+ end
78
+ reference = "evidence/imports/launch-failure-#{params.fetch('attempt_id')}-#{params.fetch('failure_digest')}"
79
+ {events: [event], blobs: {reference => params.fetch("failure_evidence")}, data: state.merge(
80
+ "phase" => "failed", "failure_digest" => params.fetch("failure_digest"), "failure_ref" => reference,
81
+ "abort_observation" => proof, "required_action" => nil)}
82
+ rescue JSON::ParserError
83
+ raise ArgumentError, "scope failure JSON is invalid"
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Assign
5
+ module Authority
6
+ class LaunchLifecycle
7
+ # Internal owner call, deliberately absent from the public operation
8
+ # allowlist. The durable winner alone may call the fixed manager. A
9
+ # lost StartUnit reply retains admission uncertainty; replay never starts.
10
+ def admit_native_service!(params:, peer:, role:)
11
+ strict!(params, %w[mapping_id assignment_id attempt_id launch_ticket mutation_id expected_generation])
12
+ unless %i[launcher supervisor].include?(role)
13
+ raise AttemptErrors::UnauthorizedIdentity, "native admission requires an exact launch owner"
14
+ end
15
+ %w[assignment_id attempt_id launch_ticket mutation_id].each { |key| token!(params.fetch(key)) }
16
+ generation!(params.fetch("expected_generation"))
17
+ @kernel.live!(peer)
18
+ map = @deployment.mapping(params.fetch("mapping_id"))
19
+ journal = journal_for(map)
20
+ input = params.reject { |key, _| key == "mutation_id" }
21
+ digest = Digest::SHA256.hexdigest(JSON.generate(canonical(input)))
22
+ with_exclusion(params, map, journal) do
23
+ admit_native_service_held!(params, map, journal, peer, role, digest)
24
+ end
25
+ end
26
+
27
+ private
28
+
29
+ def admit_native_service_held!(params, map, journal, peer, role, digest)
30
+ result = @mutex.synchronize do
31
+ # Visibility is current even on a historical mutation replay.
32
+ current = journal.read_events(params.fetch("assignment_id")).select { |event| event["attempt_id"] == params.fetch("attempt_id") }
33
+ scope_admission_owner!(params, map, current, peer, role)
34
+ journal.mutate(assignment_id: params.fetch("assignment_id"), attempt_id: params.fetch("attempt_id"),
35
+ mutation_id: params.fetch("mutation_id"), operation: "scope_service_admission", parameters_digest: digest,
36
+ expected_generation: params.fetch("expected_generation"), with_replay: true) do |events, _commit, generation|
37
+ state, lineage = scope_admission_owner!(params, map, events, peer, role)
38
+ lineage.require_open!
39
+ unless state["phase"] == "reserved" && !lineage.native_event && !lineage.child_event &&
40
+ events.none? { |event| event["type"] == "authority_mutation" && event.dig("payload", "operation") == "scope_service_admission" }
41
+ raise AttemptErrors::Conflict, "native activation was already admitted or launch advanced"
42
+ end
43
+ observer = scope_observer_for(params.fetch("mapping_id"))
44
+ installation = observer.native_admission_ready!(lineage)
45
+ binding = lineage.binding
46
+ data = state.merge("scope_generation" => binding.fetch("scope_generation"),
47
+ "scope_binding_event_id" => lineage.binding_event.fetch("digest"),
48
+ "service_unit" => map.fetch("execution_scope").fetch("service_unit"), "native_admission" => "issued_uncertain",
49
+ "network_installation" => installation)
50
+ unless installation.is_a?(Hash) && installation["namespace_path"] == map.fetch("execution_scope").fetch("network_namespace_path")
51
+ raise AttemptErrors::EvidenceUnavailable, "network verifier output names another fixed namespace path"
52
+ end
53
+ candidate = Models::EvidenceEvent.build(type: "authority_mutation", attempt_id: params.fetch("attempt_id"),
54
+ payload: {"operation" => "scope_service_admission", "data" => data.merge("generation" => generation + 1)},
55
+ previous_digest: events.last&.fetch("digest"))
56
+ Molecules::ExecutionScopeLineage.new(events: events + [candidate], project_id: map.fetch("project_id"),
57
+ assignment_id: params.fetch("assignment_id"), attempt_id: params.fetch("attempt_id"), mapping_id: params.fetch("mapping_id"))
58
+ {events: [], blobs: {}, data: data}
59
+ end
60
+ end
61
+ # No authority mutex or journal ref lock is held while systemd waits
62
+ # for its private readiness callback. Slot exclusion still prevents
63
+ # another generation, seal or maintenance from racing this start.
64
+ unless result.fetch(:replayed)
65
+ scope_observer_for(params.fetch("mapping_id")).start_admitted_service!
66
+ end
67
+ result
68
+ end
69
+
70
+ def scope_admission_owner!(params, map, events, peer, role)
71
+ state = states(events)[params.fetch("attempt_id")]
72
+ unless state && state["mapping_id"] == params.fetch("mapping_id") &&
73
+ state["launch_ticket"] == params.fetch("launch_ticket") &&
74
+ (role == :supervisor || @kernel.same?(state.fetch("launcher_identity"), peer))
75
+ raise AttemptErrors::UnauthorizedIdentity, "native admission launch origin differs"
76
+ end
77
+ lineage = Molecules::ExecutionScopeLineage.new(events: events, project_id: map.fetch("project_id"),
78
+ assignment_id: params.fetch("assignment_id"), attempt_id: params.fetch("attempt_id"), mapping_id: params.fetch("mapping_id"))
79
+ [state, lineage]
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Assign
5
+ module Authority
6
+ class LaunchLifecycle
7
+ # Source-owned close core. Its first accepted mutation seals admission
8
+ # and caches the retained running reply before any manager stop. A
9
+ # subsequent fresh mutation may record positive observation; replay of
10
+ # the first mutation always keeps its original reply and performs no I/O.
11
+ def close_execution_scope!(params:, peer:, role:)
12
+ strict!(params, %w[mapping_id assignment_id attempt_id mutation_id expected_generation])
13
+ %w[assignment_id attempt_id mutation_id].each { |key| token!(params.fetch(key)) }
14
+ generation!(params.fetch("expected_generation"))
15
+ @kernel.live!(peer)
16
+ map = @deployment.mapping(params.fetch("mapping_id"))
17
+ journal = journal_for(map)
18
+ input = params.reject { |key, _| key == "mutation_id" }
19
+ digest = Digest::SHA256.hexdigest(JSON.generate(canonical(input)))
20
+ with_exclusion(params, map, journal) do
21
+ stop_after_commit = false
22
+ result = @mutex.synchronize do
23
+ current = journal.read_events(params.fetch("assignment_id")).select { |event| event["attempt_id"] == params.fetch("attempt_id") }
24
+ scope_close_owner!(params, map, current, peer, role)
25
+ journal.mutate(assignment_id: params.fetch("assignment_id"), attempt_id: params.fetch("attempt_id"),
26
+ mutation_id: params.fetch("mutation_id"), operation: "close_execution_scope", parameters_digest: digest,
27
+ expected_generation: params.fetch("expected_generation"), with_replay: true) do |events, _commit, _generation|
28
+ lineage = scope_close_owner!(params, map, events, peer, role)
29
+ binding = lineage.binding
30
+ raise AttemptErrors::EvidenceUnavailable, "original parent binding is missing" unless binding
31
+ projection = {"attempt_id" => params.fetch("attempt_id"), "scope_generation" => binding.fetch("scope_generation"),
32
+ "state" => "running", "proof_id" => nil, "required_action" => "close_scope"}
33
+ if !lineage.sealed?
34
+ stop_after_commit = true
35
+ payload = {"scope_generation" => binding.fetch("scope_generation"), "scope_binding_event_id" => lineage.binding_event.fetch("digest")}
36
+ {events: [{type: "scope_sealed", payload: payload}], blobs: {}, data: projection}
37
+ elsif lineage.proof_event
38
+ scope_observer_for(params.fetch("mapping_id")).verify_closed!(lineage)
39
+ {events: [], blobs: {}, data: projection.merge("state" => "closed_no_writers", "proof_id" => lineage.proof_id, "required_action" => nil)}
40
+ elsif scope_observer_for(params.fetch("mapping_id")).sealed_service_stop_required?(lineage)
41
+ # A fresh canonical close resumes a stop lost after the seal.
42
+ # Replaying either mutation performs no manager I/O.
43
+ stop_after_commit = true
44
+ {events: [], blobs: {}, data: projection}
45
+ else
46
+ # This call must return a fresh validated observation, never
47
+ # a cached population, caller JSON or a manifest boolean.
48
+ payload = scope_observer_for(params.fetch("mapping_id")).closed_observation_for_proof!(lineage, events: events)
49
+ expected = binding.slice("scope_generation", "boot_id", "slice_invocation_id", "cgroup_identity").merge(
50
+ "scope_binding_event_id" => lineage.binding_event.fetch("digest"), "seal_event_id" => lineage.seal_event.fetch("digest"), "populated" => 0)
51
+ unless payload == expected
52
+ raise AttemptErrors::EvidenceUnavailable, "fresh observation does not name the retained sealed parent"
53
+ end
54
+ at = Time.now.utc
55
+ event = Models::EvidenceEvent.build(type: "scope_closed_no_writers", attempt_id: params.fetch("attempt_id"),
56
+ payload: payload, previous_digest: events.last&.fetch("digest"), recorded_at: at)
57
+ {events: [{type: "scope_closed_no_writers", payload: payload, recorded_at: at}], blobs: {},
58
+ data: projection.merge("state" => "closed_no_writers", "proof_id" => event.fetch("digest"), "required_action" => nil)}
59
+ end
60
+ end
61
+ end
62
+ if !result.fetch(:replayed) && stop_after_commit
63
+ scope_observer_for(params.fetch("mapping_id")).stop_sealed_service!
64
+ end
65
+ result
66
+ end
67
+ end
68
+
69
+ def observe_execution_scope!(params:, peer:, role:)
70
+ strict!(params, %w[mapping_id assignment_id attempt_id])
71
+ %w[assignment_id attempt_id].each { |key| token!(params.fetch(key)) }
72
+ @kernel.live!(peer)
73
+ map = @deployment.mapping(params.fetch("mapping_id"))
74
+ journal = journal_for(map)
75
+ with_exclusion(params, map, journal) do
76
+ @mutex.synchronize do
77
+ commit = journal.ref_value
78
+ events = journal.read_events(params.fetch("assignment_id"), commit: commit).select { |event| event["attempt_id"] == params.fetch("attempt_id") }
79
+ lineage = scope_close_owner!(params, map, events, peer, role)
80
+ projection = {"attempt_id" => params.fetch("attempt_id"), "scope_generation" => lineage.binding&.fetch("scope_generation"),
81
+ "state" => "unverifiable", "proof_id" => nil, "required_action" => "inspect_exact_scope",
82
+ "generation" => journal.authority_generation(events), "journal_commit" => commit}
83
+ return projection unless lineage.binding
84
+ begin
85
+ observation = scope_observer_for(params.fetch("mapping_id")).observe(lineage)
86
+ if lineage.proof_event
87
+ scope_observer_for(params.fetch("mapping_id")).verify_closed!(lineage)
88
+ projection.merge("state" => "closed_no_writers", "proof_id" => lineage.proof_id, "required_action" => nil)
89
+ elsif lineage.sealed? && observation.fetch("populated").zero?
90
+ projection.merge("required_action" => "close_scope")
91
+ else
92
+ projection.merge("state" => "running", "required_action" => "close_scope")
93
+ end
94
+ rescue Ace::Runtime::RuntimeUnavailableError, AttemptErrors::EvidenceUnavailable
95
+ projection
96
+ end
97
+ end
98
+ end
99
+ end
100
+
101
+ private
102
+
103
+ def scope_close_owner!(params, map, events, peer, role)
104
+ state = states(events)[params.fetch("attempt_id")]
105
+ unless state && state["mapping_id"] == params.fetch("mapping_id") &&
106
+ (role == :supervisor || role == :launcher && @kernel.same?(state.fetch("launcher_identity"), peer))
107
+ raise AttemptErrors::UnauthorizedIdentity, "scope closure requires the original launch owner"
108
+ end
109
+ Molecules::ExecutionScopeLineage.new(events: events, project_id: map.fetch("project_id"),
110
+ assignment_id: params.fetch("assignment_id"), attempt_id: params.fetch("attempt_id"), mapping_id: params.fetch("mapping_id"))
111
+ end
112
+ end
113
+ end
114
+ end
115
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Assign
5
+ module Authority
6
+ class LaunchLifecycle
7
+ private
8
+
9
+ # Called inside the existing slot/assignment exclusion, after the fresh
10
+ # reservation CAS and outside its authority mutex/journal ref lock.
11
+ # Mutation replay and restart never call this activation path.
12
+ def provision_reserved_parent_held!(reservation, map, journal)
13
+ params = reservation.slice("mapping_id", "assignment_id", "attempt_id")
14
+ context = params.merge("project_id" => map.fetch("project_id"),
15
+ "reservation_generation" => reservation.fetch("reservation_generation"),
16
+ "scope_generation" => reservation.fetch("generation") + 1)
17
+ observer = scope_observer_for(params.fetch("mapping_id"))
18
+ binding = observer.activate_parent!(context)
19
+ @mutex.synchronize do
20
+ journal.mutate(assignment_id: params.fetch("assignment_id"), attempt_id: params.fetch("attempt_id"),
21
+ mutation_id: "scope-parent-#{Digest::SHA256.hexdigest(params.fetch('attempt_id'))[0, 48]}",
22
+ operation: "scope_parent_binding", parameters_digest: Digest::SHA256.hexdigest(JSON.generate(canonical(context))),
23
+ expected_generation: reservation.fetch("generation"), with_replay: true) do |events, _commit, _generation|
24
+ state = states(events)[params.fetch("attempt_id")]
25
+ unless state && state["phase"] == "reserved" && state["launch_ticket"] == reservation.fetch("launch_ticket") &&
26
+ events.none? { |event| %w[scope_bound scope_native_bound scope_child_bound scope_sealed].include?(event["type"]) ||
27
+ event["type"] == "authority_mutation" && event.dig("payload", "operation") == "scope_service_admission" }
28
+ raise AttemptErrors::Conflict, "parent activation cannot replace or reopen an existing generation"
29
+ end
30
+ at = Time.now.utc
31
+ event = Models::EvidenceEvent.build(type: "scope_bound", attempt_id: params.fetch("attempt_id"),
32
+ payload: binding, previous_digest: events.last&.fetch("digest"), recorded_at: at)
33
+ lineage = Molecules::ExecutionScopeLineage.new(events: events + [event], project_id: map.fetch("project_id"),
34
+ assignment_id: params.fetch("assignment_id"), attempt_id: params.fetch("attempt_id"), mapping_id: params.fetch("mapping_id"))
35
+ observer.observe(lineage)
36
+ {events: [{type: "scope_bound", payload: binding, recorded_at: at}], blobs: {}, data: state.merge(
37
+ "scope_generation" => binding.fetch("scope_generation"), "scope_binding_event_id" => event.fetch("digest"))}
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,180 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Assign
5
+ module Authority
6
+ class LaunchLifecycle
7
+ # Source-only maintenance entry. Transport stop/drain/inhibition belongs
8
+ # to the trusted installer; this method supplies the exact same locks
9
+ # and canonical owner query, without a live endpoint or another ledger.
10
+ def with_execution_slots(mapping_ids:, &block)
11
+ unless mapping_ids.is_a?(Array) && mapping_ids.size.between?(1, 64) && mapping_ids.uniq == mapping_ids
12
+ raise ArgumentError, "fixed execution slot selections must be unique and bounded"
13
+ end
14
+ maps = mapping_ids.map { |id| token!(id); [id, @deployment.mapping(id)] }
15
+ .sort_by { |_id, map| [map.fetch("authority_id"), map.fetch("execution_scope").fetch("slot_id")] }
16
+ enter = lambda do |offset|
17
+ if offset < maps.size
18
+ with_slot(maps.fetch(offset).last) { enter.call(offset + 1) }
19
+ else
20
+ @mutex.synchronize do
21
+ commits = {}
22
+ contexts = maps.map do |id, map|
23
+ journal = journal_for(map)
24
+ commit = commits.fetch(map.fetch("project_id")) { commits[map.fetch("project_id")] = journal.ref_value }
25
+ {mapping_id: id.freeze, journal: journal, commit: commit&.freeze}.freeze
26
+ end.freeze
27
+ block.call(contexts)
28
+ end
29
+ end
30
+ end
31
+ enter.call(0)
32
+ end
33
+
34
+ def slot_reusable!(mapping_id:, journal:, commit:)
35
+ map = @deployment.mapping(mapping_id)
36
+ require_slot_snapshot!(map, journal, commit)
37
+ ensure_slot_available!(map, journal, commit: commit)
38
+ require_slot_snapshot!(map, journal, commit)
39
+ true
40
+ end
41
+
42
+ def retire_released_parent!(mapping_id:, journal:, commit:)
43
+ slot_reusable!(mapping_id: mapping_id, journal: journal, commit: commit)
44
+ map = @deployment.mapping(mapping_id)
45
+ slot = map.fetch("execution_scope").fetch("slot_id")
46
+ lineages = journal.assignment_ids(commit: commit).flat_map do |assignment_id|
47
+ journal.read_events(assignment_id, commit: commit).group_by { |event| event.fetch("attempt_id") }.filter_map do |attempt_id, events|
48
+ reservation = events.find { |event| event["type"] == "authority_mutation" && event.dig("payload", "operation") == "reserve_attempt" }
49
+ next unless reservation
50
+ prior_id = reservation.dig("payload", "data", "mapping_id")
51
+ next unless @deployment.mapping(prior_id).fetch("execution_scope").fetch("slot_id") == slot
52
+ Molecules::ExecutionScopeLineage.new(events: events, project_id: map.fetch("project_id"),
53
+ assignment_id: assignment_id, attempt_id: attempt_id, mapping_id: prior_id)
54
+ end
55
+ end
56
+ result = scope_observer_for(mapping_id).retire_released_parent!(lineages)
57
+ require_slot_snapshot!(map, journal, commit)
58
+ result
59
+ end
60
+
61
+ # Current connected release producer is the guarded pre-release abort.
62
+ # Finish/stop owners will call the same settlement/proof core in their
63
+ # terminal CAS; those consumer handlers are not this task's ownership.
64
+ def release_scope_reservation!(params:, peer:, role:)
65
+ strict!(params, %w[mapping_id assignment_id attempt_id mutation_id expected_generation])
66
+ %w[assignment_id attempt_id mutation_id].each { |key| token!(params.fetch(key)) }
67
+ generation!(params.fetch("expected_generation"))
68
+ @kernel.live!(peer)
69
+ map = @deployment.mapping(params.fetch("mapping_id"))
70
+ journal = journal_for(map)
71
+ digest = Digest::SHA256.hexdigest(JSON.generate(canonical(params.reject { |key, _| key == "mutation_id" })))
72
+ with_exclusion(params, map, journal) do
73
+ release_scope_reservation_held!(params, map, journal, peer, role, digest)
74
+ end
75
+ end
76
+
77
+ private
78
+
79
+ def release_scope_reservation_held!(params, map, journal, peer, role, digest)
80
+ @mutex.synchronize do
81
+ current = journal.read_events(params.fetch("assignment_id")).select { |event| event["attempt_id"] == params.fetch("attempt_id") }
82
+ scope_close_owner!(params, map, current, peer, role)
83
+ journal.mutate(assignment_id: params.fetch("assignment_id"), attempt_id: params.fetch("attempt_id"),
84
+ mutation_id: params.fetch("mutation_id"), operation: "scope_reservation_release", parameters_digest: digest,
85
+ expected_generation: params.fetch("expected_generation"), with_replay: true) do |events, commit, _generation|
86
+ lineage = scope_close_owner!(params, map, events, peer, role)
87
+ terminal = guarded_scope_abort_receipt!(events, lineage, journal, commit)
88
+ scope_observer_for(params.fetch("mapping_id")).verify_closed!(lineage)
89
+ scope_settlement_complete!(journal: journal, events: events, params: params, map: map, commit: commit)
90
+ if events.any? { |event| event["type"] == "authority_mutation" && event.dig("payload", "operation") == "scope_reservation_release" }
91
+ raise AttemptErrors::Conflict, "reservation is already released"
92
+ end
93
+ {events: [], blobs: {}, data: {"assignment_id" => params.fetch("assignment_id"), "attempt_id" => params.fetch("attempt_id"),
94
+ "mapping_id" => params.fetch("mapping_id"), "scope_generation" => lineage.binding.fetch("scope_generation"),
95
+ "scope_binding_event_id" => lineage.binding_event.fetch("digest"), "proof_id" => lineage.proof_id,
96
+ "terminal_event_id" => terminal.fetch("digest"), "reservation" => "released"}}
97
+ end
98
+ end
99
+ end
100
+
101
+ # A crash after guarded abort must not strand a known-clean reservation.
102
+ # Resume against the current canonical generation under the same slot
103
+ # exclusion; preserve the original immutable public abort reply.
104
+ def resume_scope_release_held!(response, map, journal, peer, role)
105
+ data = response.fetch(:data)
106
+ params = data.slice("mapping_id", "assignment_id", "attempt_id")
107
+ @mutex.synchronize do
108
+ commit = journal.ref_value
109
+ events = journal.read_events(params.fetch("assignment_id"), commit: commit).select { |event| event["attempt_id"] == params.fetch("attempt_id") }
110
+ lineage = scope_close_owner!(params, map, events, peer, role)
111
+ return if scope_reservation_released?(events, lineage, journal, commit)
112
+ params["expected_generation"] = journal.authority_generation(events)
113
+ end
114
+ params["mutation_id"] = "scope-release-#{Digest::SHA256.hexdigest(params.fetch('attempt_id'))[0, 48]}"
115
+ digest = Digest::SHA256.hexdigest(JSON.generate(canonical(params.reject { |key, _| key == "mutation_id" })))
116
+ release_scope_reservation_held!(params, map, journal, peer, role, digest)
117
+ end
118
+
119
+ def require_slot_snapshot!(map, journal, commit)
120
+ key = [object_id, map.fetch("authority_id"), map.fetch("execution_scope").fetch("slot_id")]
121
+ unless Thread.current[:ace_assign_scope_exclusions]&.fetch(key, false) && journal.equal?(journal_for(map)) && journal.ref_value == commit
122
+ raise AttemptErrors::EvidenceUnavailable, "slot query requires its held exclusion and exact current canonical ref"
123
+ end
124
+ end
125
+
126
+ def guarded_scope_abort_receipt!(events, lineage, journal, commit)
127
+ aborts = events.select { |event| event["type"] == "authority_mutation" && event.dig("payload", "operation") == "abort_launch" }
128
+ terminal = aborts.last
129
+ data = terminal&.dig("payload", "data")
130
+ observation = data&.fetch("abort_observation", nil)
131
+ unless data && data["phase"] == "failed" && observation.is_a?(Hash) && observation["release"] == "not_issued" &&
132
+ observation.keys.sort == (SCOPE_ABORT_FIELDS + ["release"]).sort &&
133
+ observation["kind"] == "protected_scope_before_release" && journal.canonical_attempt_state(events) == "failed" &&
134
+ events.none? { |event| event["type"] == "authority_mutation" && event.dig("payload", "operation") == "release_launch" }
135
+ raise AttemptErrors::EvidenceUnavailable, "verified guarded terminal release is unavailable"
136
+ end
137
+ selectors = observation.reject { |key, _| %w[kind release].include?(key) }
138
+ lineage.require_positive!(**selectors.transform_keys(&:to_sym))
139
+ bytes = journal.blob(data.fetch("failure_ref"), commit: commit)
140
+ unless bytes.is_a?(String) && bytes.bytesize.between?(1, 16_384) && Digest::SHA256.hexdigest(bytes) == data.fetch("failure_digest")
141
+ raise AttemptErrors::EvidenceUnavailable, "guarded terminal failure bytes changed"
142
+ end
143
+ failure = JSON.parse(bytes.dup.force_encoding(Encoding::UTF_8), create_additions: false, max_nesting: 8, allow_duplicate_key: false, allow_comments: false)
144
+ unless failure == observation.reject { |key, _| key == "release" }
145
+ raise AttemptErrors::EvidenceUnavailable, "guarded terminal failure selectors changed"
146
+ end
147
+ preceding = events.take_while { |event| event["digest"] != terminal.fetch("digest") }
148
+ transition = preceding.last
149
+ unless transition && %w[transition reconciliation].include?(transition["type"]) &&
150
+ transition.dig("payload", "reason") == "protected_scope_closed_before_release" &&
151
+ (transition.dig("payload", "to") || transition.dig("payload", "resolution")) == "failed" &&
152
+ transition.dig("payload", "abort_observation") == observation
153
+ raise AttemptErrors::EvidenceUnavailable, "guarded terminal transition differs"
154
+ end
155
+ terminal
156
+ rescue KeyError, JSON::ParserError, EncodingError
157
+ raise AttemptErrors::EvidenceUnavailable, "guarded terminal lineage is incomplete"
158
+ end
159
+
160
+ def scope_reservation_released?(events, lineage, journal, commit)
161
+ releases = events.select { |event| event["type"] == "authority_mutation" && event.dig("payload", "operation") == "scope_reservation_release" }
162
+ return false if releases.empty?
163
+ raise AttemptErrors::EvidenceUnavailable, "canonical reservation release is repeated" unless releases.size == 1
164
+ terminal = guarded_scope_abort_receipt!(events, lineage, journal, commit)
165
+ data = releases.first.dig("payload", "data")
166
+ expected = {"assignment_id" => lineage.binding.fetch("assignment_id"), "attempt_id" => lineage.binding.fetch("attempt_id"),
167
+ "mapping_id" => lineage.binding.fetch("mapping_id"), "scope_generation" => lineage.binding.fetch("scope_generation"),
168
+ "scope_binding_event_id" => lineage.binding_event.fetch("digest"), "proof_id" => lineage.proof_id,
169
+ "terminal_event_id" => terminal.fetch("digest"), "reservation" => "released"}
170
+ unless data.is_a?(Hash) && data.reject { |key, _| key == "generation" } == expected &&
171
+ events.index(releases.first) > events.index(terminal) &&
172
+ data["generation"] == events.take(events.index(releases.first) + 1).count { |event| event["type"] == "authority_mutation" }
173
+ raise AttemptErrors::EvidenceUnavailable, "canonical reservation release selectors differ"
174
+ end
175
+ true
176
+ end
177
+ end
178
+ end
179
+ end
180
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+ require "fiddle"
3
+
4
+ module Ace
5
+ module Assign
6
+ module Authority
7
+ # Linux POSIX access ACL evaluation for a fixed unprivileged principal.
8
+ # No credential switching; unsupported or unreadable policy refuses admission.
9
+ class PosixAcl
10
+ def entries(path)
11
+ raise Ace::Runtime::RuntimeUnavailableError, "receiver ACL inspection requires Linux" unless RUBY_PLATFORM.include?("linux")
12
+ function = Fiddle::Function.new(Fiddle::Handle::DEFAULT["lgetxattr"],
13
+ [Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP, Fiddle::TYPE_SIZE_T], Fiddle::TYPE_SSIZE_T)
14
+ buffer = Fiddle::Pointer.malloc(8192, Fiddle::RUBY_FREE)
15
+ count = function.call(path, "system.posix_acl_access", buffer, 8192)
16
+ return nil if count == -1 && Fiddle.last_error == Errno::ENODATA::Errno
17
+ raise Ace::Runtime::RuntimeUnavailableError, "receiver access ACL is unreadable" if count < 4 || (count - 4) % 8 != 0
18
+ bytes = buffer[0, count]
19
+ raise Ace::Runtime::RuntimeUnavailableError, "unknown receiver ACL version" unless bytes.unpack1("L<") == 2
20
+ rows = bytes.byteslice(4..).unpack("S<S<L<" * ((count - 4) / 8)).each_slice(3).to_a
21
+ validate!(rows)
22
+ rows
23
+ rescue Fiddle::DLError
24
+ raise Ace::Runtime::RuntimeUnavailableError, "receiver ACL inspection is unavailable"
25
+ end
26
+
27
+ def searchable?(path, stat:, uid:, groups:)
28
+ rows = entries(path)
29
+ unless rows
30
+ shift = uid == stat.uid ? 6 : (groups.include?(stat.gid) ? 3 : 0)
31
+ return ((stat.mode >> shift) & 1) == 1
32
+ end
33
+ return (rows.find { |tag, _perm, _id| tag == 1 }[1] & 1) == 1 if uid == stat.uid
34
+ mask = rows.find { |tag, _perm, _id| tag == 16 }&.at(1) || 7
35
+ named = rows.find { |tag, _perm, id| tag == 2 && id == uid }
36
+ return (named[1] & mask & 1) == 1 if named
37
+ matched = rows.select { |tag, _perm, id| (tag == 4 && groups.include?(stat.gid)) || (tag == 8 && groups.include?(id)) }
38
+ return matched.any? { |_tag, perm, _id| (perm & mask & 1) == 1 } unless matched.empty?
39
+ (rows.find { |tag, _perm, _id| tag == 32 }[1] & 1) == 1
40
+ end
41
+
42
+ private
43
+
44
+ def validate!(rows)
45
+ valid = rows.all? { |tag, perm, id| [1, 2, 4, 8, 16, 32].include?(tag) && (0..7).cover?(perm) &&
46
+ ([2, 8].include?(tag) ? id != 0xffffffff : id == 0xffffffff) }
47
+ valid &&= [1, 4, 32].all? { |tag| rows.count { |row| row[0] == tag } == 1 }
48
+ valid &&= rows.count { |row| row[0] == 16 } <= 1
49
+ valid &&= [2, 8].all? { |tag| ids = rows.select { |row| row[0] == tag }.map(&:last); ids.uniq == ids }
50
+ valid &&= !rows.any? { |row| [2, 8].include?(row[0]) } || rows.count { |row| row[0] == 16 } == 1
51
+ raise Ace::Runtime::RuntimeUnavailableError, "invalid receiver access ACL" unless valid
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Assign
5
+ module Authority
6
+ module PrivateDirectory
7
+ module_function
8
+
9
+ # Source configuration chooses this path. Each ancestor must prevent
10
+ # another principal from replacing the private leaf or its contents.
11
+ def verify!(root)
12
+ path = File.expand_path(root)
13
+ cursor = path
14
+ loop do
15
+ stat = File.lstat(cursor)
16
+ unless stat.directory? && !stat.symlink? && [0, Process.uid].include?(stat.uid) && (stat.mode & 0022).zero?
17
+ raise AttemptErrors::ReceiptRejected, "Private directory ancestry is not protected"
18
+ end
19
+ break if cursor == "/"
20
+ cursor = File.dirname(cursor)
21
+ end
22
+ stat = File.stat(path)
23
+ unless stat.uid == Process.uid && (stat.mode & 0077).zero?
24
+ raise AttemptErrors::ReceiptRejected, "Private directory must be owned by this peer and private"
25
+ end
26
+ path
27
+ rescue SystemCallError, TypeError
28
+ raise AttemptErrors::ReceiptRejected, "Private directory is unavailable"
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end