[verified] Separate rework intake from publication counter checks

This commit is contained in:
2026-10-13 08:27:25 +00:00
parent e1be0dd526
commit 31ac634585
5 changed files with 194 additions and 23 deletions
@@ -120,8 +120,8 @@ def _validate_raw(adapter, raw, inp):
raise InputGateError('incomplete review comment endpoints')
for rid, collected in raw['inline'].items():
adapter.validate_collection(collected, prefix+'/pulls/'+str(target['pr'])+'/reviews/'+rid+'/comments',
allow_missing=adapter.provider['inline']['mode'] == 'per_review_or_pr_count')
adapter.counts(raw)
allow_missing=False)
adapter.collection_counts(raw)
source = raw['source_issue']
if source['body'].splitlines()[:1] != ['<!-- gitea-agent:discovery-draft/v2 -->']:
raise InputGateError('invalid discovery draft marker')
@@ -225,11 +225,12 @@ def derive_decision(raw, inp, impl_actor_id):
if len(json.dumps(decision,ensure_ascii=False,sort_keys=True).encode()) > 1_000_000: raise InputGateError('decision artifact too large')
return decision
def build_receipt(raw, inp, decision):
def build_receipt(raw, inp, decision, counter_diagnostics):
receipt = {'schema':RECEIPT_SCHEMA,'status':decision['status'],'reason_codes':decision['reason_codes'],
'target':decision['target'],'head':decision['head'],'contract_sha256':decision['contract_sha256'],
'actor':decision['actor'],'input_sha256':digest(inp),'raw_input_snapshot':copy.deepcopy(raw),
'raw_input_sha256':digest(raw),'fresh_GET_provenance':{'kind':'fresh_authenticated_GET','read_only':True},
'counter_diagnostics':copy.deepcopy(counter_diagnostics),
'decision':decision}
if len(json.dumps(receipt, ensure_ascii=False, sort_keys=True, allow_nan=False).encode('utf-8')) > 1_000_000:
raise InputGateError('receipt artifact too large')
@@ -332,13 +332,15 @@ class ReworkReadbackAdapter:
or review['user']['login'] not in ('gitea-review-agent', 'gitea-spec-agent')):
raise ReadbackError('source review author or state mismatch')
def counts(self, raw):
def _collection_counts(self, raw, *, allow_counter_fallback=False):
inline = sum(integer(review.get('comments_count')) for review in raw['reviews']['rows'])
fallback = False
inline_ids = set()
for review in raw['reviews']['rows']:
collected = raw['inline'][str(review['id'])]
if collected['pages'][0]['status'] in (404,405):
if not allow_counter_fallback:
raise ReadbackError('inline collection unavailable for input gate')
if self.provider['inline']['mode'] != 'per_review_or_pr_count':
raise ReadbackError('inline fallback not explicitly authorized')
fallback = True
@@ -347,29 +349,54 @@ class ReworkReadbackAdapter:
for row in collected['rows']:
if row['id'] in inline_ids: raise ReadbackError('duplicate cross-review inline ID')
inline_ids.add(row['id'])
parts = self.provider['inline']['components']
computed = ((inline if 'inline' in parts else 0)
+ (len(raw['reviews']['rows']) if 'reviews' in parts else 0))
field = self.provider['inline']['field']
if field not in raw['pr']:
if (self.provider['inline']['mode'] != 'per_review' or fallback or computed != 0
or len(inline_ids) != 0):
raise ReadbackError('missing inline provider counter')
elif integer(raw['pr'].get(field)) != computed:
raise ReadbackError('inline provider counter crosscheck failed')
if not fallback and len(inline_ids) != inline:
raise ReadbackError('inline record cardinality mismatch')
counts = {'top_level':len(raw['comments']['rows']),
'reviews':len(raw['reviews']['rows']), 'inline':inline}
if integer(raw['source_issue'].get('comments')) != len(raw['source_comments']['rows']):
raise ReadbackError('source collection count mismatch')
return counts, fallback
def collection_counts(self, raw):
"""Initial intake requires real inline records, never a counter-only fallback."""
return self._collection_counts(raw)[0]
def counter_diagnostics(self, raw):
"""Declared PR projections are diagnostic only for complete initial intake."""
counts = self.collection_counts(raw)
diagnostics = {}
projections = {'inline': ('pr', self.provider['inline'])}
projections.update({scope: (scope, self.provider['aggregate'][scope])
for scope in ('pr', 'pr_issue')})
for name, (scope, spec) in projections.items():
observed = raw[scope].get(spec['field'])
expected = sum(counts[part] for part in spec['components'])
valid = type(observed) is int and observed >= 0
diagnostics[name] = {'field':spec['field'], 'components':list(spec['components']),
'observed':observed, 'expected':expected,
'status':('match' if observed == expected else 'mismatch')
if valid else 'unverified'}
return diagnostics
def counts(self, raw):
"""Publication/readback retains strict provider counter crosschecks."""
counts, fallback = self._collection_counts(raw, allow_counter_fallback=True)
parts = self.provider['inline']['components']
computed = sum(counts[part] for part in parts)
field = self.provider['inline']['field']
if field not in raw['pr']:
if (self.provider['inline']['mode'] != 'per_review' or fallback or computed != 0
or counts['inline'] != 0):
raise ReadbackError('missing inline provider counter')
elif integer(raw['pr'].get(field)) != computed:
raise ReadbackError('inline provider counter crosscheck failed')
for scope in ('pr','pr_issue'):
spec = self.provider['aggregate'][scope]
if integer(raw[scope].get(spec['field'])) != sum(counts[name] for name in spec['components']):
raise ReadbackError('provider aggregate count mismatch')
if integer(raw['source_issue'].get('comments')) != len(raw['source_comments']['rows']):
raise ReadbackError('source collection count mismatch')
return counts
def capture_input(self, target, *, require_default_branch=True):
def _capture_raw(self, target, *, require_default_branch):
identity, _ = self.get('/user')
if integer(identity.get('id'), 1) != self.actor_id or identity.get('login') != 'gitea-impl-agent':
raise ReadbackError('authenticated identity mismatch')
@@ -389,11 +416,16 @@ class ReworkReadbackAdapter:
raw['inline'] = {str(review['id']):self.collection(prefix+'/pulls/'+str(target['pr'])+
'/reviews/'+str(review['id'])+'/comments', allow_missing=self.provider['inline']['mode'] == 'per_review_or_pr_count') for review in raw['reviews']['rows']}
self.bind_input(target, raw, require_default_branch=require_default_branch)
self.counts(raw)
return raw
def capture_input(self, target, *, require_default_branch=True):
raw = self._capture_raw(target, require_default_branch=require_default_branch)
self.collection_counts(raw)
return raw
def capture(self, target):
raw = self.capture_input(target, require_default_branch=False)
raw = self._capture_raw(target, require_default_branch=False)
self.counts(raw)
self.bind(target, raw)
return raw
@@ -416,7 +448,7 @@ class ReworkReadbackAdapter:
if digest(first) != digest(second):
raise ReadbackError('concurrent provider drift')
decision = derive_decision(first, value, self.actor_id)
return build_receipt(first, value, decision)
return build_receipt(first, value, decision, self.counter_diagnostics(first))
def snapshot(self, expected):
target = self.target(expected)