ace-review 0.60.3 → 0.61.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: cf22abbaecfa8e1946ec8d74145079d674550f330d80c6a11b6585daf06838c8
4
- data.tar.gz: f4c8b3bacba1773ce6e524db739f775f0a3f24583b2b95eab82556ebe14405e7
3
+ metadata.gz: afd5ecbdd17112927d36de0cefcea9307419d0dfb28b47a53b8caa27fccee5a9
4
+ data.tar.gz: aee54796dbef9221a2e45d2a5f630228c7b5f9740022edfdb05a6261c2be6960
5
5
  SHA512:
6
- metadata.gz: 20a97ff08b91f5ebc898e3ab6d4b251e1effb8be044473cb2875ff34b25cb101de727fb24b6190879b5c133a2bb8e0278608f230d2556b44662ab1872baf91b1
7
- data.tar.gz: bcc904083e7d122a03ca7dbe3fce06fc9bf6934a74b8a761d9f4404a24c4ed9e6613ac7efdcc6e43f9e9d351720a1cd42b05288fdf61ccdb87a41b8be3e2dc05
6
+ metadata.gz: 1a966051ac35b1a93158cdfa5511178d18a24951a0ce8b55cc811fa6908408a597d7f40f8282effcbbc531e96aa769b8139a8ef033d39a6ebc1a92b19d95948c
7
+ data.tar.gz: 5e8d30ea044003a071efca1ebf7c1f616d13aaecda536f5f2379f776f33913cf9b56cfb12863363760a3b53b9c99f2b1f17c159d9c7ec3d59e6b400590095c93
data/CHANGELOG.md CHANGED
@@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.61.0] - 2026-10-16
11
+
12
+ ### Added
13
+
14
+ - Add source-owned nonsemantic head assessments to the existing campaign history, retaining the original reviewed head, fresh checks, and an exact predecessor chain without consuming or resetting review budgets. Accepted-result v2 distinguishes reviewed and accepted heads; unavailable assessment authority keeps acceptance closed.
15
+ - Let an installed campaign evidence owner verify real reviewer independence for agents sharing an account, while preserving the default distinct-actor requirement.
16
+ - Expose the closed 128MiB complete-head-context ceiling as `CampaignContract::MAX_HEAD_CONTEXT_BYTES` so campaign head assessments and the Assign-side candidate comparison share one explicit bound; ace-assign consumes this constant directly.
17
+
10
18
  ## [0.60.3] - 2026-10-16
11
19
 
12
20
  ### Fixed
data/docs/campaigns.md CHANGED
@@ -96,7 +96,7 @@ Expected: complete coverage increments `completed_rounds` once. Identical replay
96
96
 
97
97
  ## Current approval and finish
98
98
 
99
- Delivery defaults are three completed rounds and two consecutive rounds without confirmed High/Critical. The default scope is `full` and required check name is `tests`. Configure `campaign.profiles.delivery` in `.ace/review/config.yml`, or supply `start --policy FILE` with exactly `revision`, `minimum_rounds`, `clean_rounds`, `required_scopes`, `required_checks`. Explicit false/null or otherwise malformed policy files fail instead of selecting defaults. The campaign freezes that revision and policy. R1 does not implement discovery profiles, round caps, automatic retries or escalation.
99
+ Delivery defaults are three completed rounds and two consecutive rounds without confirmed High/Critical, bounded to five rounds in the initial phase. Discovery is bounded to two rounds. The default scope is `full` and required check name is `tests`. Configure `campaign.profiles.delivery` in `.ace/review/config.yml`, or supply `start --policy FILE` with exactly `revision`, `minimum_rounds`, `clean_rounds`, `required_scopes`, `required_checks`. Explicit false/null or otherwise malformed policy files fail instead of selecting defaults. The campaign freezes that revision and policy. Provider reservations retain the original execution when its outcome is running or uncertain; finite infrastructure retries and an explicit next-phase authorization are enforced by the existing campaign owner.
100
100
 
101
101
  The final round may reference an explicit approval artifact with `approval: {"path": ..., "sha256": ...}`. An approval binds an independently approved executed review already accepted by the assignment coordinator. Caller-written approval JSON alone cannot supply the verdict. It must contain:
102
102
 
@@ -144,10 +144,22 @@ The receipt retains ordinary attempt/assignment/project/scope binding, producer,
144
144
 
