Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/ci_check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,10 @@ jobs:
- run: uv sync --frozen
- name: Build the installed immutable Edge.5 image for schema-7 compatibility
run: docker build --build-arg BUILD_ARCH=${{ matrix.arch }} -t domus-app-edge5 addon-edge
- name: Build the immutable published schema-8 image for profile migration compatibility
run: >-
docker build --build-arg BUILD_ARCH=${{ matrix.arch }}
-t domus-app-schema8 tests/fixtures/schema8-app
- name: Build the exact checked-out source and frontend
run: >-
docker build --file tests/fixtures/current-app/Dockerfile
Expand All @@ -266,6 +270,7 @@ jobs:
DOMUS_PREVIOUS_IMAGE: domus-app-previous
DOMUS_UPGRADE_IMAGE: domus-app-installed
DOMUS_SCHEMA7_IMAGE: domus-app-edge5
DOMUS_SCHEMA8_IMAGE: domus-app-schema8
run: uv run pytest tests/containers --no-cov

aggregate:
Expand Down
15 changes: 15 additions & 0 deletions backend/src/domus/application/member_profile_ports.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
"""Scoped profile storage; application authorization precedes use of either repository."""

from typing import Protocol

from domus.domain.member_profiles import MemberPreferences, MemberPresentation


class MemberPresentations(Protocol):
def get(self, household_id: str, member_id: str) -> MemberPresentation | None: ...
def save(self, presentation: MemberPresentation, expected_revision: int) -> None: ...


class MemberPreferencesRepository(Protocol):
def get(self, household_id: str, member_id: str) -> MemberPreferences | None: ...
def save(self, preferences: MemberPreferences, expected_revision: int) -> None: ...
7 changes: 7 additions & 0 deletions backend/src/domus/application/ports.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from contextlib import AbstractContextManager
from typing import Protocol

from domus.application.member_profile_ports import MemberPreferencesRepository, MemberPresentations
from domus.application.recurrence_ports import RecurrenceRepository
from domus.application.task_material_ports import TaskMaterialRepository
from domus.application.task_ports import TaskRepository
Expand Down Expand Up @@ -62,6 +63,12 @@ def delivered(self, event_id: str) -> bool | None: ...


class UnitOfWork(Protocol):
@property
def presentations(self) -> MemberPresentations: ...

@property
def preferences(self) -> MemberPreferencesRepository: ...

@property
def recurrence(self) -> RecurrenceRepository: ...

Expand Down
18 changes: 13 additions & 5 deletions backend/src/domus/infrastructure/sqlite.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@
)
from domus.infrastructure.sqlite_history import HISTORY_STATEMENTS, SQLiteHistory
from domus.infrastructure.sqlite_identity import SQLiteMembers, SQLitePolicies
from domus.infrastructure.sqlite_member_profile_schema import PROFILE_STATEMENTS, PROFILE_TABLES
from domus.infrastructure.sqlite_member_profiles import SQLitePreferences, SQLitePresentations
from domus.infrastructure.sqlite_receipts import SQLiteReceipts, _command, _scope
from domus.infrastructure.sqlite_recovery import SQLiteRecoveries
from domus.infrastructure.sqlite_recurrence import SQLiteRecurrences
Expand All @@ -41,7 +43,7 @@
from domus.infrastructure.sqlite_task_schema import TASK_STATEMENTS, TASK_TABLES
from domus.infrastructure.sqlite_tasks import SQLiteTasks

SCHEMA_VERSION = 8
SCHEMA_VERSION = 9
APPLICATION_ID = 0x444F4D55
BASE_STATEMENTS = (
"CREATE TABLE installation(singleton INTEGER PRIMARY KEY CHECK(singleton=1), "
Expand Down Expand Up @@ -96,14 +98,16 @@
*MATERIAL_STATEMENTS,
*REVIEW_STATEMENTS,
*RECURRENCE_STATEMENTS,
*PROFILE_STATEMENTS,
)
BASE_TABLES = {"installation", "households", "receipts", "audit", "outbox"}
IDENTITY_TABLES = BASE_TABLES | {"members", "household_policy"}
RECOVERY_TABLES = IDENTITY_TABLES | {"recovery_requests"}
HISTORY_TABLES = RECOVERY_TABLES | {"retained_operations"}
TASK_SCHEMA_TABLES = HISTORY_TABLES | TASK_TABLES
MATERIAL_SCHEMA_TABLES = TASK_SCHEMA_TABLES | MATERIAL_TABLES
TABLES = MATERIAL_SCHEMA_TABLES | RECURRENCE_TABLES
RECURRENCE_SCHEMA_TABLES = MATERIAL_SCHEMA_TABLES | RECURRENCE_TABLES
TABLES = RECURRENCE_SCHEMA_TABLES | PROFILE_TABLES


