ace-assign 0.64.0 → 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 (129) hide show
  1. checksums.yaml +4 -4
  2. data/.ace-defaults/assign/catalog/steps/auto-merge.step.yml +11 -0
  3. data/CHANGELOG.md +19 -0
  4. data/docs/protected-authority.md +23 -7
  5. data/docs/usage.md +117 -4
  6. data/exe/ace-scope-ready +55 -0
  7. data/handbook/workflow-instructions/assign/drive.wf.md +26 -1
  8. data/lib/ace/assign/atoms/attempt_state_machine.rb +8 -0
  9. data/lib/ace/assign/atoms/delivery_parameters.rb +4 -39
  10. data/lib/ace/assign/authority/assignment_inventory.rb +290 -0
  11. data/lib/ace/assign/authority/campaign_consumer_policy.rb +100 -0
  12. data/lib/ace/assign/authority/campaign_execution.rb +119 -0
  13. data/lib/ace/assign/authority/campaign_round_transfer.rb +82 -0
  14. data/lib/ace/assign/authority/candidate_transfer.rb +169 -2
  15. data/lib/ace/assign/authority/cleanup_result_protection.rb +53 -0
  16. data/lib/ace/assign/authority/client.rb +156 -6
  17. data/lib/ace/assign/authority/deployment.rb +201 -39
  18. data/lib/ace/assign/authority/deployment_history.rb +146 -0
  19. data/lib/ace/assign/authority/endcap.rb +131 -18
  20. data/lib/ace/assign/authority/endcap_attempts.rb +274 -0
  21. data/lib/ace/assign/authority/endcap_campaign_results.rb +232 -0
  22. data/lib/ace/assign/authority/endcap_campaign_rounds.rb +266 -0
  23. data/lib/ace/assign/authority/endcap_contexts.rb +23 -0
  24. data/lib/ace/assign/authority/endcap_inboxes.rb +3 -280
  25. data/lib/ace/assign/authority/endcap_prepared_work.rb +80 -0
  26. data/lib/ace/assign/authority/endcap_publication.rb +207 -0
  27. data/lib/ace/assign/authority/endcap_results.rb +36 -18
  28. data/lib/ace/assign/authority/endcap_review_requests.rb +183 -0
  29. data/lib/ace/assign/authority/endcap_reviews.rb +130 -0
  30. data/lib/ace/assign/authority/endcap_service_settlement.rb +152 -0
  31. data/lib/ace/assign/authority/endcap_services.rb +221 -18
  32. data/lib/ace/assign/authority/execution_scope_observation.rb +468 -41
  33. data/lib/ace/assign/authority/inbox_context_original.rb +113 -0
  34. data/lib/ace/assign/authority/launch_attempt_consumers.rb +51 -0
  35. data/lib/ace/assign/authority/launch_campaign_registration.rb +214 -0
  36. data/lib/ace/assign/authority/launch_control_channel.rb +313 -0
  37. data/lib/ace/assign/authority/launch_control_exclusions.rb +141 -0
  38. data/lib/ace/assign/authority/launch_driver.rb +298 -19
  39. data/lib/ace/assign/authority/launch_input_inhibition.rb +132 -0
  40. data/lib/ace/assign/authority/launch_lifecycle.rb +275 -100
  41. data/lib/ace/assign/authority/launch_prepared_work.rb +122 -0
  42. data/lib/ace/assign/authority/launch_review.rb +91 -0
  43. data/lib/ace/assign/authority/launch_scope_abort.rb +0 -7
  44. data/lib/ace/assign/authority/launch_scope_admission.rb +136 -5
  45. data/lib/ace/assign/authority/launch_scope_close.rb +68 -37
  46. data/lib/ace/assign/authority/launch_scope_release.rb +696 -17
  47. data/lib/ace/assign/authority/launch_steering.rb +353 -0
  48. data/lib/ace/assign/authority/launch_stop.rb +254 -0
  49. data/lib/ace/assign/authority/maintenance_workspace_retirement.rb +229 -0
  50. data/lib/ace/assign/authority/original_inbox_selection.rb +41 -0
  51. data/lib/ace/assign/authority/posix_acl.rb +5 -2
  52. data/lib/ace/assign/authority/prepared_input.rb +130 -0
  53. data/lib/ace/assign/authority/prepared_queue.rb +185 -0
  54. data/lib/ace/assign/authority/prepared_task_context.rb +116 -0
  55. data/lib/ace/assign/authority/prepared_work.rb +304 -0
  56. data/lib/ace/assign/authority/prepared_worker.rb +69 -0
  57. data/lib/ace/assign/authority/protected_assignment_context.rb +226 -0
  58. data/lib/ace/assign/authority/readiness_hook.rb +120 -0
  59. data/lib/ace/assign/authority/router.rb +24 -2
  60. data/lib/ace/assign/authority/server.rb +103 -33
  61. data/lib/ace/assign/authority/service_cleanup_dispatch.rb +88 -0
  62. data/lib/ace/assign/authority/service_cleanup_evidence.rb +180 -0
  63. data/lib/ace/assign/authority/service_delivery_evidence.rb +101 -0
  64. data/lib/ace/assign/authority/service_evidence.rb +243 -12
  65. data/lib/ace/assign/authority/service_publication_evidence.rb +110 -0
  66. data/lib/ace/assign/authority/task_context_entry.rb +32 -0
  67. data/lib/ace/assign/authority/transfer_codec.rb +44 -12
  68. data/lib/ace/assign/cli/commands/assignment_target.rb +15 -2
  69. data/lib/ace/assign/cli/commands/attempt/base.rb +49 -0
  70. data/lib/ace/assign/cli/commands/attempt/finish.rb +20 -4
  71. data/lib/ace/assign/cli/commands/attempt/reconcile.rb +11 -0
  72. data/lib/ace/assign/cli/commands/authority/inbox_context_principal.rb +25 -0
  73. data/lib/ace/assign/cli/commands/authority/inbox_context_selection.rb +28 -0
  74. data/lib/ace/assign/cli/commands/authority/launch.rb +58 -6
  75. data/lib/ace/assign/cli/commands/authority/serve.rb +2 -1
  76. data/lib/ace/assign/cli/commands/authority/status.rb +1 -1
  77. data/lib/ace/assign/cli/commands/authority/task_context.rb +31 -0
  78. data/lib/ace/assign/cli/commands/authority/task_context_principal.rb +25 -0
  79. data/lib/ace/assign/cli/commands/authority/task_context_selection.rb +30 -0
  80. data/lib/ace/assign/cli/commands/authority/worker.rb +23 -0
  81. data/lib/ace/assign/cli/commands/campaign_export_result.rb +55 -0
  82. data/lib/ace/assign/cli/commands/campaign_record_round.rb +57 -0
  83. data/lib/ace/assign/cli/commands/create.rb +2 -0
  84. data/lib/ace/assign/cli/commands/delivery.rb +54 -4
  85. data/lib/ace/assign/cli/commands/fail.rb +6 -0
  86. data/lib/ace/assign/cli/commands/finish.rb +23 -2
  87. data/lib/ace/assign/cli/commands/fork_session.rb +6 -2
  88. data/lib/ace/assign/cli/commands/inbox_bind.rb +34 -0
  89. data/lib/ace/assign/cli/commands/protected_submission.rb +61 -0
  90. data/lib/ace/assign/cli/commands/resume.rb +12 -0
  91. data/lib/ace/assign/cli/commands/select.rb +2 -0
  92. data/lib/ace/assign/cli/commands/start.rb +2 -0
  93. data/lib/ace/assign/cli/commands/status.rb +4 -2
  94. data/lib/ace/assign/cli/commands/step.rb +3 -1
  95. data/lib/ace/assign/cli/commands/submit_candidate.rb +34 -0
  96. data/lib/ace/assign/cli/commands/submit_result.rb +61 -0
  97. data/lib/ace/assign/cli.rb +36 -5
  98. data/lib/ace/assign/errors.rb +139 -0
  99. data/lib/ace/assign/models/assignment.rb +26 -4
  100. data/lib/ace/assign/models/evidence_event.rb +3 -3
  101. data/lib/ace/assign/molecules/assignment_manager.rb +12 -2
  102. data/lib/ace/assign/molecules/attempt_reconciler.rb +20 -0
  103. data/lib/ace/assign/molecules/canonical_attempt_state.rb +151 -0
  104. data/lib/ace/assign/molecules/canonical_evidence.rb +5 -2
  105. data/lib/ace/assign/molecules/canonical_read_snapshot.rb +374 -0
  106. data/lib/ace/assign/molecules/evidence_journal.rb +720 -44
  107. data/lib/ace/assign/molecules/execution_scope_lineage.rb +27 -9
  108. data/lib/ace/assign/molecules/fork_session_launcher.rb +16 -6
  109. data/lib/ace/assign/molecules/journal_input_inhibition.rb +78 -0
  110. data/lib/ace/assign/molecules/journal_mutation.rb +82 -13
  111. data/lib/ace/assign/molecules/journal_prompt_mutation.rb +241 -0
  112. data/lib/ace/assign/molecules/lifecycle_exclusion.rb +178 -26
  113. data/lib/ace/assign/molecules/original_launch_binding.rb +37 -0
  114. data/lib/ace/assign/molecules/protected_control_exclusion.rb +303 -0
  115. data/lib/ace/assign/molecules/protected_workspace_exclusion.rb +330 -0
  116. data/lib/ace/assign/molecules/protected_workspace_provisioner.rb +164 -0
  117. data/lib/ace/assign/molecules/queue_scanner.rb +26 -11
  118. data/lib/ace/assign/molecules/receipt_verifier.rb +21 -15
  119. data/lib/ace/assign/molecules/step_writer.rb +27 -5
  120. data/lib/ace/assign/molecules/terminal_scope_receipt.rb +88 -0
  121. data/lib/ace/assign/organisms/assignment_executor.rb +4 -4
  122. data/lib/ace/assign/organisms/attempt_coordinator.rb +66 -76
  123. data/lib/ace/assign/organisms/prepared_work_builder.rb +194 -0
  124. data/lib/ace/assign/organisms/protected_delivery_coordinator.rb +100 -0
  125. data/lib/ace/assign/organisms/task_assignment_creator.rb +4 -2
  126. data/lib/ace/assign/version.rb +1 -1
  127. data/lib/ace/assign.rb +1 -112
  128. metadata +88 -9
  129. data/lib/ace/assign/cli/commands/inbox_reconcile.rb +0 -36
