ace-lab 0.5.2 → 0.6.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fc5e4fcb4d6a771437705ab78e363747cd97d78ce6d32c6a2771b4ef4343af74
4
- data.tar.gz: 22de06526b637c15a791944e4594ea94f309e57241ccdc51ae1cd91bbfc98914
3
+ metadata.gz: 1ad491380d732534c8ae2cbabe68bb796cf997adb19ff6dbdb4b6b66353470c6
4
+ data.tar.gz: c1ba284a9cf628746450b50e1ecf6dcc03589a771fc6f1d44c2eded6afa24861
5
5
  SHA512:
6
- metadata.gz: 599fc84b935b3632918f7ab8546ee4f0d7dd59fb8ea0f7933e660176403d19508e468b9153873a132beec1eb89eecbff0341339b01f9da0629f16a94c3300e93
7
- data.tar.gz: cd1758fafa5b8c944fd8b0de736b0d8137ed2b4522ffe067c8bbcad5a5be817e6176fd5a571d6410fc144cf03d88bccbb91d5c7872e9b6c8b29f85066f3c4c81
6
+ metadata.gz: d6c8605dabf1a3f4bdac9f39a8afeac4c606c30832380f4b93921fc91d9bcd3ac364d1056fbb426130d25bf23410abd29cbd1dd80ec31ad99a563fe224f2c3bc
7
+ data.tar.gz: d8ca70d0072169a318b7bf2cc57d291f6d9db14250c5af774920e0fb212735570ede41fdc360c1e099f8a41d4de43355bd1e7e6a7aca8a86fef8a21289ba04de
data/CHANGELOG.md CHANGED
@@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.6.0] - 2026-10-14
11
+
12
+ ### Added
13
+
14
+ - Add the ordinary fixed merge producer mode (`ace-lab service request/status --mode ordinary-merge`) per xz9.4: durable-once admission that issues the opaque reference only through the committed journal record, canonical SHA-256 request digests over the full eight-field binding, identical-replay acknowledgment, authorization/binding conflict refusals, read-only status, and terminal receipts bound to the admitted record and its execution identity.
15
+
10
16
  ## [0.5.2] - 2026-10-11
11
17
 
12
18
  ### Changed
@@ -2,8 +2,10 @@
2
2
 
3
3
  require "json"
4
4
  require "ace/support/cli"
5
+ require "ace/assign"
5
6
  require_relative "support"
6
7
  require_relative "../../organisms/protected_service_request"
8
+ require_relative "../../organisms/ordinary_merge_request"
7
9
 
8
10
  module Ace
9
11
  module Lab
@@ -20,7 +22,7 @@ module Ace
20
22
  end
21
23
 
22
24
  def protected_refusal(error)
23
- code = error.is_a?(ArgumentError) || error.is_a?(SecurityError) ? "protected_service_refused" : "protected_service_unavailable"
25
+ code = (error.is_a?(ArgumentError) || error.is_a?(SecurityError)) ? "protected_service_refused" : "protected_service_unavailable"
24
26
  message = error.is_a?(JSON::ParserError) ? "protected installed selection unavailable" : error.message.to_s.scrub[0, 512]
25
27
  emit({"status" => "error", "error" => {"code" => code, "message" => message}})
26
28
  end
@@ -49,6 +51,8 @@ module Ace
49
51
  option :authorization, type: :string, required: true, desc: "Exact authorization reference"
50
52
  option :request_id, type: :string, required: true, desc: "Idempotency key"
51
53
  option :dry_run, type: :boolean, desc: "Validate without claiming or executing"
54
+ option :mode, type: :string, desc: "ordinary-merge selects the ordinary fixed merge producer"
55
+ option :queue_entry, type: :string, desc: "Original queue entry (ordinary merge)"
52
56
 
53
57
  option :mapping, type: :string, desc: "Original protected mapping ID"
54
58
  option :scope, type: :string, desc: "Original hierarchical prepared scope"
@@ -59,6 +63,26 @@ module Ace
59
63
 
60
64
  def call(project:, assignment:, attempt:, operation:, input:, authorization:, request_id:, **options)
61
65
  reject_identity_flags!(options)
66
+ mode = options[:mode]
67
+ if mode && mode != Organisms::OrdinaryMergeRequest::MODE
68
+ return emit(Organisms::OrdinaryMergeRequest.refusal_envelope("ordinary_merge_invalid_request",
69
+ "unknown service mode", request_id: request_id, input_digest: nil))
70
+ end
71
+ if mode
72
+ begin
73
+ return emit(ordinary_request(project: project, assignment: assignment, attempt: attempt,
74
+ queue_entry: options[:queue_entry], operation: operation, input: input,
75
+ authorization: authorization, request_id: request_id, **options))
76
+ rescue Ace::Assign::AttemptErrors::EvidenceUnavailable, Ace::Lab::InvalidConfigurationError => e
77
+ # Infra failures (unreadable canonical evidence, malformed
78
+ # installed configuration, unavailable topology) are the
79
+ # caller-side blocker, never a raw crash and never a durable
80
+ # reference (review r1 finding B).
81
+ return emit(Organisms::OrdinaryMergeRequest.refusal_envelope(
82
+ "ordinary_merge_unavailable", e.message, request_id: request_id, input_digest: nil
83
+ ))
84
+ end
85
+ end
62
86
  selected = protected_service.selected?(options)
63
87
  if selected
64
88
  return emit(protected_service.request(project: project, assignment: assignment, attempt: attempt,
@@ -68,9 +92,21 @@ module Ace
68
92
  operation: operation, input_path: input, authorization: authorization,
69
93
  request_id: request_id, dry_run: options[:dry_run]))
