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
@@ -1,6 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../molecules/evidence_journal"
3
4
  require_relative "../molecules/canonical_evidence"
5
+ require_relative "service_cleanup_evidence"
6
+ require_relative "service_cleanup_dispatch"
7
+ require_relative "service_publication_evidence"
8
+ require_relative "../molecules/execution_scope_lineage"
4
9
 
5
10
  module Ace
6
11
  module Assign
@@ -9,30 +14,61 @@ module Ace
9
14
  # The journal supplies the record and transient pending view; callers
10
15
  # never supply an alternate reader, artifact path or trusted flag.
11
16
  class ServiceEvidence
17
+ include ServiceCleanupEvidence
18
+ include ServiceCleanupDispatch
19
+ include ServicePublicationEvidence
12
20
  FIELDS = (Molecules::EvidenceJournal::TERMINAL_BINDING_FIELDS +
13
21
  %w[dispatch_ticket_id claim_binding candidate_generation claim_generation policy_digest]).freeze
22
+ CHALLENGE_FIELDS = %w[version request_id input_digest claim_binding dispatch_ticket_id no_effect_challenge
23
+ challenge_generation failure_event_digest failure_generation].freeze
24
+ INSPECTION_FIELDS = %w[version request_id input_digest claim_binding no_effect_challenge challenge_generation
25
+ challenge_event_digest failure_event_digest failure_generation target dispatch_phase effect_absent
26
+ handler_terminated writers_absent].freeze
14
27
 
15
- def initialize(journal:)
28
+ def initialize(journal:, cleanup_artifacts: nil)
16
29
  @journal = journal
17
30
  @canonical = Molecules::CanonicalEvidence.new(journal: journal)
31
+ @cleanup_artifacts = cleanup_artifacts
18
32
  end
19
33
 
20
- def context(record, no_effect: false)
34
+ # Executor-only projection from the same authenticated original record;
35
+ # selectors never come from the receiver or a caller-supplied target.
36
+ def settlement_context(record, commit: @journal.ref_value, read_view: nil)
37
+ pending = {commit: commit, service_read_view: read_view}
38
+ context(record, pending: pending)
39
+ challenge = challenge!(record, pending: pending, current: false) if
40
+ %w[no_effect_challenge challenge_generation challenge_event_digest].any? { |key| record.key?(key) }
41
+ proof = cleanup_dispatch_context!(record, commit: commit, pending: pending) if
42
+ record.values_at("operation", "dispatch_phase") == ["prune-preserved-workspace", "dispatch_started"]
43
+ settlement_projection(record, pending, challenge, proof)
44
+ rescue KeyError, TypeError
45
+ raise AttemptErrors::EvidenceUnavailable, "original service settlement context is incomplete"
46
+ end
47
+
48
+ def context(record, no_effect: false, pending: nil)
21
49
  binding = FIELDS.to_h { |key| [key, record.fetch(key)] }
50
+ if record.fetch("operation") == "prune-preserved-workspace" && record["dispatch_phase"] == "dispatch_started"
51
+ binding["operation_owner_binding"] = record.fetch("operation_owner_binding")
52
+ executor = record.fetch("executor_process_binding")
53
+ boot = record.fetch("worker_process_binding").fetch("started_at").split(":").fetch(1)
54
+ Molecules::ExecutionScopeLineage.validate_process_identity!(executor, boot_id: boot)
55
+ raise AttemptErrors::EvidenceUnavailable, "original cleanup executor differs" unless executor.fetch("uid") == record.fetch("executor_uid")
56
+ binding["executor_process_binding"] = executor
57
+ end
58
+ if record["operation"] == "publish" && record["dispatch_phase"] != "issued"
59
+ executor = record.fetch("executor_process_binding")
60
+ boot = record.fetch("worker_process_binding").fetch("started_at").split(":").fetch(1)
61
+ Molecules::ExecutionScopeLineage.validate_process_identity!(executor, boot_id: boot)
62
+ raise AttemptErrors::EvidenceUnavailable, "original publication executor differs" unless executor.fetch("uid") == record.fetch("executor_uid")
63
+ binding["executor_process_binding"] = executor
64
+ end
22
65
  unless %w[claim_binding policy_digest].all? { |key| binding[key].is_a?(String) && binding[key].match?(/\A[0-9a-f]{64}\z/) } &&
23
66
  %w[candidate_generation claim_generation].all? { |key| binding[key].is_a?(Integer) && binding[key].positive? } &&
24
67
  binding["dispatch_ticket_id"].is_a?(String) && binding["dispatch_ticket_id"].match?(Molecules::JournalMutation::ID)
25
68
  raise AttemptErrors::EvidenceUnavailable, "canonical dispatch binding is invalid"
26
69
  end
27
70
  if no_effect