145
145
  Source-owned receipt composition can call `CampaignManager#with_verified_result!(result:, subject:, contract_identity:, policy:, head:, base:, producer:, reviewer:) { |current| ... }`. It requires a block and an existing campaign lock, revalidates current acceptance and the exact expected tuple, and yields a deeply frozen projection while holding one read transaction. Keep the entire receipt/CAS operation inside that block; a retained projection is not a later permission. Do not call public campaign methods from inside the block. Exceptions release the lock, and validation failures never yield. This API does not itself provide protected child linkage or authorize a journal mutation.
146
146
 
147
+ ### Source-owned head assessment capability
148
+
149
+ `accepted_result_snapshot` emits `ace.review.accepted-result/v2`. Its `head` and `base` identify current acceptance; `reviewed_head` and `reviewed_base` retain the original independent approval. `head_assessment_digest` and the `head_assessments` prefix count bind the append-only assessment chain. The v1 compact result is refused. A direct approval has equal reviewed and accepted heads and an empty assessment chain.
150
+
151
+ An installed source owner may supply `head_assessment_evidence` to `CampaignManager` and call `record_head_assessment(id, {"id", "reference", "predecessor"})`. The predecessor must be the exact current accepted prefix. The source callback authenticates the supervisor, derives complete bounded comparison context from retained candidates, and returns the bound assessment proof. The campaign separately revalidates each required current-head check through its existing execution-evidence reader. User-authored labels, hashes, local report files, or a callback-free manager cannot create this authority.
152
+
153
+ A verified nonsemantic assessment may carry an independent approval across a head/base change. It adds no round, does not reset findings, clean streak, phase limits, provider reservations or retry limits, and does not rewrite historical reports. Substantial, uncertain or nontext changes refuse this carry path and require an actually executed independent delta review. The source comparison reads both complete bundles as Git objects, preserving binary data and symlink targets without following links or executing candidate hooks, filters or scripts. Oversized or incomplete source context refuses acceptance rather than truncating evidence.
154
+
155
+ This API is a source capability. The ordinary installed Assign campaign composition and independent delta-round ingress are not enabled by adding it; those joins require their separate canonical owner and acceptance proof. The default local/protected actor-independence check stays strict. A composed native owner can supply `review_independence` to verify original process/session provenance for same-account agents; the callback is required again on historical consumption.
156
+
147
157
  ## Restart, contract changes and failures
148
158
 
149
159
  Campaign authority lives in `.ace-local/review/campaigns/`; retain this directory and source evidence across process/context restarts. Records are serialized with a lock and replaced atomically with checksums. Every retained completed session, including earlier rounds and partial coverage, must keep its immutable accepted review-collection authority. Historical reads use `ace-assign attempt evidence --kind review-collection --historical-head EXACT_RECORDED_HEAD` to validate that receipt and its artifacts. This read-only mode cannot certify current checks; check-purpose proofs always require the live head. Losing/corrupting evidence from any retained counted round or assessment makes it unavailable; historical counters remain diagnostic and do not manufacture acceptance. These records are durable local artifacts, not a second assignment execution journal.
150
160
 
161
+ The accepted-result v2 format cut requires the `head_assessments` history in stored campaigns and rejects v1 receipts. Pre-v2 records remain historical evidence but cannot be loaded or verified by this version; there is no automatic migration or fallback that manufactures acceptance. Retain their original bytes and provenance when changing versions.
162
+
151
163
  Start with changed requirements and `--reason "Requirement X changed"` to create a linked successor; `--predecessor ID` selects it explicitly. Same active subject/requirements/policy starts reuse one campaign, including concurrently. Requirements A → B → A create a new A successor of B; the superseded first A remains history. Restore missing active records rather than creating an orphan campaign that forgets them. Conflicting policy for an existing contract fails rather than rewriting history. A successor durably supersedes its predecessor: status keeps predecessor history and exposes `active_contract: false` and `superseded_by`, while collection, new recordings and acceptance are blocked. Old results cannot pass the assignment receipt verifier.
152
164
 
153
165
  The checksummed `.identity-index.json` retains each subject’s campaign IDs and is published before a new campaign record. A missing indexed record blocks that subject’s start/reuse, including deletion of its first record; restore the retained record to recover. Missing or corrupt indexes and unregistered records fail closed; no automatic reconstruction discards history. Subject lookup reads only its indexed records, so unrelated missing/corrupt campaign records do not block reuse. Retain the index together with the records. Existing campaign `status` and `finish` inspect their own checksummed record and successor marker.
@@ -11,6 +11,7 @@ module Ace
11
11
  # Pure campaign input contracts and canonical identities.
12
12
  module CampaignContract
