Skip to content

fix(proposals): refuse to overwrite existing artifact on approve#27

Merged
plind-junior merged 1 commit into
vouchdev:mainfrom
dripsmvcp:fix/11-approve-idempotent-artifact-guard
May 19, 2026
Merged

fix(proposals): refuse to overwrite existing artifact on approve#27
plind-junior merged 1 commit into
vouchdev:mainfrom
dripsmvcp:fix/11-approve-idempotent-artifact-guard

Conversation

@dripsmvcp

@dripsmvcp dripsmvcp commented May 19, 2026

Copy link
Copy Markdown
Contributor

Summary

  • approve() now refuses to overwrite an artifact that already exists at the target id, surfacing partially-applied state instead of silently rewriting it.

Root Cause

src/vouch/proposals.py:213-269: approve() writes the durable artifact (put_claim / put_page / put_entity / put_relation) before transitioning the proposal to APPROVED via move_proposal_to_decided. If the process crashes between the two operations, the proposal stays PENDING while the artifact is already on disk. A second approval call would then silently overwrite that artifact with new approved_by / created_at metadata.

Fix

Before writing the artifact, check whether one already exists at payload["id"] via the appropriate KBStore.get_* method. If it does, raise ProposalError so the human can reconcile manually (remove the stale artifact and re-approve, or reject the proposal). The check uses public KBStore getters that already raise ArtifactNotFoundError on miss, so no new storage surface is introduced.

The reorder-status-first alternative was rejected because it just moves the same failure window: a crash between status write and artifact write would leave a proposal marked APPROVED with no artifact, which is harder to recover from than the current direction. Failing loud on conflict is the conservative choice and matches the existing proposal.status != PENDING guard.

Test Plan

  • Regression test added: tests/test_storage.py::test_approve_refuses_to_overwrite_existing_artifact — pre-seeds a claim at the target id, calls approve(), asserts ProposalError("already exists") is raised and the pre-existing claim is untouched.
  • Verified the test fails on the unfixed code (DID NOT RAISE) and passes with the guard in place.
  • All existing approve/propose tests still pass: pytest tests/test_storage.py -k "approve or propose"
  • Full suite green: pytest -q55 passed
  • ruff check src testsAll checks passed!
  • mypy src → unchanged (one pre-existing error in context.py unrelated to this PR)
$ pytest tests/test_storage.py -k "approve or propose" -v
============================= test session starts ==============================
platform linux -- Python 3.14.4, pytest-9.0.3, pluggy-1.6.0
collected 31 items / 21 deselected / 10 selected

tests/test_storage.py::test_propose_claim_requires_evidence PASSED
tests/test_storage.py::test_propose_claim_rejects_unknown_source PASSED
tests/test_storage.py::test_propose_claim_dry_run_does_not_persist PASSED
tests/test_storage.py::test_approve_promotes_proposal_to_claim PASSED
tests/test_storage.py::test_approve_writes_audit PASSED
tests/test_storage.py::test_double_approve_rejected PASSED
tests/test_storage.py::test_approve_refuses_to_overwrite_existing_artifact PASSED
tests/test_storage.py::test_propose_entity_then_approve PASSED
tests/test_storage.py::test_propose_relation_then_approve PASSED
tests/test_storage.py::test_propose_page_round_trip_through_approval PASSED

====================== 10 passed, 21 deselected in 0.32s =======================

Closes #11

Summary by CodeRabbit

  • Bug Fixes

    • Approval process now prevents overwriting existing artifacts. When approving a proposal, the system validates that the target artifact does not already exist before proceeding, protecting against accidental data loss.
  • Tests

    • Added regression test to verify approval rejects attempts to overwrite existing artifacts.

Review Change Stack

approve() wrote the durable artifact (put_claim, put_page, put_entity,
put_relation) before transitioning the proposal to APPROVED via
move_proposal_to_decided. A crash between those steps would leave the
proposal PENDING with the artifact on disk, and a retry would silently
rewrite the artifact with new approved_by / created_at metadata.

