feat(snippets): body provenance — the cache-with-provenance half of the pointer model (#2688)
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Failing after 21s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 53s
CI & Build / Build & push image (push) Successful in 28s
CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 8s
CI & Build / integration (push) Failing after 21s
CI & Build / TypeScript typecheck (push) Successful in 33s
CI & Build / Python tests (push) Successful in 53s
CI & Build / Build & push image (push) Successful in 28s
Decision #2686: the recorded location is the source of truth for a snippet's code; the stored body is a cache of it. data.provenance now records what the cache is a cache OF — commit_sha + fetched_at — as a carried JSONB field following the verification precedent, so no migration is needed and absence keeps today's exact semantics. The rules: provenance follows the code (fresh SHA restamps it, a code edit without one drops it, a metadata edit carries it); writes ABOUT the code carry it — record_verification rebuilds data from scratch and would otherwise erase it silently; an ok verdict at a known commit restamps it, since the checker just proved the cache matches the source there. verify_snippet verdicts also record the commit they ran at, making "the repo moved on since the check" computable once the forge integration lands. create/update/verify MCP tools take commit_sha (git rev-parse HEAD — free for any session with a checkout). Unit tests pin the compose/carry logic; real-Postgres integration tests run create→verify→update end-to-end (#2663: DB paths get no mocked-only coverage). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,175 @@
|
||||
"""Body provenance — the cache-with-provenance half of the pointer model (#2688).
|
||||
|
||||
Decision #2686: the recorded location is the source of truth for a snippet's
|
||||
code and the stored body is a CACHE of it. `data.provenance` records what that
|
||||
cache is a cache OF — the commit the body was read at, and when — so staleness
|
||||
becomes judgeable instead of guessed, and so the forge fetch (milestone 288
|
||||
step 5) has something to refresh.
|
||||
|
||||
The rules under test, because each has a way to rot silently:
|
||||
|
||||
- Provenance follows the CODE. An edit that changes the code without a fresh
|
||||
SHA must DROP the stamp — carrying it would claim the new body came from
|
||||
the old commit, which is worse than not knowing.
|
||||
- Writes that are ABOUT the code rather than changes TO it (a verification
|
||||
verdict, a metadata edit) must CARRY it — record_verification rebuilds
|
||||
`data` from scratch, so forgetting the field there erases it invisibly.
|
||||
- An "ok" verdict at a known commit RESTAMPS it: the checker just proved the
|
||||
cached body matches the source there.
|
||||
|
||||
Unit tests cover the compose/carry logic; the integration section runs the
|
||||
same rules through the real service paths on real Postgres (the #2663 lesson —
|
||||
a DB-touching path with only mocked coverage is a path with no coverage).
|
||||
"""
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
|
||||
from scribe.services.snippets import (
|
||||
VERIFY_CHANGED,
|
||||
VERIFY_OK,
|
||||
compose_data,
|
||||
compose_provenance,
|
||||
compose_verification,
|
||||
snippet_fields,
|
||||
verification_view,
|
||||
)
|
||||
|
||||
SHA_A = "a" * 40
|
||||
SHA_B = "b" * 40
|
||||
|
||||
|
||||
# --- unit: composing ---------------------------------------------------------
|
||||
|
||||
def test_compose_provenance_stamps_sha_and_time():
|
||||
prov = compose_provenance(commit_sha=f" {SHA_A} ")
|
||||
assert prov["commit_sha"] == SHA_A
|
||||
assert prov["fetched_at"] # ISO stamp, defaulted
|
||||
|
||||
|
||||
def test_compose_provenance_without_a_sha_is_none_not_an_empty_record():
|
||||
# Absent provenance must stay ABSENT (the pre-#2688 semantics), never an
|
||||
# empty dict that readers would have to distinguish from a real one.
|
||||
assert compose_provenance(commit_sha="") is None
|
||||
assert compose_provenance(commit_sha=" ") is None
|
||||
|
||||
|
||||
def test_compose_data_carries_provenance_only_when_present():
|
||||
with_it = compose_data(name="x", provenance={"commit_sha": SHA_A, "fetched_at": "t"})
|
||||
without = compose_data(name="x", provenance=None)
|
||||
assert with_it["provenance"]["commit_sha"] == SHA_A
|
||||
assert "provenance" not in without
|
||||
|
||||
|
||||
def test_verification_records_and_reads_back_the_checked_commit():
|
||||
verdict = compose_verification(
|
||||
status=VERIFY_OK, checked_code_sha="c" * 32, commit_sha=SHA_A,
|
||||
)
|
||||
assert verdict["commit_sha"] == SHA_A
|
||||
# And an empty one is omitted, not stored as "".
|
||||
bare = compose_verification(status=VERIFY_OK, checked_code_sha="c" * 32)
|
||||
assert "commit_sha" not in bare
|
||||
|
||||
class _N: # minimal note stand-in for the read-time view
|
||||
data = None
|
||||
|
||||
fields = {"code": "", "verification": verdict}
|
||||
view = verification_view(_N(), fields)
|
||||
assert view["commit_sha"] == SHA_A
|
||||
|
||||
|
||||
# --- integration: the rules through the real service paths -------------------
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def _dispose_engine():
|
||||
from scribe.models import engine
|
||||
yield
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def user_id(_dispose_engine):
|
||||
from scribe.models import async_session
|
||||
from scribe.models.user import User
|
||||
|
||||
async with async_session() as s:
|
||||
user = User(username="snippet_prov_itest")
|
||||
s.add(user)
|
||||
await s.flush()
|
||||
uid = user.id
|
||||
await s.commit()
|
||||
return uid
|
||||
|
||||
|
||||
async def _fresh(uid, note_id):
|
||||
from scribe.services import snippets as svc
|
||||
note = await svc.get_snippet(uid, note_id)
|
||||
return snippet_fields(note), svc.snippet_to_dict(note)
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_provenance_lives_and_dies_with_the_code_end_to_end(user_id):
|
||||
from scribe.services import snippets as svc
|
||||
|
||||
note = await svc.create_snippet(
|
||||
user_id, name="prov_helper", code="def prov_helper():\n return 1\n",
|
||||
language="python", repo="Scribe", path="src/x.py", symbol="prov_helper",
|
||||
commit_sha=SHA_A,
|
||||
)
|
||||
fields, view = await _fresh(user_id, note.id)
|
||||
assert fields["provenance"]["commit_sha"] == SHA_A
|
||||
assert view["snippet"]["provenance"]["commit_sha"] == SHA_A
|
||||
|
||||
# A metadata edit leaves the code alone → carried.
|
||||
await svc.update_snippet(user_id, note.id, when_to_use="when proving")
|
||||
fields, _ = await _fresh(user_id, note.id)
|
||||
assert fields["provenance"]["commit_sha"] == SHA_A
|
||||
|
||||
# A code edit with a fresh SHA → restamped.
|
||||
await svc.update_snippet(
|
||||
user_id, note.id, code="def prov_helper():\n return 2\n",
|
||||
commit_sha=SHA_B,
|
||||
)
|
||||
fields, _ = await _fresh(user_id, note.id)
|
||||
assert fields["provenance"]["commit_sha"] == SHA_B
|
||||
|
||||
# A code edit WITHOUT one → dropped, not carried: the new body does not
|
||||
# come from SHA_B and the record must not claim it does.
|
||||
await svc.update_snippet(
|
||||
user_id, note.id, code="def prov_helper():\n return 3\n",
|
||||
)
|
||||
fields, view = await _fresh(user_id, note.id)
|
||||
assert "provenance" not in fields
|
||||
assert "provenance" not in view["snippet"]
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
async def test_verification_stamps_the_commit_and_ok_refreshes_provenance(user_id):
|
||||
from scribe.services import snippets as svc
|
||||
|
||||
note = await svc.create_snippet(
|
||||
user_id, name="prov_verify", code="def prov_verify():\n return 1\n",
|
||||
language="python", repo="Scribe", path="src/y.py", symbol="prov_verify",
|
||||
commit_sha=SHA_A,
|
||||
)
|
||||
|
||||
# A non-ok verdict at a newer commit records where the check ran but must
|
||||
# CARRY provenance — the check didn't change what the cached body is.
|
||||
await svc.record_verification(
|
||||
user_id, note.id, status=VERIFY_CHANGED, detail="diverged", commit_sha=SHA_B,
|
||||
)
|
||||
fields, view = await _fresh(user_id, note.id)
|
||||
assert view["verification"]["commit_sha"] == SHA_B
|
||||
assert fields["provenance"]["commit_sha"] == SHA_A
|
||||
|
||||
# An OK verdict at that commit proves the cache matches the source there —
|
||||
# provenance refreshes without an edit.
|
||||
await svc.record_verification(
|
||||
user_id, note.id, status=VERIFY_OK, detail="matches", commit_sha=SHA_B,
|
||||
)
|
||||
fields, view = await _fresh(user_id, note.id)
|
||||
assert view["verification"]["commit_sha"] == SHA_B
|
||||
assert fields["provenance"]["commit_sha"] == SHA_B
|
||||
|
||||
# And the verdict itself survives untouched by the restamp.
|
||||
assert view["verification"]["status"] == VERIFY_OK
|
||||
assert view["verification"]["current"] is True
|
||||
Reference in New Issue
Block a user