ace-assign 0.67.0 → 0.68.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 +7 -0
- data/handbook/workflow-instructions/assign/drive.wf.md +39 -0
- data/lib/ace/assign/atoms/fixed_merge_schemas.rb +398 -0
- data/lib/ace/assign/errors.rb +5 -1
- data/lib/ace/assign/models/evidence_event.rb +1 -0
- data/lib/ace/assign/molecules/evidence_journal.rb +281 -10
- data/lib/ace/assign/molecules/fixed_effect_reference.rb +31 -0
- data/lib/ace/assign/molecules/ordinary_merge_cli.rb +68 -0
- data/lib/ace/assign/molecules/ordinary_merge_contract.rb +410 -0
- data/lib/ace/assign/organisms/attempt_coordinator.rb +90 -7
- data/lib/ace/assign/organisms/ordinary_merge_consumer.rb +365 -0
- data/lib/ace/assign/version.rb +1 -1
- data/lib/ace/assign.rb +3 -0
- metadata +7 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e21e0378ad7810b04e9a92cbac849a7e7ce09b9add705c668a826dc9e3cd87f6
|
|
4
|
+
data.tar.gz: 0d56587d4589765dbc83758e882a9482a9ce4a12dc9c803aaeacc7cc0314cb57
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 93380ddd286cd79c67fad691e875f703e60282f585746dbc3da8b04b5145310cc94ff1577416ba4164db25e90a30a1481588ae1b070ce196daea709ba8c55c25
|
|
7
|
+
data.tar.gz: e9ac0d6ef4ac39f6812e1c8f276f64dab32d15aa023830b2bf563a2211addd4353d875736506d2103a6d2b4c01efbdcf66bb205dd2e091e08e361a4146140476
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.68.0] - 2026-10-14
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- Admit ordinary fixed merge requests through the maintained `ace-lab service request/status --mode ordinary-merge` producer via the closed-argv `OrdinaryMergeCli`, with opaque-reference recovery, exact terminal receipt acceptance, and the canonical request digest defined as lowercase SHA-256 over the full eight-field immutable binding per spec ruling r4 — re-derived from the recorded binding on every poll and validated against every acknowledgment, refusal, status, and receipt echo.
|
|
15
|
+
- Add fixed merge request schemas, opaque fixed-effect references, evidence-journal records, and coordinator primitives (admit/begin/settle/read-only status) so the canonical Assign attempt/evidence state remains the sole durable record: an identical resubmission replays the original acknowledgment, a reused, consumed, expired, or out-of-scope authorization raises the distinct `AuthorizationConflict`, and a restarted receiver can never begin the effect twice.
|
|
16
|
+
|
|
10
17
|
## [0.67.0] - 2026-10-12
|
|
11
18
|
|
|
12
19
|
### Added
|
|
@@ -747,6 +747,45 @@ ace-assign attempt reconcile --attempt "$ATTEMPT_ID" --receipt /tmp/attempt-rece
|
|
|
747
747
|
- Running attempts stay running only while their recorded process is verifiably live; anything else becomes `stopped` or `uncertain`.
|
|
748
748
|
- Never replay `merge`, `publish`, or `deploy` automatically. Reconciliation records evidence; it never re-executes effects.
|
|
749
749
|
|
|
750
|
+
#### Integration Gate and Typed Outcomes (every integration invocation)
|
|
751
|
+
|
|
752
|
+
These gates apply at EVERY integration entry point — a native integrate
|
|
753
|
+
command, a service invocation, or an assignment integration step — with
|
|
754
|
+
shared contract validation so no entry point bypasses them.
|
|
755
|
+
|
|
756
|
+
- Before any mutation, resolve FRESH repository, PR, candidate exact head,
|
|
757
|
+
target base/head, review/test evidence and the authorized executor; recheck
|
|
758
|
+
target/head immediately before the mutation. A detected race invalidates
|
|
759
|
+
eligibility for that invocation.
|
|
760
|
+
- The low-level privileged integrator ENFORCES the gates and returns one typed
|
|
761
|
+
outcome: `merged`, `needs-rebase`, `blocked` or `uncertain`. It never
|
|
762
|
+
authors, rebases or reviews its own candidate. Relay alone is not
|
|
763
|
+
execution: the queue owner monitors until receipt and continuation.
|
|
764
|
+
- If the target (current main) is NOT an ancestor of the candidate head, the
|
|
765
|
+
outcome is `needs-rebase`: never merge and never silently rewrite. The queue
|
|
766
|
+
owner delegates `wfi://git/rebase` (as-git-rebase, including CHANGELOG
|
|
767
|
+
preservation) in the candidate's isolated worktree, obtains refreshed
|
|
768
|
+
exact-head checks plus independent review of the NEW head, updates the PR,
|
|
769
|
+
verifies remote candidate identity and resubmits. A common old base does
|
|
770
|
+
NOT make sibling heads sequentially fast-forwardable.
|
|
771
|
+
- Integration is serial in PREPARATION as well as in effect: the accepted
|
|
772
|
+
receipt for one candidate causes preparation of ONLY the next selected
|
|
773
|
+
candidate — never a mass rebase of the remaining queue. Each candidate, on
|
|
774
|
+
its turn: resolve fresh target main; delegate `wfi://git/rebase` in that
|
|
775
|
+
candidate's isolated worktree IF needed; run the applicable checks; obtain
|
|
776
|
+
an INDEPENDENT EXPLICIT review of the resulting exact head (patch identity
|
|
777
|
+
may support a short delta review but never substitutes the explicit
|
|
778
|
+
independent head approval); verify the remote PR head/base; issue ONE
|
|
779
|
+
scoped integrator request; wait/reconcile the actual integration receipt;
|
|
780
|
+
refresh main; select and prepare ONLY the next candidate. Queued later
|
|
781
|
+
PRs keep their existing branches/heads untouched until their turn; prior
|
|
782
|
+
batch work is never rolled back or reset.
|
|
783
|
+
- `uncertain` triggers reconciliation against the original durable reference;
|
|
784
|
+
it never permits blind retry.
|
|
785
|
+
- Never push directly to main; never switch the canonical checkout to a topic
|
|
786
|
+
branch; never reuse a stale review as approval for a different or moved
|
|
787
|
+
head.
|
|
788
|
+
|
|
750
789
|
### Human-in-the-Loop (HITL) Stall Protocol
|
|
751
790
|
|
|
752
791
|
Canonical source of truth: `wfi://hitl`.
|
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "json"
|
|
5
|
+
require "time"
|
|
6
|
+
require "uri"
|
|
7
|
+
|
|
8
|
+
module Ace
|
|
9
|
+
module Assign
|
|
10
|
+
module Atoms
|
|
11
|
+
# Closed wire schemas for the ordinary fixed merge service (spec
|
|
12
|
+
# 8x3.t.xz9.4). One owner for the exact request document, the terminal
|
|
13
|
+
# fixed-effect receipt, and their semantic joins; both ace-assign
|
|
14
|
+
# admission and the ace-lab producer enforce this exact module instead
|
|
15
|
+
# of two dialects. Unknown fields are rejected at every nesting level,
|
|
16
|
+
# formats (uri, date-time) are enforced, and every pattern/bound below
|
|
17
|
+
# is the reviewed spec text — never widened.
|
|
18
|
+
module FixedMergeSchemas
|
|
19
|
+
REQUEST_SCHEMA_ID = "ace.lab.service.ordinary-merge-request/v1"
|
|
20
|
+
RECEIPT_SCHEMA_ID = "ace.assign.fixed-effect-receipt/v1"
|
|
21
|
+
ACK_SCHEMA_ID = "ace.lab.service.ordinary-merge-ack/v1"
|
|
22
|
+
REFUSAL_SCHEMA_ID = "ace.lab.service.ordinary-merge-refusal/v1"
|
|
23
|
+
STATUS_SCHEMA_ID = "ace.lab.service.ordinary-merge-status/v1"
|
|
24
|
+
STATUS_ERROR_SCHEMA_ID = "ace.lab.service.ordinary-merge-status-error/v1"
|
|
25
|
+
|
|
26
|
+
SHA256 = /\A[0-9a-f]{64}\z/
|
|
27
|
+
HEAD = /\A[0-9a-f]{40}\z/
|
|
28
|
+
FORGE = /\A[A-Za-z0-9][A-Za-z0-9._:-]{0,127}\z/
|
|
29
|
+
TERMINAL_STATUSES = %w[succeeded failed].freeze
|
|
30
|
+
EFFECT_OUTCOMES = %w[merged already_merged failed_no_effect].freeze
|
|
31
|
+
FAILURE_CODES = [nil, "merge_failed_no_effect"].freeze
|
|
32
|
+
|
|
33
|
+
# Every key must be an exact String with no unknown siblings.
|
|
34
|
+
class SchemaError < ::StandardError; end
|
|
35
|
+
|
|
36
|
+
module_function
|
|
37
|
+
|
|
38
|
+
# Canonical request digest — spec ruling r4: lowercase hex SHA-256 over
|
|
39
|
+
# the canonical JSON (recursively key-sorted by code point, compact
|
|
40
|
+
# encoder, UTF-8) of the FULL eight-field immutable binding. The digest
|
|
41
|
+
# covers the binding, never the input document alone: byte-identical
|
|
42
|
+
# input under any different selector, authorization or request ID
|
|
43
|
+
# yields a different digest. The producer computes it at admission and
|
|
44
|
+
# re-derives it for replay and receipt verification; consumers re-derive
|
|
45
|
+
# the identical digest from their recorded binding. Both packages call
|
|
46
|
+
# this one implementation so a pair can never diverge.
|
|
47
|
+
def canonical_request_digest(project:, assignment:, attempt:, queue_entry:,
|
|
48
|
+
authorization:, request_id:, input:)
|
|
49
|
+
preimage = canonical({
|
|
50
|
+
"project" => project, "assignment" => assignment, "attempt" => attempt,
|
|
51
|
+
"queue_entry" => queue_entry, "operation" => "merge",
|
|
52
|
+
"authorization" => authorization, "request_id" => request_id, "input" => input
|
|
53
|
+
})
|
|
54
|
+
Digest::SHA256.hexdigest(JSON.generate(preimage))
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Recursively sort object keys by code point; arrays keep order.
|
|
58
|
+
def canonical(value)
|
|
59
|
+
case value
|
|
60
|
+
when Hash then value.keys.sort.each_with_object({}) { |key, result| result[key] = canonical(value[key]) }
|
|
61
|
+
when Array then value.map { |item| canonical(item) }
|
|
62
|
+
else value
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# The only admitted ordinary merge request document.
|
|
67
|
+
def validate_request!(document)
|
|
68
|
+
require_object!(document, "request")
|
|
69
|
+
require_exact_keys!(document, "request", %w[schema forge pr_url head_repository head_ref
|
|
70
|
+
candidate_head base_repository base_ref expected_base_head method review_evidence test_evidence])
|
|
71
|
+
const!(document, "schema", REQUEST_SCHEMA_ID)
|
|
72
|
+
string_bound!(document, "forge", 1, 128, pattern: FORGE)
|
|
73
|
+
string_bound!(document, "pr_url", 1, 2048)
|
|
74
|
+
uri_format!(document.fetch("pr_url"))
|
|
75
|
+
string_bound!(document, "head_repository", 1, 1024)
|
|
76
|
+
string_bound!(document, "head_ref", 1, 255)
|
|
77
|
+
pattern!(document, "candidate_head", HEAD)
|
|
78
|
+
string_bound!(document, "base_repository", 1, 1024)
|
|
79
|
+
string_bound!(document, "base_ref", 1, 255)
|
|
80
|
+
pattern!(document, "expected_base_head", HEAD)
|
|
81
|
+
const!(document, "method", "merge")
|
|
82
|
+
validate_review_evidence!(document.fetch("review_evidence"))
|
|
83
|
+
validate_test_evidence!(document.fetch("test_evidence"))
|
|
84
|
+
true
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# The semantic join schema validity alone never waives: the reviewed
|
|
88
|
+
# head and every test receipt head must equal the exact candidate.
|
|
89
|
+
def require_request_joins!(document)
|
|
90
|
+
candidate = document.fetch("candidate_head")
|
|
91
|
+
unless document.dig("review_evidence", "reviewed_head") == candidate
|
|
92
|
+
raise SchemaError, "review_evidence.reviewed_head does not equal candidate_head"
|
|
93
|
+
end
|
|
94
|
+
document.fetch("test_evidence").each_with_index do |item, index|
|
|
95
|
+
unless item.fetch("candidate_head") == candidate
|
|
96
|
+
raise SchemaError, "test_evidence[#{index}].candidate_head does not equal candidate_head"
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
true
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def validate_review_evidence!(evidence)
|
|
103
|
+
require_object!(evidence, "review_evidence")
|
|
104
|
+
require_exact_keys!(evidence, "review_evidence", %w[schema reference reviewer verdict
|
|
105
|
+
reviewed_head recorded_at record_sha256])
|
|
106
|
+
const!(evidence, "schema", "ace.review.acceptance-reference/v1")
|
|
107
|
+
string_bound!(evidence, "reference", 1, 256)
|
|
108
|
+
string_bound!(evidence, "reviewer", 1, 128)
|
|
109
|
+
const!(evidence, "verdict", "approved")
|
|
110
|
+
pattern!(evidence, "reviewed_head", HEAD)
|
|
111
|
+
date_time!(evidence.fetch("recorded_at"))
|
|
112
|
+
pattern!(evidence, "record_sha256", SHA256)
|
|
113
|
+
true
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def validate_test_evidence!(items)
|
|
117
|
+
unless items.is_a?(Array) && !items.empty?
|
|
118
|
+
raise SchemaError, "test_evidence must be a non-empty array"
|
|
119
|
+
end
|
|
120
|
+
items.each_with_index do |item, index|
|
|
121
|
+
require_object!(item, "test_evidence[#{index}]")
|
|
122
|
+
require_exact_keys!(item, "test_evidence[#{index}]", %w[schema reference candidate_head
|
|
123
|
+
command result recorded_at receipt_sha256])
|
|
124
|
+
const!(item, "schema", "ace.test.execution-reference/v1")
|
|
125
|
+
string_bound!(item, "reference", 1, 256)
|
|
126
|
+
pattern!(item, "candidate_head", HEAD)
|
|
127
|
+
string_bound!(item, "command", 1, 2048)
|
|
128
|
+
const!(item, "result", "passed")
|
|
129
|
+
date_time!(item.fetch("recorded_at"))
|
|
130
|
+
pattern!(item, "receipt_sha256", SHA256)
|
|
131
|
+
end
|
|
132
|
+
# uniqueItems: deep equality over the whole closed document
|
|
133
|
+
unless items.map { |item| JSON.generate(item) }.uniq.size == items.size
|
|
134
|
+
raise SchemaError, "test_evidence items are not unique"
|
|
135
|
+
end
|
|
136
|
+
true
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# The exact terminal receipt document. Structural pass only; binding
|
|
140
|
+
# against the canonical admitted record is validate_receipt_against!
|
|
141
|
+
def validate_receipt!(document)
|
|
142
|
+
require_object!(document, "receipt")
|
|
143
|
+
require_exact_keys!(document, "receipt", %w[schema reference request_id input_digest
|
|
144
|
+
terminal_status request producer effect remote_observation])
|
|
145
|
+
const!(document, "schema", RECEIPT_SCHEMA_ID)
|
|
146
|
+
string_bound!(document, "reference", 1, 256)
|
|
147
|
+
string_bound!(document, "request_id", 1, 256)
|
|
148
|
+
pattern!(document, "input_digest", SHA256)
|
|
149
|
+
unless TERMINAL_STATUSES.include?(document.fetch("terminal_status"))
|
|
150
|
+
raise SchemaError, "terminal_status must be succeeded or failed"
|
|
151
|
+
end
|
|
152
|
+
validate_receipt_request!(document.fetch("request"))
|
|
153
|
+
validate_receipt_producer!(document.fetch("producer"))
|
|
154
|
+
validate_receipt_effect!(document.fetch("effect"))
|
|
155
|
+
validate_receipt_observation!(document.fetch("remote_observation"))
|
|
156
|
+
require_terminal_invariant!(document)
|
|
157
|
+
true
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Embedded, never flattened: all selectors and the complete validated
|
|
161
|
+
# input document live under the single request object.
|
|
162
|
+
def validate_receipt_request!(request)
|
|
163
|
+
require_object!(request, "request")
|
|
164
|
+
require_exact_keys!(request, "request", %w[project assignment attempt queue_entry
|
|
165
|
+
operation authorization input])
|
|
166
|
+
string_bound!(request, "project", 1, 128)
|
|
167
|
+
string_bound!(request, "assignment", 1, 128)
|
|
168
|
+
string_bound!(request, "attempt", 1, 128)
|
|
169
|
+
string_bound!(request, "queue_entry", 1, 128)
|
|
170
|
+
const!(request, "operation", "merge")
|
|
171
|
+
string_bound!(request, "authorization", 1, 256)
|
|
172
|
+
validate_request!(request.fetch("input"))
|
|
173
|
+
true
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def validate_receipt_producer!(producer)
|
|
177
|
+
require_object!(producer, "producer")
|
|
178
|
+
require_exact_keys!(producer, "producer", %w[receiver_id principal configuration_sha256])
|
|
179
|
+
string_bound!(producer, "receiver_id", 1, 128)
|
|
180
|
+
string_bound!(producer, "principal", 1, 256)
|
|
181
|
+
pattern!(producer, "configuration_sha256", SHA256)
|
|
182
|
+
true
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def validate_receipt_effect!(effect)
|
|
186
|
+
require_object!(effect, "effect")
|
|
187
|
+
require_exact_keys!(effect, "effect", %w[started_at finished_at outcome failure_code no_effect_evidence])
|
|
188
|
+
unless effect.fetch("started_at").nil? || effect.fetch("started_at").is_a?(String)
|
|
189
|
+
raise SchemaError, "effect.started_at must be a date-time or null"
|
|
190
|
+
end
|
|
191
|
+
date_time!(effect.fetch("started_at")) unless effect.fetch("started_at").nil?
|
|
192
|
+
date_time!(effect.fetch("finished_at"))
|
|
193
|
+
unless EFFECT_OUTCOMES.include?(effect.fetch("outcome"))
|
|
194
|
+
raise SchemaError, "effect.outcome must be merged, already_merged or failed_no_effect"
|
|
195
|
+
end
|
|
196
|
+
unless FAILURE_CODES.include?(effect.fetch("failure_code"))
|
|
197
|
+
raise SchemaError, "effect.failure_code must be null or merge_failed_no_effect"
|
|
198
|
+
end
|
|
199
|
+
evidence = effect.fetch("no_effect_evidence")
|
|
200
|
+
return true if evidence.nil?
|
|
201
|
+
require_object!(evidence, "no_effect_evidence")
|
|
202
|
+
require_exact_keys!(evidence, "no_effect_evidence", %w[reference sha256])
|
|
203
|
+
string_bound!(evidence, "reference", 1, 256)
|
|
204
|
+
pattern!(evidence, "sha256", SHA256)
|
|
205
|
+
true
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def validate_receipt_observation!(observation)
|
|
209
|
+
return true if observation.nil?
|
|
210
|
+
require_object!(observation, "remote_observation")
|
|
211
|
+
require_exact_keys!(observation, "remote_observation", %w[forge_instance pr_identity_url
|
|
212
|
+
target_repository_identity target_ref_observed merged_candidate_head admission_target_head
|
|
213
|
+
resulting_target_head merge_commit checkout_id worktree_id branch])
|
|
214
|
+
pattern!(observation, "forge_instance", FORGE)
|
|
215
|
+
string_bound!(observation, "pr_identity_url", 1, 2048)
|
|
216
|
+
uri_format!(observation.fetch("pr_identity_url"))
|
|
217
|
+
string_bound!(observation, "target_repository_identity", 1, 1024)
|
|
218
|
+
string_bound!(observation, "target_ref_observed", 1, 255)
|
|
219
|
+
%w[merged_candidate_head admission_target_head resulting_target_head merge_commit].each do |key|
|
|
220
|
+
pattern!(observation, key, HEAD)
|
|
221
|
+
end
|
|
222
|
+
string_bound!(observation, "checkout_id", 1, 256)
|
|
223
|
+
string_bound!(observation, "worktree_id", 1, 256)
|
|
224
|
+
string_bound!(observation, "branch", 1, 255)
|
|
225
|
+
true
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# The closed terminal oneOf: exactly one branch may hold.
|
|
229
|
+
def require_terminal_invariant!(document)
|
|
230
|
+
succeeded = document.fetch("terminal_status") == "succeeded"
|
|
231
|
+
effect = document.fetch("effect")
|
|
232
|
+
if succeeded
|
|
233
|
+
raise SchemaError, "succeeded receipt needs effect.started_at" if effect.fetch("started_at").nil?
|
|
234
|
+
unless %w[merged already_merged].include?(effect.fetch("outcome"))
|
|
235
|
+
raise SchemaError, "succeeded receipt outcome must be merged or already_merged"
|
|
236
|
+
end
|
|
237
|
+
unless effect.fetch("failure_code").nil? && effect.fetch("no_effect_evidence").nil?
|
|
238
|
+
raise SchemaError, "succeeded receipt cannot carry a failure code or no-effect evidence"
|
|
239
|
+
end
|
|
240
|
+
unless document.fetch("remote_observation").is_a?(Hash)
|
|
241
|
+
raise SchemaError, "succeeded receipt requires remote_observation"
|
|
242
|
+
end
|
|
243
|
+
else
|
|
244
|
+
unless effect.fetch("outcome") == "failed_no_effect"
|
|
245
|
+
raise SchemaError, "failed receipt outcome must be failed_no_effect"
|
|
246
|
+
end
|
|
247
|
+
unless effect.fetch("failure_code") == "merge_failed_no_effect"
|
|
248
|
+
raise SchemaError, "failed receipt requires the immutable failure code"
|
|
249
|
+
end
|
|
250
|
+
unless effect.fetch("no_effect_evidence").is_a?(Hash)
|
|
251
|
+
raise SchemaError, "failed receipt is terminal only with immutable no_effect_evidence"
|
|
252
|
+
end
|
|
253
|
+
unless document.fetch("remote_observation").nil?
|
|
254
|
+
raise SchemaError, "failed receipt cannot observe a remote merge"
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
true
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
# The producer-side binding: every embedded request value and top-level
|
|
261
|
+
# ID/digest must match the canonical admitted record, and the remote
|
|
262
|
+
# observation must join back to the embedded request and the
|
|
263
|
+
# registered execution identity.
|
|
264
|
+
def validate_receipt_against!(receipt, record)
|
|
265
|
+
validate_receipt!(receipt)
|
|
266
|
+
request = receipt.fetch("request")
|
|
267
|
+
embedded = request.fetch("input")
|
|
268
|
+
unless receipt.fetch("request_id") == record.fetch("request_id") &&
|
|
269
|
+
receipt.fetch("input_digest") == record.fetch("input_digest") &&
|
|
270
|
+
receipt.fetch("reference") == record.fetch("reference")
|
|
271
|
+
raise SchemaError, "receipt identity does not match the canonical admitted record"
|
|
272
|
+
end
|
|
273
|
+
# Ruling r4: receipt verification RE-DERIVES the digest from the
|
|
274
|
+
# embedded request and the record selectors — an echoed digest that
|
|
275
|
+
# merely matches a stored value is not sufficient.
|
|
276
|
+
embedded_binding = request
|
|
277
|
+
rederived = canonical_request_digest(
|
|
278
|
+
project: record.fetch("project_id"), assignment: record.fetch("assignment_id"),
|
|
279
|
+
attempt: record.fetch("attempt_id"), queue_entry: record.fetch("queue_entry"),
|
|
280
|
+
authorization: record.fetch("authorization"), request_id: record.fetch("request_id"),
|
|
281
|
+
input: embedded_binding.fetch("input")
|
|
282
|
+
)
|
|
283
|
+
unless rederived == record.fetch("input_digest") && rederived == receipt.fetch("input_digest")
|
|
284
|
+
raise SchemaError, "receipt digest does not re-derive from the embedded request binding"
|
|
285
|
+
end
|
|
286
|
+
selector_map = {
|
|
287
|
+
"project" => "project_id", "assignment" => "assignment_id", "attempt" => "attempt_id",
|
|
288
|
+
"queue_entry" => "queue_entry", "operation" => "operation", "authorization" => "authorization"
|
|
289
|
+
}
|
|
290
|
+
joined = selector_map.all? { |request_key, record_key|
|
|
291
|
+
request.fetch(request_key) == record.fetch(record_key)
|
|
292
|
+
}
|
|
293
|
+
unless joined
|
|
294
|
+
raise SchemaError, "embedded request selectors do not match the canonical admitted record"
|
|
295
|
+
end
|
|
296
|
+
unless embedded == record.fetch("request")
|
|
297
|
+
raise SchemaError, "embedded input does not equal the canonical admitted request document"
|
|
298
|
+
end
|
|
299
|
+
producer = receipt.fetch("producer")
|
|
300
|
+
unless producer.fetch("receiver_id") == record.fetch("receiver_id") &&
|
|
301
|
+
producer.fetch("principal") == record.fetch("principal") &&
|
|
302
|
+
producer.fetch("configuration_sha256") == record.fetch("configuration_sha256")
|
|
303
|
+
raise SchemaError, "receipt producer does not match the canonical admitted record"
|
|
304
|
+
end
|
|
305
|
+
observation = receipt.fetch("remote_observation")
|
|
306
|
+
if observation
|
|
307
|
+
unless observation.fetch("forge_instance") == embedded.fetch("forge") &&
|
|
308
|
+
observation.fetch("pr_identity_url") == embedded.fetch("pr_url") &&
|
|
309
|
+
observation.fetch("target_repository_identity") == embedded.fetch("base_repository") &&
|
|
310
|
+
observation.fetch("target_ref_observed") == embedded.fetch("base_ref") &&
|
|
311
|
+
observation.fetch("merged_candidate_head") == embedded.fetch("candidate_head") &&
|
|
312
|
+
observation.fetch("admission_target_head") == embedded.fetch("expected_base_head")
|
|
313
|
+
raise SchemaError, "remote_observation does not join back to the embedded request"
|
|
314
|
+
end
|
|
315
|
+
identity = record.fetch("execution_identity")
|
|
316
|
+
unless identity && observation.fetch("checkout_id") == identity.fetch("checkout_id") &&
|
|
317
|
+
observation.fetch("worktree_id") == identity.fetch("worktree_id") &&
|
|
318
|
+
observation.fetch("branch") == identity.fetch("branch")
|
|
319
|
+
raise SchemaError, "remote_observation does not match the registered execution identity"
|
|
320
|
+
end
|
|
321
|
+
unless observation.fetch("merge_commit") == observation.fetch("resulting_target_head")
|
|
322
|
+
raise SchemaError, "merge_commit must equal resulting_target_head for the fixed merge method"
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
true
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
# ---- closed primitive validators ------------------------------------
|
|
329
|
+
|
|
330
|
+
def require_object!(value, name)
|
|
331
|
+
raise SchemaError, "#{name} must be an object" unless value.is_a?(Hash)
|
|
332
|
+
true
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
def require_exact_keys!(object, name, keys)
|
|
336
|
+
unknown = object.keys - keys
|
|
337
|
+
unless unknown.empty?
|
|
338
|
+
raise SchemaError, "#{name} has unknown fields: #{unknown.sort.join(", ")}"
|
|
339
|
+
end
|
|
340
|
+
missing = keys - object.keys
|
|
341
|
+
unless missing.empty?
|
|
342
|
+
raise SchemaError, "#{name} is missing fields: #{missing.sort.join(", ")}"
|
|
343
|
+
end
|
|
344
|
+
true
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
def const!(object, key, value)
|
|
348
|
+
unless object.fetch(key) == value
|
|
349
|
+
raise SchemaError, "#{key} must be #{value.to_s.inspect}"
|
|
350
|
+
end
|
|
351
|
+
true
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
def pattern!(object, key, pattern)
|
|
355
|
+
value = object.fetch(key)
|
|
356
|
+
unless value.is_a?(String) && value.match?(pattern)
|
|
357
|
+
raise SchemaError, "#{key} has an invalid format"
|
|
358
|
+
end
|
|
359
|
+
true
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
def string_bound!(object, key, min, max, pattern: nil)
|
|
363
|
+
value = object.fetch(key)
|
|
364
|
+
unless value.is_a?(String) && value.length.between?(min, max)
|
|
365
|
+
raise SchemaError, "#{key} must be a string of #{min}..#{max} characters"
|
|
366
|
+
end
|
|
367
|
+
return true if pattern.nil? || value.match?(pattern)
|
|
368
|
+
raise SchemaError, "#{key} has an invalid format"
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
def date_time!(value)
|
|
372
|
+
unless value.is_a?(String) && value.match?(/\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})\z/)
|
|
373
|
+
raise SchemaError, "date-time must be an RFC 3339 timestamp"
|
|
374
|
+
end
|
|
375
|
+
begin
|
|
376
|
+
Time.iso8601(value)
|
|
377
|
+
rescue ArgumentError
|
|
378
|
+
raise SchemaError, "date-time is not a valid timestamp"
|
|
379
|
+
end
|
|
380
|
+
true
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
def uri_format!(value)
|
|
384
|
+
begin
|
|
385
|
+
uri = URI.parse(value)
|
|
386
|
+
rescue URI::Error
|
|
387
|
+
raise SchemaError, "uri format is invalid"
|
|
388
|
+
end
|
|
389
|
+
unless uri.is_a?(URI::Generic) && !uri.scheme.to_s.empty? && !uri.host.to_s.empty? &&
|
|
390
|
+
value.match?(/\A\p{ASCII}+\z/) && !value.match?(/[\\\s<>"]|\A\//)
|
|
391
|
+
raise SchemaError, "uri format is invalid"
|
|
392
|
+
end
|
|
393
|
+
true
|
|
394
|
+
end
|
|
395
|
+
end
|
|
396
|
+
end
|
|
397
|
+
end
|
|
398
|
+
end
|
data/lib/ace/assign/errors.rb
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
|
+
|
|
2
3
|
require "ace/support/cli/error"
|
|
3
4
|
|
|
4
5
|
module Ace
|
|
@@ -70,6 +71,10 @@ module Ace
|
|
|
70
71
|
end
|
|
71
72
|
end
|
|
72
73
|
|
|
74
|
+
# An exact authorization reference is not currently valid for a new
|
|
75
|
+
# effect: consumed by another live request, expired, or out of scope.
|
|
76
|
+
class AuthorizationConflict < Conflict; end
|
|
77
|
+
|
|
73
78
|
class NotFound < Error
|
|
74
79
|
def initialize(message = "Attempt not found")
|
|
75
80
|
super(message, exit_code: 5)
|
|
@@ -134,6 +139,5 @@ module Ace
|
|
|
134
139
|
class InboxContextPending < EvidenceUnavailable
|
|
135
140
|
end
|
|
136
141
|
end
|
|
137
|
-
|
|
138
142
|
end
|
|
139
143
|
end
|
|
@@ -20,6 +20,7 @@ module Ace
|
|
|
20
20
|
service_claim service_transition service_challenge service_no_effect_challenge service_publication_challenge service_publication_issuing recovery_observation inbox_binding delivery
|
|
21
21
|
authority_mutation evidence_import proposal_state result_submitted prompt_issued prompt_outcome prompt_completion_observed input_inhibited
|
|
22
22
|
scope_provisioning scope_bound scope_native_bound scope_child_bound scope_sealed scope_closed_no_writers
|
|
23
|
+
merge_admission merge_transition
|
|
23
24
|
].freeze
|
|
24
25
|
|
|
25
26
|
# Build an event chained to a previous digest.
|