diff --git a/.github/workflows/ci_check.yml b/.github/workflows/ci_check.yml index 23cea79..6d5a354 100644 --- a/.github/workflows/ci_check.yml +++ b/.github/workflows/ci_check.yml @@ -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 @@ -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: diff --git a/backend/src/domus/application/member_profile_ports.py b/backend/src/domus/application/member_profile_ports.py new file mode 100644 index 0000000..695e2f0 --- /dev/null +++ b/backend/src/domus/application/member_profile_ports.py @@ -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: ... diff --git a/backend/src/domus/application/ports.py b/backend/src/domus/application/ports.py index dc76f6d..a6472e3 100644 --- a/backend/src/domus/application/ports.py +++ b/backend/src/domus/application/ports.py @@ -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 @@ -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: ... diff --git a/backend/src/domus/infrastructure/sqlite.py b/backend/src/domus/infrastructure/sqlite.py index 0f840b9..44c5d7d 100644 --- a/backend/src/domus/infrastructure/sqlite.py +++ b/backend/src/domus/infrastructure/sqlite.py @@ -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 @@ -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), " @@ -96,6 +98,7 @@ *MATERIAL_STATEMENTS, *REVIEW_STATEMENTS, *RECURRENCE_STATEMENTS, + *PROFILE_STATEMENTS, ) BASE_TABLES = {"installation", "households", "receipts", "audit", "outbox"} IDENTITY_TABLES = BASE_TABLES | {"members", "household_policy"} @@ -103,7 +106,8 @@ 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: @@ -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) @@ -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 = { @@ -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") @@ -368,7 +375,7 @@ 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 ()), @@ -376,7 +383,8 @@ def initialize(self, *, require_existing: bool = False) -> None: *(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) diff --git a/backend/src/domus/infrastructure/sqlite_identity.py b/backend/src/domus/infrastructure/sqlite_identity.py index 19d0d12..9e18a40 100644 --- a/backend/src/domus/infrastructure/sqlite_identity.py +++ b/backend/src/domus/infrastructure/sqlite_identity.py @@ -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]: @@ -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=? " diff --git a/backend/src/domus/infrastructure/sqlite_member_profile_schema.py b/backend/src/domus/infrastructure/sqlite_member_profile_schema.py new file mode 100644 index 0000000..372ee3b --- /dev/null +++ b/backend/src/domus/infrastructure/sqlite_member_profile_schema.py @@ -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), " + "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", +) diff --git a/backend/src/domus/infrastructure/sqlite_member_profiles.py b/backend/src/domus/infrastructure/sqlite_member_profiles.py new file mode 100644 index 0000000..f5c2b9c --- /dev/null +++ b/backend/src/domus/infrastructure/sqlite_member_profiles.py @@ -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") diff --git a/backend/src/domus/infrastructure/sqlite_snapshots.py b/backend/src/domus/infrastructure/sqlite_snapshots.py index f95a8e9..6e0e11b 100644 --- a/backend/src/domus/infrastructure/sqlite_snapshots.py +++ b/backend/src/domus/infrastructure/sqlite_snapshots.py @@ -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 @@ -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) @@ -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 diff --git a/docs/architecture-household-administration.md b/docs/architecture-household-administration.md index 8946d56..75c25f0 100644 --- a/docs/architecture-household-administration.md +++ b/docs/architecture-household-administration.md @@ -15,7 +15,7 @@ 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 @@ -23,6 +23,20 @@ adds the omitted independent risks to a 72-case floor. Its original identity and 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. diff --git a/docs/evidence/household-administration.json b/docs/evidence/household-administration.json index f76e0e5..17d014a 100644 --- a/docs/evidence/household-administration.json +++ b/docs/evidence/household-administration.json @@ -70,6 +70,108 @@ "consumer": "Original failing and passing model/backend measurements, source inventory and pinned SDD reproduction retained for current PR review; disposable coverage databases, XML and pytest cache removed.", "privateLocalOnly": true }, - "pending": "Current-authorized application/HTTP/profile-source behavior, private preference disclosure guards, schema9 after actual recurrence schema8, separate administration journal, real EN/ES workspace, product browser/image/owner acceptance and complete issue16 acceptance. Existing runtime APIs and packaged Edge.5 do not expose these new models.", - "acceptance": "138 parameterized unit executions and a complete backend campaign are not 72 completed administration journeys. No notification delivery, HA metadata availability, schema migration, package/adoption, all-domain release or Stable distribution acceptance is claimed." + "pending": "Owner-private application/HTTP original results and audit privacy, verified HA metadata, separate journal, full EN/ES administration workspace, complete72-case product acceptance and fresh-head image/native checks after documentation correction. Recurrence continuation remains issue23; installed Edge.5 unchanged.", + "acceptance": "138 parameterized unit executions and a complete backend campaign are not 72 completed administration journeys. No notification delivery, HA metadata availability, schema migration, package/adoption, all-domain release or Stable distribution acceptance is claimed.", + "storageIncrement": { + "issue": "https://github.com/vypdev/domus/issues/21", + "sourceBase": "a9c4b9723c66868884476743940102ada65d2db0", + "baselineMerge": "4b71969d3b3f6df5f28d617ab0da4e86c9be5884", + "sourceIdentity": "Exact140-module backend inventory and wheel; working schema9 storage increment. This is not complete administration acceptance.", + "backendInventorySHA256": "902c56bf1045ebb3eac632881ee41794db48b6651f2d678ad0fd8bc904bd81c9", + "wheelSHA256": "9598d11e44083e7e7aac42fbb061d360bbe1593e7bf7498ce782ef188300ac62", + "implemented": "Independent scoped alias/private preference repositories, atomic member defaults, strict persisted decoding/CAS, schema1..8 to9 initialization and independent profile checkpoint inspection.", + "testsFirst": { + "original": "SQLiteUnit lacked presentations in the first authored storage test.", + "nullPrimaryKey": "Two direct SQL controls reproduced nullable TEXT primary keys; both profile primary keys now explicitly require NOT NULL.", + "historicalInput": "Schema5 retains the existing schema6 material-document conversion; all other original facts are compared unchanged." + }, + "focused": { + "executions": 74, + "scope": "Storage/adapters, all historical inputs, real simultaneous independent writers, late SQL/command/migration rollback, corruption and actual generation restoration. Repeated executions are not independent product journeys." + }, + "completeBackend": { + "executions": 4105, + "authoredModules": 140, + "statements": 9919, + "branches": 2822, + "coveragePercent": 100, + "independentGlobalAndPerModuleAudit": "passed" + }, + "checks": { + "strictTypes": "passed140modules", + "ruff": "passed354files", + "architecture": "3kept0broken;184files1204dependencies", + "packageAndArchitectureTests": "56passed", + "nodePolicy": "38passed;100%coverage", + "localDocumentationLinks": "passed", + "originalPinnedSddFormatAndConsistency": "passed;7originalpolicybytesverified;18retainedowners" + }, + "originalInput": { + "source": "ed7cd14190d750846833cf1bb822988b628560da", + "rawDatabaseSHA256": "badd63184fa40380462ab3fe54cd957b63a967d1beafb4b73fc6c2475df8340b", + "logicalFixtureSHA256": "041615f0253c0537ac6c713e10a8984fb5043b1f6df29a6a2c7193824ebc6ed5", + "sourceArchiveSHA256": "1d5c69cd2a61f2e8b2c7702ea112a2034c7849f4f59d3a479e41be001b8f2454", + "archiveAllTrackedFilesByteVerified": 520, + "originalBackendModules": 137 + }, + "runtimeScripts": { + "scope": "Exact current container script executed on host only; actual CPU images and Docker isolation remain pending.", + "outcomes": { + "native-0": { + "returnCode": 0, + "stdout": "", + "stderr": "" + }, + "native-8": { + "returnCode": 0, + "stdout": "", + "stderr": "" + }, + "standalone-0": { + "returnCode": 0, + "stdout": "", + "stderr": "" + }, + "standalone-8": { + "returnCode": 0, + "stdout": "", + "stderr": "" + } + } + }, + "hostedImages": { + "head": "e129e76e25deff7314efe3b72996d295cf73f7f3", + "run": 41459181271, + "allProductChecks": "success", + "amd64Job": 125323532976, + "aarch64Job": 125323533003, + "backendJob": 125323533010, + "aggregateJob": 125327226440, + "scope": "Actual both-CPU image upgrade/refusal/restoration and current native/standalone controls passed. A subsequent documentation-only correction requires fresh-head CI; all production module bytes are unchanged." + }, + "copilotReports": { + "assembly": "https://github.com/vypdev/copilot/issues/833#issuecomment-6578894964", + "unsupportedPublishedCommand": "https://github.com/vypdev/copilot/issues/858", + "publicationRecovery": "https://github.com/vypdev/copilot/issues/856#issuecomment-6578910284", + "foreignKeyFalsePositive": "https://github.com/vypdev/copilot/issues/859" + }, + "pending": "Owner-private application/HTTP original results and audit privacy, verified HA metadata, separate journal, full EN/ES administration workspace, complete72-case product acceptance and fresh-head image/native checks after documentation correction. Recurrence continuation remains issue23; installed Edge.5 unchanged.", + "localEvidence": { + "root": "/private/tmp/domus-member-storage-a9c4b97", + "privateLocalOnly": true, + "scope": "Original failing and final passing logs, full coverage JSON, source/wheel/SDK proofs and genuine input provenance retained for current PR review; disposable test DBs retired after producers finish." + }, + "initialNativeReview": { + "run": 41459181117, + "job": 125323533768, + "partitionsCompleted": 4, + "partitionsTotal": 4, + "contextCoverage": "complete", + "published": 2, + "validCorrection": "Generated owner Git blob citation corrected against immutable base; owner bytes/cases unchanged.", + "falsePositive": "Composite FK claim disproved by9 exact-source controls and2 private counterfactuals; existing task_member_scope unique index is unchanged. No production workaround.", + "report": "https://github.com/vypdev/copilot/issues/859", + "pending": "Fresh native reconciliation after documentation correction; no clean-review claim inferred from an empty or manually resolved thread." + } + } } diff --git a/docs/household-administration-migration.md b/docs/household-administration-migration.md new file mode 100644 index 0000000..814ef09 --- /dev/null +++ b/docs/household-administration-migration.md @@ -0,0 +1,81 @@ +# Member profile storage migration + +Issue [#21](https://github.com/vypdev/domus/issues/21) adds schema9 on the actual schema8 +checkpoint merged in [PR #22](https://github.com/vypdev/domus/pull/22). The +[integration companion](../specs/household-administration-integration.md) retains the complete +[administration contract](../specs/household-administration.md). Storage is one implementation +increment; it does not establish the workspace, owner-authorized HTTP commands, HA presentation +metadata, notification delivery, or any of the 72 complete administration journeys. + +## State and defaults + +`member_presentations` holds the canonical member/household references, nullable public alias, +and its own revision. `member_preferences` holds those references, nullable locale, the strict +routine-summary flag, and a separate revision. Both reference the existing composite member +scope. Existing identity, membership status, permissions and HA links remain authoritative. + +Migration creates one explicit record of each kind for every existing member, including pending, +suspended and archived members. New member creation initializes both domain-defined defaults in +the same transaction. Defaults are alias null, locale null (browser-following), routine summaries +true, and revision1. A default does not invent an HA name or deliver a notification. Membership +updates preserve profile state; alias and preference revisions do not invalidate one another. + +Storage ports do not authorize a caller. The eventual application commands and queries must +enforce current source/local authority, owner-only preference disclosure, CSRF/context, immutable +original requests, and safe audit/event content before exposing this state. Direct repository +tests are not acceptance of those boundaries. + +## Genuine upgrade inputs + +Supported initialization inputs are the original schema1 through schema8 fixtures. Historical SQL +files remain unchanged. The new [schema8 fixture](../tests/fixtures/sqlite/v8.sql) is a byte copy +of the complete logical dump captured from published source +`ed7cd14190d750846833cf1bb822988b628560da`, including the original `application_id` and +`user_version` pragmas. It was not reconstructed by current code or relabeled as an older schema. + +| Original input | SHA256 | +|---|---| +| Private raw source database | `badd63184fa40380462ab3fe54cd957b63a967d1beafb4b73fc6c2475df8340b` | +| Complete logical dump / committed v8 fixture | `041615f0253c0537ac6c713e10a8984fb5043b1f6df29a6a2c7193824ebc6ed5` | +| Published source archive used by compatibility image | `1d5c69cd2a61f2e8b2c7702ea112a2034c7849f4f59d3a479e41be001b8f2454` | + +The source fixture has its own original installation UUID +`00000000-0000-0000-0000-000000000007` and Edge channel. Compatibility tests read that identity +instead of modifying it to match another fixture. Its fixed slots, pristine relative progress, +task state and six original operation/audit/outbox records are real application output with +fictional HA proof. They contain no owner HA data and do not prove relative materialization. + +Ordinary reads/writes refuse previous schemas. Explicit initialization verifies installation and +table ownership, then performs historical additions and schema9 additions inside one transaction. +Only successful completion writes version9. Source8 task, recurrence, identity and operation rows +remain identical. Earlier supported migrations retain their existing transformations: schema5 +task documents acquire schema6 empty material snapshots and version2; original receipts, cycles, +audit and events remain unchanged. No schema1..8 input is edited to achieve compatibility. + +## Failure, restoration and rollback + +An invalid addition or a late write failure rolls back the new records, tables and version together. +Tests compare the original database bytes after injected migration failure for each supported +input. A changed version number without the two profile tables cannot authorize profile use. +Missing or invalid persisted profile records fail closed; reads do not silently recreate defaults. +Checkpoint inspection independently checks schema, foreign keys and both profile records for +every member, in addition to existing task, recurrence and operation integrity checks. + +Existing offline checkpoint preparation, installation leases, generation selection and restoration +continue to apply. A current checkpoint includes public aliases and private preferences. Restoring +that checkpoint restores its profile state, while the prior complete source remains available +under the existing rollback contract. Original processed-operation evidence is retained separately; +restoration cannot replay an old effect as new work. + +A previous application must refuse schema9 and selected newer generations without changing files. +Compatible rollback requires the matching complete application, database and installation/generation +state; running an old executable against the schema9 database is not a downgrade procedure. The +[immutable schema8 image](../tests/fixtures/schema8-app/Dockerfile) builds the complete published +backend and frontend from its checksum-pinned archive. Every PR CI Check runs it with both current +CPU images for real upgrade, snapshot inspection, refusal and restore controls. Hosted results must +pass before image compatibility is claimed. Host execution of a packaged test script establishes +only host behavior. + +The installed owner Edge.5/schema7 package is unchanged. This increment does not publish a release, +promote Stable/Edge images, update HA, or accept the remaining recurrence/calendar issue +[#23](https://github.com/vypdev/domus/issues/23). diff --git a/specs/CATALOG.md b/specs/CATALOG.md index d0c0235..863971e 100644 --- a/specs/CATALOG.md +++ b/specs/CATALOG.md @@ -11,7 +11,7 @@ debt or convert unknown historic intent into a design decision. | Capability ID | Status | Scope | Primary SDD | Evidence | |---|---|---|---|---| | `platform-foundation` | Proposed | One local modular application serves the complete household product in both a Supervisor-managed App and standalone Docker from the first release. Home Assistant owns user authentication; Domus owns its business records. | [Local application platform and persistence](./platform-foundation.md) + 2 companion | 189 paths · 2026-11-01 | -| `household-identity` | Proposed | Each participating person uses their own Home Assistant account. Domus associates that verified user with one household member and applies explicit backend permissions independently of an optional person entity. | [Household members, Home Assistant identity and permissions](./household-identity.md) + 1 companion | 168 paths · 2026-10-31 | +| `household-identity` | Proposed | Each participating person uses their own Home Assistant account. Domus associates that verified user with one household member and applies explicit backend permissions independently of an optional person entity. | [Household members, Home Assistant identity and permissions](./household-identity.md) + 2 companion | 168 paths · 2026-10-31 | | `household-tasks` | Proposed | Members create and perform household responsibilities with explicit assignments, actual performers and history. A recurring definition survives completion of each individual occurrence. | [Task definitions, occurrences and household execution](./household-tasks.md) + 1 companion | 32 paths · 2026-11-01 | | `recurrence-calendar` | Proposed | A bounded scheduler turns task definitions into stable occurrences and a family calendar distinguishes planned start, deadline and actual completion. | [Recurrence engine and family calendar](./recurrence-calendar.md) + 2 companion | 22 paths · 2026-10-29 | | `points-rewards` | Proposed | Task completion produces auditable point entries. Household-configurable collaboration policies determine each share and the actual award total; corrections preserve history and optional rewards never control basic participation. | [Auditable points, participation and optional rewards](./points-rewards.md) | 5 paths · 2026-11-01 | @@ -39,7 +39,7 @@ debt or convert unknown historic intent into a design decision. - Owner: Domus maintainers (review assignment pending) - Last verified: 2026-10-31 -- Specifications: [`specs/household-identity.md`](./household-identity.md) · [`specs/household-administration.md`](./household-administration.md) +- Specifications: [`specs/household-identity.md`](./household-identity.md) · [`specs/household-administration.md`](./household-administration.md) · [`specs/household-administration-integration.md`](./household-administration-integration.md) - Workflows: Not applicable for this capability. - Entrypoints: [`backend/src/domus/composition/server.py`](../backend/src/domus/composition/server.py) · [`backend/src/domus/entrypoints/http.py`](../backend/src/domus/entrypoints/http.py) · [`backend/src/domus/composition/ingress.py`](../backend/src/domus/composition/ingress.py) · [`backend/src/domus/entrypoints/ingress_http.py`](../backend/src/domus/entrypoints/ingress_http.py) · [`backend/src/domus/entrypoints/household_http.py`](../backend/src/domus/entrypoints/household_http.py) · [`backend/src/domus/composition/household.py`](../backend/src/domus/composition/household.py) · [`backend/src/domus/entrypoints/native_http.py`](../backend/src/domus/entrypoints/native_http.py) · [`backend/src/domus/composition/admin.py`](../backend/src/domus/composition/admin.py) · [`backend/src/domus/entrypoints/console_recovery.py`](../backend/src/domus/entrypoints/console_recovery.py) · [`backend/src/domus/composition/ha_app.py`](../backend/src/domus/composition/ha_app.py) - Core code: [`backend/src/domus/domain/identity.py`](../backend/src/domus/domain/identity.py) · [`backend/src/domus/application/authorization.py`](../backend/src/domus/application/authorization.py) · [`backend/src/domus/application/identity_ports.py`](../backend/src/domus/application/identity_ports.py) · [`backend/src/domus/application/sessions.py`](../backend/src/domus/application/sessions.py) · [`backend/src/domus/infrastructure/sqlite_identity.py`](../backend/src/domus/infrastructure/sqlite_identity.py) · [`backend/src/domus/infrastructure/memory_sessions.py`](../backend/src/domus/infrastructure/memory_sessions.py) · [`backend/src/domus/infrastructure/ha_config.py`](../backend/src/domus/infrastructure/ha_config.py) · [`backend/src/domus/infrastructure/ha_http.py`](../backend/src/domus/infrastructure/ha_http.py) · [`backend/src/domus/infrastructure/ha_websocket.py`](../backend/src/domus/infrastructure/ha_websocket.py) · [`backend/src/domus/infrastructure/ha_ingress.py`](../backend/src/domus/infrastructure/ha_ingress.py) · [`backend/src/domus/application/authentication.py`](../backend/src/domus/application/authentication.py) · [`backend/src/domus/application/authentication_values.py`](../backend/src/domus/application/authentication_values.py) · [`backend/src/domus/composition/server.py`](../backend/src/domus/composition/server.py) · [`backend/src/domus/entrypoints/http.py`](../backend/src/domus/entrypoints/http.py) · [`backend/src/domus/infrastructure/auth_runtime.py`](../backend/src/domus/infrastructure/auth_runtime.py) · [`backend/src/domus/infrastructure/memory_attempts.py`](../backend/src/domus/infrastructure/memory_attempts.py) · [`backend/src/domus/infrastructure/standalone_sessions.py`](../backend/src/domus/infrastructure/standalone_sessions.py) · [`backend/src/domus/application/session_values.py`](../backend/src/domus/application/session_values.py) · [`backend/src/domus/composition/ingress.py`](../backend/src/domus/composition/ingress.py) · [`backend/src/domus/entrypoints/ingress_http.py`](../backend/src/domus/entrypoints/ingress_http.py) · [`backend/src/domus/entrypoints/bounded_http.py`](../backend/src/domus/entrypoints/bounded_http.py) · [`backend/src/domus/application/household_commands.py`](../backend/src/domus/application/household_commands.py) · [`backend/src/domus/infrastructure/application_runtime.py`](../backend/src/domus/infrastructure/application_runtime.py) · [`backend/src/domus/infrastructure/ha_directory.py`](../backend/src/domus/infrastructure/ha_directory.py) · [`backend/src/domus/application/member_commands.py`](../backend/src/domus/application/member_commands.py) · [`backend/src/domus/application/administrator_proof.py`](../backend/src/domus/application/administrator_proof.py) · [`backend/src/domus/application/policy_commands.py`](../backend/src/domus/application/policy_commands.py) · [`backend/src/domus/application/household_queries.py`](../backend/src/domus/application/household_queries.py) · [`backend/src/domus/entrypoints/household_http.py`](../backend/src/domus/entrypoints/household_http.py) · [`backend/src/domus/entrypoints/http_contract.py`](../backend/src/domus/entrypoints/http_contract.py) · [`backend/src/domus/composition/household.py`](../backend/src/domus/composition/household.py) · [`backend/src/domus/infrastructure/session_access.py`](../backend/src/domus/infrastructure/session_access.py) · [`backend/src/domus/entrypoints/native_http.py`](../backend/src/domus/entrypoints/native_http.py) · [`backend/src/domus/entrypoints/http_errors.py`](../backend/src/domus/entrypoints/http_errors.py) · [`backend/src/domus/entrypoints/ingress_boundary.py`](../backend/src/domus/entrypoints/ingress_boundary.py) · [`backend/src/domus/infrastructure/ingress_sessions.py`](../backend/src/domus/infrastructure/ingress_sessions.py) · [`backend/src/domus/infrastructure/owned_storage.py`](../backend/src/domus/infrastructure/owned_storage.py) · [`backend/src/domus/domain/recovery.py`](../backend/src/domus/domain/recovery.py) · [`backend/src/domus/application/recovery_commands.py`](../backend/src/domus/application/recovery_commands.py) · [`backend/src/domus/infrastructure/sqlite_recovery.py`](../backend/src/domus/infrastructure/sqlite_recovery.py) · [`backend/src/domus/composition/admin.py`](../backend/src/domus/composition/admin.py) · [`backend/src/domus/entrypoints/console_recovery.py`](../backend/src/domus/entrypoints/console_recovery.py) · [`backend/src/domus/domain/snapshots.py`](../backend/src/domus/domain/snapshots.py) · [`backend/src/domus/application/snapshots.py`](../backend/src/domus/application/snapshots.py) · [`backend/src/domus/infrastructure/sqlite_snapshots.py`](../backend/src/domus/infrastructure/sqlite_snapshots.py) · [`backend/src/domus/composition/ha_app.py`](../backend/src/domus/composition/ha_app.py) · [`backend/src/domus/infrastructure/sqlite_history.py`](../backend/src/domus/infrastructure/sqlite_history.py) · [`backend/src/domus/domain/generations.py`](../backend/src/domus/domain/generations.py) · [`backend/src/domus/domain/activation.py`](../backend/src/domus/domain/activation.py) · [`backend/src/domus/application/activation.py`](../backend/src/domus/application/activation.py) · [`backend/src/domus/composition/restore.py`](../backend/src/domus/composition/restore.py) · [`backend/src/domus/infrastructure/offline_storage.py`](../backend/src/domus/infrastructure/offline_storage.py) · [`backend/src/domus/infrastructure/restore_source.py`](../backend/src/domus/infrastructure/restore_source.py) · [`backend/src/domus/infrastructure/sqlite_activation.py`](../backend/src/domus/infrastructure/sqlite_activation.py) · [`backend/src/domus/domain/member_profiles.py`](../backend/src/domus/domain/member_profiles.py) diff --git a/specs/catalog.json b/specs/catalog.json index a954304..2bcea1e 100644 --- a/specs/catalog.json +++ b/specs/catalog.json @@ -228,7 +228,8 @@ "lastVerified": "2026-10-31", "specs": [ "specs/household-identity.md", - "specs/household-administration.md" + "specs/household-administration.md", + "specs/household-administration-integration.md" ], "workflows": [], "entrypoints": [ diff --git a/specs/household-administration-integration.md b/specs/household-administration-integration.md new file mode 100644 index 0000000..aa30911 --- /dev/null +++ b/specs/household-administration-integration.md @@ -0,0 +1,433 @@ +# Household administration integration continuation + +- Status: Draft +- Date: 2026-11-02 +- Catalog capability ID: household-identity (companion) +- Last verified: not applicable to proposed integration; baseline verified at `4b71969d3b3f6df5f28d617ab0da4e86c9be5884` +- Owners: Domus maintainers (review assignment pending) +- Scope: Complete the retained household administration workspace on the merged schema8 base, adding schema9 while preserving existing owners and all original product journeys. +- Related issues/PRs: [Issue #21](https://github.com/vypdev/domus/issues/21) · [PR #22](https://github.com/vypdev/domus/pull/22) · [Issue #23](https://github.com/vypdev/domus/issues/23) +- Required review gates: product UX, architecture, testing, documentation, security/operations +- Open decisions blocking readiness: None; implementation and acceptance evidence remain outstanding. + +This new companion preserves `specs/household-administration.md`, `specs/household-identity.md`, and delivery ownership verbatim. The existing administration owner remains authoritative for all eight HADM requirements and its 72 original case rows. All identity and delivery requirements also remain in force. This companion adds integration constraints and eight separately identified executable refinements; it does not replace or redefine the 72 product journeys. Publication is not implementation acceptance. + +## 1. Executive summary + +Verified participants need a complete place to understand household membership and manage data they are permitted to change. The proposed workspace provides a current-authority roster, participant and administrator actions, public presentation, and owner-private preferences in Home Assistant (HA) Ingress and standalone deployments, in English and Spanish. Administrators manage participation and existing permission profiles; they cannot inspect another member's private preferences. HA identity and source checks remain authoritative. A name or optional alias is display text, never identity or permission. + +Recommended defaults are a nullable alias, browser-language locale with English fallback, and enabled routine-summary preference. The preference is stored only; delivery remains unavailable until its actual owning consumer exists. Mutations use current authorization, aggregate revisions, and original-request recovery. A receipt proves the original request was processed, not that its effects remain current. + +```text +Verified entry -> current authorized roster -> explicit revision-bound action -> original receipt -> refreshed current state +``` + +## 2. Problem, current behavior, and evidence + +### 2.1 Problem + +Participants can use existing identity and entry capabilities, but cannot yet manage the complete verified household workspace. Without it they cannot reliably review their participation, presentation, or private preferences, while administrators lack the integrated management surface for membership and existing access policy. This gap must be completed without fabricating HA facts, disclosing private preferences, or weakening current-authority checks. + +### 2.2 Current behavior + +1. The selected development base is the actual PR #22 merge, `4b71969d3b3f6df5f28d617ab0da4e86c9be5884`; its tree is byte-identical to reviewed head `ed7cd14190d750846833cf1bb822988b628560da`. +2. That source has schema8, identity and task APIs, entry UI, pure member-presentation and member-preference models, and merged recurrence schema8, fixed execution, and relative storage/creation. +3. It does not have the complete administration workspace, persisted public/private profile state, HA profile-source adapter, or separate administration journal. Those remain proposed work. +4. The full recurrence/calendar scope continues in issue #23 and is not accepted by this companion. Owner Edge.5 remains a separate schema7 package; this document does not update it. + +### 2.3 Evidence + +- Code/workflow/documentation references: immutable selected source tree; owning catalog entry `household-identity`; `specs/household-identity.md`, `specs/household-administration.md`, and `specs/delivery-quality.md` at the selected base. +- Baseline measurements supplied for that source: 4,031 backend executions, 137 authored modules, and 100% supported statement/branch coverage globally and per module; applicable product CI and both actual image architectures pass. These establish baseline evidence only, not acceptance of this proposal or all 72 journeys. +- Existing administration owner evidence: `specs/household-administration.md`, 57,838 UTF-8 bytes, Git blob `f315a00d9efbb7da8a9ea44de8a9df2d088e100a`, SHA256 `333791e5664212473dd8874be9751c00c8ee7644cf9f861f11a3876dd97321f3`, 72 case rows, HADM-01 through HADM-08. +- The selected development source contains 22 specification files. Bootstrap master `8ee7e1a15e91c91b6204ca97e8808c3207dee008` has no specs directory and is not the selected source. +- Genuine schema8 input evidence captured before schema9: all 137 source modules were byte-checked against Git; raw database SHA256 `badd63184fa40380462ab3fe54cd957b63a967d1beafb4b73fc6c2475df8340b`; complete logical dump with captured header pragmas SHA256 `041615f0253c0537ac6c713e10a8984fb5043b1f6df29a6a2c7193824ebc6ed5`. This is fictional local HA proof and real application/SQLite execution, not owner data or proof of relative materialization. +- Incidents or concrete examples: issue #21 identifies the unfinished portion of closed issue #16; PR #18 merged only the pure-model checkpoint, not the full 72-case contract. +- External primary sources: not applicable; no external provider behavior is inferred. +- Unknowns: implementation details and runtime acceptance remain to be established. Provider/library feasibility is not inferred from current specification paths. + +### 2.4 Retrospective classification (as-built baselines only) + +Not applicable: this is a prospective companion, not a reconstruction of implemented behavior. The evidence above describes the verified baseline and does not imply the proposed workspace exists. + +## 3. Actors, surfaces, and terminology + +| Actor | Goal | Entry point | Visible surfaces | +|---|---|---|---| +| Verified participant | Review identity/access and manage own alias and private preferences | Existing authenticated HA context | Ingress or standalone administration workspace | +| Current household administrator | Manage participation and permitted household/access policy | Existing administrator proof and current household context | Roster, member detail, settings and permission-profile forms | +| Operator | Preserve, inspect, or restore compatible owned application state | Existing installation/recovery tools | Operator and migration documentation | +| Maintainer | Implement and verify the proposed integration | Repository and CI | Specs, code, tests, docs, checks and evidence | + +A canonical HA user is the identity. HA name and optional Domus alias are public presentation. Locale and routine-summary choice are owner-private preferences. Membership status, role, permission profile, mapping, household settings, task and recurrence facts remain owned by their existing authoritative models. A technical receipt describes original processing, not present authority or effects. + +## 4. Goals, non-goals, and fixed invariants + +### 4.1 Goals + +| Requirement | Behavior | +|---|---| +| HADI-01 | Integrate public presentation and alias behavior while preserving original HADM-01 identity distinction and HADM-03 authority contracts. | +| HADI-02 | Integrate bounded, honest HA metadata/directory reads and permission-aware roster/detail queries, preserving HADM-01 and HADM-02. | +| HADI-03 | Integrate current-authorized, revisioned alias, household/member/profile/mapping commands and original-result disclosure, preserving HADM-03 and HADM-05. | +| HADI-04 | Persist and expose only owner-authorized locale and routine-summary preferences, preserving HADM-04 and privacy rules. | +| HADI-05 | Add actual schema9 migration and restore compatibility from verified schema8, preserving HADM-06 and genuine original evidence. | +| HADI-06 | Add separate closed administration recovery journal and current-context command/query boundaries, preserving HADM-05. | +| HADI-07 | Complete accessible English/Spanish administration in both deployments and the existing catalog/browser workflow, preserving HADM-07. | +| HADI-08 | Maintain tests-first delivery, full supported per-module/global coverage, Clean Architecture and truthful documentation/quality evidence, preserving HADM-08 and all 72 original cases. | + +### 4.2 Non-goals + +1. No HA owner update, release/tag, image promotion, deployment, extra port, or production rollout is authorized by publication. +2. No gateway, Copilot resource/configuration/credential changes, person-based authentication, arbitrary external image, or notification delivery is added. +3. Issue #23's unfinished recurrence/calendar scope is not included or claimed accepted. +4. This companion does not copy, replace, or reduce the owning administration or identity contracts. + +### 4.3 Fixed product/safety invariants + +1. Current HA source checks and current local authorization govern each action; stale grants never authorize a new effect. +2. Alias, display name, avatar, browser state, request key and labels cannot establish identity or grant access. +3. Administrators cannot read or edit another member's private preferences, including through receipts, audit, events, roster, logs or shared projections. +4. Preserve pending, active, suspended and archived membership; inherited permission provenance; protected permissions; and last-effective-administrator safeguards. +5. No automatic mutation retry, request replacement, stale-dialog rebase, or silent replay with a new key. +6. Preserve original identities/history, schema1..8 inputs, processed-operation and restore evidence, immutable fixtures, installation/channel/generation isolation, and compatible complete application/database rollback. +7. Keep administration journal separate from the entry journal; no CSRF, token, cookie or raw provider state is stored in either journal. + +## 5. Current versus proposed product journey + +| Stage | Current | Proposed | User/operator effect | +|---|---|---|---| +| Entry | Verified identity and setup/join entry exist; no full administration workspace | Open current household workspace in either deployment | Participant sees current verified context | +| Review | Existing APIs/models provide partial building blocks | Permission-aware roster, member details, pending review and honest HA metadata | User can distinguish verified facts from unavailable data | +| Change | Full integrated profile/preferences and administration flow is absent | Explicit form submits a revision-bound, currently authorized action | No implicit authority or stale overwrite | +| Recovery | Existing identity/recovery owners remain authoritative | Separate bounded administration journal retains original operation identity | Lost response is resolved against the same request | +| Upgrade | Selected base is schema8 | Actual migration to schema9 preserves original facts | Operator can verify migration or restore compatible state | + +```mermaid +flowchart LR + A[Verified participant entry] --> B[Current source and local authority] + B --> C[Read permitted roster or own preferences] + C --> D[Human confirms revision-bound change] + D --> E[Persist original result and refresh current state] +``` + +Text equivalent: verified entry -> current source/local authority -> permitted view -> explicit revision-bound change -> original result and refreshed current state. + +## 6. Functional behavior and state model + +### 6.1 Happy path + +1. The entrypoint derives the principal and household only from the existing trusted session/context. +2. Application queries obtain current authority and bounded HA source data; unavailable or denied source data is shown as unavailable, never as an empty verified directory. +3. The participant views public roster data and only their own private preferences. An administrator sees permitted household/member/access data and no other participant's preferences. +4. A human selects an action. The application checks fresh proof, CSRF, Origin/context, expected aggregate revision, source authorization, original key/payload, and current local grants. +5. The writer rechecks mutable local grants after external preparation and commits the action atomically with its original result/audit facts. +6. The interface reports effect and next step, then refreshes current state. Historical receipt and current effect remain distinct. + +### 6.2 Alternative paths + +- Pending membership remains pending until an authorized administrator acts. Suspension and archive retain their original owner-defined semantics. +- A missing HA name/avatar uses a neutral fallback; no empty directory or invented identity is presented as verified. +- Saving routine-summary preference changes storage only. Delivery is shown as unavailable until the actual owning consumer exists. +- A stale revision, changed permission, unavailable source, or invalid proof blocks a new effect and leaves an actionable draft where safe. +- A lost response retains the same original request for protected lookup/replay. It never creates a replacement operation automatically. +- After restore, original historical processing can be disclosed separately from absent current effects; restore does not recreate those effects or delivery. + +### 6.3 State machine + +| State | Entered when | User-visible meaning | Allowed next states | Recovery/owner | +|---|---|---|---|---| +| Current | Current source and local context verified | View reflects current authorized facts | Draft, unavailable, denied | Refresh through existing query owner | +| Draft | Human edits a permitted form | No change has been committed | Submitted, canceled, stale | Participant/admin decides; presentation owns draft | +| Submitted | Explicit request sent with original key/revision | Outcome may be processing or uncertain | Committed, rejected, uncertain | Application command and journal | +| Uncertain | Response absent or unreadable | Effect is not yet known | Recovered, expired | Recover same original request; no replacement | +| Committed | Original result confirms processing | Original action processed | Current after refresh, historical after restore | Receipt is historical evidence only | +| Unavailable/denied | Source, proof, or access is not sufficient | No new effect is authorized | Current after recheck, draft | Correct context or retry query after human action | + +Duplicates use the original idempotency contract. Out-of-order revisions are rejected. Cancel before send may discard a draft; cancel after send cannot negate an effect. Partial migration or late-write failure rolls back data, defaults, version and associated receipt/audit/outbox together. + +## 7. User-facing configuration + +| Input | Type | Recommended default | Allowed values/range | Scope/persistence | +|---|---|---|---|---| +| Domus alias | nullable string | null; HA name remains default | null or trimmed 1..80 Unicode code points; reject Cc before trimming; preserve literal text without HTML or Unicode normalization | Public presentation for the canonical member; revisioned persistence | +| Locale | nullable enum | null, following browser base language, then English | null, `en`, `es` | Owner-private preference; persisted and read only by owner | +| Routine-summary preference | boolean | true | strict boolean only | Owner-private persisted choice; no delivery claim | +| Administration journal | closed record collection | empty | version 1, at most 8 records, 65,536 bytes total, one-hour retention | Separate browser namespace; original key/payload/context/lifecycle only | + +Household name/timezone, member/profile/mapping limits, at most 256 ordinary decisions, and verified directory result bounds remain those in the existing owner. Unknown fields and boolean-alias/numeric encodings fail safely without changing the original request. No delivery address/channel, arbitrary settings object or image URL is configurable. In-flight actions snapshot their original payload and expected revision; a retry looks up that same operation. Configuration cannot weaken authorization, source checks, journal separation or last-administrator safeguards. + +Recommended example: alias unset, locale unset, routine summary enabled. Meaningful alternative: participant chooses a bounded alias and Spanish locale, and disables routine-summary preference; none of these changes identity or sends a notification. + +## 8. Clean Architecture design + +### 8.1 Responsibilities and dependency direction + +| Layer/boundary | Owns | Must not own/import | +|---|---|---| +| Domain/pure policy | Member presentation/preference values, bounds, alias validation, permission invariants | HA SDK, HTTP, browser, SQLite | +| Application | Administration commands/queries, authorization ordering, semantic ports, revisions and result contracts | Concrete SDK/process calls or UI orchestration | +| Adapters/data | HA metadata translation, strict persistence, journal codec and error mapping | Product permission decisions | +| Infrastructure/composition | SQLite transactions, deployment wiring and credentials | Business decisions or principal selection from request data | +| Entrypoints | Closed DTO parsing and trusted current-context adaptation | Duplicated orchestration or accepting a principal from browser payload | +| Presentation | Accessible view models, localized content, inert text rendering | Authorization or durable mutation policy | + +```mermaid +flowchart LR + E[Entrypoint] --> A[Application use cases] + A --> D[Domain policy] + A --> P[Semantic ports] + H[HA adapter] --> P + S[SQLite adapter] --> P + A --> V[Presentation model] + V --> U[Ingress and standalone UI] +``` + +Text equivalent: entrypoints adapt trusted context to application use cases; application uses pure domain policy and semantic ports; HA/SQLite adapters implement those ports; presentation renders results for both deployment surfaces. + +### 8.2 Contracts, state, and trust boundaries + +- Pure decisions: alias bounds/literal handling, nullable defaults, locale selection, preference ownership, permission-profile provenance and last-administrator invariants. +- Application contracts: immutable narrow commands/queries with canonical member identity, expected revision and trusted current context supplied by composition. +- Semantic ports: bounded verified HA profile/directory read, member-profile persistence, current authority, and atomic original-operation journal/result access. +- Durable state/schema ownership: schema9 adds public presentation and owner-private preference state without changing identity or task/recurrence authority. Independent checkpoint inspection validates new records. +- Concurrency/idempotency: aggregate revision and persisted original key/payload; recheck local mutable grants in writer transaction after external preparation; atomically persist result and related facts. +- Trusted/untrusted inputs: session/composition context is trusted only after existing verification. Browser DTO, aliases, HA labels, journal records and provider response are untrusted until validated. +- Provider error mapping: unavailable, denied, malformed and confirmed-absent are distinct outcomes. None is silently mapped to verified empty data. + +### 8.3 Executable architecture constraints + +Automated dependency/import checks MUST keep domain free of infrastructure/framework imports, application free of concrete provider SDKs, and presentation free of mutation policy. Contract tests MUST reject unknown DTO fields and malformed journal records. Schema tests MUST inspect actual checkpoints and migration rollback. Shared command/recovery registries MUST enumerate allowed actions; unknown actions cannot gain generic recovery authority. CI MUST run all applicable suites and both image architectures on every PR. + +## 9. UI/UX and content contract + +### 9.1 Information hierarchy + +Show current status, completed facts, next transition, required human action, impact and inspectable route, then collapsed technical evidence. Explain verified versus unavailable HA facts and show delivery unavailable beside the saved routine-summary choice. + +### 9.2 Representative primary view + +```markdown +# Household members + +> **Current status:** Your household information is up to date. +> +> **Action required:** Review the pending member request. + +## Members + +- Ana García — Active · HA name +- R. — Pending · details available to household administrators + +## Your preferences + +- Language: English (follows browser) +- Routine summaries: Saved; delivery is unavailable + +## What happens next + +Choose a permitted action. Changes are checked against current access before saving. +``` + +Concrete state variants: + +- Pending/no action: “Your household is up to date. No action required. The current roster is available below.” +- Action required: “One membership request is pending. Review its verified account details before approving or declining.” +- Blocked before effect: “No change was saved. Your access changed while this page was open. Refresh the roster and choose again; your draft remains on this device.” +- Partial/uncertain: “We could not confirm whether the request completed. Its original request is retained. Check its result before taking another action.” +- Completed: “Your alias was saved. The household view has refreshed; this receipt records the original save, while the roster shows current state.” + +### 9.3 Issue, PR, and comment behavior + +Issue and PR titles describe household administration integration. Merge or close does not itself prove runtime acceptance. Status updates must be deterministic and idempotent; checks and labels supplement the readable state. Keep comments/notifications to one initial summary and necessary state changes, without repeating unchanged evidence. Link directly to the canonical issue, PR, selected source, docs and recovery destination when those destinations exist. No remote branch or workflow resource is created by this specification. + +### 9.4 Accessibility, localization, and responsive behavior + +Support English and Spanish. An explicit owner-private locale wins; otherwise use browser base language, then English. Provide keyboard operation, visible focus, screen-reader status, logical headings, responsive reflow and light/dark contrast. Text is the equivalent of icons and diagrams. At narrow widths tables become readable stacked content. Escape all member/provider text as inert text; do not render user text as HTML, mention, URL or command. No arbitrary remote images or uploads. + +## 10. Failure, recovery, and cleanup + +| Failure/partial state | User impact | Retained facts | Automatic retry | Required action | Cleanup | +|---|---|---|---|---|---| +| Stale revision or permission changed | No new effect | Draft and current server facts | No mutation retry | Refresh and make a new human decision | Expire draft under existing policy | +| HA source unavailable/malformed | Metadata cannot be verified | Last valid application state, clearly dated if shown | Bounded query policy only | Retry read when available | No fabricated directory record | +| Response lost after send | Outcome uncertain | Original journal record and request identity | No replacement/resubmission | Recover same original action | Expire within one hour, retain server evidence | +| Migration or late write fails | Upgrade not committed | Original schema8 DB and associated evidence | No partial continuation | Operator follows migration recovery | Atomic rollback of data/defaults/version/results | +| Historical receipt after restore | Effect may be absent now | Original processed-operation evidence | No replay of old effect | Inspect current state and decide anew | Preserve generation isolation | + +Errors follow impact -> cause -> action -> retained state. Cleanup may remove only expired owned browser records and safe terminal build/test outputs; preserve genuine source inputs, failure evidence, original records and rollback state. + +## 11. Security, permissions, and privacy + +1. Every query and mutation checks current context and relevant current authority. Own-scoped preference checks precede administrator/profile grants, including original receipts and any audit/event projection. +2. A current administrator may manage only owner-approved household settings, membership and permission profiles; they cannot read or edit another member's private locale or routine-summary preference. +3. HA lookups are bounded and source-verified. Revocation, protected-role constraints, mapping proofs, permission provenance and last-effective-administrator recovery remain authoritative. +4. Browser records, aliases, labels and request keys are untrusted. The closed journal cannot replace a key, change owner/context or widen the entry contract. +5. Do not store credentials, CSRF, cookies, tokens, private preference contents in shared audit/outbox/logs, or raw provider state. Sanitize diagnostics and use only necessary correlation identifiers. + +## 12. Observability and operational UX + +User-facing results distinguish saved, stale, denied, unavailable, uncertain and historical-absent states. Operator evidence records sanitized outcome, correlation/operation identifier and aggregate revision, never credentials or private preference contents. Migration reports name source schema, destination, validation and rollback result. Baseline coverage or green tooling is not evidence of full product delivery. No rate-limit or retry policy may turn an uncertain mutation into a new request; read retries remain bounded by owning adapter contracts. + +## 13. Compatibility, migration, rollout, and rollback + +Schema8 is merged at the selected base; this issue owns schema9 next. Preserve supported schema1..8 upgrade inputs, original fixtures, identities/history, HA links, roles/status/mappings/setup provenance, tasks/materials/recurrence, processed operations, restore evidence and generation fences. Add nullable legacy presentation and explicit owner-private defaults without inventing HA names. Validate new state on normal use and independent checkpoint inspection. + +Use the genuine schema8 evidence identified in section 2.3, retaining original header pragmas and matching installation identity. Do not relabel a newly built fixture as old, change only a version number, or rebuild a supposed old fixture under schema9. Both actual image architectures and persistent runtimes require real upgrade, refusal, restore and compatible rollback evidence. Compatible full application/database/generation rollback remains mandatory. Edge.5/schema7 is a separate immutable control. No HA update, release, tag or deployment follows from this publication. Issue #23 must use the next actually merged schema after9 for later DDL. + +## 14. Testing strategy and numeric budget + +All 72 original `household-admin-C01` through `household-admin-C72` cases, all owning identity cases `household-identity-C01` through `household-identity-C30`, and applicable delivery-owner cases remain required. They are retained-owner inventory, not rows in this companion's new-case budget. The following eight cases are additional integration refinements; overlap does not make them additional independent product journeys beyond their stated checks. No cases below are claimed executed. + +| Area | Minimum distinct cases | Behaviors/risks covered | +|---|---:|---| +| Domain/configuration/pure planning | 1 | Observable scenarios in the case inventory. | +| Adapters/provider contracts | 1 | Observable scenarios in the case inventory. | +| State/application/idempotency/races | 1 | Observable scenarios in the case inventory. | +| Application use cases | 1 | Observable scenarios in the case inventory. | +| Workflows/permissions/schemas/setup | 1 | Observable scenarios in the case inventory. | +| Security/abuse | 1 | Observable scenarios in the case inventory. | +| Presentation/localization/accessibility/sanitization | 1 | Observable scenarios in the case inventory. | +| Integration/end-to-end/compatibility/migration | 1 | Observable scenarios in the case inventory. | +| Total | 8 | Distinct inventory cases; repeated executions do not add cases. | + +| Case ID | Area | Requirements | Scenario | +|---|---|---|---| +| household-admin-integration-C01 | Domain/configuration/pure planning | HADI-01 | Two canonical HA members with the same label and an explicit alias remain distinct; defaults and literal bounded alias validation are pure and deterministic. | +| household-admin-integration-C02 | Adapters/provider contracts | HADI-02 | A bounded verified directory lookup distinguishes source failure from confirmed omission and never fabricates name, avatar or selector facts. | +| household-admin-integration-C03 | State/application/idempotency/races | HADI-03 | Independent writers or permission loss during external preparation produce one authorized revision/original result and no partial alias/access effect. | +| household-admin-integration-C04 | Application use cases | HADI-04 | Owner preference change and protected original recovery remain denied to another participant or administrator, with no private shared audit/event leakage. | +| household-admin-integration-C05 | Workflows/permissions/schemas/setup | HADI-05 | Genuine schema8 state migrates atomically to9; a failing addition preserves source, defaults, version, recurrence and original-operation evidence. | +| household-admin-integration-C06 | Security/abuse | HADI-06 | Corrupt, stale, cross-context or wrong-namespace administration journal input cannot replace a key, disclose another owner or widen entry recovery. | +| household-admin-integration-C07 | Presentation/localization/accessibility/sanitization | HADI-07 | Real Python/SQLite English and Spanish browser journeys render permitted roster/forms and private preferences with keyboard, responsive and inert-text states. | +| household-admin-integration-C08 | Integration/end-to-end/compatibility/migration | HADI-08 | Both actual current-source images and owned runtimes preserve schema9 upgrade/refusal/restoration and exact original results with full supported coverage. | + +The budget is a floor for distinct executable checks; each inventory row counts once. Existing repository requirement remains 100% supported statement and branch coverage globally and per module. New pure policy also requires complete branch coverage. Use deterministic clocks, IDs and fakes; no real waits or live services in unit/contract tests. Add semantic assertions alongside UI fixtures, structural workflow/schema checks rather than prose grep, migration fixtures from genuine source evidence, race/replay/authorization tests, and manual UX review for readability, focus and comprehensible error content. Every PR CI Check runs all applicable suites and both image architectures. Acceptance remains unproven until results are produced. + +## 15. Documentation and discoverability + +| Path | Change | +|---|---| +| `specs/household-administration-integration.md` | create | +| `specs/household-administration.md` | reference | +| `specs/household-identity.md` | reference | +| `specs/delivery-quality.md` | reference | +| `specs/catalog.json` | update | +| `specs/CATALOG.md` | update | +| `backend/src/domus/domain/member_profiles.py` | reference | +| `backend/src/domus/application/ports.py` | update | +| `backend/src/domus/application/identity_ports.py` | update | +| `backend/src/domus/application/member_profile_ports.py` | create | +| `backend/src/domus/application/member_profile_commands.py` | create | +| `backend/src/domus/application/household_administration_queries.py` | create | +| `backend/src/domus/infrastructure/sqlite.py` | update | +| `backend/src/domus/infrastructure/sqlite_identity.py` | update | +| `backend/src/domus/infrastructure/sqlite_member_profiles.py` | create | +| `backend/src/domus/infrastructure/sqlite_member_profile_schema.py` | create | +| `backend/src/domus/infrastructure/sqlite_snapshots.py` | update | +| `backend/src/domus/infrastructure/ha_member_profiles.py` | create | +| `backend/src/domus/entrypoints/administration_http.py` | create | +| `backend/src/domus/entrypoints/administration_dto.py` | create | +| `backend/src/domus/composition/household_administration.py` | create | +| `frontend/src/domain/administration.ts` | create | +| `frontend/src/application/administration-controller.ts` | create | +| `frontend/src/adapters/administration-http.ts` | create | +| `frontend/src/adapters/administration-journal.ts` | create | +| `frontend/src/presentation/domus-administration.ts` | create | +| `frontend/src/presentation/messages.ts` | update | +| `tests/backend/test_sqlite_member_profiles.py` | create | +| `tests/backend/test_member_profile_commands.py` | create | +| `tests/backend/test_household_administration_queries.py` | create | +| `tests/backend/test_ha_member_profiles.py` | create | +| `tests/backend/test_administration_http.py` | create | +| `tests/fixtures/sqlite/v8.sql` | create | +| `tests/fixtures/schema8-app/Dockerfile` | create | +| `tests/containers/test_administration_image.py` | create | +| `.github/workflows/ci_check.yml` | update | +| `docs/household-administration.md` | create | +| `docs/household-administration-operations.md` | create | +| `docs/household-administration-migration.md` | create | +| `docs/architecture-household-administration.md` | update | +| `docs/evidence/household-administration.json` | update | + +| `tests/backend/test_sqlite_identity.py` | update | +| `tests/backend/test_sqlite_recovery.py` | update | +| `tests/backend/test_sqlite_history.py` | update | +| `tests/backend/test_sqlite_task_integrity.py` | update | +| `tests/backend/test_sqlite_recurrence.py` | update | +| `tests/backend/test_sqlite_recurrence_integrity.py` | update | +| `tests/backend/test_sqlite_relative_recurrence.py` | update | +| `tests/backend/test_sqlite_task_reviews.py` | update | +| `tests/backend/test_sqlite_task_materials.py` | update | +| `tests/backend/test_sqlite_snapshots.py` | update | +| `tests/backend/test_restore_source.py` | update | +| `tests/backend/test_task_restoration.py` | update | +| `tests/backend/test_recurrence_frontier.py` | update | +| `tests/backend/test_activation_compatibility.py` | update | +| `tests/containers/restore_fixture.py` | update | +| `tests/containers/test_app_compatibility.py` | update | +| `tests/containers/test_app_image.py` | update | +| `tests/containers/test_recurrence_image.py` | update | + +| `tests/backend/test_sqlite_tasks.py` | update | +| `tests/backend/test_recurrence_until_proof.py` | update | + +These are proposed implementation paths, not evidence that files exist or framework execution is supported. Existing catalog paths are evidence of current source locations only. Declare any additional actual paths before implementation. User documentation covers normal journey/defaults; setup documentation covers permissions and deployment context; operator documentation covers states, recovery and cleanup; migration documentation covers schema inputs, verification and rollback; contributor documentation covers ports, trust boundaries and architecture checks. Validate links and examples against implementation fixtures. Preserve repository layout and language conventions. + +## 16. Acceptance scenarios + +1. Given two distinct verified HA users with the same displayed name, when the roster is rendered, then canonical identities remain distinct and any alias is inert presentation. +2. Given a verified participant, when they save their own locale and routine-summary preference, then the values persist privately and no notification is claimed or sent. +3. Given a current administrator and another member's private preferences, when the administrator queries roster, detail, receipt or audit-visible surfaces, then those preferences are neither returned nor inferable. +4. Given an administrator whose authority changes after external preparation, when the write transaction rechecks grants, then it creates no unauthorized or partial effect and retains the original outcome contract. +5. Given an unavailable or malformed HA source, when the workspace renders directory/member metadata, then it shows unavailable or denied status rather than fabricated empty facts. +6. Given a lost response, when the participant recovers the operation, then only the same original key/payload/context is used; no new automatic mutation is issued. +7. Given a genuine schema8 database and matching installation identity, when upgraded, then schema9 preserves identities, recurrence, originals, restore evidence and generation isolation; injected migration failure leaves the schema8 source intact. +8. Given Ingress and standalone deployments at narrow viewport and keyboard-only use, when English and Spanish views are traversed, then focus, labels, status, responsive content and escaped inert text remain understandable. +9. Given both actual image architectures and owned runtimes, when compatible upgrade/refusal/restore and rollback are exercised, then evidence records the real source/runtime and does not claim issue #23 accepted. +10. Given the completed implementation, when repository validation runs, then all retained owner cases and these new cases are mapped, supported coverage remains 100% globally/per module, CI runs all applicable suites and architectures, and user/setup/operator/contributor/migration documentation links validate. + +## 17. Requirements traceability + +| Requirement | Cases | +|---|---| +| HADI-01 | household-admin-integration-C01 | +| HADI-02 | household-admin-integration-C02 | +| HADI-03 | household-admin-integration-C03 | +| HADI-04 | household-admin-integration-C04 | +| HADI-05 | household-admin-integration-C05 | +| HADI-06 | household-admin-integration-C06 | +| HADI-07 | household-admin-integration-C07 | +| HADI-08 | household-admin-integration-C08 | + +The derived traceability table must map every HADI requirement to its exact integration case and every inventory case back to its requirement. It is a publication consistency check, not runtime/security acceptance. + +## 18. Implementation sequence + +1. Confirm semantic contracts, trusted context, configuration bounds and architecture checks against the retained owners. +2. Implement pure presentation/preferences policies and branch-complete tests. +3. Implement application queries/commands, authorization ordering, revision/idempotency and race tests. +4. Implement HA and SQLite adapters, schema9, journal separation, migration and rollback tests. +5. Implement Ingress and standalone presentation, accessible fixtures and English/Spanish content. +6. Complete operator/user/setup/contributor/migration documentation alongside behavior; update catalog and evidence only from actual results. +7. Run all applicable integration, security, coverage, browser, workflow and both-architecture checks; obtain human UX review. Do not mark planned cases executed without evidence. + +## 19. Definition of Done + +- [ ] All HADI requirements and all retained HADM, identity and delivery contracts have acceptance and traceability; the 72 owner cases remain unchanged. +- [ ] Architecture boundaries and executable dependency, DTO, journal and schema constraints pass. +- [ ] Numeric integration budget is met in addition to all retained owner inventories; supported coverage is 100% globally and per module. +- [ ] Defaults, bounds, validation, privacy, schema migration and journal separation match implementation. +- [ ] Ingress and standalone UI states, accessibility, localization, responsive behavior and sanitization pass and receive human UX review. +- [ ] User, setup, operator, contributor and migration documentation is complete, discoverable and validated. +- [ ] Failure, partial success, retry, idempotency, concurrency, security, restoration and cleanup behavior pass. +- [ ] Every PR CI Check runs applicable suites and both image architectures; evidence names actual source/runtime and limits. +- [ ] `specs/catalog.json` and `specs/CATALOG.md` are updated consistently and the original pinned SDD format/consistency validation passes. +- [ ] No readiness-blocking decision remains unresolved; Draft status remains until actual implementation evidence and review support readiness. + +## 20. References and decisions + +- Primary sources: selected immutable development source `4b71969d3b3f6df5f28d617ab0da4e86c9be5884`; [source tree](https://github.com/vypdev/domus/tree/4b71969d3b3f6df5f28d617ab0da4e86c9be5884); [archive](https://github.com/vypdev/domus/archive/4b71969d3b3f6df5f28d617ab0da4e86c9be5884.tar.gz). +- Related specs/ADRs/issues/PRs: `specs/household-administration.md`; `specs/household-identity.md`; `specs/delivery-quality.md`; [issue #21](https://github.com/vypdev/domus/issues/21); [PR #22](https://github.com/vypdev/domus/pull/22); [issue #23](https://github.com/vypdev/domus/issues/23). +- Decisions: use a new companion at `specs/household-administration-integration.md`; preserve existing owner documents and their cases verbatim; schema8 is satisfied and schema9 belongs to this issue; journal is separate and closed; no product decision remains open. +- Follow-up outside this spec: unfinished recurrence/calendar work in issue #23; any release, deployment or HA owner update requires its own authorized work and evidence. \ No newline at end of file diff --git a/tests/backend/test_activation_compatibility.py b/tests/backend/test_activation_compatibility.py index f8d9e40..931acd3 100644 --- a/tests/backend/test_activation_compatibility.py +++ b/tests/backend/test_activation_compatibility.py @@ -79,7 +79,7 @@ def test_real_activation_upgrades_only_target_from_frozen_source_and_checkpoint( selected = open_storage(root, "edge") try: with selected.database._connection() as connection: - assert connection.execute("PRAGMA user_version").fetchone()[0] == 8 + assert connection.execute("PRAGMA user_version").fetchone()[0] == 9 assert connection.execute("SELECT COUNT(*) FROM retained_operations").fetchone()[0] == ( 2 if version == 4 else 1 ) diff --git a/tests/backend/test_recurrence_frontier.py b/tests/backend/test_recurrence_frontier.py index dd7b59f..0aba43e 100644 --- a/tests/backend/test_recurrence_frontier.py +++ b/tests/backend/test_recurrence_frontier.py @@ -61,7 +61,7 @@ def test_frontier_commits_reopens_and_never_creates_task_or_business_evidence(tm SQLiteSnapshots(store, tmp_path / "checkpoint", store.installation, Clock()) .create("frontier") .schema_version - == 8 + == 9 ) @@ -172,7 +172,7 @@ def test_genuine_prior_unreleased_schema8_layout_is_refused_without_mutating_its before = path.read_bytes() store = SQLiteDatabase(path, IDENTITY) for method in (store.read, store.write): - with pytest.raises(DatabaseRejected, match="unrelated_database"): + with pytest.raises(DatabaseRejected, match="unsupported_schema"): with method(): pytest.fail("An unknown prior development layout was accepted") assert path.read_bytes() == before diff --git a/tests/backend/test_recurrence_until_proof.py b/tests/backend/test_recurrence_until_proof.py index fba2fa5..8a36286 100644 --- a/tests/backend/test_recurrence_until_proof.py +++ b/tests/backend/test_recurrence_until_proof.py @@ -140,7 +140,7 @@ def test_exact_prior_unreleased_layout_is_refused_unchanged_before_temporal_evid before = path.read_bytes() store = SQLiteDatabase(path, identity) for method in (store.read, store.write): - with pytest.raises(DatabaseRejected, match="unrelated_database"): + with pytest.raises(DatabaseRejected, match="unsupported_schema"): with method(): pytest.fail("Unknown unreleased layout was admitted") assert path.read_bytes() == before diff --git a/tests/backend/test_restore_source.py b/tests/backend/test_restore_source.py index 7e8852b..638cd59 100644 --- a/tests/backend/test_restore_source.py +++ b/tests/backend/test_restore_source.py @@ -19,7 +19,7 @@ def test_bare_source_and_managed_generation_source_are_explicit_and_roundtrip(tm bare_root.mkdir(mode=0o700) adapter, store, _, _ = restoration_fixture(bare_root) result = adapter.prepare("restore-bare") - assert result.source == RestoreSource(store.installation, 8, None, None) + assert result.source == RestoreSource(store.installation, 9, None, None) assert result.document().decode()["format_version"] == 2 assert PreparedRestoration.from_value(result.document().decode()) == result root, slot, selection = generation_fixture(tmp_path) @@ -39,7 +39,7 @@ def test_bare_source_and_managed_generation_source_are_explicit_and_roundtrip(tm ).prepare("restore-generation") assert prepared.source.generation_id == selection.generation_id assert prepared.source.identity_sha256 == selection.document().fingerprint - assert prepared.source.schema_version == 8 + assert prepared.source.schema_version == 9 assert PreparedRestoration.from_value(prepared.document().decode()) == prepared finally: storage.close() diff --git a/tests/backend/test_sqlite_history.py b/tests/backend/test_sqlite_history.py index b1fd3a0..94263b2 100644 --- a/tests/backend/test_sqlite_history.py +++ b/tests/backend/test_sqlite_history.py @@ -81,7 +81,7 @@ def test_frozen_old_schema_migrates_without_losing_existing_evidence(tmp_path, v store = SQLiteDatabase(path, Installation("047cc6fb-dfb0-4fa4-b3c3-e0fe54d116b6", "edge")) store.initialize() with store._connection() as connection: - assert connection.execute("PRAGMA user_version").fetchone()[0] == SCHEMA_VERSION == 8 + assert connection.execute("PRAGMA user_version").fetchone()[0] == SCHEMA_VERSION == 9 assert connection.execute("SELECT count(*) FROM retained_operations").fetchone()[0] == len( before.get("retained_operations", []) ) diff --git a/tests/backend/test_sqlite_identity.py b/tests/backend/test_sqlite_identity.py index 6a1d87b..6ab5076 100644 --- a/tests/backend/test_sqlite_identity.py +++ b/tests/backend/test_sqlite_identity.py @@ -177,7 +177,7 @@ def test_v1_upgrade_preserves_household_receipts_audit_and_pending_outbox(tmp_pa unit.members.save(member(), 0) unit.policies.save(policy(), 0) with closing(sqlite3.connect(path)) as connection: - assert connection.execute("PRAGMA user_version").fetchone()[0] == 8 + assert connection.execute("PRAGMA user_version").fetchone()[0] == 9 assert connection.execute("PRAGMA foreign_key_check").fetchall() == [] diff --git a/tests/backend/test_sqlite_member_profiles.py b/tests/backend/test_sqlite_member_profiles.py new file mode 100644 index 0000000..bb94422 --- /dev/null +++ b/tests/backend/test_sqlite_member_profiles.py @@ -0,0 +1,439 @@ +"""Real member profile records, genuine historical migrations and independent inspection.""" + +import hashlib +import json +import sqlite3 +from concurrent.futures import ThreadPoolExecutor +from contextlib import closing +from dataclasses import replace +from pathlib import Path +from threading import Barrier + +import pytest +from test_sqlite_identity import member, store_at +from test_sqlite_snapshots import Clock + +from domus.domain.errors import Conflict, DatabaseRejected, InvalidInput, StorageFailure +from domus.domain.identity import MemberStatus +from domus.domain.member_profiles import MemberLocale, MemberPreferences, MemberPresentation +from domus.domain.storage import Installation +from domus.infrastructure.sqlite import SQLiteDatabase +from domus.infrastructure.sqlite_snapshots import SQLiteSnapshots + +FIXTURES = Path(__file__).parents[1] / "fixtures/sqlite" + + +def prepared(tmp_path): + store = store_at(tmp_path / "database") + with store.write() as unit: + unit.members.save(member(), 0) + return store + + +def frozen(tmp_path, version=8): + path = tmp_path / "database" + with closing(sqlite3.connect(path, autocommit=True)) as connection: + connection.executescript((FIXTURES / f"v{version}.sql").read_text()) + identity = connection.execute("SELECT instance_id,channel FROM installation").fetchone() + return SQLiteDatabase(path, Installation(*identity)) + + +def facts(path, tables): + with closing(sqlite3.connect(path)) as connection: + return { + table: connection.execute(f'SELECT * FROM "{table}" ORDER BY rowid').fetchall() + for table in tables + } + + +def test_created_member_has_persisted_independent_public_and_private_defaults(tmp_path): + store = prepared(tmp_path) + restarted = SQLiteDatabase(store.path, store.installation) + restarted.initialize(require_existing=True) + with restarted.read() as unit: + assert unit.presentations.get("home", "member_1") == MemberPresentation("member_1", "home") + assert unit.preferences.get("home", "member_1") == MemberPreferences("member_1", "home") + for repository in (unit.presentations, unit.preferences): + assert repository.get("other", "member_1") is None + assert repository.get("home", "missing") is None + with closing(sqlite3.connect(store.path)) as connection: + public = connection.execute("SELECT * FROM member_presentations").fetchone() + private = connection.execute("SELECT * FROM member_preferences").fetchone() + assert public == ("member_1", "home", None, 1) + assert private == ("member_1", "home", None, 1, 1) + + +@pytest.mark.parametrize("alias", [None, "🧹" * 80, "e\u0301", ""]) +def test_alias_round_trip_retains_literal_text_and_cannot_relink_identity(tmp_path, alias): + store = prepared(tmp_path) + value = MemberPresentation("member_1", "home", alias, 2) + with store.write() as unit: + unit.presentations.save(value, 1) + with SQLiteDatabase(store.path, store.installation).read() as unit: + assert unit.presentations.get("home", "member_1") == value + assert unit.members.get("home", "member_1") == member() + assert unit.preferences.get("home", "member_1") == MemberPreferences("member_1", "home") + + +@pytest.mark.parametrize("locale", [None, MemberLocale.ENGLISH, MemberLocale.SPANISH]) +@pytest.mark.parametrize("summaries", [False, True]) +def test_owner_private_preferences_round_trip_without_creating_shared_evidence( + tmp_path, locale, summaries +): + store = prepared(tmp_path) + value = MemberPreferences("member_1", "home", locale, summaries, 2) + with store.write() as unit: + unit.preferences.save(value, 1) + with SQLiteDatabase(store.path, store.installation).read() as unit: + assert unit.preferences.get("home", "member_1") == value + assert unit.presentations.get("home", "member_1") == MemberPresentation("member_1", "home") + assert unit.members.get("home", "member_1") == member() + assert unit.audit.entries("home") == unit.outbox.pending() == () + + +@pytest.mark.parametrize("kind", ["presentations", "preferences"]) +def test_profile_compare_and_swap_is_independent_scoped_and_has_one_winner(tmp_path, kind): + store = prepared(tmp_path) + other = SQLiteDatabase(store.path, store.installation) + with store.read() as unit: + original = getattr(unit, kind).get("home", "member_1") + changed = replace(original, revision=2) + with store.write() as unit: + getattr(unit, kind).save(changed, 1) + for value, expected in [ + (original, 1), + (changed, 1), + (replace(changed, revision=4), 2), + (replace(changed, household_id="other", revision=3), 2), + (replace(changed, member_id="missing", revision=3), 2), + ]: + with pytest.raises(Conflict, match="revision_conflict"): + with other.write() as unit: + getattr(unit, kind).save(value, expected) + with other.write() as unit: + unit.members.save(replace(member(), status=MemberStatus.ARCHIVED, revision=2), 1) + with other.read() as unit: + assert getattr(unit, kind).get("home", "member_1") == changed + + +@pytest.mark.parametrize("kind", ["presentations", "preferences"]) +@pytest.mark.parametrize("revision", [True, 0, -1, 1.0, None, 2**63]) +def test_profile_repositories_reject_invalid_expected_revision_before_sql(tmp_path, kind, revision): + store = prepared(tmp_path) + with pytest.raises(InvalidInput, match="invalid_revision"): + with store.write() as unit: + value = getattr(unit, kind).get("home", "member_1") + getattr(unit, kind).save(replace(value, revision=2), revision) + + +def test_profile_and_member_creation_roll_back_after_late_command_failure(tmp_path): + store = store_at(tmp_path / "database") + with closing(sqlite3.connect(store.path, autocommit=True)) as connection: + connection.execute( + "CREATE TRIGGER fail_private BEFORE INSERT ON member_preferences " + "BEGIN SELECT RAISE(ABORT,'injected private insert failure'); END" + ) + # Normal inspection rejects an unrelated trigger before entering a writer. + with pytest.raises(DatabaseRejected): + with store.write(): + pytest.fail("An extra trigger must not authorize a current transaction") + with closing(sqlite3.connect(store.path, autocommit=True)) as connection: + connection.execute("DROP TRIGGER fail_private") + with pytest.raises(RuntimeError, match="injected"): + with store.write() as unit: + unit.members.save(member(), 0) + unit.presentations.save(MemberPresentation("member_1", "home", "Alias", 2), 1) + unit.preferences.save( + MemberPreferences("member_1", "home", MemberLocale.SPANISH, False, 2), 1 + ) + raise RuntimeError("injected after all profile writes") + with store.read() as unit: + assert unit.members.list("home") == () + assert unit.presentations.get("home", "member_1") is None + assert unit.preferences.get("home", "member_1") is None + + +def test_actual_private_insert_failure_rolls_back_member_and_public_insert_together(tmp_path): + store = store_at(tmp_path / "database") + with pytest.raises(StorageFailure, match="storage_failure"): + with store.write() as unit: + unit.members.connection.execute( + "CREATE TRIGGER fail_private BEFORE INSERT ON member_preferences " + "BEGIN SELECT RAISE(ABORT,'private fault must not escape'); END" + ) + unit.members.save(member(), 0) + with store.read() as unit: + assert unit.members.list("home") == () + assert unit.presentations.get("home", "member_1") is None + assert unit.preferences.get("home", "member_1") is None + + +@pytest.mark.parametrize( + "table,column,value", + [ + ("member_presentations", "alias", " "), + ("member_presentations", "alias", " untrimmed"), + ("member_presentations", "alias", "x\n"), + ("member_presentations", "alias", "x" * 81), + ("member_presentations", "alias", b"not text"), + ("member_presentations", "revision", 0), + ("member_presentations", "revision", 1.5), + ("member_preferences", "locale", "fr"), + ("member_preferences", "locale", b"en"), + ("member_preferences", "routine_participation_summaries", 2), + ("member_preferences", "routine_participation_summaries", -1), + ("member_preferences", "routine_participation_summaries", "true"), + ("member_preferences", "routine_participation_summaries", 0.5), + ("member_preferences", "revision", 0), + ("member_preferences", "revision", "not a revision"), + ], +) +def test_corrupt_profile_is_rejected_on_use_and_independent_checkpoint_inspection( + tmp_path, table, column, value +): + store = prepared(tmp_path) + with closing(sqlite3.connect(store.path, autocommit=True)) as connection: + connection.execute("PRAGMA ignore_check_constraints=ON") + connection.execute(f"UPDATE {table} SET {column}=?", (value,)) + kind = "presentations" if table == "member_presentations" else "preferences" + with pytest.raises(StorageFailure, match="invalid_member_profile_record"): + with store.read() as unit: + getattr(unit, kind).get("home", "member_1") + with pytest.raises(DatabaseRejected, match="invalid_snapshot_records"): + SQLiteSnapshots(store, tmp_path / "snapshot", store.installation, Clock()).create("invalid") + assert not (tmp_path / "snapshot").exists() + + +@pytest.mark.parametrize( + "kind,table", [("presentations", "member_presentations"), ("preferences", "member_preferences")] +) +def test_missing_profile_cannot_be_silently_recreated_as_a_default(tmp_path, kind, table): + store = prepared(tmp_path) + with closing(sqlite3.connect(store.path, autocommit=True)) as connection: + connection.execute(f"DELETE FROM {table}") + with pytest.raises(StorageFailure, match="invalid_member_profile_record"): + with store.read() as unit: + getattr(unit, kind).get("home", "member_1") + with pytest.raises(DatabaseRejected, match="invalid_snapshot_records"): + SQLiteSnapshots(store, tmp_path / "snapshot", store.installation, Clock()).create("invalid") + + +@pytest.mark.parametrize("version", range(1, 9)) +def test_real_historical_input_upgrade_preserves_every_original_table_and_adds_defaults( + tmp_path, version +): + store = frozen(tmp_path, version) + with store._connection() as connection: + tables = [ + row[0] + for row in connection.execute("SELECT name FROM sqlite_master WHERE type='table'") + ] + original = facts(store.path, tables) + before = store.path.read_bytes() + for transaction in (store.read, store.write): + with pytest.raises(DatabaseRejected, match="unsupported_schema"): + with transaction(): + pytest.fail("Old schema must be migrated explicitly") + assert store.path.read_bytes() == before + store.initialize(require_existing=True) + store.initialize(require_existing=True) + actual = facts(store.path, tables) + if version == 5: + # The already-supported schema6 transition adds empty material snapshots. + # All other fields and all original operation/history bytes must be unchanged. + for table in ("task_definitions", "task_occurrences"): + old_rows, new_rows = original.pop(table), actual.pop(table) + assert len(old_rows) == len(new_rows) + for old, new in zip(old_rows, new_rows, strict=True): + assert old[:-1] == new[:-1] + expected = json.loads(old[-1]) + assert expected["version"] == 1 + expected["version"] = 2 + if table == "task_definitions": + expected["materials"] = [] + else: + assert expected["definition_snapshot"]["version"] == 1 + expected["definition_snapshot"].update(version=2, materials=[]) + assert json.loads(new[-1]) == expected + assert actual == original + with store.read() as unit: + for household in original["households"]: + for current in unit.members.list(household[0]): + assert unit.presentations.get( + current.household_id, current.member_id + ) == MemberPresentation(current.member_id, current.household_id) + assert unit.preferences.get( + current.household_id, current.member_id + ) == MemberPreferences(current.member_id, current.household_id) + with store._connection() as connection: + assert connection.execute("PRAGMA user_version").fetchone()[0] == 9 + assert connection.execute("PRAGMA foreign_key_check").fetchall() == [] + + +def test_schema8_fixture_is_the_exact_original_published_source_dump(): + assert hashlib.sha256((FIXTURES / "v8.sql").read_bytes()).hexdigest() == ( + "041615f0253c0537ac6c713e10a8984fb5043b1f6df29a6a2c7193824ebc6ed5" + ) + + +@pytest.mark.parametrize("version", range(1, 9)) +def test_late_profile_migration_failure_restores_exact_original_bytes( + tmp_path, monkeypatch, version +): + from domus.infrastructure import sqlite as adapter + + store = frozen(tmp_path, version) + before = store.path.read_bytes() + with monkeypatch.context() as patch: + patch.setattr(adapter, "PROFILE_STATEMENTS", (*adapter.PROFILE_STATEMENTS, "INVALID SQL")) + with pytest.raises(StorageFailure, match="storage_failure"): + store.initialize(require_existing=True) + assert store.path.read_bytes() == before + store.initialize(require_existing=True) + + +def test_schema9_version_on_unmigrated_schema8_does_not_authorize_profile_use(tmp_path): + store = frozen(tmp_path) + with store._connection() as connection: + connection.execute("PRAGMA user_version=9") + before = store.path.read_bytes() + for transaction in (store.read, store.write): + with pytest.raises(DatabaseRejected, match="unrelated_database"): + with transaction(): + pytest.fail("A changed version number is not a schema upgrade") + assert store.path.read_bytes() == before + + +def test_snapshot_independently_preserves_both_profiles_without_changing_member_history(tmp_path): + store = prepared(tmp_path) + public = MemberPresentation("member_1", "home", "Alias", 2) + private = MemberPreferences("member_1", "home", MemberLocale.SPANISH, False, 2) + with store.write() as unit: + unit.presentations.save(public, 1) + unit.preferences.save(private, 1) + archive = tmp_path / "snapshot" + snapshots = SQLiteSnapshots(store, archive, store.installation, Clock()) + checkpoint = snapshots.create("original") + assert checkpoint.schema_version == 9 and snapshots.inspect() == checkpoint + with SQLiteDatabase(archive / "domus.sqlite3", store.installation).read() as unit: + assert unit.presentations.get("home", "member_1") == public + assert unit.preferences.get("home", "member_1") == private + assert unit.members.get("home", "member_1") == member() + + +@pytest.mark.parametrize("kind", ["presentations", "preferences"]) +def test_concurrent_independent_writers_cannot_overwrite_the_same_profile_revision(tmp_path, kind): + store = prepared(tmp_path) + gate = Barrier(2, timeout=5) + + def attempt(number): + writer = SQLiteDatabase(store.path, store.installation) + with writer.read() as unit: + before = getattr(unit, kind).get("home", "member_1") + change = ( + replace(before, alias=f"Choice {number}", revision=2) + if kind == "presentations" + else replace(before, routine_participation_summaries=bool(number), revision=2) + ) + gate.wait() + try: + with writer.write() as unit: + getattr(unit, kind).save(change, before.revision) + return change + except Conflict as error: + assert error.code == "revision_conflict" + return None + + with ThreadPoolExecutor(max_workers=2) as pool: + results = list(pool.map(attempt, (0, 1))) + winners = [result for result in results if result is not None] + assert len(winners) == 1 + with store.read() as unit: + assert getattr(unit, kind).get("home", "member_1") == winners[0] + + +@pytest.mark.parametrize("status", list(MemberStatus)) +def test_all_membership_statuses_receive_explicit_defaults_without_changing_authority( + tmp_path, status +): + store = store_at(tmp_path / "database") + original = replace(member(), status=status) + with store.write() as unit: + unit.members.save(original, 0) + with store.read() as unit: + assert unit.members.get("home", "member_1") == original + assert unit.presentations.get("home", "member_1") == MemberPresentation("member_1", "home") + assert unit.preferences.get("home", "member_1") == MemberPreferences("member_1", "home") + + +@pytest.mark.parametrize("table", ["member_presentations", "member_preferences"]) +def test_profile_cannot_link_outside_the_canonical_member_household(tmp_path, table): + store = prepared(tmp_path) + with closing(sqlite3.connect(store.path, autocommit=True)) as connection: + connection.execute("PRAGMA foreign_keys=ON") + with pytest.raises(sqlite3.IntegrityError): + connection.execute(f"UPDATE {table} SET household_id='another-household'") + with store.read() as unit: + assert unit.presentations.get("home", "member_1") == MemberPresentation("member_1", "home") + assert unit.preferences.get("home", "member_1") == MemberPreferences("member_1", "home") + + +@pytest.mark.parametrize("table", ["member_presentations", "member_preferences"]) +def test_sql_constraints_cannot_hide_an_unowned_profile_with_null_primary_key(tmp_path, table): + store = prepared(tmp_path) + with closing(sqlite3.connect(store.path, autocommit=True)) as connection: + connection.execute("PRAGMA foreign_keys=ON") + placeholders = "?,?,?,?" if table == "member_presentations" else "?,?,?,?,?" + values = ( + (None, "home", None, 1) + if table == "member_presentations" + else (None, "home", None, 1, 1) + ) + with pytest.raises(sqlite3.IntegrityError): + connection.execute(f"INSERT INTO {table} VALUES ({placeholders})", values) + assert connection.execute(f"SELECT count(*) FROM {table}").fetchone()[0] == 1 + + +def test_actual_generation_restore_keeps_checkpoint_profiles_and_the_unmodified_source(tmp_path): + from test_household_commands import fixture as command_fixture + + from domus.application.household_commands import SetupHousehold + from domus.infrastructure.owned_storage import open_storage + from domus.infrastructure.sqlite_activation import SQLiteActivation + from domus.infrastructure.sqlite_restoration import SQLiteRestorations + + root = tmp_path / "owned" + root.mkdir(mode=0o700) + storage = open_storage(root, "edge") + commands, _, actor, _, _ = command_fixture(tmp_path / "unused") + commands = replace(commands, transactions=storage.database) + commands.setup("setup", SetupHousehold("Home", "UTC")) + with storage.database.read() as unit: + owner = unit.members.by_user("home", actor.current().ha_user_id) + public = MemberPresentation(owner.member_id, "home", "Checkpoint alias", 2) + private = MemberPreferences(owner.member_id, "home", MemberLocale.SPANISH, False, 2) + with storage.database.write() as unit: + unit.presentations.save(public, 1) + unit.preferences.save(private, 1) + archive = tmp_path / "snapshot" + snapshot = SQLiteSnapshots(storage.database, archive, storage.installation, Clock()).create( + "old" + ) + with storage.database.write() as unit: + unit.presentations.save(replace(public, alias="Later alias", revision=3), 2) + unit.preferences.save(replace(private, locale=MemberLocale.ENGLISH, revision=3), 2) + original = storage.database.path.read_bytes() + target, installation = tmp_path / "prepared", storage.installation + prepared_restore = SQLiteRestorations( + storage.database, archive, target, installation, Clock(), snapshot + ).prepare("restore-profiles") + storage.close() + result = SQLiteActivation(root, installation, Clock(), target).activate() + assert result.state == "active" and result.record.prepared == prepared_restore + with closing(open_storage(root, "edge")) as selected: + assert selected.generation_id == "restore-profiles" + with selected.database.read() as unit: + assert unit.presentations.get("home", owner.member_id) == public + assert unit.preferences.get("home", owner.member_id) == private + assert unit.members.get("home", owner.member_id) == owner + assert (root / "domus.sqlite3").read_bytes() == original diff --git a/tests/backend/test_sqlite_recovery.py b/tests/backend/test_sqlite_recovery.py index 1bfa113..8837c05 100644 --- a/tests/backend/test_sqlite_recovery.py +++ b/tests/backend/test_sqlite_recovery.py @@ -160,7 +160,7 @@ def test_compatible_migration_preserves_identity_history_and_adds_empty_recovery assert unit.outbox.pending()[0].event_id == "old-operation" assert unit.recoveries.pending("home", 3000) == () with closing(sqlite3.connect(path)) as connection: - assert connection.execute("PRAGMA user_version").fetchone()[0] == SCHEMA_VERSION == 8 + assert connection.execute("PRAGMA user_version").fetchone()[0] == SCHEMA_VERSION == 9 def test_failed_v2_upgrade_rolls_back_all_new_schema_and_replay_data( diff --git a/tests/backend/test_sqlite_recurrence.py b/tests/backend/test_sqlite_recurrence.py index b756eb5..c707c7d 100644 --- a/tests/backend/test_sqlite_recurrence.py +++ b/tests/backend/test_sqlite_recurrence.py @@ -233,7 +233,7 @@ def test_normal_transactions_refuse_schema7_until_explicit_atomic_upgrade(tmp_pa with store.read() as unit: assert unit.recurrence.schedules("home") == () with store._connection() as connection: - assert connection.execute("PRAGMA user_version").fetchone()[0] == 8 + assert connection.execute("PRAGMA user_version").fetchone()[0] == 9 assert tuple(connection.execute("SELECT * FROM receipts ORDER BY operation_id")) == original assert connection.execute("PRAGMA foreign_key_check").fetchall() == [] diff --git a/tests/backend/test_sqlite_recurrence_integrity.py b/tests/backend/test_sqlite_recurrence_integrity.py index 58a60a9..fbcf5a4 100644 --- a/tests/backend/test_sqlite_recurrence_integrity.py +++ b/tests/backend/test_sqlite_recurrence_integrity.py @@ -37,7 +37,7 @@ def test_checkpoint_verifies_and_reopens_real_rule_progress_slot_and_original_re snapshots = SQLiteSnapshots(store, tmp_path / "snapshot", store.installation, Clock()) before = store.path.read_bytes() checkpoint = snapshots.create("recurrence") - assert checkpoint.schema_version == 8 and snapshots.inspect() == checkpoint + assert checkpoint.schema_version == 9 and snapshots.inspect() == checkpoint assert store.path.read_bytes() == before reopened = SQLiteDatabase(tmp_path / "snapshot/domus.sqlite3", store.installation) with reopened.read() as unit: @@ -264,7 +264,7 @@ def test_pattern_edit_requires_matching_progress_and_preserves_existing_slot_sna SQLiteSnapshots(store, tmp_path / "edited", store.installation, Clock()) .create("edited") .schema_version - == 8 + == 9 ) diff --git a/tests/backend/test_sqlite_snapshots.py b/tests/backend/test_sqlite_snapshots.py index d158ee5..990a8fe 100644 --- a/tests/backend/test_sqlite_snapshots.py +++ b/tests/backend/test_sqlite_snapshots.py @@ -37,7 +37,7 @@ def test_snapshot_contains_verified_private_identity_checkpoint_and_all_atomic_r before = store.path.read_bytes() result = snapshots.create("snapshot-1") assert ( - result.schema_version == 8 + result.schema_version == 9 and result.captured_at == 2000 and result.last_committed_at == 100 ) diff --git a/tests/backend/test_sqlite_task_integrity.py b/tests/backend/test_sqlite_task_integrity.py index 0a1fdc9..73febd5 100644 --- a/tests/backend/test_sqlite_task_integrity.py +++ b/tests/backend/test_sqlite_task_integrity.py @@ -158,7 +158,7 @@ def test_actual_unit_snapshot_and_reopen_keep_task_cycles_and_one_atomic_receipt before = store.path.read_bytes() snapshots = SQLiteSnapshots(store, tmp_path / "snapshot", store.installation, Clock()) checkpoint = snapshots.create("tasks") - assert checkpoint.schema_version == 8 and snapshots.inspect() == checkpoint + assert checkpoint.schema_version == 9 and snapshots.inspect() == checkpoint assert store.path.read_bytes() == before copy = SQLiteDatabase(tmp_path / "snapshot/domus.sqlite3", store.installation) with copy.read() as unit: @@ -433,5 +433,5 @@ def test_schema4_upgrade_failure_preserves_the_exact_old_database_and_history( assert unit.tasks.policy("home") is None and unit.tasks.definitions("home") == () assert unit.receipts.get_operation("home", "human", "operation-a").historical with store._connection() as connection: - assert connection.execute("PRAGMA user_version").fetchone()[0] == 8 + assert connection.execute("PRAGMA user_version").fetchone()[0] == 9 assert connection.execute("SELECT count(*) FROM retained_operations").fetchone()[0] == 2 diff --git a/tests/backend/test_sqlite_task_materials.py b/tests/backend/test_sqlite_task_materials.py index 6ab10a3..9b5acf5 100644 --- a/tests/backend/test_sqlite_task_materials.py +++ b/tests/backend/test_sqlite_task_materials.py @@ -58,7 +58,7 @@ def test_original_schema5_migration_preserves_history_and_legacy_request_fingerp store = SQLiteDatabase(path, Installation("047cc6fb-dfb0-4fa4-b3c3-e0fe54d116b6", "edge")) store.initialize(require_existing=True) with store._connection() as connection: - assert connection.execute("PRAGMA user_version").fetchone()[0] == SCHEMA_VERSION == 8 + assert connection.execute("PRAGMA user_version").fetchone()[0] == SCHEMA_VERSION == 9 for table, rows in before.items(): assert [tuple(row) for row in connection.execute(f"SELECT * FROM {table}")] == rows with store.read() as unit: @@ -134,7 +134,7 @@ def test_real_file_material_cas_preserves_source_pending_and_confirmed_totals(tm checkpoint = SQLiteSnapshots(store, tmp_path / "snapshot", store.installation, Clock()).create( "materials" ) - assert checkpoint.schema_version == 8 + assert checkpoint.schema_version == 9 copy = SQLiteDatabase(tmp_path / "snapshot/domus.sqlite3", store.installation) with copy.read() as unit: assert unit.task_materials.get("home", "intent") == final @@ -441,7 +441,7 @@ def test_schema5_real_offline_activation_upgrades_only_target_and_keeps_original SQLiteSnapshots(selected.database, tmp_path / "new-snapshot", installation, Clock()) .create("new") .schema_version - == 8 + == 9 ) finally: selected.close() diff --git a/tests/backend/test_sqlite_task_reviews.py b/tests/backend/test_sqlite_task_reviews.py index 1baf853..5c97571 100644 --- a/tests/backend/test_sqlite_task_reviews.py +++ b/tests/backend/test_sqlite_task_reviews.py @@ -53,10 +53,13 @@ def test_original_schema6_migration_preserves_rows_and_projects_existing_cause(t "recurrence_progress_evidence", "recurrence_relative_slots", } - assert set(upgraded) == set(original) | added + assert set(upgraded) == set(original) | added | { + "member_presentations", + "member_preferences", + } assert all(upgraded[table] == values for table, values in original.items()) assert all(upgraded[table] == [] for table in added) - assert connection.execute("PRAGMA user_version").fetchone()[0] == 8 + assert connection.execute("PRAGMA user_version").fetchone()[0] == 9 service = queries(store, HumanIdentity("ha_admin", True, True, False)) detail = service.occurrence("identity-15") assert detail.dependency_review_required and len(detail.dependency_reviews.items) == 1 @@ -326,7 +329,7 @@ def test_original_schema6_checkpoint_activation_migrates_only_the_prepared_targe SQLiteSnapshots(selected.database, tmp_path / "new-checkpoint", INSTALLATION, Clock()) .create("new") .schema_version - == 8 + == 9 ) finally: selected.close() diff --git a/tests/backend/test_sqlite_tasks.py b/tests/backend/test_sqlite_tasks.py index 319aa52..311713a 100644 --- a/tests/backend/test_sqlite_tasks.py +++ b/tests/backend/test_sqlite_tasks.py @@ -22,6 +22,7 @@ from domus.domain.tasks import HouseholdTaskPolicy from domus.infrastructure.sqlite import BASE_STATEMENTS, IDENTITY_STATEMENTS from domus.infrastructure.sqlite_identity import SQLiteMembers +from domus.infrastructure.sqlite_member_profile_schema import PROFILE_STATEMENTS from domus.infrastructure.sqlite_task_schema import TASK_STATEMENTS from domus.infrastructure.sqlite_tasks import SQLiteTasks @@ -46,7 +47,12 @@ def store(tmp_path: Path) -> Iterator[Store]: connection.row_factory = sqlite3.Row connection.execute("PRAGMA foreign_keys=ON") connection.execute("PRAGMA trusted_schema=OFF") - for statement in (*BASE_STATEMENTS, *IDENTITY_STATEMENTS, *TASK_STATEMENTS): + for statement in ( + *BASE_STATEMENTS, + *IDENTITY_STATEMENTS, + *TASK_STATEMENTS, + *PROFILE_STATEMENTS, + ): connection.execute(statement) connection.execute( "INSERT INTO households(household_id,name,time_zone,revision) " diff --git a/tests/backend/test_task_restoration.py b/tests/backend/test_task_restoration.py index 16d51fb..c3ae67d 100644 --- a/tests/backend/test_task_restoration.py +++ b/tests/backend/test_task_restoration.py @@ -32,7 +32,7 @@ def test_restore_keeps_original_task_state_and_retains_later_completion_as_proce ) adapter = SQLiteRestorations(store, archive, target, store.installation, Clock(), checkpoint) prepared = adapter.prepare("restore-tasks") - assert prepared.source.schema_version == checkpoint.schema_version == 8 + assert prepared.source.schema_version == checkpoint.schema_version == 9 assert prepared.operation_count == 3 and prepared.current_operations == 2 assert adapter.inspect() == prepared and store.path.read_bytes() == source_bytes assert (archive / "domus.sqlite3").read_bytes() == checkpoint_bytes diff --git a/tests/containers/restore_fixture.py b/tests/containers/restore_fixture.py index 84aa1eb..68fdf8f 100644 --- a/tests/containers/restore_fixture.py +++ b/tests/containers/restore_fixture.py @@ -109,7 +109,7 @@ def terminate(source, target, **kwargs): else: raise AssertionError("Unknown fixture operation") with storage.database._connection() as connection: - assert connection.execute("PRAGMA user_version").fetchone()[0] == 8 + assert connection.execute("PRAGMA user_version").fetchone()[0] == 9 private_layout() print( json.dumps( diff --git a/tests/containers/test_administration_image.py b/tests/containers/test_administration_image.py new file mode 100644 index 0000000..5264c96 --- /dev/null +++ b/tests/containers/test_administration_image.py @@ -0,0 +1,128 @@ +"""Both actual CPU images preserve separate profiles in each owned deployment runtime.""" + +from pathlib import Path + +import pytest +from test_app_image import docker +from test_app_image import image as image + + +@pytest.mark.parametrize("mode", ["native", "standalone"]) +@pytest.mark.parametrize("starting_schema", [0, 8]) +def test_current_image_persists_independent_profiles_in_owned_runtime(image, mode, starting_schema): + historical = ( + (Path(__file__).parents[1] / "fixtures/sqlite/v8.sql").read_text() + if starting_schema == 8 + else "" + ) + result = docker( + "run", + "--rm", + "--network", + "none", + "--user", + "65532:65532", + "--entrypoint", + "/app/.venv/bin/python", + image, + "-c", + """ +import sys, json, sqlite3 +from contextlib import closing +from pathlib import Path +from tempfile import TemporaryDirectory +from domus.application.household_commands import HouseholdCommands, SetupHousehold +from domus.composition import ingress, server +from domus.domain.identity import HumanIdentity +from domus.domain.member_profiles import MemberLocale, MemberPresentation, MemberPreferences +from domus.infrastructure.application_runtime import SecureIdentifiers, SystemClock, TimeZoneCatalog +from domus.infrastructure.sqlite import SQLiteDatabase, SCHEMA_VERSION +from domus.infrastructure.sqlite_snapshots import SQLiteSnapshots + +assert SCHEMA_VERSION == 9 +class Human: + user_id = 'fictional_profile_ha_user' + def current(self): + return HumanIdentity(self.user_id, True, True, False) + def get(self, user_id): + raise AssertionError('No external directory subject is selected') + +mode = sys.argv[1] +assert mode in ('native', 'standalone') +with TemporaryDirectory(prefix='domus-profile-image-') as directory: + root = Path(directory) / 'owned' + root.mkdir(mode=0o700) + original = {} + if sys.argv[2]: + database = root / 'domus.sqlite3' + with closing(sqlite3.connect(database, autocommit=True)) as connection: + connection.executescript(sys.argv[2]) + assert connection.execute('PRAGMA user_version').fetchone()[0] == 8 + installation = connection.execute( + 'SELECT instance_id,channel FROM installation').fetchone() + original = { + row[0]: connection.execute('SELECT * FROM "' + row[0] + '" ORDER BY rowid') + .fetchall() + for row in connection.execute("SELECT name FROM sqlite_master WHERE type='table'") + } + Human.user_id = original['members'][0][2] + database.chmod(0o600) + marker = root / 'installation.json' + marker.write_text(json.dumps({'schema_version': 1, 'instance_id': installation[0], + 'channel': installation[1], 'state': 'ready'})) + marker.chmod(0o600) + def factory(): + if mode == 'native': + return ingress.create_owned_runtime('fictional-token', 'edge', root, + ('http://fictional-ha.local',)) + return server.create_owned_runtime('http://fictional-ha.local', + 'https://fictional-domus.example', 'edge', root) + runtime = factory() + try: + storage = runtime.storage + store = storage.database + human, clock, ids = Human(), SystemClock(), SecureIdentifiers() + if not original: + HouseholdCommands(store, human, human, TimeZoneCatalog(), clock, ids).setup( + 'setup', SetupHousehold('Profile fixture', 'UTC')) + else: + with store._connection() as connection: + for table, rows in original.items(): + assert [tuple(row) for row in connection.execute( + 'SELECT * FROM "' + table + '" ORDER BY rowid')] == rows + with store.read() as unit: + member = unit.members.by_user('home', human.current().ha_user_id) + assert unit.presentations.get('home', member.member_id) == MemberPresentation( + member.member_id, 'home') + assert unit.preferences.get('home', member.member_id) == MemberPreferences( + member.member_id, 'home') + public = MemberPresentation(member.member_id, 'home', '', 2) + private = MemberPreferences(member.member_id, 'home', MemberLocale.SPANISH, False, 2) + with store.write() as unit: + unit.presentations.save(public, 1) + unit.preferences.save(private, 1) + checkpoint = SQLiteSnapshots(store, Path(directory) / 'snapshot', storage.installation, + clock).create('profile-checkpoint') + assert checkpoint.schema_version == 9 + assert len(tuple((Path(directory) / 'snapshot').iterdir())) == 3 + finally: + runtime.close() + reopened = factory() + try: + with reopened.storage.database.read() as unit: + assert unit.members.by_user('home', human.current().ha_user_id) == member + assert unit.presentations.get('home', member.member_id) == public + assert unit.preferences.get('home', member.member_id) == private + assert len(unit.audit.entries('home')) == len(unit.outbox.pending()) == ( + len(original['receipts']) if original else 1) + with SQLiteDatabase(Path(directory) / 'snapshot/domus.sqlite3', + reopened.storage.installation).read() as unit: + assert unit.presentations.get('home', member.member_id) == public + assert unit.preferences.get('home', member.member_id) == private + finally: + reopened.close() +""", + mode, + historical, + ) + assert result.returncode == 0, result.stdout + result.stderr diff --git a/tests/containers/test_app_compatibility.py b/tests/containers/test_app_compatibility.py index 38a0cd1..253ee10 100644 --- a/tests/containers/test_app_compatibility.py +++ b/tests/containers/test_app_compatibility.py @@ -38,6 +38,11 @@ def previous_image(request): "271a3a07feb1535f0a5ec8b55abb3de9aac049a9", "0.0.0-edge.5", ), + "schema8": ( + "DOMUS_SCHEMA8_IMAGE", + "ed7cd14190d750846833cf1bb822988b628560da", + "ci-schema8", + ), }[version] value = os.environ.get(variable) assert value, "Set " + variable + " to the reviewed immutable " + label + " image" @@ -90,7 +95,7 @@ def refuse_old_image(image, previous, volume): @pytest.mark.parametrize("restore_runtime", ["native", "standalone"], indirect=True) -@pytest.mark.parametrize("previous_image", ["edge2", "edge3", "edge5"], indirect=True) +@pytest.mark.parametrize("previous_image", ["edge2", "edge3", "edge5", "schema8"], indirect=True) def test_actual_older_image_refuses_selected_generation_without_changing_any_file( image, previous_image, restore_runtime ): @@ -118,7 +123,7 @@ def test_actual_older_image_refuses_selected_generation_without_changing_any_fil @pytest.mark.parametrize( "previous_image,old_schema", - [("edge2", 3), ("edge3", 4), ("edge5", 7)], + [("edge2", 3), ("edge3", 4), ("edge5", 7), ("schema8", 8)], indirect=["previous_image"], ) def test_real_older_image_upgrades_replays_and_restores_its_pre_upgrade_snapshot( @@ -214,7 +219,7 @@ def test_real_older_image_upgrades_replays_and_restores_its_pre_upgrade_snapshot "/data/snapshots/current", ) ) - assert current["snapshot"]["schema_version"] == 8 + assert current["snapshot"]["schema_version"] == 9 assert current["snapshot"]["instance_id"] == instance_id inspected = successful( console( diff --git a/tests/containers/test_app_image.py b/tests/containers/test_app_image.py index 61c0e02..8786db8 100644 --- a/tests/containers/test_app_image.py +++ b/tests/containers/test_app_image.py @@ -255,7 +255,7 @@ def snapshot_roundtrip(name: str, instance_id: str) -> None: assert created.returncode == 0, created.stderr original = json.loads(created.stdout) assert original["state"] == "created" - assert original["snapshot"]["schema_version"] == 8 + assert original["snapshot"]["schema_version"] == 9 assert original["snapshot"]["last_committed_at"] == 100 inspected = docker( "exec", @@ -531,7 +531,7 @@ def test_packaged_console_restore_restart_and_latest_history_rollback( ) ) assert activated["state"] == "active" - assert activated["activation"]["rollback_snapshot"]["schema_version"] == 8 + assert activated["activation"]["rollback_snapshot"]["schema_version"] == 9 assert docker("start", name).returncode == 0 ready(name, standalone=mode == "standalone") active = successful(packaged_fixture(image, volume, "active", delivery)) diff --git a/tests/containers/test_recurrence_image.py b/tests/containers/test_recurrence_image.py index b5a3ec7..1b6764d 100644 --- a/tests/containers/test_recurrence_image.py +++ b/tests/containers/test_recurrence_image.py @@ -56,7 +56,7 @@ def test_current_image_executes_recurrence_creation_planning_and_closed_record_c schedule_document, read_schedule, progress_document, read_progress, slot_document, read_slot, frontier_document, read_frontier, ) -assert SCHEMA_VERSION == 8 +assert SCHEMA_VERSION == 9 policy = TimePolicy('Europe/Madrid', TIMEZONE_DATA_IDENTITY) pattern = parse_fixed_pattern('FREQ=DAILY;COUNT=2', NominalTime(datetime(2026, 3, 29, 2, 30))) times = PinnedRecurrenceTime() diff --git a/tests/fixtures/schema8-app/Dockerfile b/tests/fixtures/schema8-app/Dockerfile new file mode 100644 index 0000000..36b97f4 --- /dev/null +++ b/tests/fixtures/schema8-app/Dockerfile @@ -0,0 +1,42 @@ +# Immutable published schema8 development checkpoint; never a Supervisor release folder. +FROM ghcr.io/astral-sh/uv:0.11.29@sha256:eb2843a1e56fd9e30c7276ce1a52cba86e64c7b385f5e3279a0e08e02dd058fc AS uv +FROM python:3.14.6-slim-trixie@sha256:7bec7ddcddeff7975d6ba9b4be7dd6f6b2f55e7491539145e2978f7f97ce9144 AS source +ARG DOMUS_SOURCE_COMMIT=ed7cd14190d750846833cf1bb822988b628560da +ARG DOMUS_SOURCE_SHA256=1d5c69cd2a61f2e8b2c7702ea112a2034c7849f4f59d3a479e41be001b8f2454 +ADD https://codeload.github.com/vypdev/domus/tar.gz/${DOMUS_SOURCE_COMMIT} /tmp/source.tar.gz +RUN printf '%s /tmp/source.tar.gz\n' "$DOMUS_SOURCE_SHA256" | sha256sum --check - \ + && mkdir /app \ + && tar --extract --gzip --file=/tmp/source.tar.gz --directory=/app --strip-components=1 +WORKDIR /app + +FROM node:24.20.0-bookworm-slim@sha256:ba849c60be29959425b8734d57b8b4b7d56f98edd9504c9af091d5281095a71e AS frontend +RUN npm install --global --ignore-scripts --no-audit --no-fund pnpm@10.34.0 +WORKDIR /app +COPY --from=source /app/package.json /app/pnpm-lock.yaml /app/pnpm-workspace.yaml ./ +COPY --from=source /app/frontend ./frontend +COPY --from=source /app/tests/fixtures/frontend-toolchain/package.json ./tests/fixtures/frontend-toolchain/package.json +RUN pnpm install --frozen-lockfile --filter @domus/frontend... \ + && pnpm build:frontend + +FROM source AS builder +COPY --from=uv /uv /usr/local/bin/uv +COPY --from=frontend /app/backend/src/domus/ui /app/backend/src/domus/ui +ENV UV_PYTHON_DOWNLOADS=never UV_NO_CACHE=1 +RUN uv sync --frozen --no-dev --package domus-server --no-editable +RUN .venv/bin/python -c "from pathlib import Path; import domus; from domus.entrypoints.ui_bundle import load_bundle; from domus.infrastructure.sqlite import SCHEMA_VERSION; assert SCHEMA_VERSION == 8; assert load_bundle(Path(domus.__file__).parent / 'ui') is not None" + +FROM python:3.14.6-slim-trixie@sha256:7bec7ddcddeff7975d6ba9b4be7dd6f6b2f55e7491539145e2978f7f97ce9144 +ARG BUILD_VERSION=ci-schema8 +ARG BUILD_ARCH +ARG DOMUS_SOURCE_COMMIT=ed7cd14190d750846833cf1bb822988b628560da +LABEL io.hass.type="app" \ + io.hass.version="${BUILD_VERSION}" \ + io.hass.arch="${BUILD_ARCH}" \ + org.opencontainers.image.source="https://github.com/vypdev/domus" \ + org.opencontainers.image.revision="${DOMUS_SOURCE_COMMIT}" +COPY --from=builder /app/.venv /app/.venv +ENV PATH="/app/.venv/bin:${PATH}" PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1 +USER 0:0 +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8099/health', timeout=3)" +ENTRYPOINT ["/app/.venv/bin/domus-ha-app", "--channel", "edge"] diff --git a/tests/fixtures/sqlite/v8.sql b/tests/fixtures/sqlite/v8.sql new file mode 100644 index 0000000..7859f8b --- /dev/null +++ b/tests/fixtures/sqlite/v8.sql @@ -0,0 +1,81 @@ +PRAGMA application_id=1146047829; +PRAGMA user_version=8; +BEGIN TRANSACTION; +CREATE TABLE audit(operation_id TEXT PRIMARY KEY, household_id TEXT NOT NULL REFERENCES households(household_id), actor_id TEXT NOT NULL, action TEXT NOT NULL, command_key TEXT NOT NULL, detail TEXT NOT NULL, created_at INTEGER NOT NULL); +INSERT INTO "audit" VALUES('domus_JgL6mFkWMYaBmCp-D7EThqQYqu9ortDq','home','fixture_ha_admin','household.setup','setup','{"administrator_member_id":"domus_9ymqDtH5vfT-fpLaJAUtapBHZ-U7EBlR","mapping_mode":"all_ha_administrators","transition":"household.configured"}',1793623486184); +INSERT INTO "audit" VALUES('domus_YUkXZhsIuf5LSYHqTIa9w3fhgGmXvznm','home','fixture_ha_admin','recurrence.fixed.create','fixed','{"request":{"pattern":{"by_day":[],"by_month":[],"by_month_day":[],"by_set_position":[],"count":null,"frequency":"DAILY","interval":1,"start":"2026-03-29T02:30:00","start_kind":"time","until":1774830600000,"version":1,"week_start":"MO"},"policy":{"data_identity":"tzdata:2026.6/iana:2026f","repeated_time":"first_instant","revision":1,"zone":"Europe/Madrid"},"task":{"content":{"description":"","priority":"normal","tags":[],"title":"Actual fixed source input"},"expected_revision":0,"overrides":{"adult_performer_may_approve":null,"claim_allowed":null,"dependency_gate":null,"eligible_roles":null,"material_recording":null,"point_value_minor":null,"task_scoped_material_consumption":null,"verification":null},"state":"active"},"version":1},"result":{"definition_id":"domus_fLEQZD1_-buQxRs5bUs1Bde0Up1PvcH0","first_occurrence_id":null,"pattern_revision":1,"processing":"pending","revision":1,"schedule_id":"domus_jiNbEIJlP_4aDpDgAjxfcguJzmk68jZT","state":"active"},"transition":"recurrence.fixed.created"}',1793623486193); +INSERT INTO "audit" VALUES('domus_vAPQBgYRZXdo04dtMDUqJ5ppXGqMgJkT','home','fixture_ha_admin','recurrence.relative.create','relative','{"request":{"initial":{"kind":"time","local":"2026-03-29T02:30:00"},"interval_days":30,"policy":{"data_identity":"tzdata:2026.6/iana:2026f","repeated_time":"first_instant","revision":1,"zone":"Europe/Madrid"},"task":{"content":{"description":"","priority":"normal","tags":[],"title":"Actual pending relative input"},"expected_revision":0,"overrides":{"adult_performer_may_approve":null,"claim_allowed":null,"dependency_gate":null,"eligible_roles":null,"material_recording":null,"point_value_minor":null,"task_scoped_material_consumption":null,"verification":null},"state":"active"},"version":1,"visibility":"at_completion"},"result":{"definition_id":"domus_IVvhBs8AleFaXy34rnVB5CuACr7ZRuX4","first_occurrence_id":null,"pattern_revision":1,"processing":"pending","revision":1,"schedule_id":"domus_ASCascPHHAcqRNkq5zykTrKYoR0sNoX0","state":"active"},"transition":"recurrence.relative.created"}',1793623486197); +INSERT INTO "audit" VALUES('domus_ibbPk0aRJprWIoWNIpkGGyJxXfQA1qzP','home','system:recurrence','recurrence.materialize','122a37056186ea89c9d49277730496b71caf2d84be5232e2dbbfd9fa0e05be50','{"request":{"definition_id":"domus_fLEQZD1_-buQxRs5bUs1Bde0Up1PvcH0","nominal":{"kind":"time","local":"2026-03-29T02:30:00"},"pattern":"92d046b4f7ae037572eb6a31467ba74a3041498a271dfa1cfb1ddc5c58b21727","resolved_at":1774746000000,"slot_key":"122a37056186ea89c9d49277730496b71caf2d84be5232e2dbbfd9fa0e05be50","version":1,"zone_digest":"ca5b321ddbfc88e07e0d03ed2fa0c832ce5d0dd8e7d90a25200a8e24898c3b21"},"result":{"cursor_evidence":{"cursor":{"binding":"6bd1edfcf2c489f27216f5c1f782f53a052d26823e3127112d9c9e11f71bbcf6","closed":false,"nominal_count":1,"offset":1,"period_index":0},"last":{"nominal":{"kind":"time","local":"2026-03-29T02:30:00"},"resolved":{"nominal":{"kind":"time","local":"2026-03-29T02:30:00"},"offset_seconds":7200,"policy":{"data_identity":"tzdata:2026.6/iana:2026f","repeated_time":"first_instant","revision":1,"zone":"Europe/Madrid"},"resolution":"gap_first_valid","resolved_local":"2026-03-29T03:00:00","zone_digest":"ca5b321ddbfc88e07e0d03ed2fa0c832ce5d0dd8e7d90a25200a8e24898c3b21"}},"policy":{"data_identity":"tzdata:2026.6/iana:2026f","repeated_time":"first_instant","revision":1,"zone":"Europe/Madrid"},"range_complete":false,"terminal":null,"version":1},"definition_id":"domus_fLEQZD1_-buQxRs5bUs1Bde0Up1PvcH0","nominal":{"kind":"time","local":"2026-03-29T02:30:00"},"occurrence_id":"domus_xw39jnz0MIZ2YHRkWLsT23oetQM-LzuJ","resolved_at":1774746000000,"schedule_id":"domus_jiNbEIJlP_4aDpDgAjxfcguJzmk68jZT","slot_key":"122a37056186ea89c9d49277730496b71caf2d84be5232e2dbbfd9fa0e05be50"}}',1793623486201); +INSERT INTO "audit" VALUES('domus_s8786owhjE-93I47AAgkptHeM6TI4yeo','home','system:recurrence','recurrence.materialize','642769c4438c89b84354ca24046b9800e6338a088397f26d3dd7fa4c3f028107','{"request":{"definition_id":"domus_fLEQZD1_-buQxRs5bUs1Bde0Up1PvcH0","nominal":{"kind":"time","local":"2026-03-30T02:30:00"},"pattern":"92d046b4f7ae037572eb6a31467ba74a3041498a271dfa1cfb1ddc5c58b21727","resolved_at":1774830600000,"slot_key":"642769c4438c89b84354ca24046b9800e6338a088397f26d3dd7fa4c3f028107","version":1,"zone_digest":"ca5b321ddbfc88e07e0d03ed2fa0c832ce5d0dd8e7d90a25200a8e24898c3b21"},"result":{"cursor_evidence":{"cursor":{"binding":"6bd1edfcf2c489f27216f5c1f782f53a052d26823e3127112d9c9e11f71bbcf6","closed":false,"nominal_count":2,"offset":1,"period_index":1},"last":{"nominal":{"kind":"time","local":"2026-03-30T02:30:00"},"resolved":{"nominal":{"kind":"time","local":"2026-03-30T02:30:00"},"offset_seconds":7200,"policy":{"data_identity":"tzdata:2026.6/iana:2026f","repeated_time":"first_instant","revision":1,"zone":"Europe/Madrid"},"resolution":"exact","resolved_local":"2026-03-30T02:30:00","zone_digest":"ca5b321ddbfc88e07e0d03ed2fa0c832ce5d0dd8e7d90a25200a8e24898c3b21"}},"policy":{"data_identity":"tzdata:2026.6/iana:2026f","repeated_time":"first_instant","revision":1,"zone":"Europe/Madrid"},"range_complete":false,"terminal":null,"version":1},"definition_id":"domus_fLEQZD1_-buQxRs5bUs1Bde0Up1PvcH0","nominal":{"kind":"time","local":"2026-03-30T02:30:00"},"occurrence_id":"domus_FlZOFEushbnM2s_iGq7S8vNBadPgpb6j","resolved_at":1774830600000,"schedule_id":"domus_jiNbEIJlP_4aDpDgAjxfcguJzmk68jZT","slot_key":"642769c4438c89b84354ca24046b9800e6338a088397f26d3dd7fa4c3f028107"}}',1793623486206); +INSERT INTO "audit" VALUES('domus_xd29HsAZyEBH1sJ6zfezNio_AkBDTJG4','home','system:recurrence','recurrence.scan','b39da39e67b2c7e78bad962211e6d456627551c045c62b2b9526bde8f7be9096','{"request":{"before":{"kind":"time","local":"2026-04-01T02:30:00"},"from_cursor":{"binding":"6bd1edfcf2c489f27216f5c1f782f53a052d26823e3127112d9c9e11f71bbcf6","closed":false,"nominal_count":2,"offset":1,"period_index":1},"from_revision":3,"pattern_revision":1,"schedule_id":"domus_jiNbEIJlP_4aDpDgAjxfcguJzmk68jZT","to_cursor":{"binding":"6bd1edfcf2c489f27216f5c1f782f53a052d26823e3127112d9c9e11f71bbcf6","closed":true,"nominal_count":2,"offset":0,"period_index":2},"version":1},"result":{"cursor":{"binding":"6bd1edfcf2c489f27216f5c1f782f53a052d26823e3127112d9c9e11f71bbcf6","closed":true,"nominal_count":2,"offset":0,"period_index":2},"cursor_evidence":{"cursor":{"binding":"6bd1edfcf2c489f27216f5c1f782f53a052d26823e3127112d9c9e11f71bbcf6","closed":true,"nominal_count":2,"offset":0,"period_index":2},"last":{"nominal":{"kind":"time","local":"2026-03-30T02:30:00"},"resolved":{"nominal":{"kind":"time","local":"2026-03-30T02:30:00"},"offset_seconds":7200,"policy":{"data_identity":"tzdata:2026.6/iana:2026f","repeated_time":"first_instant","revision":1,"zone":"Europe/Madrid"},"resolution":"exact","resolved_local":"2026-03-30T02:30:00","zone_digest":"ca5b321ddbfc88e07e0d03ed2fa0c832ce5d0dd8e7d90a25200a8e24898c3b21"}},"policy":{"data_identity":"tzdata:2026.6/iana:2026f","repeated_time":"first_instant","revision":1,"zone":"Europe/Madrid"},"range_complete":true,"terminal":{"nominal":{"kind":"time","local":"2026-03-31T02:30:00"},"resolved":{"nominal":{"kind":"time","local":"2026-03-31T02:30:00"},"offset_seconds":7200,"policy":{"data_identity":"tzdata:2026.6/iana:2026f","repeated_time":"first_instant","revision":1,"zone":"Europe/Madrid"},"resolution":"exact","resolved_local":"2026-03-31T02:30:00","zone_digest":"ca5b321ddbfc88e07e0d03ed2fa0c832ce5d0dd8e7d90a25200a8e24898c3b21"}},"version":1},"processed_slots":0,"range_complete":true,"schedule_id":"domus_jiNbEIJlP_4aDpDgAjxfcguJzmk68jZT"}}',1793623486211); +CREATE TABLE household_policy(household_id TEXT PRIMARY KEY REFERENCES households(household_id), mapping_mode TEXT NOT NULL, target_ha_user_id TEXT, revision INTEGER NOT NULL CHECK(revision>=1), adjustments TEXT NOT NULL, CHECK((mapping_mode='all_ha_administrators' AND target_ha_user_id IS NULL) OR (mapping_mode IN ('setup_administrator','selected_ha_user') AND target_ha_user_id IS NOT NULL))); +INSERT INTO "household_policy" VALUES('home','all_ha_administrators',NULL,1,'{"rules":[]}'); +CREATE TABLE households(household_id TEXT PRIMARY KEY, singleton INTEGER NOT NULL DEFAULT 1 UNIQUE CHECK(singleton=1), name TEXT NOT NULL, time_zone TEXT NOT NULL, revision INTEGER NOT NULL CHECK(revision>=1)); +INSERT INTO "households" VALUES('home',1,'Fictional current-source input','UTC',1); +CREATE TABLE installation(singleton INTEGER PRIMARY KEY CHECK(singleton=1), instance_id TEXT NOT NULL, channel TEXT NOT NULL CHECK(channel IN ('stable','edge'))); +INSERT INTO "installation" VALUES(1,'00000000-0000-0000-0000-000000000007','edge'); +CREATE TABLE members(member_id TEXT PRIMARY KEY, household_id TEXT NOT NULL REFERENCES households(household_id), ha_user_id TEXT NOT NULL, role TEXT NOT NULL CHECK(role IN ('administrator','adult','restricted','viewer')), status TEXT NOT NULL CHECK(status IN ('pending','active','suspended','archived')), revision INTEGER NOT NULL CHECK(revision>=1), exceptions TEXT NOT NULL, UNIQUE(household_id,ha_user_id)); +INSERT INTO "members" VALUES('domus_9ymqDtH5vfT-fpLaJAUtapBHZ-U7EBlR','home','fixture_ha_admin','viewer','active',1,'{"rules":[]}'); +CREATE TABLE outbox(position INTEGER PRIMARY KEY AUTOINCREMENT, event_id TEXT NOT NULL UNIQUE, household_id TEXT NOT NULL REFERENCES households(household_id), event_type TEXT NOT NULL, payload TEXT NOT NULL, created_at INTEGER NOT NULL, delivered INTEGER NOT NULL DEFAULT 0 CHECK(delivered IN (0,1))); +INSERT INTO "outbox" VALUES(1,'domus_JgL6mFkWMYaBmCp-D7EThqQYqu9ortDq','home','household.setup','{"household_id":"home","revision":1}',1793623486184,0); +INSERT INTO "outbox" VALUES(2,'domus_YUkXZhsIuf5LSYHqTIa9w3fhgGmXvznm','home','recurrence.fixed.create','{"definition_id":"domus_fLEQZD1_-buQxRs5bUs1Bde0Up1PvcH0","first_occurrence_id":null,"kind":"recurrence.fixed.created","pattern_revision":1,"processing":"pending","revision":1,"schedule_id":"domus_jiNbEIJlP_4aDpDgAjxfcguJzmk68jZT","state":"active","version":1}',1793623486193,0); +INSERT INTO "outbox" VALUES(3,'domus_vAPQBgYRZXdo04dtMDUqJ5ppXGqMgJkT','home','recurrence.relative.create','{"definition_id":"domus_IVvhBs8AleFaXy34rnVB5CuACr7ZRuX4","first_occurrence_id":null,"kind":"recurrence.relative.created","pattern_revision":1,"processing":"pending","revision":1,"schedule_id":"domus_ASCascPHHAcqRNkq5zykTrKYoR0sNoX0","state":"active","version":1}',1793623486197,0); +INSERT INTO "outbox" VALUES(4,'domus_ibbPk0aRJprWIoWNIpkGGyJxXfQA1qzP','home','recurrence.materialize','{"cursor_evidence":{"cursor":{"binding":"6bd1edfcf2c489f27216f5c1f782f53a052d26823e3127112d9c9e11f71bbcf6","closed":false,"nominal_count":1,"offset":1,"period_index":0},"last":{"nominal":{"kind":"time","local":"2026-03-29T02:30:00"},"resolved":{"nominal":{"kind":"time","local":"2026-03-29T02:30:00"},"offset_seconds":7200,"policy":{"data_identity":"tzdata:2026.6/iana:2026f","repeated_time":"first_instant","revision":1,"zone":"Europe/Madrid"},"resolution":"gap_first_valid","resolved_local":"2026-03-29T03:00:00","zone_digest":"ca5b321ddbfc88e07e0d03ed2fa0c832ce5d0dd8e7d90a25200a8e24898c3b21"}},"policy":{"data_identity":"tzdata:2026.6/iana:2026f","repeated_time":"first_instant","revision":1,"zone":"Europe/Madrid"},"range_complete":false,"terminal":null,"version":1},"definition_id":"domus_fLEQZD1_-buQxRs5bUs1Bde0Up1PvcH0","kind":"recurrence.materialize","nominal":{"kind":"time","local":"2026-03-29T02:30:00"},"occurrence_id":"domus_xw39jnz0MIZ2YHRkWLsT23oetQM-LzuJ","resolved_at":1774746000000,"schedule_id":"domus_jiNbEIJlP_4aDpDgAjxfcguJzmk68jZT","slot_key":"122a37056186ea89c9d49277730496b71caf2d84be5232e2dbbfd9fa0e05be50","version":1}',1793623486201,0); +INSERT INTO "outbox" VALUES(5,'domus_s8786owhjE-93I47AAgkptHeM6TI4yeo','home','recurrence.materialize','{"cursor_evidence":{"cursor":{"binding":"6bd1edfcf2c489f27216f5c1f782f53a052d26823e3127112d9c9e11f71bbcf6","closed":false,"nominal_count":2,"offset":1,"period_index":1},"last":{"nominal":{"kind":"time","local":"2026-03-30T02:30:00"},"resolved":{"nominal":{"kind":"time","local":"2026-03-30T02:30:00"},"offset_seconds":7200,"policy":{"data_identity":"tzdata:2026.6/iana:2026f","repeated_time":"first_instant","revision":1,"zone":"Europe/Madrid"},"resolution":"exact","resolved_local":"2026-03-30T02:30:00","zone_digest":"ca5b321ddbfc88e07e0d03ed2fa0c832ce5d0dd8e7d90a25200a8e24898c3b21"}},"policy":{"data_identity":"tzdata:2026.6/iana:2026f","repeated_time":"first_instant","revision":1,"zone":"Europe/Madrid"},"range_complete":false,"terminal":null,"version":1},"definition_id":"domus_fLEQZD1_-buQxRs5bUs1Bde0Up1PvcH0","kind":"recurrence.materialize","nominal":{"kind":"time","local":"2026-03-30T02:30:00"},"occurrence_id":"domus_FlZOFEushbnM2s_iGq7S8vNBadPgpb6j","resolved_at":1774830600000,"schedule_id":"domus_jiNbEIJlP_4aDpDgAjxfcguJzmk68jZT","slot_key":"642769c4438c89b84354ca24046b9800e6338a088397f26d3dd7fa4c3f028107","version":1}',1793623486206,0); +INSERT INTO "outbox" VALUES(6,'domus_xd29HsAZyEBH1sJ6zfezNio_AkBDTJG4','home','recurrence.scan','{"cursor":{"binding":"6bd1edfcf2c489f27216f5c1f782f53a052d26823e3127112d9c9e11f71bbcf6","closed":true,"nominal_count":2,"offset":0,"period_index":2},"cursor_evidence":{"cursor":{"binding":"6bd1edfcf2c489f27216f5c1f782f53a052d26823e3127112d9c9e11f71bbcf6","closed":true,"nominal_count":2,"offset":0,"period_index":2},"last":{"nominal":{"kind":"time","local":"2026-03-30T02:30:00"},"resolved":{"nominal":{"kind":"time","local":"2026-03-30T02:30:00"},"offset_seconds":7200,"policy":{"data_identity":"tzdata:2026.6/iana:2026f","repeated_time":"first_instant","revision":1,"zone":"Europe/Madrid"},"resolution":"exact","resolved_local":"2026-03-30T02:30:00","zone_digest":"ca5b321ddbfc88e07e0d03ed2fa0c832ce5d0dd8e7d90a25200a8e24898c3b21"}},"policy":{"data_identity":"tzdata:2026.6/iana:2026f","repeated_time":"first_instant","revision":1,"zone":"Europe/Madrid"},"range_complete":true,"terminal":{"nominal":{"kind":"time","local":"2026-03-31T02:30:00"},"resolved":{"nominal":{"kind":"time","local":"2026-03-31T02:30:00"},"offset_seconds":7200,"policy":{"data_identity":"tzdata:2026.6/iana:2026f","repeated_time":"first_instant","revision":1,"zone":"Europe/Madrid"},"resolution":"exact","resolved_local":"2026-03-31T02:30:00","zone_digest":"ca5b321ddbfc88e07e0d03ed2fa0c832ce5d0dd8e7d90a25200a8e24898c3b21"}},"version":1},"kind":"recurrence.scan","processed_slots":0,"range_complete":true,"schedule_id":"domus_jiNbEIJlP_4aDpDgAjxfcguJzmk68jZT","version":1}',1793623486211,0); +CREATE TABLE receipts(household_id TEXT NOT NULL REFERENCES households(household_id), actor_id TEXT NOT NULL, action TEXT NOT NULL, command_key TEXT NOT NULL, payload_fingerprint TEXT NOT NULL, operation_id TEXT NOT NULL UNIQUE, result TEXT NOT NULL, created_at INTEGER NOT NULL, PRIMARY KEY(household_id,actor_id,action,command_key)); +INSERT INTO "receipts" VALUES('home','fixture_ha_admin','household.setup','setup','ef15cd26950294c24c5d151ee9e5c80205b0123327c9a37e2ec0a0d0343afb1d','domus_JgL6mFkWMYaBmCp-D7EThqQYqu9ortDq','{"administrator_member_id":"domus_9ymqDtH5vfT-fpLaJAUtapBHZ-U7EBlR","household_id":"home","mapping_mode":"all_ha_administrators","member_id":"domus_9ymqDtH5vfT-fpLaJAUtapBHZ-U7EBlR","policy_revision":1,"revision":1,"state":"configured"}',1793623486184); +INSERT INTO "receipts" VALUES('home','fixture_ha_admin','recurrence.fixed.create','fixed','fc7ad78e794f39529790e5f89e46f3f84d4a253a7f4ce0f8ad1385c1ec92e109','domus_YUkXZhsIuf5LSYHqTIa9w3fhgGmXvznm','{"definition_id":"domus_fLEQZD1_-buQxRs5bUs1Bde0Up1PvcH0","first_occurrence_id":null,"pattern_revision":1,"processing":"pending","revision":1,"schedule_id":"domus_jiNbEIJlP_4aDpDgAjxfcguJzmk68jZT","state":"active"}',1793623486193); +INSERT INTO "receipts" VALUES('home','fixture_ha_admin','recurrence.relative.create','relative','2ebcc8c4b77055f0aff8a13b17d556929ef1b2c5916d28a033d41a0e94b77113','domus_vAPQBgYRZXdo04dtMDUqJ5ppXGqMgJkT','{"definition_id":"domus_IVvhBs8AleFaXy34rnVB5CuACr7ZRuX4","first_occurrence_id":null,"pattern_revision":1,"processing":"pending","revision":1,"schedule_id":"domus_ASCascPHHAcqRNkq5zykTrKYoR0sNoX0","state":"active"}',1793623486197); +INSERT INTO "receipts" VALUES('home','system:recurrence','recurrence.materialize','122a37056186ea89c9d49277730496b71caf2d84be5232e2dbbfd9fa0e05be50','d12d575009442a58217b72e2f0ef4c877f93c99e890841a8989c32c40334182e','domus_ibbPk0aRJprWIoWNIpkGGyJxXfQA1qzP','{"cursor_evidence":{"cursor":{"binding":"6bd1edfcf2c489f27216f5c1f782f53a052d26823e3127112d9c9e11f71bbcf6","closed":false,"nominal_count":1,"offset":1,"period_index":0},"last":{"nominal":{"kind":"time","local":"2026-03-29T02:30:00"},"resolved":{"nominal":{"kind":"time","local":"2026-03-29T02:30:00"},"offset_seconds":7200,"policy":{"data_identity":"tzdata:2026.6/iana:2026f","repeated_time":"first_instant","revision":1,"zone":"Europe/Madrid"},"resolution":"gap_first_valid","resolved_local":"2026-03-29T03:00:00","zone_digest":"ca5b321ddbfc88e07e0d03ed2fa0c832ce5d0dd8e7d90a25200a8e24898c3b21"}},"policy":{"data_identity":"tzdata:2026.6/iana:2026f","repeated_time":"first_instant","revision":1,"zone":"Europe/Madrid"},"range_complete":false,"terminal":null,"version":1},"definition_id":"domus_fLEQZD1_-buQxRs5bUs1Bde0Up1PvcH0","nominal":{"kind":"time","local":"2026-03-29T02:30:00"},"occurrence_id":"domus_xw39jnz0MIZ2YHRkWLsT23oetQM-LzuJ","resolved_at":1774746000000,"schedule_id":"domus_jiNbEIJlP_4aDpDgAjxfcguJzmk68jZT","slot_key":"122a37056186ea89c9d49277730496b71caf2d84be5232e2dbbfd9fa0e05be50"}',1793623486201); +INSERT INTO "receipts" VALUES('home','system:recurrence','recurrence.materialize','642769c4438c89b84354ca24046b9800e6338a088397f26d3dd7fa4c3f028107','e418fa76618ae4a63cf840f18372ee6b7b44bc31b948ae1fb9f6529d0faf6ce6','domus_s8786owhjE-93I47AAgkptHeM6TI4yeo','{"cursor_evidence":{"cursor":{"binding":"6bd1edfcf2c489f27216f5c1f782f53a052d26823e3127112d9c9e11f71bbcf6","closed":false,"nominal_count":2,"offset":1,"period_index":1},"last":{"nominal":{"kind":"time","local":"2026-03-30T02:30:00"},"resolved":{"nominal":{"kind":"time","local":"2026-03-30T02:30:00"},"offset_seconds":7200,"policy":{"data_identity":"tzdata:2026.6/iana:2026f","repeated_time":"first_instant","revision":1,"zone":"Europe/Madrid"},"resolution":"exact","resolved_local":"2026-03-30T02:30:00","zone_digest":"ca5b321ddbfc88e07e0d03ed2fa0c832ce5d0dd8e7d90a25200a8e24898c3b21"}},"policy":{"data_identity":"tzdata:2026.6/iana:2026f","repeated_time":"first_instant","revision":1,"zone":"Europe/Madrid"},"range_complete":false,"terminal":null,"version":1},"definition_id":"domus_fLEQZD1_-buQxRs5bUs1Bde0Up1PvcH0","nominal":{"kind":"time","local":"2026-03-30T02:30:00"},"occurrence_id":"domus_FlZOFEushbnM2s_iGq7S8vNBadPgpb6j","resolved_at":1774830600000,"schedule_id":"domus_jiNbEIJlP_4aDpDgAjxfcguJzmk68jZT","slot_key":"642769c4438c89b84354ca24046b9800e6338a088397f26d3dd7fa4c3f028107"}',1793623486206); +INSERT INTO "receipts" VALUES('home','system:recurrence','recurrence.scan','b39da39e67b2c7e78bad962211e6d456627551c045c62b2b9526bde8f7be9096','b39da39e67b2c7e78bad962211e6d456627551c045c62b2b9526bde8f7be9096','domus_xd29HsAZyEBH1sJ6zfezNio_AkBDTJG4','{"cursor":{"binding":"6bd1edfcf2c489f27216f5c1f782f53a052d26823e3127112d9c9e11f71bbcf6","closed":true,"nominal_count":2,"offset":0,"period_index":2},"cursor_evidence":{"cursor":{"binding":"6bd1edfcf2c489f27216f5c1f782f53a052d26823e3127112d9c9e11f71bbcf6","closed":true,"nominal_count":2,"offset":0,"period_index":2},"last":{"nominal":{"kind":"time","local":"2026-03-30T02:30:00"},"resolved":{"nominal":{"kind":"time","local":"2026-03-30T02:30:00"},"offset_seconds":7200,"policy":{"data_identity":"tzdata:2026.6/iana:2026f","repeated_time":"first_instant","revision":1,"zone":"Europe/Madrid"},"resolution":"exact","resolved_local":"2026-03-30T02:30:00","zone_digest":"ca5b321ddbfc88e07e0d03ed2fa0c832ce5d0dd8e7d90a25200a8e24898c3b21"}},"policy":{"data_identity":"tzdata:2026.6/iana:2026f","repeated_time":"first_instant","revision":1,"zone":"Europe/Madrid"},"range_complete":true,"terminal":{"nominal":{"kind":"time","local":"2026-03-31T02:30:00"},"resolved":{"nominal":{"kind":"time","local":"2026-03-31T02:30:00"},"offset_seconds":7200,"policy":{"data_identity":"tzdata:2026.6/iana:2026f","repeated_time":"first_instant","revision":1,"zone":"Europe/Madrid"},"resolution":"exact","resolved_local":"2026-03-31T02:30:00","zone_digest":"ca5b321ddbfc88e07e0d03ed2fa0c832ce5d0dd8e7d90a25200a8e24898c3b21"}},"version":1},"processed_slots":0,"range_complete":true,"schedule_id":"domus_jiNbEIJlP_4aDpDgAjxfcguJzmk68jZT"}',1793623486211); +CREATE TABLE recovery_requests(request_id TEXT PRIMARY KEY, household_id TEXT NOT NULL REFERENCES households(household_id), target_ha_user_id TEXT NOT NULL, revision INTEGER NOT NULL CHECK(revision>=1), state TEXT NOT NULL CHECK(state IN ('pending','approved','consumed','canceled')), created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL, detail TEXT NOT NULL); +CREATE TABLE recurrence_frontiers(household_id TEXT PRIMARY KEY NOT NULL REFERENCES households(household_id), after_schedule_id TEXT, revision INTEGER NOT NULL CHECK(typeof(revision)='integer' AND revision>=1), selected_at INTEGER NOT NULL CHECK(typeof(selected_at)='integer' AND selected_at>=0), document TEXT NOT NULL); +CREATE TABLE recurrence_progress(schedule_id TEXT PRIMARY KEY NOT NULL, household_id TEXT NOT NULL, pattern_revision INTEGER NOT NULL CHECK(typeof(pattern_revision)='integer' AND pattern_revision>=1), revision INTEGER NOT NULL CHECK(typeof(revision)='integer' AND revision>=1), timing_kind TEXT NOT NULL CHECK(timing_kind IN ('fixed_calendar','completion_relative')), document TEXT NOT NULL, UNIQUE(household_id,schedule_id,pattern_revision,timing_kind), FOREIGN KEY(household_id,schedule_id,pattern_revision,timing_kind) REFERENCES recurrence_rules(household_id,schedule_id,pattern_revision,timing_kind) DEFERRABLE INITIALLY DEFERRED); +INSERT INTO "recurrence_progress" VALUES('domus_jiNbEIJlP_4aDpDgAjxfcguJzmk68jZT','home',1,4,'fixed_calendar','{"cursor":{"binding":"6bd1edfcf2c489f27216f5c1f782f53a052d26823e3127112d9c9e11f71bbcf6","closed":true,"nominal_count":2,"offset":0,"period_index":2},"evidence":{"command":{"action":"recurrence.scan","actor_id":"system:recurrence","household_id":"home","key":"b39da39e67b2c7e78bad962211e6d456627551c045c62b2b9526bde8f7be9096"},"operation_id":"domus_xd29HsAZyEBH1sJ6zfezNio_AkBDTJG4","time":{"cursor":{"binding":"6bd1edfcf2c489f27216f5c1f782f53a052d26823e3127112d9c9e11f71bbcf6","closed":true,"nominal_count":2,"offset":0,"period_index":2},"last":{"nominal":{"kind":"time","local":"2026-03-30T02:30:00"},"resolved":{"nominal":{"kind":"time","local":"2026-03-30T02:30:00"},"offset_seconds":7200,"policy":{"data_identity":"tzdata:2026.6/iana:2026f","repeated_time":"first_instant","revision":1,"zone":"Europe/Madrid"},"resolution":"exact","resolved_local":"2026-03-30T02:30:00","zone_digest":"ca5b321ddbfc88e07e0d03ed2fa0c832ce5d0dd8e7d90a25200a8e24898c3b21"}},"policy":{"data_identity":"tzdata:2026.6/iana:2026f","repeated_time":"first_instant","revision":1,"zone":"Europe/Madrid"},"range_complete":true,"terminal":{"nominal":{"kind":"time","local":"2026-03-31T02:30:00"},"resolved":{"nominal":{"kind":"time","local":"2026-03-31T02:30:00"},"offset_seconds":7200,"policy":{"data_identity":"tzdata:2026.6/iana:2026f","repeated_time":"first_instant","revision":1,"zone":"Europe/Madrid"},"resolution":"exact","resolved_local":"2026-03-31T02:30:00","zone_digest":"ca5b321ddbfc88e07e0d03ed2fa0c832ce5d0dd8e7d90a25200a8e24898c3b21"}},"version":1},"version":1},"household_id":"home","last_run_at":1793623486211,"pattern_revision":1,"range_complete":true,"revision":4,"schedule_id":"domus_jiNbEIJlP_4aDpDgAjxfcguJzmk68jZT","version":1}'); +INSERT INTO "recurrence_progress" VALUES('domus_ASCascPHHAcqRNkq5zykTrKYoR0sNoX0','home',1,1,'completion_relative','{"binding":"f8ae91b605cf82811a79ab16dbe24e7a9ac2a886433de6441f7a431b22b9e347","current_occurrence_id":null,"current_slot_key":null,"evidence":null,"household_id":"home","last_run_at":null,"pattern_revision":1,"revision":1,"schedule_id":"domus_ASCascPHHAcqRNkq5zykTrKYoR0sNoX0","source_after":0,"version":1}'); +CREATE TABLE recurrence_progress_evidence(household_id TEXT NOT NULL, schedule_id TEXT NOT NULL, progress_revision INTEGER NOT NULL CHECK(typeof(progress_revision)='integer' AND progress_revision>=1), operation_id TEXT NOT NULL, evidence_fingerprint TEXT NOT NULL, PRIMARY KEY(household_id,schedule_id), FOREIGN KEY(household_id,schedule_id,progress_revision) REFERENCES recurrence_progress(household_id,schedule_id,revision) DEFERRABLE INITIALLY DEFERRED); +INSERT INTO "recurrence_progress_evidence" VALUES('home','domus_jiNbEIJlP_4aDpDgAjxfcguJzmk68jZT',4,'domus_xd29HsAZyEBH1sJ6zfezNio_AkBDTJG4','5947afb2f2cf0bd060932921e0e028dde4431d104493c1302cca6e006b8d9b8b'); +CREATE TABLE recurrence_relative_slots(household_id TEXT NOT NULL, slot_key TEXT NOT NULL, schedule_id TEXT NOT NULL, definition_id TEXT NOT NULL, pattern_revision INTEGER NOT NULL CHECK(typeof(pattern_revision)='integer' AND pattern_revision>=1), occurrence_id TEXT NOT NULL, source_occurrence_id TEXT, source_cycle_id TEXT, created_at INTEGER NOT NULL CHECK(typeof(created_at)='integer' AND created_at>=0), receipt_actor TEXT NOT NULL CHECK(receipt_actor='system:recurrence'), receipt_action TEXT NOT NULL CHECK(receipt_action='recurrence.materialize'), timing_kind TEXT NOT NULL CHECK(timing_kind='completion_relative'), document TEXT NOT NULL, PRIMARY KEY(household_id,slot_key), UNIQUE(household_id,occurrence_id), CHECK((source_occurrence_id IS NULL)=(source_cycle_id IS NULL)), FOREIGN KEY(household_id,schedule_id,definition_id,timing_kind) REFERENCES recurrence_rules(household_id,schedule_id,definition_id,timing_kind), FOREIGN KEY(household_id,definition_id,occurrence_id) REFERENCES task_occurrences(household_id,definition_id,occurrence_id), FOREIGN KEY(household_id,source_occurrence_id,source_cycle_id) REFERENCES task_cycles(household_id,occurrence_id,cycle_id), FOREIGN KEY(household_id,receipt_actor,receipt_action,slot_key) REFERENCES receipts(household_id,actor_id,action,command_key) DEFERRABLE INITIALLY DEFERRED); +CREATE TABLE recurrence_rules(schedule_id TEXT PRIMARY KEY NOT NULL, household_id TEXT NOT NULL, definition_id TEXT NOT NULL, revision INTEGER NOT NULL CHECK(typeof(revision)='integer' AND revision>=1), pattern_revision INTEGER NOT NULL CHECK(typeof(pattern_revision)='integer' AND pattern_revision>=1 AND pattern_revision<=revision), state TEXT NOT NULL CHECK(state IN ('active','paused','retired')), timing_kind TEXT NOT NULL CHECK(timing_kind IN ('fixed_calendar','completion_relative')), document TEXT NOT NULL, UNIQUE(household_id,schedule_id), UNIQUE(household_id,schedule_id,definition_id), UNIQUE(household_id,schedule_id,pattern_revision,timing_kind), UNIQUE(household_id,schedule_id,definition_id,timing_kind), FOREIGN KEY(household_id,definition_id) REFERENCES task_definitions(household_id,definition_id), FOREIGN KEY(household_id,schedule_id,pattern_revision,timing_kind) REFERENCES recurrence_progress(household_id,schedule_id,pattern_revision,timing_kind) DEFERRABLE INITIALLY DEFERRED); +INSERT INTO "recurrence_rules" VALUES('domus_jiNbEIJlP_4aDpDgAjxfcguJzmk68jZT','home','domus_fLEQZD1_-buQxRs5bUs1Bde0Up1PvcH0',1,1,'active','fixed_calendar','{"created_at":1793623486193,"definition_id":"domus_fLEQZD1_-buQxRs5bUs1Bde0Up1PvcH0","household_id":"home","pattern":{"by_day":[],"by_month":[],"by_month_day":[],"by_set_position":[],"count":null,"frequency":"DAILY","interval":1,"start":{"kind":"time","local":"2026-03-29T02:30:00"},"until":1774830600000,"week_start":"MO"},"pattern_revision":1,"policy":{"data_identity":"tzdata:2026.6/iana:2026f","repeated_time":"first_instant","revision":1,"zone":"Europe/Madrid"},"revision":1,"schedule_id":"domus_jiNbEIJlP_4aDpDgAjxfcguJzmk68jZT","state":"active","version":1}'); +INSERT INTO "recurrence_rules" VALUES('domus_ASCascPHHAcqRNkq5zykTrKYoR0sNoX0','home','domus_IVvhBs8AleFaXy34rnVB5CuACr7ZRuX4',1,1,'active','completion_relative','{"created_at":1793623486196,"definition_id":"domus_IVvhBs8AleFaXy34rnVB5CuACr7ZRuX4","household_id":"home","initial":{"nominal":{"kind":"time","local":"2026-03-29T02:30:00"},"resolved":{"nominal":{"kind":"time","local":"2026-03-29T02:30:00"},"offset_seconds":7200,"policy":{"data_identity":"tzdata:2026.6/iana:2026f","repeated_time":"first_instant","revision":1,"zone":"Europe/Madrid"},"resolution":"gap_first_valid","resolved_local":"2026-03-29T03:00:00","zone_digest":"ca5b321ddbfc88e07e0d03ed2fa0c832ce5d0dd8e7d90a25200a8e24898c3b21"}},"interval_days":30,"pattern_revision":1,"policy":{"data_identity":"tzdata:2026.6/iana:2026f","repeated_time":"first_instant","revision":1,"zone":"Europe/Madrid"},"revision":1,"schedule_id":"domus_ASCascPHHAcqRNkq5zykTrKYoR0sNoX0","state":"active","version":1,"visibility":"at_completion"}'); +CREATE TABLE recurrence_slots(household_id TEXT NOT NULL, slot_key TEXT NOT NULL, schedule_id TEXT NOT NULL, definition_id TEXT NOT NULL, pattern_revision INTEGER NOT NULL CHECK(typeof(pattern_revision)='integer' AND pattern_revision>=1), occurrence_id TEXT NOT NULL, nominal_kind TEXT NOT NULL CHECK(nominal_kind IN ('date','time')), nominal_value TEXT NOT NULL, resolved_at INTEGER, created_at INTEGER NOT NULL CHECK(typeof(created_at)='integer' AND created_at>=0), receipt_actor TEXT NOT NULL CHECK(receipt_actor='system:recurrence'), receipt_action TEXT NOT NULL CHECK(receipt_action='recurrence.materialize'), timing_kind TEXT NOT NULL CHECK(timing_kind='fixed_calendar'), document TEXT NOT NULL, PRIMARY KEY(household_id,slot_key), UNIQUE(household_id,occurrence_id), CHECK((nominal_kind='date' AND resolved_at IS NULL) OR (nominal_kind='time' AND typeof(resolved_at)='integer' AND resolved_at>=0)), FOREIGN KEY(household_id,schedule_id,definition_id,timing_kind) REFERENCES recurrence_rules(household_id,schedule_id,definition_id,timing_kind), FOREIGN KEY(household_id,definition_id,occurrence_id) REFERENCES task_occurrences(household_id,definition_id,occurrence_id), FOREIGN KEY(household_id,receipt_actor,receipt_action,slot_key) REFERENCES receipts(household_id,actor_id,action,command_key) DEFERRABLE INITIALLY DEFERRED); +INSERT INTO "recurrence_slots" VALUES('home','122a37056186ea89c9d49277730496b71caf2d84be5232e2dbbfd9fa0e05be50','domus_jiNbEIJlP_4aDpDgAjxfcguJzmk68jZT','domus_fLEQZD1_-buQxRs5bUs1Bde0Up1PvcH0',1,'domus_xw39jnz0MIZ2YHRkWLsT23oetQM-LzuJ','time','2026-03-29T02:30:00',1774746000000,1793623486200,'system:recurrence','recurrence.materialize','fixed_calendar','{"created_at":1793623486200,"installation":{"channel":"edge","instance_id":"00000000-0000-0000-0000-000000000007"},"occurrence_id":"domus_xw39jnz0MIZ2YHRkWLsT23oetQM-LzuJ","planned":{"nominal":{"kind":"time","local":"2026-03-29T02:30:00"},"resolved":{"nominal":{"kind":"time","local":"2026-03-29T02:30:00"},"offset_seconds":7200,"policy":{"data_identity":"tzdata:2026.6/iana:2026f","repeated_time":"first_instant","revision":1,"zone":"Europe/Madrid"},"resolution":"gap_first_valid","resolved_local":"2026-03-29T03:00:00","zone_digest":"ca5b321ddbfc88e07e0d03ed2fa0c832ce5d0dd8e7d90a25200a8e24898c3b21"}},"schedule":{"created_at":1793623486193,"definition_id":"domus_fLEQZD1_-buQxRs5bUs1Bde0Up1PvcH0","household_id":"home","pattern":{"by_day":[],"by_month":[],"by_month_day":[],"by_set_position":[],"count":null,"frequency":"DAILY","interval":1,"start":{"kind":"time","local":"2026-03-29T02:30:00"},"until":1774830600000,"week_start":"MO"},"pattern_revision":1,"policy":{"data_identity":"tzdata:2026.6/iana:2026f","repeated_time":"first_instant","revision":1,"zone":"Europe/Madrid"},"revision":1,"schedule_id":"domus_jiNbEIJlP_4aDpDgAjxfcguJzmk68jZT","state":"active","version":1},"version":1}'); +INSERT INTO "recurrence_slots" VALUES('home','642769c4438c89b84354ca24046b9800e6338a088397f26d3dd7fa4c3f028107','domus_jiNbEIJlP_4aDpDgAjxfcguJzmk68jZT','domus_fLEQZD1_-buQxRs5bUs1Bde0Up1PvcH0',1,'domus_FlZOFEushbnM2s_iGq7S8vNBadPgpb6j','time','2026-03-30T02:30:00',1774830600000,1793623486205,'system:recurrence','recurrence.materialize','fixed_calendar','{"created_at":1793623486205,"installation":{"channel":"edge","instance_id":"00000000-0000-0000-0000-000000000007"},"occurrence_id":"domus_FlZOFEushbnM2s_iGq7S8vNBadPgpb6j","planned":{"nominal":{"kind":"time","local":"2026-03-30T02:30:00"},"resolved":{"nominal":{"kind":"time","local":"2026-03-30T02:30:00"},"offset_seconds":7200,"policy":{"data_identity":"tzdata:2026.6/iana:2026f","repeated_time":"first_instant","revision":1,"zone":"Europe/Madrid"},"resolution":"exact","resolved_local":"2026-03-30T02:30:00","zone_digest":"ca5b321ddbfc88e07e0d03ed2fa0c832ce5d0dd8e7d90a25200a8e24898c3b21"}},"schedule":{"created_at":1793623486193,"definition_id":"domus_fLEQZD1_-buQxRs5bUs1Bde0Up1PvcH0","household_id":"home","pattern":{"by_day":[],"by_month":[],"by_month_day":[],"by_set_position":[],"count":null,"frequency":"DAILY","interval":1,"start":{"kind":"time","local":"2026-03-29T02:30:00"},"until":1774830600000,"week_start":"MO"},"pattern_revision":1,"policy":{"data_identity":"tzdata:2026.6/iana:2026f","repeated_time":"first_instant","revision":1,"zone":"Europe/Madrid"},"revision":1,"schedule_id":"domus_jiNbEIJlP_4aDpDgAjxfcguJzmk68jZT","state":"active","version":1},"version":1}'); +CREATE TABLE retained_operations(household_id TEXT NOT NULL, actor_id TEXT NOT NULL, action TEXT NOT NULL, command_key TEXT NOT NULL, operation_id TEXT NOT NULL UNIQUE, position INTEGER NOT NULL UNIQUE CHECK(position>=1), current INTEGER NOT NULL CHECK(current IN (0,1)), archived_by TEXT, document TEXT NOT NULL, closed_recovery_id TEXT, PRIMARY KEY(household_id,actor_id,action,command_key)); +CREATE TABLE task_cycles(cycle_id TEXT PRIMARY KEY NOT NULL, household_id TEXT NOT NULL REFERENCES households(household_id), occurrence_id TEXT NOT NULL, active_occurrence_id TEXT, recorded_by TEXT NOT NULL, verified_by TEXT, revision INTEGER NOT NULL CHECK(typeof(revision)='integer' AND revision>=1), closed_at INTEGER CHECK(closed_at IS NULL OR (typeof(closed_at)='integer' AND closed_at>=0)), document TEXT NOT NULL, UNIQUE(household_id,active_occurrence_id,cycle_id,revision), CHECK((closed_at IS NULL AND active_occurrence_id IS occurrence_id) OR (closed_at IS NOT NULL AND active_occurrence_id IS NULL)), FOREIGN KEY(household_id,occurrence_id) REFERENCES task_occurrences(household_id,occurrence_id) DEFERRABLE INITIALLY DEFERRED, FOREIGN KEY(household_id,recorded_by) REFERENCES members(household_id,member_id), FOREIGN KEY(household_id,verified_by) REFERENCES members(household_id,member_id), FOREIGN KEY(household_id,active_occurrence_id,cycle_id,revision) REFERENCES task_occurrences(household_id,occurrence_id,current_cycle_id,current_cycle_revision) DEFERRABLE INITIALLY DEFERRED); +CREATE TABLE task_definitions(definition_id TEXT PRIMARY KEY NOT NULL, household_id TEXT NOT NULL REFERENCES task_policy(household_id), revision INTEGER NOT NULL CHECK(typeof(revision)='integer' AND revision>=1), state TEXT NOT NULL CHECK(state IN ('draft','active','paused','archived')), document TEXT NOT NULL, UNIQUE(household_id,definition_id)); +INSERT INTO "task_definitions" VALUES('domus_fLEQZD1_-buQxRs5bUs1Bde0Up1PvcH0','home',1,'active','{"content":{"description":"","priority":"normal","tags":[],"title":"Actual fixed source input"},"created_at":1793623486193,"definition_id":"domus_fLEQZD1_-buQxRs5bUs1Bde0Up1PvcH0","household_id":"home","materials":[],"overrides":{"adult_performer_may_approve":null,"claim_allowed":null,"dependency_gate":null,"eligible_roles":null,"material_recording":null,"point_value_minor":null,"task_scoped_material_consumption":null,"verification":null},"revision":1,"state":"active","version":2}'); +INSERT INTO "task_definitions" VALUES('domus_IVvhBs8AleFaXy34rnVB5CuACr7ZRuX4','home',1,'active','{"content":{"description":"","priority":"normal","tags":[],"title":"Actual pending relative input"},"created_at":1793623486196,"definition_id":"domus_IVvhBs8AleFaXy34rnVB5CuACr7ZRuX4","household_id":"home","materials":[],"overrides":{"adult_performer_may_approve":null,"claim_allowed":null,"dependency_gate":null,"eligible_roles":null,"material_recording":null,"point_value_minor":null,"task_scoped_material_consumption":null,"verification":null},"revision":1,"state":"active","version":2}'); +CREATE TABLE task_material_intents(intent_id TEXT PRIMARY KEY NOT NULL, household_id TEXT NOT NULL, occurrence_id TEXT NOT NULL, cycle_id TEXT NOT NULL, line_id TEXT NOT NULL, recorded_by TEXT NOT NULL, revision INTEGER NOT NULL CHECK(typeof(revision)='integer' AND revision>=1), state TEXT NOT NULL CHECK(state IN ('pending_recording','pending_authorization','pending_regularization','pending_processing','not_used','posted','corrected','dismissed')), document TEXT NOT NULL, UNIQUE(household_id,cycle_id,line_id), FOREIGN KEY(household_id,occurrence_id,cycle_id) REFERENCES task_cycles(household_id,occurrence_id,cycle_id), FOREIGN KEY(household_id,recorded_by) REFERENCES members(household_id,member_id)); +CREATE TABLE task_occurrences(occurrence_id TEXT PRIMARY KEY NOT NULL, household_id TEXT NOT NULL REFERENCES households(household_id), definition_id TEXT NOT NULL, revision INTEGER NOT NULL CHECK(typeof(revision)='integer' AND revision>=1), state TEXT NOT NULL CHECK(state IN ('draft','available','assigned','in_progress','blocked','completed','cancelled','skipped')), current_cycle_id TEXT, current_cycle_revision INTEGER CHECK(current_cycle_revision IS NULL OR (typeof(current_cycle_revision)='integer' AND current_cycle_revision>=1)), document TEXT NOT NULL, UNIQUE(household_id,occurrence_id), UNIQUE(household_id,occurrence_id,current_cycle_id,current_cycle_revision), CHECK((state='completed' AND current_cycle_id IS NOT NULL AND current_cycle_revision IS NOT NULL) OR (state!='completed' AND current_cycle_id IS NULL AND current_cycle_revision IS NULL)), FOREIGN KEY(household_id,definition_id) REFERENCES task_definitions(household_id,definition_id), FOREIGN KEY(household_id,occurrence_id,current_cycle_id,current_cycle_revision) REFERENCES task_cycles(household_id,active_occurrence_id,cycle_id,revision) DEFERRABLE INITIALLY DEFERRED); +INSERT INTO "task_occurrences" VALUES('domus_xw39jnz0MIZ2YHRkWLsT23oetQM-LzuJ','home','domus_fLEQZD1_-buQxRs5bUs1Bde0Up1PvcH0',1,'available',NULL,NULL,'{"assignee_ids":[],"created_at":1793623486200,"current_cycle_id":null,"current_cycle_revision":null,"definition_snapshot":{"content":{"description":"","priority":"normal","tags":[],"title":"Actual fixed source input"},"created_at":1793623486193,"definition_id":"domus_fLEQZD1_-buQxRs5bUs1Bde0Up1PvcH0","household_id":"home","materials":[],"overrides":{"adult_performer_may_approve":null,"claim_allowed":null,"dependency_gate":null,"eligible_roles":null,"material_recording":null,"point_value_minor":null,"task_scoped_material_consumption":null,"verification":null},"revision":1,"state":"active","version":2},"due_at":1774746000000,"household_id":"home","occurrence_id":"domus_xw39jnz0MIZ2YHRkWLsT23oetQM-LzuJ","prerequisite_ids":[],"required_subtask_ids":[],"revision":1,"state":"available","state_reason":"","version":2}'); +INSERT INTO "task_occurrences" VALUES('domus_FlZOFEushbnM2s_iGq7S8vNBadPgpb6j','home','domus_fLEQZD1_-buQxRs5bUs1Bde0Up1PvcH0',1,'available',NULL,NULL,'{"assignee_ids":[],"created_at":1793623486205,"current_cycle_id":null,"current_cycle_revision":null,"definition_snapshot":{"content":{"description":"","priority":"normal","tags":[],"title":"Actual fixed source input"},"created_at":1793623486193,"definition_id":"domus_fLEQZD1_-buQxRs5bUs1Bde0Up1PvcH0","household_id":"home","materials":[],"overrides":{"adult_performer_may_approve":null,"claim_allowed":null,"dependency_gate":null,"eligible_roles":null,"material_recording":null,"point_value_minor":null,"task_scoped_material_consumption":null,"verification":null},"revision":1,"state":"active","version":2},"due_at":1774830600000,"household_id":"home","occurrence_id":"domus_FlZOFEushbnM2s_iGq7S8vNBadPgpb6j","prerequisite_ids":[],"required_subtask_ids":[],"revision":1,"state":"available","state_reason":"","version":2}'); +CREATE TABLE task_policy(household_id TEXT PRIMARY KEY NOT NULL REFERENCES households(household_id), revision INTEGER NOT NULL CHECK(typeof(revision)='integer' AND revision>=1), document TEXT NOT NULL); +INSERT INTO "task_policy" VALUES('home',1,'{"household_id":"home","revision":1,"rules":{"adult_performer_may_approve":true,"claim_allowed":true,"dependency_gate":"performed","eligible_roles":["administrator","adult","restricted"],"material_recording":"review_on_completion","point_value_minor":0,"task_scoped_material_consumption":true,"verification":"role_default"},"version":1}'); +CREATE INDEX outbox_pending ON outbox(delivered,position); +CREATE INDEX recovery_pending ON recovery_requests(household_id,state,expires_at); +CREATE INDEX retained_recovery ON retained_operations(household_id,closed_recovery_id) WHERE current=0 AND closed_recovery_id IS NOT NULL; +CREATE UNIQUE INDEX task_member_scope ON members(household_id,member_id); +CREATE INDEX task_definitions_page ON task_definitions(household_id,state,definition_id); +CREATE INDEX task_occurrences_page ON task_occurrences(household_id,state,occurrence_id); +CREATE INDEX task_occurrences_definition ON task_occurrences(household_id,definition_id,occurrence_id); +CREATE INDEX task_cycles_page ON task_cycles(household_id,occurrence_id,cycle_id); +CREATE UNIQUE INDEX task_one_open_cycle ON task_cycles(household_id,occurrence_id) WHERE closed_at IS NULL; +CREATE UNIQUE INDEX task_cycle_material_scope ON task_cycles(household_id,occurrence_id,cycle_id); +CREATE INDEX task_material_cycle_page ON task_material_intents(household_id,cycle_id,intent_id); +CREATE INDEX task_cycle_event_page ON outbox(json_extract(payload,'$.cycle.cycle_id'),position); +CREATE INDEX task_occurrence_event_page ON outbox(json_extract(payload,'$.occurrence_id'),position); +CREATE INDEX task_dependency_ack_page ON outbox(json_extract(payload,'$.review.cause.dependent_cycle_id'),json_extract(payload,'$.review.cause.source_operation_id'),position); +CREATE UNIQUE INDEX recurrence_occurrence_scope ON task_occurrences(household_id,definition_id,occurrence_id); +CREATE UNIQUE INDEX recurrence_cycle_scope ON task_cycles(household_id,occurrence_id,cycle_id); +CREATE UNIQUE INDEX recurrence_progress_revision ON recurrence_progress(household_id,schedule_id,revision); +CREATE UNIQUE INDEX recurrence_current_definition ON recurrence_rules(household_id,definition_id) WHERE state!='retired'; +CREATE INDEX recurrence_rules_page ON recurrence_rules(household_id,state,schedule_id); +CREATE INDEX recurrence_slots_page ON recurrence_slots(household_id,schedule_id,slot_key); +CREATE INDEX recurrence_relative_slots_page ON recurrence_relative_slots(household_id,schedule_id,slot_key); +DELETE FROM "sqlite_sequence"; +INSERT INTO "sqlite_sequence" VALUES('outbox',6); +COMMIT;