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.
- checksums.yaml +4 -4
- data/.ace-defaults/assign/catalog/steps/auto-merge.step.yml +11 -0
- data/CHANGELOG.md +25 -0
- data/docs/protected-authority.md +23 -7
- data/docs/usage.md +117 -4
- data/exe/ace-scope-ready +55 -0
- data/handbook/workflow-instructions/assign/drive.wf.md +26 -1
- data/lib/ace/assign/atoms/attempt_state_machine.rb +8 -0
- data/lib/ace/assign/atoms/delivery_parameters.rb +4 -39
- data/lib/ace/assign/authority/assignment_inventory.rb +290 -0
- data/lib/ace/assign/authority/campaign_consumer_policy.rb +100 -0
- data/lib/ace/assign/authority/campaign_execution.rb +119 -0
- data/lib/ace/assign/authority/campaign_round_transfer.rb +82 -0
- data/lib/ace/assign/authority/candidate_transfer.rb +169 -2
- data/lib/ace/assign/authority/cleanup_result_protection.rb +53 -0
- data/lib/ace/assign/authority/client.rb +156 -6
- data/lib/ace/assign/authority/deployment.rb +201 -39
- data/lib/ace/assign/authority/deployment_history.rb +146 -0
- data/lib/ace/assign/authority/endcap.rb +131 -18
- data/lib/ace/assign/authority/endcap_attempts.rb +274 -0
- data/lib/ace/assign/authority/endcap_campaign_results.rb +232 -0
- data/lib/ace/assign/authority/endcap_campaign_rounds.rb +266 -0
- data/lib/ace/assign/authority/endcap_contexts.rb +23 -0
- data/lib/ace/assign/authority/endcap_inboxes.rb +3 -280
- data/lib/ace/assign/authority/endcap_prepared_work.rb +80 -0
- data/lib/ace/assign/authority/endcap_publication.rb +207 -0
- data/lib/ace/assign/authority/endcap_results.rb +36 -18
- data/lib/ace/assign/authority/endcap_review_requests.rb +183 -0
- data/lib/ace/assign/authority/endcap_reviews.rb +130 -0
- data/lib/ace/assign/authority/endcap_service_settlement.rb +152 -0
- data/lib/ace/assign/authority/endcap_services.rb +221 -18
- data/lib/ace/assign/authority/execution_scope_observation.rb +468 -41
- data/lib/ace/assign/authority/inbox_context_original.rb +113 -0
- data/lib/ace/assign/authority/launch_attempt_consumers.rb +51 -0
- data/lib/ace/assign/authority/launch_campaign_registration.rb +214 -0
- data/lib/ace/assign/authority/launch_control_channel.rb +313 -0
- data/lib/ace/assign/authority/launch_control_exclusions.rb +141 -0
- data/lib/ace/assign/authority/launch_driver.rb +298 -19
- data/lib/ace/assign/authority/launch_input_inhibition.rb +132 -0
- data/lib/ace/assign/authority/launch_lifecycle.rb +275 -100
- data/lib/ace/assign/authority/launch_prepared_work.rb +122 -0
- data/lib/ace/assign/authority/launch_review.rb +91 -0
- data/lib/ace/assign/authority/launch_scope_abort.rb +0 -7
- data/lib/ace/assign/authority/launch_scope_admission.rb +136 -5
- data/lib/ace/assign/authority/launch_scope_close.rb +68 -37
- data/lib/ace/assign/authority/launch_scope_release.rb +696 -17
- data/lib/ace/assign/authority/launch_steering.rb +353 -0
- data/lib/ace/assign/authority/launch_stop.rb +254 -0
- data/lib/ace/assign/authority/maintenance_workspace_retirement.rb +229 -0
- data/lib/ace/assign/authority/original_inbox_selection.rb +41 -0
- data/lib/ace/assign/authority/posix_acl.rb +5 -2
- data/lib/ace/assign/authority/prepared_input.rb +130 -0
- data/lib/ace/assign/authority/prepared_queue.rb +185 -0
- data/lib/ace/assign/authority/prepared_task_context.rb +116 -0
- data/lib/ace/assign/authority/prepared_work.rb +304 -0
- data/lib/ace/assign/authority/prepared_worker.rb +69 -0
- data/lib/ace/assign/authority/protected_assignment_context.rb +226 -0
- data/lib/ace/assign/authority/readiness_hook.rb +120 -0
- data/lib/ace/assign/authority/router.rb +24 -2
- data/lib/ace/assign/authority/server.rb +103 -33
- data/lib/ace/assign/authority/service_cleanup_dispatch.rb +88 -0
- data/lib/ace/assign/authority/service_cleanup_evidence.rb +180 -0
- data/lib/ace/assign/authority/service_delivery_evidence.rb +101 -0
- data/lib/ace/assign/authority/service_evidence.rb +243 -12
- data/lib/ace/assign/authority/service_publication_evidence.rb +110 -0
- data/lib/ace/assign/authority/task_context_entry.rb +32 -0
- data/lib/ace/assign/authority/transfer_codec.rb +44 -12
- data/lib/ace/assign/cli/commands/assignment_target.rb +15 -2
- data/lib/ace/assign/cli/commands/attempt/base.rb +49 -0
- data/lib/ace/assign/cli/commands/attempt/finish.rb +20 -4
- data/lib/ace/assign/cli/commands/attempt/reconcile.rb +11 -0
- data/lib/ace/assign/cli/commands/authority/inbox_context_principal.rb +25 -0
- data/lib/ace/assign/cli/commands/authority/inbox_context_selection.rb +28 -0
- data/lib/ace/assign/cli/commands/authority/launch.rb +58 -6
- data/lib/ace/assign/cli/commands/authority/serve.rb +2 -1
- data/lib/ace/assign/cli/commands/authority/status.rb +1 -1
- data/lib/ace/assign/cli/commands/authority/task_context.rb +31 -0
- data/lib/ace/assign/cli/commands/authority/task_context_principal.rb +25 -0
- data/lib/ace/assign/cli/commands/authority/task_context_selection.rb +30 -0
- data/lib/ace/assign/cli/commands/authority/worker.rb +23 -0
- data/lib/ace/assign/cli/commands/campaign_export_result.rb +55 -0
- data/lib/ace/assign/cli/commands/campaign_record_round.rb +57 -0
- data/lib/ace/assign/cli/commands/create.rb +2 -0
- data/lib/ace/assign/cli/commands/delivery.rb +54 -4
- data/lib/ace/assign/cli/commands/fail.rb +6 -0
- data/lib/ace/assign/cli/commands/finish.rb +23 -2
- data/lib/ace/assign/cli/commands/fork_session.rb +6 -2
- data/lib/ace/assign/cli/commands/inbox_bind.rb +34 -0
- data/lib/ace/assign/cli/commands/protected_submission.rb +61 -0
- data/lib/ace/assign/cli/commands/resume.rb +12 -0
- data/lib/ace/assign/cli/commands/select.rb +2 -0
- data/lib/ace/assign/cli/commands/start.rb +2 -0
- data/lib/ace/assign/cli/commands/status.rb +4 -2
- data/lib/ace/assign/cli/commands/step.rb +3 -1
- data/lib/ace/assign/cli/commands/submit_candidate.rb +34 -0
- data/lib/ace/assign/cli/commands/submit_result.rb +61 -0
- data/lib/ace/assign/cli.rb +36 -5
- data/lib/ace/assign/errors.rb +139 -0
- data/lib/ace/assign/models/assignment.rb +26 -4
- data/lib/ace/assign/models/evidence_event.rb +3 -3
- data/lib/ace/assign/molecules/assignment_manager.rb +12 -2
- data/lib/ace/assign/molecules/attempt_reconciler.rb +20 -0
- data/lib/ace/assign/molecules/canonical_attempt_state.rb +151 -0
- data/lib/ace/assign/molecules/canonical_evidence.rb +5 -2
- data/lib/ace/assign/molecules/canonical_read_snapshot.rb +374 -0
- data/lib/ace/assign/molecules/evidence_journal.rb +720 -44
- data/lib/ace/assign/molecules/execution_scope_lineage.rb +27 -9
- data/lib/ace/assign/molecules/fork_session_launcher.rb +16 -6
- data/lib/ace/assign/molecules/journal_input_inhibition.rb +78 -0
- data/lib/ace/assign/molecules/journal_mutation.rb +82 -13
- data/lib/ace/assign/molecules/journal_prompt_mutation.rb +241 -0
- data/lib/ace/assign/molecules/lifecycle_exclusion.rb +178 -26
- data/lib/ace/assign/molecules/original_launch_binding.rb +37 -0
- data/lib/ace/assign/molecules/protected_control_exclusion.rb +303 -0
- data/lib/ace/assign/molecules/protected_workspace_exclusion.rb +330 -0
- data/lib/ace/assign/molecules/protected_workspace_provisioner.rb +164 -0
- data/lib/ace/assign/molecules/queue_scanner.rb +26 -11
- data/lib/ace/assign/molecules/receipt_verifier.rb +21 -15
- data/lib/ace/assign/molecules/step_writer.rb +27 -5
- data/lib/ace/assign/molecules/terminal_scope_receipt.rb +88 -0
- data/lib/ace/assign/organisms/assignment_executor.rb +4 -4
- data/lib/ace/assign/organisms/attempt_coordinator.rb +66 -76
- data/lib/ace/assign/organisms/prepared_work_builder.rb +194 -0
- data/lib/ace/assign/organisms/protected_delivery_coordinator.rb +100 -0
- data/lib/ace/assign/organisms/task_assignment_creator.rb +4 -2
- data/lib/ace/assign/version.rb +1 -1
- data/lib/ace/assign.rb +1 -112
- metadata +90 -11
- data/lib/ace/assign/cli/commands/inbox_reconcile.rb +0 -36
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Assign
|
|
5
|
+
module Authority
|
|
6
|
+
# Canonical-only original dispatch proof shared by status, root admission
|
|
7
|
+
# and initial/retained inspection imports. Installed peer policy stays in
|
|
8
|
+
# the fixed root transport owner.
|
|
9
|
+
module ServiceCleanupDispatch
|
|
10
|
+
CLEANUP_MUTABLE_SETTLEMENT_FIELDS = %w[state receipt reason claimed_at failed_at no_effect_challenge
|
|
11
|
+
challenge_generation challenge_event_digest completion_digest no_effect_completion_digest].freeze
|
|
12
|
+
|
|
13
|
+
def cleanup_dispatch_context!(record, commit:, selectors: nil, pending: nil)
|
|
14
|
+
unless @journal.evidence_mode == :protected &&
|
|
15
|
+
record.values_at("operation", "dispatch_phase") == ["prune-preserved-workspace", "dispatch_started"]
|
|
16
|
+
raise AttemptErrors::EvidenceUnavailable, "cleanup original dispatch is unavailable"
|
|
17
|
+
end
|
|
18
|
+
pending ||= {commit: commit}
|
|
19
|
+
@journal.verify_canonical_prefix!(commit: commit) unless service_read_view(pending)
|
|
20
|
+
# The existing index authenticates accepted record provenance without
|
|
21
|
+
# recursively asking this terminal collection to validate itself.
|
|
22
|
+
selected = @journal.service_request_records(commit: commit)
|
|
23
|
+
.select { |entry| entry["request_id"] == record.fetch("request_id") }
|
|
24
|
+
# Initial import receives the lower writer's proposed terminal
|
|
25
|
+
# replacement. Its settlement fields are validated by that writer
|
|
26
|
+
# and collection; the original immutable dispatch must still match.
|
|
27
|
+
unless selected.one? && selected.first.except(*CLEANUP_MUTABLE_SETTLEMENT_FIELDS) ==
|
|
28
|
+
record.except(*CLEANUP_MUTABLE_SETTLEMENT_FIELDS)
|
|
29
|
+
raise AttemptErrors::EvidenceUnavailable, "cleanup selected record differs"
|
|
30
|
+
end
|
|
31
|
+
context(record, pending: {commit: commit})
|
|
32
|
+
events = service_read_events(record, pending)
|
|
33
|
+
names = %w[request_event_digest dispatch_event_digest]
|
|
34
|
+
operations = %w[request_service begin_dispatch]
|
|
35
|
+
if selectors.nil?
|
|
36
|
+
selectors = names.zip(operations).to_h do |name, operation|
|
|
37
|
+
matches = events.select do |event|
|
|
38
|
+
event["type"] == "authority_mutation" && event.dig("payload", "operation") == operation &&
|
|
39
|
+
event.dig("payload", "data", "request_id") == record.fetch("request_id") &&
|
|
40
|
+
event.dig("payload", "data", "claim_binding") == record.fetch("claim_binding") &&
|
|
41
|
+
event.dig("payload", "data", operation == "request_service" ? "claim" : "invocation") ==
|
|
42
|
+
(operation == "request_service" ? "created" : "permitted")
|
|
43
|
+
end
|
|
44
|
+
raise AttemptErrors::EvidenceUnavailable, "cleanup original acceptance is ambiguous" unless matches.one?
|
|
45
|
+
[name, matches.first.fetch("digest")]
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
unless selectors.is_a?(Hash) && selectors.keys.sort == names.sort &&
|
|
49
|
+
selectors.values.all? { |value| value.is_a?(String) && value.match?(/\A[0-9a-f]{64}\z/) } && selectors.values.uniq.size == 2
|
|
50
|
+
raise AttemptErrors::EvidenceUnavailable, "cleanup original acceptance selectors differ"
|
|
51
|
+
end
|
|
52
|
+
introductions = service_read_introductions(record, selectors.values, pending)
|
|
53
|
+
dispatch_record = nil
|
|
54
|
+
names.zip(operations).each do |name, operation|
|
|
55
|
+
selector = selectors.fetch(name)
|
|
56
|
+
matches = events.select { |event| event.fetch("digest") == selector }
|
|
57
|
+
event = matches.one? && matches.first
|
|
58
|
+
payload = event && event.fetch("payload")
|
|
59
|
+
unless event && event.fetch("type") == "authority_mutation" && payload.fetch("operation") == operation &&
|
|
60
|
+
payload.slice("assignment_id", "attempt_id") == record.slice("assignment_id", "attempt_id") &&
|
|
61
|
+
payload.fetch("data").slice("request_id", "claim_binding") == record.slice("request_id", "claim_binding") &&
|
|
62
|
+
payload.fetch("data").fetch(operation == "request_service" ? "claim" : "invocation") ==
|
|
63
|
+
(operation == "request_service" ? "created" : "permitted")
|
|
64
|
+
raise AttemptErrors::EvidenceUnavailable, "cleanup original acceptance differs"
|
|
65
|
+
end
|
|
66
|
+
prefix = @journal.read_events(record.fetch("assignment_id"), commit: introductions.fetch(selector))
|
|
67
|
+
.select { |entry| entry.fetch("attempt_id") == record.fetch("attempt_id") }
|
|
68
|
+
original = @journal.service_request(record.fetch("request_id"), commit: introductions.fetch(selector))
|
|
69
|
+
expected = operation == "request_service" ? original&.merge("dispatch_phase" => "dispatch_started",
|
|
70
|
+
"operation_owner_binding" => record.fetch("operation_owner_binding"),
|
|
71
|
+
"executor_process_binding" => record.fetch("executor_process_binding")) : original
|
|
72
|
+
unless expected&.except(*CLEANUP_MUTABLE_SETTLEMENT_FIELDS) == record.except(*CLEANUP_MUTABLE_SETTLEMENT_FIELDS) &&
|
|
73
|
+
prefix.last == event && prefix[-2] &&
|
|
74
|
+
prefix[-2].fetch("type") == (operation == "request_service" ? "service_claim" : "service_transition") &&
|
|
75
|
+
prefix[-2].dig("payload", "record_digest") == Atoms::EvidenceDigest.digest(original) &&
|
|
76
|
+
event.fetch("previous_digest") == prefix[-2].fetch("digest")
|
|
77
|
+
raise AttemptErrors::EvidenceUnavailable, "cleanup acceptance is not its original canonical introduction"
|
|
78
|
+
end
|
|
79
|
+
dispatch_record = original if operation == "begin_dispatch"
|
|
80
|
+
end
|
|
81
|
+
freeze_projection(JSON.parse(JSON.generate(selectors.merge("commit" => commit, "dispatch_record" => dispatch_record))))
|
|
82
|
+
rescue KeyError, TypeError, ArgumentError
|
|
83
|
+
raise AttemptErrors::EvidenceUnavailable, "cleanup original dispatch proof is incomplete"
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ace/runtime/molecules/protected_artifact_set"
|
|
4
|
+
require_relative "cleanup_result_protection"
|
|
5
|
+
|
|
6
|
+
module Ace
|
|
7
|
+
module Assign
|
|
8
|
+
module Authority
|
|
9
|
+
# The existing service evidence owner authenticates this complete pair.
|
|
10
|
+
# Root-private archives remain outside the authority evidence graph.
|
|
11
|
+
module ServiceCleanupEvidence
|
|
12
|
+
ROOT_SELECTION_SCHEMA = "ace.protected-workspace-prune-root-selection/v1"
|
|
13
|
+
ROOT_RECEIPT_SCHEMA = "ace.protected-workspace-prune-receipt/v1"
|
|
14
|
+
ROOT_SELECTION_FIELDS = %w[schema request_id input_digest operation_owner_binding_digest receipt_ref].freeze
|
|
15
|
+
ROOT_INSPECTION_SELECTION_SCHEMA = "ace.protected-workspace-prune-inspection-selection/v1"
|
|
16
|
+
ROOT_INSPECTION_SELECTION_FIELDS = %w[schema request_id input_digest operation_owner_binding_digest inspection_ref].freeze
|
|
17
|
+
ROOT_RECEIPT_FIELDS = %w[schema request_id input_digest maintenance target publication canonical_snapshots preservation removal].freeze
|
|
18
|
+
|
|
19
|
+
# Pre-import decoding reuses the collection validator. Only the lower
|
|
20
|
+
# writer's pending collection can authenticate protected storage and
|
|
21
|
+
# accept a terminal update.
|
|
22
|
+
def cleanup_inspection_inputs!(contents, record, challenge)
|
|
23
|
+
unless record["operation"] == "prune-preserved-workspace" && contents.is_a?(Array) && contents.size == 2
|
|
24
|
+
raise AttemptErrors::ReceiptRejected, "cleanup inspection requires its complete pair"
|
|
25
|
+
end
|
|
26
|
+
cleanup_documents!(contents, record, no_effect: true, challenge: challenge)
|
|
27
|
+
true
|
|
28
|
+
rescue KeyError, TypeError, JSON::ParserError, JSON::GeneratorError
|
|
29
|
+
raise AttemptErrors::ReceiptRejected, "cleanup inspection pair is unavailable"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def cleanup_collection!(references, record, state, pending)
|
|
35
|
+
unless record["operation"] == "prune-preserved-workspace" && %w[succeeded failed-settled].include?(state) && references.size == 2
|
|
36
|
+
raise AttemptErrors::ReceiptRejected, "cleanup completion requires its complete original evidence pair"
|
|
37
|
+
end
|
|
38
|
+
no_effect = state == "failed-settled"
|
|
39
|
+
challenge = no_effect ? challenge!(record, pending: pending) : nil
|
|
40
|
+
expected = no_effect ? context_for_challenge(record, challenge) : context(record)
|
|
41
|
+
proof = cleanup_dispatch_context!(record, commit: pending && pending[:commit] || @journal.ref_value, pending: pending)
|
|
42
|
+
contents = references.map do |reference|
|
|
43
|
+
if pending && pending[:pending_events]
|
|
44
|
+
@canonical.read_pending(reference, **expected, **pending.slice(:current_events, :pending_events, :blobs, :commit))
|
|
45
|
+
else
|
|
46
|
+
@canonical.read(reference, **expected, commit: pending && pending[:commit] || @journal.ref_value)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
reference, operation_bytes = cleanup_documents!(contents, record, no_effect: no_effect,
|
|
50
|
+
challenge: challenge)
|
|
51
|
+
path = reference.fetch("path")
|
|
52
|
+
# The lower writer supplies its pending import transaction. Historical
|
|
53
|
+
# consumers have only a pinned commit and never reopen mutable storage.
|
|
54
|
+
if pending && pending[:pending_events]
|
|
55
|
+
selected_artifacts = @cleanup_artifacts || Ace::Runtime::Molecules::ProtectedArtifactSet.new(
|
|
56
|
+
protection: CleanupResultProtection.new(result_path: path, authority_gid: Process.egid))
|
|
57
|
+
selected_artifacts.with do |artifacts|
|
|
58
|
+
unless artifacts.read!(reference) == operation_bytes
|
|
59
|
+
raise AttemptErrors::ReceiptRejected, "cleanup retained result bytes differ"
|
|
60
|
+
end
|
|
61
|
+
artifacts.verify_unchanged!
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
if (view = service_read_view(pending))
|
|
65
|
+
view.projection = settlement_projection(record, pending, challenge, proof)
|
|
66
|
+
end
|
|
67
|
+
contents.freeze
|
|
68
|
+
rescue KeyError, TypeError, JSON::ParserError, JSON::GeneratorError, Ace::Runtime::RuntimeUnavailableError
|
|
69
|
+
raise AttemptErrors::ReceiptRejected, "cleanup original evidence is unavailable"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def cleanup_documents!(contents, record, no_effect:, challenge:)
|
|
73
|
+
documents = contents.map { |bytes| cleanup_json!(bytes) }
|
|
74
|
+
selection_schema = no_effect ? ROOT_INSPECTION_SELECTION_SCHEMA : ROOT_SELECTION_SCHEMA
|
|
75
|
+
selection_fields = no_effect ? ROOT_INSPECTION_SELECTION_FIELDS : ROOT_SELECTION_FIELDS
|
|
76
|
+
selection_index = documents.each_index.select { |index| documents[index]["schema"] == selection_schema }
|
|
77
|
+
receipt_index = documents.each_index.select do |index|
|
|
78
|
+
no_effect ? documents[index].keys.sort == ServiceEvidence::INSPECTION_FIELDS.sort : documents[index]["schema"] == ROOT_RECEIPT_SCHEMA
|
|
79
|
+
end
|
|
80
|
+
unless selection_index.one? && receipt_index.one?
|
|
81
|
+
raise AttemptErrors::ReceiptRejected, "cleanup original evidence selection is ambiguous"
|
|
82
|
+
end
|
|
83
|
+
selection = documents.fetch(selection_index.first)
|
|
84
|
+
receipt = documents.fetch(receipt_index.first)
|
|
85
|
+
closed_cleanup!(selection, selection_fields)
|
|
86
|
+
closed_cleanup!(receipt, no_effect ? ServiceEvidence::INSPECTION_FIELDS : ROOT_RECEIPT_FIELDS)
|
|
87
|
+
unless [selection, receipt].all? { |value| value["request_id"] == record.fetch("request_id") && value["input_digest"] == record.fetch("input_digest") } &&
|
|
88
|
+
selection["operation_owner_binding_digest"] == Atoms::EvidenceDigest.digest(record.fetch("operation_owner_binding"))
|
|
89
|
+
raise AttemptErrors::ReceiptRejected, "cleanup original owner or request differs"
|
|
90
|
+
end
|
|
91
|
+
reference = selection.fetch(no_effect ? "inspection_ref" : "receipt_ref")
|
|
92
|
+
closed_cleanup!(reference, %w[path bytes sha256])
|
|
93
|
+
path = reference.fetch("path")
|
|
94
|
+
operation_bytes = contents.fetch(receipt_index.first)
|
|
95
|
+
unless path.is_a?(String) && path.bytesize <= 4096 && path.valid_encoding? && !path.include?("\0") &&
|
|
96
|
+
path.start_with?("/") && File.expand_path(path) == path &&
|
|
97
|
+
reference["bytes"].is_a?(Integer) && reference["bytes"].between?(1, 65_536) &&
|
|
98
|
+
reference["bytes"] == operation_bytes.bytesize && reference["sha256"] == Digest::SHA256.hexdigest(operation_bytes)
|
|
99
|
+
raise AttemptErrors::ReceiptRejected, "cleanup retained result reference differs"
|
|
100
|
+
end
|
|
101
|
+
if no_effect
|
|
102
|
+
inspection_projection!(receipt, record, challenge)
|
|
103
|
+
else
|
|
104
|
+
cleanup_receipt_projection!(receipt, record)
|
|
105
|
+
end
|
|
106
|
+
[reference, operation_bytes]
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def cleanup_json!(bytes)
|
|
110
|
+
unless bytes.is_a?(String) && bytes.bytesize.between?(1, 65_536)
|
|
111
|
+
raise AttemptErrors::ReceiptRejected, "cleanup evidence exceeds bounds"
|
|
112
|
+
end
|
|
113
|
+
text = bytes.dup.force_encoding(Encoding::UTF_8)
|
|
114
|
+
raise AttemptErrors::ReceiptRejected, "cleanup evidence is invalid UTF8" unless text.valid_encoding? && !text.include?("\0")
|
|
115
|
+
value = JSON.parse(text, create_additions: false, max_nesting: 16, allow_duplicate_key: false, allow_comments: false)
|
|
116
|
+
raise AttemptErrors::ReceiptRejected, "cleanup evidence is not an object" unless value.is_a?(Hash)
|
|
117
|
+
value
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def closed_cleanup!(value, fields)
|
|
121
|
+
unless value.is_a?(Hash) && value.keys.sort == fields.sort
|
|
122
|
+
raise AttemptErrors::ReceiptRejected, "cleanup evidence fields differ"
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def cleanup_receipt_projection!(receipt, record)
|
|
127
|
+
preservation = receipt.fetch("preservation")
|
|
128
|
+
closed_cleanup!(preservation, %w[head branch destinations manifest_sha256 inventory_sha256 file_count total_bytes private_manifest_sha256 archives_sha256])
|
|
129
|
+
unless preservation["file_count"].is_a?(Integer) && preservation["file_count"].between?(0, 4096) &&
|
|
130
|
+
preservation["total_bytes"].is_a?(Integer) && preservation["total_bytes"].between?(0, 256 * 1024 * 1024) &&
|
|
131
|
+
%w[manifest_sha256 inventory_sha256 private_manifest_sha256 archives_sha256].all? { |key| cleanup_sha?(preservation[key]) } &&
|
|
132
|
+
preservation.values_at("manifest_sha256", "inventory_sha256", "private_manifest_sha256").uniq.one?
|
|
133
|
+
raise AttemptErrors::ReceiptRejected, "cleanup preservation observation differs"
|
|
134
|
+
end
|
|
135
|
+
input = receipt.slice("maintenance", "target", "publication").merge("schema" => "ace.protected-workspace-prune/v1",
|
|
136
|
+
"preservation" => preservation.slice("head", "branch", "destinations", "manifest_sha256"))
|
|
137
|
+
unless Atoms::EvidenceDigest.digest(input) == record.fetch("input_digest") &&
|
|
138
|
+
receipt.fetch("maintenance") == record.slice("project_id", "mapping_id", "assignment_id", "attempt_id")
|
|
139
|
+
raise AttemptErrors::ReceiptRejected, "cleanup original input differs"
|
|
140
|
+
end
|
|
141
|
+
snapshots = receipt.fetch("canonical_snapshots")
|
|
142
|
+
unless snapshots.is_a?(Array) && snapshots.size.between?(1, 256)
|
|
143
|
+
raise AttemptErrors::ReceiptRejected, "cleanup canonical snapshots exceed bounds"
|
|
144
|
+
end
|
|
145
|
+
snapshots.each do |snapshot|
|
|
146
|
+
closed_cleanup!(snapshot, %w[mapping_id journal_commit binding_event_digest release_event_digest proof_event_digest])
|
|
147
|
+
unless snapshot["mapping_id"].is_a?(String) && snapshot["mapping_id"].match?(Molecules::JournalMutation::ID) &&
|
|
148
|
+
snapshot["journal_commit"].is_a?(String) && snapshot["journal_commit"].match?(/\A(?:[0-9a-f]{40}|[0-9a-f]{64})\z/) &&
|
|
149
|
+
%w[binding_event_digest release_event_digest proof_event_digest].all? { |key| cleanup_sha?(snapshot[key]) }
|
|
150
|
+
raise AttemptErrors::ReceiptRejected, "cleanup canonical snapshot differs"
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
ids = snapshots.map { |snapshot| snapshot.fetch("mapping_id") }
|
|
154
|
+
raise AttemptErrors::ReceiptRejected, "cleanup canonical snapshots are not uniquely ordered" unless ids == ids.uniq.sort
|
|
155
|
+
removal = receipt.fetch("removal")
|
|
156
|
+
closed_cleanup!(removal, %w[original captured outcome fence_digest])
|
|
157
|
+
unless removal["outcome"] == "removed" && cleanup_sha?(removal["fence_digest"])
|
|
158
|
+
raise AttemptErrors::ReceiptRejected, "cleanup has no positive removal observation"
|
|
159
|
+
end
|
|
160
|
+
%w[original captured].each do |key|
|
|
161
|
+
object = removal.fetch(key)
|
|
162
|
+
closed_cleanup!(object, %w[device inode worktree_admin_id head branch])
|
|
163
|
+
unless %w[device inode].all? { |field| object[field].is_a?(Integer) && object[field].positive? } &&
|
|
164
|
+
object["worktree_admin_id"].is_a?(String) && object["worktree_admin_id"].bytesize.between?(1, 200) &&
|
|
165
|
+
object["worktree_admin_id"].match?(/\A[a-zA-Z0-9_.-]+\z/) &&
|
|
166
|
+
object["head"] == preservation["head"] && object["branch"] == preservation["branch"]
|
|
167
|
+
raise AttemptErrors::ReceiptRejected, "cleanup captured object differs"
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
unless removal.fetch("original") == removal.fetch("captured")
|
|
171
|
+
raise AttemptErrors::ReceiptRejected, "cleanup replaced object cannot attest removal"
|
|
172
|
+
end
|
|
173
|
+
true
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def cleanup_sha?(value) = value.is_a?(String) && value.match?(/\A[0-9a-f]{64}\z/)
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require "ace/git/atoms/service_merge_evidence"
|
|
3
|
+
require "ace/git/atoms/service_pr_evidence"
|
|
4
|
+
require_relative "service_evidence"
|
|
5
|
+
|
|
6
|
+
module Ace
|
|
7
|
+
module Assign
|
|
8
|
+
module Authority
|
|
9
|
+
# The existing service completion/import transaction owns delivery writes.
|
|
10
|
+
class ServiceDeliveryEvidence
|
|
11
|
+
def initialize(journal:)
|
|
12
|
+
@journal = journal
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def completion_event(record:, receipt:, completion_digest:, artifacts:)
|
|
16
|
+
unless artifacts.length == 1
|
|
17
|
+
raise AttemptErrors::ReceiptRejected, "merge completion requires its one fixed evidence artifact"
|
|
18
|
+
end
|
|
19
|
+
proof = validate_artifact!(record, artifacts.first)
|
|
20
|
+
{type: "delivery", payload: payload(record, receipt, completion_digest, proof)}
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def verified(record:, events:, commit:)
|
|
24
|
+
unless %w[create update ready merge].include?(record["operation"]) && record["state"] == "succeeded" &&
|
|
25
|
+
record.fetch("receipt").fetch("evidence").length == 1
|
|
26
|
+
raise AttemptErrors::EvidenceUnavailable, "canonical merge completion is unavailable"
|
|
27
|
+
end
|
|
28
|
+
receipt = record.fetch("receipt")
|
|
29
|
+
reference = receipt.fetch("evidence").first
|
|
30
|
+
bytes = Molecules::CanonicalEvidence.new(journal: @journal).read(reference,
|
|
31
|
+
**ServiceEvidence.new(journal: @journal).context(record), commit: commit)
|
|
32
|
+
expected = payload(record, receipt, record.fetch("completion_digest"), validate_artifact!(record, bytes))
|
|
33
|
+
results = events.select { |event| event["type"] == "delivery" && event.dig("payload", "service_request_id") == record.fetch("request_id") }
|
|
34
|
+
unless results.one? && Atoms::EvidenceDigest.digest(results.first.fetch("payload")) == Atoms::EvidenceDigest.digest(expected)
|
|
35
|
+
raise AttemptErrors::EvidenceUnavailable, "canonical merge delivery result differs"
|
|
36
|
+
end
|
|
37
|
+
transitions = events.select { |event| event["type"] == "service_transition" &&
|
|
38
|
+
event.dig("payload", "request_id") == record.fetch("request_id") && event.dig("payload", "state") == "succeeded" &&
|
|
39
|
+
event.dig("payload", "record_digest") == Atoms::EvidenceDigest.digest(record) &&
|
|
40
|
+
event.dig("payload", "receipt_digest") == Atoms::EvidenceDigest.digest(receipt) }
|
|
41
|
+
imports = events.select { |event| event["type"] == "evidence_import" &&
|
|
42
|
+
"evidence/imports/#{event.dig('payload', 'artifact_id')}" == reference.fetch("ref") }
|
|
43
|
+
unless transitions.one? && imports.one?
|
|
44
|
+
raise AttemptErrors::EvidenceUnavailable, "canonical merge completion introduction differs"
|
|
45
|
+
end
|
|
46
|
+
# The first completion's authority mutation immediately follows its
|
|
47
|
+
# terminal transition. Later identical completion observations may
|
|
48
|
+
# have their own mutation IDs; they cannot replace this introduction.
|
|
49
|
+
completion = events[events.index(transitions.first) + 1]
|
|
50
|
+
unless completion && completion["type"] == "authority_mutation" &&
|
|
51
|
+
completion.dig("payload", "operation") == "complete_service" &&
|
|
52
|
+
completion.dig("payload", "data", "request_id") == record.fetch("request_id")
|
|
53
|
+
raise AttemptErrors::EvidenceUnavailable, "original merge completion mutation differs"
|
|
54
|
+
end
|
|
55
|
+
selected = [results.first, transitions.first, completion, imports.first]
|
|
56
|
+
introductions = @journal.event_commits!(assignment_id: record.fetch("assignment_id"),
|
|
57
|
+
event_digests: selected.map { |event| event.fetch("digest") }, commit: commit)
|
|
58
|
+
unless introductions.values.uniq.length == 1
|
|
59
|
+
raise AttemptErrors::EvidenceUnavailable, "merge delivery result was not atomically imported and completed"
|
|
60
|
+
end
|
|
61
|
+
results.first
|
|
62
|
+
rescue KeyError, TypeError, ArgumentError, AttemptErrors::ReceiptRejected
|
|
63
|
+
raise AttemptErrors::EvidenceUnavailable, "canonical merge delivery evidence is unverifiable"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def validate_artifact!(record, bytes)
|
|
69
|
+
options = {request_id: record.fetch("request_id"), input_digest: record.fetch("input_digest"),
|
|
70
|
+
target: record.fetch("target"), head: record.fetch("candidate_head")}
|
|
71
|
+
proof = if record.fetch("operation") == "merge"
|
|
72
|
+
Ace::Git::Atoms::ServiceMergeEvidence.validate(bytes, **options)
|
|
73
|
+
else
|
|
74
|
+
Ace::Git::Atoms::ServicePrEvidence.validate(bytes, operation: record.fetch("operation"), **options)
|
|
75
|
+
end
|
|
76
|
+
unless Atoms::EvidenceDigest.digest(proof.fetch(:input)) == record.fetch("input_digest")
|
|
77
|
+
raise AttemptErrors::ReceiptRejected, "merge evidence does not reconstruct the originally accepted input"
|
|
78
|
+
end
|
|
79
|
+
proof
|
|
80
|
+
rescue ArgumentError, KeyError
|
|
81
|
+
raise AttemptErrors::ReceiptRejected, "merge evidence original input or outcome differs"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def payload(record, receipt, completion_digest, proof)
|
|
85
|
+
self.class.payload(record, receipt, completion_digest, proof)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Pure result shape shared with the read-only worker consumer. This
|
|
89
|
+
# function creates no evidence or authorization on its own.
|
|
90
|
+
def self.payload(record, receipt, completion_digest, proof)
|
|
91
|
+
{"stage" => "result", "operation" => record.fetch("operation"), "head" => record.fetch("candidate_head"), "outcome" => "succeeded",
|
|
92
|
+
"intent_digest" => nil, "intent_attempt_id" => nil,
|
|
93
|
+
"pr" => proof.fetch(:pr).slice("number", "url", "head_sha", "draft", "state", "merge_commit_sha"),
|
|
94
|
+
"service_request_id" => record.fetch("request_id"), "service_completion_digest" => completion_digest,
|
|
95
|
+
"service_receipt_digest" => Atoms::EvidenceDigest.digest(receipt), "service_evidence" => receipt.fetch("evidence"),
|
|
96
|
+
"producer" => {"actor" => "service:#{record.fetch('executor_uid')}", "role" => "service", "runtime" => "herdr"}}
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|