13
13
  class Invalid < ArgumentError; end
14
+ MAX_HEAD_CONTEXT_BYTES = 128 * 1024 * 1024
14
15
  SHA = /\A[0-9a-f]{40}(?:[0-9a-f]{24})?\z/.freeze
15
16
  ID = /\A[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}\z/.freeze
16
17
 
@@ -28,9 +28,10 @@ module Ace
28
28
  !entry["completed_at"].to_s.empty?
29
29
  end
30
30
 
31
- def initialize(repo_root:, check_evidence: nil, review_evidence: nil, approval_evidence: nil, artifact_paths: {})
31
+ def initialize(repo_root:, check_evidence: nil, review_evidence: nil, approval_evidence: nil, artifact_paths: {}, review_independence: nil)
32
32
  @repo_root = File.realpath(repo_root)
33
33
  @artifact_paths = artifact_paths.dup.freeze
34
+ @review_independence = review_independence
34
35
  authority = CampaignExecutionEvidence.new(repo_root: @repo_root)
35
36
  @check_evidence = check_evidence || authority.method(:check)
36
37
  @review_evidence = review_evidence || authority.method(:review)
@@ -164,6 +165,7 @@ module Ace
164
165
  proof = @approval_evidence.call(approval.fetch("receipt"), head: approval.fetch("head"),
165
166
  artifacts: approval.fetch("reports"), producer: approval.fetch("producer"),
166
167
  reviewer: approval.fetch("reviewer"), historical: historical)
168
+ verify_independence!(producer: approval.fetch("producer"), reviewer: approval.fetch("reviewer"), proof: proof)
167
169
  verify_execution_linkage!(approval["execution_proof"], proof)
168
170
  approval.fetch("checks").each do |check|
169
171
  accepted = @check_evidence.call(check.fetch("receipt"), head: approval.fetch("head"), name: check.fetch("name"), historical: historical)
@@ -180,6 +182,20 @@ module Ace
180
182
  true
181
183
  end
182
184
 
185
+ def head_checks(checks, head:, required:, historical: false)
186
+ unless checks.is_a?(Array) && !checks.empty? && checks.all? { |item|
187
+ item.is_a?(Hash) && item.keys.sort == %w[name receipt] && item["name"].is_a?(String) } &&
188
+ checks.map { |item| item["name"] }.uniq.size == checks.size &&
189
+ (required - checks.map { |item| item["name"] }).empty?
190
+ raise Contract::Invalid, "head assessment requires all executed checks"
191
+ end
192
+ checks.map do |check|
193
+ proof = @check_evidence.call(check.fetch("receipt"), head: head, name: check.fetch("name"), historical: historical)
194
+ raise Contract::Invalid, "canonical head check evidence is unavailable" unless proof.is_a?(Hash) && !proof.empty?
195
+ check.merge("execution_proof" => proof)
196
+ end
197
+ end
198
+
183
199
  def reassessment(previous)
184
200
  directory = previous["source_id"].split("#", 2).first
185
201
  finding = feedback(File.expand_path(directory, @repo_root)).find do |source|
@@ -208,7 +224,6 @@ module Ace
208
224
  end
209
225
  producer = Contract.string!(data["producer"], "approval producer")
210
226
  reviewer = Contract.string!(data["reviewer"], "approval reviewer")
211
- raise Contract::Invalid, "independent reviewer required (self-approval)" if producer == reviewer
212
227
  # Approval explicitly identifies completed reviewer reports, rather
213
228
  # than treating a model response or arbitrary file as a verdict.
214
229
  review_refs = data["reports"]
@@ -249,6 +264,7 @@ module Ace
249
264
  approval_receipt = data.fetch("receipt")
250
265
  approval_proof = @approval_evidence.call(approval_receipt, head: binding["head"], artifacts: review_refs,
251
266
  producer: producer, reviewer: reviewer)
267
+ verify_independence!(producer: producer, reviewer: reviewer, proof: approval_proof)
252
268
  checks = data["checks"]
253
269
  raise Contract::Invalid, "approval requires executed checks" unless checks.is_a?(Array) && !checks.empty?
254
270
  check_refs = []
@@ -282,6 +298,16 @@ module Ace
282
298
 
283
299
  private
284
300
 
301
+ def verify_independence!(producer:, reviewer:, proof:)
302
+ accepted = if @review_independence
303
+ @review_independence.call(producer: producer, reviewer: reviewer, proof: proof) == true
304
+ else
305
+ producer != reviewer
306
+ end
307
+ raise Contract::Invalid, "independent reviewer required (self-approval)" unless accepted
308
+ true
309
+ end
310
+
285
311
  def json_keys(value)
