A rulebook holds two kinds of row in one table. A NORM is a decision: no truth value, changes only when its author changes it, and they know they did. A CONSTRAINT asserts a fact about someone else's software, and goes false with nobody present. Milestone 307's audit found nine stale sites; every one was a constraint, and not one norm had rotted. Three nullable columns so a rule can say how to check itself. expires_when is a STATE, not a date — constraints expire when the ground moves, not on a schedule. verified_at NULL means never checked and sorts FIRST in the sweep to come: unexamined outranks examined-long-ago. Most rules set none of the three; a null verify_with is the marker for "this is a decision, there is nothing to go and check," and it only reads that way while it stays honest. Nothing is backfilled and nothing is indexed. A migration cannot invent a check any more than 0088 could invent a trigger, and the sweep reads a whole rulebook — hundreds of rows, on operator demand, never on a request path. Also, in the backup service the fields had to pass through: - Restore now remaps arose_from_id through note_id_map. It has been exported since 0088 and silently dropped on the way back in ever since, so every restore lost every rule's provenance link. - _dt_or_none, because _dt substitutes now() for an absent value. That is right for created_at/updated_at and wrong here: a rule nobody ever checked would restore looking freshly checked and fall to the bottom of the sweep it should top. Column additions do not move BACKUP_VERSION; only new sections do, as when 0088 added when_to_apply/tier/arose_from_id to the same helper. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
193 lines
7.7 KiB
Python
193 lines
7.7 KiB
Python
"""Unit tests for the backup export contract.
|
|
|
|
This is the no-database lane, so these cover the parts that need none: the
|
|
version/coverage constants, the pure row helpers, and the export dict shape
|
|
(via a mocked session). The full FK-remapping round-trip needs real Postgres
|
|
and belongs in a `@pytest.mark.integration` module — it is not written yet,
|
|
which is why every row helper here is a plain function that can be tested
|
|
without a session.
|
|
"""
|
|
from datetime import datetime, timezone
|
|
from types import SimpleNamespace
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
|
|
from scribe.services import backup
|
|
|
|
|
|
def test_backup_version_is_v8():
|
|
"""v7 added code_shapes (#2787), v8 its history (#2793). The bump is the
|
|
point of the test — a payload section added without moving the version
|
|
produces backups that are structurally different and indistinguishable
|
|
by inspection."""
|
|
assert backup.BACKUP_VERSION == 10
|
|
|
|
|
|
def test_not_included_lists_the_known_gaps():
|
|
# The deferred tables must be surfaced explicitly, not silently dropped.
|
|
# forge_connections is excluded as CREDENTIALS (api_keys reasoning): a
|
|
# backup that carries forge tokens is a token-exfiltration file (#2778).
|
|
for table in ("groups", "project_shares", "note_shares", "api_keys",
|
|
"note_embeddings", "retrieval_logs", "forge_connections"):
|
|
assert table in backup._NOT_INCLUDED
|
|
|
|
|
|
def test_every_table_is_either_backed_up_or_explicitly_excluded():
|
|
"""THE GUARD (#2293), and the only shape of test that catches an ABSENCE.
|
|
|
|
A new table gets a model and a migration — both fail loudly if wrong — and
|
|
then silently never gets a backup section. No error, no warning, and a
|
|
restore that reports success. That is how `systems`, `record_systems`,
|
|
`note_usage_events`, `design_systems`, `design_tokens` and `repo_bindings`
|
|
all went missing, over five migrations, with nothing to notice.
|
|
|
|
Extending the export fixes today. THIS fixes the next one: adding a table
|
|
now fails here until someone either backs it up or states in
|
|
`_NOT_INCLUDED` that it shouldn't be. Either is fine; silence is not.
|
|
"""
|
|
from scribe.models import Base
|
|
|
|
schema = set(Base.metadata.tables)
|
|
accounted = set(backup._BACKED_UP) | set(backup._NOT_INCLUDED)
|
|
|
|
unaccounted = schema - accounted
|
|
assert not unaccounted, (
|
|
f"{len(unaccounted)} table(s) are neither backed up nor explicitly "
|
|
f"excluded: {sorted(unaccounted)}. Add each to backup._BACKED_UP (and "
|
|
f"give it an export + restore section) or to backup._NOT_INCLUDED with "
|
|
f"a reason in the comment above it."
|
|
)
|
|
|
|
# And the reverse: a name in either list that no longer exists is a lie the
|
|
# guard would otherwise keep telling. This half is what caught "embeddings",
|
|
# "invitations" and "password_resets" — three entries that named nothing.
|
|
phantom = accounted - schema
|
|
assert not phantom, (
|
|
f"backup lists table(s) that are not in the schema: {sorted(phantom)}. "
|
|
f"Renamed or dropped — fix the list rather than leaving it to read as "
|
|
f"coverage."
|
|
)
|
|
|
|
|
|
def test_join_table_row_helpers_are_pure():
|
|
subs = [SimpleNamespace(project_id=1, rulebook_id=2)]
|
|
rsup = [SimpleNamespace(project_id=1, rule_id=9)]
|
|
tsup = [SimpleNamespace(project_id=1, topic_id=7)]
|
|
assert backup._subscription_rows(subs) == [{"project_id": 1, "rulebook_id": 2}]
|
|
assert backup._rule_suppression_rows(rsup) == [{"project_id": 1, "rule_id": 9}]
|
|
assert backup._topic_suppression_rows(tsup) == [{"project_id": 1, "topic_id": 7}]
|
|
|
|
|
|
class _Result:
|
|
def scalars(self):
|
|
return self
|
|
|
|
def all(self):
|
|
return []
|
|
|
|
|
|
class _Session:
|
|
async def execute(self, *a, **k):
|
|
return _Result()
|
|
|
|
async def get(self, *a, **k):
|
|
return None
|
|
|
|
|
|
class _CM:
|
|
async def __aenter__(self):
|
|
return _Session()
|
|
|
|
async def __aexit__(self, *a):
|
|
return False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_export_full_backup_contains_every_declared_section():
|
|
with patch("scribe.services.backup.async_session", lambda: _CM()):
|
|
out = await backup.export_full_backup()
|
|
|
|
assert out["version"] == backup.BACKUP_VERSION
|
|
assert out["scope"] == "full"
|
|
assert "api_keys" in out["_not_included"]
|
|
# The sections v2 silently dropped, the six v5 added, v6's
|
|
# note_supersessions, and v7's code_shapes (all empty here).
|
|
for key in ("rulebooks", "rulebook_topics", "rules",
|
|
"rulebook_subscriptions", "rule_suppressions",
|
|
"topic_suppressions",
|
|
"systems", "record_systems", "design_systems",
|
|
"design_tokens", "note_usage_events", "repo_bindings",
|
|
"note_supersessions", "code_shapes", "code_shape_events",
|
|
"code_shape_uses", "rulebook_exclusions"):
|
|
assert key in out, f"missing export section: {key}"
|
|
assert out[key] == []
|
|
|
|
|
|
def test_supersession_rows_serialise_the_pair():
|
|
"""The row builder is a plain function precisely so it can be tested with
|
|
no database — same reason as the other v5/v6 builders."""
|
|
class _Row:
|
|
def __init__(self, a, b):
|
|
self.superseder_id, self.superseded_id = a, b
|
|
|
|
assert backup._note_supersession_rows([_Row(9, 4), _Row(9, 5)]) == [
|
|
{"superseder_id": 9, "superseded_id": 4},
|
|
{"superseder_id": 9, "superseded_id": 5},
|
|
]
|
|
|
|
|
|
def test_rule_rows_carry_the_verification_fields():
|
|
"""A rule's check must survive a backup.
|
|
|
|
`verify_with`/`expires_when`/`verified_at` (milestone 312) say whether a
|
|
rule is a fact that can go false and when it was last confirmed. A backup
|
|
that drops them restores a rulebook that has forgotten which of its rules
|
|
can rot — the exact blindness the fields were added to end.
|
|
|
|
Column additions do not bump BACKUP_VERSION; only new SECTIONS do. Same
|
|
call made for when_to_apply/tier/arose_from_id in 0088 (commit 6ddb8bf).
|
|
"""
|
|
checked = datetime(2026, 8, 27, 12, 0, tzinfo=timezone.utc)
|
|
row = SimpleNamespace(
|
|
id=1, topic_id=2, project_id=None, title="t", statement="s",
|
|
why="w", how_to_apply="h", order_index=0,
|
|
when_to_apply="when", tier="conditional",
|
|
verify_with="cat some/file", expires_when="the file grows a shell",
|
|
verified_at=checked, arose_from_id=99,
|
|
created_at=checked, updated_at=checked,
|
|
)
|
|
out = backup._rule_rows([row])[0]
|
|
|
|
assert out["verify_with"] == "cat some/file"
|
|
assert out["expires_when"] == "the file grows a shell"
|
|
assert out["verified_at"] == checked.isoformat()
|
|
# Provenance was exported from 0088 onward but silently dropped on the way
|
|
# back IN until milestone 312. Export side asserted here; the restore side
|
|
# remaps it through note_id_map.
|
|
assert out["arose_from_id"] == 99
|
|
|
|
|
|
def test_rule_rows_keep_an_unverified_rule_unverified():
|
|
"""NULL verified_at means never checked, and it must round-trip as null.
|
|
|
|
_dt substitutes now() so created_at/updated_at are never null. Reusing it
|
|
here would restore a rule nobody ever checked as though it had just been
|
|
checked — dropping it to the BOTTOM of the sweep it should top. That is
|
|
why _dt_or_none exists.
|
|
"""
|
|
row = SimpleNamespace(
|
|
id=1, topic_id=2, project_id=None, title="t", statement="s",
|
|
why=None, how_to_apply=None, order_index=0,
|
|
when_to_apply=None, tier="always_on",
|
|
verify_with=None, expires_when=None, verified_at=None,
|
|
arose_from_id=None,
|
|
created_at=datetime(2026, 8, 27, tzinfo=timezone.utc),
|
|
updated_at=datetime(2026, 8, 27, tzinfo=timezone.utc),
|
|
)
|
|
assert backup._rule_rows([row])[0]["verified_at"] is None
|
|
assert backup._dt_or_none(None) is None
|
|
assert backup._dt_or_none("2026-08-27T12:00:00+00:00") == datetime(
|
|
2026, 8, 27, 12, 0, tzinfo=timezone.utc
|
|
)
|