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,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
@@ -0,0 +1,286 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../molecules/evidence_journal"
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"
9
+
10
+ module Ace
11
+ module Assign
12
+ module Authority
13
+ # One source-owned context for initial and retained service evidence.
14
+ # The journal supplies the record and transient pending view; callers
15
+ # never supply an alternate reader, artifact path or trusted flag.
16
+ class ServiceEvidence
17
+ include ServiceCleanupEvidence
18
+ include ServiceCleanupDispatch
19
+ include ServicePublicationEvidence
20
+ FIELDS = (Molecules::EvidenceJournal::TERMINAL_BINDING_FIELDS +
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
27
+
28
+ def initialize(journal:, cleanup_artifacts: nil)
29
+ @journal = journal
30
+ @canonical = Molecules::CanonicalEvidence.new(journal: journal)
31
+ @cleanup_artifacts = cleanup_artifacts
32
+ end
33
+
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)
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
65
+ unless %w[claim_binding policy_digest].all? { |key| binding[key].is_a?(String) && binding[key].match?(/\A[0-9a-f]{64}\z/) } &&
66
+ %w[candidate_generation claim_generation].all? { |key| binding[key].is_a?(Integer) && binding[key].positive? } &&
67
+ binding["dispatch_ticket_id"].is_a?(String) && binding["dispatch_ticket_id"].match?(Molecules::JournalMutation::ID)
68
+ raise AttemptErrors::EvidenceUnavailable, "canonical dispatch binding is invalid"
69
+ end
70
+ if no_effect
71
+ return context_for_challenge(record, challenge!(record, pending: pending))
72
+ end
73
+ {kind: "service", project_id: record.fetch("project_id"), assignment_id: record.fetch("assignment_id"),
74
+ attempt_id: record.fetch("attempt_id"), peer_uid: record.fetch("executor_uid"), binding: binding,
75
+ request_id_or_event_id: record.fetch("request_id"), generation: record.fetch("claim_generation")}
76
+ rescue KeyError
77
+ raise AttemptErrors::EvidenceUnavailable, "canonical service evidence binding is incomplete"
78
+ end
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
+
197
+ def call(reference, record, state, pending)
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]
205
+ @canonical.read_pending(reference, **expected, **pending.slice(:current_events, :pending_events, :blobs, :commit))
206
+ else
207
+ @canonical.read(reference, **expected, commit: pending && pending[:commit] || @journal.ref_value)
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
282
+ end
283
+ end
284
+ end
285
+ end
286
+ 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