Add actor_child_id to completion_events (D-008)

Per IMPLEMENTATION-ORDER.md 1.5: a nullable actor_child_id column
records which panel profile was tapped; admin events stay NULL.

- schema.py: completion_events table body extracted to
  COMPLETION_EVENTS_TABLE_SQL; the new column carries a FK to
  children(id) and the actor-pair coherence is extended to a second
  CHECK: 'user' events require actor_user_id and forbid
  actor_child_id, 'panel' events require actor_child_id and forbid
  actor_user_id.
- migrations.py: migration 6 rebuilds legacy-shaped completion_events
  via the canonical procedure (CHECKs cannot be added to an existing
  table), backfilling each legacy panel row to its own child_id
  (pre-D-008 panel completions were always the tapped child) via
  INSERT..SELECT — no UPDATE or DELETE path exists, so the append-only
  guarantee is untouched; ids preserved; PRAGMA foreign_key_check
  verified in-transaction.
- dao_instances.py: append() takes actor_child_id, validates both
  actor shapes (panel requires it, user forbids it) and the tapped
  child's existence under the connection lock; the record and column
  list grow accordingly.
- tests: coherence CHECK matrix (5 bad shapes), unknown
  actor_child_id FK, panel/user round-trips at DDL and DAO layers,
  migration-6 backfill test on a frozen v2-shape seed; the
  completion_events leak guard gains justified INSERT exemptions for
  the migration layer (UPDATE/DELETE stay forbidden everywhere).
