ace-assign 0.71.0 → 0.73.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 +8 -8
- data/.ace-defaults/assign/catalog/steps/create-pr.step.yml +1 -1
- data/.ace-defaults/assign/catalog/steps/mark-pr-ready.step.yml +1 -1
- data/.ace-defaults/assign/config.yml +0 -13
- data/.ace-defaults/assign/presets/fix-bug.yml +1 -1
- data/.ace-defaults/assign/presets/work-on-task-ace-development.yml +1 -1
- data/.ace-defaults/assign/presets/work-on-task-auto-merge.yml +1 -1
- data/.ace-defaults/assign/presets/work-on-task.yml +1 -1
- data/CHANGELOG.md +5 -2116
- data/README.md +58 -31
- data/docs/exit-codes.md +36 -44
- data/docs/getting-started.md +9 -4
- data/docs/usage.md +129 -522
- data/handbook/skills/as-assign-drive/SKILL.md +1 -4
- data/handbook/workflow-instructions/assign/drive.wf.md +47 -195
- data/handbook/workflow-instructions/assign/prepare.wf.md +1 -1
- data/handbook/workflow-instructions/assign/verify-test-suite.wf.md +8 -2
- data/lib/ace/assign/cli/commands/assignment_target.rb +2 -15
- data/lib/ace/assign/cli/commands/create.rb +1 -8
- data/lib/ace/assign/cli/commands/fail.rb +0 -6
- data/lib/ace/assign/cli/commands/finish.rb +2 -23
- data/lib/ace/assign/cli/commands/fork_session.rb +0 -1
- data/lib/ace/assign/cli/commands/select.rb +0 -1
- data/lib/ace/assign/cli/commands/start.rb +0 -2
- data/lib/ace/assign/cli/commands/status.rb +1 -31
- data/lib/ace/assign/cli/commands/step.rb +1 -3
- data/lib/ace/assign/cli.rb +1 -85
- data/lib/ace/assign/errors.rb +8 -80
- data/lib/ace/assign/models/assignment.rb +5 -28
- data/lib/ace/assign/molecules/assignment_manager.rb +0 -26
- data/lib/ace/assign/molecules/fork_session_launcher.rb +4 -12
- data/lib/ace/assign/molecules/lifecycle_exclusion.rb +15 -112
- data/lib/ace/assign/molecules/runtime_control_surface_runner.rb +6 -2
- data/lib/ace/assign/molecules/skill_assign_source_resolver.rb +9 -0
- data/lib/ace/assign/organisms/assignment_executor.rb +0 -6
- data/lib/ace/assign/organisms/task_assignment_creator.rb +3 -8
- data/lib/ace/assign/version.rb +1 -1
- data/lib/ace/assign.rb +0 -6
- metadata +22 -176
- data/.ace-defaults/assign/presets/close-delivery.yml +0 -57
- data/.ace-defaults/assign/presets/deliver-release.yml +0 -60
- data/.ace-defaults/assign/presets/integrate-batch.yml +0 -72
- data/.ace-defaults/assign/presets/supervise-delivery.yml +0 -69
- data/docs/protected-authority.md +0 -282
- data/exe/ace-scope-ready +0 -55
- data/lib/ace/assign/atoms/assignment_scope.rb +0 -58
- data/lib/ace/assign/atoms/attempt_state_machine.rb +0 -84
- data/lib/ace/assign/atoms/candidate_limits.rb +0 -20
- data/lib/ace/assign/atoms/delivery_parameters.rb +0 -19
- data/lib/ace/assign/atoms/evidence_digest.rb +0 -78
- data/lib/ace/assign/atoms/fixed_merge_schemas.rb +0 -398
- data/lib/ace/assign/atoms/git_blob_batch.rb +0 -59
- data/lib/ace/assign/authority/assignment_definition.rb +0 -35
- data/lib/ace/assign/authority/assignment_inventory.rb +0 -290
- data/lib/ace/assign/authority/campaign_consumer_policy.rb +0 -100
- data/lib/ace/assign/authority/campaign_execution.rb +0 -119
- data/lib/ace/assign/authority/campaign_round_transfer.rb +0 -82
- data/lib/ace/assign/authority/candidate_blob_batch.rb +0 -55
- data/lib/ace/assign/authority/candidate_head_comparison.rb +0 -139
- data/lib/ace/assign/authority/candidate_transfer.rb +0 -357
- data/lib/ace/assign/authority/cleanup_result_protection.rb +0 -53
- data/lib/ace/assign/authority/client.rb +0 -257
- data/lib/ace/assign/authority/command_execution.rb +0 -180
- data/lib/ace/assign/authority/deployment.rb +0 -627
- data/lib/ace/assign/authority/deployment_history.rb +0 -146
- data/lib/ace/assign/authority/endcap.rb +0 -457
- data/lib/ace/assign/authority/endcap_attempts.rb +0 -274
- data/lib/ace/assign/authority/endcap_campaign_results.rb +0 -232
- data/lib/ace/assign/authority/endcap_campaign_rounds.rb +0 -266
- data/lib/ace/assign/authority/endcap_contexts.rb +0 -23
- data/lib/ace/assign/authority/endcap_inboxes.rb +0 -37
- data/lib/ace/assign/authority/endcap_prepared_work.rb +0 -80
- data/lib/ace/assign/authority/endcap_publication.rb +0 -41
- data/lib/ace/assign/authority/endcap_results.rb +0 -398
- data/lib/ace/assign/authority/endcap_review_requests.rb +0 -183
- data/lib/ace/assign/authority/endcap_reviews.rb +0 -130
- data/lib/ace/assign/authority/endcap_service_context.rb +0 -38
- data/lib/ace/assign/authority/endcap_service_settlement.rb +0 -152
- data/lib/ace/assign/authority/evidence_transfer.rb +0 -67
- data/lib/ace/assign/authority/execution_scope_observation.rb +0 -903
- data/lib/ace/assign/authority/fixed_publication_lifecycle.rb +0 -176
- data/lib/ace/assign/authority/fixed_service_lifecycle.rb +0 -594
- data/lib/ace/assign/authority/inbox_context_original.rb +0 -113
- data/lib/ace/assign/authority/launch_attempt_consumers.rb +0 -51
- data/lib/ace/assign/authority/launch_campaign_registration.rb +0 -214
- data/lib/ace/assign/authority/launch_control_channel.rb +0 -313
- data/lib/ace/assign/authority/launch_control_exclusions.rb +0 -127
- data/lib/ace/assign/authority/launch_driver.rb +0 -393
- data/lib/ace/assign/authority/launch_input_inhibition.rb +0 -132
- data/lib/ace/assign/authority/launch_lifecycle.rb +0 -997
- data/lib/ace/assign/authority/launch_prepared_work.rb +0 -122
- data/lib/ace/assign/authority/launch_review.rb +0 -91
- data/lib/ace/assign/authority/launch_scope_abort.rb +0 -81
- data/lib/ace/assign/authority/launch_scope_admission.rb +0 -215
- data/lib/ace/assign/authority/launch_scope_close.rb +0 -146
- data/lib/ace/assign/authority/launch_scope_parent.rb +0 -44
- data/lib/ace/assign/authority/launch_scope_release.rb +0 -859
- data/lib/ace/assign/authority/launch_steering.rb +0 -353
- data/lib/ace/assign/authority/launch_stop.rb +0 -254
- data/lib/ace/assign/authority/maintenance_workspace_retirement.rb +0 -229
- data/lib/ace/assign/authority/ordinary_attempt_start.rb +0 -137
- data/lib/ace/assign/authority/ordinary_client.rb +0 -70
- data/lib/ace/assign/authority/ordinary_completion.rb +0 -104
- data/lib/ace/assign/authority/ordinary_effect_settlement.rb +0 -118
- data/lib/ace/assign/authority/ordinary_lifecycle.rb +0 -227
- data/lib/ace/assign/authority/ordinary_merge_evidence.rb +0 -81
- data/lib/ace/assign/authority/ordinary_peer.rb +0 -54
- data/lib/ace/assign/authority/ordinary_progress.rb +0 -162
- data/lib/ace/assign/authority/ordinary_queue.rb +0 -115
- data/lib/ace/assign/authority/ordinary_release_artifacts.rb +0 -143
- data/lib/ace/assign/authority/ordinary_results.rb +0 -206
- data/lib/ace/assign/authority/ordinary_review_evidence.rb +0 -180
- data/lib/ace/assign/authority/ordinary_reviews.rb +0 -214
- data/lib/ace/assign/authority/ordinary_server.rb +0 -75
- data/lib/ace/assign/authority/ordinary_supervisor.rb +0 -99
- data/lib/ace/assign/authority/original_inbox_selection.rb +0 -41
- data/lib/ace/assign/authority/posix_acl.rb +0 -59
- data/lib/ace/assign/authority/prepared_input.rb +0 -130
- data/lib/ace/assign/authority/prepared_queue.rb +0 -185
- data/lib/ace/assign/authority/prepared_task_context.rb +0 -116
- data/lib/ace/assign/authority/prepared_work.rb +0 -309
- data/lib/ace/assign/authority/prepared_worker.rb +0 -69
- data/lib/ace/assign/authority/private_directory.rb +0 -33
- data/lib/ace/assign/authority/protected_assignment_context.rb +0 -226
- data/lib/ace/assign/authority/readiness_hook.rb +0 -120
- data/lib/ace/assign/authority/receipt_transfer.rb +0 -48
- data/lib/ace/assign/authority/release_artifact_manifest.rb +0 -91
- data/lib/ace/assign/authority/retained_input.rb +0 -53
- data/lib/ace/assign/authority/router.rb +0 -81
- data/lib/ace/assign/authority/server.rb +0 -131
- data/lib/ace/assign/authority/service_cleanup_dispatch.rb +0 -88
- data/lib/ace/assign/authority/service_cleanup_evidence.rb +0 -180
- data/lib/ace/assign/authority/service_delivery_evidence.rb +0 -101
- data/lib/ace/assign/authority/service_evidence.rb +0 -286
- data/lib/ace/assign/authority/service_publication_evidence.rb +0 -110
- data/lib/ace/assign/authority/socket_server.rb +0 -255
- data/lib/ace/assign/authority/task_context_entry.rb +0 -32
- data/lib/ace/assign/authority/transfer_codec.rb +0 -189
- data/lib/ace/assign/cli/commands/attempt/base.rb +0 -84
- data/lib/ace/assign/cli/commands/attempt/evidence.rb +0 -28
- data/lib/ace/assign/cli/commands/attempt/finish.rb +0 -51
- data/lib/ace/assign/cli/commands/attempt/reconcile.rb +0 -49
- data/lib/ace/assign/cli/commands/attempt/start.rb +0 -45
- data/lib/ace/assign/cli/commands/attempt/status.rb +0 -54
- data/lib/ace/assign/cli/commands/authority/inbox_context_principal.rb +0 -25
- data/lib/ace/assign/cli/commands/authority/inbox_context_selection.rb +0 -28
- data/lib/ace/assign/cli/commands/authority/launch.rb +0 -97
- data/lib/ace/assign/cli/commands/authority/serve.rb +0 -31
- data/lib/ace/assign/cli/commands/authority/status.rb +0 -26
- data/lib/ace/assign/cli/commands/authority/task_context.rb +0 -31
- data/lib/ace/assign/cli/commands/authority/task_context_principal.rb +0 -25
- data/lib/ace/assign/cli/commands/authority/task_context_selection.rb +0 -30
- data/lib/ace/assign/cli/commands/authority/terminate.rb +0 -29
- data/lib/ace/assign/cli/commands/authority/worker.rb +0 -23
- data/lib/ace/assign/cli/commands/campaign_export_result.rb +0 -55
- data/lib/ace/assign/cli/commands/campaign_record_round.rb +0 -57
- data/lib/ace/assign/cli/commands/delivery.rb +0 -107
- data/lib/ace/assign/cli/commands/inbox_bind.rb +0 -34
- data/lib/ace/assign/cli/commands/protected_submission.rb +0 -61
- data/lib/ace/assign/cli/commands/resume.rb +0 -38
- data/lib/ace/assign/cli/commands/submit_candidate.rb +0 -34
- data/lib/ace/assign/cli/commands/submit_result.rb +0 -61
- data/lib/ace/assign/models/attempt.rb +0 -178
- data/lib/ace/assign/models/attempt_binding.rb +0 -116
- data/lib/ace/assign/models/evidence_event.rb +0 -86
- data/lib/ace/assign/models/execution_receipt.rb +0 -139
- data/lib/ace/assign/molecules/attempt_reconciler.rb +0 -127
- data/lib/ace/assign/molecules/attempt_store.rb +0 -201
- data/lib/ace/assign/molecules/canonical_attempt_state.rb +0 -151
- data/lib/ace/assign/molecules/canonical_evidence.rb +0 -170
- data/lib/ace/assign/molecules/canonical_read_commands.rb +0 -84
- data/lib/ace/assign/molecules/canonical_read_snapshot.rb +0 -313
- data/lib/ace/assign/molecules/evidence_calculator.rb +0 -237
- data/lib/ace/assign/molecules/evidence_journal.rb +0 -1899
- data/lib/ace/assign/molecules/execution_identity_resolver.rb +0 -137
- data/lib/ace/assign/molecules/execution_scope_lineage.rb +0 -345
- data/lib/ace/assign/molecules/fixed_effect_reference.rb +0 -31
- data/lib/ace/assign/molecules/installed_journal_read.rb +0 -143
- data/lib/ace/assign/molecules/journal_input_inhibition.rb +0 -78
- data/lib/ace/assign/molecules/journal_mutation.rb +0 -274
- data/lib/ace/assign/molecules/journal_prompt_mutation.rb +0 -241
- data/lib/ace/assign/molecules/ordinary_merge_cli.rb +0 -68
- data/lib/ace/assign/molecules/ordinary_merge_contract.rb +0 -410
- data/lib/ace/assign/molecules/original_launch_binding.rb +0 -37
- data/lib/ace/assign/molecules/proposal_journal.rb +0 -179
- data/lib/ace/assign/molecules/protected_control_exclusion.rb +0 -303
- data/lib/ace/assign/molecules/protected_workspace_exclusion.rb +0 -330
- data/lib/ace/assign/molecules/protected_workspace_provisioner.rb +0 -164
- data/lib/ace/assign/molecules/receipt_verifier.rb +0 -320
- data/lib/ace/assign/molecules/terminal_scope_receipt.rb +0 -88
- data/lib/ace/assign/organisms/attempt_coordinator.rb +0 -1374
- data/lib/ace/assign/organisms/delivery_coordinator.rb +0 -342
- data/lib/ace/assign/organisms/ordinary_driver.rb +0 -310
- data/lib/ace/assign/organisms/ordinary_merge_consumer.rb +0 -365
- data/lib/ace/assign/organisms/prepared_work_builder.rb +0 -194
- data/lib/ace/assign/organisms/protected_delivery_coordinator.rb +0 -100
|
@@ -1,1899 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require_relative "canonical_attempt_state"
|
|
4
|
-
require_relative "fixed_effect_reference"
|
|
5
|
-
require_relative "../atoms/fixed_merge_schemas"
|
|
6
|
-
|
|
7
|
-
require "digest"
|
|
8
|
-
require "fileutils"
|
|
9
|
-
require "json"
|
|
10
|
-
require_relative "../atoms/git_blob_batch"
|
|
11
|
-
require "open3"
|
|
12
|
-
require "pathname"
|
|
13
|
-
require "ace/herdr/molecules/bounded_process"
|
|
14
|
-
require_relative "proposal_journal"
|
|
15
|
-
require_relative "journal_mutation"
|
|
16
|
-
|
|
17
|
-
module Ace
|
|
18
|
-
module Assign
|
|
19
|
-
module Molecules
|
|
20
|
-
# Append-only execution evidence journal backed by a dedicated Git ref.
|
|
21
|
-
#
|
|
22
|
-
# Accepted evidence for assignment attempts is committed to a separate
|
|
23
|
-
# configured evidence ref (default `refs/ace/execution`) in canonical,
|
|
24
|
-
# non-secret paths `execution/<assignment-id>/events/`. The ref lives
|
|
25
|
-
# outside the deliverable candidate branch: appending evidence never
|
|
26
|
-
# advances or changes the reviewed candidate. Working state uses an
|
|
27
|
-
# isolated, disposable detached worktree under the configured checkout
|
|
28
|
-
# root.
|
|
29
|
-
#
|
|
30
|
-
# Concurrency: all ref updates serialize on a checkout-root flock, and
|
|
31
|
-
# every update is additionally guarded by an expected-old-value
|
|
32
|
-
# `git update-ref` compare-and-swap. On a CAS conflict the journal
|
|
33
|
-
# reloads the authoritative ref and replays only still-valid,
|
|
34
|
-
# non-duplicate events.
|
|
35
|
-
class EvidenceJournal
|
|
36
|
-
include JournalMutation
|
|
37
|
-
include ProposalJournal
|
|
38
|
-
|
|
39
|
-
CAS_ATTEMPTS = 3
|
|
40
|
-
HISTORY_LIMIT = 100_000
|
|
41
|
-
EVENT_BATCH_BYTES = 1024 * 1024
|
|
42
|
-
|
|
43
|
-
OPERATION_INVENTORY_LIMIT = 8
|
|
44
|
-
|
|
45
|
-
# A wire operation may retain one event/raw service selection and bounded immutable inventories, never admission
|
|
46
|
-
# or current-ref truth. Nested operations own independent selections.
|
|
47
|
-
def self.with_event_read_operation
|
|
48
|
-
previous = Thread.current[:ace_assign_event_read_operation]
|
|
49
|
-
Thread.current[:ace_assign_event_read_operation] = {}
|
|
50
|
-
yield
|
|
51
|
-
ensure
|
|
52
|
-
Thread.current[:ace_assign_event_read_operation] = previous
|
|
53
|
-
end
|
|
54
|
-
|
|
55
|
-
# Selected by source composition, never by receipt or wire parameters.
|
|
56
|
-
def evidence_mode
|
|
57
|
-
@mode
|
|
58
|
-
end
|
|
59
|
-
|
|
60
|
-
# @param repo_root [String] Git repository root holding the evidence ref
|
|
61
|
-
# @param ref [String, nil] Evidence ref (default from config)
|
|
62
|
-
# @param checkout_root [String, nil] Isolated checkout root (default from config)
|
|
63
|
-
def initialize(repo_root:, ref: nil, checkout_root: nil, mode: :local,
|
|
64
|
-
evidence_reader: nil, service_authorizer: nil, read_boundary: nil)
|
|
65
|
-
unless %i[local protected].include?(mode) &&
|
|
66
|
-
(evidence_reader.nil? || evidence_reader.respond_to?(:call)) &&
|
|
67
|
-
(mode != :protected || [evidence_reader, service_authorizer].all? { |owner| owner.respond_to?(:call) })
|
|
68
|
-
raise ArgumentError, "protected journal requires source-owned evidence and service boundaries"
|
|
69
|
-
end
|
|
70
|
-
@mode, @evidence_reader, @service_authorizer = mode, evidence_reader, service_authorizer
|
|
71
|
-
if read_boundary && !%i[call blob_batch blob history_diff blob_sizes].all? { |method| read_boundary.respond_to?(method) }
|
|
72
|
-
raise ArgumentError, "read-only journal boundary is malformed"
|
|
73
|
-
end
|
|
74
|
-
@read_boundary = read_boundary
|
|
75
|
-
@repo_root = repo_root
|
|
76
|
-
@ref = ref || default_config("evidence_git_ref") || "refs/ace/execution"
|
|
77
|
-
root = checkout_root || default_config("evidence_checkout_root") || ".ace-local/assign/evidence-checkout"
|
|
78
|
-
@checkout_root = Pathname.new(root).absolute? ? root : File.join(repo_root, root)
|
|
79
|
-
end
|
|
80
|
-
|
|
81
|
-
attr_reader :repo_root, :ref, :checkout_root
|
|
82
|
-
|
|
83
|
-
# @return [Boolean] True when the repo can hold the evidence ref
|
|
84
|
-
def available?
|
|
85
|
-
git_ok?("rev-parse", "--git-dir")
|
|
86
|
-
end
|
|
87
|
-
|
|
88
|
-
# Original operator preparation only. Reuse the journal's existing lock
|
|
89
|
-
# and absent-ref CAS; never manufacture an event or reset existing history.
|
|
90
|
-
def initialize_canonical!(owner_uid:, owner_gid:, owner_groups:)
|
|
91
|
-
raise AttemptErrors::EvidenceUnavailable, "read-only journal cannot initialize canonical evidence" if @read_boundary
|
|
92
|
-
unless [owner_uid, owner_gid].all? { |id| id.is_a?(Integer) && id.between?(0, 2**31 - 1) } && @repo_root.is_a?(String) && @checkout_root.is_a?(String) &&
|
|
93
|
-
[@repo_root, @checkout_root].all? { |path| Pathname.new(path).absolute? && File.expand_path(path) == path } &&
|
|
94
|
-
File.directory?(@repo_root) && !File.symlink?(@repo_root) &&
|
|
95
|
-
git_ok?("check-ref-format", @ref)
|
|
96
|
-
raise AttemptErrors::EvidenceUnavailable, "canonical initialization selection is invalid"
|
|
97
|
-
end
|
|
98
|
-
unless owner_groups.is_a?(Array) && owner_groups.all? { |id| id.is_a?(Integer) && id.between?(0, 2**31 - 1) } &&
|
|
99
|
-
owner_groups.uniq.size == owner_groups.size &&
|
|
100
|
-
[Process.uid, Process.euid, Process.gid, Process.egid] == [owner_uid, owner_uid, owner_gid, owner_gid] &&
|
|
101
|
-
Process.groups.sort == owner_groups.sort
|
|
102
|
-
raise AttemptErrors::EvidenceUnavailable, "canonical initialization process identity differs"
|
|
103
|
-
end
|
|
104
|
-
selected = File.stat(@repo_root)
|
|
105
|
-
raise AttemptErrors::EvidenceUnavailable, "canonical initialization repository owner differs" unless selected.uid == owner_uid
|
|
106
|
-
identity = [selected.dev, selected.ino, selected.uid, selected.gid, selected.mode]
|
|
107
|
-
with_lock(cleanup_on_failure: false, owner_uid: owner_uid, owner_gid: owner_gid) do
|
|
108
|
-
value = ref_value
|
|
109
|
-
unless value
|
|
110
|
-
if File.exist?(checkout_dir) || File.symlink?(checkout_dir)
|
|
111
|
-
raise AttemptErrors::EvidenceUnavailable, "uninitialized journal has foreign checkout state"
|
|
112
|
-
end
|
|
113
|
-
seed_ref
|
|
114
|
-
value = ref_value
|
|
115
|
-
end
|
|
116
|
-
nodes = canonical_history_nodes!(value)
|
|
117
|
-
seed = nodes.last.first
|
|
118
|
-
tree = git!("rev-parse", "#{seed}^{tree}").first
|
|
119
|
-
message = git!("show", "-s", "--format=%B", seed).first
|
|
120
|
-
unless tree == git!("hash-object", "-t", "tree", "--stdin").first && message == "seed: ace-assign execution evidence"
|
|
121
|
-
raise AttemptErrors::EvidenceUnavailable, "canonical initialization has foreign root history"
|
|
122
|
-
end
|
|
123
|
-
canonical_event_inventory!(commit: value)
|
|
124
|
-
raw_paths = bounded_history_read!(["ls-tree", "-r", "-z", "--name-only", value], "", HISTORY_DIFF_BYTES)
|
|
125
|
-
paths = raw_paths.empty? ? [] : raw_paths.split("\0", -1)
|
|
126
|
-
unless paths.empty? || paths.pop == ""
|
|
127
|
-
raise AttemptErrors::EvidenceUnavailable, "canonical initialization tree inventory is incomplete"
|
|
128
|
-
end
|
|
129
|
-
raise AttemptErrors::EvidenceUnavailable, "canonical initialization tree inventory is oversized" if paths.size > HISTORY_LIMIT
|
|
130
|
-
paths.each do |path|
|
|
131
|
-
parts = path.split("/", -1)
|
|
132
|
-
event = parts.size == 4 && parts[0] == "execution" && parts[2] == "events" && JournalMutation::ID.match?(parts[1]) && parts[3].end_with?(".json")
|
|
133
|
-
request = parts.size == 3 && [%w[execution requests], %w[execution merge-requests]].include?(parts[0, 2]) && parts[2].end_with?(".json") && JournalMutation::ID.match?(parts[2].delete_suffix(".json"))
|
|
134
|
-
# Definitions, prepared packets, candidate bundles and imports
|
|
135
|
-
# retain the SAME mutation owner's closed immutable blob dialect.
|
|
136
|
-
validate_blob_path!(path) unless event || request
|
|
137
|
-
end
|
|
138
|
-
records = service_request_records(commit: value)
|
|
139
|
-
expected_requests = paths.select { |path| path.start_with?("execution/requests/") }.map { |path| File.basename(path, ".json") }.sort
|
|
140
|
-
unless records.map { |record| record.fetch("request_id") }.sort == expected_requests
|
|
141
|
-
raise AttemptErrors::EvidenceUnavailable, "canonical initialization service inventory differs"
|
|
142
|
-
end
|
|
143
|
-
fixed_records = fixed_merge_request_records(commit: value)
|
|
144
|
-
expected_fixed = paths.select { |path| path.start_with?("execution/merge-requests/") }.map { |path| File.basename(path, ".json") }.sort
|
|
145
|
-
unless fixed_records.map { |record| record.fetch("request_id") }.sort == expected_fixed
|
|
146
|
-
raise AttemptErrors::EvidenceUnavailable, "canonical initialization fixed merge inventory differs"
|
|
147
|
-
end
|
|
148
|
-
fixed_records.each { |record| verify_fixed_record!(record, commit: value) }
|
|
149
|
-
current = File.stat(@repo_root)
|
|
150
|
-
unless !File.symlink?(@repo_root) && [current.dev, current.ino, current.uid, current.gid, current.mode] == identity && ref_value == value
|
|
151
|
-
raise AttemptErrors::EvidenceUnavailable, "canonical initialization selection changed"
|
|
152
|
-
end
|
|
153
|
-
value.dup.freeze
|
|
154
|
-
end
|
|
155
|
-
rescue SystemCallError, ArgumentError
|
|
156
|
-
raise AttemptErrors::EvidenceUnavailable, "canonical initialization repository is unavailable"
|
|
157
|
-
end
|
|
158
|
-
|
|
159
|
-
# Current value of the evidence ref.
|
|
160
|
-
#
|
|
161
|
-
# @return [String, nil] Commit SHA or nil when the ref does not exist
|
|
162
|
-
def ref_value
|
|
163
|
-
out, stderr, status = git("rev-parse", "--verify", "--quiet", @ref)
|
|
164
|
-
return out if status.success?
|
|
165
|
-
|
|
166
|
-
raise AttemptErrors::EvidenceUnavailable, "Cannot read evidence ref #{@ref}: #{stderr}" if git_broken?(stderr)
|
|
167
|
-
|
|
168
|
-
nil
|
|
169
|
-
end
|
|
170
|
-
|
|
171
|
-
# Read-only verification for a fixed immutable maintenance snapshot.
|
|
172
|
-
def verify_commit!(commit)
|
|
173
|
-
unless commit.is_a?(String) && commit.match?(/\A[0-9a-f]{40}\z/)
|
|
174
|
-
raise AttemptErrors::EvidenceUnavailable, "canonical journal commit is invalid"
|
|
175
|
-
end
|
|
176
|
-
kind, error, status = git("cat-file", "-t", commit)
|
|
177
|
-
unless status.success? && kind == "commit"
|
|
178
|
-
raise AttemptErrors::EvidenceUnavailable, "canonical journal commit is unavailable: #{error}"
|
|
179
|
-
end
|
|
180
|
-
true
|
|
181
|
-
end
|
|
182
|
-
|
|
183
|
-
# All immutable owner queries select retained canonical evidence, not
|
|
184
|
-
# arbitrary Git objects or valid-looking abandoned CAS candidates.
|
|
185
|
-
def verify_canonical_prefix!(commit:, canonical_commit: ref_value)
|
|
186
|
-
verify_commit!(commit)
|
|
187
|
-
verify_commit!(canonical_commit)
|
|
188
|
-
text, _error, status = git("rev-list", "--first-parent", "--parents",
|
|
189
|
-
"--max-count=#{self.class::HISTORY_LIMIT + 1}", canonical_commit)
|
|
190
|
-
nodes = text.lines.map(&:split)
|
|
191
|
-
unless status.success? && nodes.size <= self.class::HISTORY_LIMIT && !nodes.empty? &&
|
|
192
|
-
nodes.all? { |node| node.size.between?(1, 2) && node.all? { |sha| sha.match?(/\A[0-9a-f]{40}\z/) } } &&
|
|
193
|
-
nodes.each_cons(2).all? { |left, right| left[1] == right[0] } && nodes.last.size == 1 &&
|
|
194
|
-
nodes.any? { |node| node.first == commit }
|
|
195
|
-
raise AttemptErrors::EvidenceUnavailable, "Evidence prefix is not retained canonical first-parent history"
|
|
196
|
-
end
|
|
197
|
-
true
|
|
198
|
-
end
|
|
199
|
-
|
|
200
|
-
# Normal append commits inherit the event unchanged. Locate its one
|
|
201
|
-
# introduction; inherited appearances are not competing provenance.
|
|
202
|
-
def event_commit!(assignment_id:, event_digest:, commit:)
|
|
203
|
-
event_commits!(assignment_id: assignment_id, event_digests: [event_digest], commit: commit).fetch(event_digest)
|
|
204
|
-
end
|
|
205
|
-
|
|
206
|
-
# Operation-scoped batch of the same provenance proof. Reuse only the
|
|
207
|
-
# already authenticated exact wire inventory; never an owner cache.
|
|
208
|
-
def event_commits!(assignment_id:, event_digests:, commit:)
|
|
209
|
-
event_commits_for_assignments!(selectors: {assignment_id => event_digests}, commit: commit).fetch(assignment_id)
|
|
210
|
-
end
|
|
211
|
-
|
|
212
|
-
# Cross-attempt projections use canonical publication order, never the
|
|
213
|
-
# wall-clock timestamps of independently chained attempts. Exactly one
|
|
214
|
-
# selected event must have been introduced by the latest relevant CAS.
|
|
215
|
-
def latest_event!(assignment_id:, event_digests:, commit:)
|
|
216
|
-
introductions = event_commits!(assignment_id: assignment_id, event_digests: event_digests, commit: commit)
|
|
217
|
-
introduced_commits = introductions.values.to_h { |value| [value, true] }
|
|
218
|
-
node = canonical_history_nodes!(commit).find { |entry| introduced_commits.key?(entry.first) }
|
|
219
|
-
selected = node && introductions.select { |_digest, introduced| introduced == node.first }.keys
|
|
220
|
-
unless selected&.one?
|
|
221
|
-
raise AttemptErrors::EvidenceUnavailable, "latest canonical event is missing or ambiguous"
|
|
222
|
-
end
|
|
223
|
-
read_events(assignment_id, commit: commit).find { |event| event.fetch("digest") == selected.first } ||
|
|
224
|
-
raise(AttemptErrors::EvidenceUnavailable, "latest canonical event is unavailable")
|
|
225
|
-
end
|
|
226
|
-
|
|
227
|
-
# One immutable walk for an operation selecting several assignments.
|
|
228
|
-
# Every selected assignment retains its complete chain and exact raw
|
|
229
|
-
# blob proof; the result is returned, never cached on this owner.
|
|
230
|
-
def event_commits_for_assignments!(selectors:, commit:)
|
|
231
|
-
unless selectors.is_a?(Hash) && !selectors.empty? && selectors.size <= HISTORY_LIMIT &&
|
|
232
|
-
selectors.keys.all? { |id| id.is_a?(String) && id.match?(JournalMutation::ID) } &&
|
|
233
|
-
selectors.values.all? { |digests|
|
|
234
|
-
digests.is_a?(Array) && !digests.empty? &&
|
|
235
|
-
digests.uniq.size == digests.size && digests.all? { |digest| digest.is_a?(String) && digest.match?(/\A[0-9a-f]{64}\z/) }
|
|
236
|
-
} &&
|
|
237
|
-
selectors.values.sum(&:size) <= HISTORY_LIMIT
|
|
238
|
-
raise AttemptErrors::EvidenceUnavailable, "historical event selectors are invalid"
|
|
239
|
-
end
|
|
240
|
-
if (inventory = retained_operation_inventory(commit))
|
|
241
|
-
return selectors.to_h do |id, digests|
|
|
242
|
-
introductions = inventory.fetch("introductions").fetch(id)
|
|
243
|
-
[id.dup.freeze, digests.to_h { |digest| [digest.dup.freeze, introductions.fetch(digest)] }.freeze]
|
|
244
|
-
end.freeze
|
|
245
|
-
end
|
|
246
|
-
nodes = canonical_history_nodes!(commit)
|
|
247
|
-
states = selectors.to_h do |id, digests|
|
|
248
|
-
[id, digests.to_h { |digest| [digest, {retained: nil, blob: nil, absent: false, introduction: nil}] }]
|
|
249
|
-
end
|
|
250
|
-
each_history_event_snapshot!(states.keys, nodes: nodes) do |node, snapshots, files|
|
|
251
|
-
states.each do |assignment_id, selected_states|
|
|
252
|
-
events = snapshots.fetch(assignment_id)
|
|
253
|
-
validated_attempt_chains!(events)
|
|
254
|
-
matches = events.select { |event| selected_states.key?(event["digest"]) }.group_by { |event| event.fetch("digest") }
|
|
255
|
-
selected_states.each do |digest, state|
|
|
256
|
-
selected = matches.fetch(digest, [])
|
|
257
|
-
raise AttemptErrors::EvidenceUnavailable, "historical event selector is ambiguous" if selected.size > 1
|
|
258
|
-
if selected.empty?
|
|
259
|
-
raise AttemptErrors::EvidenceUnavailable, "historical event disappeared" if state[:retained]
|
|
260
|
-
next
|
|
261
|
-
end
|
|
262
|
-
event = selected.first
|
|
263
|
-
blob = files.fetch(assignment_id).fetch(event_filename(event))
|
|
264
|
-
if (state[:retained] && state[:retained] != event) || (state[:blob] && state[:blob] != blob)
|
|
265
|
-
raise AttemptErrors::EvidenceUnavailable, "historical event changed"
|
|
266
|
-
end
|
|
267
|
-
state[:blob] ||= blob
|
|
268
|
-
state[:retained] ||= event
|
|
269
|
-
state[:introduction] ||= node.first
|
|
270
|
-
end
|
|
271
|
-
end
|
|
272
|
-
end
|
|
273
|
-
unless states.values.all? { |selected_states| selected_states.values.all? { |state| state[:introduction] } }
|
|
274
|
-
raise AttemptErrors::EvidenceUnavailable, "historical canonical event is unavailable"
|
|
275
|
-
end
|
|
276
|
-
states.to_h do |id, selected_states|
|
|
277
|
-
[id.dup.freeze, selected_states.to_h { |digest, state| [digest.dup.freeze, state.fetch(:introduction).dup.freeze] }.freeze]
|
|
278
|
-
end.freeze
|
|
279
|
-
rescue KeyError, TypeError, ArgumentError, NoMethodError
|
|
280
|
-
raise AttemptErrors::EvidenceUnavailable, "historical canonical event selectors are unverifiable"
|
|
281
|
-
end
|
|
282
|
-
|
|
283
|
-
HISTORY_DIFF_BYTES = 256 * 1024 * 1024
|
|
284
|
-
HISTORY_DIFF_FLAGS = %w[diff-tree --stdin --always --root -r --raw -z --no-renames
|
|
285
|
-
--no-ext-diff --no-textconv --no-abbrev --].freeze
|
|
286
|
-
|
|
287
|
-
# The existing history owner still validates every prefix. Git returns
|
|
288
|
-
# all requested headers, including unchanged commits; only immutable
|
|
289
|
-
# changed blob bytes are batched rather than respawned at each node.
|
|
290
|
-
def each_history_event_snapshot!(ids, nodes:)
|
|
291
|
-
inventory = ids.nil?
|
|
292
|
-
ids = [] if inventory
|
|
293
|
-
files = ids.to_h { |id| [id, {}] }
|
|
294
|
-
contents = ids.to_h { |id| [id, {}] }
|
|
295
|
-
paths = inventory ? ["execution/"] : ids.map { |id| "execution/#{id}/events/" }
|
|
296
|
-
nodes.reverse.each_slice(64) do |chunk|
|
|
297
|
-
commits = chunk.map(&:first)
|
|
298
|
-
raw = history_diff!(commits, paths)
|
|
299
|
-
changes = decode_history_diff!(commits, paths, raw, all_paths: inventory)
|
|
300
|
-
if inventory
|
|
301
|
-
changes.each_value do |entries|
|
|
302
|
-
entries.each do |path, *_|
|
|
303
|
-
id = path.split("/")[1]
|
|
304
|
-
next if id == "requests"
|
|
305
|
-
unless id && id.match?(JournalMutation::ID)
|
|
306
|
-
raise AttemptErrors::EvidenceUnavailable, "historical assignment path is invalid"
|
|
307
|
-
end
|
|
308
|
-
unless files.key?(id)
|
|
309
|
-
raise AttemptErrors::EvidenceUnavailable, "historical assignment inventory exceeds bound" if ids.size >= HISTORY_LIMIT
|
|
310
|
-
ids << id
|
|
311
|
-
files[id], contents[id] = {}, {}
|
|
312
|
-
end
|
|
313
|
-
end
|
|
314
|
-
entries.select! do |path, *_|
|
|
315
|
-
id = path.split("/")[1]
|
|
316
|
-
id != "requests" && path.start_with?("execution/#{id}/events/") && path.end_with?(".json")
|
|
317
|
-
end
|
|
318
|
-
end
|
|
319
|
-
end
|
|
320
|
-
oids = changes.values.flatten(1).filter_map { |change| change[3] unless change[3] == "0" * 40 }.uniq
|
|
321
|
-
decoded = {}
|
|
322
|
-
oids.each_slice(64) do |batch|
|
|
323
|
-
sizes = history_blob_sizes!(batch)
|
|
324
|
-
entries = batch.zip(sizes)
|
|
325
|
-
groups = []
|
|
326
|
-
entries.each do |entry|
|
|
327
|
-
groups << [] if groups.empty? || (!groups.last.empty? && groups.last.sum(&:last) + entry.last > EVENT_BATCH_BYTES)
|
|
328
|
-
groups.last << entry
|
|
329
|
-
end
|
|
330
|
-
groups.each do |group|
|
|
331
|
-
decode_event_blobs!(group, read_event_blobs!(group)).each_with_index do |bytes, index|
|
|
332
|
-
decoded[group.fetch(index).first] = JSON.parse(bytes)
|
|
333
|
-
end
|
|
334
|
-
end
|
|
335
|
-
end
|
|
336
|
-
chunk.each do |node|
|
|
337
|
-
changes.fetch(node.first).each do |path, status, old, fresh|
|
|
338
|
-
id = ids.find { |selected| path.start_with?("execution/#{selected}/events/") }
|
|
339
|
-
name = path.delete_prefix("execution/#{id}/events/")
|
|
340
|
-
current = files.fetch(id)[name]
|
|
341
|
-
unless (status == "A" && old == "0" * 40 && current.nil?) ||
|
|
342
|
-
(%w[M D T].include?(status) && current == old)
|
|
343
|
-
raise AttemptErrors::EvidenceUnavailable, "historical tree change differs from previous immutable snapshot"
|
|
344
|
-
end
|
|
345
|
-
if status == "D"
|
|
346
|
-
files.fetch(id).delete(name)
|
|
347
|
-
contents.fetch(id).delete(name)
|
|
348
|
-
else
|
|
349
|
-
files.fetch(id)[name] = fresh
|
|
350
|
-
contents.fetch(id)[name] = decoded.fetch(fresh)
|
|
351
|
-
end
|
|
352
|
-
end
|
|
353
|
-
snapshots = contents.transform_values do |selected|
|
|
354
|
-
events = selected.values
|
|
355
|
-
digests = events.map { |event| event.fetch("digest") }
|
|
356
|
-
raise AttemptErrors::EvidenceUnavailable, "historical event digest is ambiguous" unless digests.uniq.size == digests.size
|
|
357
|
-
order_by_chain(events)
|
|
358
|
-
end
|
|
359
|
-
yield node, snapshots, files
|
|
360
|
-
end
|
|
361
|
-
end
|
|
362
|
-
_events, actual = ids.empty? ? [{}, {}] : read_event_snapshots!(ids, commit: nodes.first.first)
|
|
363
|
-
raise AttemptErrors::EvidenceUnavailable, "historical reconstructed tip differs from its immutable tree" unless files == actual
|
|
364
|
-
rescue JSON::ParserError, KeyError, TypeError, ArgumentError
|
|
365
|
-
raise AttemptErrors::EvidenceUnavailable, "historical batched snapshots are unverifiable"
|
|
366
|
-
end
|
|
367
|
-
|
|
368
|
-
def history_diff!(commits, paths)
|
|
369
|
-
return @read_boundary.history_diff(commits, paths, output_limit: HISTORY_DIFF_BYTES) if @read_boundary
|
|
370
|
-
bounded_history_read!(HISTORY_DIFF_FLAGS + paths, commits.join("\n") + "\n", HISTORY_DIFF_BYTES)
|
|
371
|
-
end
|
|
372
|
-
|
|
373
|
-
def history_blob_sizes!(oids)
|
|
374
|
-
raw = if @read_boundary
|
|
375
|
-
@read_boundary.blob_sizes(oids)
|
|
376
|
-
else
|
|
377
|
-
bounded_history_read!(%w[cat-file --batch-check], oids.join("\n") + "\n", oids.size * 128)
|
|
378
|
-
end
|
|
379
|
-
Atoms::GitBlobBatch.sizes(oids, raw)
|
|
380
|
-
rescue Atoms::GitBlobBatch::Invalid => error
|
|
381
|
-
message = error.reason == :count ? "historical blob metadata count differs" : "historical blob metadata differs"
|
|
382
|
-
raise AttemptErrors::EvidenceUnavailable, message
|
|
383
|
-
end
|
|
384
|
-
|
|
385
|
-
def bounded_history_read!(argv, stdin, limit)
|
|
386
|
-
result = Herdr::Molecules::BoundedProcess.call(["git", *argv], chdir: File.expand_path(@repo_root),
|
|
387
|
-
stdin_data: stdin, timeout_s: 30, output_limit: limit)
|
|
388
|
-
raise AttemptErrors::EvidenceUnavailable, "historical batch unavailable or exceeds bound" unless result.status.success? && !result.oversized
|
|
389
|
-
result.stdout.to_s.b
|
|
390
|
-
rescue Timeout::Error, Herdr::Molecules::BoundedProcess::PostLaunchError, IOError, SystemCallError
|
|
391
|
-
raise AttemptErrors::EvidenceUnavailable, "historical batch is unavailable"
|
|
392
|
-
end
|
|
393
|
-
|
|
394
|
-
def decode_history_diff!(commits, paths, raw, all_paths: false)
|
|
395
|
-
unless raw.is_a?(String) && raw.end_with?("\0")
|
|
396
|
-
raise AttemptErrors::EvidenceUnavailable, "historical diff frame is incomplete"
|
|
397
|
-
end
|
|
398
|
-
tokens = raw.split("\0", -1)
|
|
399
|
-
tokens.pop
|
|
400
|
-
result = commits.to_h { |commit| [commit, []] }
|
|
401
|
-
position = 0
|
|
402
|
-
commits.each do |commit|
|
|
403
|
-
raise AttemptErrors::EvidenceUnavailable, "historical commit header differs" unless tokens[position] == commit
|
|
404
|
-
position += 1
|
|
405
|
-
seen = {}
|
|
406
|
-
while tokens[position]&.start_with?(":")
|
|
407
|
-
metadata, path = tokens.values_at(position, position + 1)
|
|
408
|
-
match = /\A:([0-7]{6}) ([0-7]{6}) ([0-9a-f]{40}) ([0-9a-f]{40}) ([AMDT])\z/.match(metadata)
|
|
409
|
-
unless match && path.is_a?(String) && paths.any? { |prefix| path.start_with?(prefix) } && !seen[path]
|
|
410
|
-
raise AttemptErrors::EvidenceUnavailable, "historical tree metadata differs"
|
|
411
|
-
end
|
|
412
|
-
seen[path] = true
|
|
413
|
-
position += 2
|
|
414
|
-
next unless all_paths || path.end_with?(".json")
|
|
415
|
-
old, fresh, status = match.values_at(3, 4, 5)
|
|
416
|
-
if all_paths && !path.match?(%r{\Aexecution/[^/]+/events/.*\.json\z})
|
|
417
|
-
# Complete discovery needs these assignment paths, but their
|
|
418
|
-
# non-event bodies/modes never become canonical event facts.
|
|
419
|
-
result.fetch(commit) << [path, status, old, fresh]
|
|
420
|
-
next
|
|
421
|
-
end
|
|
422
|
-
if all_paths && path.start_with?("execution/requests/")
|
|
423
|
-
result.fetch(commit) << [path, status, old, fresh]
|
|
424
|
-
next
|
|
425
|
-
end
|
|
426
|
-
modes = match.values_at(1, 2)
|
|
427
|
-
unless modes.all? { |mode| %w[000000 100644 100755 120000].include?(mode) } &&
|
|
428
|
-
(if status == "A"
|
|
429
|
-
old == "0" * 40 && fresh != "0" * 40 && modes.first == "000000" && modes.last != "000000"
|
|
430
|
-
else
|
|
431
|
-
(status == "D") ? old != "0" * 40 && fresh == "0" * 40 && modes.first != "000000" && modes.last == "000000" :
|
|
432
|
-
old != "0" * 40 && fresh != "0" * 40 && modes.none? { |mode| mode == "000000" }
|
|
433
|
-
end)
|
|
434
|
-
raise AttemptErrors::EvidenceUnavailable, "historical event object type differs"
|
|
435
|
-
end
|
|
436
|
-
result.fetch(commit) << [path, status, old, fresh]
|
|
437
|
-
end
|
|
438
|
-
end
|
|
439
|
-
raise AttemptErrors::EvidenceUnavailable, "historical diff has extra headers or bytes" unless position == tokens.size
|
|
440
|
-
result
|
|
441
|
-
end
|
|
442
|
-
private :each_history_event_snapshot!, :history_diff!, :history_blob_sizes!, :bounded_history_read!, :decode_history_diff!
|
|
443
|
-
|
|
444
|
-
# Complete immutable index facts are authenticated before any caller
|
|
445
|
-
# filters ownership. A missing current fact cannot hide an older one.
|
|
446
|
-
def canonical_event_inventory!(commit:)
|
|
447
|
-
context = Thread.current[:ace_assign_event_read_operation]
|
|
448
|
-
reusable = context && @mode == :protected && commit.is_a?(String) && commit.match?(/\A[0-9a-f]{40}\z/)
|
|
449
|
-
selection = operation_inventory_selection(commit) if reusable
|
|
450
|
-
if reusable
|
|
451
|
-
inventories = context[:inventories] ||= {}
|
|
452
|
-
if inventories.key?(selection)
|
|
453
|
-
# Refresh bounded insertion order, retaining only immutable facts.
|
|
454
|
-
inventory = inventories.delete(selection)
|
|
455
|
-
inventories[selection] = inventory
|
|
456
|
-
return inventory
|
|
457
|
-
end
|
|
458
|
-
else
|
|
459
|
-
context&.delete(:inventories)
|
|
460
|
-
end
|
|
461
|
-
inventory = read_canonical_inventory!(commit: commit)
|
|
462
|
-
if reusable
|
|
463
|
-
inventories.shift while inventories.size >= OPERATION_INVENTORY_LIMIT
|
|
464
|
-
inventories[selection] = inventory
|
|
465
|
-
end
|
|
466
|
-
inventory
|
|
467
|
-
end
|
|
468
|
-
|
|
469
|
-
# Query only the exact full history already authenticated in this wire
|
|
470
|
-
# operation. No historical selector builds or advances this snapshot.
|
|
471
|
-
def operation_inventory_selection(commit)
|
|
472
|
-
[object_id, @repo_root, @ref, @checkout_root, @mode, @read_boundary&.object_id, commit]
|
|
473
|
-
.map { |value| value.is_a?(String) ? value.dup.freeze : value }.freeze
|
|
474
|
-
end
|
|
475
|
-
|
|
476
|
-
def retained_operation_inventory(commit)
|
|
477
|
-
context = Thread.current[:ace_assign_event_read_operation]
|
|
478
|
-
return unless context && @mode == :protected && commit.is_a?(String) && commit.match?(/\A[0-9a-f]{40}\z/)
|
|
479
|
-
context.fetch(:inventories, {})[operation_inventory_selection(commit)]
|
|
480
|
-
end
|
|
481
|
-
private :operation_inventory_selection, :retained_operation_inventory
|
|
482
|
-
|
|
483
|
-
def read_canonical_inventory!(commit:)
|
|
484
|
-
nodes = canonical_history_nodes!(commit)
|
|
485
|
-
root_events, introductions = {}, {}
|
|
486
|
-
previous_chains, previous_files = {}, {}
|
|
487
|
-
each_history_event_snapshot!(nil, nodes: nodes) do |node, snapshots, files|
|
|
488
|
-
chains = snapshots.transform_values { |events| validated_attempt_chains!(events) }
|
|
489
|
-
snapshots.each do |id, events|
|
|
490
|
-
unless events.all? { |event| files.fetch(id).key?(event_filename(event)) }
|
|
491
|
-
raise AttemptErrors::EvidenceUnavailable, "canonical event filename differs"
|
|
492
|
-
end
|
|
493
|
-
end
|
|
494
|
-
previous_chains.each do |id, attempts|
|
|
495
|
-
attempts.each do |attempt_id, chain|
|
|
496
|
-
newer = chains.fetch(id, {}).fetch(attempt_id, [])
|
|
497
|
-
unless chain == newer.take(chain.size)
|
|
498
|
-
raise AttemptErrors::EvidenceUnavailable, "canonical inventory history removed or rewrote an attempt"
|
|
499
|
-
end
|
|
500
|
-
end
|
|
501
|
-
end
|
|
502
|
-
previous_files.each do |id, selected_files|
|
|
503
|
-
unless selected_files.all? { |name, oid| files.fetch(id, {})[name] == oid }
|
|
504
|
-
raise AttemptErrors::EvidenceUnavailable, "canonical inventory history removed or rewrote event bytes"
|
|
505
|
-
end
|
|
506
|
-
end
|
|
507
|
-
snapshots.each do |id, events|
|
|
508
|
-
introductions[id] ||= {}
|
|
509
|
-
events.each { |event| introductions.fetch(id)[event.fetch("digest")] ||= node.first }
|
|
510
|
-
end
|
|
511
|
-
root_events = snapshots.reject { |_id, events| events.empty? }
|
|
512
|
-
previous_chains = chains
|
|
513
|
-
previous_files = files.transform_values(&:dup)
|
|
514
|
-
end
|
|
515
|
-
introductions.select! { |id, _| root_events.key?(id) }
|
|
516
|
-
freeze_inventory_projection("commit" => commit, "events" => root_events, "introductions" => introductions)
|
|
517
|
-
rescue KeyError, TypeError, ArgumentError, NoMethodError
|
|
518
|
-
raise AttemptErrors::EvidenceUnavailable, "canonical inventory history is unverifiable"
|
|
519
|
-
end
|
|
520
|
-
|
|
521
|
-
private :read_canonical_inventory!
|
|
522
|
-
|
|
523
|
-
def canonical_history_nodes!(commit)
|
|
524
|
-
verify_commit!(commit)
|
|
525
|
-
topology, error, status = git("rev-list", "--first-parent", "--parents",
|
|
526
|
-
"--max-count=#{HISTORY_LIMIT + 1}", commit)
|
|
527
|
-
nodes = topology.lines.map(&:split)
|
|
528
|
-
unless status.success? && !nodes.empty? && nodes.size <= HISTORY_LIMIT &&
|
|
529
|
-
nodes.all? { |node| node.size.between?(1, 2) && node.all? { |sha| sha.match?(/\A[0-9a-f]{40}\z/) } } &&
|
|
530
|
-
nodes.each_cons(2).all? { |left, right| left[1] == right[0] } && nodes.last.size == 1
|
|
531
|
-
raise AttemptErrors::EvidenceUnavailable, "canonical history topology is unsupported or incomplete: #{error}"
|
|
532
|
-
end
|
|
533
|
-
nodes
|
|
534
|
-
end
|
|
535
|
-
|
|
536
|
-
def validated_attempt_chains!(events)
|
|
537
|
-
chains = events.group_by { |event| event.fetch("attempt_id") }
|
|
538
|
-
unless chains.values.all? { |chain| Models::EvidenceEvent.chain_valid?(chain) }
|
|
539
|
-
raise AttemptErrors::EvidenceUnavailable, "historical canonical event chain is corrupt"
|
|
540
|
-
end
|
|
541
|
-
chains
|
|
542
|
-
end
|
|
543
|
-
|
|
544
|
-
def freeze_inventory_projection(value)
|
|
545
|
-
case value
|
|
546
|
-
when Hash
|
|
547
|
-
value.to_h { |key, item| [freeze_inventory_projection(key), freeze_inventory_projection(item)] }.freeze
|
|
548
|
-
when Array
|
|
549
|
-
value.map { |item| freeze_inventory_projection(item) }.freeze
|
|
550
|
-
when String
|
|
551
|
-
value.dup.freeze
|
|
552
|
-
else
|
|
553
|
-
value.freeze
|
|
554
|
-
end
|
|
555
|
-
end
|
|
556
|
-
private :canonical_history_nodes!, :validated_attempt_chains!, :freeze_inventory_projection
|
|
557
|
-
|
|
558
|
-
# Append accepted events to the journal under lock + CAS.
|
|
559
|
-
#
|
|
560
|
-
# @param assignment_id [String] Assignment ID (journal path segment)
|
|
561
|
-
# @param attempt_id [String] Attempt ID (commit message + dedupe scope)
|
|
562
|
-
# @param events [Array<Hash>] Event payloads from Models::EvidenceEvent.build
|
|
563
|
-
# @return [String] Journal commit SHA after the append
|
|
564
|
-
# @raise [AttemptErrors::EvidenceUnavailable] when the ref stays conflicting
|
|
565
|
-
def append(assignment_id:, attempt_id:, events:)
|
|
566
|
-
return ref_value if events.empty?
|
|
567
|
-
|
|
568
|
-
with_lock do
|
|
569
|
-
CAS_ATTEMPTS.times do
|
|
570
|
-
# Re-read the authoritative value every attempt. nil means the
|
|
571
|
-
# ref does not exist yet; ensure_checkout! seeds it, so re-read
|
|
572
|
-
# before computing the compare-and-swap baseline.
|
|
573
|
-
old = ref_value
|
|
574
|
-
ensure_checkout!
|
|
575
|
-
old = ref_value if old.nil?
|
|
576
|
-
sync_checkout(old)
|
|
577
|
-
written = write_event_files(assignment_id, events)
|
|
578
|
-
new_commit = written.zero? ? old : commit_events(assignment_id, attempt_id, events)
|
|
579
|
-
return new_commit if update_ref_cas(new_commit, old)
|
|
580
|
-
|
|
581
|
-
# CAS conflict: another writer advanced the ref. The next pass
|
|
582
|
-
# resets the checkout to the authoritative tree and replays
|
|
583
|
-
# only the events that are still missing (duplicates skipped).
|
|
584
|
-
end
|
|
585
|
-
|
|
586
|
-
raise AttemptErrors::EvidenceUnavailable,
|
|
587
|
-
"Evidence ref #{@ref} stayed conflicting after #{CAS_ATTEMPTS} compare-and-swap attempts"
|
|
588
|
-
end
|
|
589
|
-
end
|
|
590
|
-
|
|
591
|
-
# Build an event against the latest chain while holding the ref lock.
|
|
592
|
-
# Re-run the eligibility guard on each CAS retry, never append a
|
|
593
|
-
# stale chain continuation after another accepted writer.
|
|
594
|
-
def record(assignment_id:, attempt_id:, type:, payload:, guard: nil)
|
|
595
|
-
with_lock do
|
|
596
|
-
CAS_ATTEMPTS.times do
|
|
597
|
-
old = ref_value
|
|
598
|
-
ensure_checkout!
|
|
599
|
-
old = ref_value if old.nil?
|
|
600
|
-
sync_checkout(old)
|
|
601
|
-
guard&.call
|
|
602
|
-
previous = read_events(assignment_id).reverse.find { |event| event["attempt_id"] == attempt_id }&.fetch("digest")
|
|
603
|
-
event = Models::EvidenceEvent.build(type: type, attempt_id: attempt_id,
|
|
604
|
-
payload: payload, previous_digest: previous)
|
|
605
|
-
write_event_files(assignment_id, [event])
|
|
606
|
-
commit = commit_events(assignment_id, attempt_id, [event])
|
|
607
|
-
return commit if update_ref_cas(commit, old)
|
|
608
|
-
end
|
|
609
|
-
raise AttemptErrors::EvidenceUnavailable, "Evidence ref stayed conflicting during event recording"
|
|
610
|
-
end
|
|
611
|
-
end
|
|
612
|
-
|
|
613
|
-
# Read all journal events recorded for an assignment, ordered by the
|
|
614
|
-
# digest chain (never by filename: same-second events sort
|
|
615
|
-
# alphabetically, which can invert lifecycle order).
|
|
616
|
-
#
|
|
617
|
-
# @param assignment_id [String] Assignment ID
|
|
618
|
-
# @return [Array<Hash>] Parsed events in journal order
|
|
619
|
-
def read_events(assignment_id, commit: ref_value)
|
|
620
|
-
context = Thread.current[:ace_assign_event_read_operation]
|
|
621
|
-
if commit.nil?
|
|
622
|
-
context&.delete(:selection)
|
|
623
|
-
context&.delete(:events)
|
|
624
|
-
return []
|
|
625
|
-
end
|
|
626
|
-
unless context && @mode == :protected && commit.is_a?(String) && commit.match?(/\A[0-9a-f]{40}\z/)
|
|
627
|
-
context&.delete(:selection)
|
|
628
|
-
context&.delete(:events)
|
|
629
|
-
return read_event_snapshots!([assignment_id], commit: commit).first.fetch(assignment_id)
|
|
630
|
-
end
|
|
631
|
-
selection = [object_id, @repo_root, @ref, @checkout_root, @mode, @read_boundary&.object_id,
|
|
632
|
-
assignment_id, commit].map { |value| value.is_a?(String) ? value.dup.freeze : value }.freeze
|
|
633
|
-
return context.fetch(:events) if context[:selection] == selection
|
|
634
|
-
context.delete(:selection)
|
|
635
|
-
context.delete(:events)
|
|
636
|
-
inventory = retained_operation_inventory(commit) if assignment_id.is_a?(String) && assignment_id.match?(JournalMutation::ID)
|
|
637
|
-
events = if inventory
|
|
638
|
-
inventory.fetch("events").fetch(assignment_id, [])
|
|
639
|
-
else
|
|
640
|
-
read_event_snapshots!([assignment_id], commit: commit).first.fetch(assignment_id)
|
|
641
|
-
end
|
|
642
|
-
frozen = freeze_inventory_projection(events)
|
|
643
|
-
context[:selection], context[:events] = selection, frozen
|
|
644
|
-
frozen
|
|
645
|
-
end
|
|
646
|
-
|
|
647
|
-
# Bounded argv/blob batches from the same immutable tree. Preserve the
|
|
648
|
-
# actual file OIDs alongside parsed chains for introduction proofs.
|
|
649
|
-
def read_event_snapshots!(assignment_ids, commit:)
|
|
650
|
-
unless assignment_ids.is_a?(Array) && !assignment_ids.empty? && assignment_ids.size <= HISTORY_LIMIT &&
|
|
651
|
-
assignment_ids.uniq.size == assignment_ids.size && assignment_ids.all? { |id| id.is_a?(String) && id.match?(JournalMutation::ID) }
|
|
652
|
-
raise AttemptErrors::EvidenceUnavailable, "canonical assignment selectors are invalid"
|
|
653
|
-
end
|
|
654
|
-
events = assignment_ids.to_h { |id| [id, []] }
|
|
655
|
-
files = assignment_ids.to_h { |id| [id, {}] }
|
|
656
|
-
entries = []
|
|
657
|
-
assignment_ids.each_slice(64) do |ids|
|
|
658
|
-
paths, stderr, status = git("ls-tree", "-r", "-l", commit, "--", *ids.map { |id| "execution/#{id}/events/" })
|
|
659
|
-
raise AttemptErrors::EvidenceUnavailable, "Cannot read journal events: #{stderr}" unless status.success?
|
|
660
|
-
paths.lines.each do |line|
|
|
661
|
-
metadata, path = line.chomp.split("\t", 2)
|
|
662
|
-
next unless path&.end_with?(".json")
|
|
663
|
-
_mode, kind, oid, size = metadata.split
|
|
664
|
-
id = path.split("/")[1]
|
|
665
|
-
unless events.key?(id) && path.start_with?("execution/#{id}/events/") && kind == "blob" &&
|
|
666
|
-
oid&.match?(/\A[0-9a-f]{40}\z/) && size&.match?(/\A(?:0|[1-9][0-9]*)\z/)
|
|
667
|
-
raise AttemptErrors::EvidenceUnavailable, "canonical event blob selection is invalid"
|
|
668
|
-
end
|
|
669
|
-
name = path.delete_prefix("execution/#{id}/events/")
|
|
670
|
-
raise AttemptErrors::EvidenceUnavailable, "canonical event file is ambiguous" if files.fetch(id).key?(name)
|
|
671
|
-
files.fetch(id)[name] = oid
|
|
672
|
-
entries << [oid, size.to_i, id]
|
|
673
|
-
end
|
|
674
|
-
end
|
|
675
|
-
batches = []
|
|
676
|
-
entries.each do |entry|
|
|
677
|
-
batches << [] if batches.empty? || batches.last.size == 64 ||
|
|
678
|
-
(!batches.last.empty? && batches.last.sum { |item| item[1] } + entry[1] > EVENT_BATCH_BYTES)
|
|
679
|
-
batches.last << entry
|
|
680
|
-
end
|
|
681
|
-
batches.each do |batch|
|
|
682
|
-
selected = batch.map { |oid, size, _id| [oid, size] }
|
|
683
|
-
contents = decode_event_blobs!(selected, read_event_blobs!(selected))
|
|
684
|
-
batch.zip(contents).each { |(_oid, _size, id), content| events.fetch(id) << JSON.parse(content) }
|
|
685
|
-
end
|
|
686
|
-
events.each_value do |chain|
|
|
687
|
-
digests = chain.map { |event| event.fetch("digest") }
|
|
688
|
-
unless digests.uniq.size == digests.size
|
|
689
|
-
raise AttemptErrors::EvidenceUnavailable, "canonical event digest is ambiguous"
|
|
690
|
-
end
|
|
691
|
-
end
|
|
692
|
-
[events.transform_values { |chain| order_by_chain(chain) }, files]
|
|
693
|
-
rescue JSON::ParserError
|
|
694
|
-
raise AttemptErrors::EvidenceUnavailable, "Corrupt canonical journal event"
|
|
695
|
-
end
|
|
696
|
-
private :read_event_snapshots!
|
|
697
|
-
|
|
698
|
-
# Accepted receipt payloads recorded for an assignment.
|
|
699
|
-
#
|
|
700
|
-
# @param assignment_id [String] Assignment ID
|
|
701
|
-
# @return [Array<Hash>] Accepted receipt payloads (digest included)
|
|
702
|
-
def accepted_receipts(assignment_id)
|
|
703
|
-
read_events(assignment_id)
|
|
704
|
-
.select { |event| event["type"] == "receipt_accepted" }
|
|
705
|
-
.map { |event| event.dig("payload", "receipt") }
|
|
706
|
-
.compact
|
|
707
|
-
end
|
|
708
|
-
|
|
709
|
-
# Reserve a globally unique service request in the same evidence ref
|
|
710
|
-
# as its owning attempt. The request index and attempt event are one
|
|
711
|
-
# Git commit, so a ref race cannot admit two effects for one ID. An
|
|
712
|
-
# exact authorization reference is consumed by its first live claim:
|
|
713
|
-
# a second request presenting the same operation/project/target
|
|
714
|
-
# authorization is rejected instead of dispatching a duplicate effect.
|
|
715
|
-
# Dispatching callers claim directly as +uncertain+ so no commit
|
|
716
|
-
# window exists where a stranded claim reads as accepted.
|
|
717
|
-
def claim_service_request(binding, state: "accepted", guard: nil)
|
|
718
|
-
guard ||= -> { authorization_conflict(binding) }
|
|
719
|
-
update_service_request(binding.fetch("request_id"), expected: nil,
|
|
720
|
-
replacement: binding.merge("state" => state, "claimed_at" => Time.now.utc.iso8601(9)),
|
|
721
|
-
event_type: "service_claim", guard: guard)
|
|
722
|
-
end
|
|
723
|
-
|
|
724
|
-
# Reject a service request. A request not yet on file gets its
|
|
725
|
-
# auditable rejection claim (which never consumed the authorization);
|
|
726
|
-
# an existing live claim transitions to rejected but stays consuming:
|
|
727
|
-
# only attributable evidence that the effect did not occur may free a
|
|
728
|
-
# dispatched authorization, never a bare rejection.
|
|
729
|
-
def reject_service_request(binding, reason:)
|
|
730
|
-
request_id = binding.fetch("request_id")
|
|
731
|
-
existing = service_request(request_id)
|
|
732
|
-
if existing
|
|
733
|
-
unless existing.except("state", "receipt", "reason", "claimed_at", "failed_at") == binding.except("state", "receipt", "reason", "claimed_at", "failed_at")
|
|
734
|
-
raise AttemptErrors::Conflict, "Service request #{request_id} has different input"
|
|
735
|
-
end
|
|
736
|
-
return existing if existing["state"] == "rejected"
|
|
737
|
-
return update_service_request(request_id, expected: existing,
|
|
738
|
-
replacement: existing.merge("state" => "rejected", "reason" => reason),
|
|
739
|
-
event_type: "service_transition")
|
|
740
|
-
end
|
|
741
|
-
update_service_request(request_id, expected: nil,
|
|
742
|
-
replacement: binding.merge("state" => "rejected", "reason" => reason, "consumed" => false),
|
|
743
|
-
event_type: "service_claim")
|
|
744
|
-
end
|
|
745
|
-
|
|
746
|
-
# Terminal writes are validated HERE at the journal boundary —
|
|
747
|
-
# receipt schema, binding against the current record, and attested
|
|
748
|
-
# outcome — so no caller-supplied flag decides trust. The
|
|
749
|
-
# coordinator additionally performs content-level attestation and
|
|
750
|
-
# file verification.
|
|
751
|
-
def transition_service_request(request_id, state:, receipt: nil, validated: false)
|
|
752
|
-
unless %w[succeeded failed uncertain rejected failed-settled].include?(state)
|
|
753
|
-
raise ArgumentError, "invalid service request state"
|
|
754
|
-
end
|
|
755
|
-
current = service_request(request_id)
|
|
756
|
-
raise AttemptErrors::NotFound, "Service request #{request_id} not found" unless current
|
|
757
|
-
# Terminal states are immutable; failed-settled is reached only
|
|
758
|
-
# from a dispatched (failed, uncertain, or consumed-rejected)
|
|
759
|
-
# effect with a validated no-effect receipt.
|
|
760
|
-
consumed_rejected = current["state"] == "rejected" && current["consumed"] != false
|
|
761
|
-
if %w[succeeded failed-settled].include?(current["state"]) ||
|
|
762
|
-
(current["state"] == "rejected" && !consumed_rejected) ||
|
|
763
|
-
(consumed_rejected && state != "failed-settled")
|
|
764
|
-
raise AttemptErrors::InvalidState, "Service request #{request_id} is terminal"
|
|
765
|
-
end
|
|
766
|
-
validate_terminal_receipt!(current, state, receipt) if terminal_state?(state)
|
|
767
|
-
stamped = current.merge("state" => state, "receipt" => receipt)
|
|
768
|
-
stamped["failed_at"] = Time.now.utc.iso8601(9) if state == "failed"
|
|
769
|
-
update_service_request(request_id, expected: current,
|
|
770
|
-
replacement: stamped,
|
|
771
|
-
event_type: "service_transition")
|
|
772
|
-
end
|
|
773
|
-
|
|
774
|
-
TERMINAL_RECEIPT_FIELDS = %w[assignment_id attempt_id candidate_head evidence executor_uid
|
|
775
|
-
input_digest operation outcome project_id request_id target transport].freeze
|
|
776
|
-
TERMINAL_BINDING_FIELDS = %w[request_id assignment_id attempt_id project_id operation
|
|
777
|
-
input_digest target candidate_head executor_uid transport].freeze
|
|
778
|
-
|
|
779
|
-
def terminal_state?(state)
|
|
780
|
-
%w[succeeded failed failed-settled].include?(state)
|
|
781
|
-
end
|
|
782
|
-
|
|
783
|
-
# Shared schema/binding checks; storage interpretation belongs to the
|
|
784
|
-
# caller's source-selected evidence owner, not this pure validator.
|
|
785
|
-
def validate_terminal_receipt_structure!(current, state, receipt)
|
|
786
|
-
unless receipt.is_a?(Hash) && receipt.keys.sort == TERMINAL_RECEIPT_FIELDS.sort
|
|
787
|
-
raise AttemptErrors::ReceiptRejected,
|
|
788
|
-
"Service terminal receipt has invalid fields"
|
|
789
|
-
end
|
|
790
|
-
TERMINAL_BINDING_FIELDS.each do |key|
|
|
791
|
-
unless receipt[key] == current[key]
|
|
792
|
-
raise AttemptErrors::ReceiptRejected, "Service terminal receipt does not match #{key}"
|
|
793
|
-
end
|
|
794
|
-
end
|
|
795
|
-
expected_outcome = (state == "failed-settled") ? "failed" : state
|
|
796
|
-
evidence_items = receipt["evidence"]
|
|
797
|
-
valid_evidence = evidence_items.is_a?(Array) && !evidence_items.empty? &&
|
|
798
|
-
evidence_items.all? do |item|
|
|
799
|
-
item.is_a?(Hash) && item.keys.sort == %w[ref sha256] &&
|
|
800
|
-
item["ref"].is_a?(String) && item["ref"].match?(/\A[a-zA-Z0-9_.:\/-]{1,256}\z/) &&
|
|
801
|
-
item["sha256"].is_a?(String) && item["sha256"].match?(/\A[0-9a-f]{64}\z/)
|
|
802
|
-
end
|
|
803
|
-
executor_valid = receipt["executor_uid"].is_a?(Integer) && receipt["executor_uid"] >= 0
|
|
804
|
-
unless receipt["outcome"] == expected_outcome && executor_valid && valid_evidence
|
|
805
|
-
raise AttemptErrors::ReceiptRejected, "Service terminal receipt has invalid executor or evidence"
|
|
806
|
-
end
|
|
807
|
-
if state == "failed-settled" &&
|
|
808
|
-
!receipt["evidence"].any? { |item| item["ref"].end_with?("no-effect") }
|
|
809
|
-
raise AttemptErrors::ReceiptRejected,
|
|
810
|
-
"Service settlement requires a no-effect attestation artifact"
|
|
811
|
-
end
|
|
812
|
-
evidence_items
|
|
813
|
-
end
|
|
814
|
-
|
|
815
|
-
def validate_terminal_receipt!(current, state, receipt, pending: nil)
|
|
816
|
-
evidence_items = validate_terminal_receipt_structure!(current, state, receipt)
|
|
817
|
-
# Canonical byte ownership is also usable by the ordinary installed
|
|
818
|
-
# service. It does not opt that service into protected authority.
|
|
819
|
-
if @evidence_reader
|
|
820
|
-
if (current["operation"] == "prune-preserved-workspace" && %w[succeeded failed-settled].include?(state)) ||
|
|
821
|
-
(current["operation"] == "publish" && %w[succeeded failed].include?(state))
|
|
822
|
-
# The same installed evidence reader owns the entire imported
|
|
823
|
-
# cleanup pair; individual textual markers cannot authorize it.
|
|
824
|
-
contents = @evidence_reader.call(evidence_items, current, state, pending)
|
|
825
|
-
unless contents.is_a?(Array) && contents.size == evidence_items.size &&
|
|
826
|
-
contents.each_with_index.all? { |content, index| content.is_a?(String) && Digest::SHA256.hexdigest(content.b) == evidence_items.fetch(index).fetch("sha256") }
|
|
827
|
-
raise AttemptErrors::ReceiptRejected, "Canonical cleanup evidence is unverifiable"
|
|
828
|
-
end
|
|
829
|
-
return true
|
|
830
|
-
end
|
|
831
|
-
evidence_items.each do |item|
|
|
832
|
-
content = @evidence_reader.call(item, current, state, pending)
|
|
833
|
-
unless content.is_a?(String) && Digest::SHA256.hexdigest(content.b) == item["sha256"]
|
|
834
|
-
raise AttemptErrors::ReceiptRejected, "Canonical service evidence is unverifiable"
|
|
835
|
-
end
|
|
836
|
-
verify_service_attestation!(content, current, state)
|
|
837
|
-
end
|
|
838
|
-
return true
|
|
839
|
-
end
|
|
840
|
-
repo_root = File.realpath(@repo_root)
|
|
841
|
-
receipt["evidence"].each do |item|
|
|
842
|
-
path = File.expand_path(item["ref"], repo_root)
|
|
843
|
-
real = begin
|
|
844
|
-
File.realpath(path)
|
|
845
|
-
rescue Errno::ENOENT, Errno::EACCES, Errno::ELOOP
|
|
846
|
-
raise AttemptErrors::ReceiptRejected,
|
|
847
|
-
"Service terminal receipt evidence is unverifiable: #{item["ref"]}"
|
|
848
|
-
end
|
|
849
|
-
intact = begin
|
|
850
|
-
real.start_with?(repo_root + File::SEPARATOR) &&
|
|
851
|
-
Digest::SHA256.file(real).hexdigest == item["sha256"]
|
|
852
|
-
rescue Errno::EACCES, Errno::ELOOP
|
|
853
|
-
false
|
|
854
|
-
end
|
|
855
|
-
unless intact
|
|
856
|
-
raise AttemptErrors::ReceiptRejected,
|
|
857
|
-
"Service terminal receipt evidence is unverifiable: #{item["ref"]}"
|
|
858
|
-
end
|
|
859
|
-
verify_service_attestation!(File.read(real), current, state)
|
|
860
|
-
end
|
|
861
|
-
end
|
|
862
|
-
|
|
863
|
-
def service_request(request_id, commit: ref_value)
|
|
864
|
-
read_service_request(request_id, commit: commit, pending: {commit: commit})
|
|
865
|
-
end
|
|
866
|
-
|
|
867
|
-
# A read operation owns this view only until its caller returns. The
|
|
868
|
-
# original complete first-parent/raw-blob owner supplies every fact;
|
|
869
|
-
# it is never retained on the journal or reused for another operation.
|
|
870
|
-
ServiceReadView = Struct.new(:journal, :commit, :inventory, :projection, keyword_init: true)
|
|
871
|
-
|
|
872
|
-
def service_settlement_read(request_id, commit: ref_value)
|
|
873
|
-
verify_canonical_prefix!(commit: commit)
|
|
874
|
-
record = read_service_request(request_id, commit: commit, pending: {commit: commit}, terminal: false)
|
|
875
|
-
return {record: nil, settlement_context: nil, read_view: nil}.freeze unless record
|
|
876
|
-
id = record.fetch("assignment_id")
|
|
877
|
-
events = read_events(id, commit: commit)
|
|
878
|
-
selectors = events.map { |event| event.fetch("digest") }
|
|
879
|
-
introductions = event_commits!(assignment_id: id, event_digests: selectors, commit: commit)
|
|
880
|
-
inventory = freeze_inventory_projection("commit" => commit, "events" => {id => events}, "introductions" => {id => introductions})
|
|
881
|
-
view = ServiceReadView.new(journal: self, commit: commit, inventory: inventory)
|
|
882
|
-
if @mode == :protected && terminal_state?(record["state"])
|
|
883
|
-
validate_terminal_receipt!(record, record["state"], record["receipt"],
|
|
884
|
-
pending: {commit: commit, service_read_view: view})
|
|
885
|
-
end
|
|
886
|
-
view.freeze
|
|
887
|
-
{record: freeze_inventory_projection(record), settlement_context: view.projection, read_view: view}.freeze
|
|
888
|
-
end
|
|
889
|
-
|
|
890
|
-
def read_service_request(request_id, commit:, pending:, terminal: true)
|
|
891
|
-
context = Thread.current[:ace_assign_event_read_operation]
|
|
892
|
-
validate_request_id!(request_id)
|
|
893
|
-
reusable = context && @mode == :protected && commit.is_a?(String) && commit.match?(/\A[0-9a-f]{40}\z/)
|
|
894
|
-
selection = if reusable
|
|
895
|
-
(operation_inventory_selection(commit) + [@evidence_reader&.object_id, @service_authorizer&.object_id, request_id])
|
|
896
|
-
.map { |value| value.is_a?(String) ? value.dup.freeze : value }.freeze
|
|
897
|
-
end
|
|
898
|
-
if reusable && context[:service_record_selection] == selection
|
|
899
|
-
retained = context[:service_record]
|
|
900
|
-
else
|
|
901
|
-
context&.delete(:service_record_selection)
|
|
902
|
-
context&.delete(:service_record)
|
|
903
|
-
retained = read_service_request_record(request_id, commit: commit)
|
|
904
|
-
if reusable && retained
|
|
905
|
-
retained = freeze_inventory_projection(retained)
|
|
906
|
-
context[:service_record_selection], context[:service_record] = selection, retained
|
|
907
|
-
end
|
|
908
|
-
end
|
|
909
|
-
# This memo contains JSON and its immutable accepted event proof,
|
|
910
|
-
# never terminal evidence admission. Every callback remains fresh;
|
|
911
|
-
# callers receive their own mutable copy rather than cached state.
|
|
912
|
-
record = retained && (reusable ? JSON.parse(JSON.generate(retained)) : retained)
|
|
913
|
-
validate_terminal_receipt!(record, record["state"], record["receipt"], pending: pending) if
|
|
914
|
-
record && terminal && @mode == :protected && terminal_state?(record["state"])
|
|
915
|
-
# Historical dependency reads may replace this one slot while the
|
|
916
|
-
# fresh callback executes. Restore only this successful raw view.
|
|
917
|
-
if reusable && retained
|
|
918
|
-
context[:service_record_selection], context[:service_record] = selection, retained
|
|
919
|
-
end
|
|
920
|
-
record
|
|
921
|
-
rescue Exception
|
|
922
|
-
context&.delete(:service_record_selection)
|
|
923
|
-
context&.delete(:service_record)
|
|
924
|
-
raise
|
|
925
|
-
end
|
|
926
|
-
|
|
927
|
-
def read_service_request_record(request_id, commit:)
|
|
928
|
-
return nil unless commit
|
|
929
|
-
out, stderr, status = git("show", "#{commit}:#{service_request_path(request_id)}")
|
|
930
|
-
if status.success?
|
|
931
|
-
record = JSON.parse(out)
|
|
932
|
-
verify_service_record!(record, commit: commit) if @mode == :protected
|
|
933
|
-
return record
|
|
934
|
-
end
|
|
935
|
-
return nil if stderr.include?("does not exist") || stderr.include?("exists on disk")
|
|
936
|
-
raise AttemptErrors::EvidenceUnavailable, "Cannot read service request: #{stderr}"
|
|
937
|
-
rescue JSON::ParserError
|
|
938
|
-
raise AttemptErrors::EvidenceUnavailable, "Corrupt service request #{request_id}"
|
|
939
|
-
end
|
|
940
|
-
|
|
941
|
-
private :read_service_request, :read_service_request_record
|
|
942
|
-
|
|
943
|
-
# Every service request recorded in this evidence ref, whatever the
|
|
944
|
-
# owning assignment. Backs the journal-wide request-ID and
|
|
945
|
-
# authorization-consumption checks.
|
|
946
|
-
# Another live request holding the same exact authorization for the
|
|
947
|
-
# same operation, project and target. Only requests that were born
|
|
948
|
-
# rejected (never claimed, never dispatched) leave the decision
|
|
949
|
-
# unconsumed: a withdrawn or failed claim keeps it consumed until
|
|
950
|
-
# attributable evidence proves the effect did not occur.
|
|
951
|
-
def authorization_conflict(binding)
|
|
952
|
-
authorization = binding["authorization"]
|
|
953
|
-
return nil unless authorization.is_a?(String) && !authorization.empty?
|
|
954
|
-
if authorization.start_with?("proposal-")
|
|
955
|
-
return proposal_claim_conflict(authorization, request_id: binding.fetch("request_id"), fixed: false)
|
|
956
|
-
end
|
|
957
|
-
service_request_records.find do |other|
|
|
958
|
-
next false if other["request_id"] == binding.fetch("request_id")
|
|
959
|
-
next false if other["state"] == "rejected" && other["consumed"] == false
|
|
960
|
-
# A settled failure frees the exact authorization only while its
|
|
961
|
-
# no-effect evidence remains verifiable in the repository.
|
|
962
|
-
next false if other["state"] == "failed-settled" && settlement_evidence_intact?(other)
|
|
963
|
-
other["authorization"] == authorization &&
|
|
964
|
-
other["operation"] == binding.fetch("operation") &&
|
|
965
|
-
other["project_id"] == binding.fetch("project_id") &&
|
|
966
|
-
other["target"] == binding.fetch("target")
|
|
967
|
-
end
|
|
968
|
-
end
|
|
969
|
-
|
|
970
|
-
def settlement_evidence_intact?(record)
|
|
971
|
-
receipt = record["receipt"]
|
|
972
|
-
return false unless receipt.is_a?(Hash)
|
|
973
|
-
if @mode == :protected
|
|
974
|
-
commit = ref_value
|
|
975
|
-
verify_service_record!(record, commit: commit)
|
|
976
|
-
validate_terminal_receipt!(record, "failed-settled", receipt, pending: {commit: commit})
|
|
977
|
-
return true
|
|
978
|
-
end
|
|
979
|
-
repo_root = File.realpath(@repo_root)
|
|
980
|
-
Array(receipt["evidence"]).all? do |item|
|
|
981
|
-
path = File.expand_path(item["ref"].to_s, repo_root)
|
|
982
|
-
real = begin
|
|
983
|
-
File.realpath(path)
|
|
984
|
-
rescue Errno::ENOENT, Errno::EACCES, Errno::ELOOP
|
|
985
|
-
next false
|
|
986
|
-
end
|
|
987
|
-
begin
|
|
988
|
-
real.start_with?(repo_root + File::SEPARATOR) &&
|
|
989
|
-
Digest::SHA256.file(real).hexdigest == item["sha256"]
|
|
990
|
-
rescue Errno::EACCES, Errno::ELOOP
|
|
991
|
-
false
|
|
992
|
-
end
|
|
993
|
-
end
|
|
994
|
-
rescue AttemptErrors::EvidenceUnavailable, AttemptErrors::ReceiptRejected
|
|
995
|
-
false
|
|
996
|
-
end
|
|
997
|
-
|
|
998
|
-
def service_request_records(commit: ref_value)
|
|
999
|
-
value = commit
|
|
1000
|
-
return [] unless value
|
|
1001
|
-
paths, stderr, status = git("ls-tree", "-r", "--name-only", value, "--", "execution/requests/")
|
|
1002
|
-
raise AttemptErrors::EvidenceUnavailable, "Cannot read service request index: #{stderr}" unless status.success?
|
|
1003
|
-
paths.lines.map(&:strip).select { |path| path.end_with?(".json") }.filter_map do |path|
|
|
1004
|
-
content, error, read_status = git("show", "#{value}:#{path}")
|
|
1005
|
-
unless read_status.success?
|
|
1006
|
-
raise AttemptErrors::EvidenceUnavailable, "Cannot read service request #{path}: #{error}"
|
|
1007
|
-
end
|
|
1008
|
-
record = JSON.parse(content)
|
|
1009
|
-
verify_service_record!(record, commit: value) if @mode == :protected
|
|
1010
|
-
record
|
|
1011
|
-
end
|
|
1012
|
-
rescue JSON::ParserError
|
|
1013
|
-
raise AttemptErrors::EvidenceUnavailable, "Corrupt service request index"
|
|
1014
|
-
end
|
|
1015
|
-
|
|
1016
|
-
def service_requests(assignment_id)
|
|
1017
|
-
request_ids = read_events(assignment_id)
|
|
1018
|
-
.select { |event| event["type"] == "service_claim" }
|
|
1019
|
-
.map { |event| event.dig("payload", "request_id") }.compact.uniq
|
|
1020
|
-
request_ids.filter_map { |request_id| service_request(request_id) }
|
|
1021
|
-
end
|
|
1022
|
-
|
|
1023
|
-
# All attempts derivable from the journal, reconstructed from intent
|
|
1024
|
-
# and process_start facts with journal-authoritative state. The
|
|
1025
|
-
# journal is the owner of attempt-state interpretation.
|
|
1026
|
-
#
|
|
1027
|
-
# @param assignment_id [String] Assignment ID
|
|
1028
|
-
# @return [Array<Models::Attempt>] Derived attempts
|
|
1029
|
-
def derived_attempts(assignment_id, commit: ref_value)
|
|
1030
|
-
events = read_events(assignment_id, commit: commit)
|
|
1031
|
-
by_attempt = events.group_by { |event| event["attempt_id"] }
|
|
1032
|
-
by_attempt.delete(nil)
|
|
1033
|
-
|
|
1034
|
-
by_attempt.filter_map do |attempt_id, attempt_events|
|
|
1035
|
-
intent = attempt_events.find { |event| event["type"] == "intent" }
|
|
1036
|
-
next unless intent
|
|
1037
|
-
|
|
1038
|
-
state = derive_state(attempt_events)
|
|
1039
|
-
next if state.nil?
|
|
1040
|
-
|
|
1041
|
-
build_attempt(assignment_id, attempt_id, intent["payload"], attempt_events, state, commit: commit)
|
|
1042
|
-
end
|
|
1043
|
-
end
|
|
1044
|
-
|
|
1045
|
-
# Non-terminal attempts derived from the journal.
|
|
1046
|
-
#
|
|
1047
|
-
# @param assignment_id [String] Assignment ID
|
|
1048
|
-
# @return [Array<Models::Attempt>] Active attempts
|
|
1049
|
-
def active_attempts(assignment_id)
|
|
1050
|
-
derived_attempts(assignment_id).reject(&:terminal?)
|
|
1051
|
-
end
|
|
1052
|
-
|
|
1053
|
-
# Assignment IDs present in the journal, discovered from the evidence
|
|
1054
|
-
# ref itself (works even when the local assignment cache is gone).
|
|
1055
|
-
#
|
|
1056
|
-
# @return [Array<String>] Assignment IDs with journal evidence
|
|
1057
|
-
def assignment_ids(commit: ref_value)
|
|
1058
|
-
value = commit
|
|
1059
|
-
return [] if value.nil?
|
|
1060
|
-
|
|
1061
|
-
paths, stderr, status = git("ls-tree", "-d", "--name-only", value, "--", "execution/")
|
|
1062
|
-
raise AttemptErrors::EvidenceUnavailable, "Cannot discover journal assignments: #{stderr}" unless status.success?
|
|
1063
|
-
paths.lines.map { |path| path.strip.delete_prefix("execution/") }
|
|
1064
|
-
.reject { |id| id == "requests" }.sort
|
|
1065
|
-
end
|
|
1066
|
-
|
|
1067
|
-
private
|
|
1068
|
-
|
|
1069
|
-
# Sizes come from the same immutable tree selection. Batch memory is
|
|
1070
|
-
# bounded by that selection; an existing larger single event retains
|
|
1071
|
-
# its previous read semantics rather than gaining a lifetime ceiling.
|
|
1072
|
-
def read_event_blobs!(entries)
|
|
1073
|
-
limit = entries.sum { |_oid, size| size + 128 }
|
|
1074
|
-
return @read_boundary.blob_batch(entries.map(&:first), output_limit: limit) if @read_boundary
|
|
1075
|
-
result = Herdr::Molecules::BoundedProcess.call(["git", "cat-file", "--batch"],
|
|
1076
|
-
chdir: File.expand_path(@repo_root), stdin_data: entries.map { |oid, _size| "#{oid}\n" }.join,
|
|
1077
|
-
timeout_s: 30, output_limit: limit)
|
|
1078
|
-
unless result.status.success? && !result.oversized
|
|
1079
|
-
raise AttemptErrors::EvidenceUnavailable, "canonical blob batch is unavailable or exceeds selected sizes"
|
|
1080
|
-
end
|
|
1081
|
-
result.stdout.to_s.b
|
|
1082
|
-
rescue Timeout::Error, Herdr::Molecules::BoundedProcess::PostLaunchError, IOError, SystemCallError => error
|
|
1083
|
-
raise AttemptErrors::EvidenceUnavailable, "canonical blob batch is unavailable: #{error.message}"
|
|
1084
|
-
end
|
|
1085
|
-
|
|
1086
|
-
def decode_event_blobs!(entries, output)
|
|
1087
|
-
Atoms::GitBlobBatch.decode(entries, output)
|
|
1088
|
-
rescue Atoms::GitBlobBatch::Invalid => error
|
|
1089
|
-
message = {header: "canonical blob batch header is invalid", bytes: "canonical blob batch bytes are invalid",
|
|
1090
|
-
trailing: "canonical blob batch has unexpected trailing bytes"}.fetch(error.reason, "canonical blob batch selector is invalid")
|
|
1091
|
-
raise AttemptErrors::EvidenceUnavailable, message
|
|
1092
|
-
end
|
|
1093
|
-
|
|
1094
|
-
def update_service_request(request_id, expected:, replacement:, event_type:, guard: nil)
|
|
1095
|
-
validate_request_id!(request_id)
|
|
1096
|
-
with_lock do
|
|
1097
|
-
CAS_ATTEMPTS.times do
|
|
1098
|
-
old = ref_value
|
|
1099
|
-
ensure_checkout!
|
|
1100
|
-
old = ref_value if old.nil?
|
|
1101
|
-
sync_checkout(old)
|
|
1102
|
-
prepared = prepare_service_update(request_id: request_id, expected: expected,
|
|
1103
|
-
replacement: replacement, event_type: event_type, guard: guard)
|
|
1104
|
-
return prepared.fetch(:record) if prepared[:replayed]
|
|
1105
|
-
write_service_records([prepared])
|
|
1106
|
-
assignment_id = replacement.fetch("assignment_id")
|
|
1107
|
-
attempt_id = replacement.fetch("attempt_id")
|
|
1108
|
-
prior = read_events(assignment_id).reverse
|
|
1109
|
-
.find { |entry| entry["attempt_id"] == attempt_id }&.fetch("digest")
|
|
1110
|
-
event = Models::EvidenceEvent.build(type: event_type, attempt_id: attempt_id,
|
|
1111
|
-
payload: prepared.fetch(:event).fetch(:payload), previous_digest: prior)
|
|
1112
|
-
write_event_files(assignment_id, [event])
|
|
1113
|
-
git!("-C", checkout_dir, "add", "--", service_request_path(request_id),
|
|
1114
|
-
"execution/#{assignment_id}/events")
|
|
1115
|
-
stage_service_records([prepared])
|
|
1116
|
-
state = replacement.fetch("state")
|
|
1117
|
-
git!("-C", checkout_dir, "-c", "user.name=ace-assign", "-c", "user.email=ace-assign@localhost",
|
|
1118
|
-
"commit", "-m", "evidence: service request #{request_id} #{state}")
|
|
1119
|
-
commit = git!("-C", checkout_dir, "rev-parse", "HEAD").first
|
|
1120
|
-
return replacement.merge("journal_commit" => commit) if update_ref_cas(commit, old)
|
|
1121
|
-
end
|
|
1122
|
-
raise AttemptErrors::EvidenceUnavailable, "Service request ref stayed conflicting"
|
|
1123
|
-
end
|
|
1124
|
-
end
|
|
1125
|
-
|
|
1126
|
-
# Shared owner for ordinary service writes and atomic authority
|
|
1127
|
-
# imports. Call only after checkout sync, under this journal's CAS lock.
|
|
1128
|
-
def prepare_service_update(request_id:, expected:, replacement:, event_type:, guard: nil, pending: nil)
|
|
1129
|
-
validate_request_id!(request_id)
|
|
1130
|
-
unless replacement.is_a?(Hash) && replacement["request_id"] == request_id &&
|
|
1131
|
-
%w[service_claim service_transition service_challenge].include?(event_type) &&
|
|
1132
|
-
%w[accepted uncertain rejected succeeded failed failed-settled].include?(replacement["state"])
|
|
1133
|
-
raise ArgumentError, "invalid service update plan"
|
|
1134
|
-
end
|
|
1135
|
-
existing = service_request(request_id)
|
|
1136
|
-
if expected.nil? && existing
|
|
1137
|
-
return {record: existing, replayed: true} if existing.except("state", "receipt", "reason", "claimed_at") ==
|
|
1138
|
-
replacement.except("state", "receipt", "reason", "claimed_at")
|
|
1139
|
-
raise AttemptErrors::Conflict, "Service request #{request_id} has different input"
|
|
1140
|
-
end
|
|
1141
|
-
if expected && existing != expected
|
|
1142
|
-
raise AttemptErrors::Conflict, "Service request #{request_id} changed during transition"
|
|
1143
|
-
end
|
|
1144
|
-
if expected.nil?
|
|
1145
|
-
unless event_type == "service_claim" && %w[accepted uncertain rejected].include?(replacement["state"])
|
|
1146
|
-
raise AttemptErrors::InvalidState, "Service request needs an initial claim"
|
|
1147
|
-
end
|
|
1148
|
-
if %w[accepted uncertain].include?(replacement["state"])
|
|
1149
|
-
if replacement["authorization"].to_s.start_with?("proposal-")
|
|
1150
|
-
unless respond_to?(:proposal_authorize!, true)
|
|
1151
|
-
raise AttemptErrors::UnauthorizedIdentity, "Canonical proposal producer is unavailable"
|
|
1152
|
-
end
|
|
1153
|
-
proposal_authorize!(replacement["authorization"], replacement)
|
|
1154
|
-
end
|
|
1155
|
-
conflict = authorization_conflict(replacement)
|
|
1156
|
-
raise_authorization_conflict!(conflict) if conflict
|
|
1157
|
-
end
|
|
1158
|
-
else
|
|
1159
|
-
challenge_update = event_type == "service_challenge" && pending && pending[:operation] == "claim_service_settlement" &&
|
|
1160
|
-
existing["state"] == replacement["state"] && existing["receipt"] == replacement["receipt"] &&
|
|
1161
|
-
%w[uncertain failed].include?(existing["state"])
|
|
1162
|
-
unless event_type == "service_transition" || challenge_update
|
|
1163
|
-
raise AttemptErrors::InvalidState, "Existing service request needs a transition"
|
|
1164
|
-
end
|
|
1165
|
-
terminal = %w[succeeded failed rejected failed-settled].include?(existing["state"])
|
|
1166
|
-
settlement = replacement["state"] == "failed-settled" &&
|
|
1167
|
-
(existing["state"] == "failed" || (existing["state"] == "rejected" && existing["consumed"] != false))
|
|
1168
|
-
recovered_cleanup = @mode == :protected && pending && pending[:operation] == "complete_service" &&
|
|
1169
|
-
existing["operation"] == "prune-preserved-workspace" && existing["state"] == "failed" &&
|
|
1170
|
-
existing["dispatch_phase"] == "dispatch_started" && existing["completion_digest"].nil? &&
|
|
1171
|
-
replacement["state"] == "succeeded"
|
|
1172
|
-
if terminal && !settlement && !challenge_update && !recovered_cleanup
|
|
1173
|
-
raise AttemptErrors::InvalidState, "Service request #{request_id} is terminal"
|
|
1174
|
-
end
|
|
1175
|
-
mutable = %w[state receipt reason claimed_at failed_at dispatch_phase no_effect_challenge
|
|
1176
|
-
challenge_generation challenge_event_digest completion_digest no_effect_completion_digest]
|
|
1177
|
-
# Only this authenticated fresh dispatch may introduce the fixed
|
|
1178
|
-
# root collaborator identity. It is immutable on every later update.
|
|
1179
|
-
if @mode == :protected && pending && pending[:operation] == "begin_dispatch" &&
|
|
1180
|
-
existing["operation"] == "prune-preserved-workspace" && existing["dispatch_phase"] == "issued" &&
|
|
1181
|
-
replacement["dispatch_phase"] == "dispatch_started" &&
|
|
1182
|
-
%w[operation_owner_binding executor_process_binding].none? { |key| existing.key?(key) } &&
|
|
1183
|
-
%w[operation_owner_binding executor_process_binding].all? { |key| replacement.key?(key) }
|
|
1184
|
-
mutable.concat(%w[operation_owner_binding executor_process_binding])
|
|
1185
|
-
end
|
|
1186
|
-
if @mode == :protected && pending && existing["operation"] == "publish"
|
|
1187
|
-
if pending[:operation] == "begin_dispatch" && existing["dispatch_phase"] == "issued" &&
|
|
1188
|
-
replacement["dispatch_phase"] == "dispatch_started" && !existing.key?("executor_process_binding") &&
|
|
1189
|
-
replacement.key?("executor_process_binding")
|
|
1190
|
-
mutable << "executor_process_binding"
|
|
1191
|
-
elsif %w[publication_challenge publication_continue].include?(pending[:operation])
|
|
1192
|
-
mutable.concat(%w[publication_challenge_digest publication_challenge_ref publication_challenge_event_digest
|
|
1193
|
-
publication_challenge_generation publication_issuing_event_digest])
|
|
1194
|
-
end
|
|
1195
|
-
end
|
|
1196
|
-
unless existing.except(*mutable) == replacement.except(*mutable)
|
|
1197
|
-
raise AttemptErrors::Conflict, "Service request #{request_id} changed immutable binding"
|
|
1198
|
-
end
|
|
1199
|
-
end
|
|
1200
|
-
conflict = guard&.call
|
|
1201
|
-
raise_authorization_conflict!(conflict) if conflict
|
|
1202
|
-
@service_authorizer.call(existing, replacement, pending) if @mode == :protected
|
|
1203
|
-
validate_terminal_receipt!(replacement, replacement["state"], replacement["receipt"], pending: pending) if terminal_state?(replacement["state"])
|
|
1204
|
-
payload = {"request_id" => request_id, "state" => replacement.fetch("state"),
|
|
1205
|
-
"input_digest" => replacement.fetch("input_digest"),
|
|
1206
|
-
"record_digest" => Atoms::EvidenceDigest.digest(replacement),
|
|
1207
|
-
"receipt_digest" => replacement["receipt"] && Atoms::EvidenceDigest.digest(replacement["receipt"])}
|
|
1208
|
-
{record: replacement, path: service_request_path(request_id),
|
|
1209
|
-
event: {type: event_type, payload: payload}, replayed: false}
|
|
1210
|
-
end
|
|
1211
|
-
|
|
1212
|
-
def prepare_service_updates(updates, assignment_id:, attempt_id:, pending:)
|
|
1213
|
-
unless updates.is_a?(Array) && updates.length <= 1
|
|
1214
|
-
raise ArgumentError, "one service update is allowed per authority mutation"
|
|
1215
|
-
end
|
|
1216
|
-
updates.map do |update|
|
|
1217
|
-
unless update.is_a?(Hash) && update.keys.sort == %i[event_type expected replacement request_id].sort &&
|
|
1218
|
-
update.dig(:replacement, "assignment_id") == assignment_id && update.dig(:replacement, "attempt_id") == attempt_id
|
|
1219
|
-
raise ArgumentError, "service update does not match mutation attempt"
|
|
1220
|
-
end
|
|
1221
|
-
prepare_service_update(**update, pending: pending)
|
|
1222
|
-
end.reject { |prepared| prepared[:replayed] }
|
|
1223
|
-
end
|
|
1224
|
-
|
|
1225
|
-
def write_service_records(records)
|
|
1226
|
-
records.each do |prepared|
|
|
1227
|
-
path = File.join(checkout_dir, prepared.fetch(:path))
|
|
1228
|
-
FileUtils.mkdir_p(File.dirname(path))
|
|
1229
|
-
File.write(path, JSON.pretty_generate(prepared.fetch(:record)))
|
|
1230
|
-
end
|
|
1231
|
-
end
|
|
1232
|
-
|
|
1233
|
-
def stage_service_records(records)
|
|
1234
|
-
stage_mutation_blobs(records.to_h { |prepared| [prepared.fetch(:path), JSON.pretty_generate(prepared.fetch(:record))] })
|
|
1235
|
-
end
|
|
1236
|
-
|
|
1237
|
-
def verify_service_record!(record, commit: ref_value)
|
|
1238
|
-
events = read_events(record.fetch("assignment_id"), commit: commit).select { |event| event["attempt_id"] == record.fetch("attempt_id") }
|
|
1239
|
-
unless Models::EvidenceEvent.chain_valid?(events)
|
|
1240
|
-
raise AttemptErrors::EvidenceUnavailable, "Canonical service event chain is unverifiable"
|
|
1241
|
-
end
|
|
1242
|
-
event = events.reverse.find do |entry|
|
|
1243
|
-
%w[service_claim service_transition service_challenge].include?(entry["type"]) && entry.dig("payload", "request_id") == record["request_id"]
|
|
1244
|
-
end
|
|
1245
|
-
unless event && event.dig("payload", "record_digest") == Atoms::EvidenceDigest.digest(record)
|
|
1246
|
-
raise AttemptErrors::EvidenceUnavailable, "Canonical service record does not match accepted event"
|
|
1247
|
-
end
|
|
1248
|
-
rescue KeyError, TypeError
|
|
1249
|
-
raise AttemptErrors::EvidenceUnavailable, "Canonical service record has invalid binding"
|
|
1250
|
-
end
|
|
1251
|
-
|
|
1252
|
-
def raise_authorization_conflict!(conflict)
|
|
1253
|
-
raise AttemptErrors::Conflict,
|
|
1254
|
-
"Authorization reference already consumed by request #{conflict.fetch("request_id")}"
|
|
1255
|
-
end
|
|
1256
|
-
|
|
1257
|
-
def verify_service_attestation!(content, request, state)
|
|
1258
|
-
expression = /^ace-service-attestation request:#{Regexp.escape(request["request_id"])} input:#{Regexp.escape(request["input_digest"])} outcome:(\S+)( no-effect:(\S+))?$/
|
|
1259
|
-
attested = content.scan(expression).first
|
|
1260
|
-
outcome = (state == "failed-settled") ? "failed" : state
|
|
1261
|
-
unless attested && attested.first == outcome && (state != "failed-settled" || attested[2] == "true")
|
|
1262
|
-
raise AttemptErrors::ReceiptRejected, "Service terminal evidence does not attest #{outcome}"
|
|
1263
|
-
end
|
|
1264
|
-
end
|
|
1265
|
-
|
|
1266
|
-
def service_request_path(request_id)
|
|
1267
|
-
"execution/requests/#{request_id}.json"
|
|
1268
|
-
end
|
|
1269
|
-
|
|
1270
|
-
def validate_request_id!(request_id)
|
|
1271
|
-
return if request_id.is_a?(String) && request_id.match?(/\A[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}\z/)
|
|
1272
|
-
raise ArgumentError, "invalid service request ID"
|
|
1273
|
-
end
|
|
1274
|
-
|
|
1275
|
-
# ---- ordinary fixed merge records (spec 8x3.t.xz9.4) --------------
|
|
1276
|
-
#
|
|
1277
|
-
# The canonical Assign attempt/evidence state is the SOLE queue/effect
|
|
1278
|
-
# record for ordinary fixed merge requests: admitted records live in
|
|
1279
|
-
# this evidence ref, chained to their owning attempt, and the issuing
|
|
1280
|
-
# receiver keeps no journal, mapping database or receipt ledger. The
|
|
1281
|
-
# closed state model is pending -> uncertain -> succeeded/failed;
|
|
1282
|
-
# pre-effect refusals are never durable (a refusal has no reference),
|
|
1283
|
-
# so every recorded request is an admitted one.
|
|
1284
|
-
|
|
1285
|
-
FIXED_MERGE_STATES = %w[pending uncertain succeeded failed].freeze
|
|
1286
|
-
# Immutable binding: a replay under the same request ID must match
|
|
1287
|
-
# every caller-supplied field, or it refuses before effect. The
|
|
1288
|
-
# opaque reference is receiver-issued, never caller-supplied, and
|
|
1289
|
-
# replays return the originally issued one.
|
|
1290
|
-
FIXED_MERGE_BINDING_FIELDS = %w[request_id project_id assignment_id attempt_id
|
|
1291
|
-
queue_entry operation authorization input_digest request principal caller_uid receiver_id
|
|
1292
|
-
configuration_sha256 execution_identity].freeze
|
|
1293
|
-
FIXED_MERGE_MUTABLE_FIELDS = %w[state receipt reference recorded_at claimed_at finished_at].freeze
|
|
1294
|
-
|
|
1295
|
-
def fixed_merge_request(request_id, commit: ref_value)
|
|
1296
|
-
return nil unless commit
|
|
1297
|
-
out, stderr, status = git("show", "#{commit}:#{fixed_merge_request_path(request_id)}")
|
|
1298
|
-
if status.success?
|
|
1299
|
-
record = JSON.parse(out)
|
|
1300
|
-
verify_fixed_record!(record, commit: commit) if @mode == :protected
|
|
1301
|
-
return record
|
|
1302
|
-
end
|
|
1303
|
-
return nil if stderr.include?("does not exist") || stderr.include?("exists on disk")
|
|
1304
|
-
raise AttemptErrors::EvidenceUnavailable, "Cannot read fixed merge request: #{stderr}"
|
|
1305
|
-
rescue JSON::ParserError
|
|
1306
|
-
raise AttemptErrors::EvidenceUnavailable, "Corrupt fixed merge request #{request_id}"
|
|
1307
|
-
end
|
|
1308
|
-
|
|
1309
|
-
def fixed_merge_request_records(commit: ref_value)
|
|
1310
|
-
value = commit
|
|
1311
|
-
return [] unless value
|
|
1312
|
-
paths, stderr, status = git("ls-tree", "-r", "--name-only", value, "--", "execution/merge-requests/")
|
|
1313
|
-
raise AttemptErrors::EvidenceUnavailable, "Cannot read fixed merge index: #{stderr}" unless status.success?
|
|
1314
|
-
paths.lines.map(&:strip).select { |path| path.end_with?(".json") }.filter_map do |path|
|
|
1315
|
-
content, error, read_status = git("show", "#{value}:#{path}")
|
|
1316
|
-
unless read_status.success?
|
|
1317
|
-
raise AttemptErrors::EvidenceUnavailable, "Cannot read fixed merge request #{path}: #{error}"
|
|
1318
|
-
end
|
|
1319
|
-
record = JSON.parse(content)
|
|
1320
|
-
verify_fixed_record!(record, commit: value) if @mode == :protected
|
|
1321
|
-
record
|
|
1322
|
-
end
|
|
1323
|
-
rescue JSON::ParserError
|
|
1324
|
-
raise AttemptErrors::EvidenceUnavailable, "Corrupt fixed merge request index"
|
|
1325
|
-
end
|
|
1326
|
-
|
|
1327
|
-
# Admit an ordinary fixed merge request: one CAS create that binds the
|
|
1328
|
-
# receiver-issued opaque reference before the record can ever be read
|
|
1329
|
-
# back. An identical binding under the same request ID replays the
|
|
1330
|
-
# original record (the reference is created once); any different
|
|
1331
|
-
# field is a conflict, never a replay. Another live record holding
|
|
1332
|
-
# the same exact authorization is refused: only a terminal record
|
|
1333
|
-
# with proven no-effect evidence frees an authorization.
|
|
1334
|
-
def admit_fixed_merge_request(record, guard: nil)
|
|
1335
|
-
reference = record.fetch("reference")
|
|
1336
|
-
raise ArgumentError, "fixed merge record needs an opaque reference" unless Molecules::FixedEffectReference.valid?(reference)
|
|
1337
|
-
raise ArgumentError, "invalid fixed merge state" unless record.fetch("state") == "pending"
|
|
1338
|
-
update_fixed_merge_request(record.fetch("request_id"), expected: nil,
|
|
1339
|
-
replacement: record, event_type: "merge_admission", guard: guard)
|
|
1340
|
-
end
|
|
1341
|
-
|
|
1342
|
-
# Claim the start of the fixed effect: pending -> uncertain in one
|
|
1343
|
-
# CAS transition, so a receiver restart can never dispatch a second
|
|
1344
|
-
# merge for the same admitted record. An already-started claim refuses;
|
|
1345
|
-
# only the winning effect may later settle it.
|
|
1346
|
-
def begin_fixed_merge_request(request_id, guard: nil)
|
|
1347
|
-
current = fixed_merge_request(request_id)
|
|
1348
|
-
raise AttemptErrors::NotFound, "Fixed merge request #{request_id} not found" unless current
|
|
1349
|
-
if current["state"] == "uncertain"
|
|
1350
|
-
raise AttemptErrors::InvalidState, "Fixed merge request #{request_id} was already started"
|
|
1351
|
-
end
|
|
1352
|
-
unless current["state"] == "pending"
|
|
1353
|
-
raise AttemptErrors::InvalidState, "Fixed merge request #{request_id} is terminal"
|
|
1354
|
-
end
|
|
1355
|
-
update_fixed_merge_request(request_id, expected: current,
|
|
1356
|
-
replacement: current.merge("state" => "uncertain", "claimed_at" => Time.now.utc.iso8601(9)),
|
|
1357
|
-
event_type: "merge_transition", guard: -> { guard&.call; verify_fixed_proposal!(current) })
|
|
1358
|
-
end
|
|
1359
|
-
|
|
1360
|
-
# Terminal settlement: succeeded/failed with the exact receipt
|
|
1361
|
-
# validated HERE at the journal boundary — structure, the closed
|
|
1362
|
-
# terminal invariants, and the binding against the current record.
|
|
1363
|
-
# Failure is terminal only with immutable no-effect evidence.
|
|
1364
|
-
def settle_fixed_merge_request(request_id, terminal_status:, receipt:, attestation: nil)
|
|
1365
|
-
unless FIXED_MERGE_STATES.include?(terminal_status) && %w[succeeded failed].include?(terminal_status)
|
|
1366
|
-
raise ArgumentError, "invalid fixed merge terminal status"
|
|
1367
|
-
end
|
|
1368
|
-
current = fixed_merge_request(request_id)
|
|
1369
|
-
raise AttemptErrors::NotFound, "Fixed merge request #{request_id} not found" unless current
|
|
1370
|
-
unless %w[pending uncertain].include?(current["state"])
|
|
1371
|
-
raise AttemptErrors::InvalidState, "Fixed merge request #{request_id} is terminal"
|
|
1372
|
-
end
|
|
1373
|
-
evidence_files = {}
|
|
1374
|
-
if terminal_status == "failed"
|
|
1375
|
-
unless attestation.is_a?(Hash) &&
|
|
1376
|
-
attestation.values_at("request_id", "input_digest", "reference", "outcome") ==
|
|
1377
|
-
current.values_at("request_id", "input_digest", "reference") + ["no_effect"] &&
|
|
1378
|
-
attestation["candidate_head"] == current.dig("request", "candidate_head") &&
|
|
1379
|
-
attestation["expected_base_head"] == current.dig("request", "expected_base_head")
|
|
1380
|
-
raise AttemptErrors::ReceiptRejected, "Fixed merge no_effect_evidence requires a bound canonical attestation"
|
|
1381
|
-
end
|
|
1382
|
-
content = JSON.generate(attestation)
|
|
1383
|
-
raise AttemptErrors::ReceiptRejected, "Fixed merge attestation exceeds bound" if content.bytesize > 16_384
|
|
1384
|
-
path = fixed_no_effect_path(request_id)
|
|
1385
|
-
evidence_files[path] = content
|
|
1386
|
-
receipt = receipt.merge("effect" => receipt.fetch("effect").merge(
|
|
1387
|
-
"no_effect_evidence" => {"reference" => path, "sha256" => Digest::SHA256.hexdigest(content)}))
|
|
1388
|
-
elsif attestation
|
|
1389
|
-
raise AttemptErrors::ReceiptRejected, "Successful merge cannot carry no-effect attestation"
|
|
1390
|
-
end
|
|
1391
|
-
validate_fixed_receipt!(current, terminal_status, receipt)
|
|
1392
|
-
update_fixed_merge_request(request_id, expected: current,
|
|
1393
|
-
replacement: current.merge("state" => terminal_status, "receipt" => receipt,
|
|
1394
|
-
"finished_at" => Time.now.utc.iso8601(9)),
|
|
1395
|
-
event_type: "merge_transition", evidence_files: evidence_files)
|
|
1396
|
-
end
|
|
1397
|
-
|
|
1398
|
-
def fixed_merge_request_path(request_id)
|
|
1399
|
-
"execution/merge-requests/#{request_id}.json"
|
|
1400
|
-
end
|
|
1401
|
-
|
|
1402
|
-
def fixed_no_effect_path(request_id)
|
|
1403
|
-
validate_request_id!(request_id)
|
|
1404
|
-
"execution/merge-evidence/#{request_id}.json"
|
|
1405
|
-
end
|
|
1406
|
-
|
|
1407
|
-
def validate_fixed_receipt!(current, terminal_status, receipt)
|
|
1408
|
-
Atoms::FixedMergeSchemas.validate_receipt!(receipt)
|
|
1409
|
-
Atoms::FixedMergeSchemas.validate_receipt_against!(receipt, current)
|
|
1410
|
-
unless receipt.fetch("terminal_status") == terminal_status
|
|
1411
|
-
raise AttemptErrors::ReceiptRejected, "Fixed merge receipt terminal status does not match the settlement"
|
|
1412
|
-
end
|
|
1413
|
-
true
|
|
1414
|
-
rescue Atoms::FixedMergeSchemas::SchemaError => e
|
|
1415
|
-
raise AttemptErrors::ReceiptRejected, "Fixed merge receipt is invalid: #{e.message}"
|
|
1416
|
-
end
|
|
1417
|
-
|
|
1418
|
-
# Another live fixed merge record holding the same exact
|
|
1419
|
-
# authorization. Terminal failure with intact no-effect evidence
|
|
1420
|
-
# proves the effect never happened and frees the reference.
|
|
1421
|
-
def fixed_authorization_conflict(record)
|
|
1422
|
-
authorization = record["authorization"]
|
|
1423
|
-
return nil unless authorization.is_a?(String) && !authorization.empty?
|
|
1424
|
-
fixed_merge_request_records.find do |other|
|
|
1425
|
-
next false if other["request_id"] == record.fetch("request_id")
|
|
1426
|
-
next false if other["state"] == "failed" && fixed_no_effect_intact?(other)
|
|
1427
|
-
other["authorization"] == authorization &&
|
|
1428
|
-
other["operation"] == record.fetch("operation") &&
|
|
1429
|
-
other["project_id"] == record.fetch("project_id")
|
|
1430
|
-
end
|
|
1431
|
-
end
|
|
1432
|
-
|
|
1433
|
-
def fixed_no_effect_intact?(record, commit: ref_value)
|
|
1434
|
-
receipt = record["receipt"]
|
|
1435
|
-
return false unless receipt.is_a?(Hash)
|
|
1436
|
-
evidence = receipt.dig("effect", "no_effect_evidence")
|
|
1437
|
-
return false unless evidence.is_a?(Hash)
|
|
1438
|
-
path = fixed_no_effect_path(record.fetch("request_id"))
|
|
1439
|
-
return false unless evidence["reference"] == path
|
|
1440
|
-
content = bounded_blob(path, commit: commit, max_bytes: 16_384)
|
|
1441
|
-
proof = JSON.parse(content)
|
|
1442
|
-
Digest::SHA256.hexdigest(content) == evidence["sha256"] &&
|
|
1443
|
-
proof.values_at("request_id", "input_digest", "reference", "outcome") == record.values_at("request_id", "input_digest", "reference") + ["no_effect"] &&
|
|
1444
|
-
proof["candidate_head"] == record.dig("request", "candidate_head") && proof["expected_base_head"] == record.dig("request", "expected_base_head")
|
|
1445
|
-
rescue AttemptErrors::EvidenceUnavailable, JSON::ParserError, NoMethodError
|
|
1446
|
-
false
|
|
1447
|
-
end
|
|
1448
|
-
|
|
1449
|
-
# Immutable terminal consumption for ordinary completion. It uses the
|
|
1450
|
-
# same receipt and no-effect owners as admission/authorization release.
|
|
1451
|
-
def fixed_merge_settlement!(request_id, commit: ref_value)
|
|
1452
|
-
record = fixed_merge_request(request_id, commit: commit)
|
|
1453
|
-
raise AttemptErrors::EvidenceUnavailable, "canonical fixed merge is missing" unless record
|
|
1454
|
-
verify_fixed_record!(record, commit: commit)
|
|
1455
|
-
unless %w[succeeded failed].include?(record["state"])
|
|
1456
|
-
raise AttemptErrors::ServiceSettlementPending, "canonical fixed merge is unresolved"
|
|
1457
|
-
end
|
|
1458
|
-
validate_fixed_receipt!(record, record.fetch("state"), record.fetch("receipt"))
|
|
1459
|
-
if record["state"] == "failed" && !fixed_no_effect_intact?(record, commit: commit)
|
|
1460
|
-
raise AttemptErrors::EvidenceUnavailable, "canonical fixed merge no-effect proof differs"
|
|
1461
|
-
end
|
|
1462
|
-
record
|
|
1463
|
-
end
|
|
1464
|
-
|
|
1465
|
-
def update_fixed_merge_request(request_id, expected:, replacement:, event_type:, guard: nil, evidence_files: {})
|
|
1466
|
-
unless %w[merge_admission merge_transition].include?(event_type)
|
|
1467
|
-
raise ArgumentError, "invalid fixed merge event type"
|
|
1468
|
-
end
|
|
1469
|
-
with_lock do
|
|
1470
|
-
CAS_ATTEMPTS.times do
|
|
1471
|
-
old = ref_value
|
|
1472
|
-
ensure_checkout!
|
|
1473
|
-
old = ref_value if old.nil?
|
|
1474
|
-
sync_checkout(old)
|
|
1475
|
-
prepared = prepare_fixed_update(request_id: request_id, expected: expected,
|
|
1476
|
-
replacement: replacement, event_type: event_type, guard: guard)
|
|
1477
|
-
return prepared.fetch(:record) if prepared[:replayed]
|
|
1478
|
-
write_mutation_blobs(evidence_files, commit: old)
|
|
1479
|
-
path = fixed_merge_request_path(request_id)
|
|
1480
|
-
assignment_id = replacement.fetch("assignment_id")
|
|
1481
|
-
attempt_id = replacement.fetch("attempt_id")
|
|
1482
|
-
prior = read_events(assignment_id).reverse
|
|
1483
|
-
.find { |entry| entry["attempt_id"] == attempt_id }&.fetch("digest")
|
|
1484
|
-
event = Models::EvidenceEvent.build(type: event_type, attempt_id: attempt_id,
|
|
1485
|
-
payload: prepared.fetch(:event).fetch(:payload), previous_digest: prior)
|
|
1486
|
-
write_event_files(assignment_id, [event])
|
|
1487
|
-
git!("-C", checkout_dir, "add", "--", "execution/#{assignment_id}/events")
|
|
1488
|
-
stage_mutation_blobs(evidence_files.merge(path => JSON.pretty_generate(prepared.fetch(:record))))
|
|
1489
|
-
state = replacement.fetch("state")
|
|
1490
|
-
git!("-C", checkout_dir, "-c", "user.name=ace-assign", "-c", "user.email=ace-assign@localhost",
|
|
1491
|
-
"commit", "-m", "evidence: fixed merge request #{request_id} #{state}")
|
|
1492
|
-
commit = git!("-C", checkout_dir, "rev-parse", "HEAD").first
|
|
1493
|
-
return replacement.merge("journal_commit" => commit) if update_ref_cas(commit, old)
|
|
1494
|
-
end
|
|
1495
|
-
raise AttemptErrors::EvidenceUnavailable, "Fixed merge evidence ref stayed conflicting"
|
|
1496
|
-
end
|
|
1497
|
-
end
|
|
1498
|
-
private :update_fixed_merge_request
|
|
1499
|
-
|
|
1500
|
-
def prepare_fixed_update(request_id:, expected:, replacement:, event_type:, guard: nil)
|
|
1501
|
-
validate_request_id!(request_id)
|
|
1502
|
-
unless replacement.is_a?(Hash) && replacement["request_id"] == request_id &&
|
|
1503
|
-
FIXED_MERGE_STATES.include?(replacement["state"]) &&
|
|
1504
|
-
(replacement.keys - (FIXED_MERGE_BINDING_FIELDS + FIXED_MERGE_MUTABLE_FIELDS + %w[journal_commit])).empty?
|
|
1505
|
-
raise ArgumentError, "invalid fixed merge update plan"
|
|
1506
|
-
end
|
|
1507
|
-
existing = fixed_merge_request(request_id)
|
|
1508
|
-
if expected.nil?
|
|
1509
|
-
unless event_type == "merge_admission" && replacement["state"] == "pending"
|
|
1510
|
-
raise AttemptErrors::InvalidState, "Fixed merge request needs an initial admission"
|
|
1511
|
-
end
|
|
1512
|
-
if existing
|
|
1513
|
-
if fixed_replay?(existing, replacement)
|
|
1514
|
-
conflict = guard&.call
|
|
1515
|
-
raise_authorization_conflict!(conflict) if conflict
|
|
1516
|
-
return {record: existing, replayed: true}
|
|
1517
|
-
end
|
|
1518
|
-
raise AttemptErrors::Conflict, "Fixed merge request #{request_id} has different input"
|
|
1519
|
-
end
|
|
1520
|
-
unless Molecules::FixedEffectReference.valid?(replacement.fetch("reference"))
|
|
1521
|
-
raise ArgumentError, "fixed merge record needs an opaque reference"
|
|
1522
|
-
end
|
|
1523
|
-
if fixed_merge_request_records.any? { |other| other["reference"] == replacement.fetch("reference") }
|
|
1524
|
-
raise AttemptErrors::Conflict, "Fixed merge reference is already bound"
|
|
1525
|
-
end
|
|
1526
|
-
else
|
|
1527
|
-
unless event_type == "merge_transition"
|
|
1528
|
-
raise AttemptErrors::InvalidState, "Admitted fixed merge request needs a transition"
|
|
1529
|
-
end
|
|
1530
|
-
unless existing == expected
|
|
1531
|
-
raise AttemptErrors::Conflict, "Fixed merge request #{request_id} changed during transition"
|
|
1532
|
-
end
|
|
1533
|
-
unless existing.except(*FIXED_MERGE_MUTABLE_FIELDS) == replacement.except(*FIXED_MERGE_MUTABLE_FIELDS)
|
|
1534
|
-
raise AttemptErrors::Conflict, "Fixed merge request #{request_id} changed immutable binding"
|
|
1535
|
-
end
|
|
1536
|
-
end
|
|
1537
|
-
conflict = guard&.call
|
|
1538
|
-
raise_authorization_conflict!(conflict) if conflict
|
|
1539
|
-
if expected.nil? || replacement["state"] == "uncertain"
|
|
1540
|
-
attempt = derived_attempts(replacement.fetch("assignment_id")).find { |item| item.attempt_id == replacement.fetch("attempt_id") }
|
|
1541
|
-
unless attempt&.managed? && attempt.active? && attempt.binding.project_id == replacement.fetch("project_id")
|
|
1542
|
-
raise AttemptErrors::ReceiptRejected, "Fixed merge admission requires its current active canonical attempt"
|
|
1543
|
-
end
|
|
1544
|
-
end
|
|
1545
|
-
verify_fixed_proposal!(replacement) if event_type == "merge_admission"
|
|
1546
|
-
payload = {"request_id" => request_id, "state" => replacement.fetch("state"),
|
|
1547
|
-
"input_digest" => replacement.fetch("input_digest"),
|
|
1548
|
-
"record_digest" => Atoms::EvidenceDigest.digest(replacement),
|
|
1549
|
-
"receipt_digest" => replacement["receipt"] && Atoms::EvidenceDigest.digest(replacement["receipt"])}
|
|
1550
|
-
{record: replacement, event: {type: event_type, payload: payload}, replayed: false}
|
|
1551
|
-
end
|
|
1552
|
-
private :prepare_fixed_update
|
|
1553
|
-
|
|
1554
|
-
def verify_fixed_proposal!(record)
|
|
1555
|
-
return unless record.fetch("authorization").start_with?("proposal-")
|
|
1556
|
-
proposal_authorize!(record.fetch("authorization"), fixed_proposal_binding(record))
|
|
1557
|
-
conflict = proposal_claim_conflict(record.fetch("authorization"),
|
|
1558
|
-
request_id: record.fetch("request_id"), fixed: true)
|
|
1559
|
-
raise AttemptErrors::AuthorizationConflict, "Proposal already holds an effect claim" if conflict
|
|
1560
|
-
nil
|
|
1561
|
-
end
|
|
1562
|
-
|
|
1563
|
-
def fixed_proposal_binding(record)
|
|
1564
|
-
record.slice("operation", "project_id", "assignment_id", "attempt_id", "input_digest", "caller_uid").merge(
|
|
1565
|
-
"target" => {"resource" => record.fetch("request").fetch("pr_url"), "artifact_digest" => nil},
|
|
1566
|
-
"candidate_head" => record.fetch("request").fetch("candidate_head"))
|
|
1567
|
-
end
|
|
1568
|
-
|
|
1569
|
-
def fixed_replay?(existing, replacement)
|
|
1570
|
-
FIXED_MERGE_BINDING_FIELDS.all? do |field|
|
|
1571
|
-
existing[field] == replacement.fetch(field)
|
|
1572
|
-
end
|
|
1573
|
-
end
|
|
1574
|
-
private :fixed_replay?
|
|
1575
|
-
|
|
1576
|
-
# Protected-mode parity with service records: the record must match
|
|
1577
|
-
# the latest merge event digest in the owning attempt chain.
|
|
1578
|
-
def verify_fixed_record!(record, commit: ref_value)
|
|
1579
|
-
events = read_events(record.fetch("assignment_id"), commit: commit)
|
|
1580
|
-
.select { |event| event["attempt_id"] == record.fetch("attempt_id") }
|
|
1581
|
-
unless Models::EvidenceEvent.chain_valid?(events)
|
|
1582
|
-
raise AttemptErrors::EvidenceUnavailable, "Canonical fixed merge event chain is unverifiable"
|
|
1583
|
-
end
|
|
1584
|
-
event = events.reverse.find do |entry|
|
|
1585
|
-
%w[merge_admission merge_transition].include?(entry["type"]) &&
|
|
1586
|
-
entry.dig("payload", "request_id") == record["request_id"]
|
|
1587
|
-
end
|
|
1588
|
-
unless event && event.dig("payload", "record_digest") == Atoms::EvidenceDigest.digest(record)
|
|
1589
|
-
raise AttemptErrors::EvidenceUnavailable, "Canonical fixed merge record does not match accepted event"
|
|
1590
|
-
end
|
|
1591
|
-
rescue KeyError, TypeError
|
|
1592
|
-
raise AttemptErrors::EvidenceUnavailable, "Canonical fixed merge record has invalid binding"
|
|
1593
|
-
end
|
|
1594
|
-
|
|
1595
|
-
# The module marks a private region above; the fixed merge primitive
|
|
1596
|
-
# is a public coordinator-facing surface, so it is re-published here.
|
|
1597
|
-
public :fixed_merge_request, :fixed_merge_request_records, :admit_fixed_merge_request,
|
|
1598
|
-
:begin_fixed_merge_request, :settle_fixed_merge_request, :fixed_authorization_conflict, :fixed_merge_settlement!,
|
|
1599
|
-
:verify_service_attestation!
|
|
1600
|
-
|
|
1601
|
-
# Latest lifecycle state implied by the events, or nil when the
|
|
1602
|
-
# events do not describe a full attempt (intent missing).
|
|
1603
|
-
def derive_state(events)
|
|
1604
|
-
CanonicalAttemptState.derive(events)
|
|
1605
|
-
end
|
|
1606
|
-
|
|
1607
|
-
def build_attempt(assignment_id, attempt_id, intent_payload, events, state, commit: ref_value)
|
|
1608
|
-
process_start = events.reverse.find do |event|
|
|
1609
|
-
event["type"] == "process_start" && event["attempt_id"] == attempt_id
|
|
1610
|
-
end
|
|
1611
|
-
candidate_head = nil
|
|
1612
|
-
events.each do |event|
|
|
1613
|
-
case event["type"]
|
|
1614
|
-
when "candidate_invalidated" then candidate_head = nil
|
|
1615
|
-
when "receipt_accepted" then candidate_head ||= event.dig("payload", "receipt", "head")
|
|
1616
|
-
when "delivery"
|
|
1617
|
-
candidate_head = event.dig("payload", "head") if %w[intent result].include?(event.dig("payload", "stage"))
|
|
1618
|
-
end
|
|
1619
|
-
end
|
|
1620
|
-
binding = Models::AttemptBinding.new(
|
|
1621
|
-
attempt_id: attempt_id,
|
|
1622
|
-
assignment_id: assignment_id,
|
|
1623
|
-
scope: intent_payload["scope"],
|
|
1624
|
-
project_id: intent_payload["project_id"],
|
|
1625
|
-
task_id: intent_payload["task_id"],
|
|
1626
|
-
actor: process_start&.dig("payload", "actor") || intent_payload["actor"] || "recovered",
|
|
1627
|
-
role: process_start&.dig("payload", "role") || intent_payload["role"] || "coordinator",
|
|
1628
|
-
runtime: process_start&.dig("payload", "runtime") || intent_payload["runtime"] || "recovered",
|
|
1629
|
-
base_head: intent_payload["base_head"],
|
|
1630
|
-
evidence_git_ref: ref,
|
|
1631
|
-
created_at: parse_event_time(intent_time(events, attempt_id))
|
|
1632
|
-
)
|
|
1633
|
-
Models::Attempt.new(
|
|
1634
|
-
binding: binding,
|
|
1635
|
-
state: state,
|
|
1636
|
-
candidate_head: candidate_head,
|
|
1637
|
-
journal_commit: commit
|
|
1638
|
-
)
|
|
1639
|
-
end
|
|
1640
|
-
|
|
1641
|
-
def intent_time(events, attempt_id)
|
|
1642
|
-
intent = events.find { |event| event["type"] == "intent" && event["attempt_id"] == attempt_id }
|
|
1643
|
-
intent&.dig("recorded_at")
|
|
1644
|
-
end
|
|
1645
|
-
|
|
1646
|
-
def parse_event_time(value)
|
|
1647
|
-
return Time.now.utc if value.nil?
|
|
1648
|
-
|
|
1649
|
-
require "time"
|
|
1650
|
-
Time.parse(value)
|
|
1651
|
-
rescue ArgumentError
|
|
1652
|
-
Time.now.utc
|
|
1653
|
-
end
|
|
1654
|
-
|
|
1655
|
-
private
|
|
1656
|
-
|
|
1657
|
-
def event_files(assignment_id)
|
|
1658
|
-
Dir.glob(File.join(checkout_dir, "execution", assignment_id, "events", "*.json")).sort
|
|
1659
|
-
end
|
|
1660
|
-
|
|
1661
|
-
# Order events by following previous_digest links from chain roots;
|
|
1662
|
-
# orphaned events (unknown predecessor) keep filename order after the
|
|
1663
|
-
# resolved chains.
|
|
1664
|
-
def order_by_chain(events)
|
|
1665
|
-
by_digest = {}
|
|
1666
|
-
events.each { |event| by_digest[event["digest"]] = event }
|
|
1667
|
-
|
|
1668
|
-
next_of = {}
|
|
1669
|
-
events.each do |event|
|
|
1670
|
-
previous = event["previous_digest"]
|
|
1671
|
-
next_of[previous] = event if previous && by_digest.key?(previous)
|
|
1672
|
-
end
|
|
1673
|
-
|
|
1674
|
-
roots = events.reject do |event|
|
|
1675
|
-
previous = event["previous_digest"]
|
|
1676
|
-
previous && by_digest.key?(previous)
|
|
1677
|
-
end
|
|
1678
|
-
|
|
1679
|
-
ordered = []
|
|
1680
|
-
visited = {}
|
|
1681
|
-
roots.each do |root|
|
|
1682
|
-
cursor = root
|
|
1683
|
-
while cursor && !visited[cursor["digest"]]
|
|
1684
|
-
visited[cursor["digest"]] = true
|
|
1685
|
-
ordered << cursor
|
|
1686
|
-
cursor = next_of[cursor["digest"]]
|
|
1687
|
-
end
|
|
1688
|
-
end
|
|
1689
|
-
|
|
1690
|
-
ordered + events.reject { |event| visited[event["digest"]] }
|
|
1691
|
-
end
|
|
1692
|
-
|
|
1693
|
-
def event_filename(event)
|
|
1694
|
-
"#{event["recorded_at"].to_s.tr("-:T Z", "")}-#{event["type"]}-#{event["digest"][0, 12]}.json"
|
|
1695
|
-
end
|
|
1696
|
-
|
|
1697
|
-
def write_event_files(assignment_id, events)
|
|
1698
|
-
written = 0
|
|
1699
|
-
events.each do |event|
|
|
1700
|
-
path = File.join(checkout_dir, "execution", assignment_id, "events", event_filename(event))
|
|
1701
|
-
next if File.exist?(path)
|
|
1702
|
-
|
|
1703
|
-
FileUtils.mkdir_p(File.dirname(path))
|
|
1704
|
-
File.write(path, JSON.pretty_generate(event))
|
|
1705
|
-
written += 1
|
|
1706
|
-
end
|
|
1707
|
-
written
|
|
1708
|
-
end
|
|
1709
|
-
|
|
1710
|
-
def commit_events(assignment_id, attempt_id, events)
|
|
1711
|
-
types = events.map { |event| event["type"] }.uniq.join(",")
|
|
1712
|
-
git!("-C", checkout_dir, "add", "-A", "execution/#{assignment_id}")
|
|
1713
|
-
git!(
|
|
1714
|
-
"-C", checkout_dir,
|
|
1715
|
-
"-c", "user.name=ace-assign", "-c", "user.email=ace-assign@localhost",
|
|
1716
|
-
"commit", "-m", "evidence: #{assignment_id} #{attempt_id} (#{types})"
|
|
1717
|
-
)
|
|
1718
|
-
git!("-C", checkout_dir, "rev-parse", "HEAD").first
|
|
1719
|
-
end
|
|
1720
|
-
|
|
1721
|
-
# Expected-old-value compare-and-swap; returns false on conflict.
|
|
1722
|
-
# A nil expectation is the create case: the ref must not exist.
|
|
1723
|
-
def update_ref_cas(new_commit, expected_old)
|
|
1724
|
-
return true if !expected_old.nil? && new_commit == expected_old
|
|
1725
|
-
|
|
1726
|
-
expected = expected_old || ("0" * 40)
|
|
1727
|
-
_out, stderr, status = git("update-ref", @ref, new_commit, expected)
|
|
1728
|
-
return true if status.success?
|
|
1729
|
-
|
|
1730
|
-
raise AttemptErrors::EvidenceUnavailable, "Cannot update evidence ref #{@ref}: #{stderr}" if git_broken?(stderr)
|
|
1731
|
-
|
|
1732
|
-
false
|
|
1733
|
-
end
|
|
1734
|
-
|
|
1735
|
-
def checkout_dir
|
|
1736
|
-
File.join(@checkout_root, "journal")
|
|
1737
|
-
end
|
|
1738
|
-
|
|
1739
|
-
def lock_path
|
|
1740
|
-
File.join(@checkout_root, ".evidence.lock")
|
|
1741
|
-
end
|
|
1742
|
-
|
|
1743
|
-
def with_lock(cleanup_on_failure: true, owner_uid: nil, owner_gid: nil, deadline: nil)
|
|
1744
|
-
raise AttemptErrors::EvidenceUnavailable, "read-only journal cannot mutate canonical evidence" if @read_boundary
|
|
1745
|
-
FileUtils.mkdir_p(@checkout_root)
|
|
1746
|
-
lock = if owner_uid
|
|
1747
|
-
initialization_lock!(owner_uid: owner_uid, owner_gid: owner_gid)
|
|
1748
|
-
else
|
|
1749
|
-
File.open(lock_path, File::RDWR | File::CREAT, 0o600)
|
|
1750
|
-
end
|
|
1751
|
-
begin
|
|
1752
|
-
if deadline
|
|
1753
|
-
unless deadline.is_a?(Numeric) && deadline.finite?
|
|
1754
|
-
raise ArgumentError, "canonical lock deadline must be finite"
|
|
1755
|
-
end
|
|
1756
|
-
loop do
|
|
1757
|
-
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
1758
|
-
raise AttemptErrors::Conflict, "canonical journal lock deadline elapsed" unless remaining.positive?
|
|
1759
|
-
break if lock.flock(File::LOCK_EX | File::LOCK_NB)
|
|
1760
|
-
sleep [remaining, 0.01].min
|
|
1761
|
-
end
|
|
1762
|
-
else
|
|
1763
|
-
lock.flock(File::LOCK_EX)
|
|
1764
|
-
end
|
|
1765
|
-
begin
|
|
1766
|
-
yield
|
|
1767
|
-
rescue
|
|
1768
|
-
# Ordinary mutation rollback is unchanged. Initialization must
|
|
1769
|
-
# not adopt/reset a foreign disposable checkout on refusal.
|
|
1770
|
-
sync_checkout(ref_value) if cleanup_on_failure && File.exist?(File.join(checkout_dir, ".git"))
|
|
1771
|
-
raise
|
|
1772
|
-
ensure
|
|
1773
|
-
lock.flock(File::LOCK_UN)
|
|
1774
|
-
end
|
|
1775
|
-
ensure
|
|
1776
|
-
lock.close
|
|
1777
|
-
end
|
|
1778
|
-
end
|
|
1779
|
-
|
|
1780
|
-
def initialization_lock!(owner_uid:, owner_gid:)
|
|
1781
|
-
created = false
|
|
1782
|
-
begin
|
|
1783
|
-
file = File.open(lock_path, File::RDWR | File::CREAT | File::EXCL | File::NOFOLLOW, 0o600)
|
|
1784
|
-
created = true
|
|
1785
|
-
rescue Errno::EEXIST
|
|
1786
|
-
file = File.open(lock_path, File::RDWR | File::NOFOLLOW)
|
|
1787
|
-
end
|
|
1788
|
-
if created
|
|
1789
|
-
file.flush
|
|
1790
|
-
file.fsync
|
|
1791
|
-
end
|
|
1792
|
-
info = file.stat
|
|
1793
|
-
unless info.file? && info.nlink == 1 && info.size.zero? &&
|
|
1794
|
-
[info.uid, info.gid, info.mode & 0o7777] == [owner_uid, owner_gid, 0o600]
|
|
1795
|
-
raise AttemptErrors::EvidenceUnavailable, "canonical initialization lock protection differs"
|
|
1796
|
-
end
|
|
1797
|
-
file
|
|
1798
|
-
rescue Exception
|
|
1799
|
-
file&.close
|
|
1800
|
-
raise
|
|
1801
|
-
end
|
|
1802
|
-
|
|
1803
|
-
def ensure_checkout!
|
|
1804
|
-
return if detached_worktree_at?(checkout_dir)
|
|
1805
|
-
|
|
1806
|
-
FileUtils.rm_rf(checkout_dir)
|
|
1807
|
-
git!("worktree", "prune")
|
|
1808
|
-
value = ref_value
|
|
1809
|
-
if value.nil?
|
|
1810
|
-
seed_ref
|
|
1811
|
-
value = ref_value
|
|
1812
|
-
end
|
|
1813
|
-
git!("worktree", "add", "--detach", checkout_dir, value)
|
|
1814
|
-
end
|
|
1815
|
-
|
|
1816
|
-
# Seed the evidence ref with an empty-tree commit so a worktree can
|
|
1817
|
-
# attach before any real evidence exists. The create is a
|
|
1818
|
-
# compare-and-swap against the zero SHA: a writer that loses the
|
|
1819
|
-
# race keeps the winner's ref instead of resetting it.
|
|
1820
|
-
def seed_ref
|
|
1821
|
-
empty_tree = git!("mktree").first
|
|
1822
|
-
seed = git!("-c", "user.name=ace-assign", "-c", "user.email=ace-assign@localhost",
|
|
1823
|
-
"commit-tree", empty_tree, "-m", "seed: ace-assign execution evidence").first
|
|
1824
|
-
_out, stderr, status = git("update-ref", @ref, seed, "0" * 40)
|
|
1825
|
-
return if status.success?
|
|
1826
|
-
|
|
1827
|
-
raise AttemptErrors::EvidenceUnavailable, "Cannot seed evidence ref #{@ref}: #{stderr}" if git_broken?(stderr)
|
|
1828
|
-
end
|
|
1829
|
-
|
|
1830
|
-
def detached_worktree_at?(path)
|
|
1831
|
-
dot_git = File.join(path, ".git")
|
|
1832
|
-
return false unless File.exist?(dot_git)
|
|
1833
|
-
|
|
1834
|
-
out, _s = git("-C", path, "rev-parse", "--abbrev-ref", "HEAD")
|
|
1835
|
-
out.to_s.strip == "HEAD"
|
|
1836
|
-
end
|
|
1837
|
-
|
|
1838
|
-
# Sync the disposable checkout to an authoritative ref value.
|
|
1839
|
-
def sync_checkout(target)
|
|
1840
|
-
return if target.nil?
|
|
1841
|
-
|
|
1842
|
-
git!("-C", checkout_dir, "reset", "--hard", target)
|
|
1843
|
-
# All writers use this boundary, including CAS retries. Only the
|
|
1844
|
-
# journal's disposable namespaces are cleaned, never the candidate.
|
|
1845
|
-
git!("-C", checkout_dir, "clean", "-fd", "--", "execution", "evidence")
|
|
1846
|
-
rescue AttemptErrors::EvidenceUnavailable
|
|
1847
|
-
# Stale, broken, or unregistered worktree: rebuild from scratch.
|
|
1848
|
-
FileUtils.rm_rf(checkout_dir)
|
|
1849
|
-
git!("worktree", "prune")
|
|
1850
|
-
git!("worktree", "add", "--detach", checkout_dir, target)
|
|
1851
|
-
end
|
|
1852
|
-
|
|
1853
|
-
def git(*argv)
|
|
1854
|
-
return @read_boundary.call(argv) if @read_boundary
|
|
1855
|
-
unless File.directory?(@repo_root)
|
|
1856
|
-
return ["", "repository root missing: #{@repo_root}", FAILED_RESULT]
|
|
1857
|
-
end
|
|
1858
|
-
|
|
1859
|
-
out, stderr, status = Open3.capture3("git", *argv, chdir: @repo_root, stdin_data: "")
|
|
1860
|
-
[out.to_s.strip, stderr.to_s.strip, status]
|
|
1861
|
-
end
|
|
1862
|
-
|
|
1863
|
-
def git!(*argv)
|
|
1864
|
-
out, stderr, status = git(*argv)
|
|
1865
|
-
unless status.success?
|
|
1866
|
-
raise AttemptErrors::EvidenceUnavailable, "git #{argv.join(" ")} failed: #{stderr}"
|
|
1867
|
-
end
|
|
1868
|
-
|
|
1869
|
-
[out, stderr, status]
|
|
1870
|
-
end
|
|
1871
|
-
|
|
1872
|
-
def git_ok?(*argv)
|
|
1873
|
-
git(*argv)[2].success?
|
|
1874
|
-
end
|
|
1875
|
-
|
|
1876
|
-
# Distinguish "ref missing" (normal, quiet, no stderr) and expected
|
|
1877
|
-
# compare-and-swap conflicts (retryable) from a broken repository or
|
|
1878
|
-
# ref store.
|
|
1879
|
-
def git_broken?(stderr)
|
|
1880
|
-
message = stderr.to_s.strip
|
|
1881
|
-
return false if message.empty?
|
|
1882
|
-
return false if message.include?("unknown revision") || message.include?("not a valid ref")
|
|
1883
|
-
return false if message.include?("cannot lock ref") || message.include?("but expected")
|
|
1884
|
-
|
|
1885
|
-
true
|
|
1886
|
-
end
|
|
1887
|
-
|
|
1888
|
-
# Stand-in status for commands that cannot run at all.
|
|
1889
|
-
FailedResult = Struct.new(:success?)
|
|
1890
|
-
FAILED_RESULT = FailedResult.new(false).freeze
|
|
1891
|
-
|
|
1892
|
-
def default_config(key)
|
|
1893
|
-
section = Ace::Assign.config["attempt"]
|
|
1894
|
-
section && section[key]
|
|
1895
|
-
end
|
|
1896
|
-
end
|
|
1897
|
-
end
|
|
1898
|
-
end
|
|
1899
|
-
end
|