28
- binding.merge!(%w[no_effect_challenge challenge_generation challenge_event_digest].to_h do |key|
29
- [key, record.fetch(key)]
30
- end)
31
- unless binding["no_effect_challenge"].is_a?(String) && !binding["no_effect_challenge"].empty? &&
32
- binding["challenge_generation"].is_a?(Integer) && binding["challenge_generation"].positive? &&
33
- binding["challenge_event_digest"].is_a?(String) && binding["challenge_event_digest"].match?(/\A[0-9a-f]{64}\z/)
34
- raise AttemptErrors::EvidenceUnavailable, "canonical no-effect challenge is invalid"
35
- end
71
+ return context_for_challenge(record, challenge!(record, pending: pending))
36
72
  end
37
73
  {kind: "service", project_id: record.fetch("project_id"), assignment_id: record.fetch("assignment_id"),
38
74
  attempt_id: record.fetch("attempt_id"), peer_uid: record.fetch("executor_uid"), binding: binding,
@@ -41,13 +77,208 @@ module Ace
41
77
  raise AttemptErrors::EvidenceUnavailable, "canonical service evidence binding is incomplete"
42
78
  end
43
79
 
80
+ # Exact original challenge and failure selected from the same immutable
81
+ # canonical prefix as its import. Pending events are the journal owner's
82
+ # held CAS plan, never caller-supplied alternative history.
83
+ def challenge!(record, pending: nil, current: true)
84
+ context(record)
85
+ selector = record.slice("no_effect_challenge", "challenge_generation", "challenge_event_digest")
86
+ unless selector.keys.sort == %w[no_effect_challenge challenge_generation challenge_event_digest].sort &&
87
+ selector["no_effect_challenge"].is_a?(String) && selector["no_effect_challenge"].match?(/\A[0-9a-f]{32}\z/) &&
88
+ selector["challenge_generation"].is_a?(Integer) && selector["challenge_generation"].positive? &&
89
+ selector["challenge_event_digest"].is_a?(String) && selector["challenge_event_digest"].match?(/\A[0-9a-f]{64}\z/)
90
+ raise AttemptErrors::EvidenceUnavailable, "canonical challenge selector is invalid"
91
+ end
92
+ commit = pending && pending[:commit] || @journal.ref_value
93
+ events = if pending && pending[:pending_events]
94
+ pending.fetch(:current_events) + pending.fetch(:pending_events)
95
+ else
96
+ service_read_events(record, pending)
97
+ end
98
+ unless Models::EvidenceEvent.chain_valid?(events)
99
+ raise AttemptErrors::EvidenceUnavailable, "canonical challenge chain is corrupt"
100
+ end
101
+ selected = events.select { |event| event["digest"] == record.fetch("challenge_event_digest") }
102
+ unless selected.size == 1 && selected.first["type"] == "service_no_effect_challenge"
103
+ raise AttemptErrors::EvidenceUnavailable, "canonical challenge event is missing"
104
+ end
105
+ challenge = selected.first
106
+ payload = challenge.fetch("payload")
107
+ before = events.take_while { |event| event["digest"] != challenge.fetch("digest") }
108
+ failures = events.select do |event|
109
+ %w[service_claim service_transition].include?(event["type"]) &&
110
+ event.dig("payload", "request_id") == record.fetch("request_id") &&
111
+ %w[uncertain failed].include?(event.dig("payload", "state"))
112
+ end
113
+ failure = before.reverse.find { |event| failures.include?(event) }
114
+ failure_prefix = before.take_while { |event| event["digest"] != failure&.fetch("digest") }
115
+ generation = failure_prefix.count { |event| event["type"] == "authority_mutation" } + 1
116
+ unless payload.is_a?(Hash) && payload.keys.sort == CHALLENGE_FIELDS.sort &&
117
+ payload["version"].is_a?(Integer) && payload["version"] == 1 &&
118
+ %w[challenge_generation failure_generation].all? { |key| payload[key].is_a?(Integer) && payload[key].positive? } &&
119
+ payload["no_effect_challenge"].is_a?(String) && payload["no_effect_challenge"].match?(/\A[0-9a-f]{32}\z/) &&
120
+ %w[request_id input_digest claim_binding dispatch_ticket_id no_effect_challenge challenge_generation].all? { |key| payload[key] == record[key] } &&
121
+ failure && (!current || failures.last == failure) && before.any? { |event| event["digest"] == failure.fetch("digest") } &&
122
+ payload["failure_event_digest"] == failure.fetch("digest") && payload["failure_generation"] == generation &&
123
+ payload["challenge_generation"] == before.count { |event| event["type"] == "authority_mutation" } + 1
124
+ raise AttemptErrors::EvidenceUnavailable, "canonical challenge or current failure binding differs"
125
+ end
126
+ position = events.index(challenge)
127
+ if pending && pending[:pending_events]&.any? { |event| event["digest"] == challenge.fetch("digest") }
128
+ unless pending[:operation] == "claim_service_settlement"
129
+ raise AttemptErrors::EvidenceUnavailable, "pending challenge has no fixed authority owner"
130
+ end
131
+ else
132
+ update, accepted = events.values_at(position + 1, position + 2)
133
+ expected_params = record.slice("mapping_id", "assignment_id", "attempt_id", "request_id", "candidate_generation").merge(
134
+ "head" => record.fetch("candidate_head"), "expected_generation" => payload.fetch("challenge_generation") - 1)
135
+ unless update && update["type"] == "service_challenge" && update.dig("payload", "request_id") == record.fetch("request_id") &&
136
+ accepted && accepted["type"] == "authority_mutation" && accepted.dig("payload", "operation") == "claim_service_settlement" &&
137
+ accepted.dig("payload", "assignment_id") == record.fetch("assignment_id") && accepted.dig("payload", "attempt_id") == record.fetch("attempt_id") &&
138
+ accepted.dig("payload", "parameters_digest") == Atoms::EvidenceDigest.digest(expected_params) &&
139
+ accepted.dig("payload", "data", "generation").is_a?(Integer) &&
140
+ accepted.dig("payload", "data", "generation") == payload.fetch("challenge_generation") &&
141
+ accepted.dig("payload", "data", "reconciliation_challenge", "challenge_generation").is_a?(Integer) &&
142
+ accepted.dig("payload", "data", "request_id") == record.fetch("request_id") &&
143
+ accepted.dig("payload", "data", "reconciliation_challenge") == record.slice("no_effect_challenge", "challenge_generation", "challenge_event_digest")
144
+ raise AttemptErrors::EvidenceUnavailable, "canonical challenge lacks its accepted authority mutation"
145
+ end
146
+ commits = service_read_introductions(record,
147
+ [challenge.fetch("digest"), failure.fetch("digest"), update.fetch("digest"), accepted.fetch("digest")], pending)
148
+ unless commits.fetch(challenge.fetch("digest")) == commits.fetch(update.fetch("digest")) &&
149
+ commits.fetch(challenge.fetch("digest")) == commits.fetch(accepted.fetch("digest"))
150
+ raise AttemptErrors::EvidenceUnavailable, "challenge record and authority acceptance were not atomic"
151
+ end
152
+ original = @journal.service_request(record.fetch("request_id"), commit: commits.fetch(challenge.fetch("digest")))
153
+ unless original && %w[uncertain failed].include?(original["state"]) &&
154
+ update.dig("payload", "record_digest") == Atoms::EvidenceDigest.digest(original) &&
155
+ (FIELDS + %w[mapping_id service_id caller_uid authorization worker_process_binding launch_ticket reservation_generation dispatch_phase]).all? { |key| original[key] == record[key] } &&
156
+ original.slice("no_effect_challenge", "challenge_generation", "challenge_event_digest") ==
157
+ record.slice("no_effect_challenge", "challenge_generation", "challenge_event_digest")
158
+ raise AttemptErrors::EvidenceUnavailable, "challenge accepted record differs from original binding"
159
+ end
160
+ end
161
+ challenge
162
+ rescue KeyError, TypeError, NoMethodError
163
+ raise AttemptErrors::EvidenceUnavailable, "canonical challenge binding is incomplete"
164
+ end
165
+
166
+ def inspection!(content, record, challenge)
167
+ unless content.is_a?(String) && content.bytesize <= Molecules::CanonicalEvidence::MAX_ARTIFACT_BYTES
168
+ raise AttemptErrors::EvidenceUnavailable, "no-effect inspection exceeds artifact bounds"
169
+ end
170
+ text = content.dup.force_encoding(Encoding::UTF_8)
171
+ raise AttemptErrors::EvidenceUnavailable, "no-effect inspection is invalid UTF8" unless text.valid_encoding?
172
+ lines = text.lines.filter_map { |line| line.delete_prefix("ace-service-no-effect ").chomp if line.start_with?("ace-service-no-effect ") }
173
+ raise AttemptErrors::EvidenceUnavailable, "no-effect inspection is missing or repeated" unless lines.size == 1
174
+ inspection = JSON.parse(lines.first, create_additions: false, max_nesting: 8,
175
+ allow_duplicate_key: false, allow_comments: false)
176
+ inspection_projection!(inspection, record, challenge)
177
+ rescue JSON::ParserError, KeyError, TypeError
178
+ raise AttemptErrors::EvidenceUnavailable, "no-effect inspection is malformed"
179
+ end
180
+
181
+ def inspection_projection!(inspection, record, challenge)
182
+ expected = challenge.fetch("payload").slice("request_id", "input_digest", "claim_binding", "no_effect_challenge",
183
+ "challenge_generation", "failure_event_digest", "failure_generation").merge("version" => 1,
184
+ "challenge_event_digest" => challenge.fetch("digest"), "target" => record.fetch("target"),
185
+ "dispatch_phase" => record.fetch("dispatch_phase"), "effect_absent" => true,
186
+ "handler_terminated" => true, "writers_absent" => true)
187
+ unless inspection.is_a?(Hash) && inspection.keys.sort == INSPECTION_FIELDS.sort &&
188
+ %w[version challenge_generation failure_generation].all? { |key| inspection[key].is_a?(Integer) } &&
189
+ inspection == expected
190
+ raise AttemptErrors::EvidenceUnavailable, "no-effect inspection differs from original challenge"
191
+ end
192
+ true
193
+ rescue KeyError, TypeError
194
+ raise AttemptErrors::EvidenceUnavailable, "no-effect inspection is malformed"
195
+ end
196
+
44
197
  def call(reference, record, state, pending)
45
- expected = context(record, no_effect: state == "failed-settled")
46
- if pending && pending[:pending_events]
198
+ if reference.is_a?(Array)
199
+ return publication_collection!(reference, record, state, pending) if record["operation"] == "publish"
200
+ return cleanup_collection!(reference, record, state, pending)
201
+ end
202
+ no_effect = state == "failed-settled"
203
+ expected = context(record, no_effect: no_effect, pending: pending)
204
+ bytes = if pending && pending[:pending_events]
47
205
  @canonical.read_pending(reference, **expected, **pending.slice(:current_events, :pending_events, :blobs, :commit))
48
206
  else
49
207
  @canonical.read(reference, **expected, commit: pending && pending[:commit] || @journal.ref_value)
50
208
  end
209
+ if no_effect
210
+ challenge = challenge!(record, pending: pending)
211
+ inspection!(bytes, record, challenge)
212
+ end
213
+ bytes
214
+ end
215
+
216
+ private
217
+
218
+ def settlement_projection(record, pending, challenge, proof)
219
+ selected = nil
220
+ if challenge
221
+ latest_failure = service_read_events(record, pending).reverse.find do |event|
222
+ event["attempt_id"] == record.fetch("attempt_id") &&
223
+ %w[service_claim service_transition].include?(event["type"]) &&
224
+ event.dig("payload", "request_id") == record.fetch("request_id") &&
225
+ %w[uncertain failed].include?(event.dig("payload", "state"))
226
+ end
227
+ if latest_failure && latest_failure.fetch("digest") == challenge.dig("payload", "failure_event_digest")
228
+ selected = challenge.fetch("payload").slice("request_id", "input_digest", "claim_binding",
229
+ "no_effect_challenge", "challenge_generation", "failure_event_digest", "failure_generation")
230
+ .merge("challenge_event_digest" => challenge.fetch("digest"))
231
+ end
232
+ end
233
+ execution = record.slice("mapping_id", "candidate_generation", "claim_binding", "dispatch_phase")
234
+ .merge("head" => record.fetch("candidate_head"))
235
+ if record["operation"] == "prune-preserved-workspace" && record["dispatch_phase"] == "dispatch_started"
236
+ execution.merge!(record.slice("operation_owner_binding", "executor_process_binding"))
237
+ execution.merge!(proof.slice("request_event_digest", "dispatch_event_digest"))
238
+ end
239
+ value = {"version" => 1, "request" => record.slice(*Molecules::EvidenceJournal::TERMINAL_BINDING_FIELDS),
240
+ "execution" => execution, "challenge" => selected}
241
+ freeze_projection(JSON.parse(JSON.generate(value)))
242
+ end
243
+
244
+ def context_for_challenge(record, selection)
245
+ expected = context(record)
246
+ binding = expected.fetch(:binding)
247
+ binding.merge!(record.slice("no_effect_challenge", "challenge_generation", "challenge_event_digest"))
248
+ binding.merge!(selection.fetch("payload").slice("failure_event_digest", "failure_generation"))
249
+ binding["dispatch_phase"] = record.fetch("dispatch_phase")
250
+ expected
251
+ end
252
+
253
+ def service_read_view(pending)
254
+ view = pending && pending[:service_read_view]
255
+ return nil unless view
256
+ unless view.is_a?(Molecules::EvidenceJournal::ServiceReadView) && view.journal.equal?(@journal) &&
257
+ view.commit == pending.fetch(:commit) && view.inventory.fetch("commit") == view.commit
258
+ raise AttemptErrors::EvidenceUnavailable, "service read view differs from its selected owner"
259
+ end
260
+ view
261
+ end
262
+
263
+ def service_read_events(record, pending)
264
+ view = service_read_view(pending)
265
+ selected = view ? view.inventory.fetch("events").fetch(record.fetch("assignment_id")) :
266
+ @journal.read_events(record.fetch("assignment_id"), commit: pending && pending[:commit] || @journal.ref_value)
267
+ selected.select { |event| event.fetch("attempt_id") == record.fetch("attempt_id") }
268
+ end
269
+
270
+ def service_read_introductions(record, selectors, pending)
271
+ view = service_read_view(pending)
272
+ return @journal.event_commits!(assignment_id: record.fetch("assignment_id"), event_digests: selectors,
273
+ commit: pending && pending[:commit] || @journal.ref_value) unless view
274
+ facts = view.inventory.fetch("introductions").fetch(record.fetch("assignment_id"))
275
+ selectors.to_h { |selector| [selector, facts.fetch(selector)] }.freeze
276
+ end
277
+
278
+ def freeze_projection(value)
279
+ value.each_value { |child| freeze_projection(child) } if value.is_a?(Hash)
280
+ value.each { |child| freeze_projection(child) } if value.is_a?(Array)
281
+ value.freeze
51
282
  end
52
283
  end
53
284
  end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Assign
5
+ module Authority
6
+ # Publication-specific facts remain artifacts of the existing service
7
+ # record and canonical import owner, not a second publication journal.
8
+ module ServicePublicationEvidence
9
+ PUBLICATION_NEED_FIELDS = %w[schema classification request_id input_digest claim_binding
10
+ candidate_generation head registry gem_name version artifact_digest dispatch_event_digest previous_challenge_digest].freeze
11
+ PUBLICATION_FIELDS = %w[publication_challenge_digest publication_challenge_ref publication_challenge_event_digest
12
+ publication_challenge_generation publication_issuing_event_digest].freeze
13
+
14
+ def publication_result!(bytes, record, state:)
15
+ value = JSON.parse(bytes, allow_duplicate_key: false, allow_nan: false, max_nesting: 16)
16
+ fields = %w[schema publication target classification code request_id input_digest claim_binding]
17
+ publication = value.fetch("publication")
18
+ allowed = state == "succeeded" ? ["registry_exact_artifact"] :
19
+ %w[provider_rejected_non_otp executor_credentials_unavailable registry_artifact_conflict]
20
+ unless bytes.bytesize.between?(1, 65536) && value.is_a?(Hash) && value.keys.sort == fields.sort &&
21
+ value["schema"] == "ace.publication-result/v1" && value["classification"] == state && allowed.include?(value["code"]) &&
22
+ %w[request_id input_digest claim_binding target].all? { |key| value[key] == record[key] } &&
23
+ publication.is_a?(Hash) && publication.keys.sort == %w[gem_name version head registry artifact_relative_path].sort &&
24
+ publication["head"] == record.fetch("candidate_head") && publication["registry"] == "https://rubygems.org" &&
25
+ publication["gem_name"].is_a?(String) && publication["gem_name"].match?(/\A[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}\z/) &&
26
+ publication["version"].is_a?(String) && publication["version"].bytesize.between?(1, 128) &&
27
+ publication["version"].match?(/\A[0-9]+(?:\.[0-9A-Za-z]+)*(?:-[0-9A-Za-z]+(?:\.[0-9A-Za-z]+)*)?\z/) &&
28
+ "rubygems:#{publication['gem_name']}:#{publication['version']}" == record.dig("target", "resource") &&
29
+ publication["artifact_relative_path"].is_a?(String) && publication["artifact_relative_path"].bytesize.between?(1, 4096) &&
30
+ publication["artifact_relative_path"].end_with?(".gem") &&
31
+ publication["artifact_relative_path"].split("/", -1).all? { |part| part.match?(/\A[a-zA-Z0-9_.-]+\z/) && !%w[. ..].include?(part) }
32
+ raise AttemptErrors::ReceiptRejected, "publication result differs from original artifact"
33
+ end
34
+ value
35
+ rescue JSON::ParserError, KeyError, TypeError, NoMethodError
36
+ raise AttemptErrors::ReceiptRejected, "publication result is invalid"
37
+ end
38
+
39
+ def publication_collection!(references, record, state, pending)
40
+ unless %w[succeeded failed].include?(state) && references.size == 1
41
+ raise AttemptErrors::ReceiptRejected, "publication requires one exact result artifact"
42
+ end
43
+ expected = context(record, pending: pending)
44
+ bytes = if pending && pending[:pending_events]
45
+ @canonical.read_pending(references.first, **expected, **pending.slice(:current_events, :pending_events, :blobs, :commit))
46
+ else
47
+ @canonical.read(references.first, **expected, commit: pending && pending[:commit] || @journal.ref_value)
48
+ end
49
+ publication_result!(bytes, record, state: state)
50
+ [bytes].freeze
51
+ end
52
+
53
+ def publication_need!(bytes, record, previous:)
54
+ value = JSON.parse(bytes, allow_duplicate_key: false, allow_comments: false,
55
+ allow_nan: false, create_additions: false, max_nesting: 16)
56
+ unless bytes.bytesize.between?(1, 65536) && value.is_a?(Hash) &&
57
+ value.keys.sort == PUBLICATION_NEED_FIELDS.sort && value["schema"] == "ace.publication-otp-need/v1" &&
58
+ %w[otp-required otp-rejected otp-expired].include?(value["classification"]) &&
59
+ %w[request_id input_digest claim_binding candidate_generation].all? { |key| value[key] == record[key] } &&
60
+ value["head"] == record["candidate_head"] && value["registry"] == "https://rubygems.org" &&
61
+ value["previous_challenge_digest"] == previous && value["artifact_digest"] == record.dig("target", "artifact_digest") &&
62
+ "rubygems:#{value['gem_name']}:#{value['version']}" == record.dig("target", "resource") &&
63
+ value["dispatch_event_digest"].is_a?(String) && value["dispatch_event_digest"].match?(/\A[0-9a-f]{64}\z/)
64
+ raise AttemptErrors::ReceiptRejected, "publication OTP need differs from original dispatch"
65
+ end
66
+ value
67
+ rescue JSON::ParserError, KeyError, TypeError
68
+ raise AttemptErrors::ReceiptRejected, "publication OTP need is invalid"
69
+ end
70
+
71
+ def publication_challenge!(record, pending: nil)
72
+ context(record)
73
+ commit = pending && pending[:commit] || @journal.ref_value
74
+ events = pending && pending[:pending_events] ? pending.fetch(:current_events) + pending.fetch(:pending_events) :
75
+ @journal.read_events(record.fetch("assignment_id"), commit: commit).select { |event| event["attempt_id"] == record.fetch("attempt_id") }
76
+ unless Models::EvidenceEvent.chain_valid?(events)
77
+ raise AttemptErrors::EvidenceUnavailable, "publication challenge chain is corrupt"
78
+ end
79
+ event = events.find { |row| row["digest"] == record["publication_challenge_event_digest"] }
80
+ unless event && event["type"] == "service_publication_challenge" &&
81
+ event.dig("payload", "challenge_digest") == record["publication_challenge_digest"] &&
82
+ event.dig("payload", "request_id") == record["request_id"] &&
83
+ event.dig("payload", "generation") == record["publication_challenge_generation"]
84
+ raise AttemptErrors::EvidenceUnavailable, "publication challenge introduction is unavailable"
85
+ end
86
+ reference = record.fetch("publication_challenge_ref")
87
+ bytes = if pending && pending[:pending_events]
88
+ @canonical.read_pending(reference, **context(record), current_events: pending.fetch(:current_events),
89
+ pending_events: pending.fetch(:pending_events), blobs: pending.fetch(:blobs), commit: commit)
90
+ else
91
+ @canonical.read(reference, **context(record), commit: commit)
92
+ end
93
+ unless Digest::SHA256.hexdigest(bytes) == record.fetch("publication_challenge_digest")
94
+ raise AttemptErrors::EvidenceUnavailable, "publication challenge digest differs"
95
+ end
96
+ need = publication_need!(bytes, record, previous: event.dig("payload", "previous_challenge_digest"))
97
+ dispatch = events.find { |row| row["digest"] == need.fetch("dispatch_event_digest") }
98
+ unless dispatch && dispatch["type"] == "authority_mutation" && dispatch.dig("payload", "operation") == "begin_dispatch" &&
99
+ dispatch.dig("payload", "data", "request_id") == record.fetch("request_id") &&
100
+ dispatch.dig("payload", "data", "executor_process_binding") == record.fetch("executor_process_binding")
101
+ raise AttemptErrors::EvidenceUnavailable, "publication need has no original executor dispatch"
102
+ end
103
+ need
104
+ rescue KeyError, TypeError
105
+ raise AttemptErrors::EvidenceUnavailable, "publication challenge evidence is incomplete"
106
+ end
107
+ end
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Assign
5
+ module Authority
6
+ # Structural admission of source-owned pins. Artifact authentication belongs
7
+ # to the selected entry owner; these references are never an execution grant.
8
+ module TaskContextEntry
9
+ LIMITS = {"manifest" => 65_536, "wrapper" => 33_554_432}.freeze
10
+
11
+ def self.validate!(value)
12
+ unless value.is_a?(Hash) && value.keys.sort == LIMITS.keys.sort
13
+ raise ArgumentError, "task context entry fields differ"
14
+ end
15
+ LIMITS.each do |key, limit|
16
+ reference = value[key]
17
+ unless reference.is_a?(Hash) && reference.keys.sort == %w[bytes path sha256] &&
18
+ reference["bytes"].is_a?(Integer) && reference["bytes"].between?(1, limit) &&
19
+ reference["sha256"].is_a?(String) && reference["sha256"].match?(/\A[0-9a-f]{64}\z/) &&
20
+ reference["path"].is_a?(String) && reference["path"].encoding == Encoding::UTF_8 &&
21
+ reference["path"].valid_encoding? && reference["path"].bytesize.between?(2, 4096) &&
22
+ !reference["path"].include?("\0") && reference["path"].start_with?("/") &&
23
+ File.expand_path(reference["path"]) == reference["path"]
24
+ raise ArgumentError, "task context entry reference differs"
25
+ end
26
+ end
27
+ value
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -3,6 +3,7 @@
3
3
  require "digest"
4
4
  require "tempfile"
5
5
  require "timeout"
6
+ require "ace/runtime/molecules/protected_socket"
6
7
  require_relative "private_directory"
7
8
 
8
9
  module Ace
@@ -12,11 +13,10 @@ module Ace
12
13
  # Server selects purpose from a source handler's fixed operation table,
13
14
  # admits a transfer slot before calling, and holds no journal lock here.
14
15
  class TransferCodec
15
- LIMITS = {candidate: [64 * 1024 * 1024, 1, 64 * 1024 * 1024],
16
+ LIMITS = {prompt_text: [16_384, 1, 16_384], scope_boundary_observation: [65_536, 1, 65_536], candidate: [64 * 1024 * 1024, 1, 64 * 1024 * 1024],
16
17
  artifacts: [256 * 1024, 16, 64 * 1024],
17
18
  receipt_artifacts: [272 * 1024, 17, 64 * 1024],
18
- service_input: [64 * 1024, 1, 64 * 1024],
19
- inbox_proof: [32 * 1024, 2, 16 * 1024]}.freeze
19
+ service_input: [64 * 1024, 1, 64 * 1024]}.freeze
20
20
  SHA256 = /\A[0-9a-f]{64}\z/
21
21
 
22
22
  class Input
@@ -43,16 +43,51 @@ module Ace
43
43
  end
44
44
  end
45
45
 
46
- def initialize(root:)
47
- @root = PrivateDirectory.verify!(root)
46
+ def initialize(root: nil)
47
+ @root = root && PrivateDirectory.verify!(root)
48
48
  end
49
49
 
50
50
  # Upload write-EOF is required before invoking the mutation consumer.
51
- # Persistent launch gate connections never use this codec.
51
+ # Persistent launch prompt streams use their fixed framed entry point below.
52
52
  def receive(socket, descriptor:, purpose:, deadline:)
53
+ receive_parts(socket, descriptor: descriptor, purpose: purpose, deadline: deadline, eof: true) { |input| yield input }
54
+ end
55
+
56
+ # Fixed authenticated launch_control stream only. A declared exact
57
+ # prompt body is followed by the next bounded control frame; unlike
58
+ # public uploads, this private duplex connection remains writable.
59
+ def receive_launch_prompt(socket, descriptor:, transfer_id:, deadline:)
60
+ deadline = [deadline, Process.clock_gettime(Process::CLOCK_MONOTONIC) + 30].min
61
+ marker = launch_prompt_marker(transfer_id)
62
+ receive_parts(socket, descriptor: descriptor, purpose: :prompt_text, deadline: deadline, eof: false) do |input|
63
+ boundary = Ace::Runtime::Molecules::ProtectedSocket.read(socket, deadline: deadline, limit: 16_384, with_size: true)
64
+ unless boundary.fetch(:data) == marker && boundary.fetch(:bytesize) == (JSON.generate(marker) + "\n").bytesize
65
+ reject!("Private prompt boundary differs")
66
+ end
67
+ yield input
68
+ end
69
+ end
70
+
71
+ def send_launch_prompt(socket, bytes:, descriptor:, transfer_id:, deadline:)
72
+ deadline = [deadline, Process.clock_gettime(Process::CLOCK_MONOTONIC) + 30].min
73
+ marker = launch_prompt_marker(transfer_id)
74
+ send(socket, parts: [bytes], descriptor: descriptor, purpose: :prompt_text, deadline: deadline)
75
+ Ace::Runtime::Molecules::ProtectedSocket.write(socket, marker, deadline: deadline, limit: 16_384)
76
+ end
77
+
78
+ def launch_prompt_marker(transfer_id)
79
+ unless transfer_id.is_a?(String) && transfer_id.match?(/\A[0-9a-f]{32}\z/)
80
+ raise ArgumentError, "invalid private prompt transfer identity"
81
+ end
82
+ {"type" => "launch_prompt_end", "transfer_id" => transfer_id}
83
+ end
84
+ private :launch_prompt_marker
85
+
86
+ def receive_parts(socket, descriptor:, purpose:, deadline:, eof:)
53
87
  validate!(descriptor, purpose)
54
88
  descriptor = descriptor.merge("parts" => descriptor.fetch("parts").map { |part| part.dup.freeze }.freeze).freeze
55
89
  deadline = [deadline, Process.clock_gettime(Process::CLOCK_MONOTONIC) + 30].min
90
+ raise ArgumentError, "receive requires protected source scratch" unless @root
56
91
  PrivateDirectory.verify!(@root)
57
92
  Tempfile.create(["transfer-", ".bytes"], @root, binmode: true) do |file|
58
93
  digest = Digest::SHA256.new
@@ -70,12 +105,13 @@ module Ace
70
105
  reject!("Transferred part digest differs") unless part_digest.hexdigest == part["sha256"]
71
106
  end
72
107
  reject!("Transferred aggregate digest differs") unless digest.hexdigest == descriptor["sha256"]
73
- reject!("Transferred bytes exceed declared size") unless read(socket, 1, deadline).nil?
108
+ reject!("Transferred bytes exceed declared size") if eof && !read(socket, 1, deadline).nil?
74
109
  file.flush
75
110
  file.rewind
76
111
  yield Input.new(file, descriptor)
77
112
  end
78
113
  end
114
+ private :receive_parts
79
115
 
80
116
  def descriptor(parts, purpose:)
81
117
  reject!("Invalid transfer parts") unless parts.is_a?(Array) && parts.all? { |part| part.is_a?(String) }
@@ -113,13 +149,9 @@ module Ace
113
149
  part["bytes"].is_a?(Integer) && part["bytes"].between?(0, each) &&
114
150
  part["sha256"].is_a?(String) && part["sha256"].match?(SHA256) } &&
