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,146 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "deployment"
4
+
5
+ module Ace
6
+ module Assign
7
+ module Authority
8
+ # Immutable installer configuration, indexed by canonical content identity.
9
+ # This object is never a transport selector or an execution ledger.
10
+ class DeploymentHistory
11
+ PATH = "/etc/ace/assignment-deployment-history.json"
12
+ attr_reader :original, :candidate, :artifact_reference
13
+
14
+ def self.load = load_protected(nil)
15
+
16
+ def self.load_artifact(reference)
17
+ validate_reference!(reference)
18
+ load_protected(reference)
19
+ end
20
+
21
+ def self.load_protected(reference)
22
+ Ace::Runtime::Molecules::ProtectedArtifactSet.new.with do |artifacts|
23
+ bytes = if reference
24
+ artifacts.read!(reference)
25
+ else
26
+ held, selected = artifacts.read_path!(PATH, limit: 65_536)
27
+ reference = selected
28
+ held
29
+ end
30
+ raw = bytes.dup.force_encoding(Encoding::UTF_8)
31
+ raise ArgumentError, "history manifest is not UTF-8" unless raw.valid_encoding?
32
+ value = JSON.parse(raw, create_additions: false, max_nesting: 32,
33
+ allow_duplicate_key: false, allow_comments: false)
34
+ unless value.is_a?(Hash) && value.keys.sort ==
35
+ %w[candidate_descriptor descriptors original_descriptor public_keys schema] &&
36
+ value["schema"] == "ace.assign.deployment-history/v1"
37
+ raise ArgumentError, "invalid deployment history manifest"
38
+ end
39
+ descriptors = value.fetch("descriptors")
40
+ keys = value.fetch("public_keys")
41
+ unless descriptors.is_a?(Array) && descriptors.size.between?(1, 128) &&
42
+ keys.is_a?(Array) && keys.size <= 128 && descriptors.size + keys.size <= 255
43
+ raise ArgumentError, "deployment history inventory exceeds bounds"
44
+ end
45
+ index = {}
46
+ descriptors.each do |selected|
47
+ validate_reference!(selected)
48
+ digest = selected.fetch("sha256")
49
+ raise ArgumentError, "duplicate historical descriptor selector" if index.key?(digest)
50
+ index[digest] = Deployment.send(:from_verified_bytes, artifacts.read!(selected), immutable(selected))
51
+ end
52
+ selections = %w[original_descriptor candidate_descriptor].map do |name|
53
+ selected = value.fetch(name)
54
+ validate_reference!(selected)
55
+ descriptor = index.fetch(selected.fetch("sha256")) do
56
+ raise ArgumentError, "transaction descriptor is not retained"
57
+ end
58
+ unless descriptor.artifact_reference == selected
59
+ raise ArgumentError, "transaction descriptor reference conflicts"
60
+ end
61
+ descriptor
62
+ end
63
+ public_keys = {}
64
+ keys.each do |entry|
65
+ unless entry.is_a?(Hash) && entry.keys.sort == %w[public_key_sha256 ref] &&
66
+ digest?(entry["public_key_sha256"])
67
+ raise ArgumentError, "invalid historical public key entry"
68
+ end
69
+ validate_reference!(entry.fetch("ref"))
70
+ digest = entry.fetch("public_key_sha256")
71
+ raise ArgumentError, "duplicate historical public key selector" if public_keys.key?(digest)
72
+ key = OpenSSL::PKey.read(artifacts.read!(entry.fetch("ref")))
73
+ unless key.is_a?(OpenSSL::PKey::RSA) && !key.private? &&
74
+ Digest::SHA256.hexdigest(key.public_key.to_der) == digest
75
+ raise ArgumentError, "historical public key identity differs"
76
+ end
77
+ public_keys[digest] = key.freeze
78
+ end
79
+ selections.first.maintenance_inventory(selections.last)
80
+ history = new(selections, index, public_keys, immutable(reference))
81
+ artifacts.verify_unchanged!
82
+ history.freeze
83
+ end
84
+ end
85
+
86
+ def initialize(selections, descriptors, keys, reference)
87
+ @original, @candidate = selections
88
+ @descriptors, @keys = descriptors.freeze, keys.freeze
89
+ @artifact_reference = reference
90
+ end
91
+ private_class_method :new
92
+
93
+ def descriptor!(sha256:)
94
+ @descriptors.fetch(sha256) do
95
+ raise AttemptErrors::EvidenceUnavailable, "original protected descriptor is not retained"
96
+ end
97
+ end
98
+
99
+ def public_key!(sha256:)
100
+ @keys.fetch(sha256) do
101
+ raise AttemptErrors::EvidenceUnavailable, "original protected public key is not retained"
102
+ end
103
+ end
104
+
105
+ def descriptors = @descriptors.values.freeze
106
+
107
+ # Publication moves verified bytes to the fixed current descriptor path.
108
+ # Retention paths stay immutable; canonical content, not pathname, joins
109
+ # the installed selection to that exact independently verified artifact.
110
+ def selects?(deployment, selection: nil)
111
+ return false unless deployment.is_a?(Deployment) && deployment.frozen? && deployment.artifact_reference
112
+ choices = case selection
113
+ when :original then [original]
114
+ when :candidate then [candidate]
115
+ when nil then [original, candidate]
116
+ else raise ArgumentError, "invalid history transaction selection"
117
+ end
118
+ choices.any? do |selected|
119
+ selected.artifact_reference.values_at("sha256", "bytes") == deployment.artifact_reference.values_at("sha256", "bytes")
120
+ end
121
+ end
122
+
123
+ def self.digest?(value) = value.is_a?(String) && value.match?(/\A[0-9a-f]{64}\z/)
124
+
125
+ def self.validate_reference!(value)
126
+ unless value.is_a?(Hash) && value.keys.sort == %w[bytes path sha256] &&
127
+ value["path"].is_a?(String) && value["path"].start_with?("/") &&
128
+ !value["path"].include?("\0") && File.expand_path(value["path"]) == value["path"] &&
129
+ digest?(value["sha256"]) && value["bytes"].is_a?(Integer) && value["bytes"].between?(1, 65_536)
130
+ raise ArgumentError, "invalid protected history artifact reference"
131
+ end
132
+ end
133
+
134
+ def self.immutable(value)
135
+ case value
136
+ when Hash then value.to_h { |key, item| [key.dup.freeze, immutable(item)] }.freeze
137
+ when Array then value.map { |item| immutable(item) }.freeze
138
+ when String then value.dup.freeze
139
+ else value.freeze
140
+ end
141
+ end
142
+ private_class_method :digest?, :validate_reference!, :immutable, :load_protected
143
+ end
144
+ end
145
+ end
146
+ end
@@ -0,0 +1,455 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "digest"
5
+ require "securerandom"
6
+ require_relative "candidate_transfer"
7
+ require_relative "receipt_transfer"
8
+ require_relative "service_evidence"
9
+ require_relative "service_delivery_evidence"
10
+ require_relative "../molecules/canonical_evidence"
11
+
12
+ module Ace
13
+ module Assign
14
+ module Authority
15
+ # Business admission shares the launch origin, lifecycle exclusion and
16
+ # journal CAS. Network transfer is completed outside those locks.
17
+ class Endcap
18
+ OPERATIONS = %w[submit_candidate export_candidate request_review review_status assign_review cancel_review accept_review request_service begin_dispatch complete_service publication_challenge publication_continue claim_service_settlement complete_no_effect service_authorization service_status workspace_prune_preview_context submit_result evidence_fetch bind_inbox finish recover campaign_record_round campaign_export_result].freeze
19
+ TRANSFER_OPERATIONS = {
20
+ "campaign_export_result" => {direction: :download, purpose: :artifacts, roles: %i[worker launcher supervisor]},
21
+ "campaign_record_round" => {direction: :upload, purpose: :receipt_artifacts, roles: %i[launcher supervisor]},
22
+ "submit_result" => {direction: :upload, purpose: :receipt_artifacts, roles: [:worker]},
23
+ "evidence_fetch" => {direction: :download, purpose: :artifacts, roles: %i[worker reviewer executor launcher supervisor signer]},
24
+ "submit_candidate" => {direction: :upload, purpose: :candidate, roles: %i[worker launcher]},
25
+ "export_candidate" => {direction: :download, purpose: :candidate, roles: %i[reviewer executor]},
26
+ "accept_review" => {direction: :upload, purpose: :receipt_artifacts, roles: [:reviewer]},
27
+ "request_service" => {direction: :upload, purpose: :service_input, roles: [:executor]},
28
+ "begin_dispatch" => {direction: :upload, purpose: :service_input, roles: [:executor]},
29
+ "service_authorization" => {direction: :upload, purpose: :service_input, roles: [:executor]},
30
+ "complete_service" => {direction: :upload, purpose: :receipt_artifacts, roles: [:executor]},
31
+ "publication_challenge" => {direction: :upload, purpose: :receipt_artifacts, roles: [:executor]},
32
+ "complete_no_effect" => {direction: :upload, purpose: :receipt_artifacts, roles: [:executor]}
33
+ }.freeze
34
+ PARAMETERS = {
35
+ "campaign_export_result" => %w[mapping_id assignment_id attempt_id candidate_generation head],
36
+ "campaign_record_round" => %w[mapping_id assignment_id attempt_id candidate_generation head input_sha256 transfer],
37
+ "submit_result" => %w[mapping_id assignment_id attempt_id expected_generation candidate_generation head receipt_sha256 transfer],
38
+ "evidence_fetch" => %w[mapping_id assignment_id attempt_id kind purpose_id artifact_id],
39
+ "submit_candidate" => %w[mapping_id assignment_id attempt_id expected_generation candidate_generation head transfer],
40
+ "export_candidate" => %w[mapping_id assignment_id attempt_id candidate_generation head purpose_id],
41
+ "request_review" => %w[mapping_id assignment_id attempt_id expected_generation candidate_generation head],
42
+ "assign_review" => %w[mapping_id assignment_id attempt_id expected_generation candidate_generation head reviewer_uid reviewer_process_binding],
43
+ "cancel_review" => %w[mapping_id assignment_id attempt_id head candidate_generation review_event_id expected_generation],
44
+ "accept_review" => %w[mapping_id assignment_id attempt_id expected_generation candidate_generation head purpose_id receipt_sha256 transfer],
45
+ "request_service" => %w[mapping_id assignment_id attempt_id expected_generation candidate_generation head request_id operation input_digest target authorization service_id worker_process_binding transfer],
46
+ "begin_dispatch" => %w[mapping_id assignment_id attempt_id expected_generation candidate_generation head request_id claim_binding transfer],
47
+ "workspace_prune_preview_context" => %w[mapping_id assignment_id attempt_id service_id worker_process_binding],
48
+ "service_status" => %w[mapping_id assignment_id attempt_id candidate_generation head request_id],
49
+ "service_authorization" => %w[mapping_id assignment_id attempt_id candidate_generation head request_id claim_binding input_digest transfer],
50
+ "complete_service" => %w[mapping_id assignment_id attempt_id candidate_generation head request_id claim_binding receipt_sha256 transfer],
51
+ "claim_service_settlement" => %w[mapping_id assignment_id attempt_id candidate_generation head request_id expected_generation],
52
+ "publication_challenge" => %w[mapping_id assignment_id attempt_id candidate_generation head request_id claim_binding input_digest expected_generation receipt_sha256 transfer],
53
+ "publication_continue" => %w[mapping_id assignment_id attempt_id candidate_generation head request_id claim_binding input_digest expected_generation challenge_digest],
54
+ "complete_no_effect" => %w[mapping_id assignment_id attempt_id candidate_generation head request_id claim_binding reconciliation_challenge receipt_sha256 transfer]
55
+ }.freeze
56
+
57
+ def initialize(deployment:, launch:, kernel: Ace::Runtime::Molecules::ProtectedLinux.new, service_policy: nil, inbox_context_clients: nil, deployment_history: nil)
58
+ @deployment, @launch, @kernel, @service_policy = deployment, launch, kernel, service_policy
59
+ @inbox_context_clients = inbox_context_clients
60
+ @deployment_history = deployment_history
61
+ launch.attach_result_owner(self) if launch.respond_to?(:attach_result_owner)
62
+ end
63
+
64
+ # Internal registration consumer only: caller holds the original parent
65
+ # lifecycle exclusion. Read one authenticated prefix; never accept a
66
+ # child's revisions as evidence of the parent's current candidate.
67
+ def campaign_parent_candidate!(journal:, commit:, params:, map:)
68
+ events = @launch.preview_attempt_events!(journal: journal, commit: commit, params: params, map: map)
69
+ original = active_origin(events, params)
70
+ current = candidate(events)
71
+ unless current && original.fetch("scope") == params.fetch("scope") &&
72
+ original.fetch("base_head") == params.fetch("base") &&
73
+ current.fetch("head") == params.fetch("head") &&
74
+ current.fetch("candidate_generation") == params.fetch("candidate_generation") && journal.ref_value == commit
75
+ raise AttemptErrors::Conflict, "campaign parent candidate changed"
76
+ end
77
+ projection = params.slice("assignment_id", "attempt_id").merge(
78
+ "scope" => original.fetch("scope"), "base" => original.fetch("base_head"),
79
+ "head" => current.fetch("head"), "candidate_generation" => current.fetch("candidate_generation"))
80
+ projection.transform_values { |value| value.is_a?(String) ? value.dup.freeze : value }.freeze
81
+ rescue KeyError, TypeError
82
+ raise AttemptErrors::EvidenceUnavailable, "campaign parent candidate is unavailable"
83
+ end
84
+
85
+ def authorize_transfer!(request:, peer:, role:)
86
+ return dispatch_campaign_export(request: request, peer: peer, role: role, body: false) if request.fetch("operation") == "campaign_export_result"
87
+ return authorize_campaign_round!(request: request, peer: peer, role: role) if request.fetch("operation") == "campaign_record_round"
88
+ return dispatch_prepared_fetch(request: request, peer: peer, role: role, body: false) if prepared_fetch?(request)
89
+ return authorize_result_transfer!(request: request, peer: peer, role: role) if %w[submit_result evidence_fetch].include?(request.fetch("operation"))
90
+ params, map = validate_request(request)
91
+ return authorize_service_settlement!(request, params, map, peer, role) if request.fetch("operation") == "complete_no_effect"
92
+ return authorize_publication_transfer!(request, params, map, peer, role) if request.fetch("operation") == "publication_challenge"
93
+ return authorize_service_transfer!(request, params, map, peer, role) if %w[request_service begin_dispatch complete_service service_authorization].include?(request.fetch("operation"))
94
+ @launch.with_assignment(params: params, map: map) do |journal, _registration|
95
+ protected_journal!(journal)
96
+ events = attempt_events(journal, params)
97
+ origin = active_origin(events, params)
98
+ if request.fetch("operation") == "submit_candidate"
99
+ worker_or_launcher!(peer, role, map, origin)
100
+ candidate_generation!(candidate(events), params) unless candidate_submission_replay!(journal, events, request, params)
101
+ else
102
+ export_admission!(journal, events, params, map, origin, peer, role)
103
+ end
104
+ end
105
+ true
106
+ end
107
+
108
+ def dispatch(request:, peer:, role:, transfer: nil)
109
+ return dispatch_campaign_export(request: request, peer: peer, role: role) if request.fetch("operation") == "campaign_export_result"
110
+ return dispatch_campaign_round(request: request, peer: peer, role: role, transfer: transfer) if request.fetch("operation") == "campaign_record_round"
111
+ if prepared_fetch?(request)
112
+ raise ArgumentError, "prepared fetch forbids upload" unless transfer.nil?
113
+ return dispatch_prepared_fetch(request: request, peer: peer, role: role)
114
+ end
115
+ return dispatch_bind_inbox(request: request, peer: peer, role: role, transfer: transfer) if request.fetch("operation") == "bind_inbox"
116
+ return dispatch_finish(request: request, peer: peer, role: role, transfer: transfer) if request.fetch("operation") == "finish"
117
+ return dispatch_recover(request: request, peer: peer, role: role, transfer: transfer) if request.fetch("operation") == "recover"
118
+ return dispatch_result(request: request, peer: peer, role: role, transfer: transfer) if %w[submit_result evidence_fetch].include?(request.fetch("operation"))
119
+ return review_status(request, peer, role, transfer) if request.fetch("operation") == "review_status"
120
+ params, map = validate_request(request)
121
+ return request_review(request, params, map, peer, role, transfer) if request.fetch("operation") == "request_review"
122
+ return cancel_review(request, params, map, peer, role, transfer) if request.fetch("operation") == "cancel_review"
123
+ return dispatch_publication(request, params, map, peer, role, transfer) if %w[publication_challenge publication_continue].include?(request.fetch("operation"))
124
+ return dispatch_service_settlement(request, params, map, peer, role, transfer) if %w[claim_service_settlement complete_no_effect].include?(request.fetch("operation"))
125
+ return workspace_prune_preview_context(request, params, map, peer, role, transfer) if request.fetch("operation") == "workspace_prune_preview_context"
126
+ return service_status(request, params, map, peer, role) if request.fetch("operation") == "service_status"
127
+ return dispatch_service(request, params, map, peer, role, transfer) if %w[request_service begin_dispatch complete_service service_authorization].include?(request.fetch("operation"))
128
+ admitted = nil
129
+ if request.fetch("operation") == "submit_candidate"
130
+ authorize_transfer!(request: request, peer: peer, role: role)
131
+ raise ArgumentError, "candidate transfer is missing" unless transfer && transfer.count == 1
132
+ bytes = transfer.bytes
133
+ descriptor = params.fetch("transfer")
134
+ project = @deployment.project(map.fetch("project_id"))
135
+ admitted = CandidateTransfer.new(root: project.fetch("candidate_root")).admit(
136
+ bytes: bytes, sha256: descriptor.fetch("sha256"), size: descriptor.fetch("bytes"), head: params.fetch("head"))
137
+ elsif request.fetch("operation") == "accept_review"
138
+ authorize_transfer!(request: request, peer: peer, role: role)
139
+ admitted = ReceiptTransfer.decode(input: transfer, receipt_sha256: params.fetch("receipt_sha256"),
140
+ artifact_field: "artifacts", reference_key: "path")
141
+ end
142
+ @launch.with_assignment(params: params, map: map) do |journal, _registration|
143
+ protected_journal!(journal)
144
+ events = attempt_events(journal, params)
145
+ origin = active_origin(events, params)
146
+ if request.fetch("operation") == "export_candidate"
147
+ current = export_admission!(journal, events, params, map, origin, peer, role)
148
+ bytes = journal.blob(current.fetch("bundle_ref"))
149
+ unless bytes.bytesize == current.fetch("bytes") && Digest::SHA256.hexdigest(bytes) == current.fetch("sha256")
150
+ raise AttemptErrors::EvidenceUnavailable, "canonical candidate bundle differs"
151
+ end
152
+ return {data: current.slice("head", "tree", "candidate_generation", "sha256", "bytes"),
153
+ replayed: false, transfer_parts: [bytes]}
154
+ end
155
+ # Journal replay skips its callback. Authenticate the caller here
156
+ # as well as on every fresh/CAS-retried mutation below.
157
+ if request.fetch("operation") == "accept_review"
158
+ export_admission!(journal, events, params, map, origin, peer, role)
159
+ else
160
+ worker_or_launcher!(peer, role, map, origin,
161
+ launcher_only: request.fetch("operation") == "assign_review")
162
+ end
163
+ delegated_review_request!(journal, events, request, params, peer, role) if request.fetch("operation") == "assign_review"
164
+ candidate_submission_replay!(journal, events, request, params, admitted: admitted) if request.fetch("operation") == "submit_candidate"
165
+ result = journal.mutate(assignment_id: params.fetch("assignment_id"), attempt_id: params.fetch("attempt_id"),
166
+ mutation_id: request.fetch("mutation_id"), operation: request.fetch("operation"),
167
+ parameters_digest: Atoms::EvidenceDigest.digest(params), expected_generation: params.fetch("expected_generation"),
168
+ with_replay: true) do |events, _commit, _generation|
169
+ origin = active_origin(events, params)
170
+ case request.fetch("operation")
171
+ when "submit_candidate"
172
+ worker_or_launcher!(peer, role, map, origin)
173
+ current = candidate(events)
174
+ candidate_generation!(current, params)
175
+ path = "candidates/bundles/#{SecureRandom.hex(16)}"
176
+ data = admitted.except("bundle").merge("bundle_ref" => path,
177
+ "candidate_generation" => params.fetch("candidate_generation") + 1,
178
+ "worker_uid" => map.fetch("worker_uid"))
179
+ {data: data, blobs: {path => admitted.fetch("bundle")}}
180
+ when "assign_review"
181
+ worker_or_launcher!(peer, role, map, origin, launcher_only: true)
182
+ current = exact_candidate!(candidate(events), params)
183
+ delegated = delegated_review_request!(journal, events, request, params, peer, role)
184
+ reservation = active_review_reservation(events, current)
185
+ if delegated
186
+ unless reservation && reservation.fetch("digest") == delegated.fetch("digest") && !active_review_event(events, current)
187
+ raise AttemptErrors::Conflict, "delegated review reservation is no longer assignable"
188
+ end
189
+ elsif reservation
190
+ raise AttemptErrors::Conflict, "candidate review reservation is active"
191
+ end
192
+ reviewer = params.fetch("reviewer_process_binding")
193
+ uid = params.fetch("reviewer_uid")
194
+ project = @deployment.project(map.fetch("project_id"))
195
+ unless uid.is_a?(Integer) && project.fetch("reviewer_uids").include?(uid) && uid != map.fetch("worker_uid") &&
196
+ reviewer.is_a?(Hash) && reviewer["uid"] == uid
197
+ raise AttemptErrors::UnauthorizedIdentity, "reviewer must be independently mapped"
198
+ end
199
+ @kernel.live!(reviewer)
200
+ {data: current.slice("head", "candidate_generation").merge(
201
+ "review_id" => SecureRandom.hex(16), "reviewer_uid" => uid, "reviewer_actor" => "uid-#{uid}",
202
+ "reviewer_process_binding" => reviewer)}
203
+ when "accept_review"
204
+ current = export_admission!(journal, events, params, map, origin, peer, role)
205
+ review = assigned_review(events)
206
+ accept_review_plan(journal, events, params, map, current, review, admitted)
207
+ end
208
+ end
209
+ if request.fetch("operation") == "assign_review"
210
+ review_event_reply(journal, result, request, params, "assignment_event_id")
211
+ else
212
+ result
213
+ end
214
+ end
215
+ end
216
+
217
+ private
218
+
219
+ def protected_journal!(journal)
220
+ unless journal.evidence_mode == :protected
221
+ raise ArgumentError, "full-service Endcap requires canonical protected evidence composition"
222
+ end
223
+ end
224
+
225
+ def validate_request(request)
226
+ operation = request.fetch("operation")
227
+ params = request.fetch("params")
228
+ unless params.is_a?(Hash) && params.keys.sort == PARAMETERS.fetch(operation).sort
229
+ raise ArgumentError, "invalid Endcap parameters"
230
+ end
231
+ %w[mapping_id assignment_id attempt_id].each do |key|
232
+ raise ArgumentError, "invalid Endcap identifier" unless params[key].is_a?(String) && params[key].match?(Molecules::JournalMutation::ID)
233
+ end
234
+ unless operation == "workspace_prune_preview_context" || (params["head"].is_a?(String) && params["head"].match?(CandidateTransfer::SHA) &&
235
+ params["candidate_generation"].is_a?(Integer) && params["candidate_generation"] >= 0)
236
+ raise ArgumentError, "invalid candidate binding"
237
+ end
238
+ if %w[service_authorization service_status workspace_prune_preview_context].include?(operation) && !request.fetch("mutation_id").nil?
239
+ raise ArgumentError, "authorization read must not supply a mutation ID"
240
+ end
241
+ map = @deployment.mapping(params.fetch("mapping_id"))
242
+ raise AttemptErrors::UnauthorizedIdentity, "project mapping differs" unless map.fetch("project_id") == request.fetch("project_id")
243
+ [params, map]
244
+ end
245
+
246
+ def attempt_events(journal, params)
247
+ journal.read_events(params.fetch("assignment_id")).select { |event| event["attempt_id"] == params.fetch("attempt_id") }
248
+ end
249
+
250
+ def active_origin(events, params)
251
+ unless Models::EvidenceEvent.chain_valid?(events) &&
252
+ !events.any? { |event| %w[receipt_accepted transition reconciliation].include?(event["type"]) &&
253
+ %w[succeeded failed stopped].include?(event.dig("payload", "to") || event.dig("payload", "resolution") || event.dig("payload", "receipt", "verdict")) }
254
+ raise AttemptErrors::InvalidState, "attempt is not active"
255
+ end
256
+ origin = @launch.origin(events, assignment_id: params.fetch("assignment_id"), attempt_id: params.fetch("attempt_id"),
257
+ mapping_id: params.fetch("mapping_id"))
258
+ raise AttemptErrors::InvalidState, "exact bound launch is required" unless %w[bound issued].include?(origin["phase"])
259
+ @kernel.live!(origin.fetch("process_binding").fetch("process_identity"))
260
+ origin
261
+ end
262
+
263
+ def worker_or_launcher!(peer, role, map, origin, launcher_only: false)
264
+ anchor = if role == :worker && !launcher_only && peer["uid"] == map.fetch("worker_uid")
265
+ origin.fetch("process_binding").fetch("process_identity")
266
+ elsif role == :launcher && peer["uid"] == map.fetch("launcher_uid")
267
+ origin.fetch("launcher_identity")
268
+ else
269
+ raise AttemptErrors::UnauthorizedIdentity, "operation requires owning worker or launcher"
270
+ end
271
+ unless @kernel.descendant?(peer, anchor)
272
+ raise AttemptErrors::UnauthorizedIdentity, "peer does not descend from exact launch binding"
273
+ end
274
+ end
275
+
276
+ def candidate(events)
277
+ event = events.reverse.find { |entry| entry["type"] == "authority_mutation" && entry.dig("payload", "operation") == "submit_candidate" }
278
+ event&.dig("payload", "data")
279
+ end
280
+
281
+ # Header replay admission still requires the live original caller above.
282
+ # TransferCodec and CandidateTransfer verify the exact body before dispatch;
283
+ # the descriptor is part of the original immutable parameter digest.
284
+ def candidate_submission_replay!(journal, events, request, params, admitted: nil)
285
+ replay = journal.mutation_result(request.fetch("mutation_id"))
286
+ return false unless replay
287
+ unless replay["operation"] == "submit_candidate" && replay["assignment_id"] == params.fetch("assignment_id") &&
288
+ replay["attempt_id"] == params.fetch("attempt_id") && replay["parameters_digest"] == Atoms::EvidenceDigest.digest(params)
289
+ raise AttemptErrors::Conflict, "candidate replay input differs"
290
+ end
291
+ data = replay.fetch("data")
292
+ retained = retained_candidate(events, data.fetch("candidate_generation"))
293
+ unless retained == data && data.fetch("candidate_generation") == params.fetch("candidate_generation") + 1 &&
294
+ data.fetch("head") == params.fetch("head")
295
+ raise AttemptErrors::EvidenceUnavailable, "candidate replay provenance differs"
296
+ end
297
+ bytes = journal.bounded_blob(data.fetch("bundle_ref"), commit: replay.fetch("journal_commit"), max_bytes: 64 * 1024 * 1024)
298
+ unless bytes.bytesize == data.fetch("bytes") && Digest::SHA256.hexdigest(bytes) == data.fetch("sha256")
299
+ raise AttemptErrors::EvidenceUnavailable, "candidate replay bundle differs"
300
+ end
301
+ if admitted && admitted.values_at("head", "tree", "sha256", "bytes") != data.values_at("head", "tree", "sha256", "bytes")
302
+ raise AttemptErrors::EvidenceUnavailable, "candidate replay normalized bundle differs"
303
+ end
304
+ true
305
+ rescue KeyError, TypeError
306
+ raise AttemptErrors::EvidenceUnavailable, "candidate replay evidence is malformed"
307
+ end
308
+
309
+ def candidate_generation!(current, params)
310
+ unless (current && current.fetch("candidate_generation") || 0) == params.fetch("candidate_generation")
311
+ raise AttemptErrors::Conflict, "candidate generation changed"
312
+ end
313
+ end
314
+
315
+ def exact_candidate!(current, params)
316
+ candidate_generation!(current, params)
317
+ raise AttemptErrors::Conflict, "candidate head differs" unless current && current.fetch("head") == params.fetch("head")
318
+ current
319
+ end
320
+
321
+ def export_admission!(journal, events, params, map, _origin, peer, role)
322
+ current = exact_candidate!(candidate(events), params)
323
+ @kernel.live!(peer)
324
+ if role == :reviewer
325
+ assignment = active_review_event(events, current)&.dig("payload", "data")
326
+ unless assignment && assignment["review_id"] == params["purpose_id"] && assignment["head"] == current["head"] &&
327
+ assignment["candidate_generation"] == current["candidate_generation"] && assignment["reviewer_uid"] == peer["uid"] &&
328
+ @kernel.descendant?(peer, assignment.fetch("reviewer_process_binding"))
329
+ raise AttemptErrors::UnauthorizedIdentity, "candidate export requires assigned independent reviewer"
330
+ end
331
+ elsif role == :executor
332
+ record = journal.service_request(params.fetch("purpose_id"))
333
+ unless record && record["assignment_id"] == params["assignment_id"] && record["attempt_id"] == params["attempt_id"] &&
334
+ record["executor_uid"] == peer["uid"] && record["candidate_head"] == current["head"] &&
335
+ record["candidate_generation"] == current["candidate_generation"] && record["dispatch_phase"] == "issued"
336
+ raise AttemptErrors::UnauthorizedIdentity, "candidate export requires current executor ticket"
337
+ end
338
+ else
339
+ raise AttemptErrors::UnauthorizedIdentity, "candidate export purpose is unauthorized"
340
+ end
341
+ current
342
+ end
343
+
344
+ def assigned_review(events)
345
+ events.reverse.find { |event| event["type"] == "authority_mutation" && event.dig("payload", "operation") == "assign_review" }&.dig("payload", "data")
346
+ end
347
+
348
+ # A retained approval grants permission only while its original imported
349
+ # bytes, independent reviewer and exact candidate still verify. An old
350
+ # successful mutation reply is not a substitute for canonical evidence.
351
+ def approved_review!(journal, events, params, map, current, commit: journal.ref_value, service_operation: nil)
352
+ accepted = events.reverse.find { |event| event["type"] == "authority_mutation" &&
353
+ event.dig("payload", "operation") == "accept_review" }&.dig("payload", "data")
354
+ review = active_review_event(events, current)&.dig("payload", "data")
355
+ unless accepted && review && accepted["head"] == current["head"] &&
356
+ accepted["candidate_generation"] == current["candidate_generation"] &&
357
+ accepted["review_id"] == review["review_id"] && accepted["reviewer_uid"] == review["reviewer_uid"] &&
358
+ accepted["review_binding"] == review.slice("review_id", "head", "candidate_generation", "reviewer_uid", "reviewer_process_binding") &&
359
+ review["reviewer_uid"] != map.fetch("worker_uid")
360
+ raise AttemptErrors::ReceiptRejected, "current candidate requires its exact independent review"
361
+ end
362
+ context = {kind: "review", project_id: map.fetch("project_id"), assignment_id: params.fetch("assignment_id"),
363
+ attempt_id: params.fetch("attempt_id"), peer_uid: review.fetch("reviewer_uid"),
364
+ binding: accepted.fetch("review_binding"), request_id_or_event_id: review.fetch("review_id"),
365
+ generation: current.fetch("candidate_generation")}
366
+ canonical = Molecules::CanonicalEvidence.new(journal: journal)
367
+ reader = ->(_data, artifact) { canonical.read({"ref" => artifact.fetch("path"), "sha256" => artifact.fetch("sha256")},
368
+ **context, commit: commit) }
369
+ intent = events.find { |event| event["type"] == "intent" }
370
+ attempt = journal.send(:build_attempt, params.fetch("assignment_id"), params.fetch("attempt_id"),
371
+ intent.fetch("payload"), events, "running")
372
+ identity = Molecules::ExecutionIdentityResolver::Identity.new(actor: "protected-authority", role: "service", runtime: "unix")
373
+ receipt = Molecules::ReceiptVerifier.new(artifact_reader: reader).verify!(accepted.fetch("review_receipt"),
374
+ attempt: attempt, identity: identity, live_head: current.fetch("head"), repo_root: journal.repo_root)
375
+ unless receipt.digest == accepted.fetch("receipt_digest") && receipt.operation == "review" &&
376
+ receipt.to_h.dig("review", "reviewer", "actor") == review.fetch("reviewer_actor")
377
+ raise AttemptErrors::ReceiptRejected, "canonical review receipt differs"
378
+ end
379
+ if %w[ready merge].include?(service_operation)
380
+ campaign_service_checks!(journal: journal, commit: commit, events: events, params: params, map: map, current: current)
381
+ end
382
+ accepted
383
+ rescue KeyError
384
+ raise AttemptErrors::ReceiptRejected, "canonical review binding is incomplete"
385
+ end
386
+
387
+ def accept_review_plan(journal, events, params, map, current, review, admitted)
388
+ receipt = admitted.fetch(:receipt)
389
+ raise AttemptErrors::EvidenceUnavailable, "protected campaigns require their canonical owner" if receipt["campaign"]
390
+ unless receipt["operation"] == "review" && receipt["verdict"] == "succeeded" &&
391
+ receipt["producer"] == {"actor" => map.fetch("worker_actor"), "role" => "worker", "runtime" => "herdr"} &&
392
+ receipt.dig("review", "reviewer", "actor") == review.fetch("reviewer_actor")
393
+ raise AttemptErrors::ReceiptRejected, "review attribution must match authenticated candidate and reviewer"
394
+ end
395
+ original = Models::ExecutionReceipt.from_h(receipt)
396
+ unless original.digest == Atoms::EvidenceDigest.digest(original.digest_payload)
397
+ raise AttemptErrors::ReceiptRejected, "uploaded review receipt digest differs"
398
+ end
399
+ context = {kind: "review", project_id: map.fetch("project_id"), assignment_id: params.fetch("assignment_id"),
400
+ attempt_id: params.fetch("attempt_id"), peer_uid: review.fetch("reviewer_uid"),
401
+ binding: review.slice("review_id", "head", "candidate_generation", "reviewer_uid", "reviewer_process_binding"),
402
+ request_id_or_event_id: review.fetch("review_id"), generation: current.fetch("candidate_generation")}
403
+ canonical = Molecules::CanonicalEvidence.new(journal: journal)
404
+ plan = canonical.import_plan(**context, artifacts: admitted.fetch(:artifacts),
405
+ admitted_after_event_digest: events.last.fetch("digest"))
406
+ original_refs = receipt.fetch("artifacts")
407
+ normalized = JSON.parse(JSON.generate(receipt)).except("digest", "recorded_at")
408
+ normalized["artifacts"] = plan.fetch(:references).map { |reference| {"path" => reference.fetch("ref"), "sha256" => reference.fetch("sha256")} }
409
+ if normalized["campaign"]
410
+ index = original_refs.index(normalized.fetch("campaign").fetch("result"))
411
+ raise AttemptErrors::ReceiptRejected, "campaign result must name an uploaded artifact" unless index
412
+ normalized["campaign"]["result"] = normalized.fetch("artifacts").fetch(index)
413
+ end
414
+ pending = journal.send(:chain_mutation_events, params.fetch("attempt_id"), events.last.fetch("digest"), plan.fetch(:events))
415
+ reader = lambda do |_data, artifact|
416
+ canonical.read_pending({"ref" => artifact.fetch("path"), "sha256" => artifact.fetch("sha256")},
417
+ **context, current_events: events, pending_events: pending, blobs: plan.fetch(:blobs), commit: journal.ref_value)
418
+ end
419
+ intent = events.find { |event| event["type"] == "intent" }
420
+ attempt = journal.send(:build_attempt, params.fetch("assignment_id"), params.fetch("attempt_id"),
421
+ intent.fetch("payload"), events, "running")
422
+ identity = Molecules::ExecutionIdentityResolver::Identity.new(actor: "protected-authority", role: "service", runtime: "unix")
423
+ verified = Molecules::ReceiptVerifier.new(artifact_reader: reader).verify!(normalized,
424
+ attempt: attempt, identity: identity, live_head: current.fetch("head"), repo_root: journal.repo_root)
425
+ plan.merge(data: {"head" => current.fetch("head"), "candidate_generation" => current.fetch("candidate_generation"),
426
+ "review_id" => review.fetch("review_id"), "reviewer_uid" => review.fetch("reviewer_uid"),
427
+ "review_binding" => context.fetch(:binding), "review_receipt" => verified.to_h,
428
+ "receipt_digest" => verified.digest, "uploaded_receipt_sha256" => admitted.fetch(:receipt_sha256)})
429
+ rescue KeyError
430
+ raise AttemptErrors::ReceiptRejected, "review receipt binding is incomplete"
431
+ end
432
+ end
433
+ end
434
+ end
435
+ end
436
+
437
+ require_relative "endcap_services"
438
+ require_relative "endcap_service_settlement"
439
+
440
+ require_relative "endcap_campaign_results"
441
+ require_relative "endcap_campaign_rounds"
442
+ require_relative "endcap_results"
443
+
444
+ require_relative "endcap_inboxes"
445
+
446
+ require_relative "endcap_contexts"
447
+ require_relative "endcap_prepared_work"
448
+
449
+ require_relative "endcap_reviews"
450
+
451
+ require_relative "endcap_review_requests"
452
+
453
+ require_relative "endcap_attempts"
454
+
455
+ require_relative "endcap_publication"