286
312
  case value
287
313
  when Hash then value.to_h { |k, v| [k.to_s, json_keys(v)] }
@@ -68,7 +68,7 @@ module Ace
68
68
  envelope["sha256"] == Atoms::CampaignContract.digest(record)
69
69
  raise Atoms::CampaignContract::Invalid, "corrupt campaign #{id}: identity or checksum mismatch"
70
70
  end
71
- %w[subject contract_identity contract policy profile bounds phases execution_attempts inherited_findings assessments attempts rounds head_transitions].each do |key|
71
+ %w[subject contract_identity contract policy profile bounds phases execution_attempts inherited_findings assessments attempts rounds head_transitions head_assessments].each do |key|
72
72
  raise Atoms::CampaignContract::Invalid, "corrupt campaign #{id}: missing #{key}" unless record.key?(key)
73
73
  end
74
74
  contract = Atoms::CampaignContract
@@ -80,7 +80,7 @@ module Ace
80
80
  raise contract::Invalid, "corrupt campaign #{id}: requirements digest mismatch"
81
81
  end
82
82
  contract.policy!(record["policy"])
83
- %w[phases execution_attempts inherited_findings assessments attempts rounds head_transitions].each do |key|
83
+ %w[phases execution_attempts inherited_findings assessments attempts rounds head_transitions head_assessments].each do |key|
84
84
  unless record[key].is_a?(Array) && record[key].all? { |entry| entry.is_a?(Hash) }
85
85
  raise contract::Invalid, "corrupt campaign #{id}: invalid #{key} records"
