feat(engineering): V1-0 write-time invariants (F-1 + F-5 hook + F-8)
Phase V1-0 of the Engineering V1 Completion Plan. Establishes the
write-time invariants every later phase depends on so no later phase
can leak invalid state into the entity store.
F-1 shared-header fields per engineering-v1-acceptance.md:45:
- entities.extractor_version (default "", EXTRACTOR_VERSION="v1.0.0"
written by service.create_entity)
- entities.canonical_home (default "entity")
- entities.hand_authored (default 0, INTEGER boolean)
Idempotent ALTERs in both _apply_migrations (database.py) and
init_engineering_schema (service.py). CREATE TABLE also carries the
columns for fresh DBs. _row_to_entity tolerates old rows without
them so tests that predate V1-0 keep passing.
F-8 provenance enforcement per promotion-rules.md:243:
create_entity raises ValueError when source_refs is empty and
hand_authored is False. New kwargs hand_authored and
extractor_version threaded through the API (EntityCreateRequest)
and the /wiki/new form body (human wiki writes set hand_authored
true by definition). The non-negotiable invariant: every row either
carries provenance or is explicitly flagged as hand-authored.
F-5 synchronous conflict-detection hook on active create per
engineering-v1-acceptance.md:99:
create_entity(status="active") now runs detect_conflicts_for_entity
with fail-open per conflict-model.md:256. Detector errors log a
warning but never 4xx-block the write (Q-3 "flag, never block").
Doc note added to engineering-ontology-v1.md recording that `project`
IS the `project_id` per "fields equivalent to" wording. No storage
rename.
Backfill script scripts/v1_0_backfill_provenance.py reports and
optionally flags existing active entities that lack provenance.
Idempotent. Supports --dry-run and --invalidate-instead.
Tests: 10 new in test_v1_0_write_invariants.py covering F-1 fields,
F-8 raise + bypass, F-5 hook on active + no-hook on candidate, Q-3
fail-open, Q-4 partial scope_only=active excludes candidates.
Three pre-existing conflict tests adapted to read list_open_conflicts
rather than re-run the detector (which now dedups because the hook
already fired at create-time). One API test adds hand_authored=true
since its fixture has no source_refs.
conftest.py wraps create_entity so tests that don't pass source_refs
or hand_authored default to hand_authored=True (tests author their
own fixture data — reasonable default). Production paths (API route,
wiki form, graduation scripts) all pass explicit values and are
unaffected.
Test count: 533 -> 543 (+10). Full suite green in 77.86s.
Pending: Codex review on the branch before squash-merge to main.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
234
tests/test_v1_0_write_invariants.py
Normal file
234
tests/test_v1_0_write_invariants.py
Normal file
@@ -0,0 +1,234 @@
|
||||
"""V1-0 write-time invariant tests.
|
||||
|
||||
Covers the Engineering V1 completion plan Phase V1-0 acceptance:
|
||||
- F-1 shared-header fields: extractor_version + canonical_home + hand_authored
|
||||
land in the entities table with working defaults
|
||||
- F-8 provenance enforcement: create_entity raises without source_refs
|
||||
unless hand_authored=True
|
||||
- F-5 synchronous conflict-detection hook on any active-entity write
|
||||
(create_entity with status="active" + the pre-existing promote_entity
|
||||
path); fail-open per conflict-model.md:256
|
||||
- Q-3 "flag, never block": a conflict never 4xx-blocks the write
|
||||
- Q-4 partial trust: get_entities scope_only filters candidates out
|
||||
|
||||
Plan: docs/plans/engineering-v1-completion-plan.md
|
||||
Spec: docs/architecture/engineering-v1-acceptance.md
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from atocore.engineering.service import (
|
||||
EXTRACTOR_VERSION,
|
||||
create_entity,
|
||||
get_entities,
|
||||
get_entity,
|
||||
init_engineering_schema,
|
||||
)
|
||||
from atocore.models.database import get_connection, init_db
|
||||
|
||||
|
||||
# ---------- F-1: shared-header fields ----------
|
||||
|
||||
|
||||
def test_entity_row_has_shared_header_fields(tmp_data_dir):
|
||||
init_db()
|
||||
init_engineering_schema()
|
||||
with get_connection() as conn:
|
||||
cols = {row["name"] for row in conn.execute("PRAGMA table_info(entities)").fetchall()}
|
||||
assert "extractor_version" in cols
|
||||
assert "canonical_home" in cols
|
||||
assert "hand_authored" in cols
|
||||
|
||||
|
||||
def test_created_entity_has_default_extractor_version_and_canonical_home(tmp_data_dir):
|
||||
init_db()
|
||||
init_engineering_schema()
|
||||
e = create_entity(
|
||||
entity_type="component",
|
||||
name="Pivot Pin",
|
||||
project="p04-gigabit",
|
||||
source_refs=["test:fixture"],
|
||||
)
|
||||
assert e.extractor_version == EXTRACTOR_VERSION
|
||||
assert e.canonical_home == "entity"
|
||||
assert e.hand_authored is False
|
||||
|
||||
# round-trip through get_entity to confirm the row mapper returns
|
||||
# the same values (not just the return-by-construct path)
|
||||
got = get_entity(e.id)
|
||||
assert got is not None
|
||||
assert got.extractor_version == EXTRACTOR_VERSION
|
||||
assert got.canonical_home == "entity"
|
||||
assert got.hand_authored is False
|
||||
|
||||
|
||||
def test_explicit_extractor_version_is_persisted(tmp_data_dir):
|
||||
init_db()
|
||||
init_engineering_schema()
|
||||
e = create_entity(
|
||||
entity_type="decision",
|
||||
name="Pick GF-PTFE pads",
|
||||
project="p04-gigabit",
|
||||
source_refs=["interaction:abc"],
|
||||
extractor_version="custom-v2.3",
|
||||
)
|
||||
got = get_entity(e.id)
|
||||
assert got.extractor_version == "custom-v2.3"
|
||||
|
||||
|
||||
# ---------- F-8: provenance enforcement ----------
|
||||
|
||||
|
||||
def test_create_entity_without_provenance_raises(tmp_data_dir):
|
||||
init_db()
|
||||
init_engineering_schema()
|
||||
with pytest.raises(ValueError, match="source_refs required"):
|
||||
create_entity(
|
||||
entity_type="component",
|
||||
name="No Provenance",
|
||||
project="p04-gigabit",
|
||||
hand_authored=False, # explicit — bypasses the test-conftest auto-flag
|
||||
)
|
||||
|
||||
|
||||
def test_create_entity_with_hand_authored_needs_no_source_refs(tmp_data_dir):
|
||||
init_db()
|
||||
init_engineering_schema()
|
||||
e = create_entity(
|
||||
entity_type="component",
|
||||
name="Human Entry",
|
||||
project="p04-gigabit",
|
||||
hand_authored=True,
|
||||
)
|
||||
assert e.hand_authored is True
|
||||
got = get_entity(e.id)
|
||||
assert got.hand_authored is True
|
||||
# source_refs stays empty — the hand_authored flag IS the provenance
|
||||
assert got.source_refs == []
|
||||
|
||||
|
||||
def test_create_entity_with_empty_source_refs_list_is_treated_as_missing(tmp_data_dir):
|
||||
init_db()
|
||||
init_engineering_schema()
|
||||
with pytest.raises(ValueError, match="source_refs required"):
|
||||
create_entity(
|
||||
entity_type="component",
|
||||
name="Empty Refs",
|
||||
project="p04-gigabit",
|
||||
source_refs=[],
|
||||
hand_authored=False,
|
||||
)
|
||||
|
||||
|
||||
# ---------- F-5: synchronous conflict-detection hook ----------
|
||||
|
||||
|
||||
def test_active_create_runs_conflict_detection_hook(tmp_data_dir, monkeypatch):
|
||||
"""status=active writes trigger detect_conflicts_for_entity."""
|
||||
init_db()
|
||||
init_engineering_schema()
|
||||
|
||||
called_with: list[str] = []
|
||||
|
||||
def _fake_detect(entity_id: str):
|
||||
called_with.append(entity_id)
|
||||
return []
|
||||
|
||||
import atocore.engineering.conflicts as conflicts_mod
|
||||
monkeypatch.setattr(conflicts_mod, "detect_conflicts_for_entity", _fake_detect)
|
||||
|
||||
e = create_entity(
|
||||
entity_type="component",
|
||||
name="Active With Hook",
|
||||
project="p04-gigabit",
|
||||
source_refs=["test:hook"],
|
||||
status="active",
|
||||
)
|
||||
|
||||
assert called_with == [e.id]
|
||||
|
||||
|
||||
def test_candidate_create_does_not_run_conflict_hook(tmp_data_dir, monkeypatch):
|
||||
"""status=candidate writes do NOT trigger detection — the hook is
|
||||
for active rows only, per V1-0 scope. Candidates are checked at
|
||||
promote time."""
|
||||
init_db()
|
||||
init_engineering_schema()
|
||||
|
||||
called: list[str] = []
|
||||
|
||||
def _fake_detect(entity_id: str):
|
||||
called.append(entity_id)
|
||||
return []
|
||||
|
||||
import atocore.engineering.conflicts as conflicts_mod
|
||||
monkeypatch.setattr(conflicts_mod, "detect_conflicts_for_entity", _fake_detect)
|
||||
|
||||
create_entity(
|
||||
entity_type="component",
|
||||
name="Candidate No Hook",
|
||||
project="p04-gigabit",
|
||||
source_refs=["test:cand"],
|
||||
status="candidate",
|
||||
)
|
||||
|
||||
assert called == []
|
||||
|
||||
|
||||
# ---------- Q-3: flag, never block ----------
|
||||
|
||||
|
||||
def test_conflict_detector_failure_does_not_block_write(tmp_data_dir, monkeypatch):
|
||||
"""Per conflict-model.md:256: detection errors must not fail the
|
||||
write. The entity is still created; only a warning is logged."""
|
||||
init_db()
|
||||
init_engineering_schema()
|
||||
|
||||
def _boom(entity_id: str):
|
||||
raise RuntimeError("synthetic detector failure")
|
||||
|
||||
import atocore.engineering.conflicts as conflicts_mod
|
||||
monkeypatch.setattr(conflicts_mod, "detect_conflicts_for_entity", _boom)
|
||||
|
||||
# The write still succeeds — no exception propagates.
|
||||
e = create_entity(
|
||||
entity_type="component",
|
||||
name="Hook Fails Open",
|
||||
project="p04-gigabit",
|
||||
source_refs=["test:failopen"],
|
||||
status="active",
|
||||
)
|
||||
assert get_entity(e.id) is not None
|
||||
|
||||
|
||||
# ---------- Q-4 (partial): trust-hierarchy — scope_only filters candidates ----------
|
||||
|
||||
|
||||
def test_scope_only_active_does_not_return_candidates(tmp_data_dir):
|
||||
"""V1-0 partial Q-4: active-scoped listing never returns candidates.
|
||||
Full trust-hierarchy coverage (no-auto-project-state, etc.) ships in
|
||||
V1-E per plan."""
|
||||
init_db()
|
||||
init_engineering_schema()
|
||||
|
||||
active = create_entity(
|
||||
entity_type="component",
|
||||
name="Active Alpha",
|
||||
project="p04-gigabit",
|
||||
source_refs=["test:alpha"],
|
||||
status="active",
|
||||
)
|
||||
candidate = create_entity(
|
||||
entity_type="component",
|
||||
name="Candidate Beta",
|
||||
project="p04-gigabit",
|
||||
source_refs=["test:beta"],
|
||||
status="candidate",
|
||||
)
|
||||
|
||||
listed = get_entities(project="p04-gigabit", status="active", scope_only=True)
|
||||
ids = {e.id for e in listed}
|
||||
assert active.id in ids
|
||||
assert candidate.id not in ids
|
||||
Reference in New Issue
Block a user