@@ -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
@@ -21,13 +21,6 @@ module Ace
21
21
  raise AttemptErrors::EvidenceUnavailable, "canonical service settlement owner is unavailable"
22
22
  end
23
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
24
  true
32
25
  end
33
26
 
@@ -19,9 +19,66 @@ module Ace
19
19
  journal = journal_for(map)
20
20
  input = params.reject { |key, _| key == "mutation_id" }
21
21
  digest = Digest::SHA256.hexdigest(JSON.generate(canonical(input)))
22
- with_exclusion(params, map, journal) do
22
+ result = with_exclusion(params, map, journal) do
23
23
  admit_native_service_held!(params, map, journal, peer, role, digest)
24
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)
25
82
  end
26
83
 
27
84
  private
@@ -58,15 +115,89 @@ module Ace
58
115
  {events: [], blobs: {}, data: data}
59
116
  end
60
117
  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
118
  unless result.fetch(:replayed)
65
- scope_observer_for(params.fetch("mapping_id")).start_admitted_service!
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
66
123
  end
67
124
  result
68
125
  end
69
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
+
70
201
  def scope_admission_owner!(params, map, events, peer, role)
71
202
  state = states(events)[params.fetch("attempt_id")]
72
203
  unless state && state["mapping_id"] == params.fetch("mapping_id") &&
