fix: validate Gitea conversation caches and safe commit messages

This commit is contained in:
2026-10-13 13:50:10 +00:00
parent a7a13aa4d5
commit a74fb747a5
6 changed files with 227 additions and 17 deletions
@@ -1,7 +1,7 @@
---
name: gitea-issue-rework
description: Gitea PR 검토 지적을 계약에 맞춰 재작업하고 검증한다.
version: 0.2.15
version: 0.2.16
author: genichin, Hermes Agent
license: MIT
platforms: [linux]
@@ -178,7 +178,7 @@ Reviewer가 rework head를 새 snapshot으로 다시 검토해야 하며 이 com
- `correlation`은 `owner`, `repository`, base-10 Issue 번호, base-10 PR 번호, base-10 review ID, lowercase reviewed head, lowercase final head와 lowercase contract digest를 이 순서로 NUL 하나씩 구분한 UTF-8 bytes의 SHA-256이다. `result-sha256`은 correlation 입력에 넣지 않는다.
- 게시 전 exact PR top-level comment endpoint target과 fresh PR metadata binding을 검증하고 그 scoped collection만 complete pagination한 뒤 같은 correlation을 찾는다. Source Issue comment endpoint, inline-comment·review-reply endpoint와 native review collection은 검색 집합에서 제외하며 endpoint scope와 PR relation을 구분할 수 없으면 fail-closed한다. 0개면 exact body를 한 번 생성하고, 1개이며 author stable account ID·body·marker·digest가 모두 같으면 write 없는 no-op으로 재사용한다. 같은 correlation의 body가 다르거나 2개 이상이면 중복을 만들지 않고 fail-closed한다.
- Comment create timeout·오류 뒤에는 blind retry하지 않는다. Complete comment collection을 한 번 authoritative하게 reconcile해 exact result 하나가 확인될 때만 성공으로 판정한다.
- 게시 또는 재사용 후 exact comment ID, author stable account ID, body, created·updated timestamp, marker, digest, verified endpoint target, fresh PR metadata binding과 PR top-level collection cardinality를 readback한다. 신규 게시에서는 scoped PR top-level comment collection에 exact comment 하나만 추가되어야 한다. Provider의 aggregate comment-count field는 fresh top-level·review·inline comment cardinality로 설명 가능해야 하며 설명할 수 없으면 fail-closed한다. 신규 게시에서 PR·underlying PR-Issue의 `updated_at`은 불변이거나 exact 신규 comment 생성 timestamp와 같을 때만 허용한다. 이는 다른 native metadata가 불변인 상태의 state-delta/exact-timestamp admission이며, GET readback만으로 독립적인 causal event attestation을 제공하지 않는다. Source Issue `<issue-number>`는 `updated_at`을 포함한 전체 native metadata와 comment collection이 완전히 불변이어야 하며, provider relation을 이유로 timestamp 예외를 허용하지 않는다. Existing exact comment 재사용에서는 PR top-level collection cardinality, PR·underlying issue와 Source Issue field가 모두 불변이어야 한다. 기존 review collection 또는 그 밖의 PR field 변경은 허용하지 않는다.
- 게시 또는 재사용 후 exact comment ID, author stable account ID, body, created·updated timestamp, marker, digest, verified endpoint target, fresh PR metadata binding과 PR top-level collection cardinality를 readback한다. 신규 게시에서는 scoped PR top-level comment collection에 exact comment 하나만 추가되어야 한다. Provider의 aggregate comment-count field는 명시된 version-specific 계약으로 설명 가능해야 하며 설명할 수 없으면 fail-closed한다. `gitea-conversation-v1` cache opt-in은 [cached conversation admission](references/readback.md#cached-conversation-admission)에 따라 snapshot의 bounded stale lag만 허용하고, 신규 게시 후에는 실제 전체 conversation 개수와 exact-match를 요구한다. Cached baseline에 무조건 `+1`을 적용하지 않으며 reuse의 counter·metadata 불변 조건은 유지한다. 신규 게시에서 PR·underlying PR-Issue의 `updated_at`은 불변이거나 exact 신규 comment 생성 timestamp와 같을 때만 허용한다. 이는 다른 native metadata가 불변인 상태의 state-delta/exact-timestamp admission이며, GET readback만으로 독립적인 causal event attestation을 제공하지 않는다. Source Issue `<issue-number>`는 `updated_at`을 포함한 전체 native metadata와 comment collection이 완전히 불변이어야 하며, provider relation을 이유로 timestamp 예외를 허용하지 않는다. Existing exact comment 재사용에서는 PR top-level collection cardinality, PR·underlying issue와 Source Issue field가 모두 불변이어야 한다. 기존 review collection 또는 그 밖의 PR field 변경은 허용하지 않는다.
완료 조건: exact final head와 finding matrix에 결합된 canonical comment가 impl role author로 정확히 하나 존재하고 authoritative readback을 통과한다.
@@ -277,7 +277,7 @@ pytest를 이미 사용하는 프로젝트의 evidence command에만 적용한
3. Worktree의 staged·unstaged·untracked state와 미완료 Git operation을 확인한다.
4. Contract Preservation Inventory가 필요한 경우 final tree에 다시 대응해 baseline 항목 수와 실제 상태를 검증하고 `missing=0`, `ambiguous=0`이 아니면 stage하거나 commit하지 않는다.
5. Exact allowlist만 stage하고 staged diff를 다시 읽는다.
6. Repository convention에 맞는 새 non-amended rework commit을 생성한다. Review별 traceability가 필요하면 commit message나 검증 기록에 PR과 review ID를 포함하되 review body를 그대로 복제하지 않는다.
6. Commit 전에 subject와 body를 공통 [commit-message reference safety](../../../../operations/repository-cache.md#commit-message-reference-safety) 규칙에 따라 확인한다. Protected Issue metadata가 immutable이어야 하면 hash-number auto-reference와 parser-triggering closing/reopening 문구를 피하고 `Issue 18 / PR 19 / review 76` 또는 evidence로 추적한다. 기본은 새 non-amended commit이며, amend는 명시적 사용자 승인·미게시 증명·tree 동일성 증명과 새 SHA exact verification이 모두 있을 때만 허용된다. 일반 amend 권한으로 해석하지 않는다.
7. Commit 후 clean HEAD에서 최종 formatter, test와 필요한 runtime·browser 검증을 다시 실행한다. 이 뒤 repository file을 바꾸면 영향을 받은 검증을 다시 실행한다.
8. 최종 검증 기록은 exact HEAD 또는 검증한 source tree, command, exit code, pass·skip·fail 수치, 환경, relevant output과 reviewer가 접근 가능한 evidence location을 포함하며, PR body·결과 comment의 각 실행 주장과 수치는 해당 location에 일대일 대응시킨다. Hermes 내부 tool transcript, delegate 자기보고, 삭제 예정 임시 파일, reviewer가 접근할 수 없는 local path와 요약 수치만 있는 기록은 evidence로 인정하지 않고 `evidence_gap` 또는 `partial`로 유지한다. 새 evidence는 checkout 밖 정본 verification root에 보존하여 final HEAD를 기록하며 evidence-only descendant를 만들지 않는다. 과거 tracked evidence의 archived 분석은 fresh 실행·원격 head 검증과 구분한다.
@@ -34,7 +34,7 @@ Initial intake validates collected rows, endpoint/pagination provenance, duplica
The receipt adds `counter_diagnostics`, with keys `inline`, `pr` and `pr_issue`. Each entry records the declared `field`/`components`, unmodified `observed` value, collection-derived `expected` value, and `status`: `match`, `mismatch`, or `unverified` (missing, negative or non-integer observed counter, including booleans). A match means only that the explicitly declared formula matches this observation; it does not attest deployed-provider semantics or counter freshness. Diagnostics never auto-select a projection, modify raw counts, or grant edit/publication permission. Missing/malformed PR counters do not bypass malformed native records, failed GETs, incomplete lists or drift checks.
Provider schema/version binding and explicit declarations are still required. Rework/no-op/blocked decisions retain their role/head/contract/state rules regardless of diagnostics. `snapshot`/`verify-live` publication readback continues to require strict provider counter crosschecks and its existing explicitly declared fallback; an initial receipt cannot substitute for that verification.
Initial diagnostic intake remains unchanged. Publication requires strict counter validation and explicit version-specific cache opt-in only: optional aggregate semantics are supported for `cache_mode: "gitea-conversation-v1"`, version `1.27.3`, with `comments` components exactly `["top_level", "reviews"]`; omission of the mode retains strict legacy behavior. No numeric fitting or automatic profile/provider adjustment. Rework/no-op/blocked decisions retain their role/head/contract/state rules regardless of diagnostics. `snapshot`/`verify-live` publication readback continues to require strict provider counter crosschecks and its existing explicitly declared fallback; an initial receipt cannot substitute for that verification.
## Derived decisions
@@ -36,28 +36,52 @@ pre-write delegation admission.
Schema: `gitea-agent.rework-provider/v1`. The contract explicitly declares the
aggregate semantics for PR and underlying PR-Issue `comments` fields. Each
`components` array contains unique values from `top_level`, `reviews`, `inline`
and must include `top_level`. Choose the reviewed deployment semantics, not the
and must include `top_level`. An optional `cache_mode: "gitea-conversation-v1"`
on each aggregate scope is accepted only for provider version `1.27.3`, field
`comments`, and components consisting of exactly `top_level` and `reviews`.
Omission retains strict legacy behavior. Choose the reviewed deployment semantics, not the
combination that happens to fit a current response. There is no guessed version
or automatic capability downgrade.
Shape example only, **not a claim about this deployment**:
Shape example only: upstream v1.27.3 source supports this shape; it does not attest that a custom build matches upstream:
```json
{
"schema": "gitea-agent.rework-provider/v1",
"version": "<independently-observed-version>",
"version": "1.27.3",
"aggregate": {
"pr": {"field": "comments", "components": ["top_level"]},
"pr_issue": {"field": "comments", "components": ["top_level"]}
"pr": {"field": "comments", "components": ["top_level", "reviews"], "cache_mode": "gitea-conversation-v1"},
"pr_issue": {"field": "comments", "components": ["top_level", "reviews"], "cache_mode": "gitea-conversation-v1"}
},
"inline": {
"mode": "per_review",
"field": "review_comments",
"components": ["inline"]
"components": ["reviews"]
}
}
```
### Cached conversation admission
For the explicit cache mode, snapshot counters must be present non-boolean,
nonnegative integers no greater than the complete collection-derived conversation
count. This admits bounded stale lag, not arbitrary mismatch; counters ahead of
the collected count, missing values and malformed scalars remain BLOCK.
Upstream `v1.27.3` `services/convert/issue.go` reads stored `Issue.NumComments`.
`models/issues/comment.go` counts plain comments and review-summary comments;
review creation bypasses the recomputation that plain-comment creation performs.
This source pin explains the opt-in policy, not a deployed custom-build attestation.
For `create`, fresh readback must prove exactly one canonical top-level addition
and the recomputed aggregate must equal the complete post-write conversation
count. A stale baseline counter `0` with one review can become `2` after the new
plain comment; `before counter + 1` is not the cache-mode rule. All old rows,
reviews, inline evidence, Source Issue metadata/comments, identity, body, head
and unrelated native fields retain their existing protections. For `reuse`, no
addition or counter/metadata refresh is admitted: even a legitimate-looking
cache change without the authorized create action is BLOCK. Strict contracts
without cache opt-in retain exact snapshot projections and the original delta.
For `inline.components`, the explicit projection modes are `["inline"]`,
`["reviews"]`, and `["reviews", "inline"]`. The review-only mode projects
only the number of reviews into `review_comments`; it does not add per-review
@@ -138,11 +138,16 @@ class ReworkReadbackAdapter:
for scope in ('pr','pr_issue'):
spec = object_value(aggregate[scope])
parts = spec.get('components')
if (set(spec) != {'field','components'} or spec.get('field') != 'comments'
mode = spec.get('cache_mode')
allowed_keys = {'field','components'} if mode is None and 'cache_mode' not in spec else {'field','components','cache_mode'}
if (set(spec) != allowed_keys or spec.get('field') != 'comments'
or type(parts) is not list or not parts
or any(type(part) is not str or part not in ('top_level','reviews','inline') for part in parts)
or len(parts) != len(set(parts)) or 'top_level' not in parts):
raise ReadbackError('invalid aggregate count declaration')
if 'cache_mode' in spec and (mode != 'gitea-conversation-v1' or contract['version'] != '1.27.3'
or set(parts) != {'top_level','reviews'}):
raise ReadbackError('invalid aggregate cache declaration')
inline = object_value(contract.get('inline'))
if (set(inline) != {'mode','field','components'}
or inline.get('mode') not in ('per_review','per_review_or_pr_count')
@@ -392,7 +397,12 @@ class ReworkReadbackAdapter:
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']):
observed = raw[scope].get(spec['field'])
computed = sum(counts[name] for name in spec['components'])
if spec.get('cache_mode') == 'gitea-conversation-v1':
if integer(observed) > computed:
raise ReadbackError('cached provider aggregate count exceeds collection')
elif integer(observed) != computed:
raise ReadbackError('provider aggregate count mismatch')
return counts
@@ -586,8 +596,17 @@ class ReworkReadbackAdapter:
raise ReadbackError('source Issue metadata drift')
else:
field = self.provider['aggregate'][name]['field']
if integer(now.pop(field)) != integer(before.pop(field)) + (1 if action == 'create' else 0):
spec = self.provider['aggregate'][name]
before_value, now_value = integer(before.get(field)), integer(now.get(field))
if spec.get('cache_mode') == 'gitea-conversation-v1':
desired = (sum(self.counts(a)[part] for part in spec['components'])
if action == 'create' else before_value)
if now_value != desired:
raise ReadbackError('cached PR counter causality mismatch')
elif now_value != before_value + (1 if action == 'create' else 0):
raise ReadbackError('PR counter delta causality mismatch')
before.pop(field)
now.pop(field)
if name != 'source_issue' and action == 'create' and now['updated_at'] != before['updated_at']:
if now['updated_at'] != matched['created_at']:
raise ReadbackError('uncausal Issue or PR timestamp')