Guard the artifact write with an existence check; raise ProposalError
when an artifact at the target id already exists so the partial state is
visible and the human can reconcile (remove the artifact or reject the
proposal).

Fixes vouchdev#11
@coderabbitai

coderabbitai Bot commented May 19, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

The PR adds an idempotent guard to approve() that prevents overwriting existing artifacts on disk. Before writing any artifact, the method now checks whether a target artifact (claim, page, entity, or relation) already exists for the proposal's payload id, raising ProposalError if found. This addresses a crash-recovery bug where a process crash between artifact write and proposal status update could cause duplicate artifacts on subsequent approval attempts.

Changes

Overwrite Prevention in Proposal Approval

Layer / File(s) Summary
Prevent artifact overwrites in approve()
src/vouch/proposals.py
Imports ArtifactNotFoundError, adds _ARTIFACT_GETTERS to map proposal kinds to store getter methods, and introduces _ensure_no_existing_artifact() helper that checks for pre-existing artifacts before approve() writes to disk.
Regression test for overwrite prevention
tests/test_storage.py
New test test_approve_refuses_to_overwrite_existing_artifact() creates a pre-existing claim artifact, attempts to approve a proposal with matching slug, verifies approval fails with "already exists" error, and confirms the original artifact remains unchanged while the proposal stays PENDING.

Sequence Diagram

sequenceDiagram
  participant Caller
  participant approve
  participant _ensure_no_existing_artifact
  participant StoreGetter
  participant StorageWrite
  Caller->>approve: approve(proposal)
  approve->>_ensure_no_existing_artifact: check artifact existence
  _ensure_no_existing_artifact->>StoreGetter: get_claim/page/entity/relation
  StoreGetter-->>_ensure_no_existing_artifact: ArtifactNotFoundError or artifact
  alt artifact found
    _ensure_no_existing_artifact-->>approve: raise ProposalError
    approve-->>Caller: ProposalError
  else artifact not found
    _ensure_no_existing_artifact-->>approve: proceed
    approve->>StorageWrite: put_claim/page/entity/relation
    StorageWrite-->>approve: success
    approve->>approve: move_proposal_to_decided
    approve-->>Caller: approval complete
  end
Loading

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~12 minutes

Poem

🐰 A rabbit hops with careful grace,
Checking paths before they trace,
No overwrites shall spoil the day—
Artifacts are safe to stay! ✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: adding a safeguard to prevent approve() from overwriting existing artifacts.
Linked Issues check ✅ Passed The changes directly address issue #11 by implementing an existence check before writing artifacts, making approve() idempotent and preventing silent overwrites.
Out of Scope Changes check ✅ Passed All changes are directly scoped to fixing the race condition in approve(): the artifact existence check, helper function, and corresponding test are all necessary to resolve issue #11.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
tests/test_storage.py (1)

225-245: ⚡ Quick win

Cover the other artifact kinds with this regression too.

This only exercises the claim path, but the new guard added branch-specific getter dispatch for pages, entities, and relations as well. A small parametrized variant here would lock in the full ProposalKind→getter contract and catch mapping regressions early.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_storage.py` around lines 225 - 245, Extend
test_approve_refuses_to_overwrite_existing_artifact to parametrize over
ProposalKind variants (claim, page, entity, relation) and exercise each artifact
getter/putter instead of only get_claim; for each kind create a pre-existing
artifact via the appropriate store.put_<kind> (matching how propose_<kind> /
approve() expect), create a proposal with the same slug via
propose_claim/propose_page/etc. (or by converting propose_claim to a generic
propose helper), then call approve(store, pr.id, approved_by=...) inside
pytest.raises(... "already exists") and assert the original artifact remained
unchanged and proposal.status is ProposalStatus.PENDING; implement a small
mapping in the test from ProposalKind to the store methods and any propose
helper so the same assertions used with get_claim and get_proposal are applied
for pages, entities, and relations as well.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/vouch/proposals.py`:
- Around line 311-323: The current TOCTOU guard in
_ensure_no_existing_artifact(…) is racy because it does a separate read before
the write; instead, modify the KBStore write API (e.g., methods referenced by
_ARTIFACT_GETTERS such as put_claim, put_page, put_<other>) to support an atomic
"create-only" operation (or a fail-if-exists flag) that fails with a distinct
exception (e.g., ArtifactAlreadyExistsError) when the artifact exists, and
update the approve() code path to call those create-only writes directly (or
call the existing write and handle the new exception) rather than performing the
preflight get; finally, remove or stop relying on _ensure_no_existing_artifact
and convert its failure handling to catch the storage-level conflict and raise
ProposalError on that storage-level exception so the write is atomic and cannot
be overwritten by concurrent approves.