115
151
  descriptor["bytes"] == descriptor["parts"].sum { |part| part["bytes"] } &&
116
- (!%i[candidate service_input].include?(purpose) || descriptor["bytes"].positive?)
152
+ (!%i[candidate service_input prompt_text].include?(purpose) || descriptor["bytes"].positive?)
117
153
  reject!("Invalid or oversized transfer descriptor")
118
154
  end
119
- if purpose == :inbox_proof &&
120
- (descriptor.fetch("parts").length != 2 || descriptor.fetch("parts").any? { |part| part.fetch("bytes").zero? })
121
- reject!("Inbox proof requires exactly two nonempty bounded parts")
122
- end
123
155
  if purpose == :receipt_artifacts
124
156
  receipt, *evidence = descriptor.fetch("parts")
125
157
  unless receipt.fetch("bytes").between?(1, 16 * 1024) && evidence.sum { |part| part.fetch("bytes") } <= 256 * 1024
@@ -1,4 +1,5 @@
1
1
  # frozen_string_literal: true
2
+ require_relative "../../authority/protected_assignment_context"
2
3
 
3
4
  module Ace
4
5
  module Assign
@@ -11,11 +12,16 @@ module Ace
11
12
  # - <assignment-id>@<step-number>
12
13
  module AssignmentTarget
