ace-assign 0.64.0 → 0.66.1

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 +25 -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 +90 -11
  129. data/lib/ace/assign/cli/commands/inbox_reconcile.rb +0 -36
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "original_inbox_selection"
4
+ require_relative "../molecules/original_launch_binding"
5
+ require_relative "../molecules/execution_scope_lineage"
6
+
7
+ module Ace
8
+ module Assign
9
+ module Authority
10
+ # Canonical original identity only. No native thread, effect permission,
11
+ # live process observation, context lock or journal mutation is returned.
12
+ class InboxContextOriginal
13
+ OPERATIONS = ["inbox_context_original"].freeze
14
+ PARAMETERS = %w[assignment_id attempt_id event_id inbox_context_id mapping_id payload_sha256 purpose].freeze
15
+ NATIVE_FIELDS = %w[scope_generation scope_binding_event_id service_invocation_id server_identity socket_identity workspace_id].freeze
16
+ TOKEN = /\A[A-Za-z0-9][A-Za-z0-9._-]{0,127}\z/
17
+ SHA = /\A[0-9a-f]{64}\z/
18
+
19
+ def initialize(deployment:, history:, authority_id:, journals:,
20
+ kernel: Ace::Runtime::Molecules::ProtectedLinux.new)
21
+ @deployment, @history, @authority_id, @journals, @kernel = deployment, history, authority_id, journals, kernel
22
+ end
23
+
24
+ def dispatch(request:, peer:, role:)
25
+ unless request.is_a?(Hash) && request.keys.sort == %w[mutation_id operation params project_id version] &&
26
+ request["version"].is_a?(Integer) && request["version"] == 1 && request["operation"] == OPERATIONS.first &&
27
+ request["mutation_id"].nil? && role == :context_owner && request["params"].is_a?(Hash) &&
28
+ request["params"].keys.sort == PARAMETERS
29
+ raise AttemptErrors::UnauthorizedIdentity, "original inbox query differs"
30
+ end
31
+ params = request.fetch("params")
32
+ unless %w[assignment_id attempt_id event_id inbox_context_id mapping_id].all? { |key| params[key].is_a?(String) && TOKEN.match?(params[key]) } &&
33
+ %w[payload_sha256].all? { |key| params[key].is_a?(String) && SHA.match?(params[key]) } &&
34
+ %w[enqueue deliver].include?(params["purpose"])
35
+ raise AttemptErrors::UnauthorizedIdentity, "original inbox selectors differ"
36
+ end
37
+ map = @deployment.mapping(params.fetch("mapping_id"))
38
+ context = @deployment.inbox_context(params.fetch("mapping_id"), params.fetch("inbox_context_id"))
39
+ unless map.values_at("authority_id", "project_id") == [@authority_id, request.fetch("project_id")] &&
40
+ context.fetch("owner_credentials").values_at("uid", "gid", "groups") == peer.values_at("uid", "gid", "groups")
41
+ raise AttemptErrors::UnauthorizedIdentity, "original inbox query requires its fixed owner"
42
+ end
43
+ @kernel.live!(peer)
44
+ journal = @journals.fetch(map.fetch("project_id"))
45
+ project = @deployment.project(map.fetch("project_id"))
46
+ unless journal.evidence_mode == :protected && project.values_at("journal_repository", "evidence_git_ref", "evidence_checkout_root") ==
47
+ [journal.repo_root, journal.ref, journal.checkout_root]
48
+ raise AttemptErrors::EvidenceUnavailable, "original inbox journal association differs"
49
+ end
50
+ commit = journal.ref_value
51
+ journal.verify_commit!(commit)
52
+ events = journal.read_events(params.fetch("assignment_id"), commit: commit).select { |event| event["attempt_id"] == params.fetch("attempt_id") }
53
+ original, original_map = OriginalInboxSelection.new(history: @history, authority_id: @authority_id)
54
+ .load!(events: events, params: params, map: map, journal: journal, commit: commit)
55
+ original_context = original.inbox_context(params.fetch("mapping_id"), params.fetch("inbox_context_id"))
56
+ unless original_context.fetch("native_mapping_id") == params.fetch("mapping_id")
57
+ raise AttemptErrors::EvidenceUnavailable, "original inbox native mapping differs"
58
+ end
59
+ lineage = Molecules::ExecutionScopeLineage.new(events: events, project_id: original_map.fetch("project_id"),
60
+ assignment_id: params.fetch("assignment_id"), attempt_id: params.fetch("attempt_id"), mapping_id: params.fetch("mapping_id"))
61
+ child = lineage.child_event&.dig("payload", "original_process_binding")
62
+ records = events.select { |event| event["type"] == "authority_mutation" && event.dig("payload", "operation") == "record_launch" }
63
+ raise AttemptErrors::EvidenceUnavailable, "original inbox launch missing" unless records.one? && child
64
+ state = records.first.fetch("payload").fetch("data")
65
+ raise AttemptErrors::EvidenceUnavailable, "original inbox child differs" unless state.fetch("process_binding") == child
66
+ guarded = Molecules::OriginalLaunchBinding.verify!(events: events, state: state, params: params)
67
+ registration = {"event_id" => params.fetch("event_id"), "attempt_id" => params.fetch("attempt_id"),
68
+ "payload_sha256" => params.fetch("payload_sha256")}
69
+ bindings = events.select { |event| event["type"] == "inbox_binding" && event.dig("payload", "event_id") == params.fetch("event_id") }
70
+ unless bindings.size <= 1 && (params.fetch("purpose") == "enqueue" || bindings.one?) && bindings.all? { |event|
71
+ event.fetch("payload") == {"attempt_id" => params.fetch("attempt_id"), "event_id" => params.fetch("event_id"),
72
+ "inbox_context_id" => params.fetch("inbox_context_id"), "registration" => registration} }
73
+ raise AttemptErrors::EvidenceUnavailable, "original inbox registration differs"
74
+ end
75
+ digests = [guarded.fetch("binding_digest"), lineage.native_event.fetch("digest"), lineage.child_event.fetch("digest")] + bindings.map { |event| event.fetch("digest") }
76
+ journal.event_commits!(assignment_id: params.fetch("assignment_id"), event_digests: digests, commit: commit)
77
+ @kernel.live!(peer)
78
+ channel = original_map.fetch("native")
79
+ server = lineage.native_event.fetch("payload").fetch("server_identity")
80
+ path = channel.fetch("socket_path")
81
+ unless path.is_a?(String) && path.valid_encoding? && path.bytesize.between?(1, 107) &&
82
+ path.start_with?("/") && !path.include?("\0") && File.expand_path(path) == path &&
83
+ channel.fetch("version") == "0.9.3" && channel.fetch("protocol") == 22 &&
84
+ channel.fetch("workspace_id") == lineage.native_event.fetch("payload").fetch("workspace_id") &&
85
+ server.values_at("uid", "gid", "groups") == original_map.values_at("worker_uid", "worker_gid", "worker_groups")
86
+ raise AttemptErrors::EvidenceUnavailable, "original inbox native channel differs"
87
+ end
88
+ data = params.slice("assignment_id", "attempt_id", "event_id", "inbox_context_id", "mapping_id", "purpose")
89
+ .merge("schema" => "ace.assign.inbox-context-original/v1", "project_id" => map.fetch("project_id"),
90
+ "commit" => commit, "registration" => registration, "registered" => bindings.one?,
91
+ "original_binding_digest" => guarded.fetch("binding_digest"), "process_binding" => child,
92
+ "guarded_origin" => guarded.fetch("origin"), "native_binding" => lineage.native_event.fetch("payload").slice(*NATIVE_FIELDS),
93
+ "native_channel" => channel.slice("socket_path", "version"))
94
+ raise AttemptErrors::EvidenceUnavailable, "original inbox projection exceeds bound" if JSON.generate(data).bytesize > 15_360
95
+ {data: immutable(data), replayed: false}
96
+ rescue KeyError, TypeError, ArgumentError, NoMethodError, Ace::Herdr::Error, Ace::Runtime::RuntimeUnavailableError
97
+ raise AttemptErrors::EvidenceUnavailable, "original inbox evidence is unavailable"
98
+ end
99
+
100
+ private
101
+
102
+ def immutable(value)
103
+ case value
104
+ when Hash then value.to_h { |key, item| [key.dup.freeze, immutable(item)] }.freeze
105
+ when Array then value.map { |item| immutable(item) }.freeze
106
+ when String then value.dup.freeze
107
+ else value.freeze
108
+ end
109
+ end
110
+ end
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Assign
5
+ module Authority
6
+ class LaunchLifecycle
7
+ # Caller owns the existing slot/assignment exclusion. This verifies
8
+ # an already accepted closure; it never seals, stops or observes anew.
9
+ def completion_scope!(journal:, events:, params:, map:, commit:)
10
+ lineage = Molecules::ExecutionScopeLineage.new(events: events, project_id: map.fetch("project_id"),
11
+ assignment_id: params.fetch("assignment_id"), attempt_id: params.fetch("attempt_id"),
12
+ mapping_id: params.fetch("mapping_id"))
13
+ unless lineage.proof_event && !pending_prompt_issuers?(events, journal, commit)
14
+ raise AttemptErrors::EvidenceUnavailable, "Original scope closure is unavailable"
15
+ end
16
+ scope_observer_for(params.fetch("mapping_id")).verify_closed!(lineage)
17
+ lineage
18
+ end
19
+
20
+ # Reuse the complete immutable inventory/terminal/release owner for
21
+ # recovery and finish replay; no current process grants terminality.
22
+ def completion_terminal!(journal:, commit:, params:, map:)
23
+ entries = inventory_index!(journal, commit, params.fetch("mapping_id"), map).select do |entry|
24
+ entry.fetch(:selector).values_at("assignment_id", "attempt_id") == params.values_at("assignment_id", "attempt_id")
25
+ end
26
+ raise AttemptErrors::NotFound, "Canonical attempt is missing" if entries.empty?
27
+ raise AttemptErrors::EvidenceUnavailable, "Canonical attempt is ambiguous" unless entries.one?
28
+ inventory_row!(journal, commit, entries.first)
29
+ end
30
+
31
+ def recovery_observation!(journal:, events:, params:, map:, commit:)
32
+ state = origin(events, **params.slice("assignment_id", "attempt_id", "mapping_id").transform_keys(&:to_sym))
33
+ return {"liveness" => "unknown"} unless %w[bound issued].include?(state["phase"])
34
+ original, original_map = inventory_original_deployment!(journal, events, params.fetch("mapping_id"), map)
35
+ lineage = Molecules::ExecutionScopeLineage.new(events: events, project_id: original_map.fetch("project_id"),
36
+ assignment_id: params.fetch("assignment_id"), attempt_id: params.fetch("attempt_id"), mapping_id: params.fetch("mapping_id"))
37
+ return {"liveness" => "unknown"} if lineage.sealed?
38
+ # Disposable local cache is not a checkpoint authority. Authenticate
39
+ # the retained original prepared selection with the existing owner.
40
+ if state.fetch("phase") == "issued"
41
+ original_prepared_registration!(journal: journal, params: params, commit: commit)
42
+ else
43
+ original_bound_prepared_registration!(journal: journal, params: params, commit: commit)
44
+ end
45
+ Molecules::AttemptReconciler.protected_observation(kernel: @kernel, binding: state.fetch("process_binding"),
46
+ observer: maintenance_scope_observer_for(original, params.fetch("mapping_id")), lineage: lineage)
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,214 @@
1
+ # frozen_string_literal: true
2
+ require "ace/review/organisms/campaign_manager"
3
+ require_relative "campaign_consumer_policy"
4
+
5
+ module Ace
6
+ module Assign
7
+ module Authority
8
+ class LaunchLifecycle
9
+ # Called by the terminal owner at its selected CAS prefix. Discover
10
+ # children from canonical registration; no caller list can omit one.
11
+ def campaign_children_settled!(journal:, commit:, params:, map:)
12
+ parent = preview_attempt_definition!(journal: journal, commit: commit, params: params, map: map)
13
+ return true unless parent.review_campaign
14
+ inventory = journal.canonical_event_inventory!(commit: commit)
15
+ inventory.fetch("events").each do |assignment, events|
16
+ next if assignment == params.fetch("assignment_id")
17
+ registrations = events.select { |event| event.dig("payload", "operation") == "register_assignment" }
18
+ next if registrations.empty?
19
+ registration = registrations.last.fetch("payload").fetch("data")
20
+ child = inventory_definition!(journal, commit,
21
+ {selector: {"assignment_id" => assignment, "attempt_id" => nil}, registration: registration})
22
+ execution = child.campaign_execution
23
+ next unless execution && execution.values_at("parent_assignment_id", "parent_attempt_id") ==
24
+ params.values_at("assignment_id", "attempt_id")
25
+ original = control_original_descriptor!(registration.fetch("lifecycle_control"))
26
+ mapping_id = registration.fetch("mapping_id")
27
+ child_map = original.mapping(mapping_id)
28
+ authenticate_inventory_registration!(journal, assignment, events, registration, mapping_id,
29
+ child_map, commit, inventory.fetch("introductions").fetch(assignment))
30
+ rows = inventory_index!(journal, commit, mapping_id, child_map).select { |entry|
31
+ entry.fetch(:selector).fetch("assignment_id") == assignment }
32
+ unless !rows.empty? && rows.all? { |entry|
33
+ row = inventory_row!(journal, commit, entry)
34
+ %w[succeeded failed stopped].include?(row["canonical_state"]) &&
35
+ row["terminal_event_id"] && row["reservation_release_event_id"] }
36
+ raise AttemptErrors::Conflict, "campaign parent has an unsettled registered child"
37
+ end
38
+ end
39
+ true
40
+ rescue KeyError, TypeError
41
+ raise AttemptErrors::EvidenceUnavailable, "campaign child completion provenance is unavailable"
42
+ end
43
+
44
+ private
45
+
46
+ # Resolve both immutable canonical owners before entering exclusions.
47
+ def campaign_child_context!(params, context, map, journal)
48
+ value = if params.key?("definition_bytes")
49
+ JSON.parse(params.fetch("definition_bytes"))
50
+ elsif context.fetch(:registration)
51
+ registration = context.fetch(:registration)
52
+ bytes = journal.blob(registration.fetch("definition_ref"), commit: context.fetch(:commit))
53
+ unless bytes.is_a?(String) && bytes.bytesize <= 32_768 &&
54
+ Digest::SHA256.hexdigest(bytes) == registration.fetch("definition_digest")
55
+ raise AttemptErrors::EvidenceUnavailable, "campaign definition content differs"
56
+ end
57
+ decoded = JSON.parse(bytes, create_additions: false, max_nesting: 32, allow_duplicate_key: false, allow_comments: false)
58
+ return context unless decoded.is_a?(Hash) && decoded.key?("campaign_execution")
59
+ inventory_definition!(journal, context.fetch(:commit),
60
+ {selector: {"assignment_id" => params.fetch("assignment_id"), "attempt_id" => params["attempt_id"]},
61
+ registration: registration}).to_h
62
+ else
63
+ return context
64
+ end
65
+ return context unless value.key?("campaign_execution")
66
+ execution = value.fetch("campaign_execution")
67
+ unless execution.is_a?(Hash) && execution.keys.sort == CampaignExecution::FIELDS
68
+ raise ArgumentError, "campaign execution fields differ"
69
+ end
70
+ parent_id, attempt = execution.values_at("parent_assignment_id", "parent_attempt_id")
71
+ token!(parent_id); token!(attempt)
72
+ raise ArgumentError, "campaign child cannot be its own parent" if parent_id == params.fetch("assignment_id")
73
+ inventory = journal.canonical_event_inventory!(commit: context.fetch(:commit))
74
+ reserves = inventory.fetch("events").fetch(parent_id).select { |event|
75
+ event["attempt_id"] == attempt && event.dig("payload", "operation") == "reserve_attempt" }
76
+ raise AttemptErrors::EvidenceUnavailable, "campaign parent reservation differs" unless reserves.one?
77
+ parent_mapping = reserves.first.fetch("payload").fetch("data").fetch("mapping_id")
78
+ parent_params = {"assignment_id" => parent_id, "attempt_id" => attempt, "mapping_id" => parent_mapping,
79
+ "scope" => execution.fetch("parent_scope"), "base" => execution.fetch("base"), "head" => execution.fetch("head"),
80
+ "candidate_generation" => execution.fetch("parent_candidate_generation")}.freeze
81
+ parent_map = @deployment.mapping(parent_mapping)
82
+ parent_context = control_registration_context!(parent_params, parent_map, journal, commit: context.fetch(:commit))
83
+ original_map = parent_context.fetch(:map)
84
+ parent = preview_attempt_definition!(journal: journal, commit: context.fetch(:commit), params: parent_params, map: original_map)
85
+ selected = CampaignExecution.validate_parent!(parent.review_campaign)
86
+ unless value["parent"] == parent_id && value["task_id"] == parent.task_id && value["project_id"] == parent.project_id &&
87
+ parent.project_id == map.fetch("project_id") &&
88
+ parent_context.fetch(:selection).fetch("root_identity") == context.fetch(:selection).fetch("root_identity") &&
89
+ original_map.fetch("authority_id") == context.fetch(:map).fetch("authority_id")
90
+ raise AttemptErrors::EvidenceUnavailable, "campaign parent task or control owner differs"
91
+ end
92
+ CampaignExecution.validate!(execution, parent: parent_id, policy: selected.fetch("policy"))
93
+ context.merge(campaign: {params: parent_params, map: parent_map, context: parent_context,
94
+ definition: parent, selected: selected, execution: execution, child_assignment: params.fetch("assignment_id")}.freeze).freeze
95
+ rescue KeyError, TypeError
96
+ raise AttemptErrors::EvidenceUnavailable, "campaign parent canonical registration is unavailable"
97
+ end
98
+
99
+ def recheck_campaign_parent!(context, journal, commit: journal.ref_value)
100
+ campaign = context.fetch(:campaign)
101
+ parent = campaign.fetch(:context)
102
+ recheck_control_registration!(parent, campaign.fetch(:params), campaign.fetch(:map), journal,
103
+ commit: commit, held_exclusion: context.fetch(:exclusion))
104
+ unless @result_owner&.respond_to?(:campaign_parent_candidate!)
105
+ raise AttemptErrors::EvidenceUnavailable, "campaign parent candidate owner is unavailable"
106
+ end
107
+ @result_owner.campaign_parent_candidate!(journal: journal, commit: commit,
108
+ params: campaign.fetch(:params), map: parent.fetch(:map))
109
+ true
110
+ end
111
+
112
+ # Called inside the existing lifecycle exclusions and authority mutex,
113
+ # before entering the journal mutation; the store lock spans CAS retries.
114
+ def with_parent_campaign_registration(params, context, replay:)
115
+ # A stored mutation remains a historical observation after supersession.
116
+ # The journal still verifies its operation and exact parameters below.
117
+ return yield if replay
118
+ if context[:campaign]
119
+ return with_child_campaign_registration(params, context) { |guard| yield guard }
120
+ end
121
+ return yield unless params.key?("definition_bytes")
122
+ definition = JSON.parse(params.fetch("definition_bytes"))
123
+ return yield unless definition.key?("review_campaign")
124
+ selected = CampaignExecution.validate_parent!(definition.fetch("review_campaign"))
125
+ manager = campaign_manager_for!(context)
126
+ manager.with_campaign_registration!(selected.fetch("campaign_id"), subject: selected.fetch("subject"),
127
+ contract_identity: selected.fetch("contract_identity"), policy: selected.fetch("policy")) { yield }
128
+ rescue KeyError, TypeError, JSON::ParserError, Ace::Review::Atoms::CampaignContract::Invalid
129
+ raise AttemptErrors::EvidenceUnavailable, "registered parent campaign is unavailable"
130
+ end
131
+
132
+ def campaign_manager_for!(context, **evidence)
133
+ descriptor = context.fetch(:descriptor)
134
+ map = context.fetch(:map)
135
+ project = descriptor.project(map.fetch("project_id"))
136
+ service = descriptor.authority(map.fetch("authority_id"))
137
+ %w[campaign_repository campaign_store_root].each do |key|
138
+ path = project.fetch(key)
139
+ wire.root_path!(path, directory: true, owner: service.fetch("uid"))
140
+ unless (File.stat(path).mode & 0o077).zero?
141
+ raise AttemptErrors::EvidenceUnavailable, "campaign owner root must remain private"
142
+ end
143
+ end
144
+ store = Ace::Review::Molecules::CampaignStore.new(root: project.fetch("campaign_store_root"))
145
+ Ace::Review::Organisms::CampaignManager.new(repo_root: project.fetch("campaign_repository"), store: store, **evidence)
146
+ end
147
+
148
+ def with_child_campaign_registration(params, context)
149
+ campaign = context.fetch(:campaign)
150
+ recheck_campaign_parent!(context, journals.fetch(context.fetch(:map).fetch("project_id")))
151
+ manager = campaign_manager_for!(campaign.fetch(:context))
152
+ deployment = @deployment
153
+ descriptor_sha = protected_descriptor_sha256!
154
+ policy_ref = deployment.project(context.fetch(:map).fetch("project_id")).fetch("campaign_policy")
155
+ CampaignConsumerPolicy.new.with(policy_ref) do |profiles, artifacts|
156
+ manager.with_execution_round!(campaign.fetch(:execution).fetch("campaign_id"),
157
+ round_id: campaign.fetch(:execution).fetch("round_id"), consumer_profiles: profiles) do |round, round_guard|
158
+ selected = campaign.fetch(:selected)
159
+ unless round.slice("campaign_id", "subject", "contract_identity", "policy") == selected.reject { |key, _| key == "version" }
160
+ raise AttemptErrors::EvidenceUnavailable, "campaign child differs from original parent contract"
161
+ end
162
+ CampaignExecution.validate_round!(campaign.fetch(:execution), parent: campaign.fetch(:definition).id, round: round)
163
+ guard = lambda do |journal, commit|
164
+ unless @deployment.equal?(deployment) && protected_descriptor_sha256! == descriptor_sha
165
+ raise AttemptErrors::Conflict, "current campaign policy descriptor changed"
166
+ end
167
+ artifacts.verify_unchanged!
168
+ round_guard.call
169
+ recheck_campaign_parent!(context, journal, commit: commit)
170
+ unique_campaign_child!(context, journal, commit) if params.key?("definition_bytes")
171
+ end
172
+ yield guard
173
+ end
174
+ end
175
+ rescue KeyError, TypeError, Ace::Review::Atoms::CampaignContract::Invalid, Ace::Runtime::RuntimeUnavailableError
176
+ raise AttemptErrors::EvidenceUnavailable, "campaign child pinned round is unavailable"
177
+ end
178
+
179
+ # One immutable phase definition, discovered from the existing canonical
180
+ # registration owner. A caller-provided child list cannot omit a sibling.
181
+ def unique_campaign_child!(context, journal, commit)
182
+ selected = context.fetch(:campaign).fetch(:execution)
183
+ fields = %w[parent_assignment_id parent_attempt_id parent_candidate_generation round_id phase]
184
+ fields += case selected.fetch("phase")
185
+ when "collection" then ["review_scope"]
186
+ when "check" then ["check_name"]
187
+ else []
188
+ end
189
+ inventory = journal.canonical_event_inventory!(commit: commit)
190
+ inventory.fetch("events").each do |assignment, events|
191
+ next if assignment == context.fetch(:campaign).fetch(:definition).id
192
+ events.select { |event| event.dig("payload", "operation") == "register_assignment" }.each do |event|
193
+ registration = event.fetch("payload").fetch("data")
194
+ entry = {selector: {"assignment_id" => assignment, "attempt_id" => nil}, registration: registration}
195
+ definition = inventory_definition!(journal, commit, entry)
196
+ child = definition.campaign_execution
197
+ next unless child && child.slice(*fields) == selected.slice(*fields)
198
+ # Re-registration of this exact child is permitted; a distinct
199
+ # assignment cannot replace the retained phase owner.
200
+ current_assignment = context.fetch(:campaign).fetch(:child_assignment)
201
+ next if assignment == current_assignment
202
+ original = control_original_descriptor!(registration.fetch("lifecycle_control"))
203
+ map_id = registration.fetch("mapping_id")
204
+ authenticate_inventory_registration!(journal, assignment, events, registration, map_id,
205
+ original.mapping(map_id), commit, inventory.fetch("introductions").fetch(assignment))
206
+ raise AttemptErrors::Conflict, "campaign phase already has a registered child"
207
+ end
208
+ end
209
+ true
210
+ end
211
+ end
212
+ end
213
+ end
214
+ end