70
94
  rescue ArgumentError, Ace::Assign::Error, Ace::Git::Error, Ace::Runtime::RuntimeUnavailableError, JSON::ParserError, SecurityError, KeyError, SystemCallError => error
71
- raise if selected == false
95
+ raise if selected == false || mode
72
96
  protected_refusal(error)
73
97
  end
98
+
99
+ private
100
+
101
+ def ordinary_producer
102
+ @ordinary_producer ||= Organisms::OrdinaryMergeRequest.new
103
+ end
104
+
105
+ # The ordinary refusal envelope is emitted exactly as received;
106
+ # it is classified for the non-zero exit like every refusal.
107
+ def ordinary_request(**arguments)
108
+ ordinary_producer.request(**arguments)
109
+ end
74
110
  end
75
111
 
76
112
  class Status < Ace::Support::Cli::Command
@@ -81,6 +117,9 @@ module Ace
81
117
  desc "Read the authoritative outcome of a service request"
82
118
  option :request, type: :string, required: true, desc: "Request ID"
83
119
  option :format, type: :string, desc: "Output format (json only)"
120
+ option :mode, type: :string, desc: "ordinary-merge selects the read-only ordinary status"
121
+ option :reference, type: :string, desc: "Opaque ordinary merge reference"
122
+ option :queue_entry, type: :string, desc: "Original queue entry (ordinary merge)"
84
123
 
85
124
  option :project, type: :string, desc: "Original project ID"
86
125
  option :assignment, type: :string, desc: "Original assignment ID"
@@ -95,16 +134,40 @@ module Ace
95
134
 
96
135
  def call(request:, **options)
97
136
  reject_identity_flags!(options)
98
- ensure_json_format!(options)
137
+ mode = options[:mode]
138
+ if mode && mode != Organisms::OrdinaryMergeRequest::MODE
139
+ return emit(Organisms::OrdinaryMergeRequest.refusal_envelope("ordinary_merge_invalid_request",
140
+ "unknown service mode", request_id: request, input_digest: nil,
141
+ schema: Ace::Assign::Atoms::FixedMergeSchemas::STATUS_ERROR_SCHEMA_ID))
142
+ end
143
+ if mode
144
+ ensure_json_format!(options)
145
+ begin
146
+ return emit(ordinary_producer.status(project: options[:project], assignment: options[:assignment],
147
+ attempt: options[:attempt], queue_entry: options[:queue_entry], reference: options[:reference],
148
+ request: request, input_digest: options[:input_digest], **options))
149
+ rescue Ace::Assign::AttemptErrors::EvidenceUnavailable, Ace::Lab::InvalidConfigurationError => e
150
+ return emit(Organisms::OrdinaryMergeRequest.refusal_envelope(
151
+ "ordinary_merge_unavailable", e.message, request_id: request, input_digest: nil,
152
+ schema: Ace::Assign::Atoms::FixedMergeSchemas::STATUS_ERROR_SCHEMA_ID
153
+ ))
154
+ end
155
+ end
99
156
  selected = protected_service.selected?(options)
100
157
  if selected
101
158
  return emit(protected_service.status(request_id: request, **options))
102
159
  end
103
160
  emit(service.status(request_id: request))
104
161
  rescue ArgumentError, Ace::Assign::Error, Ace::Git::Error, Ace::Runtime::RuntimeUnavailableError, JSON::ParserError, SecurityError, KeyError, SystemCallError => error
105
- raise if selected == false
162
+ raise if selected == false || mode
106
163
  protected_refusal(error)
107
164
  end
165
+
166
+ private
167
+
168
+ def ordinary_producer
169
+ @ordinary_producer ||= Organisms::OrdinaryMergeRequest.new
170
+ end
108
171
  end
109
172
  end