13
14
  DEFAULT_TARGET_ENV = "ACE_ASSIGN_DEFAULT_TARGET"
14
- Target = Struct.new(:assignment_id, :scope, keyword_init: true)
15
+ Target = Struct.new(:assignment_id, :scope, :prepared_input, keyword_init: true)
15
16
  View = Struct.new(:assignment, :state, :scoped_state, :active_steps, :next_step, :focus_step, :scope_root, keyword_init: true)
16
17
 
17
18
  private
18
19
 
20
+ def refuse_protected_graph_mutation!(options)
21
+ context = @protected_assignment_context ||= Ace::Assign::Authority::ProtectedAssignmentContext.load
22
+ context.refuse_graph_mutation!(options: options)
23
+ end
24
+
19
25
  def resolve_assignment_target(options)
20
26
  assignment_raw = options[:assignment]
21
27
  explicit_target = unless assignment_raw.nil? || assignment_raw.to_s.strip.empty?
@@ -29,7 +35,13 @@ module Ace
29
35
  "does not match #{DEFAULT_TARGET_ENV}=#{target_identity(env_target)}"
30
36
  end
31
37
 
32
- explicit_target || env_target || Target.new(assignment_id: nil, scope: nil)
38
+ target = explicit_target || env_target || Target.new(assignment_id: nil, scope: nil)
39
+ context = @protected_assignment_context ||= Ace::Assign::Authority::ProtectedAssignmentContext.load
40
+ target.prepared_input = context.resolve(options: options, assignment_id: target.assignment_id, scope: target.scope)
41
+ if target.prepared_input && ![Status, Step, Start, Finish, Fail, Resume].any? { |command| is_a?(command) }
42
+ raise AttemptErrors::EvidenceUnavailable, "protected queue command requires a reviewed new prepared version and attempt"
43
+ end
44
+ target
33
45
  end
34
46
 
35
47
  def parse_assignment_target(raw)
@@ -63,6 +75,7 @@ module Ace
63
75
  end
64
76
 
65
77
  def build_executor_for_target(target)
78
+ return Ace::Assign::Authority::PreparedExecutor.new(target.prepared_input) if target.prepared_input
66
79
  return Organisms::AssignmentExecutor.new unless target.assignment_id
67
80
 
68
81
  manager = Molecules::AssignmentManager.new