def _storage_error(error: sqlite3.Error) -> DomusError:
Expand Down Expand Up @@ -261,6 +265,8 @@ def __init__(self, connection: sqlite3.Connection, installation: Installation) -
self.audit = SQLiteAudit(connection)
self.outbox = SQLiteOutbox(connection)
self.members = SQLiteMembers(connection)
self.presentations = SQLitePresentations(connection)
self.preferences = SQLitePreferences(connection)
self.policies = SQLitePolicies(connection)
self.recoveries = SQLiteRecoveries(connection)
self.tasks = SQLiteTasks(connection)
Expand Down Expand Up @@ -314,7 +320,7 @@ def _inspect(
if version == 0 and application_id == 0 and not objects and allow_empty:
return 0
if version != SCHEMA_VERSION and not (
allow_previous and version in (1, 2, 3, 4, 5, 6, 7)
allow_previous and version in (1, 2, 3, 4, 5, 6, 7, 8)
):
raise DatabaseRejected("unsupported_schema")
expected_tables = {
Expand All @@ -325,6 +331,7 @@ def _inspect(
5: TASK_SCHEMA_TABLES,
6: MATERIAL_SCHEMA_TABLES,
7: MATERIAL_SCHEMA_TABLES,
8: RECURRENCE_SCHEMA_TABLES,
}.get(version, TABLES)
if application_id != APPLICATION_ID or objects != expected_tables:
raise DatabaseRejected("unrelated_database")
Expand Down Expand Up @@ -368,15 +375,16 @@ def initialize(self, *, require_existing: bool = False) -> None:
)
connection.execute(f"PRAGMA application_id={APPLICATION_ID}")
connection.execute(f"PRAGMA user_version={SCHEMA_VERSION}")
elif version in (1, 2, 3, 4, 5, 6, 7):
elif version in (1, 2, 3, 4, 5, 6, 7, 8):
migration = (
*(IDENTITY_STATEMENTS if version == 1 else ()),
*(RECOVERY_STATEMENTS if version < 3 else ()),
*(HISTORY_STATEMENTS if version < 4 else ()),
*(TASK_STATEMENTS if version < 5 else ()),
*(MATERIAL_STATEMENTS if version < 6 else ()),
*(REVIEW_STATEMENTS if version < 7 else ()),
*RECURRENCE_STATEMENTS,
*(RECURRENCE_STATEMENTS if version < 8 else ()),
*PROFILE_STATEMENTS,
)
for statement in migration:
connection.execute(statement)
Expand Down
2 changes: 2 additions & 0 deletions backend/src/domus/infrastructure/sqlite_identity.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
Role,
)
from domus.domain.storage import JsonDocument, JsonObject
from domus.infrastructure.sqlite_member_profiles import initialize_member_profiles