110
173
  end
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "socket"
5
+
6
+ module Ace
7
+ module Lab
8
+ module Molecules
9
+ # Admission transport between the ordinary fixed merge producer and the
10
+ # installed ordinary receiver: one bounded JSON document each way over
11
+ # a configured unix stream socket with a fixed deadline. Authentication
12
+ # is mutual through SO_PEERCRED — the producer verifies the receiver's
13
+ # configured UID and transmits the verified local caller identity, so
14
+ # no caller-selected credential, token or endpoint exists. This is not
15
+ # an HTTP transport; a lost or unauthenticated response is a
16
+ # caller-side blocker (ordinary_merge_unavailable), never a refusal.
17
+ class OrdinarySocket
18
+ LIMIT = 16_384
19
+ HANDSHAKE = "ace-lab/ordinary-merge/v1"
20
+
21
+ # @param socket_path [String] installed receiver endpoint (configuration)
22
+ # @param receiver_uid [Integer] the only UID the receiver may run as
23
+ # @param deadline [Time] absolute deadline for connect/write/read
24
+ def initialize(socket_path:, receiver_uid:, deadline: Time.now.utc + 30)
25
+ @socket_path = socket_path
26
+ @receiver_uid = receiver_uid
27
+ @deadline = deadline
28
+ end
29
+
30
+ # Submit one bounded request document; returns the parsed response
31
+ # document. Any framing, deadline or identity failure raises
32
+ # OrdinaryUnavailable so the producer can classify the blocker.
33
+ def submit(document)
34
+ payload = JSON.generate(document)
35
+ raise OrdinaryUnavailable, "request document exceeds the wire bound" if payload.bytesize > LIMIT
36
+ socket = Socket.new(:UNIX, :STREAM, 0)
37
+ begin
38
+ socket.connect(Addrinfo.unix(@socket_path))
39
+ verify_peer!(socket)
40
+ write_all!(socket, "#{HANDSHAKE} #{payload.bytesize}\n#{payload}")
41
+ response = read_response!(socket)
42
+ rescue OrdinaryUnavailable
43
+ raise
44
+ rescue SystemCallError, IOError => e
45
+ raise OrdinaryUnavailable, "receiver transport failed: #{e.class}"
46
+ ensure
47
+ begin
48
+ socket.close
49
+ rescue IOError
50
+ nil
51
+ end
52
+ end
53
+ response
54
+ end
55
+
56
+ class OrdinaryUnavailable < ::StandardError; end
57
+
58
+ private
59
+
60
+ def verify_peer!(socket)
61
+ credentials = socket.getsockopt(:SOCKET, :PEERCRED)
62
+ # struct ucred is {pid, uid, gid} on Linux; the producer binds the
63
+ # receiver to its installed UID and nothing else.
64
+ _peer_pid, peer_uid, = credentials.unpack("L3")
65
+ return if peer_uid == @receiver_uid
66
+ raise OrdinaryUnavailable, "receiver endpoint identity is not the installed receiver"
67
+ end
68
+
69
+ def write_all!(socket, bytes)
70
+ written = 0
71
+ while written < bytes.bytesize
72
+ begin
73
+ raise OrdinaryUnavailable, "receiver write deadline expired" if expired?
74
+ written += socket.write_nonblock(bytes.byteslice(written..))
75
+ rescue IO::WaitWritable
76
+ socket.wait_writable(remaining!) or raise OrdinaryUnavailable, "receiver write deadline expired"
77
+ end
78
+ end
79
+ end
80
+
81
+ def read_response!(socket)
82
+ header = read_line!(socket)
83
+ match = /\A#{Regexp.escape(HANDSHAKE)} (\d{1,5})\n\z/o.match(header)
84
+ raise OrdinaryUnavailable, "receiver response header is invalid" unless match
85
+ length = Integer(match[1])
86
+ raise OrdinaryUnavailable, "receiver response exceeds the wire bound" if length > LIMIT
87
+ body = read_exact!(socket, length)
88
+ JSON.parse(body, allow_duplicate_key: false, create_additions: false, max_nesting: 16)
89
+ rescue JSON::ParserError
90
+ raise OrdinaryUnavailable, "receiver response is not valid JSON"
91
+ end
92
+
93
+ def read_line!(socket)
94
+ line = +""
95
+ until line.end_with?("\n")
96
+ raise OrdinaryUnavailable, "receiver response header is unbounded" if line.bytesize > 128
97
+ line << read_exact!(socket, 1)
98
+ end
99
+ line
100
+ end
101
+
102
+ def read_exact!(socket, length)
103
+ buffer = +""
104
+ while buffer.bytesize < length
105
+ begin
106
+ raise OrdinaryUnavailable, "receiver read deadline expired" if expired?
107
+ chunk = socket.read_nonblock(length - buffer.bytesize)
108
+ raise OrdinaryUnavailable, "receiver closed the connection" if chunk.nil?
109
+ buffer << chunk
110
+ rescue IO::WaitReadable
111
+ socket.wait_readable(remaining!) or raise OrdinaryUnavailable, "receiver read deadline expired"
112
+ end
113
+ end
114
+ buffer
115
+ end
116
+
117
+ def expired?
118
+ Time.now.utc >= @deadline
119
+ end
120
+
121
+ def remaining!
122
+ left = @deadline - Time.now.utc
123
+ raise OrdinaryUnavailable, "receiver deadline expired" if left <= 0
124
+ left
125
+ end
126
+ end
127
+ end
128
+ end
129
+ end
@@ -15,6 +15,7 @@ module Ace
15
15
  ID = /\A[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}\z/
16
16
 
17
17
  def initialize(document, proposal_resolver = nil)
18
+ @document = document
18
19
  @operations = document.fetch("operations", {})
19
20
  @authorizations = document.fetch("authorizations", {})
20
21
  @proposal_resolver = proposal_resolver
@@ -23,6 +24,10 @@ module Ace
23
24
  end
24
25
  end
25
26
 
27
+ # The trusted installation document as loaded; the ordinary producer
28
+ # binds its configuration digest to these exact bytes.
29
+ attr_reader :document
30
+
26
31
  def operation!(name, project:, service_id:)
27
32
  raise ArgumentError, "invalid operation name" unless name.to_s.match?(NAME)
28
33
  operation = @operations[name]
@@ -101,6 +106,72 @@ module Ace
101
106
  decision
102
107
  end
103
108
 