86
86
  end
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Review
5
+ module Organisms
6
+ # A head assessment extends one accepted campaign prefix. Only an
7
+ # installed evidence owner can supply the source comparison, supervisor
8
+ # provenance and check receipts. This is the existing campaign history,
9
+ # not another execution or reviewer ledger.
10
+ # Host: CampaignManager supplies the store, evidence, immutable projection,
11
+ # exact-prefix verification, revisions, observation and acceptance helpers.
12
+ module CampaignHeadAssessments
13
+ Contract = Atoms::CampaignContract
14
+ SNAPSHOT_PREFIX_FIELDS = %w[assessments attempts execution_attempts head_assessments phases rounds].freeze
15
+ HEAD_PROOF_FIELDS = %w[authority campaign_id checks classification context from predecessor_identity rationale schema to].freeze
16
+
17
+ def record_head_assessment(id, input, dry_run: false, consumer_profiles: nil)
18
+ unless @head_assessment_evidence && input.is_a?(Hash) && input.keys.sort == %w[id predecessor reference]
19
+ raise Contract::Invalid, "head assessment requires its canonical evidence owner"
20
+ end
21
+ Contract.id!(input["id"], "head assessment ID")
22
+ Contract.object!(input["reference"], "head assessment reference")
23
+ digest = Contract.digest(input)
24
+ store.transaction(dry_run: dry_run) do
25
+ record = store.read(id)
26
+ if (prior = record.fetch("head_assessments").find { |entry| entry["id"] == input["id"] })
27
+ raise Contract::Invalid, "conflicting head assessment replay" unless prior["input_digest"] == digest
28
+ next projection(record).merge("replayed" => true, "dry_run" => dry_run)
29
+ end
30
+ raise Contract::Invalid, "campaign contract is superseded" if record["successor"]
31
+ verify_consumer_policy!(record, consumer_profiles) if consumer_profiles
32
+ predecessor = input.fetch("predecessor")
33
+ prefix = accepted_snapshot_prefix!(record, predecessor)
34
+ unless SNAPSHOT_PREFIX_FIELDS.all? { |field| prefix.fetch(field).size == record.fetch(field).size }
35
+ raise Contract::Invalid, "head assessment predecessor is not the current campaign prefix"
36
+ end
37
+ verify_retained_acceptance!(prefix, predecessor)
38
+ head, base = current_revisions(record)
39
+ unless clean_candidate? && [head, base] != predecessor.values_at("head", "base")
40
+ raise Contract::Invalid, "head assessment requires a changed clean candidate"
41
+ end
42
+ proof, checks = head_assessment_proof!(record, reference: input.fetch("reference"), predecessor: predecessor,
43
+ head: head, base: base, historical: false)
44
+ event = immutable_projection(input.merge("input_digest" => digest, "proof" => proof, "checks" => checks))
45
+ record.fetch("head_assessments") << event
46
+ observe(record, head, base)
47
+ store.write(record) unless dry_run
48
+ projection(record).merge("dry_run" => dry_run)
49
+ end
50
+ end
51
+
52
+ private
53
+
54
+ def head_assessment_proof!(record, reference:, predecessor:, head:, base:, historical:)
55
+ raise Contract::Invalid, "head assessment evidence owner is unavailable" unless @head_assessment_evidence
56
+ proof = @head_assessment_evidence.call(reference, campaign: record.slice("id", "subject", "contract_identity", "policy"),
57
+ predecessor: predecessor, head: head, base: base, historical: historical)
58
+ unless proof.is_a?(Hash) && proof.keys.sort == HEAD_PROOF_FIELDS &&
59
+ proof["schema"] == "ace.review.head-assessment/v1" && proof["campaign_id"] == record.fetch("id") &&
60
+ proof["predecessor_identity"] == predecessor.fetch("result_identity") &&
61
+ proof["from"] == predecessor.slice("head", "base") && proof["to"] == {"head" => head, "base" => base} &&
62
+ proof["authority"].is_a?(Hash) && !proof["authority"].empty? &&
63
+ proof["rationale"].is_a?(String) && proof["rationale"].strip.bytesize.between?(1, 8192) &&
64
+ JSON.generate(proof).bytesize <= 65_536
65
+ raise Contract::Invalid, "canonical head assessment binding differs"
66
+ end
67
+ context = proof["context"]
68
+ unless context.is_a?(Hash) && context.keys.sort == %w[bytes sha256 text_only] &&
69
+ context["sha256"].is_a?(String) && context["sha256"].match?(/\A[0-9a-f]{64}\z/) &&
70
+ context["bytes"].is_a?(Integer) && context["bytes"].between?(1, Contract::MAX_HEAD_CONTEXT_BYTES) &&
71
+ [true, false].include?(context["text_only"])
72
+ raise Contract::Invalid, "complete canonical head context is unavailable"
73
+ end
74
+ # This path carries a prior independent review. Material or uncertain
75
+ # changes need an actually executed delta round through the campaign
76
+ # owner; a classification label cannot replace that review.
77
+ unless proof["classification"] == "nonsemantic" && context["text_only"]
78
+ raise Contract::Invalid, "head change requires independent delta review"
79
+ end
80
+ checks = @evidence.head_checks(proof.fetch("checks"), head: head,
81
+ required: record.fetch("policy").fetch("required_checks"), historical: historical)
82
+ [immutable_projection(proof), immutable_projection(checks)]
83
+ rescue KeyError, TypeError
84
+ raise Contract::Invalid, "canonical head assessment is incomplete"
85
+ end
86
+
87
+ def verify_head_carry!(record, head:, base:, historical:)
88
+ current = record
89
+ target_head, target_base = head, base
90
+ first = true
91
+ loop do
92
+ round = current.fetch("rounds").last
93
+ raise Contract::Invalid, "head carry has no independent reviewed round" unless round && round["approval"]
94
+ return true if round.fetch("binding").values_at("head", "base") == [target_head, target_base]
95
+ event = current.fetch("head_assessments").last
96
+ raise Contract::Invalid, "head change has no canonical assessment" unless event
97
+ predecessor = event.fetch("predecessor")
98
+ prefix = accepted_snapshot_prefix!(current, predecessor)
99
+ unless SNAPSHOT_PREFIX_FIELDS.all? { |field|
100
+ prefix.fetch(field).size == current.fetch(field).size - (field == "head_assessments" ? 1 : 0) }
101
+ raise Contract::Invalid, "head assessment no longer follows its accepted prefix"
102
+ end
103
+ proof, checks = head_assessment_proof!(current, reference: event.fetch("reference"), predecessor: predecessor,
104
+ head: target_head, base: target_base, historical: historical || !first)
105
+ unless event.fetch("proof") == proof && event.fetch("checks").map { |check| check.except("execution_proof") } ==
106
+ checks.map { |check| check.except("execution_proof") }
107
+ raise Contract::Invalid, "retained canonical head assessment changed"
108
+ end
109
+ event.fetch("checks").zip(checks).each do |recorded, observed|
110
+ @evidence.verify_execution_linkage!(recorded.fetch("execution_proof"), observed.fetch("execution_proof"))
111
+ end
112
+ current = prefix
113
+ target_head, target_base = predecessor.values_at("head", "base")
114
+ first = false
115
+ end
116
+ rescue KeyError, TypeError
117
+ raise Contract::Invalid, "retained head assessment chain is unavailable"
118
+ end
119
+
120
+ def verify_retained_acceptance!(record, result)
121
+ projected = Atoms::CampaignProjection.build(record)
122
+ blockers = projected.fetch("open_findings").any? { |finding| %w[critical high medium].include?(finding["priority"]) }
123
+ unless projected.fetch("search_converged") && !blockers && projected.fetch("recurring_blockers").empty? && !execution_failure(record)
124
+ raise Contract::Invalid, "retained campaign prefix was not accepted"
125
+ end
126
+ record.fetch("attempts").each do |attempt|
127
+ attempt.fetch("sessions").select { |session| session["completed"] }.each do |session|
128
+ @evidence.verify_session_authority(session, head: attempt.fetch("binding").fetch("head"), historical: true)
129
+ end
130
+ @evidence.verify_approval_authority(attempt.fetch("approval"), historical: true) if attempt["approval"]
131
+ end
132
+ verify_head_carry!(record, head: result.fetch("head"), base: result.fetch("base"), historical: true)
133
+ end
134
+ end
135
+ end
136
+ end
137
+ end
@@ -5,6 +5,7 @@ require "time"
5
5
  require_relative "../atoms/campaign_projection"