def _rules(encoded: str) -> list[JsonObject]:
Expand Down Expand Up @@ -95,6 +96,7 @@ def save(self, member: Member, expected_revision: int) -> None:
)
except sqlite3.IntegrityError:
raise Conflict("member_exists") from None
initialize_member_profiles(self.connection, member)
elif (
self.connection.execute(
"UPDATE members SET role=?,status=?,revision=?,exceptions=? "
Expand Down
21 changes: 21 additions & 0 deletions backend/src/domus/infrastructure/sqlite_member_profile_schema.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
"""Schema9 adds separate public/private member records without rewriting historical facts."""

PROFILE_TABLES = {"member_presentations", "member_preferences"}
PROFILE_STATEMENTS = (
"CREATE TABLE member_presentations(member_id TEXT PRIMARY KEY NOT NULL, "
"household_id TEXT NOT NULL, alias TEXT, "
"revision INTEGER NOT NULL CHECK(typeof(revision)='integer' AND revision>=1), "
"FOREIGN KEY(household_id,member_id) REFERENCES members(household_id,member_id), "
Comment thread
efraespada marked this conversation as resolved.
Resolved
"CHECK(alias IS NULL OR (typeof(alias)='text' AND length(alias) BETWEEN 1 AND 80)))",
"CREATE TABLE member_preferences(member_id TEXT PRIMARY KEY NOT NULL, "
"household_id TEXT NOT NULL, locale TEXT CHECK(locale IS NULL OR locale IN ('en','es')), "
"routine_participation_summaries INTEGER NOT NULL "
"CHECK(typeof(routine_participation_summaries)='integer' "
"AND routine_participation_summaries IN (0,1)), "
"revision INTEGER NOT NULL CHECK(typeof(revision)='integer' AND revision>=1), "
"FOREIGN KEY(household_id,member_id) REFERENCES members(household_id,member_id))",
"INSERT INTO member_presentations(member_id,household_id,alias,revision) "
"SELECT member_id,household_id,NULL,1 FROM members",
"INSERT INTO member_preferences(member_id,household_id,locale,"
"routine_participation_summaries,revision) SELECT member_id,household_id,NULL,1,1 FROM members",
)
138 changes: 138 additions & 0 deletions backend/src/domus/infrastructure/sqlite_member_profiles.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
"""Independent scoped profile revisions, strict decoding and atomic member initialization."""

import sqlite3
from dataclasses import dataclass
from typing import Literal, cast

from domus.domain.errors import Conflict, DomusError, StorageFailure
from domus.domain.identity import Member, validate_revision
from domus.domain.member_profiles import MemberLocale, MemberPreferences, MemberPresentation


def initialize_member_profiles(connection: sqlite3.Connection, member: Member) -> None:
presentation = MemberPresentation(member.member_id, member.household_id)
preferences = MemberPreferences(member.member_id, member.household_id)
connection.execute(
"INSERT INTO member_presentations(member_id,household_id,alias,revision) VALUES (?,?,?,?)",
(
presentation.member_id,
presentation.household_id,
presentation.alias,
presentation.revision,
),
)
connection.execute(
"INSERT INTO member_preferences(member_id,household_id,locale,"
"routine_participation_summaries,revision) VALUES (?,?,?,?,?)",
(
preferences.member_id,
preferences.household_id,
preferences.locale,
int(preferences.routine_participation_summaries),
preferences.revision,
),
)


def _row(
connection: sqlite3.Connection,
table: Literal["member_presentations", "member_preferences"],
household_id: str,
member_id: str,
) -> sqlite3.Row | None:
# A missing member is absence; a missing profile for an existing member is corruption.
return cast(
sqlite3.Row | None,
connection.execute(
f"SELECT p.* FROM members m LEFT JOIN {table} p "
"ON p.household_id=m.household_id AND p.member_id=m.member_id "
"WHERE m.household_id=? AND m.member_id=?",
(household_id, member_id),
).fetchone(),
)


def _revision(revision: int, expected: int) -> None:
validate_revision(expected)
if revision != expected + 1:
raise Conflict("revision_conflict")


@dataclass
class SQLitePresentations:
connection: sqlite3.Connection

def get(self, household_id: str, member_id: str) -> MemberPresentation | None:
row = _row(self.connection, "member_presentations", household_id, member_id)
if row is None:
return None
try:
value = MemberPresentation(
row["member_id"], row["household_id"], row["alias"], row["revision"]
)
if value.alias != row["alias"]:
raise ValueError("Noncanonical stored alias")
return value
except DomusError, ValueError:
raise StorageFailure("invalid_member_profile_record") from None

def save(self, presentation: MemberPresentation, expected_revision: int) -> None:
_revision(presentation.revision, expected_revision)
if (
self.connection.execute(
"UPDATE member_presentations SET alias=?,revision=? "
"WHERE household_id=? AND member_id=? AND revision=?",
(
presentation.alias,
presentation.revision,
presentation.household_id,
presentation.member_id,
expected_revision,
),
).rowcount
!= 1
):
raise Conflict("revision_conflict")


@dataclass
class SQLitePreferences:
connection: sqlite3.Connection

def get(self, household_id: str, member_id: str) -> MemberPreferences | None:
row = _row(self.connection, "member_preferences", household_id, member_id)
if row is None:
return None
try:
flag = row["routine_participation_summaries"]
if type(flag) is not int or flag not in (0, 1):
raise ValueError("Invalid stored preference flag")
return MemberPreferences(
row["member_id"],
row["household_id"],
None if row["locale"] is None else MemberLocale(row["locale"]),
flag == 1,
row["revision"],
)
except DomusError, ValueError:
raise StorageFailure("invalid_member_profile_record") from None

def save(self, preferences: MemberPreferences, expected_revision: int) -> None:
_revision(preferences.revision, expected_revision)
if (
self.connection.execute(
"UPDATE member_preferences SET locale=?,routine_participation_summaries=?,"
"revision=? "
"WHERE household_id=? AND member_id=? AND revision=?",
(
preferences.locale,
int(preferences.routine_participation_summaries),
preferences.revision,
preferences.household_id,
preferences.member_id,
expected_revision,
),
).rowcount
!= 1
):
raise Conflict("revision_conflict")
6 changes: 6 additions & 0 deletions backend/src/domus/infrastructure/sqlite_snapshots.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
last_committed_at,
verify_history,
)
from domus.infrastructure.sqlite_member_profile_schema import PROFILE_STATEMENTS
from domus.infrastructure.sqlite_recovery import _record as _recovery
from domus.infrastructure.sqlite_recurrence_integrity import verify_recurrence_records
from domus.infrastructure.sqlite_recurrence_schema import RECURRENCE_STATEMENTS
Expand Down Expand Up @@ -124,6 +125,7 @@ def _expected_schema(version: int) -> tuple[tuple[str, str], ...]:
*(MATERIAL_STATEMENTS if version >= 6 else ()),
*(REVIEW_STATEMENTS if version >= 7 else ()),
*(RECURRENCE_STATEMENTS if version >= 8 else ()),
*(PROFILE_STATEMENTS if version >= 9 else ()),
)
for statement in statements:
connection.execute(statement)
Expand Down Expand Up @@ -182,6 +184,10 @@ def _records(connection: sqlite3.Connection, version: int, installation: Install
if version >= 2:
members = unit.members.list(row[0])
policy = unit.policies.get(row[0])
if version >= 9:
for member in members:
unit.presentations.get(row[0], member.member_id)
unit.preferences.get(row[0], member.member_id)
if version >= 5:
verify_task_records(
connection, row[0], members, policy, record_version=1 if version == 5 else 2
Expand Down
16 changes: 15 additions & 1 deletion docs/architecture-household-administration.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,28 @@ facts or an explicit unavailable result, independently of human proof.
Private preference values are browser-following/null, English or Spanish locale, plus a strict
routine-summary boolean with true as the engineering default. Persisting a preference will not
establish notification delivery. Current owner-only query/command authorization, provider metadata,
HTTP contracts, the separate administration journal, schema 9 and the actual EN/ES workspace remain
HTTP contracts, the separate administration journal and the actual EN/ES workspace remain
to implement. These records do not establish their acceptance.

The [companion](../specs/household-administration.md) retains all 24 Action-published case rows and
adds the omitted independent risks to a 72-case floor. Its original identity and delivery owners
remain unchanged. The pure checkpoint runs on the admitted schema-7 base without changing storage
or APIs; persistence integration follows recurrence's actual schema-8 merge before schema 9.

The subsequent issue21 integration adds schema9 after the real PR22 schema8 merge. Application
[profile ports](../backend/src/domus/application/member_profile_ports.py) expose independently
revisioned public presentation and private preference repositories. The
[SQLite adapter](../backend/src/domus/infrastructure/sqlite_member_profiles.py) decodes persisted
records strictly and compares canonical member/household scope and expected revision on updates.
New member creation persists both domain defaults atomically; membership updates leave those
records untouched. No preference values enter public identity records, audit or outbox through
these storage operations. Application authorization and original-result privacy remain required
before these repositories gain any HTTP or workspace consumer.

The [migration guide](household-administration-migration.md) identifies genuine immutable source8
input, historical migration behavior, independent checkpoint validation, image checks and compatible
rollback. This storage increment does not reduce or complete the retained 72-case product scope.

The [checkpoint evidence](evidence/household-administration.json) distinguishes the initial failing
test, complete backend execution, per-module/global coverage, independent audit and architecture
contracts. No packaged update or owner-HA administration journey is claimed by this checkpoint.
Loading
Loading