109
+ # ---- ordinary fixed merge admission policy (spec 8x3.t.xz9.4) ------
110
+ #
111
+ # The installed ordinary section is the separately configured
112
+ # ordinary receiver mapping and admission policy. It supplies
113
+ # identity and placement only: the caller selects no endpoint,
114
+ # credential or receiver. An absent project section or a disabled
115
+ # admission flag is rollback: new admission is unavailable while
116
+ # canonical records and read-only status stay preserved.
117
+ ORDINARY_SECTION_KEYS = %w[projects].freeze
118
+ ORDINARY_PROJECT_KEYS = %w[receiver_id socket_path receiver_uid principal admission].freeze
119
+ ORDINARY_AUTHORIZATION_KEYS = %w[operation project_id assignment_id attempt_id queue_entry
120
+ input_digest forge pr_url head_repository head_ref candidate_head base_repository
121
+ base_ref expected_base_head expires_at].freeze
122
+ # expires_at bounds the decision itself; it is never exact-matched
123
+ # against the request binding, which carries no expiry.
124
+ ORDINARY_BINDING_KEYS = ORDINARY_AUTHORIZATION_KEYS - %w[expires_at].freeze
125
+
126
+ def ordinary_receiver!(project)
127
+ section = @document["ordinary"]
128
+ return nil unless section.is_a?(Hash)
129
+ validate_ordinary_section!(section)
130
+ entry = section.fetch("projects")[project.to_s]
131
+ return nil unless entry.is_a?(Hash)
132
+ entry
133
+ end
134
+
135
+ # The ordinary admission decision: exact-match on every closed
136
+ # binding key, like authorize!, but over the ordinary request
137
+ # identity (forge/PR/repository/ref) instead of execution selectors.
138
+ def authorize_ordinary!(reference, binding)
139
+ raise ArgumentError, "invalid authorization reference" unless reference.to_s.match?(ID)
140
+ decision = @authorizations[reference]
141
+ raise SecurityError, "authorization reference is unresolved" unless decision.is_a?(Hash)
142
+ unless ORDINARY_AUTHORIZATION_KEYS.all? { |key| decision.key?(key) }
143
+ raise SecurityError, "authorization reference is not an ordinary merge decision"
144
+ end
145
+ unless ORDINARY_BINDING_KEYS.all? { |key| decision[key] == binding[key] }
146
+ raise SecurityError, "authorization does not match the exact ordinary merge request"
147
+ end
148
+ raise SecurityError, "authorization has expired" if parse_time(decision["expires_at"]) <= Time.now.utc
149
+ decision
150
+ end
151
+
152
+ def validate_ordinary_section!(section)
153
+ unless (section.keys - ORDINARY_SECTION_KEYS).empty? && section["projects"].is_a?(Hash)
154
+ raise Ace::Lab::InvalidConfigurationError, "ordinary section must map projects"
155
+ end
156
+ section.fetch("projects").each do |project, entry|
157
+ unless project.is_a?(String) && project.match?(ID) && entry.is_a?(Hash) &&
158
+ (entry.keys - ORDINARY_PROJECT_KEYS).empty?
159
+ raise Ace::Lab::InvalidConfigurationError, "ordinary project section is invalid"
160
+ end
161
+ unless entry["receiver_id"].is_a?(String) && entry["receiver_id"].match?(ID) &&
162
+ entry["socket_path"].is_a?(String) && entry["socket_path"].start_with?("/") &&
163
+ entry["receiver_uid"].is_a?(Integer) && entry["receiver_uid"] >= 0 &&
164
+ entry["principal"].is_a?(String) && !entry["principal"].empty?
165
+ raise Ace::Lab::InvalidConfigurationError, "ordinary receiver mapping is incomplete"
166
+ end
167
+ admission = entry.fetch("admission", "enabled")
168
+ unless %w[enabled disabled].include?(admission)
169
+ raise Ace::Lab::InvalidConfigurationError, "ordinary admission flag is invalid"
170
+ end
171
+ end
172
+ true
173
+ end
174
+
104
175
  # Inspection never renews effect permission or selects effect argv.
105
176
  def inspection!(name, project:, service_id:, executor_uid:)
106
177
  raise ArgumentError, "invalid inspection operation" unless name.is_a?(String) && name.match?(NAME)