@@ -17,8 +17,9 @@ module Ace
17
17
  journal = journal_for(map)
18
18
  input = params.reject { |key, _| key == "mutation_id" }
19
19
  digest = Digest::SHA256.hexdigest(JSON.generate(canonical(input)))
20
- with_exclusion(params, map, journal) do
21
- stop_after_commit = false
20
+ stop_after_commit = false
21
+ inhibit_after_commit = false
22
+ result = with_containment_exclusion(params, map, journal) do
22
23
  result = @mutex.synchronize do
23
24
  current = journal.read_events(params.fetch("assignment_id")).select { |event| event["attempt_id"] == params.fetch("attempt_id") }
24
25
  scope_close_owner!(params, map, current, peer, role)
@@ -26,44 +27,27 @@ module Ace
26
27
  mutation_id: params.fetch("mutation_id"), operation: "close_execution_scope", parameters_digest: digest,
27
28
  expected_generation: params.fetch("expected_generation"), with_replay: true) do |events, _commit, _generation|
28
29
  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
30
+ closure = scope_closure_plan!(lineage: lineage, params: params, map: map, events: events, journal: journal, commit: _commit)
31
+ stop_after_commit = closure.fetch(:stop_required)
32
+ inhibit_after_commit = closure.fetch(:inhibit_required)
33
+ closure.fetch(:plan)
60
34
  end
61
35
  end
62
- if !result.fetch(:replayed) && stop_after_commit
36
+ result
37
+ end
38
+ if !result.fetch(:replayed) && inhibit_after_commit
39
+ request_original_input_inhibition!(params, map, journal)
40
+ end
41
+ if !result.fetch(:replayed) && stop_after_commit
42
+ with_containment_exclusion(params, map, journal) do
43
+ @mutex.synchronize do
44
+ current = journal.read_events(params.fetch("assignment_id")).select { |event| event["attempt_id"] == params.fetch("attempt_id") }
45
+ scope_close_owner!(params, map, current, peer, role)
46
+ end
63
47
  scope_observer_for(params.fetch("mapping_id")).stop_sealed_service!
64
48
  end
65
- result
66
49
  end
50
+ result
67
51
  end
68
52
 
69
53
  def observe_execution_scope!(params:, peer:, role:)
@@ -72,7 +56,7 @@ module Ace
72
56
  @kernel.live!(peer)
