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
+40 -15
View File
@@ -106,6 +106,7 @@ class CompletionEventRecord:
event_type: str
actor_source: str
actor_user_id: str | None
actor_child_id: int | None
occurred_at: str
was_on_time: bool | None
@@ -115,7 +116,7 @@ _INSTANCE_COLUMNS = (
)
_EVENT_COLUMNS = (
"id, instance_id, child_id, event_type, actor_source, actor_user_id, "
"occurred_at, was_on_time"
"actor_child_id, occurred_at, was_on_time"
)
#: The only event types the append path accepts; the schema CHECK is
@@ -144,8 +145,9 @@ def _event_from_row(row: tuple) -> CompletionEventRecord:
event_type=row[3],
actor_source=row[4],
actor_user_id=row[5],
occurred_at=row[6],
was_on_time=None if row[7] is None else bool(row[7]),
actor_child_id=row[6],
occurred_at=row[7],
was_on_time=None if row[8] is None else bool(row[8]),
)
@@ -396,19 +398,21 @@ class CompletionEventsDao:
was_on_time: bool | None,
*,
actor_user_id: str | None = None,
actor_child_id: int | None = None,
) -> CompletionEventRecord:
"""Append one completion or un-completion event.
``actor_source`` is 'user' (with ``actor_user_id`` set) or
'panel' (no user id — the tapped child is ``child_id``); the
actor-pair CHECK enforces this shape at the schema level.
``occurred_at`` must be the module's one strict UTC shape
(YYYY-MM-DDTHH:MM:SS+00:00) so lexicographic range queries
stay exact. ``was_on_time`` is required (True/False) for
completions; for un-completions it may be None or the reversed
completion's flag (Feature 08 still deciding). The instance
and child must exist — validated atomically under the
connection lock.
``actor_source`` is 'user' (an admin action: ``actor_user_id``
set, ``actor_child_id`` NULL) or 'panel' (a panel tap: no user
id, and ``actor_child_id`` carrying the tapped child profile,
D-008); the actor-pair CHECKs enforce this shape at the schema
level. ``occurred_at`` must be the module's one strict UTC
shape (YYYY-MM-DDTHH:MM:SS+00:00) so lexicographic range
queries stay exact. ``was_on_time`` is required (True/False)
for completions; for un-completions it may be None or the
reversed completion's flag (Feature 08 still deciding). The
instance, child and — for panel events — the tapped child must
exist — validated atomically under the connection lock.
"""
if event_type not in (EVENT_COMPLETED, EVENT_UNCOMPLETED):
raise ValueError(
@@ -428,6 +432,15 @@ class CompletionEventsDao:
raise ValueError(
"actor_source 'panel' must not carry actor_user_id"
)
if actor_source == "panel" and actor_child_id is None:
raise ValueError(
"actor_source 'panel' requires actor_child_id "
"(the tapped child profile)"
)
if actor_source == "user" and actor_child_id is not None:
raise ValueError(
"actor_source 'user' must not carry actor_child_id"
)
if was_on_time is not None and type(was_on_time) is not bool:
raise ValueError(
f"was_on_time must be True, False or None, got "
@@ -458,16 +471,28 @@ class CompletionEventsDao:
)
if child is None:
raise ValueError(f"child {child_id} does not exist")
if actor_child_id is not None:
tapped = await self._database.fetch_one(
"SELECT 1 FROM children WHERE id = ?",
(actor_child_id,),
)
if tapped is None:
raise ValueError(
f"actor_child_id {actor_child_id} does not "
"reference an existing child"
)
result = await self._database.execute(
"INSERT INTO completion_events (instance_id, child_id, "
"event_type, actor_source, actor_user_id, occurred_at, "
"was_on_time) VALUES (?, ?, ?, ?, ?, ?, ?)",
"event_type, actor_source, actor_user_id, "
"actor_child_id, occurred_at, was_on_time) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
(
instance_id,
child_id,
event_type,
actor_source,
actor_user_id,
actor_child_id,
occurred_at,
None if was_on_time is None else int(was_on_time),
),
+79 -5
View File
@@ -25,11 +25,12 @@ pre-D-008 databases to the multi-assignee model: it creates
``quest_definition_assignees`` and, when ``quest_definitions`` still
carries a single ``child_id`` column, rebuilds the table without it,
copying each definition's assignee across. Migration 4 adds the
windows table and migration 5 rebuilds ``quest_instances`` onto the
widened (definition_id, child_id, due_date, window) key. Each callable
migration runs inside its own transaction together with its version
stamp, so a crash mid-step rolls the statements and the stamp back
together.
windows table, migration 5 rebuilds ``quest_instances`` onto the
widened (definition_id, child_id, due_date, window) key, and migration
6 rebuilds ``completion_events`` with the D-008 ``actor_child_id``
column. Each callable migration runs inside its own transaction
together with its version stamp, so a crash mid-step rolls the
statements and the stamp back together.
Migration entries are either an ordered sequence of SQL statements or
an async callable taking ``(database, target_version)``. Callables
@@ -73,6 +74,7 @@ from typing import Union
from .db import NestQuestDatabase
from .schema import (
COMPLETION_EVENTS_TABLE_SQL,
QUEST_DEFINITIONS_TABLE_SQL,
QUEST_INSTANCES_TABLE_SQL,
SCHEMA_V1_STATEMENTS,
@@ -326,6 +328,74 @@ async def _widen_instance_key(
await database.execute("PRAGMA foreign_keys = ON")
async def _add_actor_child_id(
database: NestQuestDatabase, target_version: int
) -> None:
"""Bring ``completion_events`` to the D-008 actor-child shape.
The ``actor_child_id`` column records which panel profile was
tapped; it cannot be added with ``ALTER TABLE ADD COLUMN`` alone
because the canonical shape also carries an actor-pair coherence
CHECK (panel events require it, user events forbid it), and CHECK
constraints cannot be added to an existing table. Databases with
the legacy shape are therefore rebuilt via SQLite's canonical
procedure, backfilling each legacy panel row's ``actor_child_id``
to ``child_id`` — pre-D-008 panel completions were always recorded
against the tapped child themselves — while admin rows stay NULL.
Row ids are preserved so ``quest_instances`` references and the
append-only history stay intact. Fresh databases already match
the canonical shape and only stamp.
The rebuild runs with ``foreign_keys = OFF`` and verifies
``PRAGMA foreign_key_check`` inside the transaction before the
stamp. The copy is an INSERT..SELECT, not an UPDATE: the
completion_events table keeps its append-only guarantee.
"""
legacy_shape = not await _table_has_column(
database, "completion_events", "actor_child_id"
)
await database.execute("PRAGMA foreign_keys = OFF")
try:
async with database.transaction():
if legacy_shape:
await database.execute(
"CREATE TABLE completion_events_rebuilt "
f"{COMPLETION_EVENTS_TABLE_SQL}"
)
await database.execute(
"INSERT INTO completion_events_rebuilt "
"(id, instance_id, child_id, event_type, actor_source, "
"actor_user_id, actor_child_id, occurred_at, "
"was_on_time) "
"SELECT id, instance_id, child_id, event_type, "
"actor_source, actor_user_id, "
"CASE WHEN actor_source = 'panel' THEN child_id "
"ELSE NULL END, "
"occurred_at, was_on_time "
"FROM completion_events ORDER BY id"
)
await database.execute("DROP TABLE completion_events")
await database.execute(
"ALTER TABLE completion_events_rebuilt "
"RENAME TO completion_events"
)
violations = await database.fetch_all(
"PRAGMA foreign_key_check"
)
if violations:
raise RuntimeError(
"completion_events rebuild produced foreign-key "
f"violations: {violations!r}"
)
LOGGER.info(
"Added actor_child_id to completion_events; legacy "
"panel rows backfilled to their own child_id"
)
await stamp_schema_version(database, target_version)
finally:
await database.execute("PRAGMA foreign_keys = ON")
#: The ordered migration list. Append-only: never edit an applied
#: entry, add the next one instead. (Migration 1's content was
#: rewritten pre-release per D-007 — no version 1 database shipped.)
@@ -340,6 +410,10 @@ MIGRATIONS: Sequence[MigrationStep] = [
# Migration 5 (D-008): rebuild quest_instances onto the widened
# (definition_id, child_id, due_date, window) key.
_widen_instance_key,
# Migration 6 (D-008): rebuild completion_events with the
# actor_child_id column and its actor-pair coherence CHECK,
# backfilling legacy panel rows to their own child_id.
_add_actor_child_id,
]
+41 -27
View File
@@ -159,11 +159,12 @@ self-describing without joins.
Actor policy: ``actor_source`` distinguishes an authenticated HA user
(``'user'``, with ``actor_user_id`` holding the HA user id) from a
panel tap with no user context (``'panel'``, with ``actor_user_id``
NULL — the tapped child profile is already ``child_id``). A coherence
CHECK makes the pair shape non-negotiable: ``'user'`` requires
``actor_user_id``, ``'panel'`` forbids it. Other actor sources are
rejected; if an unattended system actor is ever needed, that is a
schema migration, not a silent widening.
NULL — the tapped child profile is ``actor_child_id``, D-008). Two
coherence CHECKs make both shapes non-negotiable: ``'user'`` requires
``actor_user_id`` and forbids ``actor_child_id``; ``'panel'`` requires
``actor_child_id`` (the tapped profile) and forbids ``actor_user_id``.
Other actor sources are rejected; if an unattended system actor is
ever needed, that is a schema migration, not a silent widening.
On-time policy: ``was_on_time`` is required (integer 0 or 1) for
``completed`` events — at completion time the due date is known and the
@@ -344,29 +345,42 @@ SCHEMA_V1_QUEST_INSTANCES_DDL: list[str] = [
""",
]
SCHEMA_V1_COMPLETION_EVENTS_DDL: list[str] = [
"""
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 CHECK (event_type IN ('completed', 'uncompleted')),
actor_source TEXT NOT NULL CHECK (actor_source IN ('user', 'panel')),
actor_user_id TEXT,
occurred_at TEXT NOT NULL,
was_on_time INTEGER CHECK (
was_on_time IS NULL
OR (typeof(was_on_time) = 'integer' AND was_on_time IN (0, 1))
),
CHECK (
(actor_source = 'user' AND actor_user_id IS NOT NULL)
OR (actor_source = 'panel' AND actor_user_id IS NULL)
),
CHECK (
(event_type = 'completed' AND was_on_time IS NOT NULL)
OR (event_type = 'uncompleted')
)
#: Column body of ``completion_events``, shared between the v1 DDL
#: below and the actor-child rebuild migration, which must recreate the
#: table in exactly this shape (D-008: ``actor_child_id`` records which
#: panel profile was tapped — set for panel events, NULL for admin
#: ones; legacy panel rows backfill to ``child_id``, the child they
#: always were).
COMPLETION_EVENTS_TABLE_SQL = """(
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 CHECK (event_type IN ('completed', 'uncompleted')),
actor_source TEXT NOT NULL CHECK (actor_source IN ('user', 'panel')),
actor_user_id TEXT,
actor_child_id INTEGER REFERENCES children(id),
occurred_at TEXT NOT NULL,
was_on_time INTEGER CHECK (
was_on_time IS NULL
OR (typeof(was_on_time) = 'integer' AND was_on_time IN (0, 1))
),
CHECK (
(actor_source = 'user' AND actor_user_id IS NOT NULL)
OR (actor_source = 'panel' AND actor_user_id IS NULL)
),
CHECK (
(actor_source = 'user' AND actor_child_id IS NULL)
OR (actor_source = 'panel' AND actor_child_id IS NOT NULL)
),
CHECK (
(event_type = 'completed' AND was_on_time IS NOT NULL)
OR (event_type = 'uncompleted')
)
)"""
SCHEMA_V1_COMPLETION_EVENTS_DDL: list[str] = [
f"""
CREATE TABLE IF NOT EXISTS completion_events {COMPLETION_EVENTS_TABLE_SQL}
""",
]
+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())