---

Nitpick comments:
In `@tests/test_storage.py`:
- Around line 225-245: Extend
test_approve_refuses_to_overwrite_existing_artifact to parametrize over
ProposalKind variants (claim, page, entity, relation) and exercise each artifact
getter/putter instead of only get_claim; for each kind create a pre-existing
artifact via the appropriate store.put_<kind> (matching how propose_<kind> /
approve() expect), create a proposal with the same slug via
propose_claim/propose_page/etc. (or by converting propose_claim to a generic
propose helper), then call approve(store, pr.id, approved_by=...) inside
pytest.raises(... "already exists") and assert the original artifact remained
unchanged and proposal.status is ProposalStatus.PENDING; implement a small
mapping in the test from ProposalKind to the store methods and any propose
helper so the same assertions used with get_claim and get_proposal are applied
for pages, entities, and relations as well.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 3765562b-9fb8-49ff-8775-99961b7767d2

📥 Commits

Reviewing files that changed from the base of the PR and between 89172aa and 075b899.

📒 Files selected for processing (2)
  • src/vouch/proposals.py
  • tests/test_storage.py

Comment thread src/vouch/proposals.py
Comment on lines +311 to +323
def _ensure_no_existing_artifact(
store: KBStore, kind: ProposalKind, artifact_id: str
) -> None:
getter = getattr(store, _ARTIFACT_GETTERS[kind])
try:
getter(artifact_id)
except ArtifactNotFoundError:
return
raise ProposalError(
f"cannot approve: {kind.value} {artifact_id} already exists "
f"(a prior approve may have been interrupted; reconcile manually "
f"by removing the artifact or rejecting this proposal)"
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | 🏗️ Heavy lift

Make the existence guard atomic with the write.

This is still a TOCTOU check: two approve() calls can both observe “missing” here and then both reach put_claim()/put_page()/…; the second write can still overwrite the first artifact. The overwrite refusal needs to live in the storage write path itself (exclusive create or per-artifact locking), not as a separate preflight read.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/vouch/proposals.py` around lines 311 - 323, The current TOCTOU guard in
_ensure_no_existing_artifact(…) is racy because it does a separate read before
the write; instead, modify the KBStore write API (e.g., methods referenced by
_ARTIFACT_GETTERS such as put_claim, put_page, put_<other>) to support an atomic
"create-only" operation (or a fail-if-exists flag) that fails with a distinct
exception (e.g., ArtifactAlreadyExistsError) when the artifact exists, and
update the approve() code path to call those create-only writes directly (or
call the existing write and handle the new exception) rather than performing the
preflight get; finally, remove or stop relying on _ensure_no_existing_artifact
and convert its failure handling to catch the storage-level conflict and raise
ProposalError on that storage-level exception so the write is atomic and cannot
be overwritten by concurrent approves.

@plind-junior

Copy link
Copy Markdown
Member

LGTM!

@plind-junior
plind-junior merged commit abf9c8f into vouchdev:main May 19, 2026
1 check passed
plind-junior added a commit that referenced this pull request May 22, 2026
…act-guard

fix(proposals): refuse to overwrite existing artifact on approve
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Approve Writes Artifact Before Moving Proposal

2 participants