6
6
  require_relative "../molecules/campaign_store"
7
7
  require_relative "../molecules/campaign_evidence"
8
+ require_relative "campaign_head_assessments"
8
9
 
9
10
  module Ace
10
11
  module Review
@@ -12,16 +13,18 @@ module Ace
12
13
  # Campaign history authority. Assignment attempts and approvals remain
13
14
  # owned by ace-assign; finish produces only a validated local result.
14
15
  class CampaignManager
16
+ include CampaignHeadAssessments
15
17
  Contract = Atoms::CampaignContract
16
18
  Policy = Atoms::CampaignPolicy
17
19
  attr_reader :store
18
20
 
19
- def initialize(repo_root: Dir.pwd, store: nil, revisions: nil, check_evidence: nil, review_evidence: nil, approval_evidence: nil, artifact_paths: {}, candidate_reader: nil)
21
+ def initialize(repo_root: Dir.pwd, store: nil, revisions: nil, check_evidence: nil, review_evidence: nil, approval_evidence: nil, artifact_paths: {}, candidate_reader: nil, head_assessment_evidence: nil, review_independence: nil)
20
22
  @repo_root = File.realpath(repo_root)
21
23
  raise ArgumentError, "canonical candidate cannot use a revision override" if candidate_reader && revisions
22
24
  @candidate_reader = candidate_reader
25
+ @head_assessment_evidence = head_assessment_evidence
23
26
  @store = store || Molecules::CampaignStore.new(root: File.join(@repo_root, ".ace-local/review/campaigns"))
24
- @evidence = Molecules::CampaignEvidence.new(repo_root: @repo_root, check_evidence: check_evidence, review_evidence: review_evidence, approval_evidence: approval_evidence, artifact_paths: artifact_paths)
27
+ @evidence = Molecules::CampaignEvidence.new(repo_root: @repo_root, check_evidence: check_evidence, review_evidence: review_evidence, approval_evidence: approval_evidence, artifact_paths: artifact_paths, review_independence: review_independence)
25
28
  @live_git = revisions.nil?
26
29
  @revisions = revisions || method(:git_revision)
27
30
  end
@@ -70,7 +73,7 @@ module Ace
70
73
  "execution_attempts" => [], "created_at" => Time.now.utc.iso8601(6),
71
74
  "predecessor" => previous&.dig("id"), "successor_reason" => reason,
72
75
  "inherited_findings" => inherited, "assessments" => [], "attempts" => [], "rounds" => [],
73
- "head_transitions" => []}
76
+ "head_transitions" => [], "head_assessments" => []}
74
77
  unless dry_run
75
78
  if previous
76
79
  # Publish supersession first: an interrupted successor write
@@ -312,7 +315,7 @@ module Ace
312
315
  end
313
316
  end
314
317
 
315
- ACCEPTED_SNAPSHOT_FIELDS = %w[accepted base campaign_id contract_identity dry_run effective_policy head prefix producer result_identity reviewer schema subject].freeze
318
+ ACCEPTED_SNAPSHOT_FIELDS = %w[accepted base campaign_id contract_identity dry_run effective_policy head head_assessment_digest prefix producer result_identity reviewed_base reviewed_head reviewer schema subject].freeze
316
319
 
