ace-assign 0.63.1 → 0.66.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (147) hide show
  1. checksums.yaml +4 -4
  2. data/.ace-defaults/assign/catalog/steps/auto-merge.step.yml +11 -0
  3. data/.ace-defaults/assign/catalog/steps/create-pr.step.yml +2 -2
  4. data/.ace-defaults/assign/catalog/steps/mark-pr-ready.step.yml +1 -1
  5. data/.ace-defaults/assign/catalog/steps/release-minor.step.yml +4 -2
  6. data/.ace-defaults/assign/catalog/steps/release.step.yml +4 -2
  7. data/.ace-defaults/assign/catalog/steps/update-pr-desc.step.yml +1 -1
  8. data/.ace-defaults/assign/presets/fix-bug.yml +15 -3
  9. data/.ace-defaults/assign/presets/work-on-task-ace-development.yml +16 -4
  10. data/.ace-defaults/assign/presets/work-on-task-auto-merge.yml +15 -3
  11. data/.ace-defaults/assign/presets/work-on-task.yml +16 -4
  12. data/CHANGELOG.md +62 -0
  13. data/docs/demo/canonical-skill-source.tape.yml +4 -4
  14. data/docs/protected-authority.md +282 -0
  15. data/docs/usage.md +205 -3
  16. data/exe/ace-scope-ready +55 -0
  17. data/handbook/workflow-instructions/assign/drive.wf.md +28 -3
  18. data/handbook/workflow-instructions/assign/prepare.wf.md +2 -2
  19. data/lib/ace/assign/atoms/attempt_state_machine.rb +8 -0
  20. data/lib/ace/assign/atoms/delivery_parameters.rb +19 -0
  21. data/lib/ace/assign/authority/assignment_inventory.rb +290 -0
  22. data/lib/ace/assign/authority/campaign_consumer_policy.rb +100 -0
  23. data/lib/ace/assign/authority/campaign_execution.rb +119 -0
  24. data/lib/ace/assign/authority/campaign_round_transfer.rb +82 -0
  25. data/lib/ace/assign/authority/candidate_transfer.rb +327 -0
  26. data/lib/ace/assign/authority/cleanup_result_protection.rb +53 -0
  27. data/lib/ace/assign/authority/client.rb +257 -0
  28. data/lib/ace/assign/authority/deployment.rb +627 -0
  29. data/lib/ace/assign/authority/deployment_history.rb +146 -0
  30. data/lib/ace/assign/authority/endcap.rb +455 -0
  31. data/lib/ace/assign/authority/endcap_attempts.rb +274 -0
  32. data/lib/ace/assign/authority/endcap_campaign_results.rb +232 -0
  33. data/lib/ace/assign/authority/endcap_campaign_rounds.rb +266 -0
  34. data/lib/ace/assign/authority/endcap_contexts.rb +23 -0
  35. data/lib/ace/assign/authority/endcap_inboxes.rb +37 -0
  36. data/lib/ace/assign/authority/endcap_prepared_work.rb +80 -0
  37. data/lib/ace/assign/authority/endcap_publication.rb +207 -0
  38. data/lib/ace/assign/authority/endcap_results.rb +398 -0
  39. data/lib/ace/assign/authority/endcap_review_requests.rb +183 -0
  40. data/lib/ace/assign/authority/endcap_reviews.rb +130 -0
  41. data/lib/ace/assign/authority/endcap_service_settlement.rb +152 -0
  42. data/lib/ace/assign/authority/endcap_services.rb +598 -0
  43. data/lib/ace/assign/authority/evidence_transfer.rb +67 -0
  44. data/lib/ace/assign/authority/execution_scope_observation.rb +903 -0
  45. data/lib/ace/assign/authority/inbox_context_original.rb +113 -0
  46. data/lib/ace/assign/authority/launch_attempt_consumers.rb +51 -0
  47. data/lib/ace/assign/authority/launch_campaign_registration.rb +214 -0
  48. data/lib/ace/assign/authority/launch_control_channel.rb +313 -0
  49. data/lib/ace/assign/authority/launch_control_exclusions.rb +141 -0
  50. data/lib/ace/assign/authority/launch_driver.rb +393 -0
  51. data/lib/ace/assign/authority/launch_input_inhibition.rb +132 -0
  52. data/lib/ace/assign/authority/launch_lifecycle.rb +997 -0
  53. data/lib/ace/assign/authority/launch_prepared_work.rb +122 -0
  54. data/lib/ace/assign/authority/launch_review.rb +91 -0
  55. data/lib/ace/assign/authority/launch_scope_abort.rb +81 -0
  56. data/lib/ace/assign/authority/launch_scope_admission.rb +215 -0
  57. data/lib/ace/assign/authority/launch_scope_close.rb +146 -0
  58. data/lib/ace/assign/authority/launch_scope_parent.rb +44 -0
  59. data/lib/ace/assign/authority/launch_scope_release.rb +859 -0
  60. data/lib/ace/assign/authority/launch_steering.rb +353 -0
  61. data/lib/ace/assign/authority/launch_stop.rb +254 -0
  62. data/lib/ace/assign/authority/maintenance_workspace_retirement.rb +229 -0
  63. data/lib/ace/assign/authority/original_inbox_selection.rb +41 -0
  64. data/lib/ace/assign/authority/posix_acl.rb +59 -0
  65. data/lib/ace/assign/authority/prepared_input.rb +130 -0
  66. data/lib/ace/assign/authority/prepared_queue.rb +185 -0
  67. data/lib/ace/assign/authority/prepared_task_context.rb +116 -0
  68. data/lib/ace/assign/authority/prepared_work.rb +304 -0
  69. data/lib/ace/assign/authority/prepared_worker.rb +69 -0
  70. data/lib/ace/assign/authority/private_directory.rb +33 -0
  71. data/lib/ace/assign/authority/protected_assignment_context.rb +226 -0
  72. data/lib/ace/assign/authority/readiness_hook.rb +120 -0
  73. data/lib/ace/assign/authority/receipt_transfer.rb +48 -0
  74. data/lib/ace/assign/authority/router.rb +76 -0
  75. data/lib/ace/assign/authority/server.rb +300 -0
  76. data/lib/ace/assign/authority/service_cleanup_dispatch.rb +88 -0
  77. data/lib/ace/assign/authority/service_cleanup_evidence.rb +180 -0
  78. data/lib/ace/assign/authority/service_delivery_evidence.rb +101 -0
  79. data/lib/ace/assign/authority/service_evidence.rb +286 -0
  80. data/lib/ace/assign/authority/service_publication_evidence.rb +110 -0
  81. data/lib/ace/assign/authority/task_context_entry.rb +32 -0
  82. data/lib/ace/assign/authority/transfer_codec.rb +187 -0
  83. data/lib/ace/assign/cli/commands/assignment_target.rb +15 -2
  84. data/lib/ace/assign/cli/commands/attempt/base.rb +49 -0
  85. data/lib/ace/assign/cli/commands/attempt/finish.rb +20 -4
  86. data/lib/ace/assign/cli/commands/attempt/reconcile.rb +11 -0
  87. data/lib/ace/assign/cli/commands/authority/inbox_context_principal.rb +25 -0
  88. data/lib/ace/assign/cli/commands/authority/inbox_context_selection.rb +28 -0
  89. data/lib/ace/assign/cli/commands/authority/launch.rb +97 -0
  90. data/lib/ace/assign/cli/commands/authority/serve.rb +31 -0
  91. data/lib/ace/assign/cli/commands/authority/status.rb +26 -0
  92. data/lib/ace/assign/cli/commands/authority/task_context.rb +31 -0
  93. data/lib/ace/assign/cli/commands/authority/task_context_principal.rb +25 -0
  94. data/lib/ace/assign/cli/commands/authority/task_context_selection.rb +30 -0
  95. data/lib/ace/assign/cli/commands/authority/terminate.rb +29 -0
  96. data/lib/ace/assign/cli/commands/authority/worker.rb +23 -0
  97. data/lib/ace/assign/cli/commands/campaign_export_result.rb +55 -0
  98. data/lib/ace/assign/cli/commands/campaign_record_round.rb +57 -0
  99. data/lib/ace/assign/cli/commands/create.rb +11 -1
  100. data/lib/ace/assign/cli/commands/delivery.rb +107 -0
  101. data/lib/ace/assign/cli/commands/fail.rb +6 -0
  102. data/lib/ace/assign/cli/commands/finish.rb +23 -2
  103. data/lib/ace/assign/cli/commands/fork_session.rb +6 -2
  104. data/lib/ace/assign/cli/commands/inbox_bind.rb +34 -0
  105. data/lib/ace/assign/cli/commands/protected_submission.rb +61 -0
  106. data/lib/ace/assign/cli/commands/resume.rb +38 -0
  107. data/lib/ace/assign/cli/commands/select.rb +2 -0
  108. data/lib/ace/assign/cli/commands/start.rb +2 -0
  109. data/lib/ace/assign/cli/commands/status.rb +4 -2
  110. data/lib/ace/assign/cli/commands/step.rb +3 -1
  111. data/lib/ace/assign/cli/commands/submit_candidate.rb +34 -0
  112. data/lib/ace/assign/cli/commands/submit_result.rb +61 -0
  113. data/lib/ace/assign/cli.rb +56 -4
  114. data/lib/ace/assign/errors.rb +139 -0
  115. data/lib/ace/assign/models/assignment.rb +26 -4
  116. data/lib/ace/assign/models/evidence_event.rb +4 -2
  117. data/lib/ace/assign/molecules/assignment_manager.rb +12 -2
  118. data/lib/ace/assign/molecules/attempt_reconciler.rb +51 -12
  119. data/lib/ace/assign/molecules/canonical_attempt_state.rb +151 -0
  120. data/lib/ace/assign/molecules/canonical_evidence.rb +153 -0
  121. data/lib/ace/assign/molecules/canonical_read_snapshot.rb +374 -0
  122. data/lib/ace/assign/molecules/evidence_journal.rb +922 -91
  123. data/lib/ace/assign/molecules/execution_identity_resolver.rb +49 -9
  124. data/lib/ace/assign/molecules/execution_scope_lineage.rb +345 -0
  125. data/lib/ace/assign/molecules/fork_session_launcher.rb +16 -6
  126. data/lib/ace/assign/molecules/journal_input_inhibition.rb +78 -0
  127. data/lib/ace/assign/molecules/journal_mutation.rb +271 -0
  128. data/lib/ace/assign/molecules/journal_prompt_mutation.rb +241 -0
  129. data/lib/ace/assign/molecules/lifecycle_exclusion.rb +182 -26
  130. data/lib/ace/assign/molecules/original_launch_binding.rb +37 -0
  131. data/lib/ace/assign/molecules/proposal_journal.rb +151 -0
  132. data/lib/ace/assign/molecules/protected_control_exclusion.rb +303 -0
  133. data/lib/ace/assign/molecules/protected_workspace_exclusion.rb +330 -0
  134. data/lib/ace/assign/molecules/protected_workspace_provisioner.rb +164 -0
  135. data/lib/ace/assign/molecules/queue_scanner.rb +26 -11
  136. data/lib/ace/assign/molecules/receipt_verifier.rb +68 -32
  137. data/lib/ace/assign/molecules/step_writer.rb +27 -5
  138. data/lib/ace/assign/molecules/terminal_scope_receipt.rb +88 -0
  139. data/lib/ace/assign/organisms/assignment_executor.rb +10 -4
  140. data/lib/ace/assign/organisms/attempt_coordinator.rb +302 -13
  141. data/lib/ace/assign/organisms/delivery_coordinator.rb +342 -0
  142. data/lib/ace/assign/organisms/prepared_work_builder.rb +194 -0
  143. data/lib/ace/assign/organisms/protected_delivery_coordinator.rb +100 -0
  144. data/lib/ace/assign/organisms/task_assignment_creator.rb +11 -4
  145. data/lib/ace/assign/version.rb +1 -1
  146. data/lib/ace/assign.rb +4 -102
  147. metadata +186 -8
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+ require_relative "../atoms/evidence_digest"
3
+
4
+ module Ace
5
+ module Assign
6
+ module Authority
7
+ class LaunchLifecycle
8
+ # One authenticated immutable prefix; never the latest registration.
9
+ def original_prepared_registration!(journal:, params:, commit:)
10
+ original_prepared_registration_for_phase!(journal: journal, params: params, commit: commit, phase: :issued) do |map, state|
11
+ yield(map, state) if block_given?
12
+ end
13
+ end
14
+
15
+ private
16
+
17
+ def original_bound_prepared_registration!(journal:, params:, commit:)
18
+ original_prepared_registration_for_phase!(journal: journal, params: params, commit: commit, phase: :bound)
19
+ end
20
+
21
+ def original_prepared_registration_for_phase!(journal:, params:, commit:, phase:)
22
+ inventory = journal.canonical_event_inventory!(commit: commit)
23
+ raise AttemptErrors::EvidenceUnavailable, "prepared canonical snapshot differs" unless inventory.fetch("commit") == commit
24
+ assignment = params.fetch("assignment_id")
25
+ events = inventory.fetch("events").fetch(assignment)
26
+ chain = events.select { |event| event["attempt_id"] == params.fetch("attempt_id") }
27
+ state = origin(chain, **params.slice("assignment_id", "attempt_id", "mapping_id").transform_keys(&:to_sym))
28
+ reserves = chain.select { |event| event["type"] == "authority_mutation" && event.dig("payload", "operation") == "reserve_attempt" }
29
+ releases = chain.select { |event| event["type"] == "authority_mutation" && event.dig("payload", "operation") == "release_launch" }
30
+ provisioning = chain.select { |event| event["type"] == "scope_provisioning" }
31
+ admitted_phase = if phase == :issued
32
+ releases.one? && releases.first.dig("payload", "data", "phase") == "issued"
33
+ else
34
+ releases.empty? && state.fetch("phase") == "bound"
35
+ end
36
+ unless reserves.one? && admitted_phase && provisioning.one? &&
37
+ !terminal_events?(chain) && !chain.any? { |event| %w[scope_sealed input_inhibited attempt_stopped].include?(event["type"]) }
38
+ raise AttemptErrors::EvidenceUnavailable, "original prepared attempt is not #{phase} and open"
39
+ end
40
+ identity = provisioning.first.fetch("payload")
41
+ sha = identity.fetch("descriptor_sha256")
42
+ selected = if @deployment.artifact_reference.fetch("sha256") == sha
43
+ @deployment
44
+ else
45
+ raise AttemptErrors::EvidenceUnavailable, "original prepared deployment is unavailable" unless @deployment_history
46
+ @deployment_history.descriptor!(sha256: sha)
47
+ end
48
+ map = selected.mapping(params.fetch("mapping_id"))
49
+ original_project = selected.project(map.fetch("project_id"))
50
+ unless identity.fetch("deployment_digest") == Digest::SHA256.hexdigest(JSON.generate(canonical(map))) &&
51
+ identity.fetch("slot_id") == map.fetch("execution_scope").fetch("slot_id") && state.fetch("project_id") == map.fetch("project_id") &&
52
+ selected.project(map.fetch("project_id")).values_at("journal_repository", "evidence_git_ref", "evidence_checkout_root") ==
53
+ [journal.repo_root, journal.ref, journal.checkout_root]
54
+ raise AttemptErrors::EvidenceUnavailable, "original prepared deployment association differs"
55
+ end
56
+ lineage = Molecules::ExecutionScopeLineage.new(events: chain, project_id: state.fetch("project_id"),
57
+ assignment_id: assignment, attempt_id: params.fetch("attempt_id"), mapping_id: params.fetch("mapping_id"))
58
+ unless lineage.require_launch_bound! == state.fetch("process_binding")
59
+ raise AttemptErrors::EvidenceUnavailable, "original prepared child binding differs"
60
+ end
61
+ original = original_prompt_record!(chain, state, params: params)
62
+ # Authenticate the actual original worker/descendant before reading artifacts.
63
+ yield(map, state) if block_given?
64
+ introductions = inventory.fetch("introductions").fetch(assignment)
65
+ reservation_commit = introductions.fetch(reserves.first.fetch("digest"))
66
+ registration = definition(journal, assignment, commit: reservation_commit)
67
+ original_events = journal.read_events(assignment, commit: reservation_commit)
68
+ authenticate_inventory_registration!(journal, assignment, original_events, registration, params.fetch("mapping_id"), map, reservation_commit, introductions)
69
+ registered = original_events.find { |event| event["type"] == "authority_mutation" && event.dig("payload", "operation") == "register_assignment" && event.dig("payload", "data") == registration }
70
+ registration_commit = introductions.fetch(registered.fetch("digest"))
71
+ TaskContextEntry.validate!(registration.fetch("task_context_entry"))
72
+ unless registration.fetch("task_context_entry") == map.fetch("task_context_entry")
73
+ raise AttemptErrors::EvidenceUnavailable, "original prepared task context entry differs"
74
+ end
75
+ prepared = registration.fetch("prepared_work")
76
+ unless prepared.fetch("scope") == state.fetch("scope") && prepared.fetch("task_id") == state.fetch("task_id")
77
+ raise AttemptErrors::EvidenceUnavailable, "original prepared selection differs"
78
+ end
79
+ bytes = journal.bounded_blob(registration.fetch("definition_ref"), commit: registration_commit, max_bytes: PreparedWork::MAX_MANIFEST)
80
+ unless bytes.is_a?(String) && bytes.bytesize.between?(1, PreparedWork::MAX_MANIFEST) &&
81
+ Digest::SHA256.hexdigest(bytes) == registration.fetch("definition_digest") && JSON.parse(bytes).fetch("prepared_work") == prepared
82
+ raise AttemptErrors::EvidenceUnavailable, "original prepared definition differs"
83
+ end
84
+ projection = immutable_maintenance_projection(registration: registration, registration_commit: registration_commit, state: state, map: map, events: chain,
85
+ workspace_exclusion: maintenance_scope_observer_for(selected, params.fetch("mapping_id")).workspace_exclusion_projection!(lineage),
86
+ original_worker_scratch_root: original_project.fetch("peer_credentials").fetch(map.fetch("worker_uid").to_s).fetch("scratch_root"),
87
+ original_binding_digest: original.fetch("binding_digest"), commit: commit)
88
+ pin = compact_prepared_input(projection)
89
+ if phase == :issued
90
+ unless Atoms::EvidenceDigest.digest(releases.first.dig("payload", "data", "prepared_input")) == Atoms::EvidenceDigest.digest(pin)
91
+ raise AttemptErrors::EvidenceUnavailable, "accepted prepared release differs"
92
+ end
93
+ else
94
+ bundle = journal.bounded_blob(registration.fetch("prepared_bundle_ref"), commit: registration_commit, max_bytes: CandidateTransfer::MAX_BYTES)
95
+ unless bundle.is_a?(String) && bundle.bytesize == registration.fetch("prepared_bundle_bytes") &&
96
+ bundle.bytesize.between?(1, CandidateTransfer::MAX_BYTES) && Digest::SHA256.hexdigest(bundle) == registration.fetch("prepared_bundle_sha256")
97
+ raise AttemptErrors::EvidenceUnavailable, "original prepared bundle differs"
98
+ end
99
+ end
100
+ projection
101
+ rescue KeyError, TypeError, NoMethodError, JSON::ParserError
102
+ raise AttemptErrors::EvidenceUnavailable, "original prepared registration is unavailable"
103
+ end
104
+
105
+ def compact_prepared_input(projection)
106
+ registration = projection.fetch(:registration)
107
+ immutable_maintenance_projection(
108
+ "registration_generation" => registration.fetch("generation"),
109
+ "registration_commit" => projection.fetch(:registration_commit),
110
+ "definition_digest" => registration.fetch("definition_digest"),
111
+ "original_binding_digest" => projection.fetch(:original_binding_digest),
112
+ "prepared_work" => registration.fetch("prepared_work"),
113
+ "worker_entry" => Ace::Runtime::Molecules::ProtectedWorkerEntry.validate!(projection.fetch(:map).fetch("worker_entry")),
114
+ "workspace_exclusion" => projection.fetch(:workspace_exclusion),
115
+ "bundle_ref" => registration.fetch("prepared_bundle_ref"),
116
+ "bundle_bytes" => registration.fetch("prepared_bundle_bytes"),
117
+ "bundle_sha256" => registration.fetch("prepared_bundle_sha256"))
118
+ end
119
+ end
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Assign
5
+ module Authority
6
+ class LaunchLifecycle
7
+ # Called by Endcap only while holding the existing assignment/slot
8
+ # exclusion and shared authority mutex. Returned channel capacity is
9
+ # released after the caller leaves those locks and waits on the stream.
10
+ def reserve_review_dispatch!(events:, params:, map:)
11
+ state = origin(events, **params.slice("mapping_id", "assignment_id", "attempt_id").transform_keys(&:to_sym))
12
+ unless state["phase"] == "issued" && !terminal_events?(events)
13
+ raise AttemptErrors::Conflict, "review delegation requires an issued original launcher"
14
+ end
15
+ scope_open_for_effect!(events: events, params: params, map: map)
16
+ original = original_prompt_record!(events, state, params: params)
17
+ @kernel.live!(state.fetch("launcher_identity"))
18
+ channel = @control_channels[steering_key(params, map)]
19
+ raise AttemptErrors::EvidenceUnavailable, "original review channel is unavailable" unless channel
20
+ [channel, channel.reserve_dispatch!, original]
21
+ end
22
+
23
+ def review_original_reference!(events:, params:)
24
+ state = origin(events, **params.slice("mapping_id", "assignment_id", "attempt_id").transform_keys(&:to_sym))
25
+ original_prompt_record!(events, state, params: params)
26
+ end
27
+
28
+ # Read-only original-launcher projection at the exact request commit.
29
+ # Permission to assign still belongs to Endcap's fresh/replay checks.
30
+ def launch_review_intent!(request:, peer:, role:)
31
+ params = request.fetch("params")
32
+ strict!(params, %w[mapping_id assignment_id attempt_id mutation_id request_event_id journal_commit original_binding_digest])
33
+ raise ArgumentError, "Review intent lookup is read-only" unless request.fetch("mutation_id").nil?
34
+ raise AttemptErrors::UnauthorizedIdentity, "Review intent requires original launcher" unless role == :launcher
35
+ %w[mapping_id assignment_id attempt_id mutation_id].each { |key| token!(params.fetch(key)) }
36
+ unless %w[request_event_id original_binding_digest].all? { |key| params[key].is_a?(String) && params[key].match?(/\A[0-9a-f]{64}\z/) } &&
37
+ params["journal_commit"].is_a?(String) && params["journal_commit"].match?(/\A(?:[0-9a-f]{40}|[0-9a-f]{64})\z/)
38
+ raise ArgumentError, "Review intent selectors differ"
39
+ end
40
+ map = steering_principal!(params, peer, role)
41
+ raise AttemptErrors::UnauthorizedIdentity, "Review project differs" unless request.fetch("project_id") == map.fetch("project_id")
42
+ journal = journal_for(map)
43
+ commit = params.fetch("journal_commit")
44
+ journal.verify_canonical_prefix!(commit: commit)
45
+ events = journal.read_events(params.fetch("assignment_id"), commit: commit).select { |event| event["attempt_id"] == params.fetch("attempt_id") }
46
+ state = origin(events, **params.slice("mapping_id", "assignment_id", "attempt_id").transform_keys(&:to_sym))
47
+ unless @kernel.same?(state.fetch("launcher_identity"), peer)
48
+ raise AttemptErrors::UnauthorizedIdentity, "Review intent requires exact original launcher birth"
49
+ end
50
+ original = original_prompt_record!(events, state, params: params)
51
+ event = events.find { |item| item["digest"] == params.fetch("request_event_id") }
52
+ accepted = journal.mutation_result(params.fetch("mutation_id"), commit: commit)
53
+ unless event && event["type"] == "authority_mutation" && event.dig("payload", "operation") == "request_review" &&
54
+ event.dig("payload", "mutation_id") == params.fetch("mutation_id") && accepted &&
55
+ accepted == event.fetch("payload").merge("journal_commit" => commit) &&
56
+ original.fetch("binding_digest") == params.fetch("original_binding_digest")
57
+ raise AttemptErrors::EvidenceUnavailable, "Canonical review request introduction differs"
58
+ end
59
+ data = event.fetch("payload").fetch("data")
60
+ fields = %w[candidate_generation generation head original_binding_digest requester reviewer_process_binding reviewer_uid]
61
+ unless data.is_a?(Hash) && data.keys.sort == fields && data["original_binding_digest"] == original.fetch("binding_digest") &&
62
+ data["head"].is_a?(String) && data["head"].match?(/\A(?:[0-9a-f]{40}|[0-9a-f]{64})\z/) &&
63
+ %w[candidate_generation generation reviewer_uid].all? { |key| data[key].is_a?(Integer) && data[key].positive? }
64
+ raise AttemptErrors::EvidenceUnavailable, "Canonical review request data differs"
65
+ end
66
+ identity = data.fetch("reviewer_process_binding")
67
+ boot = identity.is_a?(Hash) && identity["started_at"].is_a?(String) && identity["started_at"].split(":", -1)[1]
68
+ Molecules::ExecutionScopeLineage.validate_process_identity!(identity, boot_id: boot)
69
+ requester = data["requester"]
70
+ unless requester.is_a?(Hash) && requester.keys.sort == %w[gid groups role uid] &&
71
+ %w[uid gid].all? { |key| requester[key].is_a?(Integer) } && requester["groups"].is_a?(Array) &&
72
+ requester["groups"].all? { |group| group.is_a?(Integer) } &&
73
+ requester == identity.slice("uid", "gid", "groups").merge("role" => "reviewer") &&
74
+ data["reviewer_uid"] == identity["uid"] && identity["uid"] != map.fetch("worker_uid")
75
+ raise AttemptErrors::EvidenceUnavailable, "Canonical reviewer attribution differs"
76
+ end
77
+ candidate = events.reverse.find { |item| item["type"] == "authority_mutation" && item.dig("payload", "operation") == "submit_candidate" }&.dig("payload", "data")
78
+ unless candidate && candidate.values_at("head", "candidate_generation") == data.values_at("head", "candidate_generation")
79
+ raise AttemptErrors::EvidenceUnavailable, "Canonical request candidate differs"
80
+ end
81
+ assignment = params.slice("mapping_id", "assignment_id", "attempt_id").merge(
82
+ data.slice("head", "candidate_generation", "reviewer_uid", "reviewer_process_binding"),
83
+ "expected_generation" => data.fetch("generation"))
84
+ {data: params.slice("request_event_id", "original_binding_digest").merge("assignment_params" => assignment), replayed: false}
85
+ rescue KeyError, TypeError
86
+ raise AttemptErrors::EvidenceUnavailable, "Canonical review intent is incomplete"
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,81 @@
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
+ true
25
+ end
26
+
27
+ private
28
+
29
+ def scope_abort_plan(params, map, events, peer, journal:, commit:, supervisor:)
30
+ bytes = params.fetch("failure_evidence").dup.force_encoding(Encoding::UTF_8)
31
+ raise ArgumentError, "scope failure must be UTF-8 JSON" unless bytes.valid_encoding?
32
+ failure = JSON.parse(bytes, create_additions: false, max_nesting: 8, allow_duplicate_key: false, allow_comments: false)
33
+ strict!(failure, SCOPE_ABORT_FIELDS)
34
+ unless failure["kind"] == "protected_scope_before_release" && failure["scope_generation"].is_a?(Integer) && failure["scope_generation"].positive?
35
+ raise ArgumentError, "scope failure selectors differ"
36
+ end
37
+ %w[scope_binding_event_id seal_event_id proof_id].each do |key|
38
+ value = failure.fetch(key)
39
+ raise ArgumentError, "invalid scope failure selector" unless value.is_a?(String) && value.match?(/\A[0-9a-f]{64}\z/)
40
+ end
41
+ state = states(events)[params.fetch("attempt_id")]
42
+ unless state && state["mapping_id"] == params.fetch("mapping_id") && state["launch_ticket"] == params.fetch("launch_ticket") &&
43
+ (supervisor || @kernel.same?(state.fetch("launcher_identity"), peer))
44
+ raise AttemptErrors::UnauthorizedIdentity, "scope failure is not owned by this launcher"
45
+ end
46
+ chain = events.select { |event| event["attempt_id"] == params.fetch("attempt_id") }
47
+ lineage = Molecules::ExecutionScopeLineage.new(events: chain, project_id: map.fetch("project_id"),
48
+ assignment_id: params.fetch("assignment_id"), attempt_id: params.fetch("attempt_id"), mapping_id: params.fetch("mapping_id"))
49
+ lineage.require_positive!(**failure.reject { |key, _| key == "kind" }.transform_keys(&:to_sym))
50
+ scope_observer_for(params.fetch("mapping_id")).verify_closed!(lineage)
51
+ if chain.any? { |event| event["type"] == "authority_mutation" && event.dig("payload", "operation") == "release_launch" } ||
52
+ state["phase"] == "issued" || state["execution"] == "potentially_executed"
53
+ raise AttemptErrors::Conflict, "scope failure cannot undo issued launch permission"
54
+ end
55
+ scope_settlement_complete!(journal: journal, events: chain, params: params, map: map, commit: commit)
56
+ current = journal.canonical_attempt_state(chain)
57
+ unless %w[reserved running uncertain].include?(current)
58
+ raise AttemptErrors::Conflict, "scope failure cannot change a terminal attempt"
59
+ end
60
+ proof = failure.merge("release" => "not_issued")
61
+ reason = "protected_scope_closed_before_release"
62
+ event = if current == "uncertain"
63
+ {type: "reconciliation", payload: {"resolution" => "failed", "reason" => reason,
64
+ "failure_digest" => params.fetch("failure_digest"), "abort_observation" => proof}}
65
+ else
66
+ # This is the sole reserved -> failed admission. The generic state
67
+ # machine remains unchanged; no synthetic process_start is written.
68
+ {type: "transition", payload: {"from" => current, "to" => "failed", "reason" => reason,
69
+ "abort_observation" => proof}}
70
+ end
71
+ reference = "evidence/imports/launch-failure-#{params.fetch('attempt_id')}-#{params.fetch('failure_digest')}"
72
+ {events: [event], blobs: {reference => params.fetch("failure_evidence")}, data: state.merge(
73
+ "phase" => "failed", "failure_digest" => params.fetch("failure_digest"), "failure_ref" => reference,
74
+ "abort_observation" => proof, "required_action" => nil)}
75
+ rescue JSON::ParserError
76
+ raise ArgumentError, "scope failure JSON is invalid"
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,215 @@
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
+ result = with_exclusion(params, map, journal) do
23
+ admit_native_service_held!(params, map, journal, peer, role, digest)
24
+ end
25
+ complete_native_start!(params, map, journal, peer, role, result) unless result.fetch(:replayed)
26
+ result
27
+ ensure
28
+ settle_native_issuer!(params, map) if map
29
+ end
30
+
31
+ # Private transport owner. Exact manager hook attribution precedes
32
+ # challenge issuance; this never enters the ordinary worker dispatcher.
33
+ def native_readiness!(mapping_id:, peer:, socket:, codec:, deadline:)
34
+ map = @deployment.mapping(mapping_id)
35
+ journal = journal_for(map)
36
+ pending, key = @mutex.synchronize do
37
+ selected = @native_issuers.select { |identity, _| identity[0] == map.fetch("project_id") && identity[1] == mapping_id }
38
+ raise AttemptErrors::EvidenceUnavailable, "no unique live admitted issuer" unless selected.size == 1
39
+ identity, entry = selected.first
40
+ raise AttemptErrors::Conflict, "readiness already supplied" if entry[:report] || entry[:challenge]
41
+ entry[:challenge] = SecureRandom.hex(32)
42
+ [entry, identity]
43
+ end
44
+ params = pending.fetch(:params)
45
+ observer = scope_observer_for(mapping_id)
46
+ challenge = with_exclusion(params, map, journal) do
47
+ @mutex.synchronize do
48
+ events = journal.read_events(params.fetch("assignment_id")).select { |event| event["attempt_id"] == params.fetch("attempt_id") }
49
+ lineage = Molecules::ExecutionScopeLineage.new(events: events, project_id: map.fetch("project_id"),
50
+ assignment_id: params.fetch("assignment_id"), attempt_id: params.fetch("attempt_id"), mapping_id: mapping_id)
51
+ lineage.require_open!
52
+ server = observer.readiness_peer!(lineage, peer)
53
+ {"version" => 1, "challenge_id" => pending.fetch(:challenge), "server_identity" => server}
54
+ end
55
+ end
56
+ wire.write(socket, challenge, deadline: deadline, limit: 16_384)
57
+ envelope = wire.read(socket, deadline: deadline, limit: 16_384)
58
+ unless envelope.is_a?(Hash) && envelope.keys.sort == %w[challenge_id transfer version] &&
59
+ envelope["version"] == 1 && envelope["challenge_id"] == challenge.fetch("challenge_id")
60
+ raise ArgumentError, "private readiness upload envelope differs"
61
+ end
62
+ codec.receive(socket, descriptor: envelope.fetch("transfer"), purpose: :scope_boundary_observation,
63
+ deadline: deadline) do |input|
64
+ bytes = input.bytes.force_encoding(Encoding::UTF_8)
65
+ raise ArgumentError, "readiness observation is not UTF-8" unless bytes.valid_encoding?
66
+ report = JSON.parse(bytes, create_additions: false, max_nesting: 12, allow_duplicate_key: false, allow_comments: false)
67
+ with_exclusion(params, map, journal) do
68
+ @mutex.synchronize do
69
+ unless @native_issuers[key].equal?(pending) && !pending[:report]
70
+ raise AttemptErrors::EvidenceUnavailable, "readiness issuer lifetime ended"
71
+ end
72
+ events = journal.read_events(params.fetch("assignment_id")).select { |event| event["attempt_id"] == params.fetch("attempt_id") }
73
+ lineage = Molecules::ExecutionScopeLineage.new(events: events, project_id: map.fetch("project_id"),
74
+ assignment_id: params.fetch("assignment_id"), attempt_id: params.fetch("attempt_id"), mapping_id: mapping_id)
75
+ lineage.require_open!
76
+ pending[:report] = observer.verify_readiness_report!(lineage, peer, report, challenge: challenge)
77
+ end
78
+ end
79
+ end
80
+ wire.write(socket, {"version" => 1, "challenge_id" => challenge.fetch("challenge_id"), "status" => "ready"},
81
+ deadline: deadline, limit: 16_384)
82
+ end
83
+
84
+ private
85
+
86
+ def admit_native_service_held!(params, map, journal, peer, role, digest)
87
+ result = @mutex.synchronize do
88
+ # Visibility is current even on a historical mutation replay.
89
+ current = journal.read_events(params.fetch("assignment_id")).select { |event| event["attempt_id"] == params.fetch("attempt_id") }
90
+ scope_admission_owner!(params, map, current, peer, role)
91
+ journal.mutate(assignment_id: params.fetch("assignment_id"), attempt_id: params.fetch("attempt_id"),
92
+ mutation_id: params.fetch("mutation_id"), operation: "scope_service_admission", parameters_digest: digest,
93
+ expected_generation: params.fetch("expected_generation"), with_replay: true) do |events, _commit, generation|
94
+ state, lineage = scope_admission_owner!(params, map, events, peer, role)
95
+ lineage.require_open!
96
+ unless state["phase"] == "reserved" && !lineage.native_event && !lineage.child_event &&
97
+ events.none? { |event| event["type"] == "authority_mutation" && event.dig("payload", "operation") == "scope_service_admission" }
98
+ raise AttemptErrors::Conflict, "native activation was already admitted or launch advanced"
99
+ end
100
+ observer = scope_observer_for(params.fetch("mapping_id"))
101
+ installation = observer.native_admission_ready!(lineage)
102
+ binding = lineage.binding
103
+ data = state.merge("scope_generation" => binding.fetch("scope_generation"),
104
+ "scope_binding_event_id" => lineage.binding_event.fetch("digest"),
105
+ "service_unit" => map.fetch("execution_scope").fetch("service_unit"), "native_admission" => "issued_uncertain",
106
+ "network_installation" => installation)
107
+ unless installation.is_a?(Hash) && installation["namespace_path"] == map.fetch("execution_scope").fetch("network_namespace_path")
108
+ raise AttemptErrors::EvidenceUnavailable, "network verifier output names another fixed namespace path"
109
+ end
110
+ candidate = Models::EvidenceEvent.build(type: "authority_mutation", attempt_id: params.fetch("attempt_id"),
111
+ payload: {"operation" => "scope_service_admission", "data" => data.merge("generation" => generation + 1)},
112
+ previous_digest: events.last&.fetch("digest"))
113
+ Molecules::ExecutionScopeLineage.new(events: events + [candidate], project_id: map.fetch("project_id"),
114
+ assignment_id: params.fetch("assignment_id"), attempt_id: params.fetch("attempt_id"), mapping_id: params.fetch("mapping_id"))
115
+ {events: [], blobs: {}, data: data}
116
+ end
117
+ end
118
+ unless result.fetch(:replayed)
119
+ @mutex.synchronize do
120
+ raise AttemptErrors::Conflict, "native issuer already active" if @native_issuers.key?(native_issuer_key(params, map))
121
+ @native_issuers[native_issuer_key(params, map)] = {params: params.dup.freeze, report: nil, owner: Thread.current}
122
+ end
123
+ end
124
+ result
125
+ end
126
+
127
+ def complete_native_start!(params, map, journal, peer, role, result)
128
+ attempt_id = params.fetch("attempt_id")
129
+ observer = scope_observer_for(params.fetch("mapping_id"))
130
+ issue = with_exclusion(params, map, journal) do
131
+ @mutex.synchronize do
132
+ events = journal.read_events(params.fetch("assignment_id")).select { |event| event["attempt_id"] == attempt_id }
133
+ _, lineage = scope_admission_owner!(params, map, events, peer, role)
134
+ !lineage.sealed?
135
+ end
136
+ end
137
+ observer.start_admitted_service! if issue
138
+ # The pending issuer lifetime remains live through this final check
139
+ # and cleanup. A seal cannot publish proof then permit a late start.
140
+ with_exclusion(params, map, journal) do
141
+ events, lineage = @mutex.synchronize do
142
+ current = journal.read_events(params.fetch("assignment_id")).select { |event| event["attempt_id"] == attempt_id }
143
+ _, retained = scope_admission_owner!(params, map, current, peer, role)
144
+ [current, retained]
145
+ end
146
+ if lineage.sealed?
147
+ observer.stop_sealed_service!
148
+ elsif issue
149
+ payload = observer.complete_native_readiness!(lineage,
150
+ @mutex.synchronize { @native_issuers.fetch(native_issuer_key(params, map)).fetch(:report) })
151
+ @mutex.synchronize { commit_ready_native!(params, map, journal, events, lineage, payload) }
152
+ end
153
+ end
154
+ rescue StandardError
155
+ # Lost command completion is uncertain. Cleanup of the fixed service
156
+ # must finish before the live issuer can retire its pending lifetime.
157
+ observer.stop_sealed_service! if issue
158
+ raise
159
+ ensure
160
+ settle_native_issuer!(params, map) if attempt_id
161
+ end
162
+
163
+ def commit_ready_native!(params, map, journal, events, lineage, payload)
164
+ context = params.slice("mapping_id", "assignment_id", "attempt_id").merge(
165
+ "scope_binding_event_id" => lineage.binding_event.fetch("digest"))
166
+ journal.mutate(assignment_id: params.fetch("assignment_id"), attempt_id: params.fetch("attempt_id"),
167
+ mutation_id: "scope-native-#{Digest::SHA256.hexdigest(lineage.binding_event.fetch('digest'))[0, 48]}",
168
+ operation: "scope_native_binding", parameters_digest: Digest::SHA256.hexdigest(JSON.generate(canonical(context))),
169
+ expected_generation: journal.authority_generation(events), with_replay: true) do |current, _commit, _generation|
170
+ fresh = Molecules::ExecutionScopeLineage.new(events: current, project_id: map.fetch("project_id"),
171
+ assignment_id: params.fetch("assignment_id"), attempt_id: params.fetch("attempt_id"), mapping_id: params.fetch("mapping_id"))
172
+ fresh.require_open!
173
+ raise AttemptErrors::Conflict, "native binding already exists" if fresh.native_event
174
+ at = Time.now.utc
175
+ event = Models::EvidenceEvent.build(type: "scope_native_bound", attempt_id: params.fetch("attempt_id"),
176
+ payload: payload, previous_digest: current.last&.fetch("digest"), recorded_at: at)
177
+ Molecules::ExecutionScopeLineage.new(events: current + [event], project_id: map.fetch("project_id"),
178
+ assignment_id: params.fetch("assignment_id"), attempt_id: params.fetch("attempt_id"), mapping_id: params.fetch("mapping_id"))
179
+ {events: [{type: "scope_native_bound", payload: payload, recorded_at: at}], blobs: {}, data: context}
180
+ end
181
+ end
182
+
183
+ def native_issuer_key(params, map)
184
+ [map.fetch("project_id"), params.fetch("mapping_id"), params.fetch("assignment_id"), params.fetch("attempt_id")].freeze
185
+ end
186
+
187
+ def settle_native_issuer!(params, map)
188
+ @mutex.synchronize do
189
+ key = native_issuer_key(params, map)
190
+ pending = @native_issuers[key]
191
+ @native_issuers.delete(key) if pending && pending[:owner].equal?(Thread.current)
192
+ end
193
+ end
194
+
195
+ def native_issuer_pending!(params, map)
196
+ if @native_issuers.key?(native_issuer_key(params, map))
197
+ raise AttemptErrors::EvidenceUnavailable, "native issuance is still pending"
198
+ end
199
+ end
200
+
201
+ def scope_admission_owner!(params, map, events, peer, role)
202
+ state = states(events)[params.fetch("attempt_id")]
203
+ unless state && state["mapping_id"] == params.fetch("mapping_id") &&
204
+ state["launch_ticket"] == params.fetch("launch_ticket") &&
205
+ (role == :supervisor || @kernel.same?(state.fetch("launcher_identity"), peer))
206
+ raise AttemptErrors::UnauthorizedIdentity, "native admission launch origin differs"
207
+ end
208
+ lineage = Molecules::ExecutionScopeLineage.new(events: events, project_id: map.fetch("project_id"),
209
+ assignment_id: params.fetch("assignment_id"), attempt_id: params.fetch("attempt_id"), mapping_id: params.fetch("mapping_id"))
210
+ [state, lineage]
211
+ end
212
+ end
213
+ end
214
+ end
215
+ end