This commit is contained in:
Joshua King
2026-09-15 10:25:50 -04:00
parent 25f1479444
commit fe90064b65
7 changed files with 318 additions and 66 deletions
+36 -10
View File
@@ -664,6 +664,7 @@ def test_append_panel_event_round_trips(tmp_path) -> None:
definition.id, child.id, D1, _now_stamp(), window="morning")
event = await events.append(
instance.id, child.id, "completed", "panel", _now_stamp(), False,
actor_child_id=child.id,
actor_user_id=None,
)
assert event.actor_source == "panel"
@@ -718,17 +719,31 @@ def test_append_validation_rejects_bad_shapes(tmp_path) -> None:
with pytest.raises(ValueError, match="must not carry"):
await events.append(
instance.id, child.id, "completed", "panel", _now_stamp(), True,
actor_child_id=child.id,
actor_user_id="user-1",
)
with pytest.raises(ValueError, match="requires actor_child_id"):
await events.append(
instance.id, child.id, "completed", "panel",
_now_stamp(), True,
)
with pytest.raises(ValueError, match="must not carry actor_child_id"):
await events.append(
instance.id, child.id, "completed", "user", _now_stamp(), True,
actor_user_id="user-1",
actor_child_id=child.id,
)
with pytest.raises(ValueError, match="requires was_on_time"):
await events.append(
instance.id, child.id, "completed", "panel", _now_stamp(), None
instance.id, child.id, "completed", "panel", _now_stamp(), None,
actor_child_id=child.id
)
# was_on_time must be a real bool: int-convertible junk that
# would silently coerce (0.5 -> False) is rejected outright.
with pytest.raises(ValueError, match="must be True, False or None"):
await events.append(
instance.id, child.id, "completed", "panel", _now_stamp(), 0.5
instance.id, child.id, "completed", "panel",
_now_stamp(), 0.5, actor_child_id=child.id,
)
with pytest.raises(ValueError, match="must be True, False or None"):
await events.append(
@@ -758,7 +773,7 @@ def test_append_rejects_non_utc_timestamps(tmp_path) -> None:
with pytest.raises(ValueError, match="UTC"):
await events.append(
instance.id, child.id, "completed", "panel", bad,
True,
True, actor_child_id=child.id,
)
return None
@@ -815,14 +830,16 @@ def test_completed_uncompleted_recompleted_three_ordered_events(
instance = await instances.upsert(
definition.id, child.id, D1, _now_stamp(), window="morning")
await events.append(
instance.id, child.id, "completed", "panel", _now_stamp(), True
instance.id, child.id, "completed", "panel", _now_stamp(), True,
actor_child_id=child.id
)
await events.append(
instance.id, child.id, "uncompleted", "user", _now_stamp(), True,
actor_user_id="user-1",
)
await events.append(
instance.id, child.id, "completed", "panel", _now_stamp(), False
instance.id, child.id, "completed", "panel", _now_stamp(), False,
actor_child_id=child.id
)
history = await events.list_by_instance(instance.id)
assert [e.event_type for e in history] == [
@@ -855,7 +872,8 @@ def test_get_latest_returns_uncompleted_when_reversed(tmp_path) -> None:
instance = await instances.upsert(
definition.id, child.id, D1, _now_stamp(), window="morning")
await events.append(
instance.id, child.id, "completed", "panel", _now_stamp(), True
instance.id, child.id, "completed", "panel", _now_stamp(), True,
actor_child_id=child.id
)
await events.append(
instance.id, child.id, "uncompleted", "user", _now_stamp(), True,
@@ -878,17 +896,20 @@ def test_list_by_child_and_date_range_scopes_by_due_date(tmp_path) -> None:
late = await instances.upsert(
definition.id, child.id, D20, _now_stamp(), window="morning")
await events.append(
early.id, child.id, "completed", "panel", _now_stamp(), True
early.id, child.id, "completed", "panel", _now_stamp(), True,
actor_child_id=child.id
)
await events.append(
inside.id, child.id, "completed", "panel", _now_stamp(), False
inside.id, child.id, "completed", "panel", _now_stamp(), False,
actor_child_id=child.id
)
await events.append(
inside.id, child.id, "uncompleted", "user", _now_stamp(), True,
actor_user_id="user-1",
)
await events.append(
late.id, child.id, "completed", "panel", _now_stamp(), True
late.id, child.id, "completed", "panel", _now_stamp(), True,
actor_child_id=child.id
)
found = await events.list_by_child_and_date_range(
child.id, D2, D2
@@ -922,7 +943,7 @@ def test_list_events_occurred_at_scope_variant(tmp_path) -> None:
# queried on today's date finds it too.
event = await events.append(
late_due.id, child.id, "completed", "panel",
_now_stamp(), False,
_now_stamp(), False, actor_child_id=child.id,
)
by_due = await events.list_by_child_and_date_range(
child.id, D20, D20
@@ -1010,7 +1031,12 @@ def test_no_mutation_sql_for_completion_events_anywhere() -> None:
# UPDATE/DELETE/non-DAO-INSERT paths hit the pattern.
insert_exempt = {
"custom_components/nestquest/dao_instances.py",
"custom_components/nestquest/migrations.py", # the rebuild
# migrations re-create the table via INSERT..SELECT (append-
# order preserved); UPDATE/DELETE stays forbidden everywhere
"tests/test_schema.py",
"tests/test_migrations.py", # seeds frozen legacy shapes to
# exercise the rebuild migrations
"tests/test_dao_instances.py", # this guard file; scanned by
# its own guard's regex spans below instead
}
+4 -2
View File
@@ -372,14 +372,16 @@ def test_matrix_completion_events_append_read_and_no_mutation(
async def _body(w: World):
# insert: append a completion then a reversal, then re-complete
await w.events.append(
w.instance.id, w.child.id, "completed", "panel", _stamp(), True
w.instance.id, w.child.id, "completed", "panel", _stamp(), True,
actor_child_id=w.child.id,
)
await w.events.append(
w.instance.id, w.child.id, "uncompleted", "user", _stamp(), None,
actor_user_id="user-1",
)
await w.events.append(
w.instance.id, w.child.id, "completed", "panel", _stamp(), False
w.instance.id, w.child.id, "completed", "panel", _stamp(), False,
actor_child_id=w.child.id,
)
# read: full ordered history + latest
history = await w.events.list_by_instance(w.instance.id)
+58 -4
View File
@@ -149,16 +149,17 @@ def test_migration_list_shape() -> None:
(D-007) to create the quest_* tables directly; migration 2 exists
for dev databases stamped version 1 by the pre-rename runner,
migration 3 brings pre-D-008 definitions to the multi-assignee
model, migration 4 adds the windows table, and migration 5 rebuilds
model, migration 4 adds the windows table, migration 5 rebuilds
quest_instances onto the widened (definition, child, date, window)
key.
key, and migration 6 adds actor_child_id to completion_events.
"""
assert MIGRATIONS[0] == SCHEMA_V1_STATEMENTS
assert callable(MIGRATIONS[1])
assert callable(MIGRATIONS[2])
assert MIGRATIONS[3] == SCHEMA_V1_QUEST_DEFINITION_WINDOWS_DDL
assert callable(MIGRATIONS[4])
assert len(MIGRATIONS) == 5
assert callable(MIGRATIONS[5])
assert len(MIGRATIONS) == 6
# ---------------------------------------------------------------------------
@@ -442,6 +443,21 @@ _V2_INSTANCES_DDL = [
""",
]
_V2_EVENTS_DDL = [
"""
CREATE TABLE IF NOT EXISTS completion_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
instance_id INTEGER NOT NULL REFERENCES quest_instances(id),
child_id INTEGER NOT NULL REFERENCES children(id),
event_type TEXT NOT NULL,
actor_source TEXT NOT NULL,
actor_user_id TEXT,
occurred_at TEXT NOT NULL,
was_on_time INTEGER
)
""",
]
def _seed_v2_multi_assignee_upgrade(database) -> None:
"""Create a database shaped like a post-rename, pre-D-008 dev file.
@@ -458,12 +474,13 @@ def _seed_v2_multi_assignee_upgrade(database) -> None:
"quest_definition_assignees ",
"quest_definition_windows ",
"quest_instances ",
"completion_events ",
)
for sql in SCHEMA_V1_STATEMENTS:
if any(name in sql for name in _v2_replaced):
continue
_run(database.execute(sql))
for sql in _V2_DEFINITIONS_DDL + _V2_INSTANCES_DDL:
for sql in _V2_DEFINITIONS_DDL + _V2_INSTANCES_DDL + _V2_EVENTS_DDL:
_run(database.execute(sql))
_run(database.execute(VERSION_TABLE_DDL))
_run(
@@ -503,6 +520,26 @@ def _seed_v2_multi_assignee_upgrade(database) -> None:
"VALUES (1, 2, '2026-09-15', '2026-09-14T00:00:00+00:00')"
)
)
# One legacy panel event and one legacy admin event: the panel one
# must backfill actor_child_id to its own child; the admin one must
# stay NULL.
_run(
database.execute(
"INSERT INTO completion_events (instance_id, child_id, "
"event_type, actor_source, occurred_at, was_on_time) "
"VALUES (1, 2, 'completed', 'panel', "
"'2026-09-15T10:00:00+00:00', 1)"
)
)
_run(
database.execute(
"INSERT INTO completion_events (instance_id, child_id, "
"event_type, actor_source, actor_user_id, occurred_at, "
"was_on_time) "
"VALUES (1, 2, 'uncompleted', 'user', 'parent-1', "
"'2026-09-15T11:00:00+00:00', 1)"
)
)
def test_v2_definitions_rebuilt_to_multi_assignee(tmp_path) -> None:
@@ -559,6 +596,23 @@ def test_v2_definitions_rebuilt_to_multi_assignee(tmp_path) -> None:
assert [
"definition_id", "child_id", "due_date", "window"
] in unique_columns
# The completion events survived the actor_child_id rebuild:
# the panel row backfilled to its own child, the admin row
# stayed NULL, and ids are preserved.
events = _run(
database.fetch_all(
"SELECT id, event_type, actor_source, actor_child_id "
"FROM completion_events ORDER BY id"
)
)
assert events == [
(1, "completed", "panel", 2),
(2, "uncompleted", "user", None),
]
columns = _run(
database.fetch_all("PRAGMA table_info(completion_events)")
)
assert "actor_child_id" in {c[1] for c in columns}
# No dangling references anywhere.
assert _run(database.fetch_all("PRAGMA foreign_key_check")) == []
# The rebuilt table is live for the new model: a definition
+60 -3
View File
@@ -1851,6 +1851,7 @@ def test_completion_events_columns_types_and_constraints(tmp_path) -> None:
("event_type", "TEXT", 1, None, 0),
("actor_source", "TEXT", 1, None, 0),
("actor_user_id", "TEXT", 0, None, 0),
("actor_child_id", "INTEGER", 0, None, 0),
("occurred_at", "TEXT", 1, None, 0),
("was_on_time", "INTEGER", 0, None, 0),
]
@@ -1881,6 +1882,7 @@ def test_completion_events_foreign_keys_declared(tmp_path) -> None:
assert declared == {
("quest_instances", "instance_id", "id"),
("children", "child_id", "id"),
("children", "actor_child_id", "id"),
}
finally:
_run(database.close())
@@ -2182,16 +2184,71 @@ def test_completion_events_valid_panel_completion_round_trips(tmp_path) -> None:
instance_id=instance_id,
actor_source="panel",
actor_user_id=None,
actor_child_id=1,
was_on_time=0,
)
row = _run(
database.fetch_one(
"SELECT actor_source, actor_user_id, was_on_time "
"FROM completion_events WHERE id = ?",
"SELECT actor_source, actor_user_id, actor_child_id, "
"was_on_time FROM completion_events WHERE id = ?",
(event_id,),
)
)
assert row == ("panel", None, 0)
assert row == ("panel", None, 1, 0)
finally:
_run(database.close())
@pytest.mark.parametrize(
("actor_source", "actor_user_id", "actor_child_id"),
[
("user", "user-1", 1), # admin event must not carry a profile
("panel", None, None), # panel event requires the profile
("panel", "user-1", 1), # panel must not carry a user id either
("panel", "user-1", None),
("user", None, None),
],
)
def test_completion_events_actor_child_coherence_enforced(
tmp_path, actor_source, actor_user_id, actor_child_id
) -> None:
"""D-008 actor-pair shape: 'user' events carry actor_user_id and no
actor_child_id; 'panel' events carry actor_child_id and no
actor_user_id. The CHECKs make both halves non-negotiable."""
database = _open_db(tmp_path / "completion-events-coherence.db")
try:
_apply(database)
_setup_definition(database)
instance_id = _insert_instance(database, definition_id=1)
with pytest.raises(sqlite3.IntegrityError, match="CHECK"):
_insert_event(
database,
instance_id=instance_id,
event_type="completed",
actor_source=actor_source,
actor_user_id=actor_user_id,
actor_child_id=actor_child_id,
was_on_time=1,
)
finally:
_run(database.close())
def test_completion_events_actor_child_unknown_child_fails(tmp_path) -> None:
database = _open_db(tmp_path / "completion-events-bad-actor-child.db")
try:
_apply(database)
_setup_definition(database)
instance_id = _insert_instance(database, definition_id=1)
with pytest.raises(sqlite3.IntegrityError, match="FOREIGN KEY"):
_insert_event(
database,
instance_id=instance_id,
actor_source="panel",
actor_user_id=None,
actor_child_id=999,
was_on_time=1,
)
finally:
_run(database.close())