317
320
  # Bounded serialization of the same accepted record, not new authority.
318
321
  def accepted_result_snapshot(id)
@@ -326,14 +329,17 @@ module Ace
326
329
 
327
330
  private def accepted_snapshot(record, current)
328
331
  approval = current.fetch("rounds").last.fetch("approval")
329
- immutable_projection({"schema" => "ace.review.accepted-result/v1", "campaign_id" => record.fetch("id"),
332
+ immutable_projection({"schema" => "ace.review.accepted-result/v2", "campaign_id" => record.fetch("id"),
330
333
  "accepted" => true, "dry_run" => false, "subject" => current.fetch("subject"),
331
334
  "contract_identity" => current.fetch("contract_identity"), "effective_policy" => current.fetch("effective_policy"),
332
335
  "head" => current.fetch("evidence").fetch("current_head"), "base" => current.fetch("evidence").fetch("current_base"),
336
+ "reviewed_head" => approval.fetch("head"), "reviewed_base" => approval.fetch("base"),
337
+ "head_assessment_digest" => Contract.digest(record.fetch("head_assessments")),
333
338
  "producer" => approval.fetch("producer"), "reviewer" => approval.fetch("reviewer"),
334
339
  "result_identity" => current.fetch("result_identity"), "prefix" => {
335
340
  "attempts" => record.fetch("attempts").length, "rounds" => record.fetch("rounds").length,
336
341
  "assessments" => record.fetch("assessments").length,
342
+ "head_assessments" => record.fetch("head_assessments").length,
337
343
  "phases" => record.fetch("phases").length, "execution_attempts" => record.fetch("execution_attempts").length}})
338
344
  end
339
345
 
@@ -342,6 +348,9 @@ module Ace
342
348
  def with_verified_result!(result:, subject:, contract_identity:, policy:, head:, base:, producer:, reviewer:, consumer_profiles: nil)
343
349
  raise ArgumentError, "verified campaign result requires a block" unless block_given?
344
350
  Contract.object!(result, "campaign result")
351
+ if result.key?("schema") && result["schema"] != "ace.review.accepted-result/v2"
352
+ raise Contract::Invalid, "unsupported accepted campaign snapshot schema"
353
+ end
345
354
  subject = Contract.subject!(subject)
346
355
  policy = Contract.policy!(policy)
347
356
  id = Contract.id!(result["campaign_id"], "campaign ID")
@@ -350,10 +359,10 @@ module Ace
350
359
  verify_consumer_policy!(record, consumer_profiles) if consumer_profiles
351
360
  current = projection(record)
352
361
  approval = current["rounds"].last&.fetch("approval", nil)
353
- compact = result["schema"] == "ace.review.accepted-result/v1"
362
+ compact = result["schema"] == "ace.review.accepted-result/v2"
354
363
  if compact
355
364
  prefix = accepted_snapshot_prefix!(record, result)
356
- unless prefix.values_at("attempts", "rounds", "assessments", "phases", "execution_attempts").map(&:length) == record.values_at("attempts", "rounds", "assessments", "phases", "execution_attempts").map(&:length)
365
+ unless SNAPSHOT_PREFIX_FIELDS.all? { |field| prefix.fetch(field).size == record.fetch(field).size }
357
366
  raise Contract::Invalid, "campaign result prefix is no longer current"
358
367
  end
359
368
  end
@@ -392,33 +401,30 @@ module Ace
392
401
  raise Contract::Invalid, "retained campaign result differs from original store evidence"
393
402
  end
394
403
  approval = rounds.last.fetch("approval")
395
- unless approval && approval.values_at("producer", "reviewer", "head") == [producer, reviewer, head]
404
+ unless approval && approval.values_at("producer", "reviewer", "head", "base") ==
405
+ [producer, reviewer, result.fetch("reviewed_head"), result.fetch("reviewed_base")]
396
406
  raise Contract::Invalid, "retained campaign approval actors differ"
397
407
  end
398
- attempts.each do |attempt|
399
- attempt.fetch("sessions").select { |session| session["completed"] }.each do |session|
400
- @evidence.verify_session_authority(session, head: attempt.fetch("binding").fetch("head"), historical: true)
401
- end
402
- @evidence.verify_approval_authority(attempt.fetch("approval"), historical: true) if attempt["approval"]
403
- end
408
+ verify_retained_acceptance!(snapshot, result)
404
409
  true
405
410
  end
406
411
  end
407
412
 
408
413
  private def accepted_snapshot_prefix!(record, result)
409
414
  prefix = result["prefix"]
410
- unless result.keys.sort == ACCEPTED_SNAPSHOT_FIELDS && result["schema"] == "ace.review.accepted-result/v1" &&
411
- prefix.is_a?(Hash) && prefix.keys.sort == %w[assessments attempts execution_attempts phases rounds] &&
415
+ unless result.keys.sort == ACCEPTED_SNAPSHOT_FIELDS && result["schema"] == "ace.review.accepted-result/v2" &&
416
+ prefix.is_a?(Hash) && prefix.keys.sort == SNAPSHOT_PREFIX_FIELDS &&
412
417
  prefix.all? { |key, count| count.is_a?(Integer) && count.between?(0, record.fetch(key).length) } &&
413
418
  prefix.fetch("rounds").positive? && result["accepted"] == true && result["dry_run"] == false &&
414
419
  result.values_at("campaign_id", "subject", "contract_identity", "effective_policy") ==
415
420
  record.values_at("id", "subject", "contract_identity", "policy") &&
416
- %w[head base].all? { |key| result[key].is_a?(String) && result[key].match?(/\A[0-9a-f]{40}\z/) }
421
+ %w[head base reviewed_head reviewed_base].all? { |key| result[key].is_a?(String) && result[key].match?(/\A[0-9a-f]{40}\z/) }
417
422
  raise Contract::Invalid, "accepted campaign snapshot selectors differ"
418
423
  end
419
424
  snapshot = record.merge(prefix.to_h { |key, count| [key, record.fetch(key).first(count)] }, "successor" => nil)
420
425
  approval = snapshot.fetch("rounds").last.fetch("approval")
421
- unless approval && approval.values_at("producer", "reviewer", "head") == result.values_at("producer", "reviewer", "head") &&
426
+ unless approval && approval.values_at("producer", "reviewer", "head", "base") == result.values_at("producer", "reviewer", "reviewed_head", "reviewed_base") &&
427
+ Contract.digest(snapshot.fetch("head_assessments")) == result["head_assessment_digest"] &&
422
428
  result_identity_for(snapshot, result.fetch("head"), result.fetch("base")) == result["result_identity"]
423
429
  raise Contract::Invalid, "accepted campaign snapshot identity differs from retained prefix"
424
430
  end
@@ -431,6 +437,7 @@ module Ace
431
437
  Contract.digest({"campaign_id" => record["id"], "contract_identity" => record["contract_identity"],
432
438
  "policy" => record["policy"], "profile" => record["profile"], "bounds" => record["bounds"],
433
439
  "phases" => record["phases"], "execution_attempts" => record["execution_attempts"], "attempts" => record["attempts"], "assessments" => record["assessments"],
440
+ "head_assessments" => record.fetch("head_assessments"),
434
441
  "current_head" => head, "current_base" => base, "successor" => record["successor"]})
435
442
  end
436
443
 
@@ -710,7 +717,15 @@ module Ace
710
717
  later_blocker_update = later_attempts.flat_map { |attempt| attempt["assessments"] }.any? do |finding|
711
718
  blocker_ids.include?(finding["id"])
712
719
  end
713
- current_evidence = !!(round && available && head && base && source_head == head && source_base == base &&
720
+ accepted_head = source_head == head && source_base == base
721
+ if !accepted_head && round && available && head && base
722
+ begin
723
+ accepted_head = verify_head_carry!(record, head: head, base: base, historical: false)
724
+ rescue Contract::Invalid => e
725
+ error = e.message
726
+ end
727
+ end
728
+ current_evidence = !!(round && available && head && base && accepted_head &&
714
729
  clean_candidate? && !later_blocker_update)
715
730
  blockers = result["open_findings"].select { |f| %w[critical high medium].include?(f["priority"]) }
716
731
  reasons = []
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ace
4
4
  module Review
5
- VERSION = "0.60.3"
5
+ VERSION = "0.61.0"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ace-review
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.60.3
4
+ version: 0.61.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Michal Czyz
@@ -415,6 +415,7 @@ files:
415
415
  - lib/ace/review/molecules/subject_filter.rb
416
416
  - lib/ace/review/molecules/task_report_saver.rb
417
417
  - lib/ace/review/molecules/task_resolver.rb
418
+ - lib/ace/review/organisms/campaign_head_assessments.rb
418
419
  - lib/ace/review/organisms/campaign_manager.rb
419
420
  - lib/ace/review/organisms/feedback_manager.rb
420
421
  - lib/ace/review/organisms/prepared_candidate_review.rb