ace-review 0.60.2 → 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 +4 -4
- data/CHANGELOG.md +14 -0
- data/docs/campaigns.md +13 -1
- data/lib/ace/review/atoms/campaign_contract.rb +1 -0
- data/lib/ace/review/molecules/campaign_evidence.rb +28 -2
- data/lib/ace/review/molecules/campaign_execution_evidence.rb +4 -1
- data/lib/ace/review/molecules/campaign_store.rb +2 -2
- data/lib/ace/review/molecules/delta_resolver.rb +47 -0
- data/lib/ace/review/molecules/review_evidence.rb +3 -2
- data/lib/ace/review/organisms/campaign_head_assessments.rb +137 -0
- data/lib/ace/review/organisms/campaign_manager.rb +34 -19
- data/lib/ace/review/version.rb +1 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: afd5ecbdd17112927d36de0cefcea9307419d0dfb28b47a53b8caa27fccee5a9
|
|
4
|
+
data.tar.gz: aee54796dbef9221a2e45d2a5f630228c7b5f9740022edfdb05a6261c2be6960
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1a966051ac35b1a93158cdfa5511178d18a24951a0ce8b55cc811fa6908408a597d7f40f8282effcbbc531e96aa769b8139a8ef033d39a6ebc1a92b19d95948c
|
|
7
|
+
data.tar.gz: 5e8d30ea044003a071efca1ebf7c1f616d13aaecda536f5f2379f776f33913cf9b56cfb12863363760a3b53b9c99f2b1f17c159d9c7ec3d59e6b400590095c93
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,20 @@ 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
|
+
|
|
18
|
+
## [0.60.3] - 2026-10-16
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
|
|
22
|
+
- Keep campaign execution receipts stable across unrelated journal appends, preserve historical-head evidence joins, and make delta reports uniquely attributable without requiring a local branch checkout.
|
|
23
|
+
|
|
10
24
|
## [0.60.2] - 2026-10-16
|
|
11
25
|
|
|
12
26
|
### Changed
|
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.
|
|
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,10 @@ module Ace
|
|
|
68
68
|
data["head"] == head && data["kind"] == kind && data["historical"] == historical
|
|
69
69
|
raise Atoms::CampaignContract::Invalid, "accepted execution receipt identity/head/kind mismatch"
|
|
70
70
|
end
|
|
71
|
-
|
|
71
|
+
# The journal tip is a read cursor, not the identity of an accepted
|
|
72
|
+
# receipt. Unrelated attempts advance it without changing this proof.
|
|
73
|
+
# Keep the receipt digest, exact head and canonical ref in the proof.
|
|
74
|
+
data.except("journal_commit")
|
|
72
75
|
rescue Gem::LoadError, JSON::ParserError, SystemCallError => e
|
|
73
76
|
raise Atoms::CampaignContract::Invalid, "ace-assign execution authority unavailable: #{e.message}"
|
|
74
77
|
end
|
|
@@ -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
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "open3"
|
|
4
4
|
require "yaml"
|
|
5
|
+
require "ace/git/atoms/server_url"
|
|
5
6
|
|
|
6
7
|
module Ace
|
|
7
8
|
module Review
|
|
@@ -46,6 +47,10 @@ module Ace
|
|
|
46
47
|
return {success: false, error: "Delta reference '#{reference}' is not a commit SHA"} unless reference_head.match?(SHA_PATTERN)
|
|
47
48
|
return {success: false, error: "Delta reference head #{reference_head} is not an ancestor of the current PR head #{head}; history was likely rewritten — re-run a full review round"} unless ancestor?(reference_head, head, project_root: project_root)
|
|
48
49
|
|
|
50
|
+
if reference_head == head && newer_local_head?(head, pr_metadata, project_root: project_root)
|
|
51
|
+
return {success: false, error: "PR metadata still reports the reviewed head #{head}, but the local PR branch is newer; reconcile the push and refresh PR metadata before recording an empty delta"}
|
|
52
|
+
end
|
|
53
|
+
|
|
49
54
|
diff = run_git(["diff", "#{reference_head}..#{head}"], project_root: project_root)
|
|
50
55
|
return diff unless diff[:success]
|
|
51
56
|
|
|
@@ -53,6 +58,48 @@ module Ace
|
|
|
53
58
|
session_dir: session_dir, diff: diff[:stdout]}
|
|
54
59
|
end
|
|
55
60
|
|
|
61
|
+
# Repeated provider reads can return the same stale snapshot after a
|
|
62
|
+
# push. A local descendant is evidence against certifying that no code
|
|
63
|
+
# changed. Select the PR branch explicitly; an unrelated checkout does
|
|
64
|
+
# not define that PR's head.
|
|
65
|
+
def self.newer_local_head?(head, metadata, project_root:)
|
|
66
|
+
branch = metadata["headRefName"].to_s
|
|
67
|
+
repository = metadata["head_repository_url"].to_s
|
|
68
|
+
return false if branch.empty? || repository.empty?
|
|
69
|
+
remote = run_git(["for-each-ref", "--format=%(push:remotename)%00%(push:remoteref)", "refs/heads/#{branch}"], project_root: project_root)
|
|
70
|
+
return false unless remote[:success]
|
|
71
|
+
remote_name, remote_ref = remote[:stdout].strip.split("\0", -1)
|
|
72
|
+
return false if remote_name.to_s.empty?
|
|
73
|
+
remote_ref = default_push_ref(branch, remote_name, project_root: project_root) if remote_ref.to_s.empty?
|
|
74
|
+
return false unless remote_ref == "refs/heads/#{branch}"
|
|
75
|
+
urls = run_git(["remote", "get-url", "--push", "--all", remote_name], project_root: project_root)
|
|
76
|
+
return false unless urls[:success] && urls[:stdout].lines.any? do |url|
|
|
77
|
+
Ace::Git::Atoms::ServerUrl.match?(repository, url.strip)
|
|
78
|
+
end
|
|
79
|
+
local = rev_parse("refs/heads/#{branch}", project_root: project_root)
|
|
80
|
+
local[:success] && local[:stdout] != head && ancestor?(head, local[:stdout], project_root: project_root)
|
|
81
|
+
end
|
|
82
|
+
private_class_method :newer_local_head?
|
|
83
|
+
|
|
84
|
+
# Git exposes push:remoteref for explicit push refspecs only. Its
|
|
85
|
+
# default policies use branch names/upstream, not that empty atom.
|
|
86
|
+
def self.default_push_ref(branch, remote, project_root:)
|
|
87
|
+
explicit = run_git(["config", "--get-all", "remote.#{remote}.push"], project_root: project_root)
|
|
88
|
+
return nil if explicit[:success] && !explicit[:stdout].empty?
|
|
89
|
+
policy = run_git(["config", "--get", "push.default"], project_root: project_root)
|
|
90
|
+
upstream = run_git(["config", "--get", "branch.#{branch}.merge"], project_root: project_root)
|
|
91
|
+
case policy[:stdout].to_s.strip
|
|
92
|
+
when "", "simple"
|
|
93
|
+
target = upstream[:stdout].to_s.strip
|
|
94
|
+
fetch = run_git(["config", "--get", "branch.#{branch}.remote"], project_root: project_root)
|
|
95
|
+
triangular = fetch[:success] && fetch[:stdout].strip != remote
|
|
96
|
+
"refs/heads/#{branch}" if triangular || target.empty? || target == "refs/heads/#{branch}"
|
|
97
|
+
when "current", "matching" then "refs/heads/#{branch}"
|
|
98
|
+
when "upstream" then upstream[:stdout].to_s.strip
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
private_class_method :default_push_ref
|
|
102
|
+
|
|
56
103
|
# Find the head SHA recorded by the most recent prior session of this PR.
|
|
57
104
|
#
|
|
58
105
|
# @param pr_url [String] canonical PR URL stored in session metadata
|
|
@@ -23,8 +23,9 @@ module Ace
|
|
|
23
23
|
end
|
|
24
24
|
# Reviewer reports are written as review-report-<model>.md (Ruby API
|
|
25
25
|
# path) or review-<model>.md (CLI provider path); prompt/context and
|
|
26
|
-
# dev-feedback files are not reports.
|
|
27
|
-
|
|
26
|
+
# dev-feedback files are not reports. Single-model and no-op rounds
|
|
27
|
+
# use the maintained review.md output.
|
|
28
|
+
reports = Dir.glob(File.join(dir, "{review-*,review}.md")).select do |path|
|
|
28
29
|
base = File.basename(path)
|
|
29
30
|
!base.end_with?(".prompt.md", ".context.md") &&
|
|
30
31
|
!%w[review-dev-feedback.md].include?(base)
|
|
@@ -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/
|
|
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/
|
|
362
|
+
compact = result["schema"] == "ace.review.accepted-result/v2"
|
|
354
363
|
if compact
|
|
355
364
|
prefix = accepted_snapshot_prefix!(record, result)
|
|
356
|
-
unless
|
|
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") ==
|
|
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
|
-
|
|
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/
|
|
411
|
-
prefix.is_a?(Hash) && prefix.keys.sort ==
|
|
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", "
|
|
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
|
-
|
|
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 = []
|
data/lib/ace/review/version.rb
CHANGED
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.
|
|
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
|