ace-assign 0.70.0 → 0.71.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +20 -0
- data/handbook/skills/as-assign-drive/SKILL.md +5 -2
- data/handbook/workflow-instructions/assign/drive.wf.md +77 -0
- data/lib/ace/assign/atoms/candidate_limits.rb +20 -0
- data/lib/ace/assign/atoms/git_blob_batch.rb +59 -0
- data/lib/ace/assign/authority/candidate_blob_batch.rb +55 -0
- data/lib/ace/assign/authority/candidate_head_comparison.rb +139 -0
- data/lib/ace/assign/authority/candidate_transfer.rb +55 -25
- data/lib/ace/assign/authority/command_execution.rb +180 -0
- data/lib/ace/assign/authority/endcap.rb +4 -2
- data/lib/ace/assign/authority/endcap_campaign_rounds.rb +1 -1
- data/lib/ace/assign/authority/endcap_publication.rb +0 -166
- data/lib/ace/assign/authority/endcap_service_context.rb +38 -0
- data/lib/ace/assign/authority/fixed_publication_lifecycle.rb +176 -0
- data/lib/ace/assign/authority/{endcap_services.rb → fixed_service_lifecycle.rb} +42 -46
- data/lib/ace/assign/authority/ordinary_completion.rb +13 -3
- data/lib/ace/assign/authority/ordinary_lifecycle.rb +40 -4
- data/lib/ace/assign/authority/ordinary_merge_evidence.rb +81 -0
- data/lib/ace/assign/authority/ordinary_release_artifacts.rb +143 -0
- data/lib/ace/assign/authority/ordinary_results.rb +11 -1
- data/lib/ace/assign/authority/ordinary_review_evidence.rb +180 -0
- data/lib/ace/assign/authority/ordinary_reviews.rb +214 -0
- data/lib/ace/assign/authority/ordinary_supervisor.rb +99 -0
- data/lib/ace/assign/authority/prepared_work.rb +7 -2
- data/lib/ace/assign/authority/release_artifact_manifest.rb +91 -0
- data/lib/ace/assign/authority/retained_input.rb +53 -0
- data/lib/ace/assign/authority/transfer_codec.rb +5 -3
- data/lib/ace/assign/cli/commands/submit_candidate.rb +1 -1
- data/lib/ace/assign/molecules/canonical_evidence.rb +25 -8
- data/lib/ace/assign/molecules/evidence_journal.rb +17 -30
- data/lib/ace/assign/molecules/journal_mutation.rb +2 -1
- data/lib/ace/assign/molecules/receipt_verifier.rb +8 -2
- data/lib/ace/assign/molecules/runtime_control_surface_runner.rb +2 -1
- data/lib/ace/assign/organisms/attempt_coordinator.rb +40 -3
- data/lib/ace/assign/organisms/ordinary_driver.rb +310 -0
- data/lib/ace/assign/version.rb +1 -1
- metadata +19 -4
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "json"
|
|
5
|
+
require "stringio"
|
|
6
|
+
require "zlib"
|
|
7
|
+
require "rubygems/package"
|
|
8
|
+
require_relative "../molecules/canonical_evidence"
|
|
9
|
+
|
|
10
|
+
module Ace
|
|
11
|
+
module Assign
|
|
12
|
+
module Authority
|
|
13
|
+
# One retained gem and the source-owned publication input it binds.
|
|
14
|
+
# Inspect bounded metadata only; never extract or evaluate build files.
|
|
15
|
+
module ReleaseArtifactManifest
|
|
16
|
+
FIELDS = %w[gem_name version platform registry sha256 bytes].freeze
|
|
17
|
+
NAME = /\A[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}\z/
|
|
18
|
+
MAX_METADATA_BYTES = 1024 * 1024
|
|
19
|
+
REGISTRY = "https://rubygems.org"
|
|
20
|
+
|
|
21
|
+
def self.validate!(value)
|
|
22
|
+
unless value.is_a?(Hash) && value.keys.sort == FIELDS.sort &&
|
|
23
|
+
value["gem_name"].is_a?(String) && NAME.match?(value["gem_name"]) &&
|
|
24
|
+
value["version"].is_a?(String) && value["version"].bytesize.between?(1, 128) && Gem::Version.correct?(value["version"]) &&
|
|
25
|
+
value["platform"].is_a?(String) && NAME.match?(value["platform"]) &&
|
|
26
|
+
value["registry"] == REGISTRY && value["sha256"].is_a?(String) && value["sha256"].match?(/\A[0-9a-f]{64}\z/) &&
|
|
27
|
+
value["bytes"].is_a?(Integer) && value["bytes"].between?(1, Molecules::CanonicalEvidence::MAX_RELEASE_ARTIFACT_BYTES)
|
|
28
|
+
raise ArgumentError, "invalid release artifact manifest"
|
|
29
|
+
end
|
|
30
|
+
value
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.verify!(bytes, manifest)
|
|
34
|
+
validate!(manifest)
|
|
35
|
+
unless bytes.is_a?(String) && bytes.bytesize == manifest.fetch("bytes") &&
|
|
36
|
+
Digest::SHA256.hexdigest(bytes.b) == manifest.fetch("sha256")
|
|
37
|
+
raise AttemptErrors::ReceiptRejected, "release artifact bytes differ from the manifest"
|
|
38
|
+
end
|
|
39
|
+
metadata = metadata!(bytes)
|
|
40
|
+
unless metadata == manifest.slice("gem_name", "version", "platform")
|
|
41
|
+
raise AttemptErrors::ReceiptRejected, "release artifact gem metadata differs from the manifest"
|
|
42
|
+
end
|
|
43
|
+
true
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Shared immutable input for retention and the later fixed publisher.
|
|
47
|
+
# Candidate/reference come from the installed canonical owner, not wire
|
|
48
|
+
# claims. Its digest is the existing service authorization input_digest.
|
|
49
|
+
def self.publication_input(manifest:, candidate:, reference:)
|
|
50
|
+
validate!(manifest)
|
|
51
|
+
{"target" => {"resource" => "rubygems:#{manifest.fetch('gem_name')}:#{manifest.fetch('version')}",
|
|
52
|
+
"artifact_digest" => manifest.fetch("sha256")},
|
|
53
|
+
"publication" => manifest.slice("gem_name", "version", "platform", "registry").merge(
|
|
54
|
+
"head" => candidate.fetch("head"), "tree" => candidate.fetch("tree"), "candidate_reference" => reference,
|
|
55
|
+
"artifact_bytes" => manifest.fetch("bytes"), "artifact_relative_path" => "artifacts/#{manifest.fetch('sha256')}.gem")}
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def self.metadata!(bytes)
|
|
59
|
+
metadata = nil
|
|
60
|
+
Gem::Package::TarReader.new(StringIO.new(bytes)) do |archive|
|
|
61
|
+
archive.each do |entry|
|
|
62
|
+
next unless entry.full_name == "metadata.gz"
|
|
63
|
+
unless metadata.nil? && entry.file? && entry.header.size.between?(1, MAX_METADATA_BYTES)
|
|
64
|
+
raise AttemptErrors::ReceiptRejected, "release artifact metadata is missing, repeated or oversized"
|
|
65
|
+
end
|
|
66
|
+
compressed = entry.read
|
|
67
|
+
Zlib::GzipReader.wrap(StringIO.new(compressed)) do |reader|
|
|
68
|
+
metadata = reader.read(MAX_METADATA_BYTES + 1)
|
|
69
|
+
if metadata.bytesize > MAX_METADATA_BYTES
|
|
70
|
+
raise AttemptErrors::ReceiptRejected, "release artifact metadata exceeds its bound"
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
raise AttemptErrors::ReceiptRejected, "release artifact metadata is unavailable" unless metadata
|
|
76
|
+
specification = Gem::Specification.from_yaml(metadata)
|
|
77
|
+
unless specification.is_a?(Gem::Specification)
|
|
78
|
+
raise AttemptErrors::ReceiptRejected, "release artifact metadata is invalid"
|
|
79
|
+
end
|
|
80
|
+
{"gem_name" => specification.name, "version" => specification.version.to_s,
|
|
81
|
+
"platform" => specification.platform.to_s}
|
|
82
|
+
rescue AttemptErrors::ReceiptRejected
|
|
83
|
+
raise
|
|
84
|
+
rescue StandardError
|
|
85
|
+
raise AttemptErrors::ReceiptRejected, "release artifact metadata is invalid"
|
|
86
|
+
end
|
|
87
|
+
private_class_method :metadata!
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "digest"
|
|
5
|
+
require_relative "private_directory"
|
|
6
|
+
|
|
7
|
+
module Ace
|
|
8
|
+
module Assign
|
|
9
|
+
module Authority
|
|
10
|
+
# Immutable requester inputs, never evidence that an operation executed.
|
|
11
|
+
module RetainedInput
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
def read_file(path, limit)
|
|
15
|
+
File.open(path, File::RDONLY | File::NOFOLLOW | File::NONBLOCK) do |file|
|
|
16
|
+
before = file.stat
|
|
17
|
+
raise ArgumentError, "retained input is not a bounded regular file" unless before.file? && before.size.between?(0, limit)
|
|
18
|
+
bytes = (file.read(limit + 1) || "").b
|
|
19
|
+
after, selected = file.stat, File.lstat(path)
|
|
20
|
+
identity = ->(stat) { [stat.dev, stat.ino, stat.size, stat.mtime, stat.ctime] }
|
|
21
|
+
unless bytes.bytesize == before.size && identity.call(before) == identity.call(after) &&
|
|
22
|
+
identity.call(after) == identity.call(selected) && !selected.symlink?
|
|
23
|
+
raise ArgumentError, "retained input changed during read"
|
|
24
|
+
end
|
|
25
|
+
bytes.freeze
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def json(path, limit = 16_384)
|
|
30
|
+
JSON.parse(read_file(path, limit), allow_duplicate_key: false, create_additions: false, max_nesting: 24)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def create_output(path)
|
|
34
|
+
raise ArgumentError, "output must be a new directory path" unless path.is_a?(String) && !path.empty?
|
|
35
|
+
directory = File.expand_path(path)
|
|
36
|
+
Dir.mkdir(directory, 0o700)
|
|
37
|
+
PrivateDirectory.verify!(directory)
|
|
38
|
+
directory
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def write_file(directory, name, bytes)
|
|
42
|
+
raise ArgumentError, "retained filename is invalid" unless File.basename(name) == name && !%w[. ..].include?(name)
|
|
43
|
+
File.open(File.join(directory, name), File::WRONLY | File::CREAT | File::EXCL | File::NOFOLLOW, 0o600) do |file|
|
|
44
|
+
file.write(bytes)
|
|
45
|
+
file.flush
|
|
46
|
+
file.fsync
|
|
47
|
+
end
|
|
48
|
+
File.open(directory) { |file| file.fsync }
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -5,6 +5,7 @@ require "tempfile"
|
|
|
5
5
|
require "timeout"
|
|
6
6
|
require "ace/runtime/molecules/protected_socket"
|
|
7
7
|
require_relative "private_directory"
|
|
8
|
+
require_relative "../atoms/candidate_limits"
|
|
8
9
|
|
|
9
10
|
module Ace
|
|
10
11
|
module Assign
|
|
@@ -13,10 +14,11 @@ module Ace
|
|
|
13
14
|
# Server selects purpose from a source handler's fixed operation table,
|
|
14
15
|
# admits a transfer slot before calling, and holds no journal lock here.
|
|
15
16
|
class TransferCodec
|
|
16
|
-
LIMITS = {prompt_text: [16_384, 1, 16_384], scope_boundary_observation: [65_536, 1, 65_536], candidate: [
|
|
17
|
+
LIMITS = {prompt_text: [16_384, 1, 16_384], scope_boundary_observation: [65_536, 1, 65_536], candidate: [Atoms::CandidateLimits::BYTES, 1, Atoms::CandidateLimits::BYTES],
|
|
18
|
+
release_artifact: [64 * 1024 * 1024, 1, 64 * 1024 * 1024],
|
|
17
19
|
artifacts: [256 * 1024, 16, 64 * 1024],
|
|
18
20
|
receipt_artifacts: [272 * 1024, 17, 64 * 1024],
|
|
19
|
-
service_input: [64 * 1024, 1, 64 * 1024], assignment_snapshot: [
|
|
21
|
+
service_input: [64 * 1024, 1, 64 * 1024], assignment_snapshot: [Atoms::CandidateLimits::BYTES, 1, Atoms::CandidateLimits::BYTES]}.freeze
|
|
20
22
|
SHA256 = /\A[0-9a-f]{64}\z/
|
|
21
23
|
|
|
22
24
|
class Input
|
|
@@ -149,7 +151,7 @@ module Ace
|
|
|
149
151
|
part["bytes"].is_a?(Integer) && part["bytes"].between?(0, each) &&
|
|
150
152
|
part["sha256"].is_a?(String) && part["sha256"].match?(SHA256) } &&
|
|
151
153
|
descriptor["bytes"] == descriptor["parts"].sum { |part| part["bytes"] } &&
|
|
152
|
-
(!%i[candidate service_input prompt_text assignment_snapshot].include?(purpose) || descriptor["bytes"].positive?)
|
|
154
|
+
(!%i[candidate service_input prompt_text assignment_snapshot release_artifact].include?(purpose) || descriptor["bytes"].positive?)
|
|
153
155
|
reject!("Invalid or oversized transfer descriptor")
|
|
154
156
|
end
|
|
155
157
|
if purpose == :receipt_artifacts
|
|
@@ -23,7 +23,7 @@ module Ace
|
|
|
23
23
|
head = require_option(options, :head, usage)
|
|
24
24
|
raise Ace::Support::Cli::Error, "Head must be an exact SHA-1" unless head.match?(/\A[0-9a-f]{40}\z/)
|
|
25
25
|
params.merge!("head" => head, "candidate_generation" => integer_option(options, :candidate_generation, usage))
|
|
26
|
-
bytes = input_bytes(require_option(options, :bundle, usage), limit:
|
|
26
|
+
bytes = input_bytes(require_option(options, :bundle, usage), limit: Ace::Assign::Authority::CandidateTransfer::MAX_BYTES)
|
|
27
27
|
parts = [bytes]
|
|
28
28
|
emit_json(upload_call(client, "submit_candidate", params, mutation, parts, :candidate))
|
|
29
29
|
end
|
|
@@ -14,8 +14,12 @@ module Ace
|
|
|
14
14
|
MAX_TOTAL_BYTES = 256 * 1024
|
|
15
15
|
MAX_ARTIFACTS = 16
|
|
16
16
|
ID = /\A[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}\z/
|
|
17
|
+
MAX_RELEASE_ARTIFACT_BYTES = 64 * 1024 * 1024
|
|
17
18
|
ROLES = {"service" => "executor", "review" => "reviewer",
|
|
18
|
-
"result" => "worker"}.freeze
|
|
19
|
+
"result" => "worker", "release-artifact" => "worker"}.freeze
|
|
20
|
+
REPORT_LIMITS = [MAX_ARTIFACT_BYTES, MAX_TOTAL_BYTES, MAX_ARTIFACTS].freeze
|
|
21
|
+
LIMITS = {"service" => REPORT_LIMITS, "review" => REPORT_LIMITS, "result" => REPORT_LIMITS,
|
|
22
|
+
"release-artifact" => [MAX_RELEASE_ARTIFACT_BYTES, MAX_RELEASE_ARTIFACT_BYTES, 1].freeze}.freeze
|
|
19
23
|
DESCRIPTOR_FIELDS = %w[version artifact_id kind project_id assignment_id attempt_id peer_uid role
|
|
20
24
|
binding_digest sha256 bytes admitted_at admitted_after_event_digest request_id_or_event_id
|
|
21
25
|
candidate_generation_or_claim_generation].freeze
|
|
@@ -27,7 +31,8 @@ module Ace
|
|
|
27
31
|
%w[artifact_id project_id assignment_id attempt_id request_id_or_event_id].all? { |key| descriptor[key].is_a?(String) && descriptor[key].match?(ID) } &&
|
|
28
32
|
%w[binding_digest sha256 admitted_after_event_digest].all? { |key| descriptor[key].is_a?(String) && descriptor[key].match?(/\A[0-9a-f]{64}\z/) } &&
|
|
29
33
|
descriptor["peer_uid"].is_a?(Integer) && descriptor["peer_uid"] >= 0 &&
|
|
30
|
-
descriptor["bytes"].is_a?(Integer) && descriptor["bytes"].between?(0,
|
|
34
|
+
descriptor["bytes"].is_a?(Integer) && descriptor["bytes"].between?(descriptor["kind"] == "release-artifact" ? 1 : 0,
|
|
35
|
+
LIMITS.fetch(descriptor["kind"]).first) &&
|
|
31
36
|
descriptor["candidate_generation_or_claim_generation"].is_a?(Integer) && descriptor["candidate_generation_or_claim_generation"] >= 0 &&
|
|
32
37
|
descriptor["admitted_at"].is_a?(String)
|
|
33
38
|
Time.iso8601(descriptor.fetch("admitted_at"))
|
|
@@ -46,13 +51,15 @@ module Ace
|
|
|
46
51
|
def import_plan(kind:, project_id:, assignment_id:, attempt_id:, peer_uid:, binding:,
|
|
47
52
|
request_id_or_event_id:, generation:, admitted_after_event_digest:, artifacts:)
|
|
48
53
|
role = ROLES.fetch(kind) { raise ArgumentError, "invalid evidence kind" }
|
|
54
|
+
max_each, max_total, max_count = LIMITS.fetch(kind)
|
|
55
|
+
minimum = kind == "release-artifact" ? 1 : 0
|
|
49
56
|
ids = [project_id, assignment_id, attempt_id, request_id_or_event_id]
|
|
50
57
|
unless ids.all? { |id| id.is_a?(String) && id.match?(ID) } && binding.is_a?(Hash) &&
|
|
51
58
|
admitted_after_event_digest.is_a?(String) && admitted_after_event_digest.match?(/\A[0-9a-f]{64}\z/) &&
|
|
52
59
|
peer_uid.is_a?(Integer) && peer_uid >= 0 && generation.is_a?(Integer) && generation >= 0 &&
|
|
53
|
-
artifacts.is_a?(Array) && !artifacts.empty? && artifacts.length <=
|
|
54
|
-
artifacts.all? { |bytes| bytes.is_a?(String) && bytes.bytesize
|
|
55
|
-
artifacts.sum(&:bytesize) <=
|
|
60
|
+
artifacts.is_a?(Array) && !artifacts.empty? && artifacts.length <= max_count &&
|
|
61
|
+
artifacts.all? { |bytes| bytes.is_a?(String) && bytes.bytesize.between?(minimum, max_each) } &&
|
|
62
|
+
artifacts.sum(&:bytesize) <= max_total
|
|
56
63
|
raise ArgumentError, "invalid or oversized canonical evidence import"
|
|
57
64
|
end
|
|
58
65
|
events = []
|
|
@@ -85,7 +92,11 @@ module Ace
|
|
|
85
92
|
verified_bytes(reference, events: events, kind: kind, project_id: project_id,
|
|
86
93
|
assignment_id: assignment_id, attempt_id: attempt_id, peer_uid: peer_uid, binding: binding,
|
|
87
94
|
request_id_or_event_id: request_id_or_event_id, generation: generation) do |path|
|
|
88
|
-
|
|
95
|
+
if kind == "release-artifact"
|
|
96
|
+
@journal.bounded_blob(path, commit: commit, max_bytes: MAX_RELEASE_ARTIFACT_BYTES)
|
|
97
|
+
else
|
|
98
|
+
@journal.blob(path, commit: commit)
|
|
99
|
+
end
|
|
89
100
|
end
|
|
90
101
|
end
|
|
91
102
|
|
|
@@ -102,7 +113,13 @@ module Ace
|
|
|
102
113
|
verified_bytes(reference, events: events, kind: kind, project_id: project_id,
|
|
103
114
|
assignment_id: assignment_id, attempt_id: attempt_id, peer_uid: peer_uid, binding: binding,
|
|
104
115
|
request_id_or_event_id: request_id_or_event_id, generation: generation) do |path|
|
|
105
|
-
blobs.key?(path)
|
|
116
|
+
if blobs.key?(path)
|
|
117
|
+
blobs.fetch(path)
|
|
118
|
+
elsif kind == "release-artifact"
|
|
119
|
+
@journal.bounded_blob(path, commit: commit, max_bytes: MAX_RELEASE_ARTIFACT_BYTES)
|
|
120
|
+
else
|
|
121
|
+
@journal.blob(path, commit: commit)
|
|
122
|
+
end
|
|
106
123
|
end
|
|
107
124
|
end
|
|
108
125
|
|
|
@@ -137,7 +154,7 @@ module Ace
|
|
|
137
154
|
end
|
|
138
155
|
Time.iso8601(descriptor.fetch("admitted_at"))
|
|
139
156
|
bytes = yield(reference.fetch("ref"))
|
|
140
|
-
unavailable! unless bytes.is_a?(String) && bytes.bytesize <=
|
|
157
|
+
unavailable! unless bytes.is_a?(String) && bytes.bytesize <= LIMITS.fetch(kind).first && bytes.bytesize == descriptor["bytes"] &&
|
|
141
158
|
Digest::SHA256.hexdigest(bytes) == descriptor["sha256"]
|
|
142
159
|
bytes
|
|
143
160
|
rescue ArgumentError, KeyError, TypeError
|
|
@@ -7,6 +7,7 @@ require_relative "../atoms/fixed_merge_schemas"
|
|
|
7
7
|
require "digest"
|
|
8
8
|
require "fileutils"
|
|
9
9
|
require "json"
|
|
10
|
+
require_relative "../atoms/git_blob_batch"
|
|
10
11
|
require "open3"
|
|
11
12
|
require "pathname"
|
|
12
13
|
require "ace/herdr/molecules/bounded_process"
|
|
@@ -375,13 +376,10 @@ module Ace
|
|
|
375
376
|
else
|
|
376
377
|
bounded_history_read!(%w[cat-file --batch-check], oids.join("\n") + "\n", oids.size * 128)
|
|
377
378
|
end
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
raise AttemptErrors::EvidenceUnavailable, "historical blob metadata differs" unless match && match[1] == oid
|
|
383
|
-
match[2].to_i
|
|
384
|
-
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
|
|
385
383
|
end
|
|
386
384
|
|
|
387
385
|
def bounded_history_read!(argv, stdin, limit)
|
|
@@ -1086,26 +1084,11 @@ module Ace
|
|
|
1086
1084
|
end
|
|
1087
1085
|
|
|
1088
1086
|
def decode_event_blobs!(entries, output)
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
raise AttemptErrors::EvidenceUnavailable, "canonical blob batch header is invalid"
|
|
1095
|
-
end
|
|
1096
|
-
offset = ending + 1
|
|
1097
|
-
content = output.byteslice(offset, size)
|
|
1098
|
-
unless content&.bytesize == size && output.byteslice(offset + size, 1) == "\n" &&
|
|
1099
|
-
Digest::SHA1.hexdigest("blob #{size}\0" + content) == oid
|
|
1100
|
-
raise AttemptErrors::EvidenceUnavailable, "canonical blob batch bytes are invalid"
|
|
1101
|
-
end
|
|
1102
|
-
offset += size + 1
|
|
1103
|
-
content
|
|
1104
|
-
end.tap do
|
|
1105
|
-
unless offset == output.bytesize
|
|
1106
|
-
raise AttemptErrors::EvidenceUnavailable, "canonical blob batch has unexpected trailing bytes"
|
|
1107
|
-
end
|
|
1108
|
-
end
|
|
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
|
|
1109
1092
|
end
|
|
1110
1093
|
|
|
1111
1094
|
def update_service_request(request_id, expected:, replacement:, event_type:, guard: nil)
|
|
@@ -1360,7 +1343,7 @@ module Ace
|
|
|
1360
1343
|
# CAS transition, so a receiver restart can never dispatch a second
|
|
1361
1344
|
# merge for the same admitted record. An already-started claim refuses;
|
|
1362
1345
|
# only the winning effect may later settle it.
|
|
1363
|
-
def begin_fixed_merge_request(request_id)
|
|
1346
|
+
def begin_fixed_merge_request(request_id, guard: nil)
|
|
1364
1347
|
current = fixed_merge_request(request_id)
|
|
1365
1348
|
raise AttemptErrors::NotFound, "Fixed merge request #{request_id} not found" unless current
|
|
1366
1349
|
if current["state"] == "uncertain"
|
|
@@ -1371,7 +1354,7 @@ module Ace
|
|
|
1371
1354
|
end
|
|
1372
1355
|
update_fixed_merge_request(request_id, expected: current,
|
|
1373
1356
|
replacement: current.merge("state" => "uncertain", "claimed_at" => Time.now.utc.iso8601(9)),
|
|
1374
|
-
event_type: "merge_transition", guard: -> { verify_fixed_proposal!(current) })
|
|
1357
|
+
event_type: "merge_transition", guard: -> { guard&.call; verify_fixed_proposal!(current) })
|
|
1375
1358
|
end
|
|
1376
1359
|
|
|
1377
1360
|
# Terminal settlement: succeeded/failed with the exact receipt
|
|
@@ -1527,7 +1510,11 @@ module Ace
|
|
|
1527
1510
|
raise AttemptErrors::InvalidState, "Fixed merge request needs an initial admission"
|
|
1528
1511
|
end
|
|
1529
1512
|
if existing
|
|
1530
|
-
|
|
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
|
|
1531
1518
|
raise AttemptErrors::Conflict, "Fixed merge request #{request_id} has different input"
|
|
1532
1519
|
end
|
|
1533
1520
|
unless Molecules::FixedEffectReference.valid?(replacement.fetch("reference"))
|
|
@@ -4,6 +4,7 @@ require "json"
|
|
|
4
4
|
require "digest"
|
|
5
5
|
require "ace/herdr/molecules/bounded_process"
|
|
6
6
|
require_relative "journal_prompt_mutation"
|
|
7
|
+
require_relative "../atoms/candidate_limits"
|
|
7
8
|
require_relative "journal_input_inhibition"
|
|
8
9
|
|
|
9
10
|
module Ace
|
|
@@ -192,7 +193,7 @@ module Ace
|
|
|
192
193
|
def bounded_blob(path, commit:, max_bytes:)
|
|
193
194
|
validate_blob_path!(path)
|
|
194
195
|
unless commit.is_a?(String) && commit.match?(/\A[0-9a-f]{40}(?:[0-9a-f]{24})?\z/) &&
|
|
195
|
-
max_bytes.is_a?(Integer) && max_bytes.between?(1,
|
|
196
|
+
max_bytes.is_a?(Integer) && max_bytes.between?(1, Atoms::CandidateLimits::BYTES)
|
|
196
197
|
raise AttemptErrors::EvidenceUnavailable, "Canonical prepared blob selector differs"
|
|
197
198
|
end
|
|
198
199
|
return @read_boundary.blob(path, commit: commit, max_bytes: max_bytes) if @read_boundary
|
|
@@ -24,7 +24,7 @@ module Ace
|
|
|
24
24
|
# @param identity_resolver [ExecutionIdentityResolver] Trust boundary
|
|
25
25
|
# @param artifact_reader [#call, nil] Source-owned canonical byte
|
|
26
26
|
# reader for protected composition; nil selects explicit local files
|
|
27
|
-
def initialize(identity_resolver: nil, artifact_reader: nil, campaign_verifier: nil)
|
|
27
|
+
def initialize(identity_resolver: nil, artifact_reader: nil, campaign_verifier: nil, review_independence_verifier: nil)
|
|
28
28
|
@identity_resolver = identity_resolver || ExecutionIdentityResolver.new
|
|
29
29
|
unless artifact_reader.nil? || artifact_reader.respond_to?(:call)
|
|
30
30
|
raise ArgumentError, "artifact reader must be a source-owned callable"
|
|
@@ -34,6 +34,10 @@ module Ace
|
|
|
34
34
|
raise ArgumentError, "campaign verifier must be a source-owned callable"
|
|
35
35
|
end
|
|
36
36
|
@campaign_verifier = campaign_verifier
|
|
37
|
+
unless review_independence_verifier.nil? || review_independence_verifier.respond_to?(:call)
|
|
38
|
+
raise ArgumentError, "review independence verifier must be a source-owned callable"
|
|
39
|
+
end
|
|
40
|
+
@review_independence_verifier = review_independence_verifier
|
|
37
41
|
end
|
|
38
42
|
|
|
39
43
|
# Validate a submitted receipt against an attempt.
|
|
@@ -222,7 +226,9 @@ module Ace
|
|
|
222
226
|
reject_unless(reviewer.is_a?(Hash) && !reviewer["actor"].to_s.strip.empty?, "review must name an executed reviewer")
|
|
223
227
|
|
|
224
228
|
producer_actor = data.dig("producer", "actor").to_s.strip
|
|
225
|
-
if
|
|
229
|
+
if @review_independence_verifier
|
|
230
|
+
reject("canonical reviewer independence is unverifiable") unless @review_independence_verifier.call(data) == true
|
|
231
|
+
elsif reviewer["actor"].to_s.strip == producer_actor
|
|
226
232
|
reject("reviewer and producer must differ (self-approval rejected)")
|
|
227
233
|
end
|
|
228
234
|
|
|
@@ -12,9 +12,10 @@ module Ace
|
|
|
12
12
|
# operation delegates to the resolved Ace::Runtime adapter; assign
|
|
13
13
|
# never calls a runtime-native API directly (k86.3 interface rule).
|
|
14
14
|
class RuntimeControlSurfaceRunner
|
|
15
|
-
def initialize(runtime: nil, env: ENV)
|
|
15
|
+
def initialize(runtime: nil, env: ENV, adapter: nil)
|
|
16
16
|
@runtime_name = runtime
|
|
17
17
|
@env = env
|
|
18
|
+
@runtime = adapter
|
|
18
19
|
end
|
|
19
20
|
|
|
20
21
|
attr_reader :runtime_name, :env
|
|
@@ -37,7 +37,7 @@ module Ace
|
|
|
37
37
|
# @param identity_resolver [Molecules::ExecutionIdentityResolver, nil] Trust boundary
|
|
38
38
|
# @param verifier [Molecules::ReceiptVerifier, nil] Receipt verifier
|
|
39
39
|
def initialize(cache_base: nil, repo_root: nil, journal: nil, identity_resolver: nil, verifier: nil,
|
|
40
|
-
lifecycle_exclusion: nil, recorded_binding_observer: nil)
|
|
40
|
+
lifecycle_exclusion: nil, recorded_binding_observer: nil, ordinary_merge_evidence: nil)
|
|
41
41
|
unless recorded_binding_observer.nil? || recorded_binding_observer.respond_to?(:call)
|
|
42
42
|
raise ArgumentError, "recorded native binding observer must be callable"
|
|
43
43
|
end
|
|
@@ -49,6 +49,7 @@ module Ace
|
|
|
49
49
|
@verifier = verifier || Molecules::ReceiptVerifier.new(identity_resolver: @identity_resolver)
|
|
50
50
|
@lifecycle_exclusion = lifecycle_exclusion
|
|
51
51
|
@recorded_binding_observer = recorded_binding_observer
|
|
52
|
+
@ordinary_merge_evidence = ordinary_merge_evidence
|
|
52
53
|
end
|
|
53
54
|
|
|
54
55
|
attr_reader :store
|
|
@@ -328,6 +329,7 @@ module Ace
|
|
|
328
329
|
# never need message-string discrimination: replay/binding conflicts
|
|
329
330
|
# stay Conflict, a consumed authorization is AuthorizationConflict.
|
|
330
331
|
guard = lambda do
|
|
332
|
+
verify_fixed_merge_evidence!(record)
|
|
331
333
|
conflict = journal_for.fixed_authorization_conflict(record)
|
|
332
334
|
if conflict
|
|
333
335
|
raise AttemptErrors::AuthorizationConflict,
|
|
@@ -335,15 +337,50 @@ module Ace
|
|
|
335
337
|
end
|
|
336
338
|
nil
|
|
337
339
|
end
|
|
338
|
-
|
|
340
|
+
with_fixed_merge_scope(record) do
|
|
341
|
+
journal_for.admit_fixed_merge_request(record, guard: guard)
|
|
342
|
+
end
|
|
339
343
|
end
|
|
340
344
|
|
|
341
345
|
# Begin the fixed effect (pending -> uncertain). The single CAS
|
|
342
346
|
# transition is the at-most-one-merge proof: a restarted receiver
|
|
343
347
|
# refuses an already-started claim and must not dispatch again.
|
|
344
348
|
def begin_fixed_merge_effect(request_id)
|
|
345
|
-
journal_for.
|
|
349
|
+
record = journal_for.fixed_merge_request(request_id)
|
|
350
|
+
raise AttemptErrors::NotFound, "fixed merge record is unavailable" unless record
|
|
351
|
+
with_fixed_merge_scope(record) do
|
|
352
|
+
journal_for.begin_fixed_merge_request(request_id, guard: -> { verify_fixed_merge_evidence!(record) })
|
|
353
|
+
end
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
# Hold the existing assignment exclusion through the external effect.
|
|
357
|
+
# Review cancellation/head changes/finish cannot race this interval.
|
|
358
|
+
def with_fixed_merge_evidence(record)
|
|
359
|
+
with_fixed_merge_scope(record, exclusive: true) do
|
|
360
|
+
verify_fixed_merge_evidence!(record)
|
|
361
|
+
yield
|
|
362
|
+
end
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
def verify_fixed_merge_evidence!(record)
|
|
366
|
+
unless @ordinary_merge_evidence
|
|
367
|
+
raise AttemptErrors::ReceiptRejected, "canonical ordinary merge evidence owner is not installed"
|
|
368
|
+
end
|
|
369
|
+
@ordinary_merge_evidence.verify_merge_evidence_at!(record: record, commit: journal_for.ref_value,
|
|
370
|
+
deadline: Process.clock_gettime(Process::CLOCK_MONOTONIC) + 30)
|
|
371
|
+
nil
|
|
372
|
+
rescue AttemptErrors::EvidenceUnavailable, AttemptErrors::UnauthorizedIdentity, AttemptErrors::Conflict, AttemptErrors::NotFound
|
|
373
|
+
raise AttemptErrors::ReceiptRejected, "canonical ordinary review or executed test evidence is unavailable"
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
def with_fixed_merge_scope(record, exclusive: false, &block)
|
|
377
|
+
unless @ordinary_merge_evidence
|
|
378
|
+
raise AttemptErrors::ReceiptRejected, "canonical ordinary merge evidence owner is not installed"
|
|
379
|
+
end
|
|
380
|
+
@ordinary_merge_evidence.with_merge_scope(record: record, exclusive: exclusive,
|
|
381
|
+
deadline: Process.clock_gettime(Process::CLOCK_MONOTONIC) + 30, &block)
|
|
346
382
|
end
|
|
383
|
+
private :with_fixed_merge_scope
|
|
347
384
|
|
|
348
385
|
# Settle the fixed effect with the exact terminal receipt. The
|
|
349
386
|
# journal validates the closed receipt schema and its binding to the
|