@@ -0,0 +1,422 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "json"
5
+ require "open3"
6
+ require_relative "protected_service_request"
7
+ require_relative "../molecules/ordinary_socket"
8
+ require "ace/assign/atoms/fixed_merge_schemas"
9
+
10
+ module Ace
11
+ module Lab
12
+ module Organisms
13
+ # The ordinary fixed merge admission producer (spec 8x3.t.xz9.4). One
14
+ # mode of the maintained `service request/status` vocabulary: it
15
+ # canonicalizes the immutable request binding, authenticates the
16
+ # verified project overseer against the installed ordinary mapping,
17
+ # resolves the named queue entry and attempt, transports the request
18
+ # to the configured ordinary receiver, and emits the adopted ace-lab
19
+ # envelopes — never a second protocol. Refusals are pre-effect and
20
+ # carry no durable reference; unavailability is a caller-side blocker;
21
+ # the canonical Assign attempt/evidence state stays the sole
22
+ # queue/effect record, so this class owns no ledger.
23
+ class OrdinaryMergeRequest
24
+ MODE = "ordinary-merge"
25
+ OPERATION = "merge"
26
+
27
+ # The closed pre-effect refusal codes (exhaustive pre-effect mapping).
28
+ CODES = %w[ordinary_merge_invalid_request ordinary_merge_unauthenticated
29
+ ordinary_merge_forbidden ordinary_merge_selection_mismatch
30
+ ordinary_merge_authorization_invalid ordinary_merge_provenance_mismatch
31
+ ordinary_merge_target_changed ordinary_merge_evidence_invalid
32
+ ordinary_merge_request_conflict ordinary_merge_unavailable].freeze
33
+
34
+ # Selector keys this producer accepts; anything else — including the
35
+ # protected mapping/scope/service/generation selectors and any
36
+ # executable/argv/UID/socket/evidence-path/credential/token selector
37
+ # — refuses before effect.
38
+ REQUEST_KEYS = %i[project assignment attempt queue_entry operation input authorization
39
+ request_id mode].freeze
40
+ STATUS_KEYS = %i[project assignment attempt queue_entry reference request input_digest
41
+ mode format].freeze
42
+
43
+ ID = Molecules::ServicePolicy::ID
44
+
45
+ def initialize(topology: nil, policy: nil, coordinator: nil, repo_root: nil, ingress: nil)
46
+ @topology = topology || TopologyService.from_config
47
+ @repo_root = repo_root || git_toplevel
48
+ @coordinator = coordinator ||
49
+ Ace::Assign::Organisms::AttemptCoordinator.new(repo_root: @repo_root)
50
+ @policy = policy || Molecules::ServicePolicy.new(
51
+ Molecules::GrantResolver.trusted_document(Ace::Lab.authorization_path)
52
+ )
53
+ @ingress = ingress
54
+ end
55
+
56
+ def request(project:, assignment:, attempt:, queue_entry:, operation:, input:,
57
+ authorization:, request_id:, **options)
58
+ digest = nil
59
+ unless (options.keys - REQUEST_KEYS).empty?
60
+ invalid!("unknown request selector: #{unknown(options.keys, REQUEST_KEYS)}", request_id)
61
+ end
62
+ invalid!("operation must be merge", request_id) unless operation == OPERATION
63
+ identifiers!(project: project, assignment: assignment, attempt: attempt,
64
+ queue_entry: queue_entry, request_id: request_id)
65
+ unless authorization.is_a?(String) && authorization.match?(ID)
66
+ invalid!("invalid authorization reference", request_id)
67
+ end
68
+ document = load_request_document(input, request_id)
69
+ validate_document!(document, request_id)
70
+ digest = input_digest(project: project, assignment: assignment, attempt: attempt,
71
+ queue_entry: queue_entry, authorization: authorization, request_id: request_id,
72
+ document: document)
73
+ principal = authenticate!(project)
74
+ existing = @coordinator.fixed_merge_record(request_id)
75
+ if existing
76
+ return ack_envelope(existing) if exact_replay?(existing, project: project,
77
+ assignment: assignment, attempt: attempt, queue_entry: queue_entry,
78
+ authorization: authorization, digest: digest)
79
+ conflict!("request ID already exists with a different binding", request_id, digest)
80
+ end
81
+ receiver = receiver_mapping!(project)
82
+ resolve_selection!(project: project, assignment: assignment, attempt: attempt,
83
+ queue_entry: queue_entry)
84
+ authorize!(authorization, project: project, assignment: assignment, attempt: attempt,
85
+ queue_entry: queue_entry, document: document, digest: digest)
86
+ verify_candidate_head!(document)
87
+ submission = {"project_id" => project, "assignment_id" => assignment,
88
+ "attempt_id" => attempt, "queue_entry" => queue_entry, "operation" => OPERATION,
89
+ "authorization" => authorization, "request_id" => request_id,
90
+ "input_digest" => digest, "request" => document, "principal" => principal,
91
+ "receiver_id" => receiver.fetch("receiver_id"),
92
+ "configuration_sha256" => receiver.fetch("configuration_sha256")}
93
+ response = deliver(receiver, submission)
94
+ normalize_response(response, request_id: request_id, input_digest: digest)
95
+ rescue OrdinaryMergeRefusal => e
96
+ self.class.refusal_envelope(e.code, e.detail, request_id: e.request_id || request_id,
97
+ input_digest: e.input_digest || digest)
98
+ rescue Ace::Assign::AttemptErrors::AuthorizationConflict => e
99
+ self.class.refusal_envelope("ordinary_merge_authorization_invalid", e.message,
100
+ request_id: request_id, input_digest: digest)
101
+ rescue Ace::Assign::AttemptErrors::Conflict => e
102
+ self.class.refusal_envelope("ordinary_merge_request_conflict", e.message,
103
+ request_id: request_id, input_digest: digest)
104
+ rescue Molecules::OrdinarySocket::OrdinaryUnavailable => e
105
+ self.class.refusal_envelope("ordinary_merge_unavailable", e.message,
106
+ request_id: request_id, input_digest: digest)
107
+ rescue Ace::Assign::AttemptErrors::EvidenceUnavailable, Ace::Lab::InvalidConfigurationError => e
108
+ # Canonical evidence or installed configuration is unreadable: a
109
+ # caller-side blocker (endpoint/configuration failed), never a
110
+ # refusal and never a durable reference.
111
+ self.class.refusal_envelope("ordinary_merge_unavailable", e.message,
112
+ request_id: request_id, input_digest: digest)
113
+ end
114
+
115
+ # Read-only status: resolve the opaque reference against the canonical
116
+ # record with every selector; performs no dispatch, retry,
117
+ # authorization refresh or selector repair. Read-only status survives
118
+ # rollback and receiver downtime because the canonical Assign state —
119
+ # never a receiver — owns the record.
120
+ def status(project:, assignment:, attempt:, queue_entry:, reference:, request:,
121
+ input_digest:, **options)
122
+ unless (options.keys - STATUS_KEYS).empty?
123
+ return status_error(self.class.refusal_envelope("ordinary_merge_invalid_request",
124
+ "unknown status selector: #{unknown(options.keys, STATUS_KEYS)}",
125
+ request_id: request, input_digest: nil,
126
+ schema: Ace::Assign::Atoms::FixedMergeSchemas::STATUS_ERROR_SCHEMA_ID))
127
+ end
128
+ identifiers!(project: project, assignment: assignment, attempt: attempt,
129
+ queue_entry: queue_entry, request_id: request)
130
+ unless options[:format] == "json"
131
+ invalid!("status requires --format json", request)
132
+ end
133
+ unless reference.is_a?(String) && Ace::Assign::Molecules::FixedEffectReference.valid?(reference)
134
+ invalid!("invalid reference", request)
135
+ end
136
+ unless input_digest.is_a?(String) && input_digest.match?(/\A[0-9a-f]{64}\z/)
137
+ invalid!("invalid input digest", request)
138
+ end
139
+ authenticate!(project)
140
+ record = @coordinator.fixed_merge_status(reference: reference, request_id: request,
141
+ project_id: project, assignment_id: assignment, attempt_id: attempt,
142
+ queue_entry: queue_entry, input_digest: input_digest)
143
+ # Unknown or mismatched references answer identically, without
144
+ # revealing whether another project holds the reference.
145
+ return status_error(selection_error) unless record
146
+ {"status" => "ok", "data" => {
147
+ "schema" => Ace::Assign::Atoms::FixedMergeSchemas::STATUS_SCHEMA_ID,
148
+ "state" => record.fetch("state"),
149
+ "reference" => record.fetch("reference"),
150
+ "request_id" => record.fetch("request_id"),
151
+ "input_digest" => record.fetch("input_digest"),
152
+ "receipt" => %w[pending uncertain].include?(record.fetch("state")) ? nil : record["receipt"]
153
+ }}
154
+ rescue OrdinaryMergeRefusal => e
155
+ status_error(self.class.refusal_envelope(e.code, e.detail, request_id: request, input_digest: nil,
156
+ schema: Ace::Assign::Atoms::FixedMergeSchemas::STATUS_ERROR_SCHEMA_ID))
157
+ rescue Ace::Assign::AttemptErrors::EvidenceUnavailable, Ace::Lab::InvalidConfigurationError => e
158
+ # Canonical state or installed configuration unreadable: the status
159
+ # command degrades to the caller-side blocker envelope, never to a
160
+ # fabricated state or a raw crash.
161
+ status_error(self.class.refusal_envelope("ordinary_merge_unavailable", e.message,
162
+ request_id: request, input_digest: nil,
163
+ schema: Ace::Assign::Atoms::FixedMergeSchemas::STATUS_ERROR_SCHEMA_ID))
164
+ end
165
+
166
+ # ---- envelope composition (the adopted ace-lab vocabulary) ---------
167
+
168
+ def ack_envelope(record)
169
+ {"status" => "ok", "data" => {
170
+ "schema" => Ace::Assign::Atoms::FixedMergeSchemas::ACK_SCHEMA_ID,
171
+ "type" => "accepted",
172
+ "request_id" => record.fetch("request_id"),
173
+ "input_digest" => record.fetch("input_digest"),
174
+ "reference" => record.fetch("reference")
175
+ }}
176
+ end
177
+
178
+ def self.sanitize(message)
179
+ cleaned = message.to_s.scrub.gsub(/\s+/, " ").strip[0, 512]
180
+ cleaned.empty? ? "ordinary merge request refused" : cleaned
181
+ end
182
+
183
+ # Envelope composition is instance-independent; the CLI reuses it for
184
+ # mode-selection refusals before any producer exists.
185
+ def self.refusal_envelope(code, message, request_id:, input_digest:,
186
+ schema: Ace::Assign::Atoms::FixedMergeSchemas::REFUSAL_SCHEMA_ID)
187
+ raise ArgumentError, "unknown ordinary merge refusal code" unless CODES.include?(code)
188
+ echo = request_id.to_s[0, 256]
189
+ echo = "unknown" if echo.empty?
190
+ {"status" => "error", "error" => {
191
+ "schema" => schema,
192
+ "code" => code,
193
+ "message" => sanitize(message),
194
+ "request_id" => echo,
195
+ "input_digest" => input_digest
196
+ }}
197
+ end
198
+
199
+ private
200
+
201
+ # The immutable binding digest — spec ruling r4: lowercase hex SHA-256
202
+ # over the canonical JSON (recursively key-sorted, compact, UTF-8) of
203
+ # the full eight-field binding. Computed once here at admission; the
204
+ # same shared implementation re-derives it for replay and receipt
205
+ # verification, so the pair can never diverge.
206
+ def input_digest(project:, assignment:, attempt:, queue_entry:, authorization:,
207
+ request_id:, document:)
208
+ Ace::Assign::Atoms::FixedMergeSchemas.canonical_request_digest(
209
+ project: project, assignment: assignment, attempt: attempt,
210
+ queue_entry: queue_entry, authorization: authorization,
211
+ request_id: request_id, input: document
212
+ )
213
+ end
214
+
215
+ def validate_document!(document, request_id)
216
+ Ace::Assign::Atoms::FixedMergeSchemas.validate_request!(document)
217
+ Ace::Assign::Atoms::FixedMergeSchemas.require_request_joins!(document)
218
+ rescue Ace::Assign::Atoms::FixedMergeSchemas::SchemaError => e
219
+ invalid!(e.message, request_id)
220
+ end
221
+
222
+ # The verified local process owner is the only caller identity; it
223
+ # must be the configured ordinary project overseer principal.
224
+ def authenticate!(project)
225
+ identities = Molecules::CallerAuthorizer.local_identity
226
+ refused("ordinary_merge_unauthenticated", "verified local identity is unavailable") if identities.empty?
227
+ receiver = @policy.ordinary_receiver!(project)
228
+ unless receiver.is_a?(Hash) && identities.include?(receiver.fetch("principal"))
229
+ refused("ordinary_merge_forbidden",
230
+ "authenticated principal is not the configured ordinary project overseer")
231
+ end
232
+ receiver.fetch("principal")
233
+ end
234
+
235
+ # The installed ordinary section supplies identity and placement only.
236
+ # A disabled or absent admission mapping is rollback: a caller-side
237
+ # blocker that never redirects ordinary callers and never deletes the
238
+ # canonical records — read-only status stays fully available.
239
+ def receiver_mapping!(project)
240
+ receiver = @policy.ordinary_receiver!(project)
241
+ if receiver.nil? || receiver.fetch("admission", "enabled") != "enabled"
242
+ raise Molecules::OrdinarySocket::OrdinaryUnavailable,
243
+ "ordinary merge admission is not available in the installed configuration"
244
+ end
245
+ receiver.merge("configuration_sha256" => Atoms::ServiceInput.digest(@policy.document))
246
+ end
247
+
248
+ def resolve_selection!(project:, assignment:, attempt:, queue_entry:)
249
+ @coordinator.service_attempt("assignment_id" => assignment, "attempt_id" => attempt,
250
+ "project_id" => project)
251
+ rescue Ace::Assign::AttemptErrors::NotFound, Ace::Assign::AttemptErrors::ReceiptRejected,
252
+ Ace::Assign::AttemptErrors::InvalidState => e
253
+ refused("ordinary_merge_selection_mismatch", e.message)
254
+ end
255
+
256
+ def authorize!(authorization, project:, assignment:, attempt:, queue_entry:, document:, digest:)
257
+ binding = {"operation" => OPERATION, "project_id" => project,
258
+ "assignment_id" => assignment, "attempt_id" => attempt, "queue_entry" => queue_entry,
259
+ "input_digest" => digest, "forge" => document.fetch("forge"),
260
+ "pr_url" => document.fetch("pr_url"), "head_repository" => document.fetch("head_repository"),
261
+ "head_ref" => document.fetch("head_ref"), "candidate_head" => document.fetch("candidate_head"),
262
+ "base_repository" => document.fetch("base_repository"), "base_ref" => document.fetch("base_ref"),
263
+ "expected_base_head" => document.fetch("expected_base_head")}
264
+ @policy.authorize_ordinary!(authorization, binding)
265
+ rescue SecurityError => e
266
+ # Spec table (ruling r4): the authorization decision or reference is
267
+ # invalid for this request — missing, expired, revoked, out of scope,
268
+ # bound to another operation/project/PR/head, or not an ordinary
269
+ # merge decision at all. Authorization-decision mismatches NEVER use
270
+ # provenance_mismatch; that code is reserved for registered
271
+ # provenance drift verified by the receiver (and relayed verbatim).
272
+ refused("ordinary_merge_authorization_invalid", e.message)
273
+ end
274
+
275
+ def verify_candidate_head!(document)
276
+ unless current_head == document.fetch("candidate_head")
277
+ refused("ordinary_merge_target_changed", "candidate head moved from the immutable request")
278
+ end
279
+ end
280
+
281
+ def deliver(receiver, submission)
282
+ ingress = @ingress || default_ingress(receiver)
283
+ ingress.call(submission: submission)
284
+ end
285
+
286
+ def default_ingress(receiver)
287
+ socket = Molecules::OrdinarySocket.new(socket_path: receiver.fetch("socket_path"),
288
+ receiver_uid: receiver.fetch("receiver_uid"))
289
+ ->(submission:) { socket.submit(submission) }
290
+ end
291
+
292
+ # A valid receiver acknowledgment is relayed exactly; a valid receiver
293
+ # refusal is relayed with its closed code; anything else is a lost or
294
+ # unauthenticated response — a caller-side blocker, never a proven
295
+ # refusal and never a resubmission authorization.
296
+ def normalize_response(response, request_id:, input_digest:)
297
+ unless response.is_a?(Hash) && (response.keys - %w[status data error]).empty? &&
298
+ %w[ok error].include?(response["status"])
299
+ raise Molecules::OrdinarySocket::OrdinaryUnavailable, "receiver response envelope is invalid"
300
+ end
301
+ return normalize_ack(response, request_id: request_id, input_digest: input_digest) if
302
+ response.fetch("status") == "ok"
303
+ normalize_refusal(response, request_id: request_id, input_digest: input_digest)
304
+ end
305
+
306
+ def normalize_ack(response, request_id:, input_digest:)
307
+ data = response.fetch("data") { {} }
308
+ expected = %w[schema type request_id input_digest reference]
309
+ digest = data["input_digest"]
310
+ unless data.is_a?(Hash) && (data.keys - expected).empty? &&
311
+ expected.all? { |key| data[key].is_a?(String) && !data[key].empty? } &&
312
+ data.fetch("schema") == Ace::Assign::Atoms::FixedMergeSchemas::ACK_SCHEMA_ID &&
313
+ data.fetch("type") == "accepted" && data.fetch("request_id") == request_id &&
314
+ digest == input_digest && digest.match?(/\A[0-9a-f]{64}\z/) &&
315
+ Ace::Assign::Molecules::FixedEffectReference.valid?(data.fetch("reference"))
316
+ raise Molecules::OrdinarySocket::OrdinaryUnavailable,
317
+ "receiver acknowledgment is invalid or lost before durability could be confirmed"
318
+ end
319
+ {"status" => "ok", "data" => data.slice(*expected)}
320
+ end
321
+
322
+ def normalize_refusal(response, request_id:, input_digest:)
323
+ error = response.fetch("error") { {} }
324
+ expected = %w[schema code message request_id input_digest]
325
+ valid = error.is_a?(Hash) && (error.keys - expected).empty? &&
326
+ error.fetch("schema") == Ace::Assign::Atoms::FixedMergeSchemas::REFUSAL_SCHEMA_ID &&
327
+ CODES.include?(error.fetch("code")) &&
328
+ error.fetch("message").is_a?(String) && !error.fetch("message").empty? &&
329
+ error.fetch("message").length <= 512 &&
330
+ error.fetch("request_id") == request_id &&
331
+ (error.fetch("input_digest").nil? || error.fetch("input_digest") == input_digest)
332
+ unless valid
333
+ raise Molecules::OrdinarySocket::OrdinaryUnavailable, "receiver refusal envelope is invalid"
334
+ end
335
+ {"status" => "error", "error" => error.slice(*expected)}
336
+ end
337
+
338
+ def selection_error
339
+ self.class.refusal_envelope("ordinary_merge_selection_mismatch",
340
+ "reference and selectors do not match a canonical ordinary merge record",
341
+ request_id: nil, input_digest: nil,
342
+ schema: Ace::Assign::Atoms::FixedMergeSchemas::STATUS_ERROR_SCHEMA_ID)
343
+ end
344
+
345
+ def status_error(envelope)
346
+ {"status" => "error", "error" => envelope.fetch("error")}
347
+ end
348
+
349
+ def identifiers!(project:, assignment:, attempt:, queue_entry:, request_id:)
350
+ {"project" => project, "assignment" => assignment, "attempt" => attempt,
351
+ "queue_entry" => queue_entry, "request_id" => request_id}.each do |name, value|
352
+ invalid!("invalid #{name} ID", request_id) unless value.is_a?(String) && value.match?(ID)
353
+ end
354
+ end
355
+
356
+ def load_request_document(input, request_id)
357
+ Atoms::ServiceInput.load(input)
358
+ rescue ArgumentError => e
359
+ invalid!(e.message, request_id)
360
+ end
361
+
362
+ def unknown(keys, allowed)
363
+ (keys - allowed).sort.join(", ")
364
+ end
365
+
366
+ # A pre-effect refusal carries its closed code, sanitized message and
367
+ # the request binding for the wire envelope — never a reference.
368
+ OrdinaryMergeRefusal = Class.new(::StandardError) do
369
+ attr_reader :code, :detail, :request_id, :input_digest
370
+
371
+ def initialize(code, detail, request_id: nil, input_digest: nil)
372
+ @code = code
373
+ @detail = detail
374
+ @request_id = request_id
375
+ @input_digest = input_digest
376
+ super(detail)
377
+ end
378
+ end
379
+
380
+ def invalid!(message, request_id)
381
+ raise OrdinaryMergeRefusal.new("ordinary_merge_invalid_request", message, request_id: request_id)
382
+ end
383
+
384
+ def conflict!(message, request_id, digest)
385
+ raise OrdinaryMergeRefusal.new("ordinary_merge_request_conflict", message,
386
+ request_id: request_id, input_digest: digest)
387
+ end
388
+
389
+ # A replay must match every caller-supplied immutable field: command
390
+ # selectors, authorization, input byte meaning and canonical digest.
391
+ def exact_replay?(existing, project:, assignment:, attempt:, queue_entry:,
392
+ authorization:, digest:)
393
+ existing["state"] &&
394
+ existing.slice("project_id", "assignment_id", "attempt_id", "queue_entry",
395
+ "authorization", "input_digest") == {
396
+ "project_id" => project, "assignment_id" => assignment, "attempt_id" => attempt,
397
+ "queue_entry" => queue_entry, "authorization" => authorization, "input_digest" => digest
398
+ }
399
+ end
400
+
401
+ def refused(code, message)
402
+ raise OrdinaryMergeRefusal.new(code, message)
403
+ end
404
+
405
+ # The git toplevel (not a package directory that happens to hold a
406
+ # Rakefile) bounds candidate-head verification for nested invocations.
407
+ def git_toplevel
408
+ out, status = Open3.capture2("git", "rev-parse", "--show-toplevel")
409
+ (status&.success? ? out.strip : Dir.pwd)
410
+ rescue Errno::ENOENT
411
+ Dir.pwd
412
+ end
413
+
414
+ def current_head
415
+ out, _stderr, status = Open3.capture3("git", "rev-parse", "HEAD", chdir: @repo_root)
416
+ refused("ordinary_merge_target_changed", "candidate head is unavailable") unless status.success?
417
+ out.strip
418
+ end
419
+ end
420
+ end
421
+ end
422
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ace
4
4
  module Lab