73
57
  map = @deployment.mapping(params.fetch("mapping_id"))
74
58
  journal = journal_for(map)
75
- with_exclusion(params, map, journal) do
59
+ with_containment_exclusion(params, map, journal) do
76
60
  @mutex.synchronize do
77
61
  commit = journal.ref_value
78
62
  events = journal.read_events(params.fetch("assignment_id"), commit: commit).select { |event| event["attempt_id"] == params.fetch("attempt_id") }
@@ -83,7 +67,9 @@ module Ace
83
67
  return projection unless lineage.binding
84
68
  begin
85
69
  observation = scope_observer_for(params.fetch("mapping_id")).observe(lineage)
86
- if lineage.proof_event
70
+ if pending_prompt_issuers?(events, journal, commit)
71
+ projection.merge("required_action" => "drain_original_prompt_issuer")
72
+ elsif lineage.proof_event
87
73
  scope_observer_for(params.fetch("mapping_id")).verify_closed!(lineage)
88
74
  projection.merge("state" => "closed_no_writers", "proof_id" => lineage.proof_id, "required_action" => nil)
89
75
  elsif lineage.sealed? && observation.fetch("populated").zero?
@@ -100,6 +86,51 @@ module Ace
100
86
 
101
87
  private
102
88
 
89
+ # Shared source-only plan for close and public stop. It does not issue
90
+ # manager/native control, mutate canonical state or acquire exclusions.
91
+ # Each CAS invocation derives its own post-commit work flags anew.
92
+ def scope_closure_plan!(lineage:, params:, map:, events:, journal:, commit:)
93
+ stop_required = false
94
+ inhibit_required = false
95
+ binding = lineage.binding
96
+ raise AttemptErrors::EvidenceUnavailable, "original parent binding is missing" unless binding
97
+ projection = {"attempt_id" => params.fetch("attempt_id"), "scope_generation" => binding.fetch("scope_generation"),
98
+ "state" => "running", "proof_id" => nil, "required_action" => "close_scope"}
99
+ plan = if !lineage.sealed?
100
+ stop_required = true
101
+ inhibit_required = true
102
+ payload = {"scope_generation" => binding.fetch("scope_generation"), "scope_binding_event_id" => lineage.binding_event.fetch("digest")}
103
+ {events: [{type: "scope_sealed", payload: payload}], blobs: {}, data: projection}
104
+ elsif @native_issuers.key?(native_issuer_key(params, map)) || pending_prompt_issuers?(events, journal, commit)
105
+ # A live issuer may still start after this inactive snapshot.
106
+ inhibit_required = true
107
+ {events: [], blobs: {}, data: projection}
108
+ elsif lineage.proof_event
109
+ scope_observer_for(params.fetch("mapping_id")).verify_closed!(lineage)
110
+ {events: [], blobs: {}, data: projection.merge("state" => "closed_no_writers", "proof_id" => lineage.proof_id, "required_action" => nil)}
111
+ elsif scope_observer_for(params.fetch("mapping_id")).sealed_service_stop_required?(lineage)
112
+ # A fresh canonical close resumes a stop lost after the seal.
113
+ # Replaying either mutation performs no manager I/O.
114
+ stop_required = true
115
+ {events: [], blobs: {}, data: projection}
116
+ else
117
+ # This call must return a fresh validated observation, never
118
+ # a cached population, caller JSON or a manifest boolean.
119
+ payload = scope_observer_for(params.fetch("mapping_id")).closed_observation_for_proof!(lineage, events: events)
120
+ expected = binding.slice("scope_generation", "boot_id", "slice_invocation_id", "cgroup_identity").merge(
121
+ "scope_binding_event_id" => lineage.binding_event.fetch("digest"), "seal_event_id" => lineage.seal_event.fetch("digest"), "populated" => 0)
122
+ unless payload == expected
123
+ raise AttemptErrors::EvidenceUnavailable, "fresh observation does not name the retained sealed parent"
124
+ end
125
+ at = Time.now.utc
126
+ event = Models::EvidenceEvent.build(type: "scope_closed_no_writers", attempt_id: params.fetch("attempt_id"),
127
+ payload: payload, previous_digest: events.last&.fetch("digest"), recorded_at: at)
128
+ {events: [{type: "scope_closed_no_writers", payload: payload, recorded_at: at}], blobs: {},
129
+ data: projection.merge("state" => "closed_no_writers", "proof_id" => event.fetch("digest"), "required_action" => nil)}
130
+ end
131
+ {plan: plan, stop_required: stop_required, inhibit_required: inhibit_required}
132
+ end
133
+
103
134
  def scope_close_owner!(params, map, events, peer, role)
104
135
  state = states(events)[params.fetch("attempt_id")]
105
136
  unless state && state["mapping_id"] == params.fetch("mapping_id") &&