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),
),