5
- VERSION = "0.5.2"
5
+ VERSION = "0.6.0"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ace-lab
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.2
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Michal Czyz
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-10-12 00:00:00.000000000 Z
10
+ date: 2026-10-14 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: ace-runtime
@@ -71,28 +71,28 @@ dependencies:
71
71
  requirements:
72
72
  - - "~>"
73
73
  - !ruby/object:Gem::Version
74
- version: '0.67'
74
+ version: '0.68'
75
75
  type: :runtime
76
76
  prerelease: false
77
77
  version_requirements: !ruby/object:Gem::Requirement
78
78
  requirements:
79
79
  - - "~>"
80
80
  - !ruby/object:Gem::Version
81
- version: '0.67'
81
+ version: '0.68'
82
82
  - !ruby/object:Gem::Dependency
83
83
  name: ace-hitl
84
84
  requirement: !ruby/object:Gem::Requirement
85
85
  requirements:
86
86
  - - "~>"
87
87
  - !ruby/object:Gem::Version
88
- version: 0.14.0
88
+ version: '0.15'
89
89
  type: :runtime
90
90
  prerelease: false
91
91
  version_requirements: !ruby/object:Gem::Requirement
92
92
  requirements:
93
93
  - - "~>"
94
94
  - !ruby/object:Gem::Version
95
- version: 0.14.0
95
+ version: '0.15'
96
96
  - !ruby/object:Gem::Dependency
97
97
  name: ace-support-test-helpers
98
98
  requirement: !ruby/object:Gem::Requirement
@@ -222,6 +222,7 @@ files:
222
222
  - lib/ace/lab/molecules/grant_resolver.rb
223
223
  - lib/ace/lab/molecules/hitl_authorizer.rb
224
224
  - lib/ace/lab/molecules/inventory_query.rb
225
+ - lib/ace/lab/molecules/ordinary_socket.rb
225
226
  - lib/ace/lab/molecules/protected_cleanup_owner_admission.rb
226
227
  - lib/ace/lab/molecules/protected_cleanup_owner_client.rb
227
228
  - lib/ace/lab/molecules/protected_cleanup_owner_identity.rb
@@ -232,6 +233,7 @@ files:
232
233
  - lib/ace/lab/molecules/service_policy.rb
233
234
  - lib/ace/lab/molecules/topology_loader.rb
234
235
  - lib/ace/lab/organisms/authority_composition.rb
236
+ - lib/ace/lab/organisms/ordinary_merge_request.rb
235
237
  - lib/ace/lab/organisms/protected_cleanup_owner.rb
236
238
  - lib/ace/lab/organisms/protected_service_client.rb
237
239
  - lib/ace/lab/